Skip to content
Merged
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
@@ -0,0 +1,75 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/**
* Reader for the permission metadata a remote agent host echoes onto a tool
* call that is waiting for approval.
*
* A remote host describes the pending decision (run a command, read a file, …)
* but does not stamp the `_meta.toolKind` rendering hint local agent adapters
* provide, so the kind is recovered from here instead.
*/

interface IHasPermissionRequestMeta {
readonly _meta?: Record<string, unknown>;
}

/**
* The permission kinds that carry a rendering consequence. A remote host
* reports more kinds than these; the rest are left unrecognized so they fall
* through to the generic tool presentation.
*/
export const enum AgentPermissionRequestKind {
/** Execute a shell command. */
Commands = 'commands',
/** Read a single file. */
Read = 'read',
}

export interface IAgentPermissionRequestMeta {
readonly kind?: AgentPermissionRequestKind;
}

/**
* Normalizes a wire `kind`. A shell request arrives as `"commands"` on the
* projected payload and `"shell"` on the raw one.
*
* A path-batched request (`"path"`, whose own `accessKind` may be `"shell"`) is
* not a command: its subject is a list of paths, not a command line.
*/
function normalizeKind(value: unknown): AgentPermissionRequestKind | undefined {
switch (value) {
case 'commands':
case 'shell':
return AgentPermissionRequestKind.Commands;
case 'read':
return AgentPermissionRequestKind.Read;
default:
return undefined;
}
}

function readKind(value: unknown): AgentPermissionRequestKind | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
return normalizeKind((value as Record<string, unknown>)['kind']);
}

/**
* Reads the recognized permission metadata from a tool call's `_meta` bag.
*
* Hosts echo the same request as `promptRequest` (the prompt-shaped
* projection) and `permissionRequest` (the raw form); older hosts send only
* the raw one.
*/
export function readAgentPermissionRequestMeta(source: IHasPermissionRequestMeta): IAgentPermissionRequestMeta {
const meta = source._meta;
if (!meta) {
return {};
}
const kind = readKind(meta['promptRequest']) ?? readKind(meta['permissionRequest']);
return kind ? { kind } : {};
}
23 changes: 19 additions & 4 deletions src/vs/platform/agentHost/common/state/sessionReducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,29 @@
// Re-export reducers from the protocol layer
export { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer, softAssertNever, isClientDispatchable } from './protocol/reducers.js';

import { AgentPermissionRequestKind, readAgentPermissionRequestMeta } from '../meta/agentPermissionRequestMeta.js';
import { readToolCallMeta, type ToolKind } from '../meta/agentToolCallMeta.js';
import type { ICompletedToolCall, ToolCallState } from './sessionState.js';

/** Rendering kinds implied by a remote host's permission request. */
const PERMISSION_REQUEST_TOOL_KINDS: Readonly<Partial<Record<AgentPermissionRequestKind, ToolKind>>> = {
[AgentPermissionRequestKind.Commands]: 'terminal',
[AgentPermissionRequestKind.Read]: 'read',
};

