From fb5883e299b9cdc9a317c30c0e41007a82c17790 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 16 Jul 2026 23:04:06 -0700 Subject: [PATCH 1/2] Stream output for SDK built-in shell tool --- .../node/copilot/copilotAgentSession.ts | 16 ++++ .../test/node/copilotAgentSession.test.ts | 76 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 038485bda79a1..bc7bb313f9a52 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3990,6 +3990,22 @@ 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: Replace this text snapshot bridge with an output-only AHP terminal once AHP defines + // that capability and SDK shell events expose a live shell identity. Then attach terminal + // content to the tool call and stream its output through the terminal channel. + 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 }); From 27ce7bf4578d65d7cf6a3796c8cef054f7f5d221 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 16 Jul 2026 23:30:53 -0700 Subject: [PATCH 2/2] better todo comment --- src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index bc7bb313f9a52..d7b3effeb5126 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3994,9 +3994,7 @@ export class CopilotAgentSession extends Disposable { if (!tracked || !isShellTool(tracked.toolName)) { return; } - // TODO: Replace this text snapshot bridge with an output-only AHP terminal once AHP defines - // that capability and SDK shell events expose a live shell identity. Then attach terminal - // content to the tool call and stream its output through the terminal channel. + // TODO: Use terminal-specific AHP content once live shell output is modeled separately from terminalComplete.preview. this._emitAction({ type: ActionType.ChatToolCallContentChanged, turnId: this._turnId,