diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 038485bda79a1..d7b3effeb5126 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3990,6 +3990,20 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onToolPartialResult(e => { this._logService.trace(`[Copilot:${sessionId}] Tool partial result: ${e.data.toolCallId} (${e.data.partialOutput.length} chars)`); + const tracked = this._activeToolCalls.get(e.data.toolCallId); + if (!tracked || !isShellTool(tracked.toolName)) { + return; + } + // TODO: Use terminal-specific AHP content once live shell output is modeled separately from terminalComplete.preview. + this._emitAction({ + type: ActionType.ChatToolCallContentChanged, + turnId: this._turnId, + toolCallId: e.data.toolCallId, + content: [ + ...tracked.content.filter(content => content.type !== ToolResultContentType.Text), + { type: ToolResultContentType.Text, text: e.data.partialOutput }, + ], + }, tracked.parentToolCallId); })); this._register(wrapper.onToolProgress(e => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 2e2e222eb7bbd..ce918cbcac958 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -2867,6 +2867,82 @@ suite('CopilotAgentSession', () => { assert.strictEqual((toolStart.action as ChatToolCallStartAction).intention, 'List files in the repo root'); }); + test('tool partial results stream cumulative output as running content', async () => { + const { session, mockSession, signals, waitForSignal } = await createAgentSession(disposables); + session.resetTurnState('turn-stream'); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-stream', + toolName: 'bash', + arguments: { command: 'print ticks', description: 'Print ticks' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-stream', + partialOutput: 'tick 1\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-stream', + partialOutput: 'tick 1\ntick 2\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-stream', + success: true, + result: { content: 'tick 1\ntick 2\n' }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete)); + + assert.deepStrictEqual(getActions(signals) + .filter(action => action.type === ActionType.ChatToolCallContentChanged) + .map(action => ({ + turnId: action.turnId, + toolCallId: action.toolCallId, + content: action.content, + })), [ + { + turnId: 'turn-stream', + toolCallId: 'tc-stream', + content: [{ type: ToolResultContentType.Text, text: 'tick 1\n' }], + }, + { + turnId: 'turn-stream', + toolCallId: 'tc-stream', + content: [{ type: ToolResultContentType.Text, text: 'tick 1\ntick 2\n' }], + }, + ]); + const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; + assert.deepStrictEqual(completed.result.content, [ + { type: ToolResultContentType.Text, text: 'tick 1\ntick 2\n' }, + ]); + }); + + test('tool partial results for untracked tools are ignored', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-untracked', + partialOutput: 'orphaned output', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + + assert.deepStrictEqual(getActions(signals), []); + }); + + test('tool partial results for tracked non-shell tools are ignored', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-non-shell', + toolName: 'grep', + arguments: { pattern: 'needle' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-non-shell', + partialOutput: 'unexpected partial output', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + + assert.deepStrictEqual(getActions(signals) + .filter(action => action.type === ActionType.ChatToolCallContentChanged), []); + }); + test('live tool_start strips redundant cd prefix matching workingDirectory', async () => { const wd = URI.file('/repo/project'); const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd });