diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 4076cdf14429b4..186ce539743b8e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -ea279d99 +8365a59a diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index d39a4a88c3d982..96594718600bac 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -1344,7 +1344,6 @@ export const enum ToolResultContentType { Resource = 'resource', FileEdit = 'fileEdit', Terminal = 'terminal', - TerminalComplete = 'terminalComplete', Subagent = 'subagent', } @@ -1402,6 +1401,11 @@ export interface ToolResultFileEditContent extends FileEdit { * Clients can subscribe to the terminal's URI to stream its output in real * time, providing live feedback while a tool is executing. * + * When the command exits, {@link result} is filled in on the completed + * result, retaining the outcome for clients that did not subscribe. This + * records the command's exit, not the terminal's — the terminal may keep + * running afterwards. + * * @category Tool Result Content */ export interface ToolResultTerminalContent { @@ -1410,34 +1414,31 @@ export interface ToolResultTerminalContent { resource: URI; /** Display title for the terminal content */ title: string; + /** + * Whether this terminal-style resource is backed by a pseudoterminal. + * When `false`, output is plain text and clients do not need to parse + * VT sequences. + */ + isPty?: boolean; + /** Outcome of the command, present once it has exited. */ + result?: TerminalCommandResult; } /** - * Record of a command executed by a terminal-style tool (e.g. a shell tool), - * appended to the tool result when the command exits. - * - * This records the command's exit, not the terminal's — the terminal may - * keep running afterwards. - * - * When live output was exposed through a terminal channel (a - * {@link ToolResultTerminalContent} block in the same tool result), - * {@link resource} identifies that channel; otherwise this block stands alone - * as the retained command result. + * Outcome of a command run in a terminal-style tool, filled in on + * {@link ToolResultTerminalContent.result} once the command exits. * * @category Tool Result Content */ -export interface ToolResultTerminalCompleteContent { - type: ToolResultContentType.TerminalComplete; - /** - * URI of the `ahp-terminal:` channel that carried live output for this - * command, if one was exposed. - */ - resource?: URI; +export interface TerminalCommandResult { /** Exit code from the completed command, if reported by the runtime */ exitCode?: number; - /** Working directory where the command was executed */ - cwd?: URI; - /** Preview of the command's output, if available */ + /** + * Preview of the command's output, for clients that are not subscribed + * to the terminal or that arrive after it is disposed. When `isPty` is + * `true` the preview may contain VT sequences; when `false` it is plain + * text. + */ preview?: string; /** Whether `preview` is known to be incomplete or truncated */ truncated?: boolean; @@ -1471,8 +1472,8 @@ export interface ToolResultSubagentContent { * Mirrors the content blocks in MCP `CallToolResult.content`, plus * `ToolResultResourceContent` for lazy-loading large results, * `ToolResultFileEditContent` for file edit diffs, - * `ToolResultTerminalContent` for live terminal output, - * `ToolResultTerminalCompleteContent` for terminal-style completion metadata, and + * `ToolResultTerminalContent` for live terminal output and + * command completion metadata, and * `ToolResultSubagentContent` for tool-spawned worker chats (AHP extensions). * * @category Tool Result Content @@ -1483,5 +1484,4 @@ export type ToolResultContent = | ToolResultResourceContent | ToolResultFileEditContent | ToolResultTerminalContent - | ToolResultTerminalCompleteContent | ToolResultSubagentContent; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index ff7d4a69595f02..ad1c4f4482bc0c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -83,13 +83,21 @@ export interface CreateSessionParams extends BaseParams { */ workingDirectories?: URI[]; /** - * The primary working directory for the session's **default chat** — the - * distinguished root that chat is centered on (see - * {@link ChatState.primaryWorkingDirectory}). A session has no primary of its - * own; this seeds the default chat's primary. When set, it MUST be one of - * {@link workingDirectories}. A client SHOULD supply this when the agent - * advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a - * host MAY reject creation that omits it, or fall back to the first entry of + * The primary working directory for the session's **default chat**. + * + * A session has no primary of its own — primary is a per-chat notion (see + * {@link ChatState.primaryWorkingDirectory}). But `createSession` implicitly + * creates the session's default chat, and there is no separate `createChat` + * call to carry that chat's create-time fields. This field is therefore the + * only place a client can designate the **default chat's** primary at birth; + * it is copied into that chat's read-only `primaryWorkingDirectory`. For any + * non-default chat, pass {@link CreateChatParams.primaryWorkingDirectory} + * instead. + * + * When set, it MUST be one of {@link workingDirectories}. A client SHOULD + * supply this when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + * reject creation that omits it, or fall back to the first entry of * `workingDirectories`. Ignored for forked sessions (a fork inherits the * source session's chats and their primaries). */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts index d5eb4960a57db1..4c6f5afa74157d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-terminal/state.ts @@ -108,6 +108,12 @@ export interface TerminalState { * are absent in the normal idle state. */ supportsCommandDetection?: boolean; + /** + * Whether this terminal-style resource is backed by a pseudoterminal. + * When `false`, output is plain text and clients do not need to parse + * VT sequences. + */ + isPty?: boolean; } // ─── Terminal Content Parts ────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index a8033ad07f04f1..25a04d8ed36015 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -88,7 +88,7 @@ export { type ToolCallContributor, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, - type ToolResultTerminalCompleteContent, + type TerminalCommandResult, type ToolResultSubagentContent, type ToolResultTerminalContent, type ToolResultTextContent, diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index 02abd2613d09c8..4c39648b515cc1 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -125,6 +125,10 @@ export interface IAgentHostTerminalManager { getTerminalInfos(): TerminalInfo[]; getTerminalState(uri: string): TerminalState | undefined; getDefaultShell(): Promise; + createOutputTerminal(uri: string, options: { title: string; claim: TerminalClaim }): void; + appendOutputTerminalData(uri: string, data: string): void; + resetOutputTerminal(uri: string): void; + finalizeOutputTerminal(uri: string, exitCode: number | undefined): void; } // node-pty is loaded dynamically to avoid bundling issues in non-node environments @@ -169,6 +173,20 @@ interface IManagedTerminal { terminalQueryFilterState: ITerminalQueryFilterState; } +/** + * A lightweight output-only terminal channel: no PTY behind it, plain-text + * content appended by its owner (e.g. runtime-executed shell tools). Served + * to subscribers with `isPty: false` so clients skip VT parsing. + */ +interface IOutputTerminal { + readonly uri: string; + title: string; + content: TerminalContentPart[]; + contentSize: number; + claim: TerminalClaim; + exitCode?: number; +} + /** * Manages terminal processes for the agent host. Each terminal is backed by * a node-pty instance and identified by a protocol URI. @@ -181,6 +199,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe declare readonly _serviceBrand: undefined; private readonly _terminals = new Map(); + private readonly _outputTerminals = new Map(); constructor( @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @@ -229,6 +248,16 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Get the full state for a terminal (for subscribe snapshots). */ getTerminalState(uri: string): TerminalState | undefined { + const outputTerminal = this._outputTerminals.get(uri); + if (outputTerminal) { + return { + title: outputTerminal.title, + content: outputTerminal.content, + exitCode: outputTerminal.exitCode, + claim: outputTerminal.claim, + isPty: false, + }; + } const terminal = this._terminals.get(uri); if (!terminal) { return undefined; @@ -242,6 +271,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe exitCode: terminal.exitCode, claim: terminal.claim, supportsCommandDetection: terminal.commandTracker?.detectionAvailableEmitted, + isPty: true, }; } @@ -520,7 +550,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Get accumulated scrollback content for a terminal as raw text. */ getContent(uri: string): string | undefined { - const terminal = this._terminals.get(uri); + const terminal = this._terminals.get(uri) ?? this._outputTerminals.get(uri); if (!terminal) { return undefined; } @@ -529,12 +559,12 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Get the current claim for a terminal. */ getClaim(uri: string): TerminalClaim | undefined { - return this._terminals.get(uri)?.claim; + return (this._terminals.get(uri) ?? this._outputTerminals.get(uri))?.claim; } /** Check whether a terminal exists. */ hasTerminal(uri: string): boolean { - return this._terminals.has(uri); + return this._terminals.has(uri) || this._outputTerminals.has(uri); } /** Whether the terminal has shell integration active for command detection. */ @@ -545,7 +575,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe /** Get the exit code for a terminal, or undefined if still running. */ getExitCode(uri: string): number | undefined { - return this._terminals.get(uri)?.exitCode; + return (this._terminals.get(uri) ?? this._outputTerminals.get(uri))?.exitCode; } /** Resize a terminal. */ @@ -743,7 +773,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } /** Append cleaned data to the terminal's structured content array. */ - private _appendToContent(managed: IManagedTerminal, data: string): void { + private _appendToContent(managed: { content: TerminalContentPart[]; contentSize: number }, data: string): void { const tail = managed.content.length > 0 ? managed.content[managed.content.length - 1] : undefined; if (tail?.type === 'command' && !tail.isComplete) { @@ -766,7 +796,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } /** Trim content parts to stay within the rolling buffer limit. */ - private _trimContent(managed: IManagedTerminal): void { + private _trimContent(managed: { content: TerminalContentPart[]; contentSize: number }): void { const maxSize = 100_000; const targetSize = 80_000; if (managed.contentSize <= maxSize) { @@ -790,8 +820,73 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } } + /** + * Create an output-only terminal channel. Unlike {@link createTerminal} + * there is no PTY behind it: the owner appends plain-text output via + * {@link appendOutputTerminalData}. The channel is not announced on the + * root terminal list — clients discover it through the tool result's + * terminal content block and subscribe to its URI. + */ + createOutputTerminal(uri: string, options: { title: string; claim: TerminalClaim }): void { + if (this._terminals.has(uri) || this._outputTerminals.has(uri)) { + throw new Error(`Terminal already exists: ${uri}`); + } + this._outputTerminals.set(uri, { + uri, + title: options.title, + content: [], + contentSize: 0, + claim: options.claim, + }); + } + + /** Append plain-text data to an output-only terminal and stream it to subscribers. */ + appendOutputTerminalData(uri: string, data: string): void { + const terminal = this._outputTerminals.get(uri); + if (!terminal || data.length === 0) { + return; + } + this._appendToContent(terminal, data); + this._trimContent(terminal); + this._stateManager.dispatchServerAction(uri, { + type: ActionType.TerminalData, + data, + }); + } + + /** Clear an output-only terminal's content (e.g. when cumulative source output was rewritten). */ + resetOutputTerminal(uri: string): void { + const terminal = this._outputTerminals.get(uri); + if (!terminal) { + return; + } + terminal.content = []; + terminal.contentSize = 0; + this._stateManager.dispatchServerAction(uri, { + type: ActionType.TerminalCleared, + }); + } + + /** Record the command's exit on an output-only terminal and notify subscribers. */ + finalizeOutputTerminal(uri: string, exitCode: number | undefined): void { + const terminal = this._outputTerminals.get(uri); + if (!terminal || terminal.exitCode !== undefined) { + return; + } + if (exitCode !== undefined) { + terminal.exitCode = exitCode; + this._stateManager.dispatchServerAction(uri, { + type: ActionType.TerminalExited, + exitCode, + }); + } + } + /** Dispose a terminal: kill the process and remove it. */ disposeTerminal(uri: string): void { + if (this._outputTerminals.delete(uri)) { + return; + } const terminal = this._terminals.get(uri); if (terminal) { this._terminals.delete(uri); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 85a5596c11ef9a..63a592f42b3b4e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -54,6 +54,7 @@ import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; import { parseLeadingSlashCommand } from '../../common/agentHostSlashCommand.js'; import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; +import { NonPtyShellTerminalStreams } from './copilotNonPtyShellTerminals.js'; import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; @@ -658,6 +659,8 @@ export class CopilotAgentSession extends Disposable { private readonly _launchPlan: CopilotSessionLaunchPlan; private readonly _isLaunchTokenStillCurrent: () => boolean; private readonly _shellManager: ShellManager | undefined; + /** Streams runtime-executed shell output into output-only (non-pty) terminal channels. */ + private readonly _nonPtyShellTerminals: NonPtyShellTerminalStreams; private readonly _workingDirectory: URI | undefined; private readonly _customizationDirectory: URI | undefined; private readonly _serverToolHost: IAgentServerToolHost | undefined; @@ -735,6 +738,7 @@ export class CopilotAgentSession extends Disposable { this._launchPlan = options.launchPlan; this._isLaunchTokenStillCurrent = options.isLaunchTokenCurrent ?? (() => true); this._shellManager = options.shellManager; + this._nonPtyShellTerminals = this._register(this._instantiationService.createInstance(NonPtyShellTerminalStreams, options.sessionUri)); this._workingDirectory = options.workingDirectory; this._customizationDirectory = options.customizationDirectory; this._serverToolHost = options.serverToolHost; @@ -3275,7 +3279,24 @@ export class CopilotAgentSession extends Disposable { if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } - appendSdkToolResultContent(content, e.data.result?.contents); + + // Attach the pty terminal reference for shell tools before folding in + // SDK result content, so a `shell_exit` lands its completion data on + // the terminal block (skip if any terminal block was already added + // while the tool was running). + if (isShellTool(tracked.toolName) && this._shellManager) { + const terminalUri = this._shellManager.getTerminalUriForToolCall(e.data.toolCallId); + if (terminalUri && !content.some(c => c.type === ToolResultContentType.Terminal)) { + content.push({ + type: ToolResultContentType.Terminal, + resource: terminalUri, + title: tracked.displayName, + }); + } + } + + const shellExitCode = appendSdkToolResultContent(content, e.data.result?.contents, { toolCallId: e.data.toolCallId, title: tracked.displayName }); + this._nonPtyShellTerminals.finalize(e.data.toolCallId, shellExitCode); const command = isString(tracked.parameters?.command) ? tracked.parameters.command : undefined; const filePaths = isEditTool(tracked.toolName, command) ? this._getEditFilePaths(tracked.parameters) : []; @@ -3290,19 +3311,6 @@ export class CopilotAgentSession extends Disposable { } } - // Add terminal content reference for shell tools (skip if already - // added during onDidAssociateTerminal while the tool was running) - if (isShellTool(tracked.toolName) && this._shellManager) { - const terminalUri = this._shellManager.getTerminalUriForToolCall(e.data.toolCallId); - if (terminalUri && !content.some(c => c.type === ToolResultContentType.Terminal && c.resource === terminalUri)) { - content.push({ - type: ToolResultContentType.Terminal, - resource: terminalUri, - title: tracked.displayName, - }); - } - } - this._emitAction({ type: ActionType.ChatToolCallComplete, turnId: this._turnId, @@ -4253,6 +4261,29 @@ 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 { uri, created } = this._nonPtyShellTerminals.append(e.data.toolCallId, e.data.partialOutput, tracked.displayName); + if (created) { + tracked.content.push({ + type: ToolResultContentType.Terminal, + resource: uri, + title: tracked.displayName, + isPty: false, + }); + this._emitAction({ + type: ActionType.ChatToolCallContentChanged, + turnId: this._turnId, + toolCallId: e.data.toolCallId, + content: tracked.content, + }, tracked.parentToolCallId); + } })); this._register(wrapper.onToolProgress(e => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts new file mode 100644 index 00000000000000..908a075b7a7993 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { TerminalClaimKind, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; +import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; + +/** + * Builds the terminal channel URI for a runtime-executed (non-pty) shell tool + * call. Keyed by tool call id so the URI is stable across live streaming and + * history replay of the same command. + */ +export function buildNonPtyShellTerminalUri(toolCallId: string): string { + return `agenthost-terminal://shell/copilotNonPtyShells/${toolCallId}`; +} + +interface INonPtyShellStream { + readonly uri: string; + /** The last cumulative snapshot written to the channel (cleared on finalize). */ + lastEmitted: string; + finalized: boolean; +} + +/** + * Streams output of SDK-runtime-executed shell tool calls into output-only + * AHP terminal channels. The runtime reports ANSI-stripped plain-text output + * via `tool.execution_partial_result` as cumulative snapshots (throttled and + * capped to the leading ~10KB with a trailing truncation marker); this class + * emits only the unseen suffix as `terminal/data` while the snapshot grows + * in place, and resets the channel when the snapshot was rewritten (e.g. the + * truncation marker changed), so subscribed clients receive live plain-text + * output (`isPty: false` — no VT parsing needed). + * + * Created once per session and disposed with it, matching the pty-backed + * `ShellManager` lifecycle. + */ +export class NonPtyShellTerminalStreams extends Disposable { + + private readonly _streams = new Map(); + + constructor( + private readonly _sessionUri: URI, + @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, + ) { + super(); + + this._register(toDisposable(() => { + for (const stream of this._streams.values()) { + this._terminalManager.disposeTerminal(stream.uri); + } + this._streams.clear(); + })); + } + + /** + * Appends the unseen suffix of `cumulativeOutput` to the tool call's + * output terminal, creating the channel on first call. Returns the channel + * URI and whether this call created it (so the caller can attach the + * terminal content block exactly once). + */ + append(toolCallId: string, cumulativeOutput: string, title: string): { uri: string; created: boolean } { + let stream = this._streams.get(toolCallId); + let created = false; + if (!stream) { + const uri = buildNonPtyShellTerminalUri(toolCallId); + const claim: TerminalSessionClaim = { + kind: TerminalClaimKind.Session, + session: this._sessionUri.toString(), + toolCallId, + }; + this._terminalManager.createOutputTerminal(uri, { title, claim }); + stream = { uri, lastEmitted: '', finalized: false }; + this._streams.set(toolCallId, stream); + created = true; + } + 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 (its ~10KB cap keeps leading lines and splices in a + // growing truncation marker). 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 command's exit on the tool call's output terminal, if one + * was created. Later partial results for the tool call are ignored. + */ + finalize(toolCallId: string, exitCode: number | undefined): void { + const stream = this._streams.get(toolCallId); + if (!stream || stream.finalized) { + return; + } + stream.finalized = true; + stream.lastEmitted = ''; + this._terminalManager.finalizeOutputTerminal(stream.uri, exitCode); + } +} diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 568c92a500c485..48fd3d124af7bb 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -14,7 +14,8 @@ import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { toToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; -import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type AgentSelection, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type AgentSelection, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type TerminalCommandResult, type ToolCallCompletedState, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { buildNonPtyShellTerminalUri } from './copilotNonPtyShellTerminals.js'; import { getInvocationMessage, getPastTenseMessage, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; import { buildSessionDbUri } from '../shared/fileEditTracker.js'; import { getMediaMime } from '../../../../base/common/mime.js'; @@ -45,20 +46,43 @@ function isSyntheticUserMessage(event: SessionEvent): boolean { return !!source && source.toLowerCase() !== 'user'; } -export function appendSdkToolResultContent(content: ToolResultContent[], sdkContents: readonly ToolExecutionCompleteContent[] | undefined): void { +/** + * Converts SDK `tool.execution_complete` content blocks into AHP tool result + * content. A `shell_exit` block becomes {@link TerminalCommandResult} data on + * the tool call's terminal content block; when no terminal block exists yet + * (e.g. history replay, where no live channel survives) and `terminal` is + * provided, a non-pty terminal block is synthesized so the outcome still + * renders from `result.preview`. Returns the `shell_exit` exit code, if any. + */ +export function appendSdkToolResultContent(content: ToolResultContent[], sdkContents: readonly ToolExecutionCompleteContent[] | undefined, terminal?: { toolCallId: string; title: string }): number | undefined { + let shellExitCode: number | undefined; for (const sdkContent of sdkContents ?? []) { switch (sdkContent.type) { - case 'shell_exit': - content.push({ - type: ToolResultContentType.TerminalComplete, + case 'shell_exit': { + shellExitCode = sdkContent.exitCode; + const result: TerminalCommandResult = { exitCode: sdkContent.exitCode, - ...(sdkContent.cwd !== undefined ? { cwd: URI.file(sdkContent.cwd).toString() } : {}), ...(sdkContent.outputPreview !== undefined ? { preview: sdkContent.outputPreview } : {}), ...(sdkContent.outputTruncated !== undefined ? { truncated: sdkContent.outputTruncated } : {}), - }); + }; + const terminalIndex = content.findIndex(c => c.type === ToolResultContentType.Terminal); + if (terminalIndex !== -1) { + const terminalBlock = content[terminalIndex] as ToolResultTerminalContent; + content[terminalIndex] = { ...terminalBlock, result }; + } else if (terminal) { + content.push({ + type: ToolResultContentType.Terminal, + resource: buildNonPtyShellTerminalUri(terminal.toolCallId), + title: terminal.title, + isPty: false, + result, + }); + } break; + } } } + return shellExitCode; } // ============================================================================= @@ -719,7 +743,7 @@ function makeCompletedToolCallPart( if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } - appendSdkToolResultContent(content, d.result?.contents); + appendSdkToolResultContent(content, d.result?.contents, { toolCallId: d.toolCallId, title: info.displayName }); // Restore file edit content references from the database. const edits = storedEdits?.get(d.toolCallId); diff --git a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts index 9255f113c5e82c..871a65b3bf2d15 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts @@ -794,3 +794,76 @@ suite('AgentHostTerminalManager – command detection integration', () => { ]); }); }); + +suite('AgentHostTerminalManager – output-only terminals', () => { + + const disposables = new DisposableStore(); + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + function createManager() { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); + const productService = { _serviceBrand: undefined, applicationName: 'vscode' } as IProductService; + const manager = disposables.add(new AgentHostTerminalManager(stateManager, logService, productService, configurationService)); + return { manager, stateManager }; + } + + test('streams appended data, snapshots state with isPty false, and records the exit', () => { + const { manager, stateManager } = createManager(); + const uri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-1'; + const claim: TerminalClaim = { kind: TerminalClaimKind.Session, session: 'agent-session://copilot/s1', toolCallId: 'tc-1' }; + const dispatched: StateAction[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + if (envelope.channel === uri) { + dispatched.push(envelope.action); + } + })); + + manager.createOutputTerminal(uri, { title: 'Run Shell Command', claim }); + manager.appendOutputTerminalData(uri, 'tick 1\n'); + manager.appendOutputTerminalData(uri, 'tick 2\n'); + manager.finalizeOutputTerminal(uri, 0); + manager.finalizeOutputTerminal(uri, 1); // recorded exit is immutable + + assert.deepStrictEqual(manager.getTerminalState(uri), { + title: 'Run Shell Command', + content: [{ type: 'unclassified', value: 'tick 1\ntick 2\n' }], + exitCode: 0, + claim, + isPty: false, + }); + assert.deepStrictEqual(dispatched, [ + { type: ActionType.TerminalData, data: 'tick 1\n' }, + { type: ActionType.TerminalData, data: 'tick 2\n' }, + { type: ActionType.TerminalExited, exitCode: 0 }, + ]); + assert.strictEqual(manager.hasTerminal(uri), true); + // Output channels are discovered through tool result content, not the root terminal list. + assert.deepStrictEqual(manager.getTerminalInfos(), []); + }); + + test('reset clears content and dispose removes the channel', () => { + const { manager, stateManager } = createManager(); + const uri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-2'; + const dispatched: StateAction[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + if (envelope.channel === uri) { + dispatched.push(envelope.action); + } + })); + + manager.createOutputTerminal(uri, { title: 'Bash', claim: { kind: TerminalClaimKind.Session, session: 'agent-session://copilot/s1' } }); + manager.appendOutputTerminalData(uri, 'old output'); + manager.resetOutputTerminal(uri); + manager.appendOutputTerminalData(uri, 'fresh output'); + + assert.deepStrictEqual(manager.getTerminalState(uri)?.content, [{ type: 'unclassified', value: 'fresh output' }]); + assert.deepStrictEqual(dispatched.map(action => action.type), [ActionType.TerminalData, ActionType.TerminalCleared, ActionType.TerminalData]); + + manager.disposeTerminal(uri); + assert.strictEqual(manager.hasTerminal(uri), false); + assert.strictEqual(manager.getTerminalState(uri), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index e8bd148517ea34..d08bf060c0527f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -212,6 +212,10 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { getTerminalInfos(): [] { return []; } getTerminalState(): undefined { return undefined; } async getDefaultShell(): Promise { return '/bin/bash'; } + createOutputTerminal(): void { } + appendOutputTerminalData(): void { } + resetOutputTerminal(): void { } + finalizeOutputTerminal(): void { } } class TestCopilotApiService implements ICopilotApiService { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index bfffb122abfadc..ec665194e9d62f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -29,12 +29,15 @@ import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService, type ISessionDatabase } from '../../common/sessionDataService.js'; import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction } from '../../common/state/sessionActions.js'; import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, createSessionState, mergeSessionWithDefaultChat, readUsageInfoMeta, SessionStatus, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { TerminalClaimKind } from '../../common/state/protocol/state.js'; import { CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization } from '../../common/state/protocol/channels-session/state.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; +import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystemNotification.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; @@ -367,6 +370,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { mockSession: MockCopilotSession; signals: AgentSignal[]; waitForSignal: (predicate: (signal: AgentSignal) => boolean) => Promise; + terminalManager: TestAgentHostTerminalManager; sessionConfigUpdates: ReadonlyArray<{ session: string; patch: Record }>; setConfigValue: (key: string, value: unknown) => void; setRootValue: (key: string, value: unknown) => void; @@ -513,6 +517,8 @@ async function createAgentSession(disposables: DisposableStore, options?: { if (options?.environmentServiceRegistration !== 'none') { services.set(INativeEnvironmentService, environmentService); } + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + services.set(IAgentHostTerminalManager, terminalManager); const instantiationService = disposables.add(new InstantiationService(services)); const session = disposables.add(instantiationService.createInstance( @@ -544,6 +550,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { mockSession, signals, waitForSignal, + terminalManager, sessionConfigUpdates, setConfigValue: (key, value) => { configValues[key] = value; }, setRootValue: (key, value) => { rootValues[key] = value; }, @@ -2916,6 +2923,136 @@ suite('CopilotAgentSession', () => { assert.strictEqual((toolStart.action as ChatToolCallStartAction).intention, 'List files in the repo root'); }); + test('tool partial results stream into an output-only terminal channel', async () => { + const { session, mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); + session.resetTurnState('turn-stream'); + + const terminalUri = 'agenthost-terminal://shell/copilotNonPtyShells/tc-stream'; + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-stream', + toolName: 'bash', + 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/copilotNonPtyShells/tc-rewrite'; + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-rewrite', + toolName: 'bash', + arguments: { command: 'yes' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-rewrite', + partialOutput: 'tick 1\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + // Past its output cap the runtime keeps leading lines and splices in + // a truncation marker, so the snapshot stops being prefix-stable. + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-rewrite', + partialOutput: 'tick 1\n[...truncated 42 lines...]\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-rewrite', + partialOutput: 'tick 1\n[...truncated 99 lines...]\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + + assert.deepStrictEqual({ data: terminalManager.outputTerminalData, resets: terminalManager.outputTerminalResets }, { + data: [ + { uri: terminalUri, data: 'tick 1\n' }, + { uri: terminalUri, data: '[...truncated 42 lines...]\n' }, + { uri: terminalUri, data: 'tick 1\n[...truncated 99 lines...]\n' }, + ], + resets: [terminalUri], + }); + }); + + test('tool partial results for untracked tools are ignored', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + + 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 }); @@ -2997,9 +3134,14 @@ suite('CopilotAgentSession', () => { assert.strictEqual(action.result.success, true); assert.deepStrictEqual(action.result.content, [ { type: ToolResultContentType.Text, text: 'command not found\n' }, - { type: ToolResultContentType.TerminalComplete, exitCode: 127, cwd: URI.file('/repo').toString() }, + { + type: ToolResultContentType.Terminal, + resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-shell-exit', + title: 'Run Shell Command', + isPty: false, + result: { exitCode: 127 }, + }, ]); - assert.ok(!action.result.content?.some(content => content.type === ToolResultContentType.Terminal)); } }); diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index d284190bf084a3..62710ac19e414e 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -88,6 +88,10 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { getTerminalInfos(): TerminalInfo[] { return []; } getTerminalState(): undefined { return undefined; } async getDefaultShell(): Promise { return this.defaultShell; } + createOutputTerminal(): void { } + appendOutputTerminalData(): void { } + resetOutputTerminal(): void { } + finalizeOutputTerminal(): void { } fireCommandFinished(event: ICommandFinishedEvent): void { this._onCommandFinished.fire(event); } fireData(data: string): void { this._onData.fire(data); } fireExit(exitCode: number): void { this._onExit.fire(exitCode); } diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index bf0cecf04ba267..3b410dae6bf08b 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -4,12 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { AgentSession } from '../../common/agentService.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart } from '../../common/state/sessionState.js'; -import { mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart, type ToolResultContent } from '../../common/state/sessionState.js'; +import { appendSdkToolResultContent, mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; import { toSessionEvents, type ISessionEvent } from './copilotTestEvents.js'; suite('mapSessionEvents — history replay', () => { @@ -234,7 +233,13 @@ suite('mapSessionEvents — history replay', () => { if (part.toolCall.status !== ToolCallStatus.Completed) { return; } assert.deepStrictEqual(part.toolCall.content, [ { type: ToolResultContentType.Text, text: 'hi\n' }, - { type: ToolResultContentType.TerminalComplete, exitCode: 0, cwd: URI.file('/repo').toString(), preview: 'hi\n' }, + { + type: ToolResultContentType.Terminal, + resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', + title: 'Run Shell Command', + isPty: false, + result: { exitCode: 0, preview: 'hi\n' }, + }, ]); }); @@ -263,8 +268,13 @@ suite('mapSessionEvents — history replay', () => { assert.strictEqual(part.toolCall.status, ToolCallStatus.Completed); if (part.toolCall.status !== ToolCallStatus.Completed) { return; } assert.strictEqual(part.toolCall.success, true); - assert.ok(part.toolCall.content?.some(content => content.type === ToolResultContentType.TerminalComplete && content.exitCode === 127)); - assert.ok(!part.toolCall.content?.some(content => content.type === ToolResultContentType.Terminal)); + assert.deepStrictEqual(part.toolCall.content?.find(content => content.type === ToolResultContentType.Terminal), { + type: ToolResultContentType.Terminal, + resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', + title: 'Run Shell Command', + isPty: false, + result: { exitCode: 127 }, + }); }); test('restores best-effort model, fallback agent, and attachments onto user messages', async () => { @@ -724,3 +734,28 @@ suite('mapSessionEvents — subagent routing', () => { }); }); }); + +suite('appendSdkToolResultContent', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('folds shell_exit into an existing terminal block instead of adding a second one', () => { + const content: ToolResultContent[] = [ + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/abc', title: 'Bash' }, + ]; + + const exitCode = appendSdkToolResultContent(content, [ + { type: 'shell_exit', shellId: '0', exitCode: 2, outputPreview: 'boom\n', outputTruncated: false }, + ], { toolCallId: 'tc-1', title: 'Run Shell Command' }); + + assert.strictEqual(exitCode, 2); + assert.deepStrictEqual(content, [ + { + type: ToolResultContentType.Terminal, + resource: 'agenthost-terminal://shell/abc', + title: 'Bash', + result: { exitCode: 2, preview: 'boom\n', truncated: false }, + }, + ]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts b/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts index a6b42f7459b816..bdfce7fb3e6458 100644 --- a/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts @@ -25,6 +25,10 @@ export class TestAgentHostTerminalManager extends Disposable implements IAgentHo readonly created: CreateTerminalParams[] = []; readonly sentTexts: { uri: string; data: string }[] = []; readonly disposedTerminals: string[] = []; + readonly outputTerminalsCreated: { uri: string; title: string; claim: TerminalClaim }[] = []; + readonly outputTerminalData: { uri: string; data: string }[] = []; + readonly outputTerminalResets: string[] = []; + readonly outputTerminalsFinalized: { uri: string; exitCode: number | undefined }[] = []; /** Resolves once a command-finished listener is registered (i.e. a command is running). */ readonly commandFinishedListenerRegistered = new DeferredPromise(); @@ -59,5 +63,9 @@ export class TestAgentHostTerminalManager extends Disposable implements IAgentHo getTerminalInfos(): TerminalInfo[] { return []; } getTerminalState(): TerminalState | undefined { return undefined; } async getDefaultShell(): Promise { return this.defaultShell; } + createOutputTerminal(uri: string, options: { title: string; claim: TerminalClaim }): void { this.outputTerminalsCreated.push({ uri, title: options.title, claim: options.claim }); } + appendOutputTerminalData(uri: string, data: string): void { this.outputTerminalData.push({ uri, data }); } + resetOutputTerminal(uri: string): void { this.outputTerminalResets.push(uri); } + finalizeOutputTerminal(uri: string, exitCode: number | undefined): void { this.outputTerminalsFinalized.push({ uri, exitCode }); } fireCommandFinished(event: ICommandFinishedEvent): void { this._onCommandFinished.fire(event); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 0ac5df5b8c0c73..c7292b17c91d15 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -91,7 +91,7 @@ import { buildHostLocalEventsPath } from '../../copilotCliEventsUri.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; import { IAgentHostImportConversationStore } from './agentHostImportConversationStore.js'; -import { activeTurnToProgress, BOOLEAN_TRUE_OPTION_ID, completedToolCallToEditParts, completedToolCallToSerialized, convertProtocolAnswers, convertProtocolPlanReviewResult, createInputRequestCarousel, createInputRequestPlanReview, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContentUri, getUrlInputRequestPresentation, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rewriteAgentHostLinkTarget, stringOrMarkdownToString, systemNotificationToChatPart, toolCallAuthenticationServer, toolCallConfirmationMessages, toolCallStateToInvocation, toolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, type IAgentHostToolInvocationOptions, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, BOOLEAN_TRUE_OPTION_ID, completedToolCallToEditParts, completedToolCallToSerialized, convertProtocolAnswers, convertProtocolPlanReviewResult, createInputRequestCarousel, createInputRequestPlanReview, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContentUri, getTerminalContentIsPty, 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 }; @@ -3299,6 +3299,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC language: 'shellscript', terminalToolSessionId: sessionId, terminalCommandUri, + isPty: getTerminalContentIsPty(tc.content) ?? existing?.isPty, terminalCommandId: identityChanged ? undefined : existing?.terminalCommandId, terminalCommandOutput: identityChanged ? undefined : existing?.terminalCommandOutput, terminalCommandState: identityChanged ? undefined : existing?.terminalCommandState, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 161ea6d1e4d3a6..37c5aee900e887 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -421,19 +421,25 @@ export function isSubagentTool(tc: ToolCallState): boolean { } /** - * Finds a terminal content block in a tool call's content array. - * Returns the terminal URI if found. + * The terminal content block of a tool call, if any. Single source of truth for + * {@link getTerminalContentUri} / {@link getTerminalContentIsPty}. */ +function getTerminalContent(content: ToolResultContent[] | undefined) { + return content?.find(isToolResultTerminalContent); +} + +/** Terminal URI of the tool call's terminal content block, if any. */ export function getTerminalContentUri(content: ToolResultContent[] | undefined): string | undefined { - if (!content) { - return undefined; - } - for (const block of content) { - if (block.type === ToolResultContentType.Terminal) { - return block.resource; - } - } - return undefined; + return getTerminalContent(content)?.resource; +} + +/** + * `isPty` flag of the tool call's terminal content block. `undefined` when there + * is no block or the flag is unset — callers treat `undefined` as "assume a real + * terminal" and gate only on the explicit `false` (agent-host non-pty channels). + */ +export function getTerminalContentIsPty(content: ToolResultContent[] | undefined): boolean | undefined { + return getTerminalContent(content)?.isPty; } /** @@ -1169,16 +1175,16 @@ function getTerminalOutput(tc: ToolCallState) { return undefined; } - const terminalComplete = tc.content?.find(isToolResultTerminalCompleteContent); + const terminalResult = tc.content?.find(isToolResultTerminalContent)?.result; // Prefer the structured terminal snapshot. Text content is a compatibility // fallback for older/restored results and can include legacy bookkeeping. - let text = terminalComplete?.preview; + let text = terminalResult?.preview; if (text === undefined) { const fallbackText = tc.content?.find(isToolResultTextContent)?.text; text = fallbackText === undefined ? undefined : stripLegacyTerminalExitMarkers(fallbackText); } - if (text === undefined || (!text && terminalComplete?.truncated !== true)) { + if (text === undefined || (!text && terminalResult?.truncated !== true)) { return undefined; } @@ -1188,7 +1194,7 @@ function getTerminalOutput(tc: ToolCallState) { // normalize to `\r\n` here. The replace is idempotent on already-CRLF input. return { text: text.replace(/\r?\n/g, '\r\n'), - ...(terminalComplete?.truncated !== undefined ? { truncated: terminalComplete.truncated } : {}), + ...(terminalResult?.truncated !== undefined ? { truncated: terminalResult.truncated } : {}), }; } @@ -1201,17 +1207,17 @@ function isToolResultTextContent(content: ToolResultContent): content is Extract } function getTerminalCommandState(tc: ToolCallState, fallbackSuccess?: boolean): IChatTerminalToolInvocationData['terminalCommandState'] | undefined { - const terminalComplete = tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running - ? tc.content?.find(isToolResultTerminalCompleteContent) + const terminalResult = tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running + ? tc.content?.find(isToolResultTerminalContent)?.result : undefined; - if (terminalComplete?.exitCode !== undefined) { - return { exitCode: terminalComplete.exitCode }; + if (terminalResult?.exitCode !== undefined) { + return { exitCode: terminalResult.exitCode }; } return fallbackSuccess === undefined ? undefined : { exitCode: fallbackSuccess ? 0 : 1 }; } -function isToolResultTerminalCompleteContent(content: ToolResultContent): content is Extract { - return content.type === ToolResultContentType.TerminalComplete; +function isToolResultTerminalContent(content: ToolResultContent): content is Extract { + return content.type === ToolResultContentType.Terminal; } function getTerminalLanguage(tc: ToolCallState) { @@ -1275,9 +1281,10 @@ function buildTerminalToolSpecificData( sessionResource: URI, existing?: IChatTerminalToolInvocationData, ): IChatTerminalToolInvocationData { - const terminalContentUri = (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) - ? getTerminalContentUri(tc.content) - : undefined; + const hasContent = tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed; + const terminalBlock = hasContent ? getTerminalContent(tc.content) : undefined; + const terminalContentUri = terminalBlock?.resource; + const terminalIsPty = terminalBlock?.isPty; const nextCommand = getTerminalInput(tc); const commandLine = nextCommand ? { ...existing?.commandLine, original: nextCommand } @@ -1296,6 +1303,9 @@ function buildTerminalToolSpecificData( ? makeAhpTerminalToolSessionId(terminalContentUri, sessionResource) : existing?.terminalToolSessionId, terminalCommandUri: terminalContentUri ? URI.parse(terminalContentUri) : existing?.terminalCommandUri, + // Never clobber a known value with a block that omits `isPty`; keep this + // merge rule identical to _reviveTerminalIfNeeded (the other producer). + isPty: terminalContentUri ? (terminalIsPty ?? existing?.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 9e74cd79b46eac..4a19cbdc70ccf3 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 @@ -704,6 +704,17 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._decoration.update(resolvedCommand); } + /** + * True for agent-host output-only (non-pty) shell channels, whose terminal + * instance exists only to render streamed output. Gates the Focus/Show + * Terminal action and the stale command-resource fallback — there is nothing + * interactive to focus. Only `isPty === false` is non-pty; `true` and + * `undefined` (local pty tool, or no block yet) both read as focusable. + */ + private get _isNonPtyTerminal(): boolean { + return this._terminalData.isPty === false; + } + /** * Rebuilds the ActionBar actions based on current toolbar state. */ @@ -725,7 +736,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._actionBarActions.add(action); actions.push(action); } - if (this._toolbarHasInstance) { + // Focus/Show applies only to a real interactive pty — see _isNonPtyTerminal. + if (this._toolbarHasInstance && !this._isNonPtyTerminal) { const focusLabel = this._toolbarIsHiddenTerminal ? localize('showTerminal', 'Show and Focus Terminal') : localize('focusTerminal', 'Focus Terminal'); @@ -1075,6 +1087,14 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } public async focusTerminal(): Promise { + // Non-pty output-only shell channels have a terminal instance only to render + // streamed output — there is nothing interactive to focus. Guard the whole + // method (not just the toolbar button) so every caller is covered, including + // the "Focus Most Recent Terminal" command, which resolves progress parts + // without an isPty filter. + if (this._isNonPtyTerminal) { + return; + } const instance = await this._ensureTerminalInstance(); type FocusChatInstanceTelemetryEvent = { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 3c3d04b4b13fd3..3355ee31a61dde 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -625,6 +625,13 @@ export interface IChatTerminalToolInvocationData { alternativeRecommendation?: string; language: string; terminalToolSessionId?: string; + /** + * Whether the terminal backing this invocation is a real pseudoterminal. + * `false` for agent-host output-only (non-pty) shell channels, where there is + * no interactive terminal to focus. Left unset for the local pty terminal tool + * (always a real pty) and for cards with no 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/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 3bb4abe32d912e..4eda91aceb98ea 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -11,7 +11,7 @@ import type { IMarkdownString } from '../../../../../../base/common/htmlContent. import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { McpAuthRequiredReason } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { fromAgentHostUri, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { buildSubagentChatUri, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatMarkdownContent, type IChatTerminalToolInvocationData, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, toolCallStateToPreparedInvocation as rawToolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, usageInfoToAutoModeResolution, usageInfoToQuotas, formatTurnResponseDetails, rewriteAgentHostLinkTarget, rewriteMarkdownLinks, type TurnModelLookup } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; @@ -1967,7 +1967,7 @@ suite('stateToProgressAdapter', () => { toolInput: 'gti status', content: [ { type: ToolResultContentType.Text, text: 'command not found\n' }, - { type: ToolResultContentType.TerminalComplete, exitCode: 127, cwd: URI.file('/repo').toString(), preview: 'preview only\n', truncated: true }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false, result: { exitCode: 127, preview: 'preview only\n', truncated: true } }, ], success: true, }); @@ -1994,7 +1994,7 @@ suite('stateToProgressAdapter', () => { toolInput: 'ehco hi', content: [ { type: ToolResultContentType.Text, text: 'bash: line 1: ehco: command not found\n' }, - { type: ToolResultContentType.TerminalComplete, exitCode: 127, cwd: URI.file('/repo').toString() }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false, result: { exitCode: 127 } }, ], success: true, }); @@ -2014,13 +2014,41 @@ suite('stateToProgressAdapter', () => { assert.ok(!termData.terminalCommandOutput?.text.includes('shellId')); }); + test('ignores legacy terminalComplete blocks from old persisted state', () => { + const tc = createCompletedToolCall({ + _meta: { toolKind: 'terminal' }, + toolInput: 'pwd', + content: [ + { type: ToolResultContentType.Text, text: '/repo\n' }, + // Removed from the protocol in AHP 0.7.0; may linger in old persisted turns. + { type: 'terminalComplete', exitCode: 127, preview: 'stale preview\n' } as unknown as ToolResultContent, + ], + success: true, + }); + + const turn = createTurn({ + responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: tc } as ToolCallResponsePart], + }); + + const history = turnsToHistory(URI.file('/'), [turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + const termData = getSerializedTerminalData(serialized); + // The unknown block is skipped: output comes from Text content and + // the exit code from the tool's success flag. + assert.strictEqual(termData.terminalCommandOutput?.text, '/repo\r\n'); + assert.strictEqual(termData.terminalCommandState?.exitCode, 0); + }); + test('keeps zero terminal completion exit code as success for completed SDK shell tool history', () => { const tc = createCompletedToolCall({ _meta: { toolKind: 'terminal' }, toolInput: 'pwd', content: [ { type: ToolResultContentType.Text, text: '/repo\n' }, - { type: ToolResultContentType.TerminalComplete, exitCode: 0, cwd: URI.file('/repo').toString() }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false, result: { exitCode: 0 } }, ], success: true, }); @@ -2045,7 +2073,7 @@ suite('stateToProgressAdapter', () => { toolInput: 'pwd', content: [ { type: ToolResultContentType.Text, text: '/repo\n' }, - { type: ToolResultContentType.TerminalComplete, cwd: URI.file('/repo').toString() }, + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false, result: {} }, ], success: true, }); @@ -2081,6 +2109,41 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandUri.toString(), 'agenthost-terminal:/running-term'); }); + test('threads isPty from the terminal content block into invocation data', () => { + const nonPty = createToolCallState({ + _meta: { toolKind: 'terminal' }, + toolInput: 'ls -la', + content: [ + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/copilotNonPtyShells/tc-1', title: 'Run Shell Command', isPty: false }, + ], + }); + const nonPtyData = toolCallStateToInvocation(nonPty).toolSpecificData as { kind: 'terminal'; isPty?: boolean }; + assert.strictEqual(nonPtyData.isPty, false, 'non-pty block => isPty false (Focus/Show hidden, stale open suppressed)'); + + const pty = createToolCallState({ + _meta: { toolKind: 'terminal' }, + toolInput: 'npm test', + content: [ + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal:///pty-term', title: 'Terminal', isPty: true }, + ], + }); + const ptyData = toolCallStateToInvocation(pty).toolSpecificData as { kind: 'terminal'; isPty?: boolean }; + assert.notStrictEqual(ptyData.isPty, false, 'pty block => Focus/Show kept'); + + // A terminal block that omits isPty (pty custom-terminal / local tool) + // must read as undefined, NOT false — the "undefined = real pty" contract + // the Focus/stale gates depend on. + const implicit = createToolCallState({ + _meta: { toolKind: 'terminal' }, + toolInput: 'ls', + content: [ + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal:///implicit-term', title: 'Terminal' }, + ], + }); + const implicitData = toolCallStateToInvocation(implicit).toolSpecificData as { kind: 'terminal'; isPty?: boolean }; + assert.strictEqual(implicitData.isPty, undefined, 'block without isPty => undefined (real pty, Focus kept)'); + }); + test('finalize preserves terminal URI from content block', () => { const tc = createToolCallState({ _meta: { toolKind: 'terminal' }, @@ -2136,7 +2199,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 } }, ], }); @@ -2344,11 +2407,13 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n'); }); - test('preserves AHP terminal fields (terminalToolSessionId, terminalCommandUri) when refreshing output', () => { + test('preserves AHP terminal fields (terminalToolSessionId, terminalCommandUri, isPty) when refreshing output', () => { // Simulates the race where `_reviveTerminalIfNeeded` has populated // AHP terminal fields and a subsequent content change triggers // `updateRunningToolSpecificData`. The async-populated fields - // must survive the refresh. + // must survive the refresh — including `isPty`, which the Focus/stale + // gates depend on (a refresh dropping it, or using `||` instead of `??`, + // would let Focus/Show reappear mid-stream for a non-pty run). const tc = createToolCallState({ toolName: 'bash', toolInput: 'echo hi', @@ -2363,6 +2428,7 @@ suite('stateToProgressAdapter', () => { terminalToolSessionId: 'session-id-from-revive', terminalCommandUri: reviveUri, terminalCommandId: 'cmd-id-from-revive', + isPty: false, }; const runningTc: ToolCallRunningState = { @@ -2378,11 +2444,13 @@ suite('stateToProgressAdapter', () => { terminalCommandUri?: URI; terminalCommandId?: string; terminalCommandOutput?: { text: string }; + isPty?: boolean; }; assert.strictEqual(termData.terminalToolSessionId, 'session-id-from-revive'); assert.strictEqual(termData.terminalCommandUri, reviveUri); assert.strictEqual(termData.terminalCommandId, 'cmd-id-from-revive'); assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n'); + assert.strictEqual(termData.isPty, false, 'isPty from revive survives the refresh'); }); }); diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts index 4549c4a0fd26a0..039a11b5da0b5c 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts @@ -110,6 +110,10 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { private _supportsCommandDetection = false; get supportsCommandDetection(): boolean { return this._supportsCommandDetection; } + private _isPlainTextOutput = false; + /** Whether the channel is output-only plain text (`TerminalState.isPty === false`). Known once the process is ready. */ + get isPlainTextOutput(): boolean { return this._isPlainTextOutput; } + /** * Command IDs for sentinel commands that should be suppressed from shell * integration events. When the copilot shell tools fall back to sentinel- @@ -164,6 +168,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { this._supportsCommandDetection = true; this._onSupportsCommandDetection.fire(); } + this._isPlainTextOutput = state.isPty === false; this._replayContent(state.content); // 5. Track initial cwd @@ -427,6 +432,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { this._supportsCommandDetection = true; this._onSupportsCommandDetection.fire(); } + this._isPlainTextOutput = state.isPty === false; // Clear the terminal buffer before replaying to avoid duplicate // content. ESC[2J clears the screen, ESC[3J clears scrollback, diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts index 0b4adabbe0aea2..6181c4d1afea84 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts @@ -356,6 +356,20 @@ export class AgentHostTerminalService extends Disposable implements IAgentHostTe commandSource.connect(instance, pty); } + // Output-only channels (isPty false) carry plain text where a + // pty would emit TTY line endings; let xterm convert instead + // of treating the stream as raw TTY output. Mirrors the + // processTraits-driven option updates in TerminalInstance. + store.add(pty.onProcessReady(async () => { + if (!pty.isPlainTextOutput) { + return; + } + const xterm = await instance.xtermReadyPromise; + if (xterm) { + xterm.raw.options.convertEol = true; + } + })); + this._activePtys.set(key, { pty, clientId: connection.clientId }); return pty; },