mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-04 22:51:08 +00:00
Merge remote-tracking branch 'origin/main' into feat/channels-telegram
This commit is contained in:
commit
7962d4f790
85 changed files with 3878 additions and 1263 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
208
packages/cli/src/acp-integration/session/permissionUtils.ts
Normal file
208
packages/cli/src/acp-integration/session/permissionUtils.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
24
packages/cli/src/constants/alibabaStandardApiKey.ts
Normal file
24
packages/cli/src/constants/alibabaStandardApiKey.ts
Normal 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',
|
||||
};
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -360,7 +360,6 @@ export async function main() {
|
|||
argv,
|
||||
process.cwd(),
|
||||
argv.extensions,
|
||||
settings,
|
||||
);
|
||||
|
||||
// Register cleanup for MCP clients as early as possible
|
||||
|
|
|
|||
|
|
@ -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`.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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`を手動で設定する方法の詳細はこちら。',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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`.',
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue