mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
refactor: centralize approval mode contracts (#9796)
* refactor: centralize approval mode contracts * fix: align Python SDK import grouping * test: restore approval mode exports in CLI mocks * fix: close approval mode drift gaps * test(cli): preserve core exports in serve mocks * fix: close approval mode review gaps * test(cli): complete permission request fixture * test(sdk): match approval mode route * test(approval): close review coverage gaps * test(sdk): cover approval mode global scope path
This commit is contained in:
39 files changed
+421
-255
No files matched your search
@@ -7,6 +7,7 @@ on:
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'packages/sdk-java/**'
|
||||
- 'packages/core/src/config/approval-modes.json'
|
||||
- 'packages/cli/src/commands/serve.ts'
|
||||
- 'packages/cli/src/serve/**'
|
||||
- 'packages/cli/src/acp-integration/**'
|
||||
@@ -25,6 +26,7 @@ on:
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'packages/sdk-java/**'
|
||||
- 'packages/core/src/config/approval-modes.json'
|
||||
- 'packages/cli/src/commands/serve.ts'
|
||||
- 'packages/cli/src/serve/**'
|
||||
- 'packages/cli/src/acp-integration/**'
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'packages/sdk-python/**'
|
||||
- 'packages/core/src/config/approval-modes.json'
|
||||
- '.github/workflows/sdk-python.yml'
|
||||
push:
|
||||
branches:
|
||||
@@ -17,6 +18,7 @@ on:
|
||||
- 'release/**'
|
||||
paths:
|
||||
- 'packages/sdk-python/**'
|
||||
- 'packages/core/src/config/approval-modes.json'
|
||||
- '.github/workflows/sdk-python.yml'
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Approval Mode Contract
|
||||
|
||||
## Decision
|
||||
|
||||
Keep `ApprovalMode` and `APPROVAL_MODES` in core as the runtime source of
|
||||
truth. TypeScript packages derive their local types and validators from either
|
||||
that core contract or the TypeScript SDK's checked tuple. Python and Java keep
|
||||
native public types, with their accepted values checked against a small JSON
|
||||
fixture that is also checked against core.
|
||||
|
||||
This avoids adding a runtime dependency from the published SDKs to core and
|
||||
does not introduce code generation for one five-value domain.
|
||||
|
||||
## Changes
|
||||
|
||||
- Export the string-union form of core's `ApprovalMode`.
|
||||
- Replace repeated TypeScript unions and validation arrays with the core or
|
||||
SDK contract.
|
||||
- Type the CLI and TypeScript SDK system-message `permission_mode` fields with
|
||||
the shared union.
|
||||
- Check core, TypeScript, Python, and Java accepted values against one fixture
|
||||
in their existing test suites.
|
||||
- Trigger the Python and Java SDK workflows when the fixture changes.
|
||||
|
||||
Adjacent value domains such as channel modes, hook permission decisions, and
|
||||
desktop cycling preferences remain independent because their supported values
|
||||
and semantics intentionally differ.
|
||||
|
||||
## Verification
|
||||
|
||||
- Core approval-mode tests.
|
||||
- CLI ACP and non-interactive type checking plus focused tests.
|
||||
- TypeScript SDK drift and query-option tests.
|
||||
- Python validation tests.
|
||||
- Java permission-mode tests.
|
||||
- Relevant lint, typecheck, and build commands.
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
WriteTextFileResponse,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import { RequestError } from '@agentclientprotocol/sdk';
|
||||
import { APPROVAL_MODES } from '@qwen-code/qwen-code-core';
|
||||
import type { BridgeEvent, EventBus } from './eventBus.js';
|
||||
// Wire constants shared with the child-side caller (`Session.ts`) and, for the
|
||||
// SSE event type, the SDK validator + browser consumer — single sources of truth
|
||||
@@ -539,17 +540,12 @@ const MAX_SUGGESTION_LENGTH = 500;
|
||||
const EARLY_EVENT_TTL_MS = 60_000;
|
||||
|
||||
// Known approval-mode ids accepted on the in-session `current_mode_update`
|
||||
// demux path. Mirrors the `modeMap` keys in `Session.setMode` (CLI); an id
|
||||
// outside this set is dropped before it fans out to SSE clients / the SDK
|
||||
// reducer. Keep the two in lockstep. Exported so the bridge's reconcile and
|
||||
// demux path. An id outside this set is dropped before it fans out to SSE
|
||||
// clients / the SDK reducer. Exported so the bridge's reconcile and
|
||||
// snapshot-seed paths apply the same enum backstop to agent-supplied mode ids.
|
||||
export const KNOWN_APPROVAL_MODES: ReadonlySet<string> = new Set([
|
||||
'plan',
|
||||
'default',
|
||||
'auto-edit',
|
||||
'auto',
|
||||
'yolo',
|
||||
]);
|
||||
export const KNOWN_APPROVAL_MODES: ReadonlySet<string> = new Set(
|
||||
APPROVAL_MODES,
|
||||
);
|
||||
|
||||
/**
|
||||
* Human-readable label for a `fs.Stats` object's kind, used in the
|
||||
|
||||
@@ -296,8 +296,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
|
||||
})),
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
APPROVAL_MODE_INFO: {},
|
||||
APPROVAL_MODES: [],
|
||||
APPROVAL_MODE_INFO: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).APPROVAL_MODE_INFO,
|
||||
APPROVAL_MODES: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).APPROVAL_MODES,
|
||||
applyReasoningEffort: (
|
||||
config: {
|
||||
setReasoningEffort(effort: string | undefined): void;
|
||||
@@ -7979,7 +7983,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||
workspaceCwd: '/tmp',
|
||||
state: {
|
||||
models: { currentModelId: 'm(api-key)', availableModels: [] },
|
||||
modes: { currentModeId: 'default', availableModes: [] },
|
||||
modes: {
|
||||
currentModeId: 'default',
|
||||
availableModes: APPROVAL_MODES.map((id) => ({ id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(supportedCommands).toEqual({
|
||||
|
||||
@@ -1321,7 +1321,7 @@ const QWEN_CORE_SETTING_DEFINITIONS = {
|
||||
'general.language': { type: 'string' },
|
||||
'tools.approvalMode': {
|
||||
type: 'enum',
|
||||
values: ['plan', 'default', 'auto-edit', 'auto', 'yolo'],
|
||||
values: APPROVAL_MODES,
|
||||
},
|
||||
'general.vimMode': { type: 'boolean' },
|
||||
'general.enableAutoUpdate': { type: 'boolean' },
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApprovalModeValue,
|
||||
Config,
|
||||
ToolArtifact,
|
||||
ToolResultBoundaryArtifact,
|
||||
@@ -17,12 +18,7 @@ import type {
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { MessageRewriteMiddleware } from './rewrite/index.js';
|
||||
|
||||
export type ApprovalModeValue =
|
||||
| 'plan'
|
||||
| 'default'
|
||||
| 'auto-edit'
|
||||
| 'auto'
|
||||
| 'yolo';
|
||||
export type { ApprovalModeValue };
|
||||
|
||||
/**
|
||||
* Interface for sending session updates to the ACP client.
|
||||
|
||||
@@ -4,17 +4,12 @@ import type {
|
||||
ChannelWebhookSourceConfig,
|
||||
ChannelWebhookTargetConfig,
|
||||
} from '@qwen-code/channel-base';
|
||||
import { APPROVAL_MODES } from '@qwen-code/qwen-code-core';
|
||||
import { resolveChannelCwd } from './channel-cwd.js';
|
||||
import { getPlugin, supportedTypes } from './channel-registry.js';
|
||||
|
||||
const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
||||
const CHANNEL_APPROVAL_MODES = new Set([
|
||||
'plan',
|
||||
'default',
|
||||
'auto-edit',
|
||||
'auto',
|
||||
'yolo',
|
||||
]);
|
||||
const CHANNEL_APPROVAL_MODES = new Set<string>(APPROVAL_MODES);
|
||||
|
||||
export { findCliEntryPath } from './cli-entry-path.js';
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
sessionsPath,
|
||||
} from './runtime.js';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
|
||||
APPROVAL_MODES: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).APPROVAL_MODES,
|
||||
Storage: { getGlobalQwenDir: () => '/tmp/qwen' },
|
||||
hashDaemonWorkspace: (workspace: string) =>
|
||||
workspace === '/workspace' ? 'workspace-hash' : 'other-hash',
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
// reached a verdict" from "blocking verdict" (opt-in via --fail-on).
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { isUnusableScriptEntry } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
APPROVAL_MODES,
|
||||
isUnusableScriptEntry,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
@@ -677,7 +680,7 @@ export const runCommand: CommandModule = {
|
||||
.option('approval-mode', {
|
||||
type: 'string',
|
||||
default: 'yolo',
|
||||
choices: ['plan', 'default', 'auto-edit', 'auto', 'yolo'],
|
||||
choices: APPROVAL_MODES,
|
||||
describe:
|
||||
'Approval mode for the child CLI. The default is yolo: headless runs cannot answer ' +
|
||||
'confirmation prompts, and anything still unapproved would be auto-denied mid-review.',
|
||||
|
||||
@@ -290,6 +290,27 @@ describe('parseArguments', () => {
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
it('includes every approval mode description in --help', async () => {
|
||||
process.argv = ['node', 'script.js', '--help'];
|
||||
const output: string[] = [];
|
||||
const log = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
||||
output.push(args.join(' '));
|
||||
});
|
||||
try {
|
||||
await expect(parseArguments()).rejects.toThrow(
|
||||
'process.exit unexpectedly called with "0"',
|
||||
);
|
||||
const help = output.join('');
|
||||
for (const mode of ServerConfig.APPROVAL_MODES) {
|
||||
expect(help).toContain(
|
||||
`${mode} (${ServerConfig.APPROVAL_MODE_INFO[mode].description})`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw an error when both --prompt and --prompt-interactive are used together', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
@@ -4478,9 +4499,19 @@ describe('loadCliConfig approval mode', () => {
|
||||
it('should normalize approval mode values from settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
tools: { approvalMode: ServerConfig.ApprovalMode.AUTO_EDIT },
|
||||
};
|
||||
const settings = {
|
||||
tools: { approvalMode: 'auto_edit' },
|
||||
} as unknown as Settings;
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT);
|
||||
});
|
||||
|
||||
it('should normalize legacy autoedit approval mode from settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings = {
|
||||
tools: { approvalMode: 'autoedit' },
|
||||
} as unknown as Settings;
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
import {
|
||||
ApprovalMode,
|
||||
APPROVAL_MODE_INFO,
|
||||
APPROVAL_MODES,
|
||||
AuthType,
|
||||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
@@ -100,14 +102,6 @@ function resolveLocaleForExtensions(settings: Settings): string {
|
||||
return detectSystemLanguage();
|
||||
}
|
||||
|
||||
const VALID_APPROVAL_MODE_VALUES = [
|
||||
'plan',
|
||||
'default',
|
||||
'auto-edit',
|
||||
'auto',
|
||||
'yolo',
|
||||
] as const;
|
||||
|
||||
const SKILL_LEVELS: readonly SkillLevel[] = [
|
||||
'project',
|
||||
'user',
|
||||
@@ -121,7 +115,7 @@ function isSkillLevel(value: unknown): value is SkillLevel {
|
||||
|
||||
function formatApprovalModeError(value: string): Error {
|
||||
return new Error(
|
||||
`Invalid approval mode: ${value}. Valid values are: ${VALID_APPROVAL_MODE_VALUES.join(
|
||||
`Invalid approval mode: ${value}. Valid values are: ${APPROVAL_MODES.join(
|
||||
', ',
|
||||
)}`,
|
||||
);
|
||||
@@ -129,22 +123,15 @@ function formatApprovalModeError(value: string): Error {
|
||||
|
||||
function parseApprovalModeValue(value: string): ApprovalMode {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'plan':
|
||||
return ApprovalMode.PLAN;
|
||||
case 'default':
|
||||
return ApprovalMode.DEFAULT;
|
||||
case 'yolo':
|
||||
return ApprovalMode.YOLO;
|
||||
case 'auto_edit':
|
||||
case 'autoedit':
|
||||
case 'auto-edit':
|
||||
return ApprovalMode.AUTO_EDIT;
|
||||
case 'auto':
|
||||
return ApprovalMode.AUTO;
|
||||
default:
|
||||
throw formatApprovalModeError(value);
|
||||
const canonical =
|
||||
normalized === 'auto_edit' || normalized === 'autoedit'
|
||||
? ApprovalMode.AUTO_EDIT
|
||||
: normalized;
|
||||
const approvalMode = APPROVAL_MODES.find((mode) => mode === canonical);
|
||||
if (approvalMode === undefined) {
|
||||
throw formatApprovalModeError(value);
|
||||
}
|
||||
return approvalMode;
|
||||
}
|
||||
|
||||
export interface CliArgs {
|
||||
@@ -547,6 +534,9 @@ function normalizeOutputFormat(
|
||||
|
||||
export async function parseArguments(): Promise<CliArgs> {
|
||||
let rawArgv = hideBin(process.argv);
|
||||
const approvalModeDescription = APPROVAL_MODES.map(
|
||||
(mode) => `${mode} (${APPROVAL_MODE_INFO[mode].description})`,
|
||||
).join(', ');
|
||||
|
||||
// hack: if the first argument is the CLI entry point, remove it
|
||||
if (
|
||||
@@ -715,9 +705,8 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
})
|
||||
.option('approval-mode', {
|
||||
type: 'string',
|
||||
choices: ['plan', 'default', 'auto-edit', 'auto', 'yolo'],
|
||||
description:
|
||||
'Set the approval mode: plan (plan only), default (prompt for approval), auto-edit (auto-approve edit tools), auto (LLM classifier auto-approves safe actions, blocks risky ones), yolo (auto-approve all tools)',
|
||||
choices: APPROVAL_MODES,
|
||||
description: `Set the approval mode: ${approvalModeDescription}`,
|
||||
})
|
||||
.option('acp', {
|
||||
type: 'boolean',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApprovalMode,
|
||||
InputFormat,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
@@ -50,6 +51,42 @@ function createRegistry(): IPendingRequestRegistry {
|
||||
}
|
||||
|
||||
describe('PermissionController', () => {
|
||||
it.each([
|
||||
[ApprovalMode.PLAN, 'allow'],
|
||||
[ApprovalMode.DEFAULT, 'deny'],
|
||||
[ApprovalMode.AUTO_EDIT, 'allow'],
|
||||
[ApprovalMode.AUTO, 'allow'],
|
||||
[ApprovalMode.YOLO, 'allow'],
|
||||
] as const)(
|
||||
'checks %s permission mode for can_use_tool',
|
||||
async (mode, behavior) => {
|
||||
const context = createContext();
|
||||
context.permissionMode = mode;
|
||||
const controller = new PermissionController(
|
||||
context,
|
||||
createRegistry(),
|
||||
'PermissionController',
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.handleRequest(
|
||||
{
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'read_file',
|
||||
tool_use_id: `tool-${mode}`,
|
||||
input: {},
|
||||
permission_suggestions: null,
|
||||
blocked_path: null,
|
||||
},
|
||||
`request-${mode}`,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
subtype: 'can_use_tool',
|
||||
behavior,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('round-trips workflow approval through can_use_tool with updated input', async () => {
|
||||
const context = createContext(120_000);
|
||||
const resolvePendingApproval = vi.fn().mockResolvedValue(true);
|
||||
|
||||
@@ -18,12 +18,13 @@ import type {
|
||||
WaitingToolCall,
|
||||
ToolExecuteConfirmationDetails,
|
||||
ToolMcpConfirmationDetails,
|
||||
ApprovalMode,
|
||||
TeammateApprovalRequestEvent,
|
||||
ToolConfirmationPayload,
|
||||
WorkflowApproval,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
APPROVAL_MODES,
|
||||
InputFormat,
|
||||
ToolConfirmationOutcome,
|
||||
ToolNames,
|
||||
@@ -146,22 +147,23 @@ export class PermissionController extends BaseController {
|
||||
private checkPermissionMode(): { allowed: boolean; message?: string } {
|
||||
const mode = this.context.permissionMode;
|
||||
|
||||
// Map permission modes to approval logic (aligned with VALID_APPROVAL_MODE_VALUES)
|
||||
switch (mode) {
|
||||
case 'yolo': // Allow all tools
|
||||
case 'auto-edit': // Auto-approve edit operations
|
||||
case 'auto': // Auto-approve via LLM classifier — coreToolScheduler enforces the gate
|
||||
case 'plan': // Auto-approve planning operations
|
||||
return { allowed: true };
|
||||
|
||||
case 'default': // TODO: allow all tools for test
|
||||
default:
|
||||
return {
|
||||
allowed: false,
|
||||
message:
|
||||
'Tool execution requires manual approval. Update permission mode or approve via host.',
|
||||
};
|
||||
if (mode === ApprovalMode.DEFAULT) {
|
||||
return {
|
||||
allowed: false,
|
||||
message:
|
||||
'Tool execution requires manual approval. Update permission mode or approve via host.',
|
||||
};
|
||||
}
|
||||
|
||||
const validModes = APPROVAL_MODES as readonly PermissionMode[];
|
||||
if (validModes.includes(mode)) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
message: `Invalid permission mode: ${mode}. Valid values are: ${validModes.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,13 +219,7 @@ export class PermissionController extends BaseController {
|
||||
}
|
||||
|
||||
const mode = payload.mode;
|
||||
const validModes: PermissionMode[] = [
|
||||
'default',
|
||||
'plan',
|
||||
'auto-edit',
|
||||
'auto',
|
||||
'yolo',
|
||||
];
|
||||
const validModes = APPROVAL_MODES as readonly PermissionMode[];
|
||||
|
||||
if (!validModes.includes(mode)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type {
|
||||
ActiveGoal,
|
||||
ApprovalModeValue,
|
||||
GoalSnapshotV2,
|
||||
SubagentConfig,
|
||||
McpToolProgressData,
|
||||
@@ -145,7 +146,7 @@ export interface CLISystemMessage {
|
||||
status: string;
|
||||
}>;
|
||||
model?: string;
|
||||
permission_mode?: string;
|
||||
permission_mode?: PermissionMode;
|
||||
slash_commands?: string[];
|
||||
qwen_code_version?: string;
|
||||
output_style?: string;
|
||||
@@ -278,7 +279,7 @@ export interface CLIPartialAssistantMessage {
|
||||
parent_tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'auto' | 'yolo';
|
||||
export type PermissionMode = ApprovalModeValue;
|
||||
|
||||
/**
|
||||
* Permission suggestion for tool use requests
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
registerWorkspaceQualifiedGitDiffRoutes,
|
||||
} from './workspace-git-diff.js';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@qwen-code/qwen-code-core')>()),
|
||||
fetchGitDiff: vi.fn(),
|
||||
fetchGitDiffHunksForFile: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -35,7 +35,8 @@ const { mockDebugLogger } = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@qwen-code/qwen-code-core')>()),
|
||||
createDebugLogger: () => mockDebugLogger,
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ApprovalModeValue, Config } from '../config/config.js';
|
||||
import * as jsonl from '../utils/jsonl-utils.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
@@ -107,8 +107,6 @@ const WORKTREE_ISOLATION_BLOCKED_REASON =
|
||||
const INCOMPATIBLE_ISOLATION_BLOCKED_REASON =
|
||||
'Background task isolation metadata is incompatible.';
|
||||
|
||||
type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo';
|
||||
|
||||
/**
|
||||
* Returns true when the subagent's effective tool surface will include the
|
||||
* Skill tool. Mirrors `AgentCore.willHaveSkillTool()` for the resume path
|
||||
|
||||
@@ -12,4 +12,6 @@ export enum ApprovalMode {
|
||||
YOLO = 'yolo',
|
||||
}
|
||||
|
||||
export type ApprovalModeValue = `${ApprovalMode}`;
|
||||
|
||||
export const APPROVAL_MODES = Object.values(ApprovalMode);
|
||||
@@ -0,0 +1 @@
|
||||
["plan", "default", "auto-edit", "auto", "yolo"]
|
||||
@@ -325,7 +325,11 @@ export {
|
||||
|
||||
export type ModelInvocableCommandExecutorResult = string | { error: string };
|
||||
|
||||
export { ApprovalMode, APPROVAL_MODES } from './approval-mode.js';
|
||||
export {
|
||||
ApprovalMode,
|
||||
APPROVAL_MODES,
|
||||
type ApprovalModeValue,
|
||||
} from './approval-mode.js';
|
||||
|
||||
/**
|
||||
* Thrown by `Config.setApprovalMode` when the requested mode would grant
|
||||
|
||||
+5
-3
@@ -1,6 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { PermissionMode } from '@qwen-code/sdk';
|
||||
|
||||
export type { PermissionMode };
|
||||
|
||||
export interface Annotation {
|
||||
type: string;
|
||||
value: string;
|
||||
@@ -120,7 +124,7 @@ export interface SDKSystemMessage {
|
||||
status: string;
|
||||
}>;
|
||||
model?: string;
|
||||
permission_mode?: string;
|
||||
permission_mode?: PermissionMode;
|
||||
slash_commands?: string[];
|
||||
qwen_code_version?: string;
|
||||
output_style?: string;
|
||||
@@ -230,8 +234,6 @@ export interface SDKPartialAssistantMessage {
|
||||
parent_tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'auto' | 'yolo';
|
||||
|
||||
/**
|
||||
* TODO: Align with `ToolCallConfirmationDetails`
|
||||
*/
|
||||
|
||||
+41
-4
@@ -1,11 +1,20 @@
|
||||
package com.alibaba.qwen.code.cli.transport;
|
||||
|
||||
import com.alibaba.qwen.code.cli.protocol.data.PermissionMode;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.qwen.code.cli.protocol.data.PermissionMode;
|
||||
import com.alibaba.qwen.code.daemon.DaemonApprovalMode;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class PermissionModeTest {
|
||||
|
||||
@Test
|
||||
@@ -17,4 +26,32 @@ public class PermissionModeTest {
|
||||
assertEquals("yolo", PermissionMode.YOLO.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void permissionModesMatchCoreContract() throws IOException {
|
||||
Set<String> expected = readCoreContract();
|
||||
Set<String> cliModes = Arrays.stream(PermissionMode.values())
|
||||
.map(PermissionMode::getValue)
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> daemonModes = Arrays.stream(DaemonApprovalMode.values())
|
||||
.map(DaemonApprovalMode::getWireValue)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertEquals(expected, cliModes);
|
||||
assertEquals(expected, daemonModes);
|
||||
}
|
||||
|
||||
private static Set<String> readCoreContract() throws IOException {
|
||||
Path directory = Path.of("").toAbsolutePath();
|
||||
while (directory != null) {
|
||||
Path contract = directory.resolve(
|
||||
"packages/core/src/config/approval-modes.json");
|
||||
if (Files.exists(contract)) {
|
||||
return new HashSet<>(JSON.parseArray(
|
||||
Files.readString(contract), String.class));
|
||||
}
|
||||
directory = directory.getParent();
|
||||
}
|
||||
throw new IOException("Cannot find approval-modes.json");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, cast
|
||||
from pathlib import Path
|
||||
from typing import Any, cast, get_args
|
||||
|
||||
import pytest
|
||||
from qwen_code_sdk.errors import ValidationError
|
||||
from qwen_code_sdk.types import QueryOptions, TimeoutOptions
|
||||
from qwen_code_sdk.types import PermissionMode, QueryOptions, TimeoutOptions
|
||||
from qwen_code_sdk.validation import validate_query_options
|
||||
|
||||
VALID_UUID = "123e4567-e89b-12d3-a456-426614174000"
|
||||
@@ -77,12 +79,21 @@ def test_accepts_canonical_session_id_in_either_case() -> None:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode",
|
||||
["default", "plan", "auto-edit", "auto", "yolo"],
|
||||
get_args(PermissionMode),
|
||||
)
|
||||
def test_accepts_valid_permission_modes(mode: str) -> None:
|
||||
validate_query_options(QueryOptions.from_mapping({"permission_mode": mode}))
|
||||
|
||||
|
||||
def test_permission_modes_match_core_contract() -> None:
|
||||
contract_path = (
|
||||
Path(__file__).parents[3] / "core" / "src" / "config" / "approval-modes.json"
|
||||
)
|
||||
expected = set(json.loads(contract_path.read_text(encoding="utf-8")))
|
||||
|
||||
assert set(get_args(PermissionMode)) == expected
|
||||
|
||||
|
||||
def test_rejects_invalid_permission_mode() -> None:
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
|
||||
@@ -9,6 +9,13 @@ import { tool } from '../../tool.js';
|
||||
import { formatJsonResult, formatToolError } from '../../formatters.js';
|
||||
import type { BridgeState } from '../types.js';
|
||||
import { handler, resolveSessionId } from '../helpers.js';
|
||||
import { PERMISSION_MODES } from '../../../types/permission-mode.js';
|
||||
|
||||
const SAFE_LOCAL_APPROVAL_MODES = new Set(['plan', 'default']);
|
||||
const GLOBAL_SCOPE_APPROVAL_MODES = PERMISSION_MODES.filter(
|
||||
(mode) => !SAFE_LOCAL_APPROVAL_MODES.has(mode),
|
||||
);
|
||||
const PERMISSION_MODE_LIST = PERMISSION_MODES.join(', ');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export function workspaceWriteTools(state: BridgeState): any[] {
|
||||
@@ -80,11 +87,9 @@ export function workspaceWriteTools(state: BridgeState): any[] {
|
||||
|
||||
tool(
|
||||
'session_set_approval_mode',
|
||||
'Change the approval mode of a session (plan, default, auto-edit, auto, yolo).',
|
||||
`Change the approval mode of a session (${PERMISSION_MODE_LIST}).`,
|
||||
{
|
||||
mode: z
|
||||
.enum(['plan', 'default', 'auto-edit', 'auto', 'yolo'])
|
||||
.describe('Approval mode.'),
|
||||
mode: z.enum(PERMISSION_MODES).describe('Approval mode.'),
|
||||
persist: z
|
||||
.boolean()
|
||||
.optional()
|
||||
@@ -97,10 +102,9 @@ export function workspaceWriteTools(state: BridgeState): any[] {
|
||||
handler(async (args) => {
|
||||
// Block dangerous modes and persistent changes without explicit opt-in
|
||||
if (!state.allowGlobalScope) {
|
||||
const dangerousModes = ['yolo', 'auto', 'auto-edit'];
|
||||
if (dangerousModes.includes(args.mode)) {
|
||||
if (GLOBAL_SCOPE_APPROVAL_MODES.includes(args.mode)) {
|
||||
return formatToolError(
|
||||
`Approval modes '${dangerousModes.join("', '")}' are restricted for security. Set QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE=true to enable.`,
|
||||
`Approval modes '${GLOBAL_SCOPE_APPROVAL_MODES.join("', '")}' are restricted for security. Set QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE=true to enable.`,
|
||||
);
|
||||
}
|
||||
if (args.persist) {
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
* signals breaking wire changes (per the design doc).
|
||||
*/
|
||||
|
||||
import {
|
||||
PERMISSION_MODES,
|
||||
type PermissionMode,
|
||||
} from '../types/permission-mode.js';
|
||||
|
||||
export type DaemonMode = 'http-bridge' | 'native';
|
||||
|
||||
/** Goal v2 wire types, duplicated here to keep the SDK independent of Core. */
|
||||
@@ -2782,14 +2787,8 @@ export interface SetSessionLanguageResult {
|
||||
* Order matters for diagnostic UIs that render the modes in the
|
||||
* advertised sequence.
|
||||
*/
|
||||
export const DAEMON_APPROVAL_MODES = [
|
||||
'plan',
|
||||
'default',
|
||||
'auto-edit',
|
||||
'auto',
|
||||
'yolo',
|
||||
] as const;
|
||||
export type DaemonApprovalMode = (typeof DAEMON_APPROVAL_MODES)[number];
|
||||
export const DAEMON_APPROVAL_MODES = PERMISSION_MODES;
|
||||
export type DaemonApprovalMode = PermissionMode;
|
||||
|
||||
/**
|
||||
* Result body of `POST /session/:id/approval-mode`. `previous` and
|
||||
|
||||
@@ -2,6 +2,7 @@ export { query } from './query/createQuery.js';
|
||||
export { AbortError, isAbortError } from './types/errors.js';
|
||||
export { Query } from './query/Query.js';
|
||||
export { SdkLogger } from './utils/logger.js';
|
||||
export { PERMISSION_MODES } from './types/permission-mode.js';
|
||||
|
||||
// Daemon HTTP client (talks to `qwen serve`)
|
||||
export {
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
type SdkControlServerTransportOptions,
|
||||
} from '../daemon-mcp/SdkControlServerTransport.js';
|
||||
import { ControlRequestType } from '../types/protocol.js';
|
||||
import type { PermissionMode } from '../types/permission-mode.js';
|
||||
|
||||
interface PendingControlRequest {
|
||||
resolve: (response: Record<string, unknown> | null) => void;
|
||||
@@ -1004,7 +1005,7 @@ export class Query implements AsyncIterable<SDKMessage> {
|
||||
return this.sendControlRequest(ControlRequestType.CONTINUE_LAST_TURN);
|
||||
}
|
||||
|
||||
async setPermissionMode(mode: string): Promise<void> {
|
||||
async setPermissionMode(mode: PermissionMode): Promise<void> {
|
||||
await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, {
|
||||
mode,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const PERMISSION_MODES = [
|
||||
'plan',
|
||||
'default',
|
||||
'auto-edit',
|
||||
'auto',
|
||||
'yolo',
|
||||
] as const;
|
||||
|
||||
export type PermissionMode = (typeof PERMISSION_MODES)[number];
|
||||
@@ -1,6 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { PermissionMode } from './permission-mode.js';
|
||||
|
||||
export type { PermissionMode } from './permission-mode.js';
|
||||
export interface Annotation {
|
||||
type: string;
|
||||
value: string;
|
||||
@@ -120,7 +123,7 @@ export interface SDKSystemMessage {
|
||||
status: string;
|
||||
}>;
|
||||
model?: string;
|
||||
permission_mode?: string;
|
||||
permission_mode?: PermissionMode;
|
||||
slash_commands?: string[];
|
||||
qwen_code_version?: string;
|
||||
output_style?: string;
|
||||
@@ -230,8 +233,6 @@ export interface SDKPartialAssistantMessage {
|
||||
parent_tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'auto' | 'yolo';
|
||||
|
||||
/**
|
||||
* Authentication types supported by the CLI.
|
||||
* Aligns with CLI's --auth-type parameter.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { CanUseTool } from './types.js';
|
||||
import type { SubagentConfig } from './protocol.js';
|
||||
import { PERMISSION_MODES } from './permission-mode.js';
|
||||
|
||||
const RESERVED_CLI_FLAGS = new Set([
|
||||
'--input-format',
|
||||
@@ -201,9 +202,7 @@ export const QueryOptionsSchema = z
|
||||
QuerySystemPromptPresetSchema,
|
||||
])
|
||||
.optional(),
|
||||
permissionMode: z
|
||||
.enum(['default', 'plan', 'auto-edit', 'auto', 'yolo'])
|
||||
.optional(),
|
||||
permissionMode: z.enum(PERMISSION_MODES).optional(),
|
||||
canUseTool: z
|
||||
.custom<CanUseTool>((val) => typeof val === 'function', {
|
||||
message: 'canUseTool must be a function',
|
||||
|
||||
@@ -307,7 +307,7 @@ export interface QueryOptions {
|
||||
* @see allowedTools For auto-approving specific tools
|
||||
* @see excludeTools For blocking specific tools
|
||||
*/
|
||||
permissionMode?: 'default' | 'plan' | 'auto-edit' | 'auto' | 'yolo';
|
||||
permissionMode?: PermissionMode;
|
||||
|
||||
/**
|
||||
* Custom permission handler for tool execution approval.
|
||||
|
||||
@@ -4,41 +4,24 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Drift detector for the SDK ↔ core approval-mode contract.
|
||||
*
|
||||
* Two sources of truth converge here:
|
||||
* 1. core's `APPROVAL_MODES` const array (= `Object.values(ApprovalMode)`,
|
||||
* consumed by `Config.setApprovalMode` and the daemon route's body
|
||||
* validator)
|
||||
* 2. SDK's `DAEMON_APPROVAL_MODES` literal tuple (mirrored for SDK
|
||||
* consumers; backs the `DaemonApprovalMode` union)
|
||||
*
|
||||
* The third source — the `ApprovalMode` enum itself — is structurally
|
||||
* tied to `APPROVAL_MODES` via `Object.values(ApprovalMode)` at the
|
||||
* core's definition site, so there's no separate assertion needed for
|
||||
* it: `APPROVAL_MODES` is always a 1:1 mirror by construction.
|
||||
*
|
||||
* If `DAEMON_APPROVAL_MODES` drifts away from `APPROVAL_MODES` (e.g. a
|
||||
* future fifth mode added to the enum but not the SDK list), this test
|
||||
* fires before runtime and before the protocol docs go out of sync.
|
||||
*
|
||||
* Lives in the SDK package (not the CLI) because the SDK already
|
||||
* depends on `@qwen-code/qwen-code-core`, so all three identifiers are
|
||||
* visible — and this is the package whose contract the test is
|
||||
* actually pinning.
|
||||
*
|
||||
* #4175 Wave 4 PR 17 (#4282 fold-in 1, wenshao review).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KNOWN_APPROVAL_MODES } from '@qwen-code/acp-bridge/bridgeClient';
|
||||
import { APPROVAL_MODES } from '@qwen-code/qwen-code-core';
|
||||
import { DAEMON_APPROVAL_MODES } from '../../src/index.js';
|
||||
import { DAEMON_APPROVAL_MODES, PERMISSION_MODES } from '../../src/index.js';
|
||||
|
||||
const crossLanguageContract = JSON.parse(
|
||||
readFileSync(
|
||||
new URL('../../../core/src/config/approval-modes.json', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
) as string[];
|
||||
|
||||
describe('approval-mode SDK ↔ core drift detection', () => {
|
||||
it('DAEMON_APPROVAL_MODES (SDK) mirrors core APPROVAL_MODES exactly', () => {
|
||||
// Order matters — diagnostic UIs that render modes in registration
|
||||
// order stay stable across SDK / daemon versions only when the two
|
||||
// tuples are sequence-equal, not just set-equal.
|
||||
expect([...DAEMON_APPROVAL_MODES]).toEqual([...APPROVAL_MODES]);
|
||||
it('keeps core and SDK contracts synchronized', () => {
|
||||
expect([...APPROVAL_MODES]).toEqual(crossLanguageContract);
|
||||
expect([...PERMISSION_MODES]).toEqual(crossLanguageContract);
|
||||
expect([...KNOWN_APPROVAL_MODES]).toEqual(crossLanguageContract);
|
||||
expect(DAEMON_APPROVAL_MODES).toBe(PERMISSION_MODES);
|
||||
});
|
||||
});
|
||||
@@ -577,49 +577,30 @@ describe('serve-bridge', () => {
|
||||
expect(result.content[0].text).toContain('Global scope is disabled');
|
||||
});
|
||||
|
||||
it('should reject yolo approval mode without allowGlobalScope', async () => {
|
||||
const { state } = makeMockState({
|
||||
defaultSessionId: 'test-session',
|
||||
});
|
||||
state.allowGlobalScope = false;
|
||||
it.each(['auto-edit', 'auto', 'yolo'] as const)(
|
||||
'should reject %s approval mode without allowGlobalScope',
|
||||
async (mode) => {
|
||||
const { state } = makeMockState({
|
||||
defaultSessionId: 'test-session',
|
||||
});
|
||||
state.allowGlobalScope = false;
|
||||
|
||||
const { workspaceWriteTools } = await import(
|
||||
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
|
||||
);
|
||||
const tools = workspaceWriteTools(state);
|
||||
const approvalTool = tools.find(
|
||||
(t: { name: string }) => t.name === 'session_set_approval_mode',
|
||||
);
|
||||
const { workspaceWriteTools } = await import(
|
||||
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
|
||||
);
|
||||
const tools = workspaceWriteTools(state);
|
||||
const approvalTool = tools.find(
|
||||
(t: { name: string }) => t.name === 'session_set_approval_mode',
|
||||
);
|
||||
|
||||
const result = await approvalTool.handler(
|
||||
{ mode: 'yolo', session_id: 'test-session' },
|
||||
{},
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('restricted for security');
|
||||
});
|
||||
|
||||
it('should reject auto-edit approval mode without allowGlobalScope', async () => {
|
||||
const { state } = makeMockState({
|
||||
defaultSessionId: 'test-session',
|
||||
});
|
||||
state.allowGlobalScope = false;
|
||||
|
||||
const { workspaceWriteTools } = await import(
|
||||
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
|
||||
);
|
||||
const tools = workspaceWriteTools(state);
|
||||
const approvalTool = tools.find(
|
||||
(t: { name: string }) => t.name === 'session_set_approval_mode',
|
||||
);
|
||||
|
||||
const result = await approvalTool.handler(
|
||||
{ mode: 'auto-edit', session_id: 'test-session' },
|
||||
{},
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('restricted for security');
|
||||
});
|
||||
const result = await approvalTool.handler(
|
||||
{ mode, session_id: 'test-session' },
|
||||
{},
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('restricted for security');
|
||||
},
|
||||
);
|
||||
|
||||
it('should reject persistent approval mode change without allowGlobalScope', async () => {
|
||||
const { state } = makeMockState({
|
||||
@@ -643,6 +624,62 @@ describe('serve-bridge', () => {
|
||||
expect(result.content[0].text).toContain('restricted for security');
|
||||
});
|
||||
|
||||
it.each(['default', 'plan'] as const)(
|
||||
'should allow local %s approval mode changes without allowGlobalScope',
|
||||
async (mode) => {
|
||||
const { state, calls } = makeMockState({
|
||||
defaultSessionId: 'default-session',
|
||||
});
|
||||
state.allowGlobalScope = false;
|
||||
|
||||
const { workspaceWriteTools } = await import(
|
||||
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
|
||||
);
|
||||
const tools = workspaceWriteTools(state);
|
||||
const approvalTool = tools.find(
|
||||
(t: { name: string }) => t.name === 'session_set_approval_mode',
|
||||
);
|
||||
|
||||
const result = await approvalTool.handler(
|
||||
{ mode, session_id: 'other-session' },
|
||||
{},
|
||||
);
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(calls[0]?.url).toBe(
|
||||
'http://127.0.0.1:4170/session/other-session/approval-mode',
|
||||
);
|
||||
expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ mode });
|
||||
},
|
||||
);
|
||||
|
||||
it('should allow persistent elevated approval mode changes with allowGlobalScope', async () => {
|
||||
const { state, calls } = makeMockState({
|
||||
defaultSessionId: 'test-session',
|
||||
});
|
||||
state.allowGlobalScope = true;
|
||||
|
||||
const { workspaceWriteTools } = await import(
|
||||
'../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js'
|
||||
);
|
||||
const tools = workspaceWriteTools(state);
|
||||
const approvalTool = tools.find(
|
||||
(t: { name: string }) => t.name === 'session_set_approval_mode',
|
||||
);
|
||||
|
||||
const result = await approvalTool.handler(
|
||||
{ mode: 'auto', persist: true, session_id: 'test-session' },
|
||||
{},
|
||||
);
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(calls[0]?.url).toBe(
|
||||
'http://127.0.0.1:4170/session/test-session/approval-mode',
|
||||
);
|
||||
expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({
|
||||
mode: 'auto',
|
||||
persist: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow read-only agents_manage actions with global scope', async () => {
|
||||
const { state } = makeMockState({
|
||||
defaultSessionId: 'test-session',
|
||||
|
||||
@@ -291,14 +291,9 @@ export class QwenAgentManager {
|
||||
const obj = (init || {}) as Record<string, unknown>;
|
||||
const modes = obj['modes'] as
|
||||
| {
|
||||
currentModeId?:
|
||||
| 'plan'
|
||||
| 'default'
|
||||
| 'auto-edit'
|
||||
| 'auto'
|
||||
| 'yolo';
|
||||
currentModeId?: ApprovalModeValue;
|
||||
availableModes?: Array<{
|
||||
id: 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo';
|
||||
id: ApprovalModeValue;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
@@ -1409,9 +1404,9 @@ export class QwenAgentManager {
|
||||
*/
|
||||
onModeInfo(
|
||||
callback: (info: {
|
||||
currentModeId?: 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo';
|
||||
currentModeId?: ApprovalModeValue;
|
||||
availableModes?: Array<{
|
||||
id: 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo';
|
||||
id: ApprovalModeValue;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
@@ -1424,11 +1419,7 @@ export class QwenAgentManager {
|
||||
/**
|
||||
* Register mode changed callback
|
||||
*/
|
||||
onModeChanged(
|
||||
callback: (
|
||||
modeId: 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo',
|
||||
) => void,
|
||||
): void {
|
||||
onModeChanged(callback: (modeId: ApprovalModeValue) => void): void {
|
||||
this.callbacks.onModeChanged = callback;
|
||||
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
||||
}
|
||||
|
||||
@@ -4,29 +4,34 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enum for approval modes with UI-friendly labels
|
||||
* Represents the different approval modes available in the ACP protocol
|
||||
* with their corresponding user-facing display names
|
||||
*/
|
||||
export enum ApprovalMode {
|
||||
PLAN = 'plan',
|
||||
DEFAULT = 'default',
|
||||
AUTO_EDIT = 'auto-edit',
|
||||
AUTO = 'auto',
|
||||
YOLO = 'yolo',
|
||||
}
|
||||
import {
|
||||
DAEMON_APPROVAL_MODES,
|
||||
type DaemonApprovalMode,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
|
||||
type ApprovalModeByValue = {
|
||||
[Mode in DaemonApprovalMode]: Mode;
|
||||
};
|
||||
|
||||
const APPROVAL_MODE_BY_VALUE = Object.fromEntries(
|
||||
DAEMON_APPROVAL_MODES.map((mode) => [mode, mode]),
|
||||
) as ApprovalModeByValue;
|
||||
|
||||
export const ApprovalMode = {
|
||||
PLAN: APPROVAL_MODE_BY_VALUE.plan,
|
||||
DEFAULT: APPROVAL_MODE_BY_VALUE.default,
|
||||
AUTO_EDIT: APPROVAL_MODE_BY_VALUE['auto-edit'],
|
||||
AUTO: APPROVAL_MODE_BY_VALUE.auto,
|
||||
YOLO: APPROVAL_MODE_BY_VALUE.yolo,
|
||||
} as const;
|
||||
|
||||
export type ApprovalMode = DaemonApprovalMode;
|
||||
|
||||
/**
|
||||
* Mapping from string values to enum values for runtime conversion
|
||||
*/
|
||||
export const APPROVAL_MODE_MAP: Record<string, ApprovalMode> = {
|
||||
plan: ApprovalMode.PLAN,
|
||||
default: ApprovalMode.DEFAULT,
|
||||
'auto-edit': ApprovalMode.AUTO_EDIT,
|
||||
auto: ApprovalMode.AUTO,
|
||||
yolo: ApprovalMode.YOLO,
|
||||
};
|
||||
export const APPROVAL_MODE_MAP: Record<string, ApprovalMode> =
|
||||
APPROVAL_MODE_BY_VALUE;
|
||||
|
||||
/**
|
||||
* UI display information for each approval mode
|
||||
|
||||
@@ -4,13 +4,4 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type for approval mode values
|
||||
* Used in ACP protocol for controlling agent behavior
|
||||
*/
|
||||
export type ApprovalModeValue =
|
||||
| 'plan'
|
||||
| 'default'
|
||||
| 'auto-edit'
|
||||
| 'auto'
|
||||
| 'yolo';
|
||||
export type { DaemonApprovalMode as ApprovalModeValue } from '@qwen-code/sdk/daemon';
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { ModelInfo } from '@agentclientprotocol/sdk';
|
||||
import { knownTokenLimit } from '@qwen-code/qwen-code-core';
|
||||
import { DAEMON_APPROVAL_MODES } from '@qwen-code/sdk/daemon';
|
||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||
|
||||
type AcpMeta = Record<string, unknown>;
|
||||
@@ -114,17 +115,9 @@ export interface SessionModeState {
|
||||
}>;
|
||||
}
|
||||
|
||||
const APPROVAL_MODE_VALUES: ApprovalModeValue[] = [
|
||||
'plan',
|
||||
'default',
|
||||
'auto-edit',
|
||||
'auto',
|
||||
'yolo',
|
||||
];
|
||||
|
||||
const isApprovalModeValue = (value: unknown): value is ApprovalModeValue =>
|
||||
typeof value === 'string' &&
|
||||
APPROVAL_MODE_VALUES.includes(value as ApprovalModeValue);
|
||||
DAEMON_APPROVAL_MODES.includes(value as ApprovalModeValue);
|
||||
|
||||
/**
|
||||
* Extract complete model state from ACP `session/new` result.
|
||||
|
||||
@@ -362,12 +362,7 @@ export class WebViewProvider {
|
||||
// Surface available modes and current mode (from ACP initialize)
|
||||
this.agentManager.onModeInfo((info) => {
|
||||
try {
|
||||
const current = (info?.currentModeId || null) as
|
||||
| 'plan'
|
||||
| 'default'
|
||||
| 'auto-edit'
|
||||
| 'yolo'
|
||||
| null;
|
||||
const current = info?.currentModeId ?? null;
|
||||
this.currentModeId = current;
|
||||
} catch (_error) {
|
||||
// Ignore error when parsing mode info
|
||||
|
||||
Reference in new issue
Block a user