Merge remote-tracking branch 'origin/main' into feat/channels-telegram

This commit is contained in:
tanzhenxin 2026-03-30 19:17:22 +08:00
commit 7962d4f790
85 changed files with 3878 additions and 1263 deletions

View file

@ -57,7 +57,7 @@ import { buildAuthMethods } from './authMethods.js';
import { AcpFileSystemService } from './service/filesystem.js';
import { Readable, Writable } from 'node:stream';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope } from '../config/settings.js';
import { loadSettings, SettingScope } from '../config/settings.js';
import type { ApprovalModeValue } from './session/types.js';
import { z } from 'zod';
import type { CliArgs } from '../config/config.js';
@ -223,30 +223,18 @@ class QwenAgent implements Agent {
return sessionService.sessionExists(params.sessionId);
},
);
if (!exists) {
throw RequestError.invalidParams(
undefined,
`Session not found for id: ${params.sessionId}`,
);
}
const config = await this.newSessionConfig(
params.cwd,
params.mcpServers,
params.sessionId,
exists,
);
await this.ensureAuthenticated(config);
this.setupFileSystem(config);
const sessionData = config.getResumedSessionData();
if (!sessionData) {
throw RequestError.internalError(
undefined,
`Failed to load session data for id: ${params.sessionId}`,
);
}
await this.createAndStoreSession(config, sessionData.conversation);
await this.createAndStoreSession(config, sessionData?.conversation);
const modesData = this.buildModesData(config);
const availableModels = this.buildAvailableModels(config);
@ -380,7 +368,9 @@ class QwenAgent implements Agent {
cwd: string,
mcpServers: McpServer[],
sessionId?: string,
resume?: boolean,
): Promise<Config> {
this.settings = loadSettings(cwd);
const mergedMcpServers = { ...this.settings.merged.mcpServers };
for (const server of mcpServers) {
@ -402,11 +392,11 @@ class QwenAgent implements Agent {
const settings = { ...this.settings.merged, mcpServers: mergedMcpServers };
const argvForSession = {
...this.argv,
resume: sessionId,
...(resume ? { resume: sessionId } : { sessionId }),
continue: false,
};
const config = await loadCliConfig(settings, argvForSession, cwd);
const config = await loadCliConfig(settings, argvForSession, cwd, []);
await config.initialize();
return config;
}

View file

@ -34,6 +34,7 @@ describe('Session', () => {
let currentAuthType: AuthType;
let switchModelSpy: ReturnType<typeof vi.fn>;
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
let mockToolRegistry: { getTool: ReturnType<typeof vi.fn> };
beforeEach(() => {
currentModel = 'qwen3-code-plus';
@ -50,7 +51,7 @@ describe('Session', () => {
addHistory: vi.fn(),
} as unknown as GeminiChat;
const toolRegistry = { getTool: vi.fn() };
mockToolRegistry = { getTool: vi.fn() };
const fileService = { shouldGitIgnoreFile: vi.fn().mockReturnValue(false) };
mockConfig = {
@ -65,8 +66,9 @@ describe('Session', () => {
getChatRecordingService: vi.fn().mockReturnValue({
recordUserMessage: vi.fn(),
recordUiTelemetryEvent: vi.fn(),
recordToolResult: vi.fn(),
}),
getToolRegistry: vi.fn().mockReturnValue(toolRegistry),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getFileService: vi.fn().mockReturnValue(fileService),
getFileFilteringRespectGitIgnore: vi.fn().mockReturnValue(true),
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
@ -275,5 +277,204 @@ describe('Session', () => {
expect.any(Function),
);
});
it('hides allow-always options when confirmation already forbids them', async () => {
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'ok',
returnDisplay: 'ok',
});
const onConfirmSpy = vi.fn().mockResolvedValue(undefined);
const invocation = {
params: { path: '/tmp/file.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
getConfirmationDetails: vi.fn().mockResolvedValue({
type: 'info',
title: 'Need permission',
prompt: 'Allow?',
hideAlwaysAllow: true,
onConfirm: onConfirmSpy,
}),
getDescription: vi.fn().mockReturnValue('Inspect file'),
toolLocations: vi.fn().mockReturnValue([]),
execute: executeSpy,
};
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue(invocation),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.DEFAULT);
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
(async function* () {
yield {
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-1',
name: 'read_file',
args: { path: '/tmp/file.txt' },
},
],
},
};
})(),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'run tool' }],
});
expect(mockClient.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
options: [
expect.objectContaining({ kind: 'allow_once' }),
expect.objectContaining({ kind: 'reject_once' }),
],
}),
);
const options = (mockClient.requestPermission as ReturnType<typeof vi.fn>)
.mock.calls[0][0].options as Array<{ kind: string }>;
expect(options.some((option) => option.kind === 'allow_always')).toBe(
false,
);
});
it('returns permission error for disabled tools (L1 isToolEnabled check)', async () => {
const executeSpy = vi.fn();
const invocation = {
params: { path: '/tmp/file.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
getConfirmationDetails: vi.fn().mockResolvedValue({
type: 'info',
title: 'Need permission',
prompt: 'Allow?',
onConfirm: vi.fn(),
}),
getDescription: vi.fn().mockReturnValue('Write file'),
toolLocations: vi.fn().mockReturnValue([]),
execute: executeSpy,
};
const tool = {
name: 'write_file',
kind: core.Kind.Edit,
build: vi.fn().mockReturnValue(invocation),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.DEFAULT);
// Mock a PermissionManager that denies the tool
mockConfig.getPermissionManager = vi.fn().mockReturnValue({
isToolEnabled: vi.fn().mockResolvedValue(false),
});
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
(async function* () {
yield {
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-denied',
name: 'write_file',
args: { path: '/tmp/file.txt' },
},
],
},
};
})(),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'write something' }],
});
// Tool should NOT have been executed
expect(executeSpy).not.toHaveBeenCalled();
// No permission dialog should have been opened
expect(mockClient.requestPermission).not.toHaveBeenCalled();
});
it('respects permission-request hook allow decisions without opening ACP permission dialog', async () => {
const hookSpy = vi
.spyOn(core, 'firePermissionRequestHook')
.mockResolvedValue({
hasDecision: true,
shouldAllow: true,
updatedInput: { path: '/tmp/updated.txt' },
denyMessage: undefined,
});
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'ok',
returnDisplay: 'ok',
});
const onConfirmSpy = vi.fn().mockResolvedValue(undefined);
const invocation = {
params: { path: '/tmp/original.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
getConfirmationDetails: vi.fn().mockResolvedValue({
type: 'info',
title: 'Need permission',
prompt: 'Allow?',
onConfirm: onConfirmSpy,
}),
getDescription: vi.fn().mockReturnValue('Inspect file'),
toolLocations: vi.fn().mockReturnValue([]),
execute: executeSpy,
};
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue(invocation),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockConfig.getApprovalMode = vi
.fn()
.mockReturnValue(ApprovalMode.DEFAULT);
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
mockConfig.getEnableHooks = vi.fn().mockReturnValue(true);
mockConfig.getMessageBus = vi.fn().mockReturnValue({});
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
(async function* () {
yield {
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-2',
name: 'read_file',
args: { path: '/tmp/original.txt' },
},
],
},
};
})(),
);
try {
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'run tool' }],
});
} finally {
hookSpy.mockRestore();
}
expect(mockClient.requestPermission).not.toHaveBeenCalled();
expect(onConfirmSpy).toHaveBeenCalledWith(
core.ToolConfirmationOutcome.ProceedOnce,
);
expect(invocation.params).toEqual({ path: '/tmp/updated.txt' });
expect(executeSpy).toHaveBeenCalled();
});
});
});

View file

