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>
This commit is contained in:
Rob Lourens
2026-04-28 16:11:55 -07:00
committed by GitHub
co-authored by Copilot
parent 5bd6db722a
commit 55ae04b9ce
2 changed files with 65 additions and 2 deletions
@@ -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
@@ -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,