Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1606,7 +1606,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
} else if (status === ToolCallStatus.Running || status === ToolCallStatus.PendingResultConfirmation) {
invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, this._config.connectionAuthority);
this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession);
updateRunningToolSpecificData(invocation, tc, this._config.connectionAuthority);
updateRunningToolSpecificData(invocation, tc, opts.backendSession, this._config.connectionAuthority);
}

if ((status === ToolCallStatus.Completed || status === ToolCallStatus.Cancelled) && !IChatToolInvocation.isComplete(invocation)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,82 @@ function getTerminalLanguage(tc: ToolCallState) {
return tc.toolName === 'powershell' ? 'powershell' : 'shellscript';
}

/**
* True if this tool call should render as a terminal pill in the chat UI.
*
* Combines three signals so the workbench renders consistently across every
* stage of the tool lifecycle:
*
* 1. `existingKind === 'terminal'` — preserve the prior render decision so a
* tool already set up as terminal stays terminal across snapshots.
* 2. `getToolKind(tc) === 'terminal'` — the always-available `_meta.toolKind`
* flag set by the event mapper for built-in `bash`/`powershell` SDK tools
* that never emit a {@link ToolResultContentType.Terminal} content block.
* 3. A `Terminal` content block in `tc.content` (Running/Completed only) —
* the AHP-side signal for the custom terminal tool (`agenthost-terminal:`
* URIs).
*
* Without (1) and (2) the live invocation would race against the async
* arrival of the Terminal block via `onDidAssociateTerminal`.
*/
function isTerminalToolCall(tc: ToolCallState, existingKind?: string): boolean {
if (existingKind === 'terminal') {
return true;
}
if (getToolKind(tc) === 'terminal') {
return true;
}
if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) {
return !!getTerminalContentUri(tc.content);
}
return false;
}

/**
* Build an {@link IChatTerminalToolInvocationData} payload from a tool-call
* state. Single source of truth for the five places that need to (re)compute
* the terminal payload: pending confirmation, live create, streaming refresh,
* finalize, and history replay.
*
* Each field falls back to `existing` so callers can re-call on later
* snapshots without losing values that arrived earlier. This is critical for
* the AHP fields `terminalToolSessionId` / `terminalCommandUri`, which
* `_reviveTerminalIfNeeded` populates asynchronously once a Terminal content
* block arrives — refreshing from `tc` alone would clobber them whenever the
* block hasn't landed yet.
*
* Completion-only fields (e.g. `terminalCommandState` from `tc.success`)
* are layered on top by the caller; the helper is status-agnostic.
*/
function buildTerminalToolSpecificData(
tc: ToolCallState,
sessionResource: URI,
existing?: IChatTerminalToolInvocationData,
): IChatTerminalToolInvocationData {
const terminalContentUri = (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed)
? getTerminalContentUri(tc.content)
: undefined;
const nextCommand = getTerminalInput(tc);
const commandLine = nextCommand
? { ...existing?.commandLine, original: nextCommand }
: existing?.commandLine ?? { original: '' };
const nextOutput = getTerminalOutput(tc);
// Spread `existing` so any field set by a prior pass (notably the
// async-populated AHP fields and anything we don't explicitly handle)
// is preserved unless we have a fresh value to override it with.
return {
...existing,
kind: 'terminal',
commandLine,
language: existing?.language ?? getTerminalLanguage(tc),
terminalToolSessionId: terminalContentUri
? makeAhpTerminalToolSessionId(terminalContentUri, sessionResource)
: existing?.terminalToolSessionId,
terminalCommandUri: terminalContentUri ? URI.parse(terminalContentUri) : existing?.terminalCommandUri,
terminalCommandOutput: nextOutput ?? existing?.terminalCommandOutput,
};
}

function getToolInputOutputDetails(tc: ToolCallState, isError: boolean, errorString: string | undefined): IToolResultInputOutputDetails | undefined {
const toolInput = tc.status === ToolCallStatus.Streaming ? undefined : tc.toolInput;
if (!toolInput) {
Expand Down Expand Up @@ -468,8 +544,7 @@ function getToolErrorString(tc: ToolCallState): string | undefined {
* tool invocation suitable for history replay.
*/
export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string | undefined): IChatToolInvocationSerialized {
const terminalContentUri = tc.status === ToolCallStatus.Completed ? getTerminalContentUri(tc.content) : undefined;
const isTerminal = !!terminalContentUri || getToolKind(tc) === 'terminal';
const isTerminal = isTerminalToolCall(tc);
const isSuccess = tc.status === ToolCallStatus.Completed && tc.success;
const invocationMsg = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? localize('ahp.running', "Running {0}...", tc.displayName);

Expand Down Expand Up @@ -507,12 +582,7 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn
let toolSpecificData: IChatTerminalToolInvocationData | IChatSearchToolInvocationData | undefined;
if (isTerminal) {
toolSpecificData = {
kind: 'terminal',
commandLine: { original: getTerminalInput(tc) ?? '' },
language: getTerminalLanguage(tc),
terminalToolSessionId: terminalContentUri ? makeAhpTerminalToolSessionId(terminalContentUri, sessionResource) : undefined,
terminalCommandUri: terminalContentUri ? URI.parse(terminalContentUri) : undefined,
terminalCommandOutput: getTerminalOutput(tc),
...buildTerminalToolSpecificData(tc, sessionResource),
terminalCommandState: { exitCode: isSuccess ? 0 : 1 },
};
} else if (getToolKind(tc) === 'search') {
Expand Down Expand Up @@ -839,11 +909,7 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI
}),
};
} else if (getToolKind(tc) === 'terminal' && tc.toolInput) {
toolSpecificData = {
kind: 'terminal',
commandLine: { original: getTerminalInput(tc) || '' },
language: getTerminalLanguage(tc),
};
toolSpecificData = buildTerminalToolSpecificData(tc, sessionResource);
} else if (tc.toolInput) {
let rawInput: unknown;
try { rawInput = JSON.parse(tc.toolInput); } catch { rawInput = { input: tc.toolInput }; }
Expand All @@ -867,18 +933,17 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI
const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, subAgentInvocationId, undefined);
invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? localize('ahp.running', "Running {0}...", tc.displayName);

const terminalContentUri = (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed)
? getTerminalContentUri(tc.content)
: undefined;
if (terminalContentUri) {
invocation.toolSpecificData = {
kind: 'terminal',
commandLine: { original: getTerminalInput(tc) || '' },
language: getTerminalLanguage(tc),
terminalToolSessionId: makeAhpTerminalToolSessionId(terminalContentUri, sessionResource),
terminalCommandUri: URI.parse(terminalContentUri),
terminalCommandOutput: getTerminalOutput(tc),
} satisfies IChatTerminalToolInvocationData;
if (isTerminalToolCall(tc)) {
// Set terminal toolSpecificData eagerly so the renderer shows a
// terminal pill (expandable command + output area) from the start,
// instead of falling back to the generic tool widget that only
// surfaces the first line of the command via the invocation message.
// For the SDK's built-in `bash`/`powershell` tools there's no
// Terminal content block (they run outside AHP's terminal infra),
// so the AHP-terminal fields (`terminalToolSessionId`,
// `terminalCommandUri`) stay undefined — the renderer treats this
// as a display-only terminal that still surfaces command + output.
invocation.toolSpecificData = buildTerminalToolSpecificData(tc, sessionResource);
} else if (isSubagentTool(tc)) {
// Subagent-spawning tool: set subagent toolSpecificData eagerly so the
// renderer groups it correctly from the start (before child content
Expand Down Expand Up @@ -907,7 +972,7 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI
* Called from the session handler when a tool transitions to Running state
* to set the initial `toolSpecificData`, or when content changes arrive.
*/
export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: ToolCallState, connectionAuthority: string | undefined): void {
export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: ToolCallState, sessionResource: URI, connectionAuthority: string | undefined): void {
if (tc.status !== ToolCallStatus.Running) {
return;
}
Expand Down Expand Up @@ -936,6 +1001,27 @@ export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc:
existing.toolSpecificData = { kind: 'subagent', description, agentName };
existing.notifyToolSpecificDataChanged();
}
return;
}

// Refresh terminal toolSpecificData as streaming text content arrives
// (or when terminal toolSpecificData was not set up-front because the
// tool transitioned through the Streaming state before reaching
// Running). Preserves AHP-terminal fields (`terminalToolSessionId`,
// `terminalCommandUri`, `terminalCommandId`) that `_reviveTerminalIfNeeded`
// in the session handler populates asynchronously when a Terminal
// content block is present.
const existingTerminal = existing.toolSpecificData?.kind === 'terminal'
? existing.toolSpecificData
: undefined;
if (isTerminalToolCall(tc, existing.toolSpecificData?.kind)) {
const next = buildTerminalToolSpecificData(tc, sessionResource, existingTerminal);
const outputChanged = next.terminalCommandOutput?.text !== existingTerminal?.terminalCommandOutput?.text;
const commandChanged = next.commandLine.original !== existingTerminal?.commandLine.original;
if (!existingTerminal || outputChanged || commandChanged) {
existing.toolSpecificData = next;
existing.notifyToolSpecificDataChanged();
}
}
}

Expand Down Expand Up @@ -970,10 +1056,7 @@ export interface IToolCallFileEdit {
export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolCallState, backendSession: URI, connectionAuthority: string | undefined): IToolCallFileEdit[] {
const isCompleted = tc.status === ToolCallStatus.Completed;
const isCancelled = tc.status === ToolCallStatus.Cancelled;
const terminalContentUri = tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed
? getTerminalContentUri(tc.content)
: undefined;
const isTerminal = invocation.toolSpecificData?.kind === 'terminal' || !!terminalContentUri || getToolKind(tc) === 'terminal';
const isTerminal = isTerminalToolCall(tc, invocation.toolSpecificData?.kind);

if ((isCompleted || isCancelled) && hasKey(tc, { invocationMessage: true })) {
invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? invocation.invocationMessage;
Expand Down Expand Up @@ -1003,16 +1086,11 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC
}

if (isTerminal && (isCompleted || isCancelled)) {
const existing = invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined;
const existing = invocation.toolSpecificData?.kind === 'terminal' ? invocation.toolSpecificData : undefined;
invocation.presentation = undefined;
invocation.toolSpecificData = {
kind: 'terminal',
commandLine: existing?.commandLine || { original: getTerminalInput(tc) || '' },
language: getTerminalLanguage(tc),
terminalToolSessionId: terminalContentUri ? makeAhpTerminalToolSessionId(terminalContentUri, backendSession) : existing?.terminalToolSessionId,
terminalCommandOutput: getTerminalOutput(tc),
...buildTerminalToolSpecificData(tc, backendSession, existing),
terminalCommandState: { exitCode: isCompleted && tc.success ? 0 : 1 },
terminalCommandUri: terminalContentUri ? URI.parse(terminalContentUri) : existing?.terminalCommandUri,
};
} else if (isCompleted && tc.pastTenseMessage) {
invocation.pastTenseMessage = stringOrMarkdownToString(tc.pastTenseMessage, connectionAuthority);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ function activeTurnToProgress(sessionResource: Parameters<typeof rawActiveTurnTo
}

function updateRunningToolSpecificData(existing: Parameters<typeof rawUpdateRunningToolSpecificData>[0], tc: Parameters<typeof rawUpdateRunningToolSpecificData>[1]) {
return rawUpdateRunningToolSpecificData(existing, tc, undefined);
return rawUpdateRunningToolSpecificData(existing, tc, URI.file('/'), undefined);
}

function assertInputOutputDetails(details: unknown): asserts details is IToolResultInputOutputDetails {
Expand Down Expand Up @@ -674,6 +674,45 @@ suite('stateToProgressAdapter', () => {
assert.strictEqual(termData.commandLine.original, 'ls -la');
});

test('sets terminal toolSpecificData for built-in bash via _meta.toolKind (no Terminal content block)', () => {
// The SDK's built-in bash tool (used when the Custom Terminal tool
// is disabled) runs outside AHP's terminal infra and does not emit
// a Terminal content block. The terminal pill must still render so
// the user can expand the full multi-line command and output.
const tc = createToolCallState({
toolName: 'bash',
displayName: 'Run Shell Command',
toolInput: 'ls -la\nwc -l',
_meta: { toolKind: 'terminal' },
});

const invocation = toolCallStateToInvocation(tc);
assert.ok(invocation.toolSpecificData);
assert.strictEqual(invocation.toolSpecificData.kind, 'terminal');
const termData = invocation.toolSpecificData as { kind: 'terminal'; commandLine: { original: string }; language?: string; terminalToolSessionId?: string; terminalCommandUri?: URI };
assert.strictEqual(termData.commandLine.original, 'ls -la\nwc -l');
assert.strictEqual(termData.language, 'shellscript');
assert.strictEqual(termData.terminalToolSessionId, undefined, 'no AHP terminal session for built-in bash');
assert.strictEqual(termData.terminalCommandUri, undefined, 'no AHP terminal URI for built-in bash');
});

test('built-in bash terminal toolSpecificData picks up streaming text output (running)', () => {
const tc = createToolCallState({
toolName: 'bash',
toolInput: 'echo hi',
_meta: { toolKind: 'terminal' },
status: ToolCallStatus.Running,
content: [
{ type: ToolResultContentType.Text, text: 'hi\n' },
],
});

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');
});

test('creates invocation without toolArguments', () => {
const tc = createToolCallState({});

Expand Down Expand Up @@ -1338,5 +1377,68 @@ suite('stateToProgressAdapter', () => {
updateRunningToolSpecificData(invocation, runningTc);
assert.strictEqual(invocation.toolSpecificData, originalData, 'toolSpecificData should not change');
});

test('refreshes terminal output as text content streams (built-in bash)', () => {
const tc = createToolCallState({
toolName: 'bash',
toolInput: 'sleep 1; echo hi',
_meta: { toolKind: 'terminal' },
});
const invocation = toolCallStateToInvocation(tc);
assert.strictEqual(invocation.toolSpecificData?.kind, 'terminal');
assert.strictEqual((invocation.toolSpecificData as { terminalCommandOutput?: { text: string } }).terminalCommandOutput, undefined);

const runningTc: ToolCallRunningState = {
...tc,
status: ToolCallStatus.Running,
content: [{ type: ToolResultContentType.Text, text: 'hi\n' }],
};

updateRunningToolSpecificData(invocation, runningTc);
const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput?: { text: string } };
assert.strictEqual(termData.kind, 'terminal');
assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n');
});

test('preserves AHP terminal fields (terminalToolSessionId, terminalCommandUri) 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.
const tc = createToolCallState({
toolName: 'bash',
toolInput: 'echo hi',
_meta: { toolKind: 'terminal' },
});
const invocation = toolCallStateToInvocation(tc);
const reviveUri = URI.parse('agenthost-terminal:///t9');
invocation.toolSpecificData = {
kind: 'terminal',
commandLine: { original: 'echo hi' },
language: 'shellscript',
terminalToolSessionId: 'session-id-from-revive',
terminalCommandUri: reviveUri,
terminalCommandId: 'cmd-id-from-revive',
};

const runningTc: ToolCallRunningState = {
...tc,
status: ToolCallStatus.Running,
content: [{ type: ToolResultContentType.Text, text: 'hi\n' }],
};

updateRunningToolSpecificData(invocation, runningTc);
const termData = invocation.toolSpecificData as {
kind: 'terminal';
terminalToolSessionId?: string;
terminalCommandUri?: URI;
terminalCommandId?: string;
terminalCommandOutput?: { text: string };
};
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');
});
});
});
Loading