From 55ae04b9cedf898ec7d47ca7ae05c7ec01dca74a Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Tue, 28 Apr 2026 16:11:55 -0700 Subject: [PATCH] Fix reasoning bunching on agent host session restore (live path) (#313129) The live event mapper cleared _currentMarkdownPartId on tool_start but not _currentReasoningPartId. Within a single chat turn, the Copilot SDK emits multiple rounds of (reasoning -> message -> tool calls); every later round's reasoning was appended onto the very first reasoning ResponsePart, which sits before any ToolCall. On restore from in-process state, all reasoning rendered bunched at the top. Symmetric to the history-replay fix in #312559, which only covered the cold-restore path through _buildTurnsFromMessages. Tests added in agentEventMapper.test.ts covering both tool_start and tool_complete as the trigger for a new reasoning round. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentEventMapper.ts | 10 +++- .../test/node/agentEventMapper.test.ts | 57 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts index 7a48b4f4be6..057cac4e586 100644 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -86,9 +86,15 @@ export class AgentEventMapper { } case 'tool_start': { - // A new tool call invalidates the current markdown part so the - // next text delta creates a fresh part after the tool call. + // A new tool call invalidates the current markdown and reasoning + // parts so the next text/reasoning delta creates fresh parts + // after the tool call. The Copilot SDK emits multiple rounds + // of (reasoning → message → tool calls) within a single chat + // turn; without this, every later round's reasoning would be + // appended onto the very first reasoning part and bunch at + // the top of the response on restore. this._currentMarkdownPartId.delete(session); + this._currentReasoningPartId.delete(session); // The Copilot SDK provides full parameters at tool_start time. // We emit both toolCallStart (streaming → created) and toolCallReady diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts index 9572493fa3e..e86ceef4c5b 100644 --- a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -238,6 +238,63 @@ suite('AgentEventMapper', () => { assert.strictEqual(reasoning.partId, partId); }); + test('reasoning event after tool_start creates a fresh responsePart', () => { + // The Copilot SDK emits multiple rounds of (reasoning → message → + // tool calls) within a single chat turn. Each new reasoning batch + // after a tool call must produce a fresh Reasoning ResponsePart so + // it renders interleaved with the tool calls in the response. + // Otherwise the SessionReasoning reducer appends every later round + // onto the very first part, causing all reasoning to bunch at the + // top of the response when the session is later restored from state. + const first: IAgentReasoningEvent = { session, type: 'reasoning', content: 'Round 1 thoughts' }; + mapper.mapProgressEventToActions(first, session.toString(), turnId); + + const toolStart: IAgentToolStartEvent = { + session, type: 'tool_start', + toolCallId: 'tc-1', toolName: 'bash', displayName: 'Bash', + invocationMessage: 'Running', toolInput: 'ls', + }; + mapper.mapProgressEventToActions(toolStart, session.toString(), turnId); + + const second: IAgentReasoningEvent = { session, type: 'reasoning', content: 'Round 2 thoughts' }; + const actions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId)); + assert.strictEqual(actions.length, 1); + assert.strictEqual(actions[0].type, 'session/responsePart'); + const part = (actions[0] as IResponsePartAction).part; + assert.strictEqual(part.kind, 'reasoning'); + assert.strictEqual(part.content, 'Round 2 thoughts'); + }); + + test('reasoning event after tool_complete creates a fresh responsePart', () => { + // Symmetric to the tool_start case: after a tool call finishes, + // the next reasoning batch belongs to a new round and must not + // be appended onto the previous round's reasoning part. + const first: IAgentReasoningEvent = { session, type: 'reasoning', content: 'Round 1 thoughts' }; + mapper.mapProgressEventToActions(first, session.toString(), turnId); + + const toolStart: IAgentToolStartEvent = { + session, type: 'tool_start', + toolCallId: 'tc-1', toolName: 'bash', displayName: 'Bash', + invocationMessage: 'Running', toolInput: 'ls', + }; + mapper.mapProgressEventToActions(toolStart, session.toString(), turnId); + + const toolComplete: IAgentToolCompleteEvent = { + session, type: 'tool_complete', + toolCallId: 'tc-1', + result: { success: true, content: [{ type: ToolResultContentType.Text, text: 'ok' }], pastTenseMessage: 'Ran' }, + }; + mapper.mapProgressEventToActions(toolComplete, session.toString(), turnId); + + const second: IAgentReasoningEvent = { session, type: 'reasoning', content: 'Round 2 thoughts' }; + const actions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId)); + assert.strictEqual(actions.length, 1); + assert.strictEqual(actions[0].type, 'session/responsePart'); + const part = (actions[0] as IResponsePartAction).part; + assert.strictEqual(part.kind, 'reasoning'); + assert.strictEqual(part.content, 'Round 2 thoughts'); + }); + test('message event with no prior deltas creates responsePart', () => { const event: IAgentMessageEvent = { session,