diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 4076cdf14429b..186ce539743b8 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 d39a4a88c3d98..96594718600ba 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 ff7d4a69595f0..ad1c4f4482bc0 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 d5eb4960a57db..4c6f5afa74157 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 a8033ad07f04f..25a04d8ed3601 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 02abd2613d09c..6535a2fb965a7 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,19 @@ 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 { + 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 +198,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 +247,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 +270,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe exitCode: terminal.exitCode, claim: terminal.claim, supportsCommandDetection: terminal.commandTracker?.detectionAvailableEmitted, + isPty: true, }; } @@ -743,7 +772,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 +795,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 +819,72 @@ 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, { + 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 85a5596c11ef9..83c19abf09119 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'; @@ -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; @@ -3121,6 +3125,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); @@ -3275,7 +3282,39 @@ 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). + 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, { 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) { + 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 }; + } + } + } const command = isString(tracked.parameters?.command) ? tracked.parameters.command : undefined; const filePaths = isEditTool(tracked.toolName, command) ? this._getEditFilePaths(tracked.parameters) : []; @@ -3290,19 +3329,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, @@ -4253,6 +4279,30 @@ 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 (this._shellManager?.getTerminalUriForToolCall(e.data.toolCallId)) { + // Client-hosted pty shell — its terminal channel streams live output itself. + return; + } + const appended = this._nonPtyShellTerminals.append(e.data.toolCallId, e.data.partialOutput); + if (appended?.created) { + const { uri } = appended; + 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 0000000000000..9673f5b017acd --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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. 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(session: URI | string, toolCallId: string): string { + return `agenthost-terminal://shell/${encodeURIComponent(AgentSession.id(session))}/${encodeURIComponent(toolCallId)}`; +} + +interface INonPtyShellStream { + readonly uri: string; + readonly title: string; + created: boolean; + /** The last cumulative snapshot written to the channel. */ + lastEmitted: string; + finalized: boolean; +} + +/** + * 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. + */ +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 { + exitCode: Number(match[2]), + preview: text.slice(0, match.index), + }; +} + +export interface INonPtyShellToolCompletion { + readonly uri: string; + readonly result?: TerminalCommandResult; +} + +/** + * 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 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. + */ +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()) { + if (stream.created) { + 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). + */ + track(toolCallId: string, title: string): void { + if (!this._streams.has(toolCallId)) { + this._streams.set(toolCallId, { + uri: buildNonPtyShellTerminalUri(this._sessionUri, toolCallId), + title, + lastEmitted: '', + finalized: false, + created: false, + }); + } + } + + append(toolCallId: string, cumulativeOutput: string): { uri: string; created: boolean } | undefined { + const stream = this._streams.get(toolCallId); + if (!stream) { + return undefined; + } + const created = !stream.created; + if (created) { + this._createTerminal(toolCallId, stream); + } + if (stream.finalized || cumulativeOutput === stream.lastEmitted) { + return { uri: stream.uri, created }; + } + 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 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); + } + stream.lastEmitted = cumulativeOutput; + return { uri: stream.uri, created }; + } + + /** + * Records the process lifecycle information carried by tool completion. + * 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); + if (!stream) { + return undefined; + } + + const result = shellExit?.result ?? parseCompletedShell(toolOutput); + if (!result) { + return stream.created ? { uri: stream.uri } : undefined; + } + if (!stream.created) { + this._createTerminal(toolCallId, stream); + } + if (result.preview !== undefined) { + this.append(toolCallId, result.preview); + } + if (result.exitCode !== undefined) { + this._finalize(stream, result.exitCode); + } + return { uri: stream.uri, result }; + } + + private _finalize(stream: INonPtyShellStream, exitCode: number): void { + if (stream.finalized) { + return; + } + stream.finalized = true; + 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 568c92a500c48..4816cc976149b 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,49 @@ 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` outcome, if any, so + * the live path can settle the non-pty output channel from it. + */ +export interface ISdkShellExit { + readonly shellId: string; + readonly result: TerminalCommandResult; +} + +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) { - case 'shell_exit': - content.push({ - type: ToolResultContentType.TerminalComplete, + case 'shell_exit': { + 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 } : {}), - }); + }; + shellExit = { shellId: sdkContent.shellId, result }; + 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.session, terminal.toolCallId), + title: terminal.title, + isPty: false, + result, + }); + } break; + } } } + return shellExit; } // ============================================================================= @@ -719,7 +749,7 @@ function makeCompletedToolCallPart( if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } - appendSdkToolResultContent(content, d.result?.contents); + 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/agentHostTerminalManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts index 9255f113c5e82..70c7843bf11fe 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 }, + ]); + // Output channels are discovered through tool result content, not generic PTY terminal APIs. + assert.strictEqual(manager.hasTerminal(uri), false); + 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 e8bd148517ea3..d08bf060c0527 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 bfffb122abfad..4c6fb14bb761c 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -29,12 +29,16 @@ 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 { 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'; 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 +371,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 +518,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 +551,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,6 +2924,325 @@ 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/test-session-1/tc-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', + 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 => ({ + turnId: action.turnId, + toolCallId: action.toolCallId, + content: action.content, + })), [ + { + turnId: 'turn-stream', + toolCallId: 'tc-stream', + 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' }, + ]); + }); + + 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/test-session-1/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']); + // 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', + } 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('zero-partial shell completion creates, seeds, and finalizes the output channel', async () => { + const { mockSession, signals, terminalManager } = await createAgentSession(disposables); + + const terminalUri = 'agenthost-terminal://shell/test-session-1/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/test-session-1/tc-err', data: 'boom\n' }, + { uri: 'agenthost-terminal://shell/test-session-1/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/test-session-1/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.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' + )); + }); + + 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', + 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)); + assert.deepStrictEqual(terminalManager.outputTerminalsCreated, []); + + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-background', + partialOutput: 'late output\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + + 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({ + created: terminalManager.outputTerminalsCreated, + data: terminalManager.outputTerminalData, + finalized: terminalManager.outputTerminalsFinalized, + }, { created: [], data: [], finalized: [] }); + }); + + 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); + + 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 }); @@ -2973,7 +3300,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', @@ -2997,10 +3324,26 @@ 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/test-session-1/tc-shell-exit', + title: 'Run Shell Command', + isPty: false, + result: { exitCode: 127 }, + }, ]); - assert.ok(!action.result.content?.some(content => content.type === ToolResultContentType.Terminal)); } + // 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/test-session-1/tc-shell-exit'], + data: [], + finalized: [{ uri: 'agenthost-terminal://shell/test-session-1/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/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index d284190bf084a..62710ac19e414 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/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index 051767803d8bf..7ed1d5cff4f7d 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 bf0cecf04ba26..64b93d5cba936 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/test-session/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/test-session/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 result = appendSdkToolResultContent(content, [ + { type: 'shell_exit', shellId: '0', exitCode: 2, outputPreview: 'boom\n', outputTruncated: false }, + ], { 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, [ + { + 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 a6b42f7459b81..bdfce7fb3e645 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/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 7a28669582ea8..fae3e0a870c93 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -93,7 +93,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 }; @@ -214,6 +214,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] @@ -2664,6 +2669,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 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; @@ -2706,7 +2714,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, outputTerminalAttachment); updateRunningToolSpecificData(invocation, tc, opts.backendSession, this._config.connectionAuthority); } @@ -2717,7 +2725,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, outputTerminalAttachment); const fileEdits = finalizeToolInvocation(invocation, tc, opts.backendSession, this._config.connectionAuthority); if (fileEdits.length > 0) { opts.onFileEdits?.(tc, fileEdits); @@ -3331,21 +3339,28 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC invocation: ChatToolInvocation, tc: ToolCallState, backendSession: URI, + outputTerminalAttachment: IOutputTerminalAttachment, ): 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 && 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 : undefined; @@ -3362,6 +3377,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, @@ -3372,8 +3388,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 161ea6d1e4d3a..fa629d25943df 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'; @@ -424,16 +424,12 @@ 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 { - if (!content) { - return undefined; - } - for (const block of content) { - if (block.type === ToolResultContentType.Terminal) { - return block.resource; - } - } - return undefined; +function getTerminalContentUri(content: ToolResultContent[] | undefined): string | undefined { + return getTerminalContent(content)?.resource; +} + +export function getTerminalContent(content: ToolResultContent[] | undefined): Extract | undefined { + return content?.find(isToolResultTerminalContent); } /** @@ -1169,26 +1165,23 @@ function getTerminalOutput(tc: ToolCallState) { return undefined; } - const terminalComplete = tc.content?.find(isToolResultTerminalCompleteContent); + 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 = terminalComplete?.preview; - if (text === undefined) { + let text = terminalResult?.preview; + if (text === undefined && terminalContent?.isPty !== false) { 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; } - // 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'), - ...(terminalComplete?.truncated !== undefined ? { truncated: terminalComplete.truncated } : {}), + ...(terminalResult?.truncated !== undefined ? { truncated: terminalResult.truncated } : {}), }; } @@ -1201,17 +1194,47 @@ 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 + ? getTerminalCommandResult(tc) : undefined; - if (terminalComplete?.exitCode !== undefined) { - return { exitCode: terminalComplete.exitCode }; + 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 + // fabricating a successful process exit when none was reported. + return fallbackSuccess === false ? { exitCode: 1 } : undefined; } 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; +} + +/** + * 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) { @@ -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,7 @@ function buildTerminalToolSpecificData( ? makeAhpTerminalToolSessionId(terminalContentUri, sessionResource) : existing?.terminalToolSessionId, terminalCommandUri: terminalContentUri ? URI.parse(terminalContentUri) : existing?.terminalCommandUri, + isPty: terminalContent?.isPty ?? existing?.isPty, 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 9e74cd79b46ea..6e7c94829e55e 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,8 @@ class TerminalCommandDecoration extends Disposable { private _getHoverText(): string { const command = this._options.getResolvedCommand(); - const storedState = this._options.terminalData.terminalCommandState; - return getTerminalCommandDecorationTooltip(command, storedState) || ''; + const { effectiveCommand, storedState } = this._getDecorationInput(command); + return getTerminalCommandDecorationTooltip(effectiveCommand, storedState) || ''; } public update(command?: ITerminalCommand): void { @@ -201,9 +204,7 @@ class TerminalCommandDecoration extends Disposable { private _apply(decoration: HTMLElement, command: ITerminalCommand | undefined): void { const terminalData = this._options.terminalData; - let storedState = terminalData.terminalCommandState; - - if (command) { + if (terminalData.isPty !== false && command) { const existingState = terminalData.terminalCommandState ?? {}; terminalData.terminalCommandState = { ...existingState, @@ -211,15 +212,14 @@ 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 decorationState = getTerminalCommandDecorationState(command, storedState); - const tooltip = getTerminalCommandDecorationTooltip(command, storedState); + const { effectiveCommand, storedState } = this._getDecorationInput(command); + const decorationState = getTerminalCommandDecorationState(effectiveCommand, storedState); + const tooltip = getTerminalCommandDecorationTooltip(effectiveCommand, storedState); const isRunning = this._options.getIsRunning(); @@ -245,6 +245,22 @@ class TerminalCommandDecoration extends Disposable { } } + 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(); + storedState = exitCode === undefined ? storedState : { ...storedState, exitCode }; + return { + effectiveCommand: command?.exitCode === undefined && storedState?.exitCode !== undefined ? undefined : command, + storedState + }; + } + } /** @@ -292,6 +308,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 +377,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 +402,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart ChatTerminalToolOutputSection, () => this._ensureTerminalInstance(), () => this._getResolvedCommand(), + () => this._outputSource, () => this._terminalData.terminalCommandOutput, () => this._commandText, () => this._terminalData.terminalTheme, @@ -421,6 +441,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 +585,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 +603,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart isComplete, isSkipped, isRunningInBackground, - () => this.focusTerminal(), + this._terminalData.isPty === false ? undefined : () => this.focusTerminal(), )); this._thinkingCollapsibleWrapper = wrapper; @@ -613,6 +639,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 +717,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 +797,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 +1072,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 +1087,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 +1162,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 +1338,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 +1407,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 +1456,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 +1512,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 +1627,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 3c3d04b4b13fd..033c3c1e50d28 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 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; /** 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 93a769171d836..857bc43b673ae 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 @@ -5360,6 +5360,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); 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 3bb4abe32d912..66271d19680bc 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'; @@ -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\r\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('normalizes plain-text line endings for the detached terminal', () => { 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\r\nline2\r\nline3\r\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', () => { @@ -1967,7 +1966,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, }); @@ -1988,13 +1987,13 @@ suite('stateToProgressAdapter', () => { 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', 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, }); @@ -2010,8 +2009,35 @@ 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('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: 'legacy 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 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\r\n'); + assert.strictEqual(termData.terminalCommandState?.exitCode, 127); }); test('keeps zero terminal completion exit code as success for completed SDK shell tool history', () => { @@ -2020,7 +2046,7 @@ suite('stateToProgressAdapter', () => { 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, }); @@ -2039,13 +2065,13 @@ 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', 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, }); @@ -2061,7 +2087,32 @@ 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('running tool call with terminal content block sets terminalCommandUri', () => { @@ -2136,7 +2187,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/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts index 5b2698a964776..bcbd368d38a13 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 0000000000000..ff29cbf6f6ff6 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/agentHostOutputChannel.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * 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('') + .replace(/\r?\n/g, '\r\n'); + 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 0b4adabbe0aea..0618a77365eca 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. @@ -334,6 +338,13 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe return revive; } + attachOutputTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string): IDisposable { + 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 { const existing = this._revivedInstances.get(key); if (existing) { diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 6d39991038882..9607bc48bf8af 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, @@ -639,7 +643,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 +663,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 +685,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 a52e4565f3108..0d8eefd4ed369 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 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 ee6cee88e04cc..43fad3c6198a0 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\r\n'); + conn.fireAction(terminalUri, { type: ActionType.TerminalData, data: 'next\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\r\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 53528f9687ca4..4191b8cbef6dd 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;