Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/concepts/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,13 @@ between chat messages. They follow the same snapshot/delta pattern as the state
system so that UIs can render a complete activity view immediately and then
incrementally update it as new information arrives.

An activity message occupies the same id space as every other message, so its
`messageId` must not be reused by a text message, and vice versa. The two carry
different shapes of `content` — a structured object for activity, a string for
text — so a shared id has no coherent meaning. A client that receives a text
message under an id an activity message already holds should leave the activity
message untouched rather than overwrite it.

### ActivitySnapshot

Delivers a complete snapshot of an activity message.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -630,3 +630,57 @@ describe("MESSAGES_SNAPSHOT with snapshot-supplied activity", () => {
expect(activity.content?.tasks).toEqual(["local"]);
});
});

describe("TEXT_MESSAGE_* against an activity message's id", () => {
// Message ids are unique across a conversation, so a text message arriving under an id an
// activity message already holds means the producer reused the id. Streaming the text in
// would overwrite the activity's structured content with a string, leaving it no longer a
// valid ActivityMessage. The text handlers warn and leave the activity message alone,
// the same way ACTIVITY_DELTA does when it lands on a non-activity message.

const activityId = "shared-id";
const streamTextInto = (id: string) => (events$: Subject<BaseEvent>) => {
events$.next({
type: EventType.ACTIVITY_SNAPSHOT,
messageId: activityId,
activityType: "PLAN",
content: { tasks: ["plan"] },
});
events$.next({ type: EventType.TEXT_MESSAGE_START, messageId: id, role: "assistant" });
events$.next({ type: EventType.TEXT_MESSAGE_CONTENT, messageId: id, delta: "Hello" });
events$.next({ type: EventType.TEXT_MESSAGE_END, messageId: id });
};

it("leaves the activity message intact instead of streaming text into it", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const updates = await emitAndCollect([], streamTextInto(activityId));
warnSpy.mockRestore();

const messages = updates[updates.length - 1]!.messages!;
expect(messages.length).toBe(1);
const activity = messages[0] as { role: string; content: { tasks?: string[] } };
expect(activity.role).toBe("activity");
expect(activity.content).toEqual({ tasks: ["plan"] });
});

it("warns on the id collision from each text handler", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await emitAndCollect([], streamTextInto(activityId));
const warnings = warnSpy.mock.calls.map((call) => String(call[0]));
warnSpy.mockRestore();

expect(warnings.some((w) => w.startsWith("TEXT_MESSAGE_START:"))).toBe(true);
expect(warnings.some((w) => w.startsWith("TEXT_MESSAGE_CONTENT:"))).toBe(true);
expect(warnings.some((w) => w.startsWith("TEXT_MESSAGE_END:"))).toBe(true);
});

it("still streams text normally when the id does not collide", async () => {
const updates = await emitAndCollect([], streamTextInto("free-id"));

const messages = updates[updates.length - 1]!.messages!;
expect(messages.map((m) => m.id)).toEqual([activityId, "free-id"]);
const text = messages.find((m) => m.id === "free-id")!;
expect(text.role).toBe("assistant");
expect(text.content).toBe("Hello");
});
});
28 changes: 27 additions & 1 deletion sdks/typescript/packages/client/src/apply/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,15 @@ export const defaultApplyEvents = (
// with the same parentMessageId)
const existingMessage = messages.find((m) => m.id === messageId);

if (!existingMessage) {
if (existingMessage?.role === "activity") {
// Message ids are unique across the conversation, so an activity message under
// this id means the producer reused it. Streaming text into it would overwrite
// its structured content with a string. Leave it alone and drop the text.
console.warn(
`TEXT_MESSAGE_START: Message '${messageId}' is an activity message — ` +
`message ids must be unique across activity and text messages`,
);
} else if (!existingMessage) {
// Create a new message using properties from the event
// Text messages can be developer, system, assistant, or user (not tool)
const newMessage: Message = {
Expand Down Expand Up @@ -203,6 +211,15 @@ export const defaultApplyEvents = (
console.warn(`TEXT_MESSAGE_CONTENT: No message found with ID '${messageId}'`);
return emitUpdates();
}
if (targetMessage.role === "activity") {
// Appending here would replace the activity message's structured content with a
// string, leaving it no longer a valid ActivityMessage.
console.warn(
`TEXT_MESSAGE_CONTENT: Message '${messageId}' is an activity message — ` +
`message ids must be unique across activity and text messages`,
);
return emitUpdates();
}

const mutation = await runSubscribersWithMutation(
subscribers,
Expand Down Expand Up @@ -241,6 +258,15 @@ export const defaultApplyEvents = (
console.warn(`TEXT_MESSAGE_END: No message found with ID '${messageId}'`);
return emitUpdates();
}
if (targetMessage.role === "activity") {
// The matching TEXT_MESSAGE_START was dropped for the same reason, so there is no
// text message to finish — don't announce the activity message as a new one.
console.warn(
`TEXT_MESSAGE_END: Message '${messageId}' is an activity message — ` +
`message ids must be unique across activity and text messages`,
);
return emitUpdates();
}

const mutation = await runSubscribersWithMutation(
subscribers,
Expand Down