Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3306,6 +3306,11 @@ export class Session implements SessionContext {
locations: invocation.toolLocations(),
kind: mappedKind,
rawInput: args,
// Carry the tool name so consumers can give specific tools
// (e.g. the Agent tool) dedicated permission UI without
// relying on a protocol `kind` ACP can't carry. The tool_call
// frame already ships _meta.toolName; mirror it here.
_meta: { toolName },
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,21 @@ export const useWebViewMessages = ({
}

case 'permissionRequest': {
// Surface the canonical tool name (the ACP frame's `_meta.toolName`)
// onto the PermissionToolCall so the drawer can render tool-specific
// UI (e.g. the Agent tool's "Launch this agent?" prompt) without
// depending on a protocol `kind` ACP can't carry.
const incomingToolCall = message.data?.toolCall as
| (PermissionToolCall & { _meta?: { toolName?: string } })
| undefined;
if (
incomingToolCall &&
incomingToolCall.toolName === undefined &&
typeof incomingToolCall._meta?.toolName === 'string'
) {
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
incomingToolCall.toolName = incomingToolCall._meta.toolName;
}

handlers.handlePermissionRequest(message.data);

const permToolCall = message.data?.toolCall as {
Expand Down
45 changes: 45 additions & 0 deletions packages/web-shell/client/adapters/transcriptAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,51 @@ describe('extractPendingPermission', () => {
expect(result?.toolCallId).toBe('call-xyz');
});

it('extracts the canonical toolName from toolCall._meta', () => {
const permission = {
id: 'perm-agent',
kind: 'permission',
sessionId: 'session-1',
requestId: 'request-agent',
resolved: undefined,
title: 'Agent: probe agent kind',
options: [{ optionId: 'allow', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'call-agent',
kind: 'other',
_meta: { toolName: 'agent' },
rawInput: {},
},
preview: { kind: 'generic' },
createdAt: 1,
updatedAt: 1,
clientReceivedAt: 1,
} as DaemonTranscriptBlock;

const result = extractPendingPermission(state([permission]).blocks);
expect(result?.toolName).toBe('agent');
});

it('leaves toolName undefined when _meta is absent', () => {
const permission = {
id: 'perm-no-meta',
kind: 'permission',
sessionId: 'session-1',
requestId: 'request-no-meta',
resolved: undefined,
title: 'Bash: ls',
options: [{ optionId: 'allow', label: 'Allow', raw: {} }],
toolCall: { toolCallId: 'call-bash', rawInput: {} },
preview: { kind: 'generic' },
createdAt: 1,
updatedAt: 1,
clientReceivedAt: 1,
} as DaemonTranscriptBlock;

const result = extractPendingPermission(state([permission]).blocks);
expect(result?.toolName).toBeUndefined();
});

it('returns undefined toolCallId when toolCall has neither field', () => {
const permission = {
id: 'perm-tc3',
Expand Down
6 changes: 6 additions & 0 deletions packages/web-shell/client/adapters/transcriptAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,18 @@ export function extractPendingPermission(
typeof toolCallRecord?.['kind'] === 'string'
? toolCallRecord['kind']
: undefined;
const metaRecord = getRecord(toolCallRecord?.['_meta']);
const toolName =
typeof metaRecord?.['toolName'] === 'string'
? metaRecord['toolName']
: undefined;
return {
id: perm.requestId,
sessionId: perm.sessionId,
toolCallId,
title: perm.title,
toolKind,
toolName,
content: [
{
type: 'text',
Expand Down
2 changes: 2 additions & 0 deletions packages/web-shell/client/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface PermissionRequest {
toolCallId?: string;
title?: string;
toolKind?: string;
/** Canonical tool name (from the ACP frame's `_meta.toolName`). */
toolName?: string;
content: ContentBlock[];
options: PermissionOption[];
rawInput?: Record<string, unknown>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export function ToolApproval({ request, onConfirm }: ToolApprovalProps) {
}, [handleKeyDown]);

const isExec = isExecKind(request);
const isAgent = request.toolName === 'agent';
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
const command = getCommandFromRawInput(request);

return (
Expand All @@ -230,9 +231,11 @@ export function ToolApproval({ request, onConfirm }: ToolApprovalProps) {
) : null}

<div className={styles.question}>
{isExec
? t('approval.execQuestion', { tool: toolName })
: t('approval.changeQuestion')}
{isAgent
? t('approval.launchAgentQuestion')
: isExec
? t('approval.execQuestion', { tool: toolName })
: t('approval.changeQuestion')}
</div>

<div className={styles.options}>
Expand Down
2 changes: 2 additions & 0 deletions packages/web-shell/client/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ const EN: Messages = {
'request.cancelled': 'Request cancelled.',
'approval.execQuestion': (v) => `Allow execution of: '${v?.tool ?? ''}'?`,
'approval.changeQuestion': 'Apply this change?',
'approval.launchAgentQuestion': 'Launch this agent?',
'approval.option.allowOnce': 'Yes, allow once',
'approval.option.rejectOnce': 'No, suggest changes (esc)',
'approval.option.allowAllEdits': 'Allow All Edits',
Expand Down Expand Up @@ -994,6 +995,7 @@ const ZH: Messages = {
'request.cancelled': '请求已取消。',
'approval.execQuestion': (v) => `允许执行:'${v?.tool ?? ''}'?`,
'approval.changeQuestion': '是否继续?',
'approval.launchAgentQuestion': '启动这个 agent?',
'approval.option.allowOnce': '是,允许一次',
'approval.option.rejectOnce': '否,建议更改 (esc)',
'approval.option.allowAllEdits': '允许所有编辑',
Expand Down
70 changes: 70 additions & 0 deletions packages/webui/src/components/PermissionDrawer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

// @vitest-environment jsdom

import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
PermissionDrawer,
type PermissionOption,
type PermissionToolCall,
} from './PermissionDrawer.js';

const options: PermissionOption[] = [
{ name: 'Allow', kind: 'allow_once', optionId: 'proceed_once' },
{ name: 'Reject', kind: 'reject_once', optionId: 'cancel' },
];

describe('PermissionDrawer', () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;

beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});

afterEach(() => {
if (root) {
act(() => root?.unmount());
root = null;
}
if (container) {
container.remove();
container = null;
}
});

function render(toolCall: PermissionToolCall) {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<PermissionDrawer
isOpen={true}
options={options}
toolCall={toolCall}
onResponse={() => {}}
/>,
);
});
}

it('renders the dedicated Agent prompt when toolName is "agent"', () => {
render({ toolName: 'agent', kind: 'other', title: 'Run the linter agent' });
expect(container?.textContent).toContain('Launch this agent?');
});

it('falls back to the generic title for non-agent tools', () => {
render({ kind: 'other', title: 'Some tool' });
expect(container?.textContent).not.toContain('Launch this agent?');
expect(container?.textContent).toContain('Some tool');
});
});
12 changes: 11 additions & 1 deletion packages/webui/src/components/PermissionDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export interface PermissionOption {
export interface PermissionToolCall {
title?: string;
kind?: string;
/**
* Canonical tool name (from the ACP frame's `_meta.toolName`). Lets the
* drawer give specific tools dedicated UI (e.g. the Agent tool) without
* depending on a protocol `kind` ACP can't carry.
*/
toolName?: string;
toolCallId?: string;
rawInput?: {
command?: string;
Expand Down Expand Up @@ -107,6 +113,9 @@ export const PermissionDrawer: FC<PermissionDrawerProps> = ({
if (toolCall.kind === 'switch_mode') {
return 'Would you like to proceed?';
}
if (toolCall.toolName === 'agent') {
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
return 'Launch this agent?';
}
return toolCall.title || 'Permission Required';
};

Expand Down Expand Up @@ -236,7 +245,8 @@ export const PermissionDrawer: FC<PermissionDrawerProps> = ({
toolCall.kind === 'write' ||
toolCall.kind === 'read' ||
toolCall.kind === 'execute' ||
toolCall.kind === 'bash') &&
toolCall.kind === 'bash' ||
toolCall.toolName === 'agent') &&
toolCall.title && (
<div
/* 13px, normal font weight; normal whitespace wrapping + long word breaking; maximum 3 lines with overflow ellipsis */
Expand Down
Loading