@ -36,6 +36,13 @@ import {
readManyFiles,
Storage,
ToolNames,
buildPermissionCheckContext,
evaluatePermissionRules,
fireNotificationHook,
firePermissionRequestHook,
injectPermissionRulesIfMissing,
NotificationType,
persistPermissionOutcome,
} from '@qwen-code/qwen-code-core';
import { RequestError } from '@agentclientprotocol/sdk';
@ -43,7 +50,6 @@ import type {
AvailableCommand,
ContentBlock,
EmbeddedResourceResource,
PermissionOption,
PromptRequest,
PromptResponse,
RequestPermissionRequest,
@ -54,7 +60,6 @@ import type {
SetSessionModeResponse,
SetSessionModelRequest,
SetSessionModelResponse,
ToolCallContent,
AgentSideConnection,
} from '@agentclientprotocol/sdk';
import type { LoadedSettings } from '../../config/settings.js';
@ -79,6 +84,10 @@ import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
import { PlanEmitter } from './emitters/PlanEmitter.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import { SubAgentTracker } from './SubAgentTracker.js';
import {
buildPermissionRequestContent,
toPermissionOptions,
} from './permissionUtils.js';
const debugLogger = createDebugLogger('SESSION');
@ -487,13 +496,34 @@ export class Session implements SessionContext {
await this.sendUpdate(update);
}
private async resolveIdeDiffForOutcome(
confirmationDetails: ToolCallConfirmationDetails,
outcome: ToolConfirmationOutcome,
): Promise<void> {
if (
confirmationDetails.type !== 'edit' ||
!confirmationDetails.ideConfirmation
) {
return;
}
const { IdeClient } = await import('@qwen-code/qwen-code-core');
const ideClient = await IdeClient.getInstance();
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient.resolveDiffFromCli(
confirmationDetails.filePath,
cliOutcome as 'accepted' | 'rejected',
);
}
private async runTool(
abortSignal: AbortSignal,
promptId: string,
fc: FunctionCall,
): Promise<Part[]> {
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
const args = (fc.args ?? {}) as Record<string, unknown>;
let args = (fc.args ?? {}) as Record<string, unknown>;
const startTime = Date.now();
@ -526,19 +556,49 @@ export class Session implements SessionContext {
];
};
const earlyErrorResponse = async (
error: Error,
toolName = fc.name ?? 'unknown_tool',
) => {
if (toolName !== TodoWriteTool.Name) {
await this.toolCallEmitter.emitError(callId, toolName, error);
}
const errorParts = errorResponse(error);
this.config.getChatRecordingService()?.recordToolResult(errorParts, {
callId,
status: 'error',
resultDisplay: undefined,
error,
errorType: undefined,
});
return errorParts;
};
if (!fc.name) {
return errorResponse(new Error('Missing function name'));
return earlyErrorResponse(new Error('Missing function name'));
}
const toolRegistry = this.config.getToolRegistry();
const tool = toolRegistry.getTool(fc.name as string);
if (!tool) {
return errorResponse(
return earlyErrorResponse(
new Error(`Tool "${fc.name}" not found in registry.`),
);
}
// ---- L1: Tool enablement check ----
const pm = this.config.getPermissionManager?.();
if (pm && !(await pm.isToolEnabled(fc.name as string))) {
return earlyErrorResponse(
new Error(
`Qwen Code requires permission to use "${fc.name}", but that permission was declined.`,
),
fc.name,
);
}
// Detect TodoWriteTool early - route to plan updates instead of tool_call events
const isTodoWriteTool = tool.name === TodoWriteTool.Name;
const isAgentTool = tool.name === AgentTool.Name;
@ -577,127 +637,238 @@ export class Session implements SessionContext {
);
}
// Use the new permission flow: getDefaultPermission + getConfirmationDetails
// ask_user_question must always go through confirmation even in YOLO mode
// so the user always has a chance to respond to questions.
// L3→L4→L5 Permission Flow (aligned with coreToolScheduler)
//
// L3: Tool's intrinsic default permission
// L4: PermissionManager rule override
// L5: ApprovalMode override (YOLO / AUTO_EDIT / PLAN)
//
// AUTO_EDIT auto-approval is handled HERE, same as coreToolScheduler.
// The VS Code extension is just a UI layer for requestPermission.
const isAskUserQuestionTool = fc.name === ToolNames.ASK_USER_QUESTION;
// ---- L3: Tool's default permission ----
// In YOLO mode, force 'allow' for everything except ask_user_question.
const defaultPermission =
this.config.getApprovalMode() !== ApprovalMode.YOLO ||
isAskUserQuestionTool
? await invocation.getDefaultPermission()
: 'allow';
const needsConfirmation = defaultPermission === 'ask';
// ---- L4: PermissionManager override (if relevant rules exist) ----
const toolParams = invocation.params as Record<string, unknown>;
const pmCtx = buildPermissionCheckContext(
fc.name,
toolParams,
this.config.getTargetDir?.() ?? '',
);
const { finalPermission, pmForcedAsk } = await evaluatePermissionRules(
pm,
defaultPermission,
pmCtx,
);
// Check for plan mode enforcement - block non-read-only tools
// but allow ask_user_question so users can answer clarification questions
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
const needsConfirmation = finalPermission === 'ask';
// ---- L5: ApprovalMode overrides ----
const approvalMode = this.config.getApprovalMode();
const isPlanMode = approvalMode === ApprovalMode.PLAN;
// PLAN mode: block non-read-only tools
if (
isPlanMode &&
!isExitPlanModeTool &&
!isAskUserQuestionTool &&
needsConfirmation
) {
// In plan mode, block any tool that requires confirmation (write operations)
return errorResponse(
return earlyErrorResponse(
new Error(
`Plan mode is active. The tool "${fc.name}" cannot be executed because it modifies the system. ` +
'Please use the exit_plan_mode tool to present your plan and exit plan mode before making changes.',
),
fc.name,
);
}
if (defaultPermission === 'deny') {
return errorResponse(
if (finalPermission === 'deny') {
return earlyErrorResponse(
new Error(
`Tool "${fc.name}" is denied: command substitution is not allowed for security reasons.`,
defaultPermission === 'deny'
? `Tool "${fc.name}" is denied: command substitution is not allowed for security reasons.`
: `Tool "${fc.name}" is denied by permission rules.`,
),
fc.name,
);
}
let didRequestPermission = false;
if (needsConfirmation) {
const confirmationDetails =
await invocation.getConfirmationDetails(abortSignal);
const content: ToolCallContent[] = [];
if (confirmationDetails.type === 'edit') {
content.push({
type: 'diff',
path: confirmationDetails.filePath || confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
});
}
// Centralised rule injection (for display and persistence)
injectPermissionRulesIfMissing(confirmationDetails, pmCtx);
// Add plan content for exit_plan_mode
if (confirmationDetails.type === 'plan') {
content.push({
type: 'content',
content: {
type: 'text',
text: confirmationDetails.plan,
},
});
}
const messageBus = this.config.getMessageBus?.();
const hooksEnabled = this.config.getEnableHooks?.() ?? false;
let hookHandled = false;
// Map tool kind, using switch_mode for exit_plan_mode per ACP spec
const mappedKind = this.toolCallEmitter.mapToolKind(tool.kind, fc.name);
if (hooksEnabled && messageBus) {
const hookResult = await firePermissionRequestHook(
messageBus,
fc.name,
args,
String(approvalMode),
);
const params: RequestPermissionRequest = {
sessionId: this.sessionId,
options: toPermissionOptions(confirmationDetails),
toolCall: {
toolCallId: callId,
status: 'pending',
title: invocation.getDescription(),
content,
locations: invocation.toolLocations(),
kind: mappedKind,
rawInput: args,
},
};
if (hookResult.hasDecision) {
hookHandled = true;
if (hookResult.shouldAllow) {
if (hookResult.updatedInput) {
args = hookResult.updatedInput;
invocation.params =
hookResult.updatedInput as typeof invocation.params;
}
const output = (await this.client.requestPermission(
params,
)) as RequestPermissionResponse & {
answers?: Record<string, string>;
};
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
await confirmationDetails.onConfirm(outcome, {
answers: output.answers,
});
// After exit_plan_mode confirmation, send current_mode_update notification
if (isExitPlanModeTool && outcome !== ToolConfirmationOutcome.Cancel) {
await this.sendCurrentModeUpdateNotification(outcome);
}
switch (outcome) {
case ToolConfirmationOutcome.Cancel:
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case ToolConfirmationOutcome.ProceedOnce:
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysProject:
case ToolConfirmationOutcome.ProceedAlwaysUser:
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ModifyWithEditor:
break;
default: {
const resultOutcome: never = outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
await this.resolveIdeDiffForOutcome(
confirmationDetails,
ToolConfirmationOutcome.ProceedOnce,
);
await confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
} else {
return earlyErrorResponse(
new Error(
hookResult.denyMessage ||
`Permission denied by hook for "${fc.name}"`,
),
fc.name,
);
}
}
}
} else if (!isTodoWriteTool) {
// Skip tool_call event for TodoWriteTool - use ToolCallEmitter
// AUTO_EDIT mode: auto-approve edit and info tools
// (same as coreToolScheduler L5 — NOT delegated to the extension)
if (
approvalMode === ApprovalMode.AUTO_EDIT &&
(confirmationDetails.type === 'edit' ||
confirmationDetails.type === 'info')
) {
// Auto-approve, skip requestPermission.
// didRequestPermission stays false → emitStart below.
} else if (!hookHandled) {
// Show permission dialog via ACP requestPermission
didRequestPermission = true;
const content = buildPermissionRequestContent(confirmationDetails);
// Map tool kind, using switch_mode for exit_plan_mode per ACP spec
const mappedKind = this.toolCallEmitter.mapToolKind(
tool.kind,
fc.name,
);
if (hooksEnabled && messageBus) {
void fireNotificationHook(
messageBus,
`Qwen Code needs your permission to use ${fc.name}`,
NotificationType.PermissionPrompt,
'Permission needed',
);
}
const params: RequestPermissionRequest = {
sessionId: this.sessionId,
options: toPermissionOptions(confirmationDetails, pmForcedAsk),
toolCall: {
toolCallId: callId,
status: 'pending',
title: invocation.getDescription(),
content,
locations: invocation.toolLocations(),
kind: mappedKind,
rawInput: args,
},
};
const output = (await this.client.requestPermission(
params,
)) as RequestPermissionResponse & {
answers?: Record<string, string>;
};
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
await this.resolveIdeDiffForOutcome(confirmationDetails, outcome);
await confirmationDetails.onConfirm(outcome, {
answers: output.answers,
});
// Persist permission rules when user explicitly chose "Always Allow".
// This branch is only reached for tools that went through
// requestPermission (user saw dialog and made a choice).
// AUTO_EDIT auto-approved tools never reach here.
if (
outcome === ToolConfirmationOutcome.ProceedAlways ||
outcome === ToolConfirmationOutcome.ProceedAlwaysProject ||
outcome === ToolConfirmationOutcome.ProceedAlwaysUser
) {
await persistPermissionOutcome(
outcome,
confirmationDetails,
this.config.getOnPersistPermissionRule?.(),
this.config.getPermissionManager?.(),
{ answers: output.answers },
);
}
// After exit_plan_mode confirmation, send current_mode_update
if (
isExitPlanModeTool &&
outcome !== ToolConfirmationOutcome.Cancel
) {
await this.sendCurrentModeUpdateNotification(outcome);
}
// After edit tool ProceedAlways, notify the client about mode change
if (
confirmationDetails.type === 'edit' &&
outcome === ToolConfirmationOutcome.ProceedAlways
) {
await this.sendCurrentModeUpdateNotification(outcome);
}
switch (outcome) {
case ToolConfirmationOutcome.Cancel:
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case ToolConfirmationOutcome.ProceedOnce:
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysProject:
case ToolConfirmationOutcome.ProceedAlwaysUser:
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ModifyWithEditor:
break;
default: {
const resultOutcome: never = outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
}
}
if (!didRequestPermission && !isTodoWriteTool) {
// Auto-approved (L3 allow / L4 PM allow / L5 YOLO|AUTO_EDIT)
// → emit tool_call start notification
const startParams: ToolCallStartParams = {
callId,
toolName: fc.name,
@ -1041,113 +1212,3 @@ export class Session implements SessionContext {
}
}
}
// ============================================================================
// Helper functions
// ============================================================================
const basicPermissionOptions = [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Reject',
kind: 'reject_once',
},
] as const;
function toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
): PermissionOption[] {
switch (confirmation.type) {
case 'edit':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits',
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'exec':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${confirmation.rootCommand}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${confirmation.rootCommand}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${confirmation.toolName}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${confirmation.toolName}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'plan':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Yes, and auto-accept edits`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: `Yes, and manually approve edits`,
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: `No, keep planning (esc)`,
kind: 'reject_once',
},
];
case 'ask_user_question':
return [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Submit',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Cancel',
kind: 'reject_once',
},
];
default: {
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);
}
}
}

View file

@ -488,6 +488,9 @@ describe('SubAgentTracker', () => {
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
{
answers: undefined,
},
);
});
});
@ -528,7 +531,58 @@ describe('SubAgentTracker', () => {
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
expect(respondSpy).toHaveBeenCalledWith(
ToolConfirmationOutcome.Cancel,
{
answers: undefined,
},
);
});
});
it('should forward answers payload from ACP permission responses', async () => {
requestPermissionSpy.mockResolvedValue({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedOnce,
},
answers: {
answer: 'yes',
},
});
tracker.setup(eventEmitter, abortController.signal);
const respondSpy = vi.fn().mockResolvedValue(undefined);
const confirmationDetails = {
type: 'ask_user_question',
title: 'Question',
questions: [
{
question: 'Continue?',
header: 'Question',
options: [],
multiSelect: false,
},
],
} as unknown as AgentApprovalRequestEvent['confirmationDetails'];
const event = createApprovalEvent({
name: 'ask_user_question',
callId: 'call-ask',
confirmationDetails,
respond: respondSpy,
});
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
{
answers: {
answer: 'yes',
},
},
);
});
});

View file

@ -26,44 +26,15 @@ import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import type {
AgentSideConnection,
PermissionOption,
RequestPermissionRequest,
ToolCallContent,
} from '@agentclientprotocol/sdk';
import {
buildPermissionRequestContent,
toPermissionOptions,
} from './permissionUtils.js';
const debugLogger = createDebugLogger('ACP_SUBAGENT_TRACKER');
/**
* Permission option kind type matching ACP schema.
*/
type PermissionKind =
| 'allow_once'
| 'reject_once'
| 'allow_always'
| 'reject_always';
/**
* Configuration for permission options displayed to users.
*/
interface PermissionOptionConfig {
optionId: ToolConfirmationOutcome;
name: string;
kind: PermissionKind;
}
const basicPermissionOptions: readonly PermissionOptionConfig[] = [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Reject',
kind: 'reject_once',
},
] as const;
/**
* Tracks and emits events for sub-agent tool calls within AgentTool execution.
*
@ -219,24 +190,6 @@ export class SubAgentTracker {
if (abortSignal.aborted) return;
const state = this.toolStates.get(event.callId);
const content: ToolCallContent[] = [];
// Handle edit confirmation type - show diff
if (event.confirmationDetails.type === 'edit') {
const editDetails = event.confirmationDetails as unknown as {
type: 'edit';
fileName: string;
filePath: string;
originalContent: string | null;
newContent: string;
};
content.push({
type: 'diff',
path: editDetails.filePath || editDetails.fileName,
oldText: editDetails.originalContent ?? '',
newText: editDetails.newContent,
});
}
// Build permission request
const fullConfirmationDetails = {
@ -251,12 +204,12 @@ export class SubAgentTracker {
const params: RequestPermissionRequest = {
sessionId: this.ctx.sessionId,
options: this.toPermissionOptions(fullConfirmationDetails),
options: toPermissionOptions(fullConfirmationDetails),
toolCall: {
toolCallId: event.callId,
status: 'pending',
title,
content,
content: buildPermissionRequestContent(fullConfirmationDetails),
locations,
kind,
rawInput: state?.args,
@ -274,7 +227,9 @@ export class SubAgentTracker {
.parse(output.outcome.optionId);
// Respond to subagent with the outcome
await event.respond(outcome);
await event.respond(outcome, {
answers: 'answers' in output ? output.answers : undefined,
});
} catch (error) {
// If permission request fails, cancel the tool call
debugLogger.error(
@ -324,92 +279,4 @@ export class SubAgentTracker {
);
};
}
/**
* Converts confirmation details to permission options for the client.
*/
private toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
): PermissionOption[] {
const hideAlwaysAllow =
'hideAlwaysAllow' in confirmation && confirmation.hideAlwaysAllow;
switch (confirmation.type) {
case 'edit':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits',
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'exec':
return [
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'mcp':
return [
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'info':
return [
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: 'Always Allow in project',
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: 'Always Allow for user',
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'plan':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Always Allow Plans',
kind: 'allow_always',
},
...basicPermissionOptions,
];
default: {
// Fallback for unknown types
return [...basicPermissionOptions];
}
}
}
}

View file

@ -0,0 +1,54 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { toPermissionOptions } from './permissionUtils.js';
describe('permissionUtils', () => {
describe('toPermissionOptions', () => {
it('uses permissionRules for exec always-allow labels when available', () => {
const options = toPermissionOptions({
type: 'exec',
title: 'Confirm Shell Command',
command: 'git add package.json',
rootCommand: 'git',
permissionRules: ['Bash(git add *)'],
onConfirm: async () => undefined,
});
expect(options).toContainEqual(
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: 'Always Allow in project: git add *',
}),
);
expect(options).toContainEqual(
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: 'Always Allow for user: git add *',
}),
);
});
it('falls back to rootCommand when exec permissionRules are unavailable', () => {
const options = toPermissionOptions({
type: 'exec',
title: 'Confirm Shell Command',
command: 'git add package.json',
rootCommand: 'git',
onConfirm: async () => undefined,
});
expect(options).toContainEqual(
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: 'Always Allow in project: git',
}),
);
});
});
});

View file

@ -0,0 +1,208 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolCallConfirmationDetails } from '@qwen-code/qwen-code-core';
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import type {
PermissionOption,
ToolCallContent,
} from '@agentclientprotocol/sdk';
const basicPermissionOptions = [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Reject',
kind: 'reject_once',
},
] as const satisfies readonly PermissionOption[];
function supportsHideAlwaysAllow(
confirmation: ToolCallConfirmationDetails,
): confirmation is Exclude<
ToolCallConfirmationDetails,
{ type: 'ask_user_question' }
> {
return confirmation.type !== 'ask_user_question';
}
function filterAlwaysAllowOptions(
confirmation: ToolCallConfirmationDetails,
options: PermissionOption[],
forceHideAlwaysAllow = false,
): PermissionOption[] {
const hideAlwaysAllow =
forceHideAlwaysAllow ||
(supportsHideAlwaysAllow(confirmation) &&
confirmation.hideAlwaysAllow === true);
return hideAlwaysAllow
? options.filter((option) => option.kind !== 'allow_always')
: options;
}
function formatExecPermissionScopeLabel(
confirmation: Extract<ToolCallConfirmationDetails, { type: 'exec' }>,
): string {
const permissionRules = confirmation.permissionRules ?? [];
const bashRules = permissionRules
.map((rule) => {
const match = /^Bash\((.*)\)$/.exec(rule.trim());
return match?.[1]?.trim() || undefined;
})
.filter((rule): rule is string => Boolean(rule));
const uniqueRules = [...new Set(bashRules)];
if (uniqueRules.length === 1) {
return uniqueRules[0];
}
if (uniqueRules.length > 1) {
return uniqueRules.join(', ');
}
return confirmation.rootCommand;
}
export function buildPermissionRequestContent(
confirmation: ToolCallConfirmationDetails,
): ToolCallContent[] {
const content: ToolCallContent[] = [];
if (confirmation.type === 'edit') {
content.push({
type: 'diff',
path: confirmation.filePath ?? confirmation.fileName,
oldText: confirmation.originalContent ?? '',
newText: confirmation.newContent,
});
}
if (confirmation.type === 'plan') {
content.push({
type: 'content',
content: {
type: 'text',
text: confirmation.plan,
},
});
}
return content;
}
export function toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
forceHideAlwaysAllow = false,
): PermissionOption[] {
switch (confirmation.type) {
case 'edit':
return filterAlwaysAllowOptions(
confirmation,
[
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits',
kind: 'allow_always',
},
...basicPermissionOptions,
],
forceHideAlwaysAllow,
);
case 'exec': {
const label = formatExecPermissionScopeLabel(confirmation);
return filterAlwaysAllowOptions(
confirmation,
[
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${label}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${label}`,
kind: 'allow_always',
},
...basicPermissionOptions,
],
forceHideAlwaysAllow,
);
}
case 'mcp':
return filterAlwaysAllowOptions(
confirmation,
[
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${confirmation.toolName}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${confirmation.toolName}`,
kind: 'allow_always',
},
...basicPermissionOptions,
],
forceHideAlwaysAllow,
);
case 'info':
return filterAlwaysAllowOptions(
confirmation,
[
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: 'Always Allow in project',
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: 'Always Allow for user',
kind: 'allow_always',
},
...basicPermissionOptions,
],
forceHideAlwaysAllow,
);
case 'plan':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Yes, and auto-accept edits',
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Yes, and manually approve edits',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'No, keep planning (esc)',
kind: 'reject_once',
},
];
case 'ask_user_question':
return [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Submit',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Cancel',
kind: 'reject_once',
},
];
default: {
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);
}
}
}

View file

@ -33,8 +33,8 @@ import {
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import { hooksCommand } from '../commands/hooks.js';
import type { Settings, LoadedSettings } from './settings.js';
import { SettingScope } from './settings.js';
import type { Settings } from './settings.js';
import { loadSettings, SettingScope } from './settings.js';
import { authCommand } from '../commands/auth.js';
import {
resolveCliGenerationConfig,
@ -708,7 +708,6 @@ export async function loadCliConfig(
argv: CliArgs,
cwd: string = process.cwd(),
overrideExtensions?: string[],
loadedSettings?: LoadedSettings,
): Promise<Config> {
const debugMode = isDebugMode(argv);
@ -1046,20 +1045,19 @@ export async function loadCliConfig(
deny: mergedDeny.length > 0 ? mergedDeny : undefined,
},
// Permission rule persistence callback (writes to settings files).
onPersistPermissionRule: loadedSettings
? async (scope, ruleType, rule) => {
const settingScope =
scope === 'project' ? SettingScope.Workspace : SettingScope.User;
const key = `permissions.${ruleType}`;
const currentRules: string[] =
loadedSettings.forScope(settingScope).settings.permissions?.[
ruleType
] ?? [];
if (!currentRules.includes(rule)) {
loadedSettings.setValue(settingScope, key, [...currentRules, rule]);
}
}
: undefined,
onPersistPermissionRule: async (scope, ruleType, rule) => {
const currentSettings = loadSettings(cwd);
const settingScope =
scope === 'project' ? SettingScope.Workspace : SettingScope.User;
const key = `permissions.${ruleType}`;
const currentRules: string[] =
currentSettings.forScope(settingScope).settings.permissions?.[
ruleType
] ?? [];
if (!currentRules.includes(rule)) {
currentSettings.setValue(settingScope, key, [...currentRules, rule]);
}
},
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand: settings.mcp?.serverCommand,

View file

@ -0,0 +1,24 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export type AlibabaStandardRegion =
| 'cn-beijing'
| 'sg-singapore'
| 'us-virginia'
| 'cn-hongkong';
export const DASHSCOPE_STANDARD_API_KEY_ENV_KEY = 'DASHSCOPE_API_KEY';
export const ALIBABA_STANDARD_API_KEY_ENDPOINTS: Record<
AlibabaStandardRegion,
string
> = {
'cn-beijing': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
'sg-singapore': 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
'us-virginia': 'https://dashscope-us.aliyuncs.com/compatible-mode/v1',
'cn-hongkong':
'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1',
};

View file

@ -54,7 +54,7 @@ export function generateCodingPlanTemplate(
return [
{
id: 'qwen3.5-plus',
name: '[Bailian Coding Plan] qwen3.5-plus',
name: '[ModelStudio Coding Plan] qwen3.5-plus',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -66,7 +66,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'glm-5',
name: '[Bailian Coding Plan] glm-5',
name: '[ModelStudio Coding Plan] glm-5',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -78,7 +78,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'kimi-k2.5',
name: '[Bailian Coding Plan] kimi-k2.5',
name: '[ModelStudio Coding Plan] kimi-k2.5',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -90,7 +90,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'MiniMax-M2.5',
name: '[Bailian Coding Plan] MiniMax-M2.5',
name: '[ModelStudio Coding Plan] MiniMax-M2.5',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -102,7 +102,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'qwen3-coder-plus',
name: '[Bailian Coding Plan] qwen3-coder-plus',
name: '[ModelStudio Coding Plan] qwen3-coder-plus',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -111,7 +111,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'qwen3-coder-next',
name: '[Bailian Coding Plan] qwen3-coder-next',
name: '[ModelStudio Coding Plan] qwen3-coder-next',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -120,7 +120,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'qwen3-max-2026-01-23',
name: '[Bailian Coding Plan] qwen3-max-2026-01-23',
name: '[ModelStudio Coding Plan] qwen3-max-2026-01-23',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -132,7 +132,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'glm-4.7',
name: '[Bailian Coding Plan] glm-4.7',
name: '[ModelStudio Coding Plan] glm-4.7',
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -145,11 +145,11 @@ export function generateCodingPlanTemplate(
];
}
// Global region uses Bailian Coding Plan branding for Global/Intl
// Global region uses ModelStudio Coding Plan branding for Global/Intl
return [
{
id: 'qwen3.5-plus',
name: '[Bailian Coding Plan for Global/Intl] qwen3.5-plus',
name: '[ModelStudio Coding Plan for Global/Intl] qwen3.5-plus',
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -161,7 +161,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'qwen3-coder-plus',
name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-plus',
name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-plus',
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -170,7 +170,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'qwen3-coder-next',
name: '[Bailian Coding Plan for Global/Intl] qwen3-coder-next',
name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-next',
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -179,7 +179,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'qwen3-max-2026-01-23',
name: '[Bailian Coding Plan for Global/Intl] qwen3-max-2026-01-23',
name: '[ModelStudio Coding Plan for Global/Intl] qwen3-max-2026-01-23',
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -191,7 +191,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'glm-4.7',
name: '[Bailian Coding Plan for Global/Intl] glm-4.7',
name: '[ModelStudio Coding Plan for Global/Intl] glm-4.7',
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -203,7 +203,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'glm-5',
name: '[Bailian Coding Plan for Global/Intl] glm-5',
name: '[ModelStudio Coding Plan for Global/Intl] glm-5',
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -215,7 +215,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'MiniMax-M2.5',
name: '[Bailian Coding Plan for Global/Intl] MiniMax-M2.5',
name: '[ModelStudio Coding Plan for Global/Intl] MiniMax-M2.5',
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {
@ -227,7 +227,7 @@ export function generateCodingPlanTemplate(
},
{
id: 'kimi-k2.5',
name: '[Bailian Coding Plan for Global/Intl] kimi-k2.5',
name: '[ModelStudio Coding Plan for Global/Intl] kimi-k2.5',
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
envKey: CODING_PLAN_ENV_KEY,
generationConfig: {

View file

@ -360,7 +360,6 @@ export async function main() {
argv,
process.cwd(),
argv.extensions,
settings,
);
// Register cleanup for MCP clients as early as possible

View file

@ -1784,8 +1784,8 @@ export default {
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!',
"Paste your api key of ModelStudio Coding Plan and you're all set!":
'Fügen Sie Ihren ModelStudio Coding Plan API-Schlüssel ein und Sie sind bereit!',
Custom: 'Benutzerdefiniert',
'More instructions about configuring `modelProviders` manually.':
'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.',

View file

@ -1833,8 +1833,8 @@ export default {
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
"Paste your api key of Bailian Coding Plan and you're all set!",
"Paste your api key of ModelStudio Coding Plan and you're all set!":
"Paste your api key of ModelStudio Coding Plan and you're all set!",
Custom: 'Custom',
'More instructions about configuring `modelProviders` manually.':
'More instructions about configuring `modelProviders` manually.',

View file

@ -1285,8 +1285,8 @@ export default {
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です',
"Paste your api key of ModelStudio Coding Plan and you're all set!":
'ModelStudio Coding PlanのAPIキーを貼り付けるだけで準備完了です',
Custom: 'カスタム',
'More instructions about configuring `modelProviders` manually.':
'`modelProviders`を手動で設定する方法の詳細はこちら。',

View file

@ -1777,8 +1777,8 @@ export default {
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Cole sua chave de API do Bailian Coding Plan e pronto!',
"Paste your api key of ModelStudio Coding Plan and you're all set!":
'Cole sua chave de API do ModelStudio Coding Plan e pronto!',
Custom: 'Personalizado',
'More instructions about configuring `modelProviders` manually.':
'Mais instruções sobre como configurar `modelProviders` manualmente.',

View file

@ -1711,8 +1711,8 @@ export default {
// Auth Dialog - View Titles and Labels
// ============================================================================
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!',
"Paste your api key of ModelStudio Coding Plan and you're all set!":
'Вставьте ваш API-ключ ModelStudio Coding Plan и всё готово!',
Custom: 'Пользовательский',
'More instructions about configuring `modelProviders` manually.':
'Дополнительные инструкции по ручной настройке `modelProviders`.',

View file

@ -1650,7 +1650,7 @@ export default {
// ============================================================================
'API-KEY': 'API-KEY',
'Coding Plan': 'Coding Plan',
"Paste your api key of Bailian Coding Plan and you're all set!":
"Paste your api key of ModelStudio Coding Plan and you're all set!":
'粘贴您的百炼 Coding Plan API Key即可完成设置',
Custom: '自定义',
'More instructions about configuring `modelProviders` manually.':

View file

@ -133,12 +133,12 @@ export class ShellProcessor implements IPromptProcessor {
// Security check on the final, escaped command string.
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
checkCommandPermissions(command, config, sessionShellAllowlist);
await checkCommandPermissions(command, config, sessionShellAllowlist);
// Determine if this command is explicitly auto-approved via PermissionManager
const pm = config.getPermissionManager?.();
const isAllowedBySettings = pm
? pm.isCommandAllowed(command) === 'allow'
? (await pm.isCommandAllowed(command)) === 'allow'
: false;
if (!allAllowed) {

View file

@ -190,6 +190,8 @@ describe('AppContainer State Management', () => {
isAuthDialogOpen: false,
isAuthenticating: false,
handleAuthSelect: vi.fn(),
handleCodingPlanSubmit: vi.fn(),
handleAlibabaStandardSubmit: vi.fn(),
openAuthDialog: vi.fn(),
cancelAuthentication: vi.fn(),
});

View file

@ -457,6 +457,7 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
handleAuthSelect,
handleCodingPlanSubmit,
handleAlibabaStandardSubmit,
openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config, historyManager.addItem, refreshStatic);
@ -1691,6 +1692,7 @@ export const AppContainer = (props: AppContainerProps) => {
onAuthError,
cancelAuthentication,
handleCodingPlanSubmit,
handleAlibabaStandardSubmit,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
@ -1748,6 +1750,7 @@ export const AppContainer = (props: AppContainerProps) => {
onAuthError,
cancelAuthentication,
handleCodingPlanSubmit,
handleAlibabaStandardSubmit,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,

View file

@ -32,6 +32,9 @@ const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
// AuthDialog only uses handleAuthSelect
const baseActions = {
handleAuthSelect: vi.fn(),
handleCodingPlanSubmit: vi.fn(),
handleAlibabaStandardSubmit: vi.fn(),
onAuthError: vi.fn(),
handleRetryLastPrompt: vi.fn(),
} as Partial<UIActions>;

View file

@ -13,6 +13,7 @@ import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { DescriptiveRadioButtonSelect } from '../components/shared/DescriptiveRadioButtonSelect.js';
import { ApiKeyInput } from '../components/ApiKeyInput.js';
import { TextInput } from '../components/shared/TextInput.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
@ -21,6 +22,10 @@ import {
CodingPlanRegion,
isCodingPlanConfig,
} from '../../constants/codingPlan.js';
import {
ALIBABA_STANDARD_API_KEY_ENDPOINTS,
type AlibabaStandardRegion,
} from '../../constants/alibabaStandardApiKey.js';
const MODEL_PROVIDERS_DOCUMENTATION_URL =
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/';
@ -39,15 +44,39 @@ function parseDefaultAuthType(
// Main menu option type
type MainOption = typeof AuthType.QWEN_OAUTH | 'CODING_PLAN' | 'API_KEY';
type ApiKeyOption = 'ALIBABA_STANDARD_API_KEY' | 'CUSTOM_API_KEY';
// View level for navigation
type ViewLevel = 'main' | 'region-select' | 'api-key-input' | 'custom-info';
type ViewLevel =
| 'main'
| 'region-select'
| 'api-key-input'
| 'api-key-type-select'
| 'alibaba-standard-region-select'
| 'alibaba-standard-api-key-input'
| 'alibaba-standard-model-id-input'
| 'custom-info';
const ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER = 'qwen3.5-plus,glm-5,kimi-k2.5';
const ALIBABA_STANDARD_API_DOCUMENTATION_URLS: Record<
AlibabaStandardRegion,
string
> = {
'cn-beijing': 'https://bailian.console.aliyun.com/cn-beijing?tab=api#/api',
'sg-singapore':
'https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=api#/api/?type=model&url=2712195',
'us-virginia':
'https://modelstudio.console.alibabacloud.com/us-east-1?tab=api#/api/?type=model&url=2712195',
'cn-hongkong':
'https://modelstudio.console.alibabacloud.com/cn-hongkong?tab=api#/api/?type=model&url=2712195',
};
export function AuthDialog(): React.JSX.Element {
const { pendingAuthType, authError } = useUIState();
const {
handleAuthSelect: onAuthSelect,
handleCodingPlanSubmit,
handleAlibabaStandardSubmit,
onAuthError,
} = useUIActions();
const config = useConfig();
@ -58,6 +87,18 @@ export function AuthDialog(): React.JSX.Element {
const [region, setRegion] = useState<CodingPlanRegion>(
CodingPlanRegion.CHINA,
);
const [alibabaStandardRegionIndex, setAlibabaStandardRegionIndex] =
useState<number>(0);
const [apiKeyTypeIndex, setApiKeyTypeIndex] = useState<number>(0);
const [alibabaStandardRegion, setAlibabaStandardRegion] =
useState<AlibabaStandardRegion>('cn-beijing');
const [alibabaStandardApiKey, setAlibabaStandardApiKey] = useState('');
const [alibabaStandardApiKeyError, setAlibabaStandardApiKeyError] = useState<
string | null
>(null);
const [alibabaStandardModelId, setAlibabaStandardModelId] = useState('');
const [alibabaStandardModelIdError, setAlibabaStandardModelIdError] =
useState<string | null>(null);
// Main authentication entries (flat three-option layout)
const mainItems = [
@ -124,21 +165,87 @@ export function AuthDialog(): React.JSX.Element {
},
];
const alibabaStandardRegionItems = [
{
key: 'cn-beijing',
title: t('China (Beijing)'),
label: t('China (Beijing)'),
description: (
<Text color={theme.text.secondary}>
Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['cn-beijing']}
</Text>
),
value: 'cn-beijing' as AlibabaStandardRegion,
},
{
key: 'sg-singapore',
title: t('Singapore'),
label: t('Singapore'),
description: (
<Text color={theme.text.secondary}>
Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['sg-singapore']}
</Text>
),
value: 'sg-singapore' as AlibabaStandardRegion,
},
{
key: 'us-virginia',
title: t('US (Virginia)'),
label: t('US (Virginia)'),
description: (
<Text color={theme.text.secondary}>
Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['us-virginia']}
</Text>
),
value: 'us-virginia' as AlibabaStandardRegion,
},
{
key: 'cn-hongkong',
title: t('China (Hong Kong)'),
label: t('China (Hong Kong)'),
description: (
<Text color={theme.text.secondary}>
Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS['cn-hongkong']}
</Text>
),
value: 'cn-hongkong' as AlibabaStandardRegion,
},
];
const apiKeyTypeItems = [
{
key: 'ALIBABA_STANDARD_API_KEY',
title: t('Alibaba Cloud ModelStudio Standard API Key'),
label: t('Alibaba Cloud ModelStudio Standard API Key'),
description: t('Quick setup for Model Studio (China/International)'),
value: 'ALIBABA_STANDARD_API_KEY' as ApiKeyOption,
},
{
key: 'CUSTOM_API_KEY',
title: t('Custom API Key'),
label: t('Custom API Key'),
description: t(
'For other OpenAI / Anthropic / Gemini-compatible providers',
),
value: 'CUSTOM_API_KEY' as ApiKeyOption,
},
];
// Map an AuthType to the corresponding main menu option.
// QWEN_OAUTH maps directly; any other auth type maps to CODING_PLAN only
// if the current config actually uses a Coding Plan baseUrl+envKey,
// otherwise it maps to API_KEY.
// QWEN_OAUTH maps directly; USE_OPENAI maps to:
// - CODING_PLAN when current config matches coding plan
// - API_KEY for other OpenAI / Anthropic / Gemini-compatible configs
const contentGenConfig = config.getContentGeneratorConfig();
const isCurrentlyCodingPlan =
isCodingPlanConfig(
contentGenConfig?.baseUrl,
contentGenConfig?.apiKeyEnvKey,
) !== false;
const authTypeToMainOption = (authType: AuthType): MainOption => {
if (authType === AuthType.QWEN_OAUTH) return AuthType.QWEN_OAUTH;
if (authType === AuthType.USE_OPENAI && isCurrentlyCodingPlan)
if (authType === AuthType.USE_OPENAI && isCurrentlyCodingPlan) {
return 'CODING_PLAN';
}
return 'API_KEY';
};
@ -180,8 +287,7 @@ export function AuthDialog(): React.JSX.Element {
}
if (value === 'API_KEY') {
// Navigate directly to custom API key info
setViewLevel('custom-info');
setViewLevel('api-key-type-select');
return;
}
@ -189,6 +295,20 @@ export function AuthDialog(): React.JSX.Element {
await onAuthSelect(value);
};
const handleApiKeyTypeSelect = async (value: ApiKeyOption) => {
setErrorMessage(null);
onAuthError(null);
if (value === 'ALIBABA_STANDARD_API_KEY') {
setAlibabaStandardModelIdError(null);
setAlibabaStandardApiKeyError(null);
setViewLevel('alibaba-standard-region-select');
return;
}
setViewLevel('custom-info');
};
const handleRegionSelect = async (selectedRegion: CodingPlanRegion) => {
setErrorMessage(null);
onAuthError(null);
@ -196,6 +316,17 @@ export function AuthDialog(): React.JSX.Element {
setViewLevel('api-key-input');
};
const handleAlibabaStandardRegionSelect = async (
selectedRegion: AlibabaStandardRegion,
) => {
setErrorMessage(null);
onAuthError(null);
setAlibabaStandardApiKeyError(null);
setAlibabaStandardModelIdError(null);
setAlibabaStandardRegion(selectedRegion);
setViewLevel('alibaba-standard-api-key-input');
};
const handleApiKeyInputSubmit = async (apiKey: string) => {
setErrorMessage(null);
@ -208,14 +339,59 @@ export function AuthDialog(): React.JSX.Element {
await handleCodingPlanSubmit(apiKey, region);
};
const handleAlibabaStandardApiKeySubmit = () => {
const trimmedKey = alibabaStandardApiKey.trim();
if (!trimmedKey) {
setAlibabaStandardApiKeyError(t('API key cannot be empty.'));
return;
}
setAlibabaStandardApiKeyError(null);
if (!alibabaStandardModelId.trim()) {
setAlibabaStandardModelId(ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER);
}
setViewLevel('alibaba-standard-model-id-input');
};
const handleAlibabaStandardModelSubmit = () => {
const trimmedApiKey = alibabaStandardApiKey.trim();
const trimmedModelIds = alibabaStandardModelId.trim();
if (!trimmedApiKey) {
setAlibabaStandardApiKeyError(t('API key cannot be empty.'));
setViewLevel('alibaba-standard-api-key-input');
return;
}
if (!trimmedModelIds) {
setAlibabaStandardModelIdError(t('Model IDs cannot be empty.'));
return;
}
setAlibabaStandardModelIdError(null);
void handleAlibabaStandardSubmit(
trimmedApiKey,
alibabaStandardRegion,
trimmedModelIds,
);
};
const handleGoBack = () => {
setErrorMessage(null);
onAuthError(null);
if (viewLevel === 'region-select' || viewLevel === 'custom-info') {
if (viewLevel === 'region-select') {
setViewLevel('main');
} else if (viewLevel === 'api-key-input') {
setViewLevel('region-select');
} else if (viewLevel === 'api-key-type-select') {
setViewLevel('main');
} else if (viewLevel === 'custom-info') {
setViewLevel('api-key-type-select');
} else if (viewLevel === 'alibaba-standard-region-select') {
setViewLevel('api-key-type-select');
} else if (viewLevel === 'alibaba-standard-api-key-input') {
setViewLevel('alibaba-standard-region-select');
} else if (viewLevel === 'alibaba-standard-model-id-input') {
setViewLevel('alibaba-standard-api-key-input');
}
};
@ -232,6 +408,15 @@ export function AuthDialog(): React.JSX.Element {
handleGoBack();
return;
}
if (
viewLevel === 'api-key-type-select' ||
viewLevel === 'alibaba-standard-region-select' ||
viewLevel === 'alibaba-standard-api-key-input' ||
viewLevel === 'alibaba-standard-model-id-input'
) {
handleGoBack();
return;
}
// For main view, use existing logic
if (errorMessage) {
@ -304,6 +489,135 @@ export function AuthDialog(): React.JSX.Element {
</Box>
);
const renderApiKeyTypeSelectView = () => (
<>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={apiKeyTypeItems}
initialIndex={apiKeyTypeIndex}
onSelect={handleApiKeyTypeSelect}
onHighlight={(value) => {
const index = apiKeyTypeItems.findIndex(
(item) => item.value === value,
);
setApiKeyTypeIndex(index);
}}
itemGap={1}
/>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t('Enter to select, ↑↓ to navigate, Esc to go back')}
</Text>
</Box>
</>
);
const renderAlibabaStandardRegionSelectView = () => (
<>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={alibabaStandardRegionItems}
initialIndex={alibabaStandardRegionIndex}
onSelect={handleAlibabaStandardRegionSelect}
onHighlight={(value) => {
const index = alibabaStandardRegionItems.findIndex(
(item) => item.value === value,
);
setAlibabaStandardRegionIndex(index);
}}
itemGap={1}
/>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t('Enter to select, ↑↓ to navigate, Esc to go back')}
</Text>
</Box>
</>
);
const renderAlibabaStandardApiKeyInputView = () => (
<Box marginTop={1} flexDirection="column">
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Endpoint: {ALIBABA_STANDARD_API_KEY_ENDPOINTS[alibabaStandardRegion]}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Documentation')}:</Text>
</Box>
<Box marginTop={0}>
<Link
url={ALIBABA_STANDARD_API_DOCUMENTATION_URLS[alibabaStandardRegion]}
fallback={false}
>
<Text color={theme.text.link}>
{ALIBABA_STANDARD_API_DOCUMENTATION_URLS[alibabaStandardRegion]}
</Text>
</Link>
</Box>
<Box marginTop={1}>
<TextInput
value={alibabaStandardApiKey}
onChange={(value) => {
setAlibabaStandardApiKey(value);
if (alibabaStandardApiKeyError) {
setAlibabaStandardApiKeyError(null);
}
}}
onSubmit={handleAlibabaStandardApiKeySubmit}
placeholder="sk-..."
/>
</Box>
{alibabaStandardApiKeyError && (
<Box marginTop={1}>
<Text color={theme.status.error}>{alibabaStandardApiKeyError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Enter to submit, Esc to go back')}
</Text>
</Box>
</Box>
);
const renderAlibabaStandardModelIdInputView = () => (
<Box marginTop={1} flexDirection="column">
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t(
'You can enter multiple model IDs, separated by commas. Examples: qwen3.5-plus,glm-5,kimi-k2.5',
)}
</Text>
</Box>
<Box marginTop={1}>
<TextInput
value={alibabaStandardModelId}
onChange={(value) => {
setAlibabaStandardModelId(value);
if (alibabaStandardModelIdError) {
setAlibabaStandardModelIdError(null);
}
}}
onSubmit={handleAlibabaStandardModelSubmit}
placeholder={ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER}
/>
</Box>
{alibabaStandardModelIdError && (
<Box marginTop={1}>
<Text color={theme.status.error}>{alibabaStandardModelIdError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Enter to submit, Esc to go back')}
</Text>
</Box>
</Box>
);
// Render custom mode info
const renderCustomInfoView = () => (
<>
@ -336,8 +650,18 @@ export function AuthDialog(): React.JSX.Element {
return t('Select Region for Coding Plan');
case 'api-key-input':
return t('Enter Coding Plan API Key');
case 'api-key-type-select':
return t('Select API Key Type');
case 'custom-info':
return t('Custom Configuration');
case 'alibaba-standard-region-select':
return t(
'Select Region for Alibaba Cloud ModelStudio Standard API Key',
);
case 'alibaba-standard-api-key-input':
return t('Enter Alibaba Cloud ModelStudio Standard API Key');
case 'alibaba-standard-model-id-input':
return t('Enter Model IDs');
default:
return t('Select Authentication Method');
}
@ -356,6 +680,13 @@ export function AuthDialog(): React.JSX.Element {
{viewLevel === 'main' && renderMainView()}
{viewLevel === 'region-select' && renderRegionSelectView()}
{viewLevel === 'api-key-input' && renderApiKeyInputView()}
{viewLevel === 'api-key-type-select' && renderApiKeyTypeSelectView()}
{viewLevel === 'alibaba-standard-region-select' &&
renderAlibabaStandardRegionSelectView()}
{viewLevel === 'alibaba-standard-api-key-input' &&
renderAlibabaStandardApiKeyInputView()}
{viewLevel === 'alibaba-standard-model-id-input' &&
renderAlibabaStandardModelIdInputView()}
{viewLevel === 'custom-info' && renderCustomInfoView()}
{(authError || errorMessage) && (

View file

@ -36,6 +36,11 @@ import {
CODING_PLAN_ENV_KEY,
} from '../../constants/codingPlan.js';
import { backupSettingsFile } from '../../utils/settingsUtils.js';
import {
ALIBABA_STANDARD_API_KEY_ENDPOINTS,
DASHSCOPE_STANDARD_API_KEY_ENV_KEY,
type AlibabaStandardRegion,
} from '../../constants/alibabaStandardApiKey.js';
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
@ -421,6 +426,134 @@ export const useAuthCommand = (
[settings, config, handleAuthFailure, addItem, onAuthChange],
);
/**
* Handle Alibaba Cloud standard API key flow.
* Persists key to env.DASHSCOPE_API_KEY and creates a modelProviders.openai entry.
*/
const handleAlibabaStandardSubmit = useCallback(
async (
apiKey: string,
region: AlibabaStandardRegion,
modelIdsInput: string,
) => {
try {
setIsAuthenticating(true);
setAuthError(null);
const trimmedApiKey = apiKey.trim();
const modelIds = modelIdsInput
.split(',')
.map((id) => id.trim())
.filter(
(id, index, array) => id.length > 0 && array.indexOf(id) === index,
);
if (!trimmedApiKey) {
throw new Error(t('API key cannot be empty.'));
}
if (modelIds.length === 0) {
throw new Error(t('Model IDs cannot be empty.'));
}
const baseUrl = ALIBABA_STANDARD_API_KEY_ENDPOINTS[region];
const persistScope = getPersistScopeForModelSelection(settings);
const settingsFile = settings.forScope(persistScope);
backupSettingsFile(settingsFile.path);
settings.setValue(
persistScope,
`env.${DASHSCOPE_STANDARD_API_KEY_ENV_KEY}`,
trimmedApiKey,
);
process.env[DASHSCOPE_STANDARD_API_KEY_ENV_KEY] = trimmedApiKey;
const newConfigs: ProviderModelConfig[] = modelIds.map((modelId) => ({
id: modelId,
name: `[ModelStudio Standard] ${modelId}`,
baseUrl,
envKey: DASHSCOPE_STANDARD_API_KEY_ENV_KEY,
}));
const existingConfigs =
(
settings.merged.modelProviders as ModelProvidersConfig | undefined
)?.[AuthType.USE_OPENAI] || [];
const nonAlibabaStandardConfigs = existingConfigs.filter(
(existing) =>
!(
existing.envKey === DASHSCOPE_STANDARD_API_KEY_ENV_KEY &&
typeof existing.baseUrl === 'string' &&
Object.values(ALIBABA_STANDARD_API_KEY_ENDPOINTS).includes(
existing.baseUrl,
)
),
);
const updatedConfigs = [...newConfigs, ...nonAlibabaStandardConfigs];
settings.setValue(
persistScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
settings.setValue(
persistScope,
'security.auth.selectedType',
AuthType.USE_OPENAI,
);
settings.setValue(persistScope, 'model.name', modelIds[0]);
const updatedModelProviders: ModelProvidersConfig = {
...(settings.merged.modelProviders as
| ModelProvidersConfig
| undefined),
[AuthType.USE_OPENAI]: updatedConfigs,
};
config.reloadModelProvidersConfig(updatedModelProviders);
await config.refreshAuth(AuthType.USE_OPENAI);
setAuthError(null);
setAuthState(AuthState.Authenticated);
setPendingAuthType(undefined);
setIsAuthDialogOpen(false);
setIsAuthenticating(false);
onAuthChange?.();
addItem(
{
type: MessageType.INFO,
text: t(
'Alibaba Cloud ModelStudio Standard API Key successfully entered. Settings updated with env.DASHSCOPE_API_KEY and {{modelCount}} model(s).',
{ modelCount: String(modelIds.length) },
),
},
Date.now(),
);
addItem(
{
type: MessageType.INFO,
text: t(
'You can use /model to see new ModelStudio Standard models and switch between them.',
),
},
Date.now(),
);
const authEvent = new AuthEvent(
AuthType.USE_OPENAI,
'manual',
'success',
);
logAuth(config, authEvent);
} catch (error) {
handleAuthFailure(error);
}
},
[settings, config, handleAuthFailure, addItem, onAuthChange],
);
/**
/**
* We previously used a useEffect to trigger authentication automatically when
@ -472,6 +605,7 @@ export const useAuthCommand = (
qwenAuthState,
handleAuthSelect,
handleCodingPlanSubmit,
handleAlibabaStandardSubmit,
openAuthDialog,
cancelAuthentication,
};

View file

@ -140,6 +140,71 @@ describe('clearCommand', () => {
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
});
it('should clear UI before resetChat for immediate responsiveness', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
const callOrder: string[] = [];
(mockContext.ui.clear as ReturnType<typeof vi.fn>).mockImplementation(
() => {
callOrder.push('ui.clear');
},
);
mockResetChat.mockImplementation(async () => {
callOrder.push('resetChat');
});
await clearCommand.action(mockContext, '');
// ui.clear should be called before resetChat for immediate UI feedback
const clearIndex = callOrder.indexOf('ui.clear');
const resetIndex = callOrder.indexOf('resetChat');
expect(clearIndex).toBeGreaterThanOrEqual(0);
expect(resetIndex).toBeGreaterThanOrEqual(0);
expect(clearIndex).toBeLessThan(resetIndex);
});
it('should not await hook events (fire-and-forget)', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
// Make hooks take a long time - they should not block
let sessionEndResolved = false;
let sessionStartResolved = false;
mockFireSessionEndEvent.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => {
sessionEndResolved = true;
resolve(undefined);
}, 5000);
}),
);
mockFireSessionStartEvent.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => {
sessionStartResolved = true;
resolve(undefined);
}, 5000);
}),
);
await clearCommand.action(mockContext, '');
// The action should complete immediately without waiting for hooks
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
// Hooks should have been called but not necessarily resolved
expect(mockFireSessionEndEvent).toHaveBeenCalled();
expect(mockFireSessionStartEvent).toHaveBeenCalled();
// Hooks should NOT have resolved yet since they have 5s timeouts
expect(sessionEndResolved).toBe(false);
expect(sessionStartResolved).toBe(false);
});
it('should not attempt to reset chat if config service is not available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');

View file

@ -27,14 +27,13 @@ export const clearCommand: SlashCommand = {
const { config } = context.services;
if (config) {
// Fire SessionEnd event before clearing (current session ends)
try {
await config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.Clear);
} catch (err) {
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
}
// Fire SessionEnd event (non-blocking to avoid UI lag)
config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.Clear)
.catch((err) => {
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
});
const newSessionId = config.startNewSession();
@ -54,6 +53,9 @@ export const clearCommand: SlashCommand = {
context.session.startNewSession(newSessionId);
}
// Clear UI first for immediate responsiveness
context.ui.clear();
const geminiClient = config.getGeminiClient();
if (geminiClient) {
context.ui.setDebugMessage(
@ -66,22 +68,20 @@ export const clearCommand: SlashCommand = {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}
// Fire SessionStart event after clearing (new session starts)
try {
await config
.getHookSystem()
?.fireSessionStartEvent(
SessionStartSource.Clear,
config.getModel() ?? '',
String(config.getApprovalMode()) as PermissionMode,
);
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
}
// Fire SessionStart event (non-blocking to avoid UI lag)
config
.getHookSystem()
?.fireSessionStartEvent(
SessionStartSource.Clear,
config.getModel() ?? '',
String(config.getApprovalMode()) as PermissionMode,
)
.catch((err) => {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
});
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
context.ui.clear();
}
context.ui.clear();
},
};

View file

@ -78,6 +78,12 @@ export const ToolConfirmationMessage: React.FC<
}, [config]);
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
// Call onConfirm before resolving the IDE diff so that the CLI outcome
// (e.g. ProceedAlways) is processed first. resolveDiffFromCli would
// otherwise trigger the scheduler's ideConfirmation .then() handler
// with ProceedOnce, racing with the intended CLI outcome.
onConfirm(outcome);
if (confirmationDetails.type === 'edit') {
if (config.getIdeMode() && isDiffingEnabled) {
const cliOutcome =
@ -88,7 +94,6 @@ export const ToolConfirmationMessage: React.FC<
);
}
}
onConfirm(outcome);
};
const isTrustedFolder = config.isTrustedFolder();

View file

@ -540,7 +540,16 @@ export function KeypressProvider({
}
};
// Matches terminal query responses (DA1, DA2, Kitty protocol query)
// that may arrive late from startup detection in kittyProtocolDetector.
// These are never valid user input.
// eslint-disable-next-line no-control-regex
const TERMINAL_RESPONSE_RE = /^\x1b\[[?>][\d;]*[uc]$/;
const handleKeypress = async (_: unknown, key: Key) => {
if (TERMINAL_RESPONSE_RE.test(key.sequence)) {
return;
}
if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) {
return;
}

View file

@ -16,6 +16,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { type SettingScope } from '../../config/settings.js';
import { type CodingPlanRegion } from '../../constants/codingPlan.js';
import { type AlibabaStandardRegion } from '../../constants/alibabaStandardApiKey.js';
import type { AuthState } from '../types.js';
import { type ArenaDialogType } from '../hooks/useArenaCommand.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
@ -45,6 +46,11 @@ export interface UIActions {
apiKey: string,
region?: CodingPlanRegion,
) => Promise<void>;
handleAlibabaStandardSubmit: (
apiKey: string,
region: AlibabaStandardRegion,
modelIdsInput: string,
) => Promise<void>;
setAuthState: (state: AuthState) => void;
onAuthError: (error: string | null) => void;
cancelAuthentication: () => void;

View file

@ -417,6 +417,95 @@ describe('useCommandCompletion', () => {
});
});
describe('Completion mode detection', () => {
it('should switch to AT mode when typing @ after a slash command (#2518)', async () => {
setupMocks({
atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }],
});
const text = '/qc:create-issue @file';
renderHook(() =>
useCommandCompletion(
useTextBufferForTest(text),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
await waitFor(() => {
expect(useAtCompletion).toHaveBeenLastCalledWith(
expect.objectContaining({
enabled: true,
pattern: 'file',
}),
);
});
});
it('should remain in SLASH mode when no @ is typed after slash command', async () => {
setupMocks({
slashSuggestions: [{ label: 'help', value: 'help' }],
});
const text = '/help';
renderHook(() =>
useCommandCompletion(
useTextBufferForTest(text),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
await waitFor(() => {
expect(useSlashCompletion).toHaveBeenLastCalledWith(
expect.objectContaining({
enabled: true,
query: '/help',
}),
);
});
});
it('should complete a file path when @ appears after a slash command', async () => {
setupMocks({
atSuggestions: [{ label: 'src/index.ts', value: 'src/index.ts' }],
});
const text = '/review @src/ind';
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest(text);
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
);
return { ...completion, textBuffer };
});
await waitFor(() => {
expect(result.current.suggestions.length).toBe(1);
});
act(() => {
result.current.handleAutocomplete(0);
});
expect(result.current.textBuffer.text).toBe('/review @src/index.ts ');
});
});
describe('handleAutocomplete', () => {
it('should complete a partial command', async () => {
setupMocks({

View file

@ -74,15 +74,9 @@ export function useCommandCompletion(
const { completionMode, query, completionStart, completionEnd } =
useMemo(() => {
const currentLine = buffer.lines[cursorRow] || '';
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return {
completionMode: CompletionMode.SLASH,
query: currentLine,
completionStart: 0,
completionEnd: currentLine.length,
};
}
// Check for @ completion first, so that typing @ after a slash command
// still triggers file search (see #2518).
const codePoints = toCodePoints(currentLine);
for (let i = cursorCol - 1; i >= 0; i--) {
const char = codePoints[i];
@ -121,6 +115,15 @@ export function useCommandCompletion(
}
}
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return {
completionMode: CompletionMode.SLASH,
query: currentLine,
completionStart: 0,
completionEnd: currentLine.length,
};
}
return {
completionMode: CompletionMode.IDLE,
query: null,

View file

@ -37,11 +37,20 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
const onTimeout = () => {
timeoutId = undefined;
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
detectionComplete = true;
resolve(false);
// Keep a drain handler briefly to consume any late-arriving terminal
// responses that would otherwise leak into the application input.
const drainHandler = () => {};
process.stdin.on('data', drainHandler);
setTimeout(() => {
process.stdin.removeListener('data', drainHandler);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
detectionComplete = true;
resolve(false);
}, 100);
};
const handleData = (data: Buffer) => {