diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index b2fbee8e2c..86e22b2afe 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -997,7 +997,9 @@ class AcpRuntime { ): string { if (messageId) { this.fallbackChunkIds.delete(kind); - return messageId; + // Native IDs identify a message within its chunk kind. Timeline IDs must + // also distinguish reasoning from replies and from generated fallback IDs. + return JSON.stringify([kind, messageId]); } let id = this.fallbackChunkIds.get(kind); if (!id) { diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index f4b429578f..1d99c73a75 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -1307,7 +1307,15 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => { const items = events.flatMap((event) => event.type === "timeline.item" && (event.item.type === "assistant_message" || event.item.type === "reasoning") - ? [{ id: event.item.id, text: event.item.text }] + ? [ + { + id: event.item.id, + text: event.item.text, + ...(event.item.type === "assistant_message" && event.item.messageId + ? { messageId: event.item.messageId } + : {}), + }, + ] : [], ); expect(items).toEqual([ @@ -1315,7 +1323,11 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => { { id: "agent_message_chunk:1", text: "- **Current temperature**: 25°C" }, { id: "agent_message_chunk:2", text: "next turn" }, { id: "agent_thought_chunk:3", text: "think A" }, - { id: "m-explicit", text: "say" }, + { + id: JSON.stringify(["agent_message_chunk", "m-explicit"]), + messageId: "m-explicit", + text: "say", + }, { id: "agent_thought_chunk:4", text: "think B" }, ]); await connection.close(); @@ -1402,6 +1414,21 @@ async function streamChunkTurns( } describe("ACP streamed message boundaries", () => { + it("keeps reasoning separate from a reply sharing its native message ID", async () => { + expect( + await streamChunkTurns([ + textChunk("private ", "agent_thought_chunk", "shared-id"), + textChunk("reasoning", "agent_thought_chunk", "shared-id"), + textChunk("public ", "agent_message_chunk", "shared-id"), + textChunk("reply", "agent_message_chunk", "shared-id"), + ]), + ).toEqual([ + { type: "reasoning", text: "private reasoning" }, + { type: "assistant_message", text: "public reply" }, + { type: "assistant_message", text: "Next turn" }, + ]); + }); + it.each([ { name: "tool call", @@ -1584,8 +1611,12 @@ describe("ACP session history replay", () => { ); try { expect(timelineProjection(events)).toEqual([ - { type: "user_message", id: "user-1", text: "What is the " }, - { type: "user_message", id: "user-1", text: "What is the temperature?" }, + { type: "user_message", id: '["user_message_chunk","user-1"]', text: "What is the " }, + { + type: "user_message", + id: '["user_message_chunk","user-1"]', + text: "What is the temperature?", + }, { type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" }, { type: "tool_call", id: "tool-1", status: "running" }, { type: "tool_call", id: "tool-1", status: "completed" }, @@ -1599,6 +1630,48 @@ describe("ACP session history replay", () => { } }); + it("keeps replayed user, reasoning and reply chunks separate when their native IDs match", async () => { + const { connection, events } = await openReplaySession( + [ + userChunk("What is the ", "shared-id"), + userChunk("temperature?", "shared-id"), + textChunk("Checking ", "agent_thought_chunk", "shared-id"), + textChunk("the sensor", "agent_thought_chunk", "shared-id"), + textChunk("It is ", "agent_message_chunk", "shared-id"), + textChunk("25°C", "agent_message_chunk", "shared-id"), + ], + "replay", + ); + try { + const items = new Map( + events.flatMap((event) => + event.type === "timeline.item" ? [[event.item.id, event.item] as const] : [], + ), + ); + expect([...items.values()]).toEqual([ + { + type: "user_message", + id: '["user_message_chunk","shared-id"]', + messageId: "shared-id", + text: "What is the temperature?", + }, + { + type: "reasoning", + id: '["agent_thought_chunk","shared-id"]', + text: "Checking the sensor", + }, + { + type: "assistant_message", + id: '["agent_message_chunk","shared-id"]', + messageId: "shared-id", + text: "It is 25°C", + }, + ]); + } finally { + await connection.close(); + } + }); + it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => { const { connection, events } = await openReplaySession( [