/**
* Extracts the VS Code-specific `toolKind` hint from a tool call's `_meta`
* bag. This is not part of the protocol and is injected by the agent adapter
* (e.g. `copilotEventMapper`).
* Extracts the VS Code-specific `toolKind` rendering hint for a tool call.
*
* Normally the `_meta.toolKind` flag an agent adapter injects (e.g.
* `copilotEventMapper`); it is not part of the protocol. A remote agent host
* does not stamp that key, so for a call awaiting approval the kind comes from
* the permission request it echoes instead.
*/
export function getToolKind(tc: ToolCallState | ICompletedToolCall): ToolKind | undefined {
return readToolCallMeta(tc).toolKind;
const kind = readToolCallMeta(tc).toolKind;
if (kind) {
return kind;
}
const permissionKind = readAgentPermissionRequestMeta(tc).kind;
return permissionKind ? PERMISSION_REQUEST_TOOL_KINDS[permissionKind] : undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -1494,14 +1494,15 @@ function getTerminalLanguage(tc: ToolCallState) {
*
* 1. `existingKind === 'terminal'` — preserve the prior render decision so a
* tool already set up as terminal stays terminal across snapshots.
* 2. `getToolKind(tc) === 'terminal'` with a command available — 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. We only render the
* terminal pill once we actually have the command (`getTerminalInput`):
* rendering a terminal pill with an empty command line looks broken, so
* until the command arrives we fall back to the generic tool widget
* (the `invocationMessage`).
* 2. `getToolKind(tc) === 'terminal'` with a command available — either the
* `_meta.toolKind` flag set by the event mapper for built-in
* `bash`/`powershell` SDK tools that never emit a
* {@link ToolResultContentType.Terminal} content block, or a command
* permission request from a remote host that does not set that flag. We
* only render the terminal pill once we actually have the command
* (`getTerminalInput`): rendering a terminal pill with an empty command
* line looks broken, so until the command arrives we fall back to the
* generic tool widget (the `invocationMessage`).
* 3. A `Terminal` content block in `tc.content` (Running/Completed only) —
* the AHP-side signal for the custom terminal tool (`agenthost-terminal:`
* URIs).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1543,11 +1543,80 @@ suite('stateToProgressAdapter', () => {
assert.strictEqual(invocation.invocationMessage, 'Running shell command');
});

test('renders the terminal confirmation for a remote host command permission (no _meta.toolKind)', () => {
// A remote host describes a pending command approval by echoing the
// permission request rather than setting `_meta.toolKind`.
const tc: ToolCallPendingConfirmationState = {
toolCallId: 'tc-perm',
toolName: 'shell',
displayName: 'Shell',
invocationMessage: 'Find copilot CLI sandbox builder',
status: ToolCallStatus.PendingConfirmation,
confirmationTitle: 'Run command',
toolInput: 'rg -n "sandbox" --glob "*.ts"',
_meta: {
requestId: 'req-1',
promptRequest: { kind: 'commands', toolCallId: 'tc-perm' },
permissionRequest: { kind: 'shell', toolCallId: 'tc-perm' },
},
};

const invocation = toolCallStateToInvocation(tc);
assert.deepStrictEqual({
kind: invocation.toolSpecificData?.kind,
command: (invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.commandLine.original,
language: (invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.language,
}, {
kind: 'terminal',
command: 'rg -n "sandbox" --glob "*.ts"',
language: 'shellscript',
});
});

test('falls back to the raw permission request when the projected one is absent', () => {
// Older hosts echo only `permissionRequest`, spelling the same
// decision `shell` rather than `commands`.
const tc: ToolCallPendingConfirmationState = {
toolCallId: 'tc-perm-raw',
toolName: 'shell',
displayName: 'Shell',
invocationMessage: 'Check the build',
status: ToolCallStatus.PendingConfirmation,
toolInput: 'npm run compile',
_meta: { requestId: 'req-2', permissionRequest: { kind: 'shell' } },
};

const invocation = toolCallStateToInvocation(tc);
assert.deepStrictEqual({
kind: invocation.toolSpecificData?.kind,
command: (invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.commandLine.original,
}, {
kind: 'terminal',
command: 'npm run compile',
});
});

test('does not render a path permission as a terminal command', () => {
// A path request's subject is a list of paths, not a command line,
// even when its `accessKind` is `shell`.
const tc: ToolCallPendingConfirmationState = {
toolCallId: 'tc-perm-path',
toolName: 'shell',
displayName: 'Shell',
invocationMessage: 'Access paths',
status: ToolCallStatus.PendingConfirmation,
toolInput: '/a/one.ts, /a/two.ts',
_meta: { requestId: 'req-3', promptRequest: { kind: 'path', accessKind: 'shell' } },
};

const invocation = toolCallStateToInvocation(tc);
assert.strictEqual(invocation.toolSpecificData?.kind, 'input');
});

test('sets subagent toolSpecificData from _meta for subagent toolKind', () => {
const tc = createToolCallState({
_meta: { toolKind: 'subagent', subagentDescription: 'Review code', subagentAgentName: 'code-reviewer' },
});

const invocation = toolCallStateToInvocation(tc);
assert.ok(invocation.toolSpecificData);
assert.strictEqual(invocation.toolSpecificData.kind, 'subagent');
Expand Down
Loading