From fb5883e299b9cdc9a317c30c0e41007a82c17790 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 16 Jul 2026 23:04:06 -0700 Subject: [PATCH 01/16] 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 038485bda79a1e..bc7bb313f9a528 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 2e2e222eb7bbde..ce918cbcac9580 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 02/16] 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 bc7bb313f9a528..d7b3effeb5126a 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, From c9aed0aa99c97b839825c4ab852c691f12ea2722 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 13:31:37 -0700 Subject: [PATCH 03/16] Bump Copilot SDK to 1.0.8 preview Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5083fb1f-34d7-44bf-82a1-c4582e57683b --- package-lock.json | 278 +++++++++++++++++- package.json | 3 +- remote/package-lock.json | 278 +++++++++++++++++- remote/package.json | 3 +- .../node/copilot/copilotAgentSession.ts | 6 +- .../node/copilot/copilotSystemNotification.ts | 4 +- 6 files changed, 555 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index faa6f4f1e7fb2a..3bb1482ba8514f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@github/copilot": "^1.0.72", - "@github/copilot-sdk": "^1.0.7-preview.0", + "@github/copilot-sdk": "^1.0.8-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -1243,12 +1243,13 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.7-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.7-preview.0.tgz", - "integrity": "sha512-qffczR5m1IbywMjh3EA9Qaz5GiSALMbI4v88cC4umymkeToTpYkNd/Sz4YcQqEVUYUuvtWJVhXUue/mWBNnJqw==", + "version": "1.0.8-preview.0", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.8-preview.0.tgz", + "integrity": "sha512-qX/8ibp8VItOVLiBNI8iSMbZQd9m8SxL9+iYjLZb6w7dATNDE6MppiKiKTjepKeLu5GywGOlTCC6f13m4itGRw==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.70-0", + "@github/copilot": "^1.0.72", + "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -1779,6 +1780,246 @@ } } }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.2.tgz", + "integrity": "sha512-32pU4pNZABIz+l9DNJl51Y+jur4vv+SF4Ip2CSF4OUg1xUyefoLpX0NttDmzGITIrneUEVSEN+dT22524ESKBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.2.tgz", + "integrity": "sha512-S+H6LQgUoMj77BqDegwlRaxwLXDfwvSJGuceOqtH0I5V8rzKLmu/hC7NBlxOoAlvKlcV63FtdNiE2E9YSltffg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.2.tgz", + "integrity": "sha512-fD0ow2PBE60nw7K6xcbala6qwXxfcYeU62tduNeIPvx0KoWhU2rMKZiDNe+iI5TQb3rxYYjjP+aF2Sdm9y6EXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.2.tgz", + "integrity": "sha512-t8OmL+hoJGDLZDnuLjgLemSYrXX99M7Md+zJX8bMHOtiNbFtkGXn/mV21Pb1ik9JhBXjwK1r4hvBPNlqTMGrHg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.2.tgz", + "integrity": "sha512-axbLgiM4Y2vyDOTqlXCI8vkg9wqjwSRsmoWXSKreA5YFJwnYA6Sc4aHMz+qZgUSfFei52Qrv1RGhDyo4kHvqhA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.2.tgz", + "integrity": "sha512-f0hqAIlFcL9wlRGJ/uCfyfspqnGaASk2gLx1UAP3RBgMQl68D1e+fiHNdXa7g9d76ttmpA8/PGNAqc1X4Byy1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.2.tgz", + "integrity": "sha512-UGLPuqeOV/UArsK6oeB5yI/XjSWkFqFlBTC9rUbezBuHJhSibk1EMv7QC0cvtDMu18bo+ucqXWPzh42oT5yYlw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.2.tgz", + "integrity": "sha512-jI0+gM2oDsJ7reOt3XPyO7lyQtZ1CT6NR2uqGQcQVM43cyXBAVYYCUxEH3LHCbgumFaZ+LueIUgbMSwb9pHBxQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.2.tgz", + "integrity": "sha512-yB99adXBRd5T+xXG+f6nnUkC3jCI0iXvPU6RqD9Kx7aZP4Y4NNUWJ5Q4FaP9jb1XmZLY4pGBUiHt8u03Yl7NyA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.2.tgz", + "integrity": "sha512-Oxvo6F3Edzy/Jm2EtbHWkJ2xRB0mXDAe63k5+USL5uiGE5xZjwEUDOBKIhv2BpCZSOAJrfoojFFogj6+ICKQhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.2.tgz", + "integrity": "sha512-SSWzUhL8Ex84JTsO67+MdWZrdwgOzoOrQ0+ZbB+UsivHoAxmWLHKWZaSafNqyBZtxGY1EgtR8AIPouWE9U+Zfw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.2.tgz", + "integrity": "sha512-0ZuI4St7chq3M0d3VivvKIqacZ7RhgohdR476V3HpJkaNdfIywsJIw+GBvqkQahu+4A2Rpu6yQJpWSrfk/Z+Jw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.2.tgz", + "integrity": "sha512-8Wn6phw7y53uI52+aBPAqEfZ5pj/HCjg/YtdthqSWYHy+d0MhyASKlcmuP0B5raxQnnA1Bm9LC8UO3M3RojeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.2.tgz", + "integrity": "sha512-FkKaPBMawgHMNnp1FwLldXMNvEa139GXkxPi9JD9xU71Kh/ZmuEYHGSD6JwZDmDr4jekVrBrr+eGZ+j6C2mkXg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.2.tgz", + "integrity": "sha512-FeFC59UU1XX4J3ZaqKrsrEzczzB5qksMJo7/R45vIg8mGNVSLMVE85JRiZpjcp9i5Lbav5Vw47QvwFzBgIfvlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", @@ -13477,6 +13718,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/koffi": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.2.tgz", + "integrity": "sha512-wVwuE21TBl8/si6E0hPorKR2PJ2q33mEWVETANrtSp3kFM8fi2FcD/J5wmxu0T4TBcqmMQ4xKuF1X1ayFmphzw==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.2", + "@koromix/koffi-darwin-x64": "3.1.2", + "@koromix/koffi-freebsd-arm64": "3.1.2", + "@koromix/koffi-freebsd-ia32": "3.1.2", + "@koromix/koffi-freebsd-x64": "3.1.2", + "@koromix/koffi-linux-arm64": "3.1.2", + "@koromix/koffi-linux-ia32": "3.1.2", + "@koromix/koffi-linux-loong64": "3.1.2", + "@koromix/koffi-linux-riscv64": "3.1.2", + "@koromix/koffi-linux-x64": "3.1.2", + "@koromix/koffi-openbsd-ia32": "3.1.2", + "@koromix/koffi-openbsd-x64": "3.1.2", + "@koromix/koffi-win32-arm64": "3.1.2", + "@koromix/koffi-win32-ia32": "3.1.2", + "@koromix/koffi-win32-x64": "3.1.2" + } + }, "node_modules/last-run": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/last-run/-/last-run-1.1.1.tgz", diff --git a/package.json b/package.json index dbb5da5bc8e03c..1ceb0ebeeee35b 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@github/copilot": "^1.0.72", - "@github/copilot-sdk": "^1.0.7-preview.0", + "@github/copilot-sdk": "^1.0.8-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -295,6 +295,7 @@ "cpu-features": false, "foundry-local-sdk@1.2.3": true, "kerberos@2.1.1": true, + "koffi@3.1.2": true, "native-keymap@3.3.9": true, "protobufjs@7.6.5": false, "native-is-elevated@0.9.0": true, diff --git a/remote/package-lock.json b/remote/package-lock.json index 2873f0d7bdf79f..c7a1134c52899b 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@github/copilot": "^1.0.72", - "@github/copilot-sdk": "^1.0.7-preview.0", + "@github/copilot-sdk": "^1.0.8-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.1", @@ -191,12 +191,13 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.7-preview.0", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.7-preview.0.tgz", - "integrity": "sha512-qffczR5m1IbywMjh3EA9Qaz5GiSALMbI4v88cC4umymkeToTpYkNd/Sz4YcQqEVUYUuvtWJVhXUue/mWBNnJqw==", + "version": "1.0.8-preview.0", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.8-preview.0.tgz", + "integrity": "sha512-qX/8ibp8VItOVLiBNI8iSMbZQd9m8SxL9+iYjLZb6w7dATNDE6MppiKiKTjepKeLu5GywGOlTCC6f13m4itGRw==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.70-0", + "@github/copilot": "^1.0.72", + "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -257,6 +258,246 @@ "node": ">=18.0.0" } }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.2.tgz", + "integrity": "sha512-32pU4pNZABIz+l9DNJl51Y+jur4vv+SF4Ip2CSF4OUg1xUyefoLpX0NttDmzGITIrneUEVSEN+dT22524ESKBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.2.tgz", + "integrity": "sha512-S+H6LQgUoMj77BqDegwlRaxwLXDfwvSJGuceOqtH0I5V8rzKLmu/hC7NBlxOoAlvKlcV63FtdNiE2E9YSltffg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.2.tgz", + "integrity": "sha512-fD0ow2PBE60nw7K6xcbala6qwXxfcYeU62tduNeIPvx0KoWhU2rMKZiDNe+iI5TQb3rxYYjjP+aF2Sdm9y6EXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.2.tgz", + "integrity": "sha512-t8OmL+hoJGDLZDnuLjgLemSYrXX99M7Md+zJX8bMHOtiNbFtkGXn/mV21Pb1ik9JhBXjwK1r4hvBPNlqTMGrHg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.2.tgz", + "integrity": "sha512-axbLgiM4Y2vyDOTqlXCI8vkg9wqjwSRsmoWXSKreA5YFJwnYA6Sc4aHMz+qZgUSfFei52Qrv1RGhDyo4kHvqhA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.2.tgz", + "integrity": "sha512-f0hqAIlFcL9wlRGJ/uCfyfspqnGaASk2gLx1UAP3RBgMQl68D1e+fiHNdXa7g9d76ttmpA8/PGNAqc1X4Byy1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.2.tgz", + "integrity": "sha512-UGLPuqeOV/UArsK6oeB5yI/XjSWkFqFlBTC9rUbezBuHJhSibk1EMv7QC0cvtDMu18bo+ucqXWPzh42oT5yYlw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.2.tgz", + "integrity": "sha512-jI0+gM2oDsJ7reOt3XPyO7lyQtZ1CT6NR2uqGQcQVM43cyXBAVYYCUxEH3LHCbgumFaZ+LueIUgbMSwb9pHBxQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.2.tgz", + "integrity": "sha512-yB99adXBRd5T+xXG+f6nnUkC3jCI0iXvPU6RqD9Kx7aZP4Y4NNUWJ5Q4FaP9jb1XmZLY4pGBUiHt8u03Yl7NyA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.2.tgz", + "integrity": "sha512-Oxvo6F3Edzy/Jm2EtbHWkJ2xRB0mXDAe63k5+USL5uiGE5xZjwEUDOBKIhv2BpCZSOAJrfoojFFogj6+ICKQhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.2.tgz", + "integrity": "sha512-SSWzUhL8Ex84JTsO67+MdWZrdwgOzoOrQ0+ZbB+UsivHoAxmWLHKWZaSafNqyBZtxGY1EgtR8AIPouWE9U+Zfw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.2.tgz", + "integrity": "sha512-0ZuI4St7chq3M0d3VivvKIqacZ7RhgohdR476V3HpJkaNdfIywsJIw+GBvqkQahu+4A2Rpu6yQJpWSrfk/Z+Jw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.2.tgz", + "integrity": "sha512-8Wn6phw7y53uI52+aBPAqEfZ5pj/HCjg/YtdthqSWYHy+d0MhyASKlcmuP0B5raxQnnA1Bm9LC8UO3M3RojeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.2.tgz", + "integrity": "sha512-FkKaPBMawgHMNnp1FwLldXMNvEa139GXkxPi9JD9xU71Kh/ZmuEYHGSD6JwZDmDr4jekVrBrr+eGZ+j6C2mkXg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.2.tgz", + "integrity": "sha512-FeFC59UU1XX4J3ZaqKrsrEzczzB5qksMJo7/R45vIg8mGNVSLMVE85JRiZpjcp9i5Lbav5Vw47QvwFzBgIfvlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@microsoft/1ds-core-js": { "version": "3.2.13", "resolved": "https://registry.npmjs.org/@microsoft/1ds-core-js/-/1ds-core-js-3.2.13.tgz", @@ -1313,6 +1554,33 @@ "node": "^16 || ^18 || >= 20" } }, + "node_modules/koffi": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.2.tgz", + "integrity": "sha512-wVwuE21TBl8/si6E0hPorKR2PJ2q33mEWVETANrtSp3kFM8fi2FcD/J5wmxu0T4TBcqmMQ4xKuF1X1ayFmphzw==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.2", + "@koromix/koffi-darwin-x64": "3.1.2", + "@koromix/koffi-freebsd-arm64": "3.1.2", + "@koromix/koffi-freebsd-ia32": "3.1.2", + "@koromix/koffi-freebsd-x64": "3.1.2", + "@koromix/koffi-linux-arm64": "3.1.2", + "@koromix/koffi-linux-ia32": "3.1.2", + "@koromix/koffi-linux-loong64": "3.1.2", + "@koromix/koffi-linux-riscv64": "3.1.2", + "@koromix/koffi-linux-x64": "3.1.2", + "@koromix/koffi-openbsd-ia32": "3.1.2", + "@koromix/koffi-openbsd-x64": "3.1.2", + "@koromix/koffi-win32-arm64": "3.1.2", + "@koromix/koffi-win32-ia32": "3.1.2", + "@koromix/koffi-win32-x64": "3.1.2" + } + }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", diff --git a/remote/package.json b/remote/package.json index d995cf434fbf0e..c1344e3243c84c 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@github/copilot": "^1.0.72", - "@github/copilot-sdk": "^1.0.7-preview.0", + "@github/copilot-sdk": "^1.0.8-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.1", @@ -63,6 +63,7 @@ }, "allowScripts": { "kerberos@2.1.1": true, + "koffi@3.1.2": true, "node-pty@1.2.0-beta.13": true, "@parcel/watcher@2.5.6": true, "@vscode/deviceid@0.1.5": true, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index cd7416f7692e11..85a5596c11ef9a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotSession, ExitPlanModeRequest, MessageOptions, PermissionAllowAllMode, PermissionAutoApproval, PermissionRequestResult, SessionConfig, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; +import type { CopilotSession, ExitPlanModeRequest, McpServersLoadedServer, MessageOptions, PermissionAllowAllMode, PermissionAutoApproval, PermissionRequestResult, SessionConfig, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; import { DeferredPromise, Sequencer } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter } from '../../../../base/common/event.js'; @@ -3596,7 +3596,7 @@ export class CopilotAgentSession extends Disposable { // Capture the base usage before the await boundary so concurrent // usage events don't overwrite what we merge into. const baseUsage = lastParentUsageTurnId === turnId ? lastParentUsage : undefined; - const usage = baseUsage ?? { + const usage: UsageInfo = baseUsage ?? { inputTokens: e.data.inputTokens, outputTokens: e.data.outputTokens, model: e.data.model, @@ -3679,7 +3679,7 @@ export class CopilotAgentSession extends Disposable { // change is also logged (with structured metadata) so it flows to the // agent host's OTLP log stream and the per-server Output channels. this._register(wrapper.onMcpServersLoaded(e => { - this._logMcpServersSnapshot(e.data.servers.map(s => ({ + this._logMcpServersSnapshot(e.data.servers.map((s: McpServersLoadedServer) => ({ name: s.name, status: s.status, error: s.error, diff --git a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts index e807ba9275c9b9..6cb9dfc6d0aaba 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { SessionEventPayload } from '@github/copilot-sdk'; +import type { SessionEventPayload, SystemNotification } from '@github/copilot-sdk'; import { softAssertNever } from '../../../../base/common/assert.js'; import { localize } from '../../../../nls.js'; @@ -16,7 +16,7 @@ export interface ICopilotSystemNotification { export function buildCopilotSystemNotification(event: SessionEventPayload<'system.notification'>): ICopilotSystemNotification | undefined { const data = event.data; - const kind = data.kind; + const kind: SystemNotification = data.kind; const content = cleanSystemNotificationContent(data.content); if (!content) { return undefined; From b3225ed4e1514c34d53a5bf8da619b19f343ce9d Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 14:38:34 -0700 Subject: [PATCH 04/16] Exclude unused Koffi native modules Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5083fb1f-34d7-44bf-82a1-c4582e57683b --- build/.moduleignore | 5 +++++ package.json | 2 +- remote/package.json | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/build/.moduleignore b/build/.moduleignore index ffe0ce2fac4524..ed8de1365032ef 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -267,6 +267,11 @@ zone.js/dist/** @github/copilot-sdk/node_modules/@github/copilot-win32-arm64/** @github/copilot-sdk/node_modules/@github/copilot-win32-x64/** +# @github/copilot-sdk uses Koffi only for its in-process FFI transport. +# VS Code uses RuntimeConnection.forStdio. +koffi/** +@koromix/koffi-*/** + # es5-ext is quarantined protestware (sonatype-2022-2248). It is only consumed # in websocket's browser.js inside a try/catch with a globalThis fallback, # so it is unnecessary in the browsers/runtimes VS Code supports. diff --git a/package.json b/package.json index 1ceb0ebeeee35b..b590ad881c962c 100644 --- a/package.json +++ b/package.json @@ -295,7 +295,7 @@ "cpu-features": false, "foundry-local-sdk@1.2.3": true, "kerberos@2.1.1": true, - "koffi@3.1.2": true, + "koffi@3.1.2": false, "native-keymap@3.3.9": true, "protobufjs@7.6.5": false, "native-is-elevated@0.9.0": true, diff --git a/remote/package.json b/remote/package.json index c1344e3243c84c..a99ba78c139992 100644 --- a/remote/package.json +++ b/remote/package.json @@ -63,7 +63,7 @@ }, "allowScripts": { "kerberos@2.1.1": true, - "koffi@3.1.2": true, + "koffi@3.1.2": false, "node-pty@1.2.0-beta.13": true, "@parcel/watcher@2.5.6": true, "@vscode/deviceid@0.1.5": true, From 49b20d3191c2b462a6ba807853800851d8848af2 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 15:52:04 -0700 Subject: [PATCH 05/16] Adopt AHP terminal completion changes and stream built-in shell output over the terminal channel - Re-vendor agent-host-protocol: terminalComplete removed, exitCode/preview/truncated fold into terminal.result (TerminalCommandResult), isPty added to ToolResultTerminalContent and TerminalState - shell_exit now lands on the tool call's terminal block; history replay synthesizes a non-pty block keyed by toolCallId - Stream tool.execution_partial_result into an output-only agenthost-terminal channel (isPty false) instead of replacing Text content - Normalize LF to CRLF in AgentHostPty when the channel is plain text - Re-apply local CompletionItem.label extension clobbered by the protocol sync (still needs upstreaming) --- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/channels-chat/state.ts | 48 ++++---- .../protocol/channels-session/commands.ts | 22 ++-- .../state/protocol/channels-terminal/state.ts | 6 + .../agentHost/common/state/sessionState.ts | 2 +- .../node/agentHostTerminalManager.ts | 107 +++++++++++++++++- .../node/copilot/copilotAgentSession.ts | 65 +++++++---- .../copilot/copilotNonPtyShellTerminals.ts | 103 +++++++++++++++++ .../node/copilot/mapSessionEvents.ts | 40 +++++-- .../node/agentHostTerminalManager.test.ts | 73 ++++++++++++ .../agentHost/test/node/copilotAgent.test.ts | 4 + .../test/node/copilotAgentSession.test.ts | 54 +++++++-- .../test/node/copilotShellTools.test.ts | 4 + .../test/node/mapSessionEvents.test.ts | 47 +++++++- .../test/node/testAgentHostTerminalManager.ts | 8 ++ .../agentHost/stateToProgressAdapter.ts | 20 ++-- .../stateToProgressAdapter.test.ts | 40 ++++++- .../contrib/terminal/browser/agentHostPty.ts | 19 +++- 18 files changed, 558 insertions(+), 106 deletions(-) create mode 100644 src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 4076cdf14429b4..186ce539743b8e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -ea279d99 +8365a59a diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index d39a4a88c3d982..96594718600bac 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -1344,7 +1344,6 @@ export const enum ToolResultContentType { Resource = 'resource', FileEdit = 'fileEdit', Terminal = 'terminal', - TerminalComplete = 'terminalComplete', Subagent = 'subagent', } @@ -1402,6 +1401,11 @@ export interface ToolResultFileEditContent extends FileEdit { * Clients can subscribe to the terminal's URI to stream its output in real * time, providing live feedback while a tool is executing. * + * When the command exits, {@link result} is filled in on the completed + * result, retaining the outcome for clients that did not subscribe. This + * records the command's exit, not the terminal's — the terminal may keep + * running afterwards. + * * @category Tool Result Content */ export interface ToolResultTerminalContent { @@ -1410,34 +1414,31 @@ export interface ToolResultTerminalContent { resource: URI; /** Display title for the terminal content */ title: string; + /** + * Whether this terminal-style resource is backed by a pseudoterminal. + * When `false`, output is plain text and clients do not need to parse + * VT sequences. + */ + isPty?: boolean; + /** Outcome of the command, present once it has exited. */ + result?: TerminalCommandResult; } /** - * Record of a command executed by a terminal-style tool (e.g. a shell tool), - * appended to the tool result when the command exits. - * - * This records the command's exit, not the terminal's — the terminal may - * keep running afterwards. - * - * When live output was exposed through a terminal channel (a - * {@link ToolResultTerminalContent} block in the same tool result), - * {@link resource} identifies that channel; otherwise this block stands alone - * as the retained command result. + * Outcome of a command run in a terminal-style tool, filled in on + * {@link ToolResultTerminalContent.result} once the command exits. * * @category Tool Result Content */ -export interface ToolResultTerminalCompleteContent { - type: ToolResultContentType.TerminalComplete; - /** - * URI of the `ahp-terminal:` channel that carried live output for this - * command, if one was exposed. - */ - resource?: URI; +export interface TerminalCommandResult { /** Exit code from the completed command, if reported by the runtime */ exitCode?: number; - /** Working directory where the command was executed */ - cwd?: URI; - /** Preview of the command's output, if available */ + /** + * Preview of the command's output, for clients that are not subscribed + * to the terminal or that arrive after it is disposed. When `isPty` is + * `true` the preview may contain VT sequences; when `false` it is plain + * text. + */ preview?: string; /** Whether `preview` is known to be incomplete or truncated */ truncated?: boolean; @@ -1471,8 +1472,8 @@ export interface ToolResultSubagentContent { * Mirrors the content blocks in MCP `CallToolResult.content`, plus * `ToolResultResourceContent` for lazy-loading large results, * `ToolResultFileEditContent` for file edit diffs, - * `ToolResultTerminalContent` for live terminal output, - * `ToolResultTerminalCompleteContent` for terminal-style completion metadata, and + * `ToolResultTerminalContent` for live terminal output and + * command completion metadata, and * `ToolResultSubagentContent` for tool-spawned worker chats (AHP extensions). * * @category Tool Result Content @@ -1483,5 +1484,4 @@ export type ToolResultContent = | ToolResultResourceContent | ToolResultFileEditContent | ToolResultTerminalContent - | ToolResultTerminalCompleteContent | ToolResultSubagentContent; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index ff7d4a69595f02..ad1c4f4482bc0c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -83,13 +83,21 @@ export interface CreateSessionParams extends BaseParams { */ workingDirectories?: URI[]; /** - * The primary working directory for the session's **default chat** — the - * distinguished root that chat is centered on (see - * {@link ChatState.primaryWorkingDirectory}). A session has no primary of its - * own; this seeds the default chat's primary. When set, it MUST be one of - * {@link workingDirectories}. A client SHOULD supply this when the agent - * advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a - * host MAY reject creation that omits it, or fall back to the first entry of + * The primary working directory for the session's **default chat**. + * + * A session has no primary of its own — primary is a per-chat notion (see + * {@link ChatState.primaryWorkingDirectory}). But `createSession` implicitly + * creates the session's default chat, and there is no separate `createChat` + * call to carry that chat's create-time fields. This field is therefore the + * only place a client can designate the **default chat's** primary at birth; + * it is copied into that chat's read-only `primaryWorkingDirectory`. For any + * non-default chat, pass {@link CreateChatParams.primaryWorkingDirectory} + * instead. + * + * When set, it MUST be one of {@link workingDirectories}. A client SHOULD + * supply this when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + * reject creation that omits it, or fall back to the first entry of * `workingDirectories`. Ignored for forked sessions (a fork inherits the * source session's chats and their primaries). */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts index d5eb4960a57db1..4c6f5afa74157d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts @@ -108,6 +108,12 @@ export interface TerminalState { * are absent in the normal idle state. */ supportsCommandDetection?: boolean; + /** + * Whether this terminal-style resource is backed by a pseudoterminal. + * When `false`, output is plain text and clients do not need to parse + * VT sequences. + */ + isPty?: boolean; } // ─── Terminal Content Parts ────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index a8033ad07f04f1..25a04d8ed36015 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -88,7 +88,7 @@ export { type ToolCallContributor, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, - type ToolResultTerminalCompleteContent, + type TerminalCommandResult, type ToolResultSubagentContent, type ToolResultTerminalContent, type ToolResultTextContent, diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index 02abd2613d09c8..4c39648b515cc1 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -125,6 +125,10 @@ export interface IAgentHostTerminalManager { getTerminalInfos(): TerminalInfo[]; getTerminalState(uri: string): TerminalState | undefined; getDefaultShell(): Promise; + createOutputTerminal(uri: string, options: { title: string; claim: TerminalClaim }): void; + appendOutputTerminalData(uri: string, data: string): void; + resetOutputTerminal(uri: string): void; + finalizeOutputTerminal(uri: string, exitCode: number | undefined): void; } // node-pty is loaded dynamically to avoid bundling issues in non-node environments @@ -169,6 +173,20 @@ interface IManagedTerminal { terminalQueryFilterState: ITerminalQueryFilterState; } +/** + * A lightweight output-only terminal channel: no PTY behind it, plain-text + * content appended by its owner (e.g. runtime-executed shell tools). Served + * to subscribers with `isPty: false` so clients skip VT parsing. + */ +interface IOutputTerminal { + readonly uri: string; + title: string; + content: TerminalContentPart[]; + contentSize: number; + claim: TerminalClaim; + exitCode?: number; +} + /** * Manages terminal processes for the agent host. Each terminal is backed by * a node-pty instance and identified by a protocol URI. @@ -181,6 +199,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe declare readonly _serviceBrand: undefined; private readonly _terminals = new Map(); + private readonly _outputTerminals = new Map(); constructor( @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @@ -229,6 +248,16 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Get the full state for a terminal (for subscribe snapshots). */ getTerminalState(uri: string): TerminalState | undefined { + const outputTerminal = this._outputTerminals.get(uri); + if (outputTerminal) { + return { + title: outputTerminal.title, + content: outputTerminal.content, + exitCode: outputTerminal.exitCode, + claim: outputTerminal.claim, + isPty: false, + }; + } const terminal = this._terminals.get(uri); if (!terminal) { return undefined; @@ -242,6 +271,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe exitCode: terminal.exitCode, claim: terminal.claim, supportsCommandDetection: terminal.commandTracker?.detectionAvailableEmitted, + isPty: true, }; } @@ -520,7 +550,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Get accumulated scrollback content for a terminal as raw text. */ getContent(uri: string): string | undefined { - const terminal = this._terminals.get(uri); + const terminal = this._terminals.get(uri) ?? this._outputTerminals.get(uri); if (!terminal) { return undefined; } @@ -529,12 +559,12 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Get the current claim for a terminal. */ getClaim(uri: string): TerminalClaim | undefined { - return this._terminals.get(uri)?.claim; + return (this._terminals.get(uri) ?? this._outputTerminals.get(uri))?.claim; } /** Check whether a terminal exists. */ hasTerminal(uri: string): boolean { - return this._terminals.has(uri); + return this._terminals.has(uri) || this._outputTerminals.has(uri); } /** Whether the terminal has shell integration active for command detection. */ @@ -545,7 +575,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Get the exit code for a terminal, or undefined if still running. */ getExitCode(uri: string): number | undefined { - return this._terminals.get(uri)?.exitCode; + return (this._terminals.get(uri) ?? this._outputTerminals.get(uri))?.exitCode; } /** Resize a terminal. */ @@ -743,7 +773,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } /** Append cleaned data to the terminal's structured content array. */ - private _appendToContent(managed: IManagedTerminal, data: string): void { + private _appendToContent(managed: { content: TerminalContentPart[]; contentSize: number }, data: string): void { const tail = managed.content.length > 0 ? managed.content[managed.content.length - 1] : undefined; if (tail?.type === 'command' && !tail.isComplete) { @@ -766,7 +796,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } /** Trim content parts to stay within the rolling buffer limit. */ - private _trimContent(managed: IManagedTerminal): void { + private _trimContent(managed: { content: TerminalContentPart[]; contentSize: number }): void { const maxSize = 100_000; const targetSize = 80_000; if (managed.contentSize <= maxSize) { @@ -790,8 +820,73 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } } + /** + * Create an output-only terminal channel. Unlike {@link createTerminal} + * there is no PTY behind it: the owner appends plain-text output via + * {@link appendOutputTerminalData}. The channel is not announced on the + * root terminal list — clients discover it through the tool result's + * terminal content block and subscribe to its URI. + */ + createOutputTerminal(uri: string, options: { title: string; claim: TerminalClaim }): void { + if (this._terminals.has(uri) || this._outputTerminals.has(uri)) { + throw new Error(`Terminal already exists: ${uri}`); + } + this._outputTerminals.set(uri, { + uri, + title: options.title, + content: [], + contentSize: 0, + claim: options.claim, + }); + } + + /** Append plain-text data to an output-only terminal and stream it to subscribers. */ + appendOutputTerminalData(uri: string, data: string): void { + const terminal = this._outputTerminals.get(uri); + if (!terminal || data.length === 0) { + return; + } + this._appendToContent(terminal, data); + this._trimContent(terminal); + this._stateManager.dispatchServerAction(uri, { + type: ActionType.TerminalData, + data, + }); + } + + /** Clear an output-only terminal's content (e.g. when cumulative source output was rewritten). */ + resetOutputTerminal(uri: string): void { + const terminal = this._outputTerminals.get(uri); + if (!terminal) { + return; + } + terminal.content = []; + terminal.contentSize = 0; + this._stateManager.dispatchServerAction(uri, { + type: ActionType.TerminalCleared, + }); + } + + /** Record the command's exit on an output-only terminal and notify subscribers. */ + finalizeOutputTerminal(uri: string, exitCode: number | undefined): void { + const terminal = this._outputTerminals.get(uri); + if (!terminal || terminal.exitCode !== undefined) { + return; + } + if (exitCode !== undefined) { + terminal.exitCode = exitCode; + this._stateManager.dispatchServerAction(uri, { + type: ActionType.TerminalExited, + exitCode, + }); + } + } + /** Dispose a terminal: kill the process and remove it. */ disposeTerminal(uri: string): void { + if (this._outputTerminals.delete(uri)) { + return; + } const terminal = this._terminals.get(uri); if (terminal) { this._terminals.delete(uri); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 4ba1cf9b65a6f2..63a592f42b3b4e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -54,6 +54,7 @@ import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; import { parseLeadingSlashCommand } from '../../common/agentHostSlashCommand.js'; import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; +import { NonPtyShellTerminalStreams } from './copilotNonPtyShellTerminals.js'; import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; @@ -658,6 +659,8 @@ export class CopilotAgentSession extends Disposable { private readonly _launchPlan: CopilotSessionLaunchPlan; private readonly _isLaunchTokenStillCurrent: () => boolean; private readonly _shellManager: ShellManager | undefined; + /** Streams runtime-executed shell output into output-only (non-pty) terminal channels. */ + private readonly _nonPtyShellTerminals: NonPtyShellTerminalStreams; private readonly _workingDirectory: URI | undefined; private readonly _customizationDirectory: URI | undefined; private readonly _serverToolHost: IAgentServerToolHost | undefined; @@ -735,6 +738,7 @@ export class CopilotAgentSession extends Disposable { this._launchPlan = options.launchPlan; this._isLaunchTokenStillCurrent = options.isLaunchTokenCurrent ?? (() => true); this._shellManager = options.shellManager; + this._nonPtyShellTerminals = this._register(this._instantiationService.createInstance(NonPtyShellTerminalStreams, options.sessionUri)); this._workingDirectory = options.workingDirectory; this._customizationDirectory = options.customizationDirectory; this._serverToolHost = options.serverToolHost; @@ -3275,7 +3279,24 @@ export class CopilotAgentSession extends Disposable { if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } - appendSdkToolResultContent(content, e.data.result?.contents); + + // Attach the pty terminal reference for shell tools before folding in + // SDK result content, so a `shell_exit` lands its completion data on + // the terminal block (skip if any terminal block was already added + // while the tool was running). + if (isShellTool(tracked.toolName) && this._shellManager) { + const terminalUri = this._shellManager.getTerminalUriForToolCall(e.data.toolCallId); + if (terminalUri && !content.some(c => c.type === ToolResultContentType.Terminal)) { + content.push({ + type: ToolResultContentType.Terminal, + resource: terminalUri, + title: tracked.displayName, + }); + } + } + + const shellExitCode = appendSdkToolResultContent(content, e.data.result?.contents, { toolCallId: e.data.toolCallId, title: tracked.displayName }); + this._nonPtyShellTerminals.finalize(e.data.toolCallId, shellExitCode); const command = isString(tracked.parameters?.command) ? tracked.parameters.command : undefined; const filePaths = isEditTool(tracked.toolName, command) ? this._getEditFilePaths(tracked.parameters) : []; @@ -3290,19 +3311,6 @@ export class CopilotAgentSession extends Disposable { } } - // Add terminal content reference for shell tools (skip if already - // added during onDidAssociateTerminal while the tool was running) - if (isShellTool(tracked.toolName) && this._shellManager) { - const terminalUri = this._shellManager.getTerminalUriForToolCall(e.data.toolCallId); - if (terminalUri && !content.some(c => c.type === ToolResultContentType.Terminal && c.resource === terminalUri)) { - content.push({ - type: ToolResultContentType.Terminal, - resource: terminalUri, - title: tracked.displayName, - }); - } - } - this._emitAction({ type: ActionType.ChatToolCallComplete, turnId: this._turnId, @@ -4257,16 +4265,25 @@ export class CopilotAgentSession extends Disposable { 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); + if (this._shellManager?.getTerminalUriForToolCall(e.data.toolCallId)) { + // Client-hosted pty shell — its terminal channel streams live output itself. + return; + } + const { uri, created } = this._nonPtyShellTerminals.append(e.data.toolCallId, e.data.partialOutput, tracked.displayName); + if (created) { + tracked.content.push({ + type: ToolResultContentType.Terminal, + resource: uri, + title: tracked.displayName, + isPty: false, + }); + this._emitAction({ + type: ActionType.ChatToolCallContentChanged, + turnId: this._turnId, + toolCallId: e.data.toolCallId, + content: tracked.content, + }, tracked.parentToolCallId); + } })); this._register(wrapper.onToolProgress(e => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts new file mode 100644 index 00000000000000..bc379210d99eee --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { TerminalClaimKind, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; +import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; + +/** + * Builds the terminal channel URI for a runtime-executed (non-pty) shell tool + * call. Keyed by tool call id so the URI is stable across live streaming and + * history replay of the same command. + */ +export function buildNonPtyShellTerminalUri(toolCallId: string): string { + return `agenthost-terminal://shell/copilotNonPtyShells/${toolCallId}`; +} + +interface INonPtyShellStream { + readonly uri: string; + emittedLength: number; + finalized: boolean; +} + +/** + * Streams output of SDK-runtime-executed shell tool calls into output-only + * AHP terminal channels. The runtime reports cumulative output via + * `tool.execution_partial_result`; this class emits only the unseen suffix as + * `terminal/data` so subscribed clients receive live plain-text output + * (`isPty: false` — no VT parsing needed). + * + * Created once per session and disposed with it, matching the pty-backed + * `ShellManager` lifecycle. + */ +export class NonPtyShellTerminalStreams extends Disposable { + + private readonly _streams = new Map(); + + constructor( + private readonly _sessionUri: URI, + @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, + ) { + super(); + + this._register(toDisposable(() => { + for (const stream of this._streams.values()) { + this._terminalManager.disposeTerminal(stream.uri); + } + this._streams.clear(); + })); + } + + /** + * Appends the unseen suffix of `cumulativeOutput` to the tool call's + * output terminal, creating the channel on first call. Returns the channel + * URI and whether this call created it (so the caller can attach the + * terminal content block exactly once). + */ + append(toolCallId: string, cumulativeOutput: string, title: string): { uri: string; created: boolean } { + let stream = this._streams.get(toolCallId); + let created = false; + if (!stream) { + const uri = buildNonPtyShellTerminalUri(toolCallId); + const claim: TerminalSessionClaim = { + kind: TerminalClaimKind.Session, + session: this._sessionUri.toString(), + toolCallId, + }; + this._terminalManager.createOutputTerminal(uri, { title, claim }); + stream = { uri, emittedLength: 0, finalized: false }; + this._streams.set(toolCallId, stream); + created = true; + } + if (stream.finalized) { + return { uri: stream.uri, created }; + } + if (cumulativeOutput.length < stream.emittedLength) { + // The runtime rewrote its cumulative output (defensive); start over. + this._terminalManager.resetOutputTerminal(stream.uri); + stream.emittedLength = 0; + } + const delta = cumulativeOutput.slice(stream.emittedLength); + if (delta.length > 0) { + this._terminalManager.appendOutputTerminalData(stream.uri, delta); + stream.emittedLength = cumulativeOutput.length; + } + return { uri: stream.uri, created }; + } + + /** + * Records the command's exit on the tool call's output terminal, if one + * was created. Later partial results for the tool call are ignored. + */ + finalize(toolCallId: string, exitCode: number | undefined): void { + const stream = this._streams.get(toolCallId); + if (!stream || stream.finalized) { + return; + } + stream.finalized = true; + this._terminalManager.finalizeOutputTerminal(stream.uri, exitCode); + } +} diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 568c92a500c485..48fd3d124af7bb 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -14,7 +14,8 @@ import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { toToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; -import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type AgentSelection, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type AgentSelection, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type TerminalCommandResult, type ToolCallCompletedState, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { buildNonPtyShellTerminalUri } from './copilotNonPtyShellTerminals.js'; import { getInvocationMessage, getPastTenseMessage, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; import { buildSessionDbUri } from '../shared/fileEditTracker.js'; import { getMediaMime } from '../../../../base/common/mime.js'; @@ -45,20 +46,43 @@ function isSyntheticUserMessage(event: SessionEvent): boolean { return !!source && source.toLowerCase() !== 'user'; } -export function appendSdkToolResultContent(content: ToolResultContent[], sdkContents: readonly ToolExecutionCompleteContent[] | undefined): void { +/** + * Converts SDK `tool.execution_complete` content blocks into AHP tool result + * content. A `shell_exit` block becomes {@link TerminalCommandResult} data on + * the tool call's terminal content block; when no terminal block exists yet + * (e.g. history replay, where no live channel survives) and `terminal` is + * provided, a non-pty terminal block is synthesized so the outcome still + * renders from `result.preview`. Returns the `shell_exit` exit code, if any. + */ +export function appendSdkToolResultContent(content: ToolResultContent[], sdkContents: readonly ToolExecutionCompleteContent[] | undefined, terminal?: { toolCallId: string; title: string }): number | undefined { + let shellExitCode: number | undefined; for (const sdkContent of sdkContents ?? []) { switch (sdkContent.type) { - case 'shell_exit': - content.push({ - type: ToolResultContentType.TerminalComplete, + case 'shell_exit': { + shellExitCode = sdkContent.exitCode; + const result: TerminalCommandResult = { exitCode: sdkContent.exitCode, - ...(sdkContent.cwd !== undefined ? { cwd: URI.file(sdkContent.cwd).toString() } : {}), ...(sdkContent.outputPreview !== undefined ? { preview: sdkContent.outputPreview } : {}), ...(sdkContent.outputTruncated !== undefined ? { truncated: sdkContent.outputTruncated } : {}), - }); + }; + const terminalIndex = content.findIndex(c => c.type === ToolResultContentType.Terminal); + if (terminalIndex !== -1) { + const terminalBlock = content[terminalIndex] as ToolResultTerminalContent; + content[terminalIndex] = { ...terminalBlock, result }; + } else if (terminal) { + content.push({ + type: ToolResultContentType.Terminal, + resource: buildNonPtyShellTerminalUri(terminal.toolCallId), + title: terminal.title, + isPty: false, + result, + }); + } break; + } } } + return shellExitCode; } // ============================================================================= @@ -719,7 +743,7 @@ function makeCompletedToolCallPart( if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } - appendSdkToolResultContent(content, d.result?.contents); + appendSdkToolResultContent(content, d.result?.contents, { toolCallId: d.toolCallId, title: info.displayName }); // Restore file edit content references from the database. const edits = storedEdits?.get(d.toolCallId); diff --git a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts index 9255f113c5e82c..871a65b3bf2d15 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts @@ -794,3 +794,76 @@ suite('AgentHostTerminalManager – command detection integration', () => { ]); }); }); + +suite('AgentHostTerminalManager – output-only terminals', () => { + + const disposables = new DisposableStore(); + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + function createManager() { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); + const productService = { _serviceBrand: undefined, applicationName: 'vscode' } as IProductService; + const manager = disposables.add(new AgentHostTerminalManager(stateManager, logService, productService, configurationService)); + return { manager, stateManager }; + } + + test('streams appended data, snapshots state with isPty false, and records the exit', () => { + const { manager, stateManager } = createManager(); + const uri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-1'; + const claim: TerminalClaim = { kind: TerminalClaimKind.Session, session: 'agent-session://copilot/s1', toolCallId: 'tc-1' }; + const dispatched: StateAction[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + if (envelope.channel === uri) { + dispatched.push(envelope.action); + } + })); + + manager.createOutputTerminal(uri, { title: 'Run Shell Command', claim }); + manager.appendOutputTerminalData(uri, 'tick 1\n'); + manager.appendOutputTerminalData(uri, 'tick 2\n'); + manager.finalizeOutputTerminal(uri, 0); + manager.finalizeOutputTerminal(uri, 1); // recorded exit is immutable + + assert.deepStrictEqual(manager.getTerminalState(uri), { + title: 'Run Shell Command', + content: [{ type: 'unclassified', value: 'tick 1\ntick 2\n' }], + exitCode: 0, + claim, + isPty: false, + }); + assert.deepStrictEqual(dispatched, [ + { type: ActionType.TerminalData, data: 'tick 1\n' }, + { type: ActionType.TerminalData, data: 'tick 2\n' }, + { type: ActionType.TerminalExited, exitCode: 0 }, + ]); + assert.strictEqual(manager.hasTerminal(uri), true); + // Output channels are discovered through tool result content, not the root terminal list. + assert.deepStrictEqual(manager.getTerminalInfos(), []); + }); + + test('reset clears content and dispose removes the channel', () => { + const { manager, stateManager } = createManager(); + const uri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-2'; + const dispatched: StateAction[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + if (envelope.channel === uri) { + dispatched.push(envelope.action); + } + })); + + manager.createOutputTerminal(uri, { title: 'Bash', claim: { kind: TerminalClaimKind.Session, session: 'agent-session://copilot/s1' } }); + manager.appendOutputTerminalData(uri, 'old output'); + manager.resetOutputTerminal(uri); + manager.appendOutputTerminalData(uri, 'fresh output'); + + assert.deepStrictEqual(manager.getTerminalState(uri)?.content, [{ type: 'unclassified', value: 'fresh output' }]); + assert.deepStrictEqual(dispatched.map(action => action.type), [ActionType.TerminalData, ActionType.TerminalCleared, ActionType.TerminalData]); + + manager.disposeTerminal(uri); + assert.strictEqual(manager.hasTerminal(uri), false); + assert.strictEqual(manager.getTerminalState(uri), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index e8bd148517ea34..d08bf060c0527f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -212,6 +212,10 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { getTerminalInfos(): [] { return []; } getTerminalState(): undefined { return undefined; } async getDefaultShell(): Promise { return '/bin/bash'; } + createOutputTerminal(): void { } + appendOutputTerminalData(): void { } + resetOutputTerminal(): void { } + finalizeOutputTerminal(): void { } } class TestCopilotApiService implements ICopilotApiService { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index a9295f84a9b8ec..93744355038142 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -29,12 +29,15 @@ import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService, type ISessionDatabase } from '../../common/sessionDataService.js'; import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction } from '../../common/state/sessionActions.js'; import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, createSessionState, mergeSessionWithDefaultChat, readUsageInfoMeta, SessionStatus, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { TerminalClaimKind } from '../../common/state/protocol/state.js'; import { CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization } from '../../common/state/protocol/channels-session/state.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; +import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystemNotification.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; @@ -367,6 +370,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { mockSession: MockCopilotSession; signals: AgentSignal[]; waitForSignal: (predicate: (signal: AgentSignal) => boolean) => Promise; + terminalManager: TestAgentHostTerminalManager; sessionConfigUpdates: ReadonlyArray<{ session: string; patch: Record }>; setConfigValue: (key: string, value: unknown) => void; setRootValue: (key: string, value: unknown) => void; @@ -513,6 +517,8 @@ async function createAgentSession(disposables: DisposableStore, options?: { if (options?.environmentServiceRegistration !== 'none') { services.set(INativeEnvironmentService, environmentService); } + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + services.set(IAgentHostTerminalManager, terminalManager); const instantiationService = disposables.add(new InstantiationService(services)); const session = disposables.add(instantiationService.createInstance( @@ -544,6 +550,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { mockSession, signals, waitForSignal, + terminalManager, sessionConfigUpdates, setConfigValue: (key, value) => { configValues[key] = value; }, setRootValue: (key, value) => { rootValues[key] = value; }, @@ -2916,10 +2923,11 @@ 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); + test('tool partial results stream into an output-only terminal channel', async () => { + const { session, mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); session.resetTurnState('turn-stream'); + const terminalUri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-stream'; mockSession.fire('tool.execution_start', { toolCallId: 'tc-stream', toolName: 'bash', @@ -2936,10 +2944,15 @@ suite('CopilotAgentSession', () => { mockSession.fire('tool.execution_complete', { toolCallId: 'tc-stream', success: true, - result: { content: 'tick 1\ntick 2\n' }, + result: { + content: 'tick 1\ntick 2\n', + contents: [{ type: 'shell_exit', shellId: '0', exitCode: 0, outputPreview: 'tick 1\ntick 2\n' }], + }, } as SessionEventPayload<'tool.execution_complete'>['data']); await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete)); + // The first partial result creates the channel and attaches the + // terminal block once; later partials only append channel data. assert.deepStrictEqual(getActions(signals) .filter(action => action.type === ActionType.ChatToolCallContentChanged) .map(action => ({ @@ -2950,16 +2963,30 @@ suite('CopilotAgentSession', () => { { 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' }], + content: [{ type: ToolResultContentType.Terminal, resource: terminalUri, title: 'Run Shell Command', isPty: false }], }, ]); + assert.deepStrictEqual(terminalManager.outputTerminalsCreated, [{ + uri: terminalUri, + title: 'Run Shell Command', + claim: { kind: TerminalClaimKind.Session, session: AgentSession.uri('copilot', 'test-session-1').toString(), toolCallId: 'tc-stream' }, + }]); + assert.deepStrictEqual(terminalManager.outputTerminalData, [ + { uri: terminalUri, data: 'tick 1\n' }, + { uri: terminalUri, data: 'tick 2\n' }, + ]); + assert.deepStrictEqual(terminalManager.outputTerminalsFinalized, [{ uri: terminalUri, exitCode: 0 }]); + + // shell_exit completion data lands on the streamed terminal block. const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; assert.deepStrictEqual(completed.result.content, [ + { + type: ToolResultContentType.Terminal, + resource: terminalUri, + title: 'Run Shell Command', + isPty: false, + result: { exitCode: 0, preview: 'tick 1\ntick 2\n' }, + }, { type: ToolResultContentType.Text, text: 'tick 1\ntick 2\n' }, ]); }); @@ -3073,9 +3100,14 @@ suite('CopilotAgentSession', () => { assert.strictEqual(action.result.success, true); assert.deepStrictEqual(action.result.content, [ { type: ToolResultContentType.Text, text: 'command not found\n' }, - { type: ToolResultContentType.TerminalComplete, exitCode: 127, cwd: URI.file('/repo').toString() }, + { + type: ToolResultContentType.Terminal, + resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-shell-exit', + title: 'Run Shell Command', + isPty: false, + result: { exitCode: 127 }, + }, ]); - assert.ok(!action.result.content?.some(content => content.type === ToolResultContentType.Terminal)); } }); diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index d284190bf084a3..62710ac19e414e 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -88,6 +88,10 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { getTerminalInfos(): TerminalInfo[] { return []; } getTerminalState(): undefined { return undefined; } async getDefaultShell(): Promise { return this.defaultShell; } + createOutputTerminal(): void { } + appendOutputTerminalData(): void { } + resetOutputTerminal(): void { } + finalizeOutputTerminal(): void { } fireCommandFinished(event: ICommandFinishedEvent): void { this._onCommandFinished.fire(event); } fireData(data: string): void { this._onData.fire(data); } fireExit(exitCode: number): void { this._onExit.fire(exitCode); } diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index bf0cecf04ba267..3b410dae6bf08b 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -4,12 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { AgentSession } from '../../common/agentService.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart } from '../../common/state/sessionState.js'; -import { mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart, type ToolResultContent } from '../../common/state/sessionState.js'; +import { appendSdkToolResultContent, mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; import { toSessionEvents, type ISessionEvent } from './copilotTestEvents.js'; suite('mapSessionEvents — history replay', () => { @@ -234,7 +233,13 @@ suite('mapSessionEvents — history replay', () => { if (part.toolCall.status !== ToolCallStatus.Completed) { return; } assert.deepStrictEqual(part.toolCall.content, [ { type: ToolResultContentType.Text, text: 'hi\n' }, - { type: ToolResultContentType.TerminalComplete, exitCode: 0, cwd: URI.file('/repo').toString(), preview: 'hi\n' }, + { + type: ToolResultContentType.Terminal, + resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', + title: 'Run Shell Command', + isPty: false, + result: { exitCode: 0, preview: 'hi\n' }, + }, ]); }); @@ -263,8 +268,13 @@ suite('mapSessionEvents — history replay', () => { assert.strictEqual(part.toolCall.status, ToolCallStatus.Completed); if (part.toolCall.status !== ToolCallStatus.Completed) { return; } assert.strictEqual(part.toolCall.success, true); - assert.ok(part.toolCall.content?.some(content => content.type === ToolResultContentType.TerminalComplete && content.exitCode === 127)); - assert.ok(!part.toolCall.content?.some(content => content.type === ToolResultContentType.Terminal)); + assert.deepStrictEqual(part.toolCall.content?.find(content => content.type === ToolResultContentType.Terminal), { + type: ToolResultContentType.Terminal, + resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', + title: 'Run Shell Command', + isPty: false, + result: { exitCode: 127 }, + }); }); test('restores best-effort model, fallback agent, and attachments onto user messages', async () => { @@ -724,3 +734,28 @@ suite('mapSessionEvents — subagent routing', () => { }); }); }); + +suite('appendSdkToolResultContent', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('folds shell_exit into an existing terminal block instead of adding a second one', () => { + const content: ToolResultContent[] = [ + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/abc', title: 'Bash' }, + ]; + + const exitCode = appendSdkToolResultContent(content, [ + { type: 'shell_exit', shellId: '0', exitCode: 2, outputPreview: 'boom\n', outputTruncated: false }, + ], { toolCallId: 'tc-1', title: 'Run Shell Command' }); + + assert.strictEqual(exitCode, 2); + assert.deepStrictEqual(content, [ + { + type: ToolResultContentType.Terminal, + resource: 'agenthost-terminal://shell/abc', + title: 'Bash', + result: { exitCode: 2, preview: 'boom\n', truncated: false }, + }, + ]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts b/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts index a6b42f7459b816..bdfce7fb3e6458 100644 --- a/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts @@ -25,6 +25,10 @@ export class TestAgentHostTerminalManager extends Disposable implements IAgentHo readonly created: CreateTerminalParams[] = []; readonly sentTexts: { uri: string; data: string }[] = []; readonly disposedTerminals: string[] = []; + readonly outputTerminalsCreated: { uri: string; title: string; claim: TerminalClaim }[] = []; + readonly outputTerminalData: { uri: string; data: string }[] = []; + readonly outputTerminalResets: string[] = []; + readonly outputTerminalsFinalized: { uri: string; exitCode: number | undefined }[] = []; /** Resolves once a command-finished listener is registered (i.e. a command is running). */ readonly commandFinishedListenerRegistered = new DeferredPromise(); @@ -59,5 +63,9 @@ export class TestAgentHostTerminalManager extends Disposable implements IAgentHo getTerminalInfos(): TerminalInfo[] { return []; } getTerminalState(): TerminalState | undefined { return undefined; } async getDefaultShell(): Promise { return this.defaultShell; } + createOutputTerminal(uri: string, options: { title: string; claim: TerminalClaim }): void { this.outputTerminalsCreated.push({ uri, title: options.title, claim: options.claim }); } + appendOutputTerminalData(uri: string, data: string): void { this.outputTerminalData.push({ uri, data }); } + resetOutputTerminal(uri: string): void { this.outputTerminalResets.push(uri); } + finalizeOutputTerminal(uri: string, exitCode: number | undefined): void { this.outputTerminalsFinalized.push({ uri, exitCode }); } fireCommandFinished(event: ICommandFinishedEvent): void { this._onCommandFinished.fire(event); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 161ea6d1e4d3a6..3e41eb56c71a47 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -1169,16 +1169,16 @@ function getTerminalOutput(tc: ToolCallState) { return undefined; } - const terminalComplete = tc.content?.find(isToolResultTerminalCompleteContent); + const terminalResult = tc.content?.find(isToolResultTerminalContent)?.result; // Prefer the structured terminal snapshot. Text content is a compatibility // fallback for older/restored results and can include legacy bookkeeping. - let text = terminalComplete?.preview; + let text = terminalResult?.preview; if (text === undefined) { const fallbackText = tc.content?.find(isToolResultTextContent)?.text; text = fallbackText === undefined ? undefined : stripLegacyTerminalExitMarkers(fallbackText); } - if (text === undefined || (!text && terminalComplete?.truncated !== true)) { + if (text === undefined || (!text && terminalResult?.truncated !== true)) { return undefined; } @@ -1188,7 +1188,7 @@ function getTerminalOutput(tc: ToolCallState) { // normalize to `\r\n` here. The replace is idempotent on already-CRLF input. return { text: text.replace(/\r?\n/g, '\r\n'), - ...(terminalComplete?.truncated !== undefined ? { truncated: terminalComplete.truncated } : {}), + ...(terminalResult?.truncated !== undefined ? { truncated: terminalResult.truncated } : {}), }; } @@ -1201,17 +1201,17 @@ function isToolResultTextContent(content: ToolResultContent): content is Extract } function getTerminalCommandState(tc: ToolCallState, fallbackSuccess?: boolean): IChatTerminalToolInvocationData['terminalCommandState'] | undefined { - const terminalComplete = tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running - ? tc.content?.find(isToolResultTerminalCompleteContent) + const terminalResult = tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running + ? tc.content?.find(isToolResultTerminalContent)?.result : undefined; - if (terminalComplete?.exitCode !== undefined) { - return { exitCode: terminalComplete.exitCode }; + if (terminalResult?.exitCode !== undefined) { + return { exitCode: terminalResult.exitCode }; } return fallbackSuccess === undefined ? undefined : { exitCode: fallbackSuccess ? 0 : 1 }; } -function isToolResultTerminalCompleteContent(content: ToolResultContent): content is Extract { - return content.type === ToolResultContentType.TerminalComplete; +function isToolResultTerminalContent(content: ToolResultContent): content is Extract { + return content.type === ToolResultContentType.Terminal; } function getTerminalLanguage(tc: ToolCallState) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 3bb4abe32d912e..c546bda8eb7842 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -11,7 +11,7 @@ import type { IMarkdownString } from '../../../../../../base/common/htmlContent. import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { McpAuthRequiredReason } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { fromAgentHostUri, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { buildSubagentChatUri, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatMarkdownContent, type IChatTerminalToolInvocationData, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, toolCallStateToPreparedInvocation as rawToolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, usageInfoToAutoModeResolution, usageInfoToQuotas, formatTurnResponseDetails, rewriteAgentHostLinkTarget, rewriteMarkdownLinks, type TurnModelLookup } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; @@ -1967,7 +1967,7 @@ suite('stateToProgressAdapter', () => { toolInput: 'gti status', content: [ { type: ToolResultContentType.Text, text: 'command not found\n' }, - { type: ToolResultContentType.TerminalComplete, exitCode: 127, cwd: URI.file('/repo').toString(), preview: 'preview only\n', truncated: true }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false, result: { exitCode: 127, preview: 'preview only\n', truncated: true } }, ], success: true, }); @@ -1994,7 +1994,7 @@ suite('stateToProgressAdapter', () => { toolInput: 'ehco hi', content: [ { type: ToolResultContentType.Text, text: 'bash: line 1: ehco: command not found\n' }, - { type: ToolResultContentType.TerminalComplete, exitCode: 127, cwd: URI.file('/repo').toString() }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false, result: { exitCode: 127 } }, ], success: true, }); @@ -2014,13 +2014,41 @@ suite('stateToProgressAdapter', () => { assert.ok(!termData.terminalCommandOutput?.text.includes('shellId')); }); + test('ignores legacy terminalComplete blocks from old persisted state', () => { + const tc = createCompletedToolCall({ + _meta: { toolKind: 'terminal' }, + toolInput: 'pwd', + content: [ + { type: ToolResultContentType.Text, text: '/repo\n' }, + // Removed from the protocol in AHP 0.7.0; may linger in old persisted turns. + { type: 'terminalComplete', exitCode: 127, preview: 'stale preview\n' } as unknown as ToolResultContent, + ], + success: true, + }); + + const turn = createTurn({ + responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: tc } as ToolCallResponsePart], + }); + + const history = turnsToHistory(URI.file('/'), [turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + const termData = getSerializedTerminalData(serialized); + // The unknown block is skipped: output comes from Text content and + // the exit code from the tool's success flag. + assert.strictEqual(termData.terminalCommandOutput?.text, '/repo\r\n'); + assert.strictEqual(termData.terminalCommandState?.exitCode, 0); + }); + test('keeps zero terminal completion exit code as success for completed SDK shell tool history', () => { const tc = createCompletedToolCall({ _meta: { toolKind: 'terminal' }, toolInput: 'pwd', content: [ { type: ToolResultContentType.Text, text: '/repo\n' }, - { type: ToolResultContentType.TerminalComplete, exitCode: 0, cwd: URI.file('/repo').toString() }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false, result: { exitCode: 0 } }, ], success: true, }); @@ -2045,7 +2073,7 @@ suite('stateToProgressAdapter', () => { toolInput: 'pwd', content: [ { type: ToolResultContentType.Text, text: '/repo\n' }, - { type: ToolResultContentType.TerminalComplete, cwd: URI.file('/repo').toString() }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false, result: {} }, ], success: true, }); @@ -2136,7 +2164,7 @@ suite('stateToProgressAdapter', () => { pastTenseMessage: 'Ran false', content: [ { type: ToolResultContentType.Text, text: '' }, - { type: ToolResultContentType.TerminalComplete, exitCode: 1, cwd: URI.file('/repo').toString() }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false, result: { exitCode: 1 } }, ], }); diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts index 4549c4a0fd26a0..d3db857a10ac58 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts @@ -110,6 +110,9 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { private _supportsCommandDetection = false; get supportsCommandDetection(): boolean { return this._supportsCommandDetection; } + /** Whether the channel is output-only plain text (`TerminalState.isPty === false`). */ + private _isPlainText = false; + /** * Command IDs for sentinel commands that should be suppressed from shell * integration events. When the copilot shell tools fall back to sentinel- @@ -164,6 +167,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { this._supportsCommandDetection = true; this._onSupportsCommandDetection.fire(); } + this._isPlainText = state.isPty === false; this._replayContent(state.content); // 5. Track initial cwd @@ -193,7 +197,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { const action = envelope.action; switch (action.type) { case ActionType.TerminalData: - this.handleData(action.data); + this.handleData(this._normalizePlainTextData(action.data)); break; case ActionType.TerminalExited: this.handleExit(action.exitCode); @@ -254,11 +258,21 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { * Emits command lifecycle events for command parts so that consumers * (e.g. {@link AhpTerminalCommandSource}) can reconstruct command history. */ + /** + * Output-only channels (`isPty: false`) carry plain text with `\n` line + * endings; the client xterm treats input as a raw TTY stream where a lone + * `\n` only advances the row (producing a staircase), so normalize to + * `\r\n`. No-op for pty-backed channels. Idempotent on CRLF input. + */ + private _normalizePlainTextData(data: string): string { + return this._isPlainText ? data.replace(/\r?\n/g, '\r\n') : data; + } + private _replayContent(content: TerminalContentPart[]): void { for (const part of content) { if (part.type === 'unclassified') { if (part.value) { - this.handleData(part.value); + this.handleData(this._normalizePlainTextData(part.value)); } } else if (part.type === 'command') { if (isCopilotSentinelCommand(part.commandLine)) { @@ -427,6 +441,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { this._supportsCommandDetection = true; this._onSupportsCommandDetection.fire(); } + this._isPlainText = state.isPty === false; // Clear the terminal buffer before replaying to avoid duplicate // content. ESC[2J clears the screen, ESC[3J clears scrollback, From 57b320b03b8b31b3131343497dbfcea5856609b7 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 16:08:01 -0700 Subject: [PATCH 06/16] Track last emitted snapshot for non-pty shell streaming The runtime emits cumulative plain-text snapshots (ANSI-stripped, throttled) that are capped to the leading ~10KB with a growing truncation marker, so a length-based delta goes stale once output exceeds the cap. Track the last emitted snapshot and prefix-check it: extend in place while the snapshot grows, reset the channel and rewrite when it was rewritten. --- .../copilot/copilotNonPtyShellTerminals.ts | 35 +++++++++++-------- .../test/node/copilotAgentSession.test.ts | 34 ++++++++++++++++++ 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts index bc379210d99eee..908a075b7a7993 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts @@ -19,16 +19,20 @@ export function buildNonPtyShellTerminalUri(toolCallId: string): string { interface INonPtyShellStream { readonly uri: string; - emittedLength: number; + /** The last cumulative snapshot written to the channel (cleared on finalize). */ + lastEmitted: string; finalized: boolean; } /** * Streams output of SDK-runtime-executed shell tool calls into output-only - * AHP terminal channels. The runtime reports cumulative output via - * `tool.execution_partial_result`; this class emits only the unseen suffix as - * `terminal/data` so subscribed clients receive live plain-text output - * (`isPty: false` — no VT parsing needed). + * AHP terminal channels. The runtime reports ANSI-stripped plain-text output + * via `tool.execution_partial_result` as cumulative snapshots (throttled and + * capped to the leading ~10KB with a trailing truncation marker); this class + * emits only the unseen suffix as `terminal/data` while the snapshot grows + * in place, and resets the channel when the snapshot was rewritten (e.g. the + * truncation marker changed), so subscribed clients receive live plain-text + * output (`isPty: false` — no VT parsing needed). * * Created once per session and disposed with it, matching the pty-backed * `ShellManager` lifecycle. @@ -68,23 +72,23 @@ export class NonPtyShellTerminalStreams extends Disposable { toolCallId, }; this._terminalManager.createOutputTerminal(uri, { title, claim }); - stream = { uri, emittedLength: 0, finalized: false }; + stream = { uri, lastEmitted: '', finalized: false }; this._streams.set(toolCallId, stream); created = true; } - if (stream.finalized) { + if (stream.finalized || cumulativeOutput === stream.lastEmitted) { return { uri: stream.uri, created }; } - if (cumulativeOutput.length < stream.emittedLength) { - // The runtime rewrote its cumulative output (defensive); start over. + if (cumulativeOutput.startsWith(stream.lastEmitted)) { + this._terminalManager.appendOutputTerminalData(stream.uri, cumulativeOutput.slice(stream.lastEmitted.length)); + } else { + // The snapshot no longer extends what we emitted — the runtime + // rewrote it (its ~10KB cap keeps leading lines and splices in a + // growing truncation marker). Start the channel over. this._terminalManager.resetOutputTerminal(stream.uri); - stream.emittedLength = 0; - } - const delta = cumulativeOutput.slice(stream.emittedLength); - if (delta.length > 0) { - this._terminalManager.appendOutputTerminalData(stream.uri, delta); - stream.emittedLength = cumulativeOutput.length; + this._terminalManager.appendOutputTerminalData(stream.uri, cumulativeOutput); } + stream.lastEmitted = cumulativeOutput; return { uri: stream.uri, created }; } @@ -98,6 +102,7 @@ export class NonPtyShellTerminalStreams extends Disposable { return; } stream.finalized = true; + stream.lastEmitted = ''; this._terminalManager.finalizeOutputTerminal(stream.uri, exitCode); } } diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 93744355038142..ec665194e9d62f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -2991,6 +2991,40 @@ suite('CopilotAgentSession', () => { ]); }); + test('tool partial results reset the channel when the runtime rewrites its snapshot', async () => { + const { mockSession, terminalManager } = await createAgentSession(disposables); + + const terminalUri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-rewrite'; + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-rewrite', + toolName: 'bash', + arguments: { command: 'yes' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-rewrite', + partialOutput: 'tick 1\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + // Past its output cap the runtime keeps leading lines and splices in + // a truncation marker, so the snapshot stops being prefix-stable. + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-rewrite', + partialOutput: 'tick 1\n[...truncated 42 lines...]\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-rewrite', + partialOutput: 'tick 1\n[...truncated 99 lines...]\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + + assert.deepStrictEqual({ data: terminalManager.outputTerminalData, resets: terminalManager.outputTerminalResets }, { + data: [ + { uri: terminalUri, data: 'tick 1\n' }, + { uri: terminalUri, data: '[...truncated 42 lines...]\n' }, + { uri: terminalUri, data: 'tick 1\n[...truncated 99 lines...]\n' }, + ], + resets: [terminalUri], + }); + }); + test('tool partial results for untracked tools are ignored', async () => { const { mockSession, signals } = await createAgentSession(disposables); From 974718cc50c4eb8f70fce3d50c47797d76ef38f0 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 16:14:00 -0700 Subject: [PATCH 07/16] Normalize output terminal data to CRLF in the terminal manager Pty-backed channels already carry CRLF because the pty converts output line endings; do the same for output-only channels at the point data enters the channel, so terminal frontends render both identically and AgentHostPty needs no isPty-specific handling. Reverts the client-side normalization. --- .../node/agentHostTerminalManager.ts | 8 +++++++- .../node/agentHostTerminalManager.test.ts | 7 ++++--- .../contrib/terminal/browser/agentHostPty.ts | 19 ++----------------- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index 4c39648b515cc1..2e79bdbcb4d8d9 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -840,12 +840,18 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe }); } - /** Append plain-text data to an output-only terminal and stream it to subscribers. */ + /** + * Append plain-text data to an output-only terminal and stream it to + * subscribers. Line endings are normalized to CRLF so the channel renders + * in terminal frontends exactly like pty-backed channels (a pty performs + * the same LF→CRLF conversion on output). + */ appendOutputTerminalData(uri: string, data: string): void { const terminal = this._outputTerminals.get(uri); if (!terminal || data.length === 0) { return; } + data = data.replace(/\r?\n/g, '\r\n'); this._appendToContent(terminal, data); this._trimContent(terminal); this._stateManager.dispatchServerAction(uri, { diff --git a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts index 871a65b3bf2d15..01d23ac6c826ad 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts @@ -827,16 +827,17 @@ suite('AgentHostTerminalManager – output-only terminals', () => { manager.finalizeOutputTerminal(uri, 0); manager.finalizeOutputTerminal(uri, 1); // recorded exit is immutable + // LF input is normalized to CRLF so the channel renders like a pty-backed one. assert.deepStrictEqual(manager.getTerminalState(uri), { title: 'Run Shell Command', - content: [{ type: 'unclassified', value: 'tick 1\ntick 2\n' }], + content: [{ type: 'unclassified', value: 'tick 1\r\ntick 2\r\n' }], exitCode: 0, claim, isPty: false, }); assert.deepStrictEqual(dispatched, [ - { type: ActionType.TerminalData, data: 'tick 1\n' }, - { type: ActionType.TerminalData, data: 'tick 2\n' }, + { type: ActionType.TerminalData, data: 'tick 1\r\n' }, + { type: ActionType.TerminalData, data: 'tick 2\r\n' }, { type: ActionType.TerminalExited, exitCode: 0 }, ]); assert.strictEqual(manager.hasTerminal(uri), true); diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts index d3db857a10ac58..4549c4a0fd26a0 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts @@ -110,9 +110,6 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { private _supportsCommandDetection = false; get supportsCommandDetection(): boolean { return this._supportsCommandDetection; } - /** Whether the channel is output-only plain text (`TerminalState.isPty === false`). */ - private _isPlainText = false; - /** * Command IDs for sentinel commands that should be suppressed from shell * integration events. When the copilot shell tools fall back to sentinel- @@ -167,7 +164,6 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { this._supportsCommandDetection = true; this._onSupportsCommandDetection.fire(); } - this._isPlainText = state.isPty === false; this._replayContent(state.content); // 5. Track initial cwd @@ -197,7 +193,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { const action = envelope.action; switch (action.type) { case ActionType.TerminalData: - this.handleData(this._normalizePlainTextData(action.data)); + this.handleData(action.data); break; case ActionType.TerminalExited: this.handleExit(action.exitCode); @@ -258,21 +254,11 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { * Emits command lifecycle events for command parts so that consumers * (e.g. {@link AhpTerminalCommandSource}) can reconstruct command history. */ - /** - * Output-only channels (`isPty: false`) carry plain text with `\n` line - * endings; the client xterm treats input as a raw TTY stream where a lone - * `\n` only advances the row (producing a staircase), so normalize to - * `\r\n`. No-op for pty-backed channels. Idempotent on CRLF input. - */ - private _normalizePlainTextData(data: string): string { - return this._isPlainText ? data.replace(/\r?\n/g, '\r\n') : data; - } - private _replayContent(content: TerminalContentPart[]): void { for (const part of content) { if (part.type === 'unclassified') { if (part.value) { - this.handleData(this._normalizePlainTextData(part.value)); + this.handleData(part.value); } } else if (part.type === 'command') { if (isCopilotSentinelCommand(part.commandLine)) { @@ -441,7 +427,6 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { this._supportsCommandDetection = true; this._onSupportsCommandDetection.fire(); } - this._isPlainText = state.isPty === false; // Clear the terminal buffer before replaying to avoid duplicate // content. ESC[2J clears the screen, ESC[3J clears scrollback, From 8a821b4a225aec27db7e18b4d9134f1eee3f3721 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 16:22:58 -0700 Subject: [PATCH 08/16] no need to complicate too much with lf crlf --- .../agentHost/node/agentHostTerminalManager.ts | 8 +------- .../test/node/agentHostTerminalManager.test.ts | 7 +++---- .../contrib/terminal/browser/agentHostPty.ts | 6 ++++++ .../terminal/browser/agentHostTerminalService.ts | 14 ++++++++++++++ 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index 2e79bdbcb4d8d9..4c39648b515cc1 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -840,18 +840,12 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe }); } - /** - * Append plain-text data to an output-only terminal and stream it to - * subscribers. Line endings are normalized to CRLF so the channel renders - * in terminal frontends exactly like pty-backed channels (a pty performs - * the same LF→CRLF conversion on output). - */ + /** Append plain-text data to an output-only terminal and stream it to subscribers. */ appendOutputTerminalData(uri: string, data: string): void { const terminal = this._outputTerminals.get(uri); if (!terminal || data.length === 0) { return; } - data = data.replace(/\r?\n/g, '\r\n'); this._appendToContent(terminal, data); this._trimContent(terminal); this._stateManager.dispatchServerAction(uri, { diff --git a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts index 01d23ac6c826ad..871a65b3bf2d15 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts @@ -827,17 +827,16 @@ suite('AgentHostTerminalManager – output-only terminals', () => { manager.finalizeOutputTerminal(uri, 0); manager.finalizeOutputTerminal(uri, 1); // recorded exit is immutable - // LF input is normalized to CRLF so the channel renders like a pty-backed one. assert.deepStrictEqual(manager.getTerminalState(uri), { title: 'Run Shell Command', - content: [{ type: 'unclassified', value: 'tick 1\r\ntick 2\r\n' }], + content: [{ type: 'unclassified', value: 'tick 1\ntick 2\n' }], exitCode: 0, claim, isPty: false, }); assert.deepStrictEqual(dispatched, [ - { type: ActionType.TerminalData, data: 'tick 1\r\n' }, - { type: ActionType.TerminalData, data: 'tick 2\r\n' }, + { type: ActionType.TerminalData, data: 'tick 1\n' }, + { type: ActionType.TerminalData, data: 'tick 2\n' }, { type: ActionType.TerminalExited, exitCode: 0 }, ]); assert.strictEqual(manager.hasTerminal(uri), true); diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts index 4549c4a0fd26a0..039a11b5da0b5c 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts @@ -110,6 +110,10 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { private _supportsCommandDetection = false; get supportsCommandDetection(): boolean { return this._supportsCommandDetection; } + private _isPlainTextOutput = false; + /** Whether the channel is output-only plain text (`TerminalState.isPty === false`). Known once the process is ready. */ + get isPlainTextOutput(): boolean { return this._isPlainTextOutput; } + /** * Command IDs for sentinel commands that should be suppressed from shell * integration events. When the copilot shell tools fall back to sentinel- @@ -164,6 +168,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { this._supportsCommandDetection = true; this._onSupportsCommandDetection.fire(); } + this._isPlainTextOutput = state.isPty === false; this._replayContent(state.content); // 5. Track initial cwd @@ -427,6 +432,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { this._supportsCommandDetection = true; this._onSupportsCommandDetection.fire(); } + this._isPlainTextOutput = state.isPty === false; // Clear the terminal buffer before replaying to avoid duplicate // content. ESC[2J clears the screen, ESC[3J clears scrollback, diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts index 0b4adabbe0aea2..6181c4d1afea84 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts @@ -356,6 +356,20 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe commandSource.connect(instance, pty); } + // Output-only channels (isPty false) carry plain text where a + // pty would emit TTY line endings; let xterm convert instead + // of treating the stream as raw TTY output. Mirrors the + // processTraits-driven option updates in TerminalInstance. + store.add(pty.onProcessReady(async () => { + if (!pty.isPlainTextOutput) { + return; + } + const xterm = await instance.xtermReadyPromise; + if (xterm) { + xterm.raw.options.convertEol = true; + } + })); + this._activePtys.set(key, { pty, clientId: connection.clientId }); return pty; }, From 5cc326a5cc9dee586f429c945d4e409561a2ecb5 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 20:42:28 -0700 Subject: [PATCH 09/16] Fix output-only shell command lifecycle rendering --- .../common/meta/agentToolCallMeta.ts | 3 + .../node/copilot/copilotAgentSession.ts | 44 +++- .../copilot/copilotNonPtyShellTerminals.ts | 162 ++++++++++++--- .../node/copilot/mapSessionEvents.ts | 16 +- .../test/common/agentMetaReaders.test.ts | 2 + .../test/node/copilotAgentSession.test.ts | 192 +++++++++++++++++- .../test/node/mapSessionEvents.test.ts | 4 +- .../agentHost/agentHostSessionHandler.ts | 29 ++- .../agentHost/stateToProgressAdapter.ts | 66 ++++-- .../chatTerminalToolProgressPart.ts | 160 +++++++++++---- .../chat/common/chatService/chatService.ts | 2 + .../agentHostChatContribution.test.ts | 46 ++++- .../stateToProgressAdapter.test.ts | 92 ++++++--- .../chatTerminalToolProgressPart.test.ts | 4 +- .../browser/agentHostOutputChannel.ts | 44 ++++ .../browser/agentHostTerminalService.ts | 48 +++-- .../browser/chatTerminalCommandMirror.ts | 34 +++- .../contrib/terminal/browser/terminal.ts | 13 ++ .../terminal/browser/terminalService.ts | 1 + .../terminal/browser/xterm/xtermTerminal.ts | 3 + .../test/browser/agentHostPty.test.ts | 24 ++- .../chat/browser/terminalChatService.ts | 19 +- 22 files changed, 845 insertions(+), 163 deletions(-) create mode 100644 src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts diff --git a/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts b/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts index f934c13a459847..0f6b91442235e9 100644 --- a/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts @@ -26,6 +26,8 @@ export interface IToolCallMeta { readonly toolKind?: ToolKind; /** Shell language for a `terminal` tool call (drives syntax highlighting). */ readonly language?: string; + /** Whether a runtime-owned terminal command remained active after its tool call completed. */ + readonly terminalIsBackground?: boolean; /** Short task description for a `subagent` tool call (e.g. "Find related files"). */ readonly subagentDescription?: string; /** Agent name for a `subagent` tool call (e.g. "explore"). */ @@ -97,6 +99,7 @@ export function readToolCallMeta(source: IHasToolCallMeta): IToolCallMeta { const result: Mutable = {}; if (isToolKind(meta['toolKind'])) { result.toolKind = meta['toolKind']; } if (typeof meta['language'] === 'string') { result.language = meta['language']; } + if (typeof meta['terminalIsBackground'] === 'boolean') { result.terminalIsBackground = meta['terminalIsBackground']; } if (typeof meta['subagentDescription'] === 'string') { result.subagentDescription = meta['subagentDescription']; } if (typeof meta['subagentAgentName'] === 'string') { result.subagentAgentName = meta['subagentAgentName']; } if (typeof meta['subagentChatUri'] === 'string') { result.subagentChatUri = meta['subagentChatUri']; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 63a592f42b3b4e..8ec8a3c78b42fa 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -2955,6 +2955,9 @@ export class CopilotAgentSession extends Disposable { const sessionId = this.sessionId; this._register(wrapper.onSystemNotification(e => { + if (e.data.kind.type === 'shell_completed') { + this._nonPtyShellTerminals.finalizeShell(e.data.kind.shellId, e.data.kind.exitCode); + } const notification = buildCopilotSystemNotification(e); if (!notification) { this._logService.trace(`[Copilot:${sessionId}] Ignoring system.notification kind=${e.data.kind.type}`); @@ -3125,6 +3128,9 @@ export class CopilotAgentSession extends Disposable { } const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [], parentToolCallId, mcpServerName: e.data.mcpServerName, meta: undefined }); + if (isShellTool(e.data.toolName)) { + this._nonPtyShellTerminals.track(e.data.toolCallId, displayName); + } if (isTaskCompleteTool(e.data.toolName)) { const scope = parentToolCallId ?? ''; this._currentTurn?.markdownPartIds.delete(scope); @@ -3284,19 +3290,33 @@ export class CopilotAgentSession extends Disposable { // SDK result content, so a `shell_exit` lands its completion data on // the terminal block (skip if any terminal block was already added // while the tool was running). - if (isShellTool(tracked.toolName) && this._shellManager) { - const terminalUri = this._shellManager.getTerminalUriForToolCall(e.data.toolCallId); - if (terminalUri && !content.some(c => c.type === ToolResultContentType.Terminal)) { + const ptyTerminalUri = isShellTool(tracked.toolName) ? this._shellManager?.getTerminalUriForToolCall(e.data.toolCallId) : undefined; + if (ptyTerminalUri && !content.some(c => c.type === ToolResultContentType.Terminal)) { + content.push({ + type: ToolResultContentType.Terminal, + resource: ptyTerminalUri, + title: tracked.displayName, + }); + } + + const shellExit = appendSdkToolResultContent(content, e.data.result?.contents, { toolCallId: e.data.toolCallId, title: tracked.displayName }); + if (isShellTool(tracked.toolName) && !ptyTerminalUri) { + const completion = this._nonPtyShellTerminals.completeToolCall(e.data.toolCallId, toolOutput, shellExit); + if (completion) { + tracked.meta = { ...tracked.meta, terminalIsBackground: completion.isBackground }; + } + if (completion && !content.some(c => c.type === ToolResultContentType.Terminal)) { content.push({ type: ToolResultContentType.Terminal, - resource: terminalUri, + resource: completion.uri, title: tracked.displayName, + isPty: false, }); } } - - const shellExitCode = appendSdkToolResultContent(content, e.data.result?.contents, { toolCallId: e.data.toolCallId, title: tracked.displayName }); - this._nonPtyShellTerminals.finalize(e.data.toolCallId, shellExitCode); + if (shellExit) { + this._nonPtyShellTerminals.finalizeShell(shellExit.shellId, shellExit.result.exitCode); + } const command = isString(tracked.parameters?.command) ? tracked.parameters.command : undefined; const filePaths = isEditTool(tracked.toolName, command) ? this._getEditFilePaths(tracked.parameters) : []; @@ -4262,15 +4282,19 @@ 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)) { + if (tracked && !isShellTool(tracked.toolName)) { + return; + } + if (!tracked && !this._nonPtyShellTerminals.has(e.data.toolCallId)) { return; } if (this._shellManager?.getTerminalUriForToolCall(e.data.toolCallId)) { // Client-hosted pty shell — its terminal channel streams live output itself. return; } - const { uri, created } = this._nonPtyShellTerminals.append(e.data.toolCallId, e.data.partialOutput, tracked.displayName); - if (created) { + const appended = this._nonPtyShellTerminals.append(e.data.toolCallId, e.data.partialOutput); + if (appended?.created && tracked) { + const { uri } = appended; tracked.content.push({ type: ToolResultContentType.Terminal, resource: uri, diff --git a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts index 908a075b7a7993..9544f58580f350 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts @@ -5,7 +5,7 @@ import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; -import { TerminalClaimKind, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; +import { TerminalClaimKind, type TerminalCommandResult, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; /** @@ -19,20 +19,59 @@ export function buildNonPtyShellTerminalUri(toolCallId: string): string { interface INonPtyShellStream { readonly uri: string; + readonly title: string; + created: boolean; /** The last cumulative snapshot written to the channel (cleared on finalize). */ lastEmitted: string; finalized: boolean; } +/** + * Extracts the shell id from the runtime's explicit background handoff + * messages. Keep this deliberately narrow: these strings are the only + * currently available structured-enough signal that a successful tool call + * handed a still-running process to the background shell manager. + */ +export function parseBackgroundShellId(text: string | undefined): string | undefined { + if (!text) { + return undefined; + } + const normalized = text.trim(); + const started = /^]*)?>$/.exec(normalized); + if (started) { + return started[1]; + } + return /^]*>$/.exec(normalized)?.[1]; +} + +/** + * Extracts the process identity and exit code from the runtime's stable text + * fallback. The external SDK bridge currently removes the equivalent + * `shell_exit` content block for compatibility with older SDK clients. + */ +export function parseCompletedShell(text: string | undefined): { shellId: string; exitCode: number } | undefined { + const match = text && /\r\n]+) completed with exit code (-?\d+)>\s*$/.exec(text); + if (!match) { + return undefined; + } + return { shellId: match[1], exitCode: Number(match[2]) }; +} + +export interface INonPtyShellToolCompletion { + readonly uri: string; + readonly isBackground: boolean; +} + /** * Streams output of SDK-runtime-executed shell tool calls into output-only * AHP terminal channels. The runtime reports ANSI-stripped plain-text output - * via `tool.execution_partial_result` as cumulative snapshots (throttled and - * capped to the leading ~10KB with a trailing truncation marker); this class - * emits only the unseen suffix as `terminal/data` while the snapshot grows - * in place, and resets the channel when the snapshot was rewritten (e.g. the - * truncation marker changed), so subscribed clients receive live plain-text - * output (`isPty: false` — no VT parsing needed). + * via `tool.execution_partial_result` as throttled cumulative snapshots that + * may be rewritten once output is truncated (a trailing truncation marker + * under the emit cap, a rolling tail past the large-output threshold); this + * class emits only the unseen suffix as `terminal/data` while the snapshot + * grows in place, and resets the channel when the snapshot was rewritten, so + * subscribed clients receive live plain-text output (`isPty: false` — no VT + * parsing needed). * * Created once per session and disposed with it, matching the pty-backed * `ShellManager` lifecycle. @@ -40,6 +79,7 @@ interface INonPtyShellStream { export class NonPtyShellTerminalStreams extends Disposable { private readonly _streams = new Map(); + private readonly _toolCallIdByShellId = new Map(); constructor( private readonly _sessionUri: URI, @@ -49,7 +89,9 @@ export class NonPtyShellTerminalStreams extends Disposable { this._register(toDisposable(() => { for (const stream of this._streams.values()) { - this._terminalManager.disposeTerminal(stream.uri); + if (stream.created) { + this._terminalManager.disposeTerminal(stream.uri); + } } this._streams.clear(); })); @@ -61,20 +103,30 @@ export class NonPtyShellTerminalStreams extends Disposable { * URI and whether this call created it (so the caller can attach the * terminal content block exactly once). */ - append(toolCallId: string, cumulativeOutput: string, title: string): { uri: string; created: boolean } { - let stream = this._streams.get(toolCallId); - let created = false; + track(toolCallId: string, title: string): void { + if (!this._streams.has(toolCallId)) { + this._streams.set(toolCallId, { + uri: buildNonPtyShellTerminalUri(toolCallId), + title, + lastEmitted: '', + finalized: false, + created: false, + }); + } + } + + has(toolCallId: string): boolean { + return this._streams.has(toolCallId); + } + + append(toolCallId: string, cumulativeOutput: string): { uri: string; created: boolean } | undefined { + const stream = this._streams.get(toolCallId); if (!stream) { - const uri = buildNonPtyShellTerminalUri(toolCallId); - const claim: TerminalSessionClaim = { - kind: TerminalClaimKind.Session, - session: this._sessionUri.toString(), - toolCallId, - }; - this._terminalManager.createOutputTerminal(uri, { title, claim }); - stream = { uri, lastEmitted: '', finalized: false }; - this._streams.set(toolCallId, stream); - created = true; + return undefined; + } + const created = !stream.created; + if (created) { + this._createTerminal(toolCallId, stream); } if (stream.finalized || cumulativeOutput === stream.lastEmitted) { return { uri: stream.uri, created }; @@ -83,8 +135,8 @@ export class NonPtyShellTerminalStreams extends Disposable { this._terminalManager.appendOutputTerminalData(stream.uri, cumulativeOutput.slice(stream.lastEmitted.length)); } else { // The snapshot no longer extends what we emitted — the runtime - // rewrote it (its ~10KB cap keeps leading lines and splices in a - // growing truncation marker). Start the channel over. + // rewrote it after truncation (marker under the emit cap, rolling + // tail past the large-output threshold). Start the channel over. this._terminalManager.resetOutputTerminal(stream.uri); this._terminalManager.appendOutputTerminalData(stream.uri, cumulativeOutput); } @@ -93,16 +145,72 @@ export class NonPtyShellTerminalStreams extends Disposable { } /** - * Records the command's exit on the tool call's output terminal, if one - * was created. Later partial results for the tool call are ignored. + * Records the process lifecycle information carried by tool completion. + * A structured shell exit settles the channel. A runtime background handoff + * instead keeps the channel open and associates its shell id so late output + * and the eventual shell-completed notification remain correlated. */ - finalize(toolCallId: string, exitCode: number | undefined): void { + completeToolCall(toolCallId: string, toolOutput: string | undefined, shellExit: { shellId: string; result: TerminalCommandResult } | undefined): INonPtyShellToolCompletion | undefined { const stream = this._streams.get(toolCallId); - if (!stream || stream.finalized) { + if (!stream) { + return undefined; + } + + const backgroundShellId = parseBackgroundShellId(toolOutput); + if (backgroundShellId) { + this._toolCallIdByShellId.set(backgroundShellId, toolCallId); + if (!stream.created) { + this._createTerminal(toolCallId, stream); + } + return { uri: stream.uri, isBackground: true }; + } + + const completedShell = shellExit + ? { shellId: shellExit.shellId, exitCode: shellExit.result.exitCode } + : parseCompletedShell(toolOutput); + if (!completedShell) { + return stream.created ? { uri: stream.uri, isBackground: false } : undefined; + } + this._toolCallIdByShellId.set(completedShell.shellId, toolCallId); + if (!stream.created) { + this._createTerminal(toolCallId, stream); + } + if (shellExit?.result.preview !== undefined) { + this.append(toolCallId, shellExit.result.preview); + } + if (completedShell.exitCode !== undefined) { + this._finalize(stream, completedShell.exitCode); + } + return { uri: stream.uri, isBackground: false }; + } + + finalizeShell(shellId: string, exitCode: number | undefined): void { + if (exitCode === undefined) { + return; + } + const toolCallId = this._toolCallIdByShellId.get(shellId); + const stream = toolCallId ? this._streams.get(toolCallId) : undefined; + if (stream) { + this._finalize(stream, exitCode); + } + } + + private _finalize(stream: INonPtyShellStream, exitCode: number): void { + if (stream.finalized) { return; } stream.finalized = true; stream.lastEmitted = ''; this._terminalManager.finalizeOutputTerminal(stream.uri, exitCode); } + + private _createTerminal(toolCallId: string, stream: INonPtyShellStream): void { + const claim: TerminalSessionClaim = { + kind: TerminalClaimKind.Session, + session: this._sessionUri.toString(), + toolCallId, + }; + this._terminalManager.createOutputTerminal(stream.uri, { title: stream.title, claim }); + stream.created = true; + } } diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 48fd3d124af7bb..2f633705d7cbae 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -52,19 +52,25 @@ function isSyntheticUserMessage(event: SessionEvent): boolean { * the tool call's terminal content block; when no terminal block exists yet * (e.g. history replay, where no live channel survives) and `terminal` is * provided, a non-pty terminal block is synthesized so the outcome still - * renders from `result.preview`. Returns the `shell_exit` exit code, if any. + * renders from `result.preview`. Returns the `shell_exit` outcome, if any, so + * the live path can settle the non-pty output channel from it. */ -export function appendSdkToolResultContent(content: ToolResultContent[], sdkContents: readonly ToolExecutionCompleteContent[] | undefined, terminal?: { toolCallId: string; title: string }): number | undefined { - let shellExitCode: number | undefined; +export interface ISdkShellExit { + readonly shellId: string; + readonly result: TerminalCommandResult; +} + +export function appendSdkToolResultContent(content: ToolResultContent[], sdkContents: readonly ToolExecutionCompleteContent[] | undefined, terminal?: { toolCallId: string; title: string }): ISdkShellExit | undefined { + let shellExit: ISdkShellExit | undefined; for (const sdkContent of sdkContents ?? []) { switch (sdkContent.type) { case 'shell_exit': { - shellExitCode = sdkContent.exitCode; const result: TerminalCommandResult = { exitCode: sdkContent.exitCode, ...(sdkContent.outputPreview !== undefined ? { preview: sdkContent.outputPreview } : {}), ...(sdkContent.outputTruncated !== undefined ? { truncated: sdkContent.outputTruncated } : {}), }; + shellExit = { shellId: sdkContent.shellId, result }; const terminalIndex = content.findIndex(c => c.type === ToolResultContentType.Terminal); if (terminalIndex !== -1) { const terminalBlock = content[terminalIndex] as ToolResultTerminalContent; @@ -82,7 +88,7 @@ export function appendSdkToolResultContent(content: ToolResultContent[], sdkCont } } } - return shellExitCode; + return shellExit; } // ============================================================================= diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index 0ece2508d4ac9b..4ac592ab916d48 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -40,6 +40,7 @@ suite('Agent host _meta readers', () => { const result = readToolCallMeta(toolCall({ toolKind: 'terminal', language: 'bash', + terminalIsBackground: true, subagentDescription: 'Find files', subagentAgentName: 'explore', mcpServerName: 'srv', @@ -52,6 +53,7 @@ suite('Agent host _meta readers', () => { assert.deepStrictEqual(result, { toolKind: 'terminal', language: 'bash', + terminalIsBackground: true, subagentDescription: 'Find files', subagentAgentName: 'explore', mcpServerName: 'srv', diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index ec665194e9d62f..4c1261acb3269e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -2989,6 +2989,7 @@ suite('CopilotAgentSession', () => { }, { type: ToolResultContentType.Text, text: 'tick 1\ntick 2\n' }, ]); + assert.strictEqual(completed._meta?.['terminalIsBackground'], false); }); test('tool partial results reset the channel when the runtime rewrites its snapshot', async () => { @@ -3004,8 +3005,9 @@ suite('CopilotAgentSession', () => { toolCallId: 'tc-rewrite', partialOutput: 'tick 1\n', } as SessionEventPayload<'tool.execution_partial_result'>['data']); - // Past its output cap the runtime keeps leading lines and splices in - // a truncation marker, so the snapshot stops being prefix-stable. + // Once output is truncated the runtime rewrites its snapshot (a + // truncation marker under the emit cap, a rolling tail past the + // large-output threshold), so it stops being prefix-stable. mockSession.fire('tool.execution_partial_result', { toolCallId: 'tc-rewrite', partialOutput: 'tick 1\n[...truncated 42 lines...]\n', @@ -3025,6 +3027,179 @@ suite('CopilotAgentSession', () => { }); }); + test('zero-partial shell completion creates, seeds, and finalizes the output channel', async () => { + const { mockSession, signals, terminalManager } = await createAgentSession(disposables); + + const terminalUri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-quiet'; + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-quiet', + toolName: 'bash', + arguments: { command: 'true' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-quiet', + success: true, + result: { + content: 'ok\n', + contents: [{ type: 'shell_exit', shellId: '0', exitCode: 0, outputPreview: 'ok\n' }], + }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + + // The channel advertised by the completed tool call exists, carries + // the preview, and is terminated even though no partial result ever + // streamed into it. + assert.deepStrictEqual({ + created: terminalManager.outputTerminalsCreated.map(t => t.uri), + data: terminalManager.outputTerminalData, + finalized: terminalManager.outputTerminalsFinalized, + }, { + created: [terminalUri], + data: [{ uri: terminalUri, data: 'ok\n' }], + finalized: [{ uri: terminalUri, exitCode: 0 }], + }); + const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; + assert.ok(completed.result.content?.some(c => c.type === ToolResultContentType.Terminal && c.resource === terminalUri)); + }); + + test('tool success without shell_exit does not fabricate a process exit', async () => { + const { mockSession, terminalManager } = await createAgentSession(disposables); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-err', + toolName: 'bash', + arguments: { command: 'boom' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-err', + partialOutput: 'boom\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-err', + success: false, + error: { message: 'failed' }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-ok', + toolName: 'bash', + arguments: { command: 'fine' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-ok', + partialOutput: 'fine\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-ok', + success: true, + result: { content: 'fine\n' }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + + // Tool completion and process completion are separate lifecycles. + assert.deepStrictEqual({ + data: terminalManager.outputTerminalData, + finalized: terminalManager.outputTerminalsFinalized, + }, { + data: [ + { uri: 'agenthost-terminal://shell/copilotNonPtyShells/tc-err', data: 'boom\n' }, + { uri: 'agenthost-terminal://shell/copilotNonPtyShells/tc-ok', data: 'fine\n' }, + ], + finalized: [], + }); + }); + + test('stable shell completion fallback finalizes when the SDK strips shell_exit', async () => { + const { mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); + const terminalUri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-exit-fallback'; + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-exit-fallback', + toolName: 'bash', + arguments: { command: 'eci hi' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-exit-fallback', + partialOutput: '/bin/bash: eci: command not found\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-exit-fallback', + success: true, + result: { + content: '/bin/bash: eci: command not found\n', + }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete)); + + assert.deepStrictEqual(terminalManager.outputTerminalsFinalized, [{ uri: terminalUri, exitCode: 127 }]); + const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; + assert.strictEqual(completed._meta?.['terminalIsBackground'], false); + }); + + test('background shell keeps streaming after tool completion and exits on shell_completed', async () => { + const { session, mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); + session.resetTurnState('turn-background'); + const terminalUri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-background'; + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-background', + toolName: 'bash', + arguments: { command: 'long-running-command' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-background', + success: true, + result: { content: '' }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete)); + + const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; + assert.ok(completed.result.content?.some(content => content.type === ToolResultContentType.Terminal && content.resource === terminalUri && content.isPty === false)); + assert.strictEqual(completed._meta?.['terminalIsBackground'], true); + assert.deepStrictEqual(terminalManager.outputTerminalsFinalized, []); + + // Runtime partials remain keyed to the original tool call after the + // SDK has emitted tool.execution_complete. + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-background', + partialOutput: 'late output\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + assert.deepStrictEqual(terminalManager.outputTerminalData, [{ uri: terminalUri, data: 'late output\n' }]); + + mockSession.fire('system.notification', { + content: 'Shell command completed', + kind: { type: 'shell_completed', shellId: 'shell-bg', exitCode: 7, description: 'long-running-command' }, + } as SessionEventPayload<'system.notification'>['data']); + assert.deepStrictEqual(terminalManager.outputTerminalsFinalized, [{ uri: terminalUri, exitCode: 7 }]); + }); + + test('completions without partials or shell_exit never create output channels', async () => { + const { mockSession, terminalManager } = await createAgentSession(disposables); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-grep', + toolName: 'grep', + arguments: { pattern: 'x' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-grep', + success: true, + result: { content: 'match' }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-silent', + toolName: 'bash', + arguments: { command: 'true' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-silent', + success: true, + result: { content: '' }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + + assert.deepStrictEqual({ + created: terminalManager.outputTerminalsCreated, + finalized: terminalManager.outputTerminalsFinalized, + }, { created: [], finalized: [] }); + }); + test('tool partial results for untracked tools are ignored', async () => { const { mockSession, signals } = await createAgentSession(disposables); @@ -3110,7 +3285,7 @@ suite('CopilotAgentSession', () => { }); test('live tool_complete maps SDK shell_exit content to terminal completion', async () => { - const { mockSession, signals } = await createAgentSession(disposables); + const { mockSession, signals, terminalManager } = await createAgentSession(disposables); mockSession.fire('tool.execution_start', { toolCallId: 'tc-shell-exit', @@ -3143,6 +3318,17 @@ suite('CopilotAgentSession', () => { }, ]); } + // The advertised channel exists and is terminated; with no preview + // there is nothing to seed. + assert.deepStrictEqual({ + created: terminalManager.outputTerminalsCreated.map(t => t.uri), + data: terminalManager.outputTerminalData, + finalized: terminalManager.outputTerminalsFinalized, + }, { + created: ['agenthost-terminal://shell/copilotNonPtyShells/tc-shell-exit'], + data: [], + finalized: [{ uri: 'agenthost-terminal://shell/copilotNonPtyShells/tc-shell-exit', exitCode: 127 }], + }); }); test('live task_complete emits root markdown instead of a tool call', async () => { diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index 3b410dae6bf08b..dbccb0a96c7068 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -744,11 +744,11 @@ suite('appendSdkToolResultContent', () => { { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/abc', title: 'Bash' }, ]; - const exitCode = appendSdkToolResultContent(content, [ + const result = appendSdkToolResultContent(content, [ { type: 'shell_exit', shellId: '0', exitCode: 2, outputPreview: 'boom\n', outputTruncated: false }, ], { toolCallId: 'tc-1', title: 'Run Shell Command' }); - assert.strictEqual(exitCode, 2); + assert.deepStrictEqual(result, { shellId: '0', result: { exitCode: 2, preview: 'boom\n', truncated: false } }); assert.deepStrictEqual(content, [ { type: ToolResultContentType.Terminal, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 0ac5df5b8c0c73..cfbf8b562db640 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -10,7 +10,7 @@ import { isCancellationError } from '../../../../../../base/common/errors.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { getChatErrorDetailsFromMeta, getCopilotPlanFromEntitlement, IChatErrorContext } from '../../../common/chatErrorMessages.js'; -import { Disposable, DisposableResourceMap, DisposableStore, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { equals } from '../../../../../../base/common/objects.js'; @@ -91,7 +91,7 @@ import { buildHostLocalEventsPath } from '../../copilotCliEventsUri.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; import { IAgentHostImportConversationStore } from './agentHostImportConversationStore.js'; -import { activeTurnToProgress, BOOLEAN_TRUE_OPTION_ID, completedToolCallToEditParts, completedToolCallToSerialized, convertProtocolAnswers, convertProtocolPlanReviewResult, createInputRequestCarousel, createInputRequestPlanReview, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContentUri, getUrlInputRequestPresentation, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rewriteAgentHostLinkTarget, stringOrMarkdownToString, systemNotificationToChatPart, toolCallAuthenticationServer, toolCallConfirmationMessages, toolCallStateToInvocation, toolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, type IAgentHostToolInvocationOptions, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, BOOLEAN_TRUE_OPTION_ID, completedToolCallToEditParts, completedToolCallToSerialized, convertProtocolAnswers, convertProtocolPlanReviewResult, createInputRequestCarousel, createInputRequestPlanReview, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContent, getUrlInputRequestPresentation, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rewriteAgentHostLinkTarget, stringOrMarkdownToString, systemNotificationToChatPart, toolCallAuthenticationServer, toolCallConfirmationMessages, toolCallStateToInvocation, toolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, type IAgentHostToolInvocationOptions, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; import { resolveMcpServerAuthentication, agentHostMcpServerId } from './agentHostAuth.js'; export { toolDataToDefinition }; @@ -2601,6 +2601,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._awaitToolConfirmation(invocation, toolCallId, opts.backendSession, opts.turnId, opts.cancellationToken, () => confirmationOptions, opts.chatURI); } this._tryObserveSubagentToolCall(initial, invocation, store, opts, subagentContext); + const outputTerminalAttachments = store.add(new DisposableMap()); // Reuse the invocation whenever a tool enters confirmation to avoid duplicate cards. let previousStatus: ToolCallStatus | undefined = initial.status; @@ -2643,7 +2644,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } this._ensureLeftStreaming(invocation, tc, opts); invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, this._config.connectionAuthority); - this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession); + this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession, outputTerminalAttachments); updateRunningToolSpecificData(invocation, tc, opts.backendSession, this._config.connectionAuthority); } @@ -2654,7 +2655,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Running was skipped (e.g. throttling) and terminal content // only appears at Completed time. this._ensureLeftStreaming(invocation, tc, opts); - this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession); + this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession, outputTerminalAttachments); const fileEdits = finalizeToolInvocation(invocation, tc, opts.backendSession, this._config.connectionAuthority); if (fileEdits.length > 0) { opts.onFileEdits?.(tc, fileEdits); @@ -3268,21 +3269,28 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC invocation: ChatToolInvocation, tc: ToolCallState, backendSession: URI, + outputTerminalAttachments: DisposableMap, ): void { // content is only present on Running/Completed/PendingResultConfirmation. // toolInput is present on all post-streaming states. if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.Completed && tc.status !== ToolCallStatus.PendingResultConfirmation) { return; } - const terminalUri = getTerminalContentUri(tc.content); - if (!terminalUri || !tc.toolInput) { + const terminalContent = getTerminalContent(tc.content); + const terminalUri = terminalContent?.resource; + if (!terminalContent || !terminalUri || !tc.toolInput) { return; } invocation.presentation = undefined; const toolInput = tc.toolInput; const sessionId = makeAhpTerminalToolSessionId(terminalUri, backendSession); const terminalCommandUri = URI.parse(terminalUri); - const terminalInstance = this._ensureTerminalInstance(terminalUri, sessionId); + const isPty = terminalContent.isPty !== false; + const terminalInstance = isPty ? this._ensureTerminalInstance(terminalUri, sessionId) : undefined; + if (!isPty && !outputTerminalAttachments.has(sessionId)) { + outputTerminalAttachments.clearAndDisposeAll(); + outputTerminalAttachments.set(sessionId, this._agentHostTerminalService.attachOutputTerminal(this._config.connection, terminalCommandUri, sessionId)); + } const existing = invocation.toolSpecificData?.kind === 'terminal' ? invocation.toolSpecificData as IChatTerminalToolInvocationData : undefined; @@ -3299,6 +3307,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC language: 'shellscript', terminalToolSessionId: sessionId, terminalCommandUri, + isPty, terminalCommandId: identityChanged ? undefined : existing?.terminalCommandId, terminalCommandOutput: identityChanged ? undefined : existing?.terminalCommandOutput, terminalCommandState: identityChanged ? undefined : existing?.terminalCommandState, @@ -3309,8 +3318,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const current = invocation.toolSpecificData?.kind === 'terminal' ? invocation.toolSpecificData : undefined; - if (current?.terminalCommandId) { - void terminalInstance.catch(error => this._logService.error(`[AgentHost] Failed to revive terminal '${terminalUri}'`, error)); + if (!terminalInstance || current?.terminalCommandId) { + if (terminalInstance) { + void terminalInstance.catch(error => this._logService.error(`[AgentHost] Failed to revive terminal '${terminalUri}'`, error)); + } return; } void terminalInstance.then(() => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 3e41eb56c71a47..60d667cc238080 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -11,7 +11,7 @@ import { Schemas } from '../../../../../../base/common/network.js'; import { posix, win32 } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import { buildSubagentChatUri, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { readToolCallMeta } from '../../../../../../platform/agentHost/common/meta/agentToolCallMeta.js'; @@ -425,15 +425,11 @@ export function isSubagentTool(tc: ToolCallState): boolean { * Returns the terminal URI if found. */ export function getTerminalContentUri(content: ToolResultContent[] | undefined): string | undefined { - if (!content) { - return undefined; - } - for (const block of content) { - if (block.type === ToolResultContentType.Terminal) { - return block.resource; - } - } - return undefined; + return getTerminalContent(content)?.resource; +} + +export function getTerminalContent(content: ToolResultContent[] | undefined): Extract | undefined { + return content?.find(isToolResultTerminalContent); } /** @@ -1169,12 +1165,13 @@ function getTerminalOutput(tc: ToolCallState) { return undefined; } - const terminalResult = tc.content?.find(isToolResultTerminalContent)?.result; + const terminalContent = getTerminalContent(tc.content); + const terminalResult = getTerminalCommandResult(tc); // Prefer the structured terminal snapshot. Text content is a compatibility // fallback for older/restored results and can include legacy bookkeeping. let text = terminalResult?.preview; - if (text === undefined) { + if (text === undefined && terminalContent?.isPty !== false) { const fallbackText = tc.content?.find(isToolResultTextContent)?.text; text = fallbackText === undefined ? undefined : stripLegacyTerminalExitMarkers(fallbackText); } @@ -1182,12 +1179,8 @@ function getTerminalOutput(tc: ToolCallState) { return undefined; } - // The detached xterm used to render this output treats input as a raw TTY stream, - // so a lone `\n` only advances the row without resetting the column (producing a - // staircase). SDK terminal tools return plain text with `\n` line endings, so - // normalize to `\r\n` here. The replace is idempotent on already-CRLF input. return { - text: text.replace(/\r?\n/g, '\r\n'), + text, ...(terminalResult?.truncated !== undefined ? { truncated: terminalResult.truncated } : {}), }; } @@ -1202,11 +1195,17 @@ function isToolResultTextContent(content: ToolResultContent): content is Extract function getTerminalCommandState(tc: ToolCallState, fallbackSuccess?: boolean): IChatTerminalToolInvocationData['terminalCommandState'] | undefined { const terminalResult = tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running - ? tc.content?.find(isToolResultTerminalContent)?.result + ? getTerminalCommandResult(tc) : undefined; if (terminalResult?.exitCode !== undefined) { return { exitCode: terminalResult.exitCode }; } + if ((tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running) && getTerminalContent(tc.content)?.isPty === false) { + // A failed SDK shell call does not always include shell_exit content. + // Preserve that failure for decoration/completion state without treating + // a successful background handoff as a process exit. + return fallbackSuccess === false ? { exitCode: 1 } : undefined; + } return fallbackSuccess === undefined ? undefined : { exitCode: fallbackSuccess ? 0 : 1 }; } @@ -1214,6 +1213,30 @@ function isToolResultTerminalContent(content: ToolResultContent): content is Ext return content.type === ToolResultContentType.Terminal; } +/** + * Shape of the `terminalComplete` tool result block that AHP 0.7.0 removed + * (its data moved onto the terminal block as `result`). Old persisted turns + * may still carry it, so completion data falls back to it. + */ +interface ILegacyTerminalCompleteContent { + type: 'terminalComplete'; + exitCode?: number; + preview?: string; + truncated?: boolean; +} + +/** + * Completion data for a terminal-style tool call: the terminal block's + * `result`, falling back to a legacy `terminalComplete` block. + */ +function getTerminalCommandResult(tc: { content?: ToolResultContent[] }): TerminalCommandResult | undefined { + const result = tc.content?.find(isToolResultTerminalContent)?.result; + if (result) { + return result; + } + return tc.content?.find(c => (c as { type: string }).type === 'terminalComplete') as ILegacyTerminalCompleteContent | undefined; +} + function getTerminalLanguage(tc: ToolCallState) { return tc.toolName === 'powershell' ? 'powershell' : 'shellscript'; } @@ -1275,9 +1298,10 @@ function buildTerminalToolSpecificData( sessionResource: URI, existing?: IChatTerminalToolInvocationData, ): IChatTerminalToolInvocationData { - const terminalContentUri = (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) - ? getTerminalContentUri(tc.content) + const terminalContent = (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) + ? getTerminalContent(tc.content) : undefined; + const terminalContentUri = terminalContent?.resource; const nextCommand = getTerminalInput(tc); const commandLine = nextCommand ? { ...existing?.commandLine, original: nextCommand } @@ -1296,6 +1320,8 @@ function buildTerminalToolSpecificData( ? makeAhpTerminalToolSessionId(terminalContentUri, sessionResource) : existing?.terminalToolSessionId, terminalCommandUri: terminalContentUri ? URI.parse(terminalContentUri) : existing?.terminalCommandUri, + isPty: terminalContent?.isPty ?? existing?.isPty, + isBackground: readToolCallMeta(tc).terminalIsBackground ?? existing?.isBackground, terminalCommandOutput: nextOutput ?? existing?.terminalCommandOutput, }; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 9e74cd79b46eac..d860c1be417231 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -28,7 +28,7 @@ import type { ICodeBlockRenderOptions } from '../codeBlockPart.js'; import { Action, IAction } from '../../../../../../../base/common/actions.js'; import { ActionBar } from '../../../../../../../base/browser/ui/actionbar/actionbar.js'; import { timeout } from '../../../../../../../base/common/async.js'; -import { IAhpTerminalCommandSource, IChatTerminalToolProgressPart, ITerminalChatService, ITerminalConfigurationService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../../../terminal/browser/terminal.js'; +import { IAhpTerminalCommandSource, IChatTerminalOutputSource, IChatTerminalToolProgressPart, ITerminalChatService, ITerminalConfigurationService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../../../terminal/browser/terminal.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { autorun } from '../../../../../../../base/common/observable.js'; @@ -145,6 +145,9 @@ interface ITerminalCommandDecorationOptions { * Returns whether the tool invocation is currently running. */ getIsRunning(): boolean; + + /** Returns a structured exit code that may arrive without command detection. */ + getExitCode(): number | undefined; } class TerminalCommandDecoration extends Disposable { @@ -188,8 +191,9 @@ class TerminalCommandDecoration extends Disposable { private _getHoverText(): string { const command = this._options.getResolvedCommand(); - const storedState = this._options.terminalData.terminalCommandState; - return getTerminalCommandDecorationTooltip(command, storedState) || ''; + const storedState = this._getStoredState(); + const effectiveCommand = command?.exitCode === undefined && storedState?.exitCode !== undefined ? undefined : command; + return getTerminalCommandDecorationTooltip(effectiveCommand, storedState) || ''; } public update(command?: ITerminalCommand): void { @@ -200,26 +204,10 @@ class TerminalCommandDecoration extends Disposable { } private _apply(decoration: HTMLElement, command: ITerminalCommand | undefined): void { - const terminalData = this._options.terminalData; - let storedState = terminalData.terminalCommandState; - - if (command) { - const existingState = terminalData.terminalCommandState ?? {}; - terminalData.terminalCommandState = { - ...existingState, - exitCode: command.exitCode, - timestamp: command.timestamp ?? existingState.timestamp, - duration: command.duration ?? existingState.duration - }; - storedState = terminalData.terminalCommandState; - } else if (!storedState) { - const now = Date.now(); - terminalData.terminalCommandState = { exitCode: undefined, timestamp: now }; - storedState = terminalData.terminalCommandState; - } - - const decorationState = getTerminalCommandDecorationState(command, storedState); - const tooltip = getTerminalCommandDecorationTooltip(command, storedState); + const storedState = this._getStoredState(); + const effectiveCommand = command?.exitCode === undefined && storedState?.exitCode !== undefined ? undefined : command; + const decorationState = getTerminalCommandDecorationState(effectiveCommand, storedState); + const tooltip = getTerminalCommandDecorationTooltip(effectiveCommand, storedState); const isRunning = this._options.getIsRunning(); @@ -245,6 +233,12 @@ class TerminalCommandDecoration extends Disposable { } } + private _getStoredState(): IChatTerminalToolInvocationData['terminalCommandState'] { + const storedState = this._options.terminalData.terminalCommandState; + const exitCode = this._options.getExitCode(); + return exitCode === undefined ? storedState : { ...storedState, exitCode }; + } + } /** @@ -292,6 +286,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart private readonly _commandText: string; private readonly _isSerializedInvocation: boolean; private _terminalInstance: ITerminalInstance | undefined; + private _outputSource: IChatTerminalOutputSource | undefined; + private readonly _outputSourceListener = this._register(new MutableDisposable()); private readonly _decoration: TerminalCommandDecoration; private _userToggledOutput: boolean = false; private _isInThinkingContainer: boolean = false; @@ -359,7 +355,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart getCommandBlock: () => elements.commandBlock, getIconElement: () => undefined, getResolvedCommand: () => this._getResolvedCommand(), - getIsRunning: () => this._isInvocationRunning() + getIsRunning: () => this._isInvocationRunning(), + getExitCode: () => this._outputSource?.exitCode, })); // Use presentationOverrides for display if available (e.g., extracted Python code with syntax highlighting) @@ -383,6 +380,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart ChatTerminalToolOutputSection, () => this._ensureTerminalInstance(), () => this._getResolvedCommand(), + () => this._outputSource, () => this._terminalData.terminalCommandOutput, () => this._commandText, () => this._terminalData.terminalTheme, @@ -421,6 +419,14 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart // Listen for continue in background — updates toolbar to auto-hide the action const terminalToolSessionId = this._terminalData.terminalToolSessionId; if (terminalToolSessionId) { + if (this._terminalData.isPty === false) { + this._attachOutputSource(); + this._register(this._terminalChatService.onDidRegisterOutputSource(sessionId => { + if (sessionId === terminalToolSessionId) { + this._attachOutputSource(); + } + })); + } this._register(this._terminalChatService.onDidContinueInBackground(sessionId => { if (sessionId === terminalToolSessionId) { this._terminalData.didContinueInBackground = true; @@ -557,9 +563,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart // tool returned) while the terminal command is still running. Detect this // so the wrapper shows "Running … in background" instead of "Ran …". const toolInvocationComplete = IChatToolInvocation.isComplete(toolInvocation); - const commandHasNotFinished = this._terminalData.terminalCommandState?.exitCode === undefined; - const isRunningInBackground = toolInvocationComplete && commandHasNotFinished - && (this._terminalData.isBackground === true || this._terminalData.didContinueInBackground === true); + const isRunningInBackground = toolInvocationComplete && this._isInvocationRunning(); const isComplete = toolInvocationComplete && !isRunningInBackground; const isSkipped = IChatToolInvocation.executionConfirmedOrDenied(toolInvocation)?.type === ToolConfirmKind.Skipped; const autoExpandFailures = this._configurationService.getValue(ChatConfiguration.AutoExpandToolFailures); @@ -577,7 +581,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart isComplete, isSkipped, isRunningInBackground, - () => this.focusTerminal(), + this._terminalData.isPty === false ? undefined : () => this.focusTerminal(), )); this._thinkingCollapsibleWrapper = wrapper; @@ -613,6 +617,11 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._updateToolbarContextKeys(); return; } + if (this._terminalData.isPty === false) { + this._attachOutputSource(); + this._updateToolbarContextKeys(undefined, terminalToolSessionId); + return; + } const attachInstance = async (instance: ITerminalInstance | undefined) => { if (this._store.isDisposed) { @@ -686,14 +695,14 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart // Show output action (only when NOT using collapsible wrapper) if (!this._usesCollapsibleWrapper) { - const hasSnapshot = !!this._terminalData.terminalCommandOutput; + const hasSnapshot = !!this._terminalData.terminalCommandOutput || !!this._outputSource?.output; const hasOutput = !!resolvedCommand || hasSnapshot; this._toolbarHasOutput = hasOutput; // Auto-expand on first detection of failed output if (hasOutput && !this._outputView.isExpanded) { const autoExpandFailures = this._configurationService.getValue(ChatConfiguration.AutoExpandToolFailures); - const exitCode = resolvedCommand?.exitCode ?? this._terminalData.terminalCommandState?.exitCode; + const exitCode = resolvedCommand?.exitCode ?? this._outputSource?.exitCode ?? this._terminalData.terminalCommandState?.exitCode; if (exitCode !== undefined && exitCode !== 0 && autoExpandFailures) { this._toggleOutput(true); } @@ -766,18 +775,30 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } private _isInvocationRunning(): boolean { + const currentTerminalData = this.toolInvocation.toolSpecificData?.kind === 'terminal' + ? migrateLegacyTerminalToolSpecificData(this.toolInvocation.toolSpecificData) + : this._terminalData; + if (currentTerminalData.isPty === false) { + if (this._outputSource?.exitCode !== undefined || currentTerminalData.terminalCommandState?.exitCode !== undefined) { + return false; + } + if (!IChatToolInvocation.isComplete(this.toolInvocation)) { + return true; + } + return currentTerminalData.isBackground === true || currentTerminalData.didContinueInBackground === true; + } const commandExitCode = this._getResolvedCommand()?.exitCode; if (commandExitCode !== undefined) { return false; } - const storedExitCode = this._terminalData.terminalCommandState?.exitCode; + const storedExitCode = currentTerminalData.terminalCommandState?.exitCode; if (storedExitCode !== undefined) { return false; } if (!IChatToolInvocation.isComplete(this.toolInvocation)) { return true; } - return this._terminalData.isBackground === true || this._terminalData.didContinueInBackground === true; + return currentTerminalData.isBackground === true || currentTerminalData.didContinueInBackground === true; } private _clearCommandAssociation(options?: { clearPersistentData?: boolean }): void { @@ -1029,6 +1050,9 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } private async _ensureTerminalInstance(): Promise { + if (this._terminalData.isPty === false) { + return undefined; + } if (this._terminalInstance?.isDisposed) { this._terminalInstance = undefined; } @@ -1041,6 +1065,47 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart return this._terminalInstance; } + private _attachOutputSource(): void { + const source = this._terminalChatService.getOutputSource(this._terminalData.terminalToolSessionId); + if (!source || source === this._outputSource) { + return; + } + this._outputSource = source; + const store = new DisposableStore(); + const onCommandExecuted = store.add(new Emitter()); + const onCommandFinished = store.add(new Emitter()); + const autoExpand = store.add(new TerminalToolAutoExpand({ + onCommandExecuted: onCommandExecuted.event, + onCommandFinished: onCommandFinished.event, + onWillData: source.onDidChange, + shouldAutoExpand: () => this._shouldAutoExpand(), + hasRealOutput: () => !!source.output, + })); + store.add(autoExpand.onDidRequestExpand(() => { + if (this._usesCollapsibleWrapper) { + this.expandCollapsibleWrapper(); + } + void this._toggleOutput(true); + })); + store.add(source.onDidChange(() => { + this._decoration.update(); + this._updateToolbarContextKeys(undefined, this._terminalData.terminalToolSessionId); + void this._outputView.refresh(); + if (source.exitCode !== undefined) { + onCommandFinished.fire(); + this.markCollapsibleWrapperComplete(); + } + })); + this._outputSourceListener.value = store; + onCommandExecuted.fire(); + if (source.exitCode !== undefined) { + onCommandFinished.fire(); + } + this._decoration.update(); + this._updateToolbarContextKeys(undefined, this._terminalData.terminalToolSessionId); + void this._outputView.refresh(); + } + private _handleOutputFocus(): void { this._terminalOutputContextKey.set(true); this._terminalChatService.setFocusedProgressPart(this); @@ -1075,6 +1140,9 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } public async focusTerminal(): Promise { + if (this._terminalData.isPty === false) { + return; + } const instance = await this._ensureTerminalInstance(); type FocusChatInstanceTelemetryEvent = { @@ -1248,6 +1316,7 @@ class ChatTerminalToolOutputSection extends Disposable { constructor( private readonly _ensureTerminalInstance: () => Promise, private readonly _resolveCommand: () => ITerminalCommand | undefined, + private readonly _getOutputSource: () => IChatTerminalOutputSource | undefined, private readonly _getTerminalCommandOutput: () => IChatTerminalToolInvocationData['terminalCommandOutput'] | undefined, private readonly _getCommandText: () => string, private readonly _getStoredTheme: () => IChatTerminalToolInvocationData['terminalTheme'] | undefined, @@ -1316,6 +1385,12 @@ class ChatTerminalToolOutputSection extends Disposable { return true; } + public async refresh(): Promise { + if (this.isExpanded) { + await this._updateTerminalContent(); + } + } + public focus(): void { this._scrollableContainer?.getDomNode().focus(); } @@ -1359,7 +1434,8 @@ class ChatTerminalToolOutputSection extends Disposable { return `${commandHeader}\n${lines.join('\n').trimEnd()}`; } - const snapshot = this._getTerminalCommandOutput(); + const source = this._getOutputSource(); + const snapshot = source ? { text: source.output } : this._getTerminalCommandOutput(); if (!snapshot) { return `${commandHeader}\n${localize('chatTerminalOutputUnavailable', 'Command output is no longer available.')}`; } @@ -1414,6 +1490,20 @@ class ChatTerminalToolOutputSection extends Disposable { } private async _updateTerminalContent(): Promise { + const outputSource = this._getOutputSource(); + if (outputSource) { + this._disposeLiveMirror(); + if (outputSource.output) { + await this._renderSnapshotOutput({ text: outputSource.output }); + } else if (outputSource.exitCode === undefined) { + this._hideEmptyMessage(); + this._layoutOutput(0); + } else { + this._showEmptyMessage(localize('chat.terminalOutputEmpty', 'No output was produced by the command.')); + this._layoutOutput(0); + } + return; + } const liveTerminalInstance = await this._resolveLiveTerminal(); const command = liveTerminalInstance ? this._resolveCommand() : undefined; const snapshot = this._getTerminalCommandOutput(); @@ -1515,7 +1605,9 @@ class ChatTerminalToolOutputSection extends Disposable { private async _renderSnapshotOutput(snapshot: NonNullable): Promise { if (this._snapshotMirror) { - this._layoutOutput(snapshot.lineCount ?? this._lastRenderedLineCount ?? 0); + this._snapshotMirror.setOutput(snapshot); + const result = await this._snapshotMirror.render(); + this._layoutOutput(result?.lineCount ?? snapshot.lineCount ?? this._lastRenderedLineCount ?? 0); return; } if (this._store.isDisposed) { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 3c3d04b4b13fd3..f4615a6b7a01f9 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -625,6 +625,8 @@ export interface IChatTerminalToolInvocationData { alternativeRecommendation?: string; language: string; terminalToolSessionId?: string; + /** False for an output-only AHP channel that must not create a workbench terminal instance. */ + isPty?: boolean; /** The predefined command ID that will be used for this terminal command */ terminalCommandId?: string; /** Whether the terminal command was started as a background execution */ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index fbf5f0dc9fa65a..91b28f497f6372 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -5357,6 +5357,48 @@ suite('AgentHostChatContribution', () => { await turnPromise; }); + test('output-only terminal attaches to chat without reviving a terminal instance', async () => { + let reviveCalls = 0; + let attachmentDisposed = false; + let attached: { terminalUri: string; terminalToolSessionId: string } | undefined; + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { + agentHostTerminalServiceOverride: { + reviveTerminal: async () => { + reviveCalls++; + return {} as ITerminalInstance; + }, + attachOutputTerminal: (_connection, terminalUri, terminalToolSessionId) => { + attached = { terminalUri: terminalUri.toString(), terminalToolSessionId }; + return toDisposable(() => attachmentDisposed = true); + }, + }, + }); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + + fire({ type: 'chat/toolCallStart', session, turnId, toolCallId: 'tc-output', toolName: 'bash', displayName: 'Bash', _meta: { toolKind: 'terminal', language: 'shellscript' } } as ChatAction); + fire({ type: 'chat/toolCallReady', session, turnId, toolCallId: 'tc-output', invocationMessage: 'Running command', toolInput: 'long-running-command', confirmed: 'not-needed' } as ChatAction); + fire({ + type: 'chat/toolCallContentChanged', + session, + turnId, + toolCallId: 'tc-output', + content: [{ type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/output', title: 'Terminal', isPty: false }], + } as ChatAction); + + const terminalData = (collected[0][0] as ChatToolInvocation).toolSpecificData as IChatTerminalToolInvocationData; + assert.strictEqual(reviveCalls, 0); + assert.strictEqual(terminalData.isPty, false); + assert.deepStrictEqual(attached, { + terminalUri: 'agenthost-terminal://shell/output', + terminalToolSessionId: JSON.stringify({ terminal: 'agenthost-terminal://shell/output', session: 'copilot:/new-turntest' }), + }); + + fire({ type: 'chat/toolCallComplete', session, turnId, toolCallId: 'tc-output', result: { success: true, pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/output', title: 'Terminal', isPty: false }] } } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; + assert.strictEqual(attachmentDisposed, true); + }); + test('bash tool renders as terminal command block with output', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -5390,7 +5432,7 @@ suite('AgentHostChatContribution', () => { dataKind: 'terminal', commandLine: 'echo hello', language: 'shellscript', - outputText: 'hello\r\n', + outputText: 'hello\n', exitCode: 0, }); })); @@ -5547,7 +5589,7 @@ suite('AgentHostChatContribution', () => { // Terminal tool has output and exit code assert.strictEqual(toolPart.toolSpecificData?.kind, 'terminal'); const termData = toolPart.toolSpecificData as IChatTerminalToolInvocationData; - assert.strictEqual(termData.terminalCommandOutput?.text, 'file1\r\nfile2'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'file1\nfile2'); assert.strictEqual(termData.terminalCommandState?.exitCode, 0); } }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index c546bda8eb7842..90f00a0b9fc8c6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -936,7 +936,7 @@ suite('stateToProgressAdapter', () => { const invocation = toolCallStateToInvocation(tc); assert.strictEqual(invocation.toolSpecificData?.kind, 'terminal'); const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput?: { text: string } }; - assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n', 'normalizes \\n to \\r\\n for xterm'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\n'); }); test('does not render terminal pill for terminal toolKind without a command (falls back to invocationMessage)', () => { @@ -1255,7 +1255,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(value, 'Read [](vscode-agent-host://ssh__macbook-air/path/to/foo.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0)'); }); - test('finalizes terminal tool with output and exit code', () => { + test('finalizes pty terminal tool with compatibility output and exit code', () => { const tc = createToolCallState({ toolInput: 'echo hi', status: ToolCallStatus.Running, @@ -1283,13 +1283,13 @@ suite('stateToProgressAdapter', () => { assert.ok(invocation.toolSpecificData); assert.strictEqual(invocation.toolSpecificData.kind, 'terminal'); - const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput: { text: string }; terminalCommandState: { exitCode: number } }; - assert.strictEqual(termData.terminalCommandOutput.text, 'output text'); - assert.strictEqual(termData.terminalCommandState.exitCode, 0); + const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput?: { text: string }; terminalCommandState?: { exitCode: number } }; + assert.strictEqual(termData.terminalCommandOutput?.text, 'output text'); + assert.strictEqual(termData.terminalCommandState?.exitCode, 0); assert.strictEqual(IChatToolInvocation.resultDetails(invocation), undefined); }); - test('normalizes LF line endings to CRLF in terminal output for xterm rendering', () => { + test('leaves plain-text line endings unchanged for xterm convertEol', () => { const tc = createToolCallState({ toolInput: 'grep -n foo', status: ToolCallStatus.Running, @@ -1315,8 +1315,8 @@ suite('stateToProgressAdapter', () => { ], }); - const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput: { text: string } }; - assert.strictEqual(termData.terminalCommandOutput.text, 'line1\r\nline2\r\nline3\r\n'); + const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput?: { text: string } }; + assert.strictEqual(termData.terminalCommandOutput?.text, 'line1\nline2\r\nline3\n'); }); test('finalizes generic tool with input/output details', () => { @@ -1911,7 +1911,7 @@ suite('stateToProgressAdapter', () => { _meta: { toolKind: 'terminal' }, toolInput: 'npm test', content: [ - { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal:///abc123', title: 'Terminal' }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal:///abc123', title: 'Terminal', isPty: false }, ], success: true, }); @@ -1932,14 +1932,14 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandUri.toString(), 'agenthost-terminal:/abc123'); }); - test('terminal content block skips output from text content', () => { + test('terminal content block skips bookkeeping text output', () => { const tc = createCompletedToolCall({ _meta: { toolKind: 'terminal', }, toolInput: 'npm test', content: [ - { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal:///abc123', title: 'Terminal' }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal:///abc123', title: 'Terminal', isPty: false }, { type: ToolResultContentType.Text, text: 'text-output' }, ], success: true, @@ -1957,8 +1957,7 @@ suite('stateToProgressAdapter', () => { const termData = serialized.toolSpecificData as { kind: 'terminal'; terminalCommandUri?: { toString(): string }; terminalCommandOutput?: { text: string } }; // Terminal content block URI should be set assert.ok(termData.terminalCommandUri); - // Text content is still extracted as output - assert.strictEqual(termData.terminalCommandOutput?.text, 'text-output'); + assert.strictEqual(termData.terminalCommandOutput, undefined); }); test('uses terminal completion exit code for completed SDK shell tool history', () => { @@ -1983,12 +1982,12 @@ suite('stateToProgressAdapter', () => { const serialized = response.parts[0] as IChatToolInvocationSerialized; const termData = getSerializedTerminalData(serialized); assert.strictEqual(termData.terminalCommandState?.exitCode, 127); - assert.strictEqual(termData.terminalCommandOutput?.text, 'preview only\r\n'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'preview only\n'); assert.strictEqual(termData.terminalCommandOutput?.truncated, true); assert.ok(!termData.terminalCommandOutput?.text.includes('shellId')); }); - test('strips legacy shell completion marker from terminal fallback output', () => { + test('does not use text content when a terminal block owns the output', () => { const tc = createCompletedToolCall({ _meta: { toolKind: 'terminal' }, toolInput: 'ehco hi', @@ -2010,18 +2009,17 @@ suite('stateToProgressAdapter', () => { const serialized = response.parts[0] as IChatToolInvocationSerialized; const termData = getSerializedTerminalData(serialized); assert.strictEqual(termData.terminalCommandState?.exitCode, 127); - assert.strictEqual(termData.terminalCommandOutput?.text, 'bash: line 1: ehco: command not found\r\n'); - assert.ok(!termData.terminalCommandOutput?.text.includes('shellId')); + assert.strictEqual(termData.terminalCommandOutput, undefined); }); - test('ignores legacy terminalComplete blocks from old persisted state', () => { + test('reads legacy terminalComplete blocks from old persisted state', () => { const tc = createCompletedToolCall({ _meta: { toolKind: 'terminal' }, toolInput: 'pwd', content: [ { type: ToolResultContentType.Text, text: '/repo\n' }, // Removed from the protocol in AHP 0.7.0; may linger in old persisted turns. - { type: 'terminalComplete', exitCode: 127, preview: 'stale preview\n' } as unknown as ToolResultContent, + { type: 'terminalComplete', exitCode: 127, preview: 'legacy preview\n' } as unknown as ToolResultContent, ], success: true, }); @@ -2036,10 +2034,10 @@ suite('stateToProgressAdapter', () => { if (response.type !== 'response') { return; } const serialized = response.parts[0] as IChatToolInvocationSerialized; const termData = getSerializedTerminalData(serialized); - // The unknown block is skipped: output comes from Text content and - // the exit code from the tool's success flag. - assert.strictEqual(termData.terminalCommandOutput?.text, '/repo\r\n'); - assert.strictEqual(termData.terminalCommandState?.exitCode, 0); + // The legacy block's completion data is preserved instead of + // degrading to the Text fallback and the tool success flag. + assert.strictEqual(termData.terminalCommandOutput?.text, 'legacy preview\n'); + assert.strictEqual(termData.terminalCommandState?.exitCode, 127); }); test('keeps zero terminal completion exit code as success for completed SDK shell tool history', () => { @@ -2067,7 +2065,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandState?.exitCode, 0); }); - test('falls back to tool success when terminal completion has no exit code', () => { + test('does not fall back to tool success when terminal completion has no exit code', () => { const tc = createCompletedToolCall({ _meta: { toolKind: 'terminal' }, toolInput: 'pwd', @@ -2089,7 +2087,47 @@ suite('stateToProgressAdapter', () => { const serialized = response.parts[0] as IChatToolInvocationSerialized; assert.strictEqual(serialized.toolSpecificData?.kind, 'terminal'); const termData = serialized.toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode: number } }; - assert.strictEqual(termData.terminalCommandState?.exitCode, 0); + assert.strictEqual(termData.terminalCommandState, undefined); + }); + + test('uses failed tool state when an output-only terminal has no shell exit', () => { + const tc = createCompletedToolCall({ + _meta: { toolKind: 'terminal' }, + toolInput: 'eci hi', + content: [ + { type: ToolResultContentType.Text, text: '/bin/bash: eci: command not found\n' }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false }, + ], + success: false, + }); + + const turn = createTurn({ + responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: tc } as ToolCallResponsePart], + }); + + const history = turnsToHistory(URI.file('/'), [turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + assert.strictEqual(serialized.toolSpecificData?.kind, 'terminal'); + const termData = serialized.toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode: number } }; + assert.deepStrictEqual(termData.terminalCommandState, { exitCode: 1 }); + }); + + test('maps explicit runtime background state onto output-only terminal data', () => { + const tc = createCompletedToolCall({ + _meta: { toolKind: 'terminal', terminalIsBackground: true }, + toolInput: 'long-running-command', + content: [ + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false }, + ], + }); + + const invocation = toolCallStateToInvocation(tc); + assert.strictEqual(invocation.toolSpecificData?.kind, 'terminal'); + const terminalData = invocation.toolSpecificData as { kind: 'terminal'; isBackground?: boolean }; + assert.strictEqual(terminalData.isBackground, true); }); test('running tool call with terminal content block sets terminalCommandUri', () => { @@ -2369,7 +2407,7 @@ suite('stateToProgressAdapter', () => { updateRunningToolSpecificData(invocation, runningTc); const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput?: { text: string } }; assert.strictEqual(termData.kind, 'terminal'); - assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\n'); }); test('preserves AHP terminal fields (terminalToolSessionId, terminalCommandUri) when refreshing output', () => { @@ -2410,7 +2448,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalToolSessionId, 'session-id-from-revive'); assert.strictEqual(termData.terminalCommandUri, reviveUri); assert.strictEqual(termData.terminalCommandId, 'cmd-id-from-revive'); - assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\n'); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts index 5b2698a964776a..bcbd368d38a136 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts @@ -94,9 +94,9 @@ suite('ChatTerminalToolProgressPart Auto-Expand Logic', () => { terminalContent, context, false, - true, false, false, + true, undefined, )); mainWindow.document.body.appendChild(part.domNode); @@ -117,12 +117,14 @@ suite('ChatTerminalToolProgressPart Auto-Expand Logic', () => { initiallyInert, expandedInert: animationContent.inert, containsTerminal: animationContent.contains(terminalContent), + hasShowLink: !!part.domNode.querySelector('.chat-terminal-show-link'), }, { hasAnimationClass: true, animationDisplay: 'grid', initiallyInert: true, expandedInert: false, containsTerminal: true, + hasShowLink: false, }); }); }); diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts b/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts new file mode 100644 index 00000000000000..b6294bb7f9398a --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; +import type { TerminalState } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; +import type { IChatTerminalOutputSource } from './terminal.js'; + +/** + * Read-only view of an AHP output channel. Unlike {@link AgentHostPty}, this + * does not create a terminal process or an {@code ITerminalInstance}; chat + * renders the accumulated plain-text state in its own detached xterm. + */ +export class AgentHostOutputChannel extends Disposable implements IChatTerminalOutputSource { + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private _output = ''; + get output(): string { return this._output; } + + private _exitCode: number | undefined; + get exitCode(): number | undefined { return this._exitCode; } + + constructor(connection: IAgentConnection, terminalUri: URI) { + super(); + const subscriptionRef = this._register(connection.getSubscription(StateComponents.Terminal, terminalUri, 'AgentHostOutputChannel')); + const subscription = subscriptionRef.object; + if (subscription.value && !(subscription.value instanceof Error)) { + this._acceptState(subscription.value); + } + this._register(subscription.onDidChange(state => this._acceptState(state))); + } + + private _acceptState(state: TerminalState): void { + this._output = state.content.map(part => part.type === 'command' ? part.output : part.value).join(''); + this._exitCode = state.exitCode; + this._onDidChange.fire(); + } +} diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts index 6181c4d1afea84..300ec5c099fcbd 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts @@ -12,6 +12,7 @@ import { IAgentConnection } from '../../../../platform/agentHost/common/agentSer import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { AgentHostPty } from './agentHostPty.js'; +import { AgentHostOutputChannel } from './agentHostOutputChannel.js'; import { AhpTerminalCommandSource } from './ahpTerminalCommandSource.js'; import { ITerminalChatService, ITerminalInstance, ITerminalLocationOptions, ITerminalService } from './terminal.js'; import { ITerminalProfileProvider, ITerminalProfileService } from '../common/terminal.js'; @@ -90,6 +91,9 @@ export interface IAgentHostTerminalService { */ reviveTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string): Promise; + /** Attach a non-pty output channel directly to chat without creating a terminal instance. */ + attachOutputTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string): IDisposable; + /** * Sets the default cwd used by profile providers when no explicit cwd * is provided. Call with `undefined` to clear. @@ -116,6 +120,7 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe */ private readonly _activePtys = new Map(); private readonly _pendingRevives = new Map>(); + private readonly _outputChannels = new Map(); constructor( @ITerminalService private readonly _terminalService: ITerminalService, @@ -124,6 +129,12 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe @IQuickInputService private readonly _quickInputService: IQuickInputService, ) { super(); + this._register(toDisposable(() => { + for (const entry of this._outputChannels.values()) { + entry.store.dispose(); + } + this._outputChannels.clear(); + })); } // #region Profile management @@ -334,6 +345,29 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe return revive; } + attachOutputTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string): IDisposable { + let entry = this._outputChannels.get(terminalToolSessionId); + if (!entry) { + const store = new DisposableStore(); + const source = store.add(new AgentHostOutputChannel(connection, terminalUri)); + store.add(this._terminalChatService.registerOutputSource(terminalToolSessionId, source)); + entry = { store, refCount: 0 }; + this._outputChannels.set(terminalToolSessionId, entry); + } + entry.refCount++; + let disposed = false; + return toDisposable(() => { + if (disposed) { + return; + } + disposed = true; + if (--entry.refCount === 0 && this._outputChannels.get(terminalToolSessionId) === entry) { + this._outputChannels.delete(terminalToolSessionId); + entry.store.dispose(); + } + }); + } + private async _doReviveTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string, key: string): Promise { const existing = this._revivedInstances.get(key); if (existing) { @@ -356,20 +390,6 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe commandSource.connect(instance, pty); } - // Output-only channels (isPty false) carry plain text where a - // pty would emit TTY line endings; let xterm convert instead - // of treating the stream as raw TTY output. Mirrors the - // processTraits-driven option updates in TerminalInstance. - store.add(pty.onProcessReady(async () => { - if (!pty.isPlainTextOutput) { - return; - } - const xterm = await instance.xtermReadyPromise; - if (xterm) { - xterm.raw.options.convertEol = true; - } - })); - this._activePtys.set(key, { pty, clientId: connection.clientId }); return pty; }, diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 6d399910388827..ada1e4406456bb 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -3,9 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Sequencer } from '../../../../base/common/async.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import type { IMarker as IXtermMarker, Terminal as RawXtermTerminal } from '@xterm/xterm'; import type { ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalService, type IDetachedTerminalInstance } from './terminal.js'; @@ -595,9 +596,12 @@ export class DetachedTerminalSnapshotMirror extends Disposable { private _output: IChatTerminalToolInvocationData['terminalCommandOutput'] | undefined; private _container: HTMLElement | undefined; - private _dirty = true; + private readonly _renderSequencer = new Sequencer(); + private _outputVersion = 0; + private _renderedVersion = -1; private _lastRenderedLineCount: number | undefined; private _lastRenderedMaxColumnWidth: number | undefined; + private _lastRenderedText = ''; constructor( output: IChatTerminalToolInvocationData['terminalCommandOutput'] | undefined, @@ -614,6 +618,7 @@ export class DetachedTerminalSnapshotMirror extends Disposable { readonly: true, processInfo, disableOverviewRuler: true, + convertEol: true, colorProvider: { getBackgroundColor: theme => { const storedBackground = this._getTheme()?.background; @@ -639,7 +644,7 @@ export class DetachedTerminalSnapshotMirror extends Disposable { public setOutput(output: IChatTerminalToolInvocationData['terminalCommandOutput'] | undefined): void { this._output = output; - this._dirty = true; + this._outputVersion++; } public async attach(container: HTMLElement): Promise { @@ -659,11 +664,16 @@ export class DetachedTerminalSnapshotMirror extends Disposable { } public async render(): Promise<{ lineCount?: number; maxColumnWidth?: number } | undefined> { + return this._renderSequencer.queue(() => this._render()); + } + + private async _render(): Promise<{ lineCount?: number; maxColumnWidth?: number } | undefined> { const output = this._output; + const outputVersion = this._outputVersion; if (!output) { return undefined; } - if (!this._dirty) { + if (outputVersion === this._renderedVersion) { return { lineCount: this._lastRenderedLineCount ?? output.lineCount, maxColumnWidth: this._lastRenderedMaxColumnWidth }; } const terminal = await this._getTerminal(); @@ -676,16 +686,26 @@ export class DetachedTerminalSnapshotMirror extends Disposable { const text = output.text ?? ''; const lineCount = output.lineCount ?? this._estimateLineCount(text); if (!text) { - this._dirty = false; + if (this._lastRenderedText) { + await new Promise(resolve => terminal.xterm.write('\x1b[2J\x1b[3J\x1b[H', resolve)); + } + this._renderedVersion = outputVersion; + this._lastRenderedText = ''; this._lastRenderedLineCount = lineCount; this._lastRenderedMaxColumnWidth = 0; return { lineCount: 0, maxColumnWidth: 0 }; } - await new Promise(resolve => terminal.xterm.write(text, resolve)); + const write = text.startsWith(this._lastRenderedText) + ? text.slice(this._lastRenderedText.length) + : `\x1b[2J\x1b[3J\x1b[H${text}`; + if (write) { + await new Promise(resolve => terminal.xterm.write(write, resolve)); + } if (this._store.isDisposed) { return undefined; } - this._dirty = false; + this._renderedVersion = outputVersion; + this._lastRenderedText = text; this._lastRenderedLineCount = lineCount; // Only compute max column width for small outputs to avoid performance issues if (this._shouldComputeMaxColumnWidth(lineCount)) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index a52e4565f3108e..03349b51f2658a 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -130,6 +130,13 @@ export interface IChatTerminalToolProgressPart { getCommandAndOutputAsText(): string | undefined; } +/** A read-only output stream rendered by chat without a workbench terminal instance. */ +export interface IChatTerminalOutputSource { + readonly onDidChange: Event; + readonly output: string; + readonly exitCode: number | undefined; +} + export interface ITerminalChatService { readonly _serviceBrand: undefined; @@ -138,6 +145,7 @@ export interface ITerminalChatService { * the chat UI first renders, enabling late binding of the focus action. */ readonly onDidRegisterTerminalInstanceWithToolSession: Event; + readonly onDidRegisterOutputSource: Event; /** * Associate a tool session id with a terminal instance. The association is automatically @@ -201,6 +209,9 @@ export interface ITerminalChatService { */ isBackgroundTerminal(terminalToolSessionId?: string): boolean; + registerOutputSource(terminalToolSessionId: string, source: IChatTerminalOutputSource): IDisposable; + getOutputSource(terminalToolSessionId: string | undefined): IChatTerminalOutputSource | undefined; + /** * Register a chat terminal tool progress part for tracking and focus management. * @param part The progress part to register @@ -404,6 +415,8 @@ export interface IDetachedXTermOptions { readonly?: boolean; processInfo: ITerminalProcessInfo; disableOverviewRuler?: boolean; + /** Treat LF as a new line at column zero, for plain-text output. */ + convertEol?: boolean; } /** diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index 152d14d7a9b360..70eeb11fac003e 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -1128,6 +1128,7 @@ export class TerminalService extends Disposable implements ITerminalService { xtermColorProvider: options.colorProvider, capabilities, disableOverviewRuler: options.disableOverviewRuler, + convertEol: options.convertEol, detached: true, }, undefined); diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 089d87c998651a..7055d4fd242899 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -99,6 +99,8 @@ export interface IXtermTerminalOptions { xtermAddonImporter?: XtermAddonImporter; /** Whether to disable the overview ruler. */ disableOverviewRuler?: boolean; + /** Treat LF as a new line at column zero, for plain-text output. */ + convertEol?: boolean; /** * When true, skips registering listeners on global singleton services * (configuration, theme, log level) to avoid accumulating listeners when @@ -237,6 +239,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach allowProposedApi: true, cols: options.cols, rows: options.rows, + convertEol: options.convertEol, documentOverride: layoutService.mainContainer.ownerDocument, altClickMovesCursor: config.altClickMovesCursor && editorOptions.multiCursorModifier === 'alt', scrollback: config.scrollback, diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index ee6cee88e04ccb..abbb8447ff378c 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -17,8 +17,10 @@ import type { ActionEnvelope, IRootConfigChangedAction, SessionAction, TerminalA import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, ResourceMkdirParams, ResourceMkdirResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { AgentHostPty } from '../../browser/agentHostPty.js'; +import { AgentHostOutputChannel } from '../../browser/agentHostOutputChannel.js'; import { IActiveSubscriptionInfo, IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { terminalReducer } from '../../../../../platform/agentHost/common/state/protocol/reducers.js'; import type { IRemoteWatchHandle } from '../../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; // ---- Mock IAgentConnection -------------------------------------------------- @@ -102,14 +104,19 @@ class MockAgentConnection implements IAgentConnection { const onDidChange = new Emitter(); const onWillApplyAction = new Emitter(); const onDidApplyAction = new Emitter(); + const connection = this; const sub: IAgentSubscription = { - value: this._terminalState, verifiedValue: this._terminalState, onDidChange: onDidChange.event, onWillApplyAction: onWillApplyAction.event, onDidApplyAction: onDidApplyAction.event, + get value() { return connection._terminalState; }, + get verifiedValue() { return connection._terminalState; }, + onDidChange: onDidChange.event, onWillApplyAction: onWillApplyAction.event, onDidApplyAction: onDidApplyAction.event, }; // Wire onDidAction to the subscription's events const listener = this._onDidAction.event(envelope => { if (envelope.channel === _resource.toString()) { onWillApplyAction.fire(envelope); + this._terminalState = terminalReducer(this._terminalState, envelope.action as TerminalAction); onDidApplyAction.fire(envelope); + onDidChange.fire(this._terminalState); } }); return { @@ -192,6 +199,21 @@ suite('AgentHostPty', () => { assert.deepStrictEqual(dataReceived, ['existing output\n']); }); + test('output channel follows accumulated state without creating a pty', () => { + const conn = new MockAgentConnection({ isPty: false, content: [{ type: 'unclassified', value: 'existing\n' }] }); + disposables.add(conn); + const source = disposables.add(new AgentHostOutputChannel(conn, terminalUri)); + + assert.strictEqual(source.output, 'existing\n'); + conn.fireAction(terminalUri, { type: ActionType.TerminalData, data: 'next\n' }); + assert.strictEqual(source.output, 'existing\nnext\n'); + conn.fireAction(terminalUri, { type: ActionType.TerminalCleared }); + conn.fireAction(terminalUri, { type: ActionType.TerminalData, data: 'fresh\n' }); + conn.fireAction(terminalUri, { type: ActionType.TerminalExited, exitCode: 3 }); + assert.strictEqual(source.output, 'fresh\n'); + assert.strictEqual(source.exitCode, 3); + }); + test('input() dispatches terminal/input action', async () => { const conn = new MockAgentConnection(); disposables.add(conn); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts index 53528f9687ca44..4191b8cbef6dd3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts @@ -8,7 +8,7 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } import { ResourceMap } from '../../../../../base/common/map.js'; import { URI } from '../../../../../base/common/uri.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IAhpTerminalCommandSource, IChatTerminalToolProgressPart, ITerminalChatService, ITerminalInstance, ITerminalService } from '../../../terminal/browser/terminal.js'; +import { IAhpTerminalCommandSource, IChatTerminalOutputSource, IChatTerminalToolProgressPart, ITerminalChatService, ITerminalInstance, ITerminalService } from '../../../terminal/browser/terminal.js'; import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IChatService } from '../../../chat/common/chatService/chatService.js'; @@ -36,11 +36,14 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ private readonly _terminalInstancesByExecutionId = new Map(); private readonly _terminalInstanceListenersByExecutionId = this._register(new DisposableMap()); private readonly _ahpCommandSources = new Map }>(); + private readonly _outputSources = new Map(); private readonly _onDidContinueInBackground = this._register(new Emitter()); readonly onDidContinueInBackground: Event = this._onDidContinueInBackground.event; private readonly _onDidRegisterTerminalInstanceForToolSession = this._register(new Emitter()); readonly onDidRegisterTerminalInstanceWithToolSession: Event = this._onDidRegisterTerminalInstanceForToolSession.event; + private readonly _onDidRegisterOutputSource = this._register(new Emitter()); + readonly onDidRegisterOutputSource: Event = this._onDidRegisterOutputSource.event; private readonly _activeProgressParts = new Set(); private _focusedProgressPart: IChatTerminalToolProgressPart | undefined; @@ -234,6 +237,20 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ return this._chatSessionResourceByTerminalInstance.get(instance); } + registerOutputSource(terminalToolSessionId: string, source: IChatTerminalOutputSource): IDisposable { + this._outputSources.set(terminalToolSessionId, source); + this._onDidRegisterOutputSource.fire(terminalToolSessionId); + return toDisposable(() => { + if (this._outputSources.get(terminalToolSessionId) === source) { + this._outputSources.delete(terminalToolSessionId); + } + }); + } + + getOutputSource(terminalToolSessionId: string | undefined): IChatTerminalOutputSource | undefined { + return terminalToolSessionId ? this._outputSources.get(terminalToolSessionId) : undefined; + } + isBackgroundTerminal(terminalToolSessionId?: string): boolean { if (!terminalToolSessionId) { return false; From bbc83bddc3244ddbfddfcff5479b042781430126 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 21:14:08 -0700 Subject: [PATCH 10/16] keep non pty output handling scoped to agent hsot --- .../agentHost/stateToProgressAdapter.ts | 2 +- .../contrib/chat/common/chatService/chatService.ts | 2 +- .../agentSessions/stateToProgressAdapter.test.ts | 14 +++++++------- .../terminal/browser/agentHostOutputChannel.ts | 5 ++++- .../contrib/terminal/browser/agentHostPty.ts | 6 ------ .../terminal/browser/chatTerminalCommandMirror.ts | 1 - .../workbench/contrib/terminal/browser/terminal.ts | 2 -- .../contrib/terminal/browser/terminalService.ts | 1 - .../terminal/browser/xterm/xtermTerminal.ts | 3 --- .../terminal/test/browser/agentHostPty.test.ts | 6 +++--- 10 files changed, 16 insertions(+), 26 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 60d667cc238080..5674e354b2a41b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -1180,7 +1180,7 @@ function getTerminalOutput(tc: ToolCallState) { } return { - text, + text: text.replace(/\r?\n/g, '\r\n'), ...(terminalResult?.truncated !== undefined ? { truncated: terminalResult.truncated } : {}), }; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index f4615a6b7a01f9..033c3c1e50d281 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -625,7 +625,7 @@ export interface IChatTerminalToolInvocationData { alternativeRecommendation?: string; language: string; terminalToolSessionId?: string; - /** False for an output-only AHP channel that must not create a workbench terminal instance. */ + /** False for output-only data that must not create a workbench terminal instance. */ isPty?: boolean; /** The predefined command ID that will be used for this terminal command */ terminalCommandId?: string; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 90f00a0b9fc8c6..17c406f073c9eb 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -936,7 +936,7 @@ suite('stateToProgressAdapter', () => { const invocation = toolCallStateToInvocation(tc); assert.strictEqual(invocation.toolSpecificData?.kind, 'terminal'); const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput?: { text: string } }; - assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\n'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n'); }); test('does not render terminal pill for terminal toolKind without a command (falls back to invocationMessage)', () => { @@ -1289,7 +1289,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(IChatToolInvocation.resultDetails(invocation), undefined); }); - test('leaves plain-text line endings unchanged for xterm convertEol', () => { + test('normalizes plain-text line endings for the detached terminal', () => { const tc = createToolCallState({ toolInput: 'grep -n foo', status: ToolCallStatus.Running, @@ -1316,7 +1316,7 @@ suite('stateToProgressAdapter', () => { }); const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput?: { text: string } }; - assert.strictEqual(termData.terminalCommandOutput?.text, 'line1\nline2\r\nline3\n'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'line1\r\nline2\r\nline3\r\n'); }); test('finalizes generic tool with input/output details', () => { @@ -1982,7 +1982,7 @@ suite('stateToProgressAdapter', () => { const serialized = response.parts[0] as IChatToolInvocationSerialized; const termData = getSerializedTerminalData(serialized); assert.strictEqual(termData.terminalCommandState?.exitCode, 127); - assert.strictEqual(termData.terminalCommandOutput?.text, 'preview only\n'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'preview only\r\n'); assert.strictEqual(termData.terminalCommandOutput?.truncated, true); assert.ok(!termData.terminalCommandOutput?.text.includes('shellId')); }); @@ -2036,7 +2036,7 @@ suite('stateToProgressAdapter', () => { const termData = getSerializedTerminalData(serialized); // The legacy block's completion data is preserved instead of // degrading to the Text fallback and the tool success flag. - assert.strictEqual(termData.terminalCommandOutput?.text, 'legacy preview\n'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'legacy preview\r\n'); assert.strictEqual(termData.terminalCommandState?.exitCode, 127); }); @@ -2407,7 +2407,7 @@ suite('stateToProgressAdapter', () => { updateRunningToolSpecificData(invocation, runningTc); const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput?: { text: string } }; assert.strictEqual(termData.kind, 'terminal'); - assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\n'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n'); }); test('preserves AHP terminal fields (terminalToolSessionId, terminalCommandUri) when refreshing output', () => { @@ -2448,7 +2448,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalToolSessionId, 'session-id-from-revive'); assert.strictEqual(termData.terminalCommandUri, reviveUri); assert.strictEqual(termData.terminalCommandId, 'cmd-id-from-revive'); - assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\n'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n'); }); }); diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts b/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts index b6294bb7f9398a..ff29cbf6f6ff6e 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts @@ -37,7 +37,10 @@ export class AgentHostOutputChannel extends Disposable implements IChatTerminalO } private _acceptState(state: TerminalState): void { - this._output = state.content.map(part => part.type === 'command' ? part.output : part.value).join(''); + this._output = state.content + .map(part => part.type === 'command' ? part.output : part.value) + .join('') + .replace(/\r?\n/g, '\r\n'); this._exitCode = state.exitCode; this._onDidChange.fire(); } diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts index 039a11b5da0b5c..4549c4a0fd26a0 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts @@ -110,10 +110,6 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { private _supportsCommandDetection = false; get supportsCommandDetection(): boolean { return this._supportsCommandDetection; } - private _isPlainTextOutput = false; - /** Whether the channel is output-only plain text (`TerminalState.isPty === false`). Known once the process is ready. */ - get isPlainTextOutput(): boolean { return this._isPlainTextOutput; } - /** * Command IDs for sentinel commands that should be suppressed from shell * integration events. When the copilot shell tools fall back to sentinel- @@ -168,7 +164,6 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { this._supportsCommandDetection = true; this._onSupportsCommandDetection.fire(); } - this._isPlainTextOutput = state.isPty === false; this._replayContent(state.content); // 5. Track initial cwd @@ -432,7 +427,6 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { this._supportsCommandDetection = true; this._onSupportsCommandDetection.fire(); } - this._isPlainTextOutput = state.isPty === false; // Clear the terminal buffer before replaying to avoid duplicate // content. ESC[2J clears the screen, ESC[3J clears scrollback, diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index ada1e4406456bb..9607bc48bf8aff 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -618,7 +618,6 @@ export class DetachedTerminalSnapshotMirror extends Disposable { readonly: true, processInfo, disableOverviewRuler: true, - convertEol: true, colorProvider: { getBackgroundColor: theme => { const storedBackground = this._getTheme()?.background; diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 03349b51f2658a..0d8eefd4ed3695 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -415,8 +415,6 @@ export interface IDetachedXTermOptions { readonly?: boolean; processInfo: ITerminalProcessInfo; disableOverviewRuler?: boolean; - /** Treat LF as a new line at column zero, for plain-text output. */ - convertEol?: boolean; } /** diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index 70eeb11fac003e..152d14d7a9b360 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -1128,7 +1128,6 @@ export class TerminalService extends Disposable implements ITerminalService { xtermColorProvider: options.colorProvider, capabilities, disableOverviewRuler: options.disableOverviewRuler, - convertEol: options.convertEol, detached: true, }, undefined); diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 7055d4fd242899..089d87c998651a 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -99,8 +99,6 @@ export interface IXtermTerminalOptions { xtermAddonImporter?: XtermAddonImporter; /** Whether to disable the overview ruler. */ disableOverviewRuler?: boolean; - /** Treat LF as a new line at column zero, for plain-text output. */ - convertEol?: boolean; /** * When true, skips registering listeners on global singleton services * (configuration, theme, log level) to avoid accumulating listeners when @@ -239,7 +237,6 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach allowProposedApi: true, cols: options.cols, rows: options.rows, - convertEol: options.convertEol, documentOverride: layoutService.mainContainer.ownerDocument, altClickMovesCursor: config.altClickMovesCursor && editorOptions.multiCursorModifier === 'alt', scrollback: config.scrollback, diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index abbb8447ff378c..43fad3c6198a0c 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -204,13 +204,13 @@ suite('AgentHostPty', () => { disposables.add(conn); const source = disposables.add(new AgentHostOutputChannel(conn, terminalUri)); - assert.strictEqual(source.output, 'existing\n'); + assert.strictEqual(source.output, 'existing\r\n'); conn.fireAction(terminalUri, { type: ActionType.TerminalData, data: 'next\n' }); - assert.strictEqual(source.output, 'existing\nnext\n'); + assert.strictEqual(source.output, 'existing\r\nnext\r\n'); conn.fireAction(terminalUri, { type: ActionType.TerminalCleared }); conn.fireAction(terminalUri, { type: ActionType.TerminalData, data: 'fresh\n' }); conn.fireAction(terminalUri, { type: ActionType.TerminalExited, exitCode: 3 }); - assert.strictEqual(source.output, 'fresh\n'); + assert.strictEqual(source.output, 'fresh\r\n'); assert.strictEqual(source.exitCode, 3); }); From 05bb38c979359601426e173216ef04ba03b2c528 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 21:33:12 -0700 Subject: [PATCH 11/16] fix output only terminal completion state --- .../node/agentHostTerminalManager.ts | 2 -- .../node/copilot/copilotAgentSession.ts | 30 +++++++++------- .../copilot/copilotNonPtyShellTerminals.ts | 36 +++++++++---------- .../test/node/copilotAgentSession.test.ts | 11 ++++-- .../agentHost/stateToProgressAdapter.ts | 2 +- .../chatTerminalToolProgressPart.ts | 31 +++++++++++++--- .../agentHostChatContribution.test.ts | 4 +-- 7 files changed, 74 insertions(+), 42 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index 4c39648b515cc1..961ea1d4f7fe12 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -179,7 +179,6 @@ interface IManagedTerminal { * to subscribers with `isPty: false` so clients skip VT parsing. */ interface IOutputTerminal { - readonly uri: string; title: string; content: TerminalContentPart[]; contentSize: number; @@ -832,7 +831,6 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe throw new Error(`Terminal already exists: ${uri}`); } this._outputTerminals.set(uri, { - uri, title: options.title, content: [], contentSize: 0, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 8ec8a3c78b42fa..df9274acff7388 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -42,7 +42,7 @@ import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAtt import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment } from '../../common/state/protocol/state.js'; import { ActionType, isChatAction, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; -import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, getToolSubagentContent, isDefaultChatUri, isSubagentSession, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, getToolSubagentContent, isDefaultChatUri, isSubagentSession, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import type { IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; @@ -3302,21 +3302,25 @@ export class CopilotAgentSession extends Disposable { const shellExit = appendSdkToolResultContent(content, e.data.result?.contents, { toolCallId: e.data.toolCallId, title: tracked.displayName }); if (isShellTool(tracked.toolName) && !ptyTerminalUri) { const completion = this._nonPtyShellTerminals.completeToolCall(e.data.toolCallId, toolOutput, shellExit); - if (completion) { - tracked.meta = { ...tracked.meta, terminalIsBackground: completion.isBackground }; + if (completion?.isBackground) { + tracked.meta = { ...tracked.meta, terminalIsBackground: true }; } - if (completion && !content.some(c => c.type === ToolResultContentType.Terminal)) { - content.push({ - type: ToolResultContentType.Terminal, - resource: completion.uri, - title: tracked.displayName, - isPty: false, - }); + if (completion) { + const terminalIndex = content.findIndex(c => c.type === ToolResultContentType.Terminal); + if (terminalIndex === -1) { + content.push({ + type: ToolResultContentType.Terminal, + resource: completion.uri, + title: tracked.displayName, + isPty: false, + ...(completion.result ? { result: completion.result } : {}), + }); + } else if (completion.result) { + const terminalBlock = content[terminalIndex] as ToolResultTerminalContent; + content[terminalIndex] = { ...terminalBlock, result: completion.result }; + } } } - if (shellExit) { - this._nonPtyShellTerminals.finalizeShell(shellExit.shellId, shellExit.result.exitCode); - } const command = isString(tracked.parameters?.command) ? tracked.parameters.command : undefined; const filePaths = isEditTool(tracked.toolName, command) ? this._getEditFilePaths(tracked.parameters) : []; diff --git a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts index 9544f58580f350..ff3deb85a098ff 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts @@ -21,7 +21,7 @@ interface INonPtyShellStream { readonly uri: string; readonly title: string; created: boolean; - /** The last cumulative snapshot written to the channel (cleared on finalize). */ + /** The last cumulative snapshot written to the channel. */ lastEmitted: string; finalized: boolean; } @@ -32,7 +32,7 @@ interface INonPtyShellStream { * currently available structured-enough signal that a successful tool call * handed a still-running process to the background shell manager. */ -export function parseBackgroundShellId(text: string | undefined): string | undefined { +function parseBackgroundShellId(text: string | undefined): string | undefined { if (!text) { return undefined; } @@ -45,21 +45,25 @@ export function parseBackgroundShellId(text: string | undefined): string | undef } /** - * Extracts the process identity and exit code from the runtime's stable text - * fallback. The external SDK bridge currently removes the equivalent - * `shell_exit` content block for compatibility with older SDK clients. + * Extracts the command result from the runtime's stable text fallback. The + * external SDK bridge currently removes the equivalent `shell_exit` content + * block for compatibility with older SDK clients. */ -export function parseCompletedShell(text: string | undefined): { shellId: string; exitCode: number } | undefined { +function parseCompletedShell(text: string | undefined): TerminalCommandResult | undefined { const match = text && /\r\n]+) completed with exit code (-?\d+)>\s*$/.exec(text); if (!match) { return undefined; } - return { shellId: match[1], exitCode: Number(match[2]) }; + return { + exitCode: Number(match[2]), + preview: text.slice(0, match.index), + }; } export interface INonPtyShellToolCompletion { readonly uri: string; readonly isBackground: boolean; + readonly result?: TerminalCommandResult; } /** @@ -165,23 +169,20 @@ export class NonPtyShellTerminalStreams extends Disposable { return { uri: stream.uri, isBackground: true }; } - const completedShell = shellExit - ? { shellId: shellExit.shellId, exitCode: shellExit.result.exitCode } - : parseCompletedShell(toolOutput); - if (!completedShell) { + const result = shellExit?.result ?? parseCompletedShell(toolOutput); + if (!result) { return stream.created ? { uri: stream.uri, isBackground: false } : undefined; } - this._toolCallIdByShellId.set(completedShell.shellId, toolCallId); if (!stream.created) { this._createTerminal(toolCallId, stream); } - if (shellExit?.result.preview !== undefined) { - this.append(toolCallId, shellExit.result.preview); + if (result.preview !== undefined) { + this.append(toolCallId, result.preview); } - if (completedShell.exitCode !== undefined) { - this._finalize(stream, completedShell.exitCode); + if (result.exitCode !== undefined) { + this._finalize(stream, result.exitCode); } - return { uri: stream.uri, isBackground: false }; + return { uri: stream.uri, isBackground: false, result }; } finalizeShell(shellId: string, exitCode: number | undefined): void { @@ -200,7 +201,6 @@ export class NonPtyShellTerminalStreams extends Disposable { return; } stream.finalized = true; - stream.lastEmitted = ''; this._terminalManager.finalizeOutputTerminal(stream.uri, exitCode); } diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 4c1261acb3269e..64f0be5062f62c 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -2989,7 +2989,7 @@ suite('CopilotAgentSession', () => { }, { type: ToolResultContentType.Text, text: 'tick 1\ntick 2\n' }, ]); - assert.strictEqual(completed._meta?.['terminalIsBackground'], false); + assert.strictEqual(completed._meta?.['terminalIsBackground'], undefined); }); test('tool partial results reset the channel when the runtime rewrites its snapshot', async () => { @@ -3130,7 +3130,14 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(terminalManager.outputTerminalsFinalized, [{ uri: terminalUri, exitCode: 127 }]); const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; - assert.strictEqual(completed._meta?.['terminalIsBackground'], false); + assert.ok(completed.result.content?.some(content => + content.type === ToolResultContentType.Terminal + && content.resource === terminalUri + && content.isPty === false + && content.result?.exitCode === 127 + && content.result.preview === '/bin/bash: eci: command not found\n' + )); + assert.strictEqual(completed._meta?.['terminalIsBackground'], undefined); }); test('background shell keeps streaming after tool completion and exits on shell_completed', async () => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 5674e354b2a41b..2535cdcd41f070 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -424,7 +424,7 @@ export function isSubagentTool(tc: ToolCallState): boolean { * Finds a terminal content block in a tool call's content array. * Returns the terminal URI if found. */ -export function getTerminalContentUri(content: ToolResultContent[] | undefined): string | undefined { +function getTerminalContentUri(content: ToolResultContent[] | undefined): string | undefined { return getTerminalContent(content)?.resource; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index d860c1be417231..efb07b1922eeec 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -191,7 +191,10 @@ class TerminalCommandDecoration extends Disposable { private _getHoverText(): string { const command = this._options.getResolvedCommand(); - const storedState = this._getStoredState(); + if (this._options.terminalData.isPty !== false) { + return getTerminalCommandDecorationTooltip(command, this._options.terminalData.terminalCommandState) || ''; + } + const storedState = this._getOutputOnlyStoredState(); const effectiveCommand = command?.exitCode === undefined && storedState?.exitCode !== undefined ? undefined : command; return getTerminalCommandDecorationTooltip(effectiveCommand, storedState) || ''; } @@ -204,8 +207,28 @@ class TerminalCommandDecoration extends Disposable { } private _apply(decoration: HTMLElement, command: ITerminalCommand | undefined): void { - const storedState = this._getStoredState(); - const effectiveCommand = command?.exitCode === undefined && storedState?.exitCode !== undefined ? undefined : command; + const terminalData = this._options.terminalData; + let storedState = terminalData.terminalCommandState; + let effectiveCommand = command; + + if (terminalData.isPty === false) { + storedState = this._getOutputOnlyStoredState(); + effectiveCommand = command?.exitCode === undefined && storedState?.exitCode !== undefined ? undefined : command; + } else if (command) { + const existingState = terminalData.terminalCommandState ?? {}; + terminalData.terminalCommandState = { + ...existingState, + exitCode: command.exitCode, + timestamp: command.timestamp ?? existingState.timestamp, + duration: command.duration ?? existingState.duration + }; + storedState = terminalData.terminalCommandState; + } else if (!storedState) { + const now = Date.now(); + terminalData.terminalCommandState = { exitCode: undefined, timestamp: now }; + storedState = terminalData.terminalCommandState; + } + const decorationState = getTerminalCommandDecorationState(effectiveCommand, storedState); const tooltip = getTerminalCommandDecorationTooltip(effectiveCommand, storedState); @@ -233,7 +256,7 @@ class TerminalCommandDecoration extends Disposable { } } - private _getStoredState(): IChatTerminalToolInvocationData['terminalCommandState'] { + private _getOutputOnlyStoredState(): IChatTerminalToolInvocationData['terminalCommandState'] { const storedState = this._options.terminalData.terminalCommandState; const exitCode = this._options.getExitCode(); return exitCode === undefined ? storedState : { ...storedState, exitCode }; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 91b28f497f6372..538f64ebe60143 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -5432,7 +5432,7 @@ suite('AgentHostChatContribution', () => { dataKind: 'terminal', commandLine: 'echo hello', language: 'shellscript', - outputText: 'hello\n', + outputText: 'hello\r\n', exitCode: 0, }); })); @@ -5589,7 +5589,7 @@ suite('AgentHostChatContribution', () => { // Terminal tool has output and exit code assert.strictEqual(toolPart.toolSpecificData?.kind, 'terminal'); const termData = toolPart.toolSpecificData as IChatTerminalToolInvocationData; - assert.strictEqual(termData.terminalCommandOutput?.text, 'file1\nfile2'); + assert.strictEqual(termData.terminalCommandOutput?.text, 'file1\r\nfile2'); assert.strictEqual(termData.terminalCommandState?.exitCode, 0); } }); From 0fc5da8014c868c249faec8c8673ea733ae927ed Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 22:09:07 -0700 Subject: [PATCH 12/16] simplify agent host output terminal lifecycle --- .../node/agentHostTerminalManager.ts | 8 ++--- .../node/agentHostTerminalManager.test.ts | 4 +-- .../agentHost/agentHostSessionHandler.ts | 23 +++++++----- .../chatTerminalToolProgressPart.ts | 35 +++++++++---------- .../browser/agentHostTerminalService.ts | 31 +++------------- 5 files changed, 42 insertions(+), 59 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index 961ea1d4f7fe12..6535a2fb965a74 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -549,7 +549,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Get accumulated scrollback content for a terminal as raw text. */ getContent(uri: string): string | undefined { - const terminal = this._terminals.get(uri) ?? this._outputTerminals.get(uri); + const terminal = this._terminals.get(uri); if (!terminal) { return undefined; } @@ -558,12 +558,12 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Get the current claim for a terminal. */ getClaim(uri: string): TerminalClaim | undefined { - return (this._terminals.get(uri) ?? this._outputTerminals.get(uri))?.claim; + return this._terminals.get(uri)?.claim; } /** Check whether a terminal exists. */ hasTerminal(uri: string): boolean { - return this._terminals.has(uri) || this._outputTerminals.has(uri); + return this._terminals.has(uri); } /** Whether the terminal has shell integration active for command detection. */ @@ -574,7 +574,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Get the exit code for a terminal, or undefined if still running. */ getExitCode(uri: string): number | undefined { - return (this._terminals.get(uri) ?? this._outputTerminals.get(uri))?.exitCode; + return this._terminals.get(uri)?.exitCode; } /** Resize a terminal. */ diff --git a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts index 871a65b3bf2d15..70c7843bf11fe1 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts @@ -839,8 +839,8 @@ suite('AgentHostTerminalManager – output-only terminals', () => { { type: ActionType.TerminalData, data: 'tick 2\n' }, { type: ActionType.TerminalExited, exitCode: 0 }, ]); - assert.strictEqual(manager.hasTerminal(uri), true); - // Output channels are discovered through tool result content, not the root terminal list. + // Output channels are discovered through tool result content, not generic PTY terminal APIs. + assert.strictEqual(manager.hasTerminal(uri), false); assert.deepStrictEqual(manager.getTerminalInfos(), []); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index cfbf8b562db640..af42934cd06337 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -10,7 +10,7 @@ import { isCancellationError } from '../../../../../../base/common/errors.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { getChatErrorDetailsFromMeta, getCopilotPlanFromEntitlement, IChatErrorContext } from '../../../common/chatErrorMessages.js'; -import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableResourceMap, DisposableStore, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { equals } from '../../../../../../base/common/objects.js'; @@ -184,6 +184,11 @@ interface ISubagentContext { readonly observedToolIds: Set; } +interface IOutputTerminalAttachment { + sessionId?: string; + readonly disposable: MutableDisposable; +} + function getMcpAuthenticationRequiredServers(sessionResource: URI, state: ISessionWithDefaultChat | undefined): IChatMcpAuthenticationRequiredServer[] { const servers = state?.customizations?.flatMap(c => c.type === CustomizationType.McpServer ? [c] @@ -2601,7 +2606,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._awaitToolConfirmation(invocation, toolCallId, opts.backendSession, opts.turnId, opts.cancellationToken, () => confirmationOptions, opts.chatURI); } this._tryObserveSubagentToolCall(initial, invocation, store, opts, subagentContext); - const outputTerminalAttachments = store.add(new DisposableMap()); + const outputTerminalAttachment: IOutputTerminalAttachment = { + disposable: store.add(new MutableDisposable()) + }; // Reuse the invocation whenever a tool enters confirmation to avoid duplicate cards. let previousStatus: ToolCallStatus | undefined = initial.status; @@ -2644,7 +2651,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } this._ensureLeftStreaming(invocation, tc, opts); invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, this._config.connectionAuthority); - this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession, outputTerminalAttachments); + this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession, outputTerminalAttachment); updateRunningToolSpecificData(invocation, tc, opts.backendSession, this._config.connectionAuthority); } @@ -2655,7 +2662,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Running was skipped (e.g. throttling) and terminal content // only appears at Completed time. this._ensureLeftStreaming(invocation, tc, opts); - this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession, outputTerminalAttachments); + this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession, outputTerminalAttachment); const fileEdits = finalizeToolInvocation(invocation, tc, opts.backendSession, this._config.connectionAuthority); if (fileEdits.length > 0) { opts.onFileEdits?.(tc, fileEdits); @@ -3269,7 +3276,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC invocation: ChatToolInvocation, tc: ToolCallState, backendSession: URI, - outputTerminalAttachments: DisposableMap, + outputTerminalAttachment: IOutputTerminalAttachment, ): void { // content is only present on Running/Completed/PendingResultConfirmation. // toolInput is present on all post-streaming states. @@ -3287,9 +3294,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const terminalCommandUri = URI.parse(terminalUri); const isPty = terminalContent.isPty !== false; const terminalInstance = isPty ? this._ensureTerminalInstance(terminalUri, sessionId) : undefined; - if (!isPty && !outputTerminalAttachments.has(sessionId)) { - outputTerminalAttachments.clearAndDisposeAll(); - outputTerminalAttachments.set(sessionId, this._agentHostTerminalService.attachOutputTerminal(this._config.connection, terminalCommandUri, sessionId)); + if (!isPty && outputTerminalAttachment.sessionId !== sessionId) { + outputTerminalAttachment.disposable.value = this._agentHostTerminalService.attachOutputTerminal(this._config.connection, terminalCommandUri, sessionId); + outputTerminalAttachment.sessionId = sessionId; } const existing = invocation.toolSpecificData?.kind === 'terminal' ? invocation.toolSpecificData as IChatTerminalToolInvocationData diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index efb07b1922eeec..6e7c94829e55ef 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -191,11 +191,7 @@ class TerminalCommandDecoration extends Disposable { private _getHoverText(): string { const command = this._options.getResolvedCommand(); - if (this._options.terminalData.isPty !== false) { - return getTerminalCommandDecorationTooltip(command, this._options.terminalData.terminalCommandState) || ''; - } - const storedState = this._getOutputOnlyStoredState(); - const effectiveCommand = command?.exitCode === undefined && storedState?.exitCode !== undefined ? undefined : command; + const { effectiveCommand, storedState } = this._getDecorationInput(command); return getTerminalCommandDecorationTooltip(effectiveCommand, storedState) || ''; } @@ -208,13 +204,7 @@ class TerminalCommandDecoration extends Disposable { private _apply(decoration: HTMLElement, command: ITerminalCommand | undefined): void { const terminalData = this._options.terminalData; - let storedState = terminalData.terminalCommandState; - let effectiveCommand = command; - - if (terminalData.isPty === false) { - storedState = this._getOutputOnlyStoredState(); - effectiveCommand = command?.exitCode === undefined && storedState?.exitCode !== undefined ? undefined : command; - } else if (command) { + if (terminalData.isPty !== false && command) { const existingState = terminalData.terminalCommandState ?? {}; terminalData.terminalCommandState = { ...existingState, @@ -222,13 +212,12 @@ class TerminalCommandDecoration extends Disposable { timestamp: command.timestamp ?? existingState.timestamp, duration: command.duration ?? existingState.duration }; - storedState = terminalData.terminalCommandState; - } else if (!storedState) { + } else if (terminalData.isPty !== false && !terminalData.terminalCommandState) { const now = Date.now(); terminalData.terminalCommandState = { exitCode: undefined, timestamp: now }; - storedState = terminalData.terminalCommandState; } + const { effectiveCommand, storedState } = this._getDecorationInput(command); const decorationState = getTerminalCommandDecorationState(effectiveCommand, storedState); const tooltip = getTerminalCommandDecorationTooltip(effectiveCommand, storedState); @@ -256,10 +245,20 @@ class TerminalCommandDecoration extends Disposable { } } - private _getOutputOnlyStoredState(): IChatTerminalToolInvocationData['terminalCommandState'] { - const storedState = this._options.terminalData.terminalCommandState; + private _getDecorationInput(command: ITerminalCommand | undefined): { + effectiveCommand: ITerminalCommand | undefined; + storedState: IChatTerminalToolInvocationData['terminalCommandState']; + } { + let storedState = this._options.terminalData.terminalCommandState; + if (this._options.terminalData.isPty !== false) { + return { effectiveCommand: command, storedState }; + } const exitCode = this._options.getExitCode(); - return exitCode === undefined ? storedState : { ...storedState, exitCode }; + storedState = exitCode === undefined ? storedState : { ...storedState, exitCode }; + return { + effectiveCommand: command?.exitCode === undefined && storedState?.exitCode !== undefined ? undefined : command, + storedState + }; } } diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts index 300ec5c099fcbd..0618a77365eca7 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts @@ -120,7 +120,6 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe */ private readonly _activePtys = new Map(); private readonly _pendingRevives = new Map>(); - private readonly _outputChannels = new Map(); constructor( @ITerminalService private readonly _terminalService: ITerminalService, @@ -129,12 +128,6 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe @IQuickInputService private readonly _quickInputService: IQuickInputService, ) { super(); - this._register(toDisposable(() => { - for (const entry of this._outputChannels.values()) { - entry.store.dispose(); - } - this._outputChannels.clear(); - })); } // #region Profile management @@ -346,26 +339,10 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe } attachOutputTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string): IDisposable { - let entry = this._outputChannels.get(terminalToolSessionId); - if (!entry) { - const store = new DisposableStore(); - const source = store.add(new AgentHostOutputChannel(connection, terminalUri)); - store.add(this._terminalChatService.registerOutputSource(terminalToolSessionId, source)); - entry = { store, refCount: 0 }; - this._outputChannels.set(terminalToolSessionId, entry); - } - entry.refCount++; - let disposed = false; - return toDisposable(() => { - if (disposed) { - return; - } - disposed = true; - if (--entry.refCount === 0 && this._outputChannels.get(terminalToolSessionId) === entry) { - this._outputChannels.delete(terminalToolSessionId); - entry.store.dispose(); - } - }); + const store = new DisposableStore(); + const source = store.add(new AgentHostOutputChannel(connection, terminalUri)); + store.add(this._terminalChatService.registerOutputSource(terminalToolSessionId, source)); + return store; } private async _doReviveTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string, key: string): Promise { From 6b2e153160962757883548f89cf2f29179dcd76a Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 22:38:15 -0700 Subject: [PATCH 13/16] leave unnncessary background stuff out --- .../common/meta/agentToolCallMeta.ts | 3 -- .../node/copilot/copilotAgentSession.ts | 13 +---- .../copilot/copilotNonPtyShellTerminals.ts | 52 ++----------------- .../test/common/agentMetaReaders.test.ts | 2 - .../test/node/copilotAgentSession.test.ts | 23 ++++---- .../agentHost/stateToProgressAdapter.ts | 5 +- .../stateToProgressAdapter.test.ts | 15 ------ 7 files changed, 16 insertions(+), 97 deletions(-) diff --git a/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts b/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts index 0f6b91442235e9..f934c13a459847 100644 --- a/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts @@ -26,8 +26,6 @@ export interface IToolCallMeta { readonly toolKind?: ToolKind; /** Shell language for a `terminal` tool call (drives syntax highlighting). */ readonly language?: string; - /** Whether a runtime-owned terminal command remained active after its tool call completed. */ - readonly terminalIsBackground?: boolean; /** Short task description for a `subagent` tool call (e.g. "Find related files"). */ readonly subagentDescription?: string; /** Agent name for a `subagent` tool call (e.g. "explore"). */ @@ -99,7 +97,6 @@ export function readToolCallMeta(source: IHasToolCallMeta): IToolCallMeta { const result: Mutable = {}; if (isToolKind(meta['toolKind'])) { result.toolKind = meta['toolKind']; } if (typeof meta['language'] === 'string') { result.language = meta['language']; } - if (typeof meta['terminalIsBackground'] === 'boolean') { result.terminalIsBackground = meta['terminalIsBackground']; } if (typeof meta['subagentDescription'] === 'string') { result.subagentDescription = meta['subagentDescription']; } if (typeof meta['subagentAgentName'] === 'string') { result.subagentAgentName = meta['subagentAgentName']; } if (typeof meta['subagentChatUri'] === 'string') { result.subagentChatUri = meta['subagentChatUri']; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index df9274acff7388..450e7f9efe9c16 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -2955,9 +2955,6 @@ export class CopilotAgentSession extends Disposable { const sessionId = this.sessionId; this._register(wrapper.onSystemNotification(e => { - if (e.data.kind.type === 'shell_completed') { - this._nonPtyShellTerminals.finalizeShell(e.data.kind.shellId, e.data.kind.exitCode); - } const notification = buildCopilotSystemNotification(e); if (!notification) { this._logService.trace(`[Copilot:${sessionId}] Ignoring system.notification kind=${e.data.kind.type}`); @@ -3302,9 +3299,6 @@ export class CopilotAgentSession extends Disposable { const shellExit = appendSdkToolResultContent(content, e.data.result?.contents, { toolCallId: e.data.toolCallId, title: tracked.displayName }); if (isShellTool(tracked.toolName) && !ptyTerminalUri) { const completion = this._nonPtyShellTerminals.completeToolCall(e.data.toolCallId, toolOutput, shellExit); - if (completion?.isBackground) { - tracked.meta = { ...tracked.meta, terminalIsBackground: true }; - } if (completion) { const terminalIndex = content.findIndex(c => c.type === ToolResultContentType.Terminal); if (terminalIndex === -1) { @@ -4286,10 +4280,7 @@ 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; - } - if (!tracked && !this._nonPtyShellTerminals.has(e.data.toolCallId)) { + if (!tracked || !isShellTool(tracked.toolName)) { return; } if (this._shellManager?.getTerminalUriForToolCall(e.data.toolCallId)) { @@ -4297,7 +4288,7 @@ export class CopilotAgentSession extends Disposable { return; } const appended = this._nonPtyShellTerminals.append(e.data.toolCallId, e.data.partialOutput); - if (appended?.created && tracked) { + if (appended?.created) { const { uri } = appended; tracked.content.push({ type: ToolResultContentType.Terminal, diff --git a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts index ff3deb85a098ff..e30d2ec2e7f6be 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts @@ -26,24 +26,6 @@ interface INonPtyShellStream { finalized: boolean; } -/** - * Extracts the shell id from the runtime's explicit background handoff - * messages. Keep this deliberately narrow: these strings are the only - * currently available structured-enough signal that a successful tool call - * handed a still-running process to the background shell manager. - */ -function parseBackgroundShellId(text: string | undefined): string | undefined { - if (!text) { - return undefined; - } - const normalized = text.trim(); - const started = /^]*)?>$/.exec(normalized); - if (started) { - return started[1]; - } - return /^]*>$/.exec(normalized)?.[1]; -} - /** * Extracts the command result from the runtime's stable text fallback. The * external SDK bridge currently removes the equivalent `shell_exit` content @@ -62,7 +44,6 @@ function parseCompletedShell(text: string | undefined): TerminalCommandResult | export interface INonPtyShellToolCompletion { readonly uri: string; - readonly isBackground: boolean; readonly result?: TerminalCommandResult; } @@ -83,7 +64,6 @@ export interface INonPtyShellToolCompletion { export class NonPtyShellTerminalStreams extends Disposable { private readonly _streams = new Map(); - private readonly _toolCallIdByShellId = new Map(); constructor( private readonly _sessionUri: URI, @@ -119,10 +99,6 @@ export class NonPtyShellTerminalStreams extends Disposable { } } - has(toolCallId: string): boolean { - return this._streams.has(toolCallId); - } - append(toolCallId: string, cumulativeOutput: string): { uri: string; created: boolean } | undefined { const stream = this._streams.get(toolCallId); if (!stream) { @@ -150,9 +126,7 @@ export class NonPtyShellTerminalStreams extends Disposable { /** * Records the process lifecycle information carried by tool completion. - * A structured shell exit settles the channel. A runtime background handoff - * instead keeps the channel open and associates its shell id so late output - * and the eventual shell-completed notification remain correlated. + * A structured shell exit settles the channel. */ completeToolCall(toolCallId: string, toolOutput: string | undefined, shellExit: { shellId: string; result: TerminalCommandResult } | undefined): INonPtyShellToolCompletion | undefined { const stream = this._streams.get(toolCallId); @@ -160,18 +134,9 @@ export class NonPtyShellTerminalStreams extends Disposable { return undefined; } - const backgroundShellId = parseBackgroundShellId(toolOutput); - if (backgroundShellId) { - this._toolCallIdByShellId.set(backgroundShellId, toolCallId); - if (!stream.created) { - this._createTerminal(toolCallId, stream); - } - return { uri: stream.uri, isBackground: true }; - } - const result = shellExit?.result ?? parseCompletedShell(toolOutput); if (!result) { - return stream.created ? { uri: stream.uri, isBackground: false } : undefined; + return stream.created ? { uri: stream.uri } : undefined; } if (!stream.created) { this._createTerminal(toolCallId, stream); @@ -182,18 +147,7 @@ export class NonPtyShellTerminalStreams extends Disposable { if (result.exitCode !== undefined) { this._finalize(stream, result.exitCode); } - return { uri: stream.uri, isBackground: false, result }; - } - - finalizeShell(shellId: string, exitCode: number | undefined): void { - if (exitCode === undefined) { - return; - } - const toolCallId = this._toolCallIdByShellId.get(shellId); - const stream = toolCallId ? this._streams.get(toolCallId) : undefined; - if (stream) { - this._finalize(stream, exitCode); - } + return { uri: stream.uri, result }; } private _finalize(stream: INonPtyShellStream, exitCode: number): void { diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index 4ac592ab916d48..0ece2508d4ac9b 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -40,7 +40,6 @@ suite('Agent host _meta readers', () => { const result = readToolCallMeta(toolCall({ toolKind: 'terminal', language: 'bash', - terminalIsBackground: true, subagentDescription: 'Find files', subagentAgentName: 'explore', mcpServerName: 'srv', @@ -53,7 +52,6 @@ suite('Agent host _meta readers', () => { assert.deepStrictEqual(result, { toolKind: 'terminal', language: 'bash', - terminalIsBackground: true, subagentDescription: 'Find files', subagentAgentName: 'explore', mcpServerName: 'srv', diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 64f0be5062f62c..14a468b1daaa7c 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -2989,7 +2989,6 @@ suite('CopilotAgentSession', () => { }, { type: ToolResultContentType.Text, text: 'tick 1\ntick 2\n' }, ]); - assert.strictEqual(completed._meta?.['terminalIsBackground'], undefined); }); test('tool partial results reset the channel when the runtime rewrites its snapshot', async () => { @@ -3137,14 +3136,10 @@ suite('CopilotAgentSession', () => { && content.result?.exitCode === 127 && content.result.preview === '/bin/bash: eci: command not found\n' )); - assert.strictEqual(completed._meta?.['terminalIsBackground'], undefined); }); - test('background shell keeps streaming after tool completion and exits on shell_completed', async () => { - const { session, mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); - session.resetTurnState('turn-background'); - const terminalUri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-background'; - + test('background shell does not create an output-only terminal', async () => { + const { mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); mockSession.fire('tool.execution_start', { toolCallId: 'tc-background', toolName: 'bash', @@ -3158,23 +3153,23 @@ suite('CopilotAgentSession', () => { await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete)); const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; - assert.ok(completed.result.content?.some(content => content.type === ToolResultContentType.Terminal && content.resource === terminalUri && content.isPty === false)); - assert.strictEqual(completed._meta?.['terminalIsBackground'], true); - assert.deepStrictEqual(terminalManager.outputTerminalsFinalized, []); + assert.ok(!completed.result.content?.some(content => content.type === ToolResultContentType.Terminal)); + assert.deepStrictEqual(terminalManager.outputTerminalsCreated, []); - // Runtime partials remain keyed to the original tool call after the - // SDK has emitted tool.execution_complete. mockSession.fire('tool.execution_partial_result', { toolCallId: 'tc-background', partialOutput: 'late output\n', } as SessionEventPayload<'tool.execution_partial_result'>['data']); - assert.deepStrictEqual(terminalManager.outputTerminalData, [{ uri: terminalUri, data: 'late output\n' }]); mockSession.fire('system.notification', { content: 'Shell command completed', kind: { type: 'shell_completed', shellId: 'shell-bg', exitCode: 7, description: 'long-running-command' }, } as SessionEventPayload<'system.notification'>['data']); - assert.deepStrictEqual(terminalManager.outputTerminalsFinalized, [{ uri: terminalUri, exitCode: 7 }]); + assert.deepStrictEqual({ + created: terminalManager.outputTerminalsCreated, + data: terminalManager.outputTerminalData, + finalized: terminalManager.outputTerminalsFinalized, + }, { created: [], data: [], finalized: [] }); }); test('completions without partials or shell_exit never create output channels', async () => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 2535cdcd41f070..fa629d25943df4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -1202,8 +1202,8 @@ function getTerminalCommandState(tc: ToolCallState, fallbackSuccess?: boolean): } if ((tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running) && getTerminalContent(tc.content)?.isPty === false) { // A failed SDK shell call does not always include shell_exit content. - // Preserve that failure for decoration/completion state without treating - // a successful background handoff as a process exit. + // Preserve that failure for decoration/completion state without + // fabricating a successful process exit when none was reported. return fallbackSuccess === false ? { exitCode: 1 } : undefined; } return fallbackSuccess === undefined ? undefined : { exitCode: fallbackSuccess ? 0 : 1 }; @@ -1321,7 +1321,6 @@ function buildTerminalToolSpecificData( : existing?.terminalToolSessionId, terminalCommandUri: terminalContentUri ? URI.parse(terminalContentUri) : existing?.terminalCommandUri, isPty: terminalContent?.isPty ?? existing?.isPty, - isBackground: readToolCallMeta(tc).terminalIsBackground ?? existing?.isBackground, terminalCommandOutput: nextOutput ?? existing?.terminalCommandOutput, }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 17c406f073c9eb..66271d19680bc7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -2115,21 +2115,6 @@ suite('stateToProgressAdapter', () => { assert.deepStrictEqual(termData.terminalCommandState, { exitCode: 1 }); }); - test('maps explicit runtime background state onto output-only terminal data', () => { - const tc = createCompletedToolCall({ - _meta: { toolKind: 'terminal', terminalIsBackground: true }, - toolInput: 'long-running-command', - content: [ - { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false }, - ], - }); - - const invocation = toolCallStateToInvocation(tc); - assert.strictEqual(invocation.toolSpecificData?.kind, 'terminal'); - const terminalData = invocation.toolSpecificData as { kind: 'terminal'; isBackground?: boolean }; - assert.strictEqual(terminalData.isBackground, true); - }); - test('running tool call with terminal content block sets terminalCommandUri', () => { const tc = createToolCallState({ _meta: { toolKind: 'terminal' }, From 2c2666965c56d81c6a180b2846f528788a9c9a80 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Jul 2026 23:09:44 -0700 Subject: [PATCH 14/16] scope non pty shell terminals by session --- .../node/copilot/copilotAgentSession.ts | 2 +- .../copilot/copilotNonPtyShellTerminals.ts | 12 ++++--- .../node/copilot/mapSessionEvents.ts | 6 ++-- .../test/node/copilotAgentSession.test.ts | 31 +++++++++++++------ .../e2e/harness/agentHostE2ETestHarness.ts | 2 +- .../test/node/mapSessionEvents.test.ts | 6 ++-- 6 files changed, 37 insertions(+), 22 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 450e7f9efe9c16..83c19abf09119a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3296,7 +3296,7 @@ export class CopilotAgentSession extends Disposable { }); } - const shellExit = appendSdkToolResultContent(content, e.data.result?.contents, { toolCallId: e.data.toolCallId, title: tracked.displayName }); + const shellExit = appendSdkToolResultContent(content, e.data.result?.contents, { session: this.sessionUri, toolCallId: e.data.toolCallId, title: tracked.displayName }); if (isShellTool(tracked.toolName) && !ptyTerminalUri) { const completion = this._nonPtyShellTerminals.completeToolCall(e.data.toolCallId, toolOutput, shellExit); if (completion) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts index e30d2ec2e7f6be..9673f5b017acda 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts @@ -5,16 +5,18 @@ import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { AgentSession } from '../../common/agentService.js'; import { TerminalClaimKind, type TerminalCommandResult, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; /** * Builds the terminal channel URI for a runtime-executed (non-pty) shell tool - * call. Keyed by tool call id so the URI is stable across live streaming and - * history replay of the same command. + * call. The session owns the terminal namespace and each tool call addresses a + * distinct child terminal, keeping the URI stable across live streaming and + * history replay without colliding with other sessions or tool calls. */ -export function buildNonPtyShellTerminalUri(toolCallId: string): string { - return `agenthost-terminal://shell/copilotNonPtyShells/${toolCallId}`; +export function buildNonPtyShellTerminalUri(session: URI | string, toolCallId: string): string { + return `agenthost-terminal://shell/${encodeURIComponent(AgentSession.id(session))}/${encodeURIComponent(toolCallId)}`; } interface INonPtyShellStream { @@ -90,7 +92,7 @@ export class NonPtyShellTerminalStreams extends Disposable { track(toolCallId: string, title: string): void { if (!this._streams.has(toolCallId)) { this._streams.set(toolCallId, { - uri: buildNonPtyShellTerminalUri(toolCallId), + uri: buildNonPtyShellTerminalUri(this._sessionUri, toolCallId), title, lastEmitted: '', finalized: false, diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 2f633705d7cbae..4816cc976149bf 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -60,7 +60,7 @@ export interface ISdkShellExit { readonly result: TerminalCommandResult; } -export function appendSdkToolResultContent(content: ToolResultContent[], sdkContents: readonly ToolExecutionCompleteContent[] | undefined, terminal?: { toolCallId: string; title: string }): ISdkShellExit | undefined { +export function appendSdkToolResultContent(content: ToolResultContent[], sdkContents: readonly ToolExecutionCompleteContent[] | undefined, terminal?: { session: URI | string; toolCallId: string; title: string }): ISdkShellExit | undefined { let shellExit: ISdkShellExit | undefined; for (const sdkContent of sdkContents ?? []) { switch (sdkContent.type) { @@ -78,7 +78,7 @@ export function appendSdkToolResultContent(content: ToolResultContent[], sdkCont } else if (terminal) { content.push({ type: ToolResultContentType.Terminal, - resource: buildNonPtyShellTerminalUri(terminal.toolCallId), + resource: buildNonPtyShellTerminalUri(terminal.session, terminal.toolCallId), title: terminal.title, isPty: false, result, @@ -749,7 +749,7 @@ function makeCompletedToolCallPart( if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } - appendSdkToolResultContent(content, d.result?.contents, { toolCallId: d.toolCallId, title: info.displayName }); + appendSdkToolResultContent(content, d.result?.contents, { session: sessionUriStr, toolCallId: d.toolCallId, title: info.displayName }); // Restore file edit content references from the database. const edits = storedEdits?.get(d.toolCallId); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 14a468b1daaa7c..4c6fb14bb761c8 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -32,6 +32,7 @@ import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerSt import { TerminalClaimKind } from '../../common/state/protocol/state.js'; import { CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization } from '../../common/state/protocol/channels-session/state.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; +import { buildNonPtyShellTerminalUri } from '../../node/copilot/copilotNonPtyShellTerminals.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; @@ -2923,11 +2924,23 @@ suite('CopilotAgentSession', () => { assert.strictEqual((toolStart.action as ChatToolCallStartAction).intention, 'List files in the repo root'); }); + test('non-pty shell terminal URIs are scoped by session and tool call', () => { + assert.deepStrictEqual([ + buildNonPtyShellTerminalUri(AgentSession.uri('copilot', 'session-1'), 'tool-call-1'), + buildNonPtyShellTerminalUri(AgentSession.uri('copilot', 'session-1'), 'tool-call-2'), + buildNonPtyShellTerminalUri(AgentSession.uri('copilot', 'session-2'), 'tool-call-1'), + ], [ + 'agenthost-terminal://shell/session-1/tool-call-1', + 'agenthost-terminal://shell/session-1/tool-call-2', + 'agenthost-terminal://shell/session-2/tool-call-1', + ]); + }); + test('tool partial results stream into an output-only terminal channel', async () => { const { session, mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); session.resetTurnState('turn-stream'); - const terminalUri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-stream'; + const terminalUri = 'agenthost-terminal://shell/test-session-1/tc-stream'; mockSession.fire('tool.execution_start', { toolCallId: 'tc-stream', toolName: 'bash', @@ -2994,7 +3007,7 @@ suite('CopilotAgentSession', () => { test('tool partial results reset the channel when the runtime rewrites its snapshot', async () => { const { mockSession, terminalManager } = await createAgentSession(disposables); - const terminalUri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-rewrite'; + const terminalUri = 'agenthost-terminal://shell/test-session-1/tc-rewrite'; mockSession.fire('tool.execution_start', { toolCallId: 'tc-rewrite', toolName: 'bash', @@ -3029,7 +3042,7 @@ suite('CopilotAgentSession', () => { test('zero-partial shell completion creates, seeds, and finalizes the output channel', async () => { const { mockSession, signals, terminalManager } = await createAgentSession(disposables); - const terminalUri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-quiet'; + const terminalUri = 'agenthost-terminal://shell/test-session-1/tc-quiet'; mockSession.fire('tool.execution_start', { toolCallId: 'tc-quiet', toolName: 'bash', @@ -3098,8 +3111,8 @@ suite('CopilotAgentSession', () => { finalized: terminalManager.outputTerminalsFinalized, }, { data: [ - { uri: 'agenthost-terminal://shell/copilotNonPtyShells/tc-err', data: 'boom\n' }, - { uri: 'agenthost-terminal://shell/copilotNonPtyShells/tc-ok', data: 'fine\n' }, + { uri: 'agenthost-terminal://shell/test-session-1/tc-err', data: 'boom\n' }, + { uri: 'agenthost-terminal://shell/test-session-1/tc-ok', data: 'fine\n' }, ], finalized: [], }); @@ -3107,7 +3120,7 @@ suite('CopilotAgentSession', () => { test('stable shell completion fallback finalizes when the SDK strips shell_exit', async () => { const { mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); - const terminalUri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-exit-fallback'; + const terminalUri = 'agenthost-terminal://shell/test-session-1/tc-exit-fallback'; mockSession.fire('tool.execution_start', { toolCallId: 'tc-exit-fallback', @@ -3313,7 +3326,7 @@ suite('CopilotAgentSession', () => { { type: ToolResultContentType.Text, text: 'command not found\n' }, { type: ToolResultContentType.Terminal, - resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-shell-exit', + resource: 'agenthost-terminal://shell/test-session-1/tc-shell-exit', title: 'Run Shell Command', isPty: false, result: { exitCode: 127 }, @@ -3327,9 +3340,9 @@ suite('CopilotAgentSession', () => { data: terminalManager.outputTerminalData, finalized: terminalManager.outputTerminalsFinalized, }, { - created: ['agenthost-terminal://shell/copilotNonPtyShells/tc-shell-exit'], + created: ['agenthost-terminal://shell/test-session-1/tc-shell-exit'], data: [], - finalized: [{ uri: 'agenthost-terminal://shell/copilotNonPtyShells/tc-shell-exit', exitCode: 127 }], + finalized: [{ uri: 'agenthost-terminal://shell/test-session-1/tc-shell-exit', exitCode: 127 }], }); }); diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index 051767803d8bf7..7ed1d5cff4f7d5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -715,7 +715,7 @@ export class AgentHostE2EServerLease { clientSeq: 9999, action: { type: 'session/abortTurn', session }, }); - await client.call('disposeSession', { session }, 30_000); + await client.call('disposeSession', { channel: session }, 30_000); } catch { /* best-effort */ } } client.close(); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index dbccb0a96c7068..64b93d5cba936c 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -235,7 +235,7 @@ suite('mapSessionEvents — history replay', () => { { type: ToolResultContentType.Text, text: 'hi\n' }, { type: ToolResultContentType.Terminal, - resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', + resource: 'agenthost-terminal://shell/test-session/tc-1', title: 'Run Shell Command', isPty: false, result: { exitCode: 0, preview: 'hi\n' }, @@ -270,7 +270,7 @@ suite('mapSessionEvents — history replay', () => { assert.strictEqual(part.toolCall.success, true); assert.deepStrictEqual(part.toolCall.content?.find(content => content.type === ToolResultContentType.Terminal), { type: ToolResultContentType.Terminal, - resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', + resource: 'agenthost-terminal://shell/test-session/tc-1', title: 'Run Shell Command', isPty: false, result: { exitCode: 127 }, @@ -746,7 +746,7 @@ suite('appendSdkToolResultContent', () => { const result = appendSdkToolResultContent(content, [ { type: 'shell_exit', shellId: '0', exitCode: 2, outputPreview: 'boom\n', outputTruncated: false }, - ], { toolCallId: 'tc-1', title: 'Run Shell Command' }); + ], { session: AgentSession.uri('copilot', 'test-session'), toolCallId: 'tc-1', title: 'Run Shell Command' }); assert.deepStrictEqual(result, { shellId: '0', result: { exitCode: 2, preview: 'boom\n', truncated: false } }); assert.deepStrictEqual(content, [ From 7771a1aaada5a4b69468fe6f46abc88241c5a170 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 23 Jul 2026 00:23:53 -0700 Subject: [PATCH 15/16] change ahp version to be proper --- src/vs/platform/agentHost/common/state/protocol/.ahp-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 186ce539743b8e..06a2da5b4dd2cc 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -8365a59a +1ed58e31 From acd86b2e1740792815357d254d2a5fa5fe3ec409 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 23 Jul 2026 00:35:07 -0700 Subject: [PATCH 16/16] agentHost: regenerate AHP completion types --- .../protocol/channels-session/commands.ts | 9 ------ .../copilotSlashCommandCompletionProvider.ts | 8 +++-- .../agentHost/agentHostSessionHandler.ts | 8 ++--- .../agentHostChatContribution.test.ts | 30 +++++++++++++++++++ 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index ad1c4f4482bc0c..156ff423986987 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -287,15 +287,6 @@ export interface CompletionItem { */ insertText: string; - /** - * Optional display label for the completion item. When omitted, the client - * SHOULD display {@link insertText}. Provide an explicit label when the - * inserted text differs from the label shown in the picker — e.g. a pure - * action item that inserts nothing (`insertText: ''`) but should still be - * shown to the user. - */ - label?: string; - /** * If defined, the start of the range in the input's `text` that is replaced * by `insertText`. The range is the half-open interval diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index b1a3c7f7e219ae..711c9b0f2c4a87 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -7,7 +7,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { AgentSession } from '../../common/agentService.js'; import { CompletionItem, CompletionItemKind, CompletionsParams } from '../../common/state/protocol/commands.js'; import { Customization, CustomizationType, DirectoryCustomization, MessageAttachmentKind, PluginCustomization, SkillCustomization } from '../../common/state/protocol/state.js'; -import { toCommandCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; +import { getCompletionAction, toCommandCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; import { getCopilotConfigSlashCommandItems, ICopilotConfigSlashCommandState, isCopilotConfigSlashCommand } from '../../common/copilotConfigSlashCommands.js'; import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from '../agentHostCompletions.js'; import { extractLeadingSlashToken, extractWhitespaceDelimitedSlashToken } from '../agentHostSlashCompletion.js'; @@ -200,7 +200,6 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti for (const item of getCopilotConfigSlashCommandItems(typed, configState)) { completionItems.push({ insertText: item.insertText, - label: item.label, rangeStart, rangeEnd, attachment: { @@ -217,7 +216,10 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti } } - return completionItems.sort((a, b) => (a.label ?? a.insertText).localeCompare(b.label ?? b.insertText)); + const getSortText = (item: CompletionItem): string => { + return getCompletionAction(item.attachment._meta) ? item.attachment.label : item.insertText; + }; + return completionItems.sort((a, b) => getSortText(a).localeCompare(getSortText(b))); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index fae3e0a870c931..cb96aa3d40588a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -885,13 +885,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return this._config.connection.getCompletionTriggerCharacters(); } - private _createCompletionItem(raw: AhpCompletionItem, text: string, attachment: IChatInputCompletionItem['attachment']): IChatInputCompletionItem { + private _createCompletionItem(raw: AhpCompletionItem, text: string, attachment: IChatInputCompletionItem['attachment'], label?: string): IChatInputCompletionItem { const item: Mutable = { insertText: raw.insertText, attachment }; - if (raw.label !== undefined) { - item.label = raw.label; + if (label !== undefined) { + item.label = label; } if (raw.rangeStart !== undefined) { item.start = offsetToPosition(text, raw.rangeStart); @@ -913,7 +913,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC command: completionMeta.command, description: completionMeta.description ?? '', ...(attachment._meta !== undefined && { _meta: attachment._meta }), - }); + }, attachment.label !== raw.insertText ? attachment.label : undefined); } if (completionMeta?.kind === 'skill') { return this._createCompletionItem(raw, text, { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 857bc43b673ae7..dcad5457becd86 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -9655,6 +9655,36 @@ suite('AgentHostChatContribution', () => { }); }); + test('uses a command attachment label when it differs from the inserted text', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + + (agentHostService as unknown as { completions: (p: CompletionsParams) => Promise }).completions = async () => ({ + items: [ + { + insertText: '', + attachment: { + type: MessageAttachmentKind.Simple, + label: '/yolo on', + _meta: { + command: 'yolo', + description: 'Set permissions to bypass approvals', + action: { applyConfig: { autoApprove: 'autoApprove' } }, + }, + }, + }, + ], + }); + + const result = await sessionHandler.provideChatInputCompletions( + URI.from({ scheme: 'agent-host-copilot', path: '/abc' }), + { text: '/y', offset: 2 }, + CancellationToken.None, + ); + + assert.strictEqual(result?.items[0].label, '/yolo on'); + assert.strictEqual(result?.items[0].insertText, ''); + }); + test('returns undefined when the request is cancelled', async () => { const { sessionHandler, agentHostService } = createContribution(disposables);