mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Merge branch 'main' into docs-byYijing
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -47,7 +47,7 @@
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.4.5",
|
||||
"glob": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "^6.2.3",
|
||||
"ink-gradient": "^3.0.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^7.10.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
|
||||
@@ -88,6 +88,16 @@ export class AgentSideConnection implements Client {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams authentication updates (e.g. Qwen OAuth authUri) to the client.
|
||||
*/
|
||||
async authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void> {
|
||||
return await this.#connection.sendNotification(
|
||||
schema.CLIENT_METHODS.authenticate_update,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permission before running a tool
|
||||
*
|
||||
@@ -241,9 +251,11 @@ class Connection {
|
||||
).toResult();
|
||||
}
|
||||
|
||||
let errorName;
|
||||
let details;
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorName = error.name;
|
||||
details = error.message;
|
||||
} else if (
|
||||
typeof error === 'object' &&
|
||||
@@ -254,6 +266,10 @@ class Connection {
|
||||
details = error.message;
|
||||
}
|
||||
|
||||
if (errorName === 'TokenManagerError') {
|
||||
return RequestError.authRequired(details).toResult();
|
||||
}
|
||||
|
||||
return RequestError.internalError(details).toResult();
|
||||
}
|
||||
}
|
||||
@@ -357,6 +373,7 @@ export interface Client {
|
||||
params: schema.RequestPermissionRequest,
|
||||
): Promise<schema.RequestPermissionResponse>;
|
||||
sessionUpdate(params: schema.SessionNotification): Promise<void>;
|
||||
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
|
||||
writeTextFile(
|
||||
params: schema.WriteTextFileRequest,
|
||||
): Promise<schema.WriteTextFileResponse>;
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
|
||||
import type { ReadableStream, WritableStream } from 'node:stream/web';
|
||||
|
||||
import type { Config, ConversationRecord } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
APPROVAL_MODE_INFO,
|
||||
APPROVAL_MODES,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
QwenOAuth2Event,
|
||||
qwenOAuth2Events,
|
||||
MCPServerConfig,
|
||||
SessionService,
|
||||
buildApiHistoryFromConversation,
|
||||
type Config,
|
||||
type ConversationRecord,
|
||||
type DeviceAuthorizationData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ApprovalModeValue } from './schema.js';
|
||||
import * as acp from './acp.js';
|
||||
@@ -123,13 +127,33 @@ class GeminiAgent {
|
||||
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
|
||||
const method = z.nativeEnum(AuthType).parse(methodId);
|
||||
|
||||
let authUri: string | undefined;
|
||||
const authUriHandler = (deviceAuth: DeviceAuthorizationData) => {
|
||||
authUri = deviceAuth.verification_uri_complete;
|
||||
// Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking).
|
||||
void this.client.authenticateUpdate({ _meta: { authUri } });
|
||||
};
|
||||
|
||||
if (method === AuthType.QWEN_OAUTH) {
|
||||
qwenOAuth2Events.once(QwenOAuth2Event.AuthUri, authUriHandler);
|
||||
}
|
||||
|
||||
await clearCachedCredentialFile();
|
||||
await this.config.refreshAuth(method);
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
method,
|
||||
);
|
||||
try {
|
||||
await this.config.refreshAuth(method);
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
method,
|
||||
);
|
||||
} finally {
|
||||
// Ensure we don't leak listeners if auth fails early.
|
||||
if (method === AuthType.QWEN_OAUTH) {
|
||||
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async newSession({
|
||||
@@ -268,14 +292,17 @@ class GeminiAgent {
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired();
|
||||
throw acp.RequestError.authRequired('No Selected Type');
|
||||
}
|
||||
|
||||
try {
|
||||
await config.refreshAuth(selectedType);
|
||||
// Use true for the second argument to ensure only cached credentials are used
|
||||
await config.refreshAuth(selectedType, true);
|
||||
} catch (e) {
|
||||
console.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired();
|
||||
throw acp.RequestError.authRequired(
|
||||
'Authentication failed: ' + (e as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export const AGENT_METHODS = {
|
||||
export const CLIENT_METHODS = {
|
||||
fs_read_text_file: 'fs/read_text_file',
|
||||
fs_write_text_file: 'fs/write_text_file',
|
||||
authenticate_update: 'authenticate/update',
|
||||
session_request_permission: 'session/request_permission',
|
||||
session_update: 'session/update',
|
||||
};
|
||||
@@ -57,8 +58,6 @@ export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
|
||||
|
||||
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
|
||||
|
||||
export type AuthenticateResponse = z.infer<typeof authenticateResponseSchema>;
|
||||
|
||||
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
|
||||
|
||||
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
|
||||
@@ -247,7 +246,13 @@ export const authenticateRequestSchema = z.object({
|
||||
methodId: z.string(),
|
||||
});
|
||||
|
||||
export const authenticateResponseSchema = z.null();
|
||||
export const authenticateUpdateSchema = z.object({
|
||||
_meta: z.object({
|
||||
authUri: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
|
||||
|
||||
export const newSessionResponseSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
@@ -316,6 +321,23 @@ export const annotationsSchema = z.object({
|
||||
priority: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export const usageSchema = z.object({
|
||||
promptTokens: z.number().optional().nullable(),
|
||||
completionTokens: z.number().optional().nullable(),
|
||||
thoughtsTokens: z.number().optional().nullable(),
|
||||
totalTokens: z.number().optional().nullable(),
|
||||
cachedTokens: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type Usage = z.infer<typeof usageSchema>;
|
||||
|
||||
export const sessionUpdateMetaSchema = z.object({
|
||||
usage: usageSchema.optional().nullable(),
|
||||
durationMs: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
|
||||
|
||||
export const requestPermissionResponseSchema = z.object({
|
||||
outcome: requestPermissionOutcomeSchema,
|
||||
});
|
||||
@@ -500,10 +522,12 @@ export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_message_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_thought_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: z.array(toolCallContentSchema).optional(),
|
||||
@@ -536,7 +560,6 @@ export const sessionUpdateSchema = z.union([
|
||||
|
||||
export const agentResponseSchema = z.union([
|
||||
initializeResponseSchema,
|
||||
authenticateResponseSchema,
|
||||
newSessionResponseSchema,
|
||||
loadSessionResponseSchema,
|
||||
promptResponseSchema,
|
||||
|
||||
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import { AcpFileSystemService } from './filesystem.js';
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
findFiles: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
describe('readTextFile ENOENT handling', () => {
|
||||
it('parses path from ACP ENOENT message (quoted)', async () => {
|
||||
const client = {
|
||||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-1',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/remote/file.txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to requested path when none provided', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-2',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.readTextFile('/fallback/path.txt'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
path: '/fallback/path.txt',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,20 @@ export class AcpFileSystemService implements FileSystemService {
|
||||
limit: null,
|
||||
});
|
||||
|
||||
if (response.content.startsWith('ERROR: ENOENT:')) {
|
||||
// Treat ACP error strings as structured ENOENT errors without
|
||||
// assuming a specific platform format.
|
||||
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
|
||||
const err = new Error(response.content) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
const rawPath = match?.groups?.['path']?.trim();
|
||||
err['path'] = rawPath
|
||||
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
|
||||
: filePath;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return response.content;
|
||||
}
|
||||
|
||||
|
||||
@@ -411,4 +411,48 @@ describe('HistoryReplayer', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usage metadata replay', () => {
|
||||
it('should emit usage metadata after assistant message content', async () => {
|
||||
const record: ChatRecord = {
|
||||
uuid: 'assistant-uuid',
|
||||
parentUuid: 'user-uuid',
|
||||
sessionId: 'test-session',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'assistant',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hello!' }],
|
||||
},
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 150,
|
||||
},
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Hello!' },
|
||||
});
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: undefined,
|
||||
totalTokens: 150,
|
||||
cachedTokens: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Content,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
} from '@google/genai';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
@@ -52,6 +55,9 @@ export class HistoryReplayer {
|
||||
if (record.message) {
|
||||
await this.replayContent(record.message, 'assistant');
|
||||
}
|
||||
if (record.usageMetadata) {
|
||||
await this.replayUsageMetadata(record.usageMetadata);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
@@ -88,11 +94,22 @@ export class HistoryReplayer {
|
||||
toolName: functionName,
|
||||
callId,
|
||||
args: part.functionCall.args as Record<string, unknown>,
|
||||
status: 'in_progress',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays usage metadata.
|
||||
* @param usageMetadata - The usage metadata to replay
|
||||
*/
|
||||
private async replayUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
): Promise<void> {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a tool result record.
|
||||
*/
|
||||
@@ -118,6 +135,54 @@ export class HistoryReplayer {
|
||||
// Note: args aren't stored in tool_result records by default
|
||||
args: undefined,
|
||||
});
|
||||
|
||||
// Special handling: Task tool execution summary contains token usage
|
||||
const { resultDisplay } = result ?? {};
|
||||
if (
|
||||
!!resultDisplay &&
|
||||
typeof resultDisplay === 'object' &&
|
||||
'type' in resultDisplay &&
|
||||
(resultDisplay as { type?: unknown }).type === 'task_execution'
|
||||
) {
|
||||
await this.emitTaskUsageFromResultDisplay(
|
||||
resultDisplay as TaskResultDisplay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits token usage from a TaskResultDisplay execution summary, if present.
|
||||
*/
|
||||
private async emitTaskUsageFromResultDisplay(
|
||||
resultDisplay: TaskResultDisplay,
|
||||
): Promise<void> {
|
||||
const summary = resultDisplay.executionSummary;
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usageMetadata: GenerateContentResponseUsageMetadata = {};
|
||||
|
||||
if (Number.isFinite(summary.inputTokens)) {
|
||||
usageMetadata.promptTokenCount = summary.inputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.outputTokens)) {
|
||||
usageMetadata.candidatesTokenCount = summary.outputTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.thoughtTokens)) {
|
||||
usageMetadata.thoughtsTokenCount = summary.thoughtTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.cachedTokens)) {
|
||||
usageMetadata.cachedContentTokenCount = summary.cachedTokens;
|
||||
}
|
||||
if (Number.isFinite(summary.totalTokens)) {
|
||||
usageMetadata.totalTokenCount = summary.totalTokens;
|
||||
}
|
||||
|
||||
// Only emit if we captured at least one token metric
|
||||
if (Object.keys(usageMetadata).length > 0) {
|
||||
await this.messageEmitter.emitUsageMetadata(usageMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, FunctionCall, Part } from '@google/genai';
|
||||
import type {
|
||||
Content,
|
||||
FunctionCall,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
} from '@google/genai';
|
||||
import type {
|
||||
Config,
|
||||
GeminiChat,
|
||||
@@ -55,6 +60,7 @@ import type { SessionContext, ToolCallStartParams } from './types.js';
|
||||
import { HistoryReplayer } from './HistoryReplayer.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { PlanEmitter } from './emitters/PlanEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { SubAgentTracker } from './SubAgentTracker.js';
|
||||
|
||||
/**
|
||||
@@ -79,6 +85,7 @@ export class Session implements SessionContext {
|
||||
private readonly historyReplayer: HistoryReplayer;
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly planEmitter: PlanEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
|
||||
// Implement SessionContext interface
|
||||
readonly sessionId: string;
|
||||
@@ -96,6 +103,7 @@ export class Session implements SessionContext {
|
||||
this.toolCallEmitter = new ToolCallEmitter(this);
|
||||
this.planEmitter = new PlanEmitter(this);
|
||||
this.historyReplayer = new HistoryReplayer(this);
|
||||
this.messageEmitter = new MessageEmitter(this);
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
@@ -192,6 +200,8 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
|
||||
const streamStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
@@ -222,20 +232,18 @@ export class Session implements SessionContext {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content: acp.ContentBlock = {
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
};
|
||||
|
||||
this.sendUpdate({
|
||||
sessionUpdate: part.thought
|
||||
? 'agent_thought_chunk'
|
||||
: 'agent_message_chunk',
|
||||
content,
|
||||
});
|
||||
this.messageEmitter.emitMessage(
|
||||
part.text,
|
||||
'assistant',
|
||||
part.thought,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
|
||||
usageMetadata = resp.value.usageMetadata;
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
|
||||
functionCalls.push(...resp.value.functionCalls);
|
||||
}
|
||||
@@ -251,6 +259,15 @@ export class Session implements SessionContext {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (usageMetadata) {
|
||||
const durationMs = Date.now() - streamStartTime;
|
||||
await this.messageEmitter.emitUsageMetadata(
|
||||
usageMetadata,
|
||||
'',
|
||||
durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
@@ -444,7 +461,9 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
this.config.getApprovalMode() !== ApprovalMode.YOLO
|
||||
? await invocation.shouldConfirmExecute(abortSignal)
|
||||
: false;
|
||||
|
||||
if (confirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
@@ -522,6 +541,7 @@ export class Session implements SessionContext {
|
||||
callId,
|
||||
toolName: fc.name,
|
||||
args,
|
||||
status: 'in_progress',
|
||||
};
|
||||
await this.toolCallEmitter.emitStart(startParams);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'read_file',
|
||||
content: [],
|
||||
locations: [],
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
SubAgentUsageEvent,
|
||||
ToolCallConfirmationDetails,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { z } from 'zod';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import type * as acp from '../acp.js';
|
||||
|
||||
/**
|
||||
@@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
|
||||
*/
|
||||
export class SubAgentTracker {
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
private readonly toolStates = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -76,6 +79,7 @@ export class SubAgentTracker {
|
||||
private readonly client: acp.Client,
|
||||
) {
|
||||
this.toolCallEmitter = new ToolCallEmitter(ctx);
|
||||
this.messageEmitter = new MessageEmitter(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,16 +96,19 @@ export class SubAgentTracker {
|
||||
const onToolCall = this.createToolCallHandler(abortSignal);
|
||||
const onToolResult = this.createToolResultHandler(abortSignal);
|
||||
const onApproval = this.createApprovalHandler(abortSignal);
|
||||
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
|
||||
|
||||
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
|
||||
return [
|
||||
() => {
|
||||
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
|
||||
// Clean up any remaining states
|
||||
this.toolStates.clear();
|
||||
},
|
||||
@@ -252,6 +259,20 @@ export class SubAgentTracker {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for usage metadata events.
|
||||
*/
|
||||
private createUsageMetadataHandler(
|
||||
abortSignal: AbortSignal,
|
||||
): (...args: unknown[]) => void {
|
||||
return (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentUsageEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts confirmation details to permission options for the client.
|
||||
*/
|
||||
|
||||
@@ -148,4 +148,59 @@ describe('MessageEmitter', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUsageMetadata', () => {
|
||||
it('should emit agent_message_chunk with _meta.usage containing token counts', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
thoughtsTokenCount: 25,
|
||||
totalTokenCount: 175,
|
||||
cachedContentTokenCount: 10,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: 25,
|
||||
totalTokens: 175,
|
||||
cachedTokens: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include durationMs in _meta when provided', async () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
totalTokenCount: 17,
|
||||
cachedContentTokenCount: 1,
|
||||
};
|
||||
|
||||
await emitter.emitUsageMetadata(usageMetadata, 'done', 1234);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'done' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
thoughtsTokens: 2,
|
||||
totalTokens: 17,
|
||||
cachedTokens: 1,
|
||||
},
|
||||
durationMs: 1234,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type { Usage } from '../../schema.js';
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,16 @@ export class MessageEmitter extends BaseEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent message chunk.
|
||||
*/
|
||||
@@ -35,12 +47,28 @@ export class MessageEmitter extends BaseEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
* Emits usage metadata.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
async emitUsageMetadata(
|
||||
usageMetadata: GenerateContentResponseUsageMetadata,
|
||||
text: string = '',
|
||||
durationMs?: number,
|
||||
): Promise<void> {
|
||||
const usage: Usage = {
|
||||
promptTokens: usageMetadata.promptTokenCount,
|
||||
completionTokens: usageMetadata.candidatesTokenCount,
|
||||
thoughtsTokens: usageMetadata.thoughtsTokenCount,
|
||||
totalTokens: usageMetadata.totalTokenCount,
|
||||
cachedTokens: usageMetadata.cachedContentTokenCount,
|
||||
};
|
||||
|
||||
const meta =
|
||||
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
_meta: meta,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'unknown_tool', // Falls back to tool name
|
||||
content: [],
|
||||
locations: [],
|
||||
@@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-456',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'edit_file: Test tool description',
|
||||
content: [],
|
||||
locations: [{ path: '/test/file.ts', line: 10 }],
|
||||
@@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-fail',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'failing_tool', // Fallback to tool name
|
||||
content: [],
|
||||
locations: [], // Fallback to empty
|
||||
@@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => {
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: '{"output":"test output"}',
|
||||
text: 'test output',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => {
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: '{"output":"Function output"}' },
|
||||
content: { type: 'text', text: 'Function output' },
|
||||
},
|
||||
],
|
||||
rawOutput: 'raw result',
|
||||
|
||||
@@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: params.callId,
|
||||
status: 'in_progress',
|
||||
status: params.status || 'pending',
|
||||
title,
|
||||
content: [],
|
||||
locations,
|
||||
@@ -275,7 +275,18 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
// Handle functionResponse parts - stringify the response
|
||||
if ('functionResponse' in part && part.functionResponse) {
|
||||
try {
|
||||
const responseText = JSON.stringify(part.functionResponse.response);
|
||||
const resp = part.functionResponse.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const outputField = resp['output'];
|
||||
const errorField = resp['error'];
|
||||
const responseText =
|
||||
typeof outputField === 'string'
|
||||
? outputField
|
||||
: typeof errorField === 'string'
|
||||
? errorField
|
||||
: JSON.stringify(resp);
|
||||
result.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: responseText },
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface ToolCallStartParams {
|
||||
callId: string;
|
||||
/** Arguments passed to the tool */
|
||||
args?: Record<string, unknown>;
|
||||
/** Status of the tool call */
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import {
|
||||
ApprovalMode,
|
||||
AuthType,
|
||||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
@@ -129,10 +130,20 @@ export interface CliArgs {
|
||||
inputFormat?: string | undefined;
|
||||
outputFormat: string | undefined;
|
||||
includePartialMessages?: boolean;
|
||||
/**
|
||||
* If chat recording is disabled, the chat history would not be recorded,
|
||||
* so --continue and --resume would not take effect.
|
||||
*/
|
||||
chatRecording: boolean | undefined;
|
||||
/** Resume the most recent session for the current project */
|
||||
continue: boolean | undefined;
|
||||
/** Resume a specific session by its ID */
|
||||
resume: string | undefined;
|
||||
maxSessionTurns: number | undefined;
|
||||
coreTools: string[] | undefined;
|
||||
excludeTools: string[] | undefined;
|
||||
authType: string | undefined;
|
||||
channel: string | undefined;
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(
|
||||
@@ -227,6 +238,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
'proxy',
|
||||
'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.option('chat-recording', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable chat recording to disk. If false, chat history is not saved and --continue/--resume will not work.',
|
||||
})
|
||||
.command('$0 [query..]', 'Launch Qwen Code CLI', (yargsInstance: Argv) =>
|
||||
yargsInstance
|
||||
.positional('query', {
|
||||
@@ -292,6 +308,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
||||
description: 'Channel identifier (VSCode, ACP, SDK, CI)',
|
||||
})
|
||||
.option('allowed-mcp-server-names', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
@@ -411,6 +432,36 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description:
|
||||
'Resume a specific session by its ID. Use without an ID to show session picker.',
|
||||
})
|
||||
.option('max-session-turns', {
|
||||
type: 'number',
|
||||
description: 'Maximum number of session turns',
|
||||
})
|
||||
.option('core-tools', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Core tool paths',
|
||||
coerce: (tools: string[]) =>
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('exclude-tools', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Tools to exclude',
|
||||
coerce: (tools: string[]) =>
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('allowed-tools', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
description: 'Tools to allow, will bypass confirmation',
|
||||
coerce: (tools: string[]) =>
|
||||
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
|
||||
})
|
||||
.option('auth-type', {
|
||||
type: 'string',
|
||||
choices: [AuthType.USE_OPENAI, AuthType.QWEN_OAUTH],
|
||||
description: 'Authentication type',
|
||||
})
|
||||
.deprecateOption(
|
||||
'show-memory-usage',
|
||||
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
@@ -524,6 +575,12 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
|
||||
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
|
||||
if (result['experimentalAcp'] && !result['channel']) {
|
||||
(result as Record<string, unknown>)['channel'] = 'ACP';
|
||||
}
|
||||
|
||||
return result as unknown as CliArgs;
|
||||
}
|
||||
|
||||
@@ -745,8 +802,14 @@ export async function loadCliConfig(
|
||||
interactive = false;
|
||||
}
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
// However, if stream-json input is used, control can be requested via JSON messages,
|
||||
// so tools should not be excluded in that case.
|
||||
const extraExcludes: string[] = [];
|
||||
if (!interactive && !argv.experimentalAcp) {
|
||||
if (
|
||||
!interactive &&
|
||||
!argv.experimentalAcp &&
|
||||
inputFormat !== InputFormat.STREAM_JSON
|
||||
) {
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
@@ -770,6 +833,7 @@ export async function loadCliConfig(
|
||||
settings,
|
||||
activeExtensions,
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
argv.excludeTools,
|
||||
);
|
||||
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
|
||||
|
||||
@@ -850,7 +914,7 @@ export async function loadCliConfig(
|
||||
debugMode,
|
||||
question,
|
||||
fullContext: argv.allFiles || false,
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
coreTools: argv.coreTools || settings.tools?.core || undefined,
|
||||
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
|
||||
excludeTools,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
@@ -883,13 +947,16 @@ export async function loadCliConfig(
|
||||
model: resolvedModel,
|
||||
extensionContextFilePaths,
|
||||
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
|
||||
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
|
||||
maxSessionTurns:
|
||||
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
extensions: allExtensions,
|
||||
blockedMcpServers,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
authType: settings.security?.auth?.selectedType,
|
||||
authType:
|
||||
(argv.authType as AuthType | undefined) ||
|
||||
settings.security?.auth?.selectedType,
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
includePartialMessages,
|
||||
@@ -938,6 +1005,12 @@ export async function loadCliConfig(
|
||||
output: {
|
||||
format: outputSettingsFormat,
|
||||
},
|
||||
channel: argv.channel,
|
||||
// Precedence: explicit CLI flag > settings file > default(true).
|
||||
// NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will
|
||||
// always be true and the settings file can never disable recording.
|
||||
chatRecording:
|
||||
argv.chatRecording ?? settings.general?.chatRecording ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -997,8 +1070,10 @@ function mergeExcludeTools(
|
||||
settings: Settings,
|
||||
extensions: Extension[],
|
||||
extraExcludes?: string[] | undefined,
|
||||
cliExcludeTools?: string[] | undefined,
|
||||
): string[] {
|
||||
const allExcludeTools = new Set([
|
||||
...(cliExcludeTools || []),
|
||||
...(settings.tools?.exclude || []),
|
||||
...(extraExcludes || []),
|
||||
]);
|
||||
|
||||
@@ -191,8 +191,29 @@ const SETTINGS_SCHEMA = {
|
||||
{ value: 'auto', label: 'Auto (detect from system)' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh', label: '中文 (Chinese)' },
|
||||
{ value: 'ru', label: 'Русский (Russian)' },
|
||||
],
|
||||
},
|
||||
terminalBell: {
|
||||
type: 'boolean',
|
||||
label: 'Terminal Bell',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Play terminal bell sound when response completes or needs approval.',
|
||||
showInDialog: true,
|
||||
},
|
||||
chatRecording: {
|
||||
type: 'boolean',
|
||||
label: 'Chat Recording',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Enable saving chat history to disk. Disabling this will also prevent --continue and --resume from working.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
|
||||
@@ -481,6 +481,12 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
includePartialMessages: undefined,
|
||||
continue: undefined,
|
||||
resume: undefined,
|
||||
coreTools: undefined,
|
||||
excludeTools: undefined,
|
||||
authType: undefined,
|
||||
maxSessionTurns: undefined,
|
||||
channel: undefined,
|
||||
chatRecording: undefined,
|
||||
});
|
||||
|
||||
await main();
|
||||
|
||||
@@ -276,8 +276,11 @@ export async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
// For stream-json mode, don't read stdin here - it should be forwarded to the sandbox
|
||||
// and consumed by StreamJsonInputReader inside the container
|
||||
const inputFormat = argv.inputFormat as string | undefined;
|
||||
let stdinData = '';
|
||||
if (!process.stdin.isTTY) {
|
||||
if (!process.stdin.isTTY && inputFormat !== 'stream-json') {
|
||||
stdinData = await readStdin();
|
||||
}
|
||||
|
||||
@@ -383,7 +386,18 @@ export async function main() {
|
||||
|
||||
setMaxSizedBoxDebugging(isDebugMode);
|
||||
|
||||
const initializationResult = await initializeApp(config, settings);
|
||||
// Check input format early to determine initialization flow
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
|
||||
// For stream-json mode, defer config.initialize() until after the initialize control request
|
||||
// For other modes, initialize normally
|
||||
let initializationResult: InitializationResult | undefined;
|
||||
if (inputFormat !== InputFormat.STREAM_JSON) {
|
||||
initializationResult = await initializeApp(config, settings);
|
||||
}
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.selectedType ===
|
||||
@@ -417,19 +431,15 @@ export async function main() {
|
||||
settings,
|
||||
startupWarnings,
|
||||
process.cwd(),
|
||||
initializationResult,
|
||||
initializationResult!,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await config.initialize();
|
||||
|
||||
// Check input format BEFORE reading stdin
|
||||
// In STREAM_JSON mode, stdin should be left for StreamJsonInputReader
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
// For non-stream-json mode, initialize config here
|
||||
if (inputFormat !== InputFormat.STREAM_JSON) {
|
||||
await config.initialize();
|
||||
}
|
||||
|
||||
// Only read stdin if NOT in stream-json mode
|
||||
// In stream-json mode, stdin is used for protocol messages (control requests, etc.)
|
||||
@@ -442,7 +452,8 @@ export async function main() {
|
||||
}
|
||||
|
||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
(argv.authType as AuthType) ||
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
config,
|
||||
settings,
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes
|
||||
export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes
|
||||
|
||||
// State
|
||||
let currentLanguage: SupportedLanguage = 'en';
|
||||
@@ -51,10 +51,12 @@ export function detectSystemLanguage(): SupportedLanguage {
|
||||
const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG'];
|
||||
if (envLang?.startsWith('zh')) return 'zh';
|
||||
if (envLang?.startsWith('en')) return 'en';
|
||||
if (envLang?.startsWith('ru')) return 'ru';
|
||||
|
||||
try {
|
||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
|
||||
if (locale.startsWith('zh')) return 'zh';
|
||||
if (locale.startsWith('ru')) return 'ru';
|
||||
} catch {
|
||||
// Fallback to default
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ export default {
|
||||
'open full Qwen Code documentation in your browser',
|
||||
'Configuration not available.': 'Configuration not available.',
|
||||
'change the auth method': 'change the auth method',
|
||||
'Show quit confirmation dialog': 'Show quit confirmation dialog',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'Copy the last result or code snippet to clipboard',
|
||||
|
||||
@@ -690,18 +689,6 @@ export default {
|
||||
'A custom command wants to run the following shell commands:':
|
||||
'A custom command wants to run the following shell commands:',
|
||||
|
||||
// ============================================================================
|
||||
// Dialogs - Quit Confirmation
|
||||
// ============================================================================
|
||||
'What would you like to do before exiting?':
|
||||
'What would you like to do before exiting?',
|
||||
'Quit immediately (/quit)': 'Quit immediately (/quit)',
|
||||
'Generate summary and quit (/summary)':
|
||||
'Generate summary and quit (/summary)',
|
||||
'Save conversation and quit (/chat save)':
|
||||
'Save conversation and quit (/chat save)',
|
||||
'Cancel (stay in application)': 'Cancel (stay in application)',
|
||||
|
||||
// ============================================================================
|
||||
// Dialogs - Pro Quota
|
||||
// ============================================================================
|
||||
@@ -880,6 +867,7 @@ export default {
|
||||
// Exit Screen / Stats
|
||||
// ============================================================================
|
||||
'Agent powering down. Goodbye!': 'Agent powering down. Goodbye!',
|
||||
'To continue this session, run': 'To continue this session, run',
|
||||
'Interaction Summary': 'Interaction Summary',
|
||||
'Session ID:': 'Session ID:',
|
||||
'Tool Calls:': 'Tool Calls:',
|
||||
|
||||
1121
packages/cli/src/i18n/locales/ru.js
Normal file
1121
packages/cli/src/i18n/locales/ru.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -108,7 +108,6 @@ export default {
|
||||
'在浏览器中打开完整的 Qwen Code 文档',
|
||||
'Configuration not available.': '配置不可用',
|
||||
'change the auth method': '更改认证方法',
|
||||
'Show quit confirmation dialog': '显示退出确认对话框',
|
||||
'Copy the last result or code snippet to clipboard':
|
||||
'将最后的结果或代码片段复制到剪贴板',
|
||||
|
||||
@@ -655,15 +654,6 @@ export default {
|
||||
'A custom command wants to run the following shell commands:':
|
||||
'自定义命令想要运行以下 shell 命令:',
|
||||
|
||||
// ============================================================================
|
||||
// Dialogs - Quit Confirmation
|
||||
// ============================================================================
|
||||
'What would you like to do before exiting?': '退出前您想要做什么?',
|
||||
'Quit immediately (/quit)': '立即退出 (/quit)',
|
||||
'Generate summary and quit (/summary)': '生成摘要并退出 (/summary)',
|
||||
'Save conversation and quit (/chat save)': '保存对话并退出 (/chat save)',
|
||||
'Cancel (stay in application)': '取消(留在应用程序中)',
|
||||
|
||||
// ============================================================================
|
||||
// Dialogs - Pro Quota
|
||||
// ============================================================================
|
||||
@@ -830,6 +820,7 @@ export default {
|
||||
// Exit Screen / Stats
|
||||
// ============================================================================
|
||||
'Agent powering down. Goodbye!': 'Qwen Code 正在关闭,再见!',
|
||||
'To continue this session, run': '要继续此会话,请运行',
|
||||
'Interaction Summary': '交互摘要',
|
||||
'Session ID:': '会话 ID:',
|
||||
'Tool Calls:': '工具调用:',
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
* Controllers:
|
||||
* - SystemController: initialize, interrupt, set_model, supported_commands
|
||||
* - PermissionController: can_use_tool, set_permission_mode
|
||||
* - MCPController: mcp_message, mcp_server_status
|
||||
* - SdkMcpController: mcp_server_status (mcp_message handled via callback)
|
||||
* - HookController: hook_callback
|
||||
*
|
||||
* Note: mcp_message requests are NOT routed through the dispatcher. CLI MCP
|
||||
* clients send messages via SdkMcpController.createSendSdkMcpMessage() callback.
|
||||
*
|
||||
* Note: Control request types are centrally defined in the ControlRequestType
|
||||
* enum in packages/sdk/typescript/src/types/controlRequests.ts
|
||||
*/
|
||||
@@ -26,8 +29,8 @@
|
||||
import type { IControlContext } from './ControlContext.js';
|
||||
import type { IPendingRequestRegistry } from './controllers/baseController.js';
|
||||
import { SystemController } from './controllers/systemController.js';
|
||||
// import { PermissionController } from './controllers/permissionController.js';
|
||||
// import { MCPController } from './controllers/mcpController.js';
|
||||
import { PermissionController } from './controllers/permissionController.js';
|
||||
import { SdkMcpController } from './controllers/sdkMcpController.js';
|
||||
// import { HookController } from './controllers/hookController.js';
|
||||
import type {
|
||||
CLIControlRequest,
|
||||
@@ -64,8 +67,8 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
|
||||
// Make controllers publicly accessible
|
||||
readonly systemController: SystemController;
|
||||
// readonly permissionController: PermissionController;
|
||||
// readonly mcpController: MCPController;
|
||||
readonly permissionController: PermissionController;
|
||||
readonly sdkMcpController: SdkMcpController;
|
||||
// readonly hookController: HookController;
|
||||
|
||||
// Central pending request registries
|
||||
@@ -83,12 +86,16 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
this,
|
||||
'SystemController',
|
||||
);
|
||||
// this.permissionController = new PermissionController(
|
||||
// context,
|
||||
// this,
|
||||
// 'PermissionController',
|
||||
// );
|
||||
// this.mcpController = new MCPController(context, this, 'MCPController');
|
||||
this.permissionController = new PermissionController(
|
||||
context,
|
||||
this,
|
||||
'PermissionController',
|
||||
);
|
||||
this.sdkMcpController = new SdkMcpController(
|
||||
context,
|
||||
this,
|
||||
'SdkMcpController',
|
||||
);
|
||||
// this.hookController = new HookController(context, this, 'HookController');
|
||||
|
||||
// Listen for main abort signal
|
||||
@@ -228,10 +235,10 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
}
|
||||
this.pendingOutgoingRequests.clear();
|
||||
|
||||
// Cleanup controllers (MCP controller will close all clients)
|
||||
// Cleanup controllers
|
||||
this.systemController.cleanup();
|
||||
// this.permissionController.cleanup();
|
||||
// this.mcpController.cleanup();
|
||||
this.permissionController.cleanup();
|
||||
this.sdkMcpController.cleanup();
|
||||
// this.hookController.cleanup();
|
||||
}
|
||||
|
||||
@@ -291,6 +298,47 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending incoming requests (for debugging)
|
||||
*/
|
||||
getPendingIncomingRequestCount(): number {
|
||||
return this.pendingIncomingRequests.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all incoming request handlers to complete.
|
||||
*
|
||||
* Uses polling since we don't have direct Promise references to handlers.
|
||||
* The pendingIncomingRequests map is managed by BaseController:
|
||||
* - Registered when handler starts (in handleRequest)
|
||||
* - Deregistered when handler completes (success or error)
|
||||
*
|
||||
* @param pollIntervalMs - How often to check (default 50ms)
|
||||
* @param timeoutMs - Maximum wait time (default 30s)
|
||||
*/
|
||||
async waitForPendingIncomingRequests(
|
||||
pollIntervalMs: number = 50,
|
||||
timeoutMs: number = 30000,
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (this.pendingIncomingRequests.size > 0) {
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[ControlDispatcher] Timeout waiting for ${this.pendingIncomingRequests.size} pending incoming requests`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
if (this.context.debugMode && this.pendingIncomingRequests.size === 0) {
|
||||
console.error('[ControlDispatcher] All incoming requests completed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the controller that handles the given request subtype
|
||||
*/
|
||||
@@ -302,13 +350,12 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
case 'supported_commands':
|
||||
return this.systemController;
|
||||
|
||||
// case 'can_use_tool':
|
||||
// case 'set_permission_mode':
|
||||
// return this.permissionController;
|
||||
case 'can_use_tool':
|
||||
case 'set_permission_mode':
|
||||
return this.permissionController;
|
||||
|
||||
// case 'mcp_message':
|
||||
// case 'mcp_server_status':
|
||||
// return this.mcpController;
|
||||
case 'mcp_server_status':
|
||||
return this.sdkMcpController;
|
||||
|
||||
// case 'hook_callback':
|
||||
// return this.hookController;
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import type { IControlContext } from './ControlContext.js';
|
||||
import type { ControlDispatcher } from './ControlDispatcher.js';
|
||||
import type {
|
||||
// PermissionServiceAPI,
|
||||
PermissionServiceAPI,
|
||||
SystemServiceAPI,
|
||||
// McpServiceAPI,
|
||||
// HookServiceAPI,
|
||||
@@ -61,43 +61,31 @@ export class ControlService {
|
||||
* Handles tool execution permissions, approval checks, and callbacks.
|
||||
* Delegates to the shared PermissionController instance.
|
||||
*/
|
||||
// get permission(): PermissionServiceAPI {
|
||||
// const controller = this.dispatcher.permissionController;
|
||||
// return {
|
||||
// /**
|
||||
// * Check if a tool should be allowed based on current permission settings
|
||||
// *
|
||||
// * Evaluates permission mode and tool registry to determine if execution
|
||||
// * should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||
// *
|
||||
// * @param toolRequest - Tool call request information
|
||||
// * @param confirmationDetails - Optional confirmation details for UI
|
||||
// * @returns Permission decision with optional updated arguments
|
||||
// */
|
||||
// shouldAllowTool: controller.shouldAllowTool.bind(controller),
|
||||
//
|
||||
// /**
|
||||
// * Build UI suggestions for tool confirmation dialogs
|
||||
// *
|
||||
// * Creates actionable permission suggestions based on tool confirmation details.
|
||||
// *
|
||||
// * @param confirmationDetails - Tool confirmation details
|
||||
// * @returns Array of permission suggestions or null
|
||||
// */
|
||||
// buildPermissionSuggestions:
|
||||
// controller.buildPermissionSuggestions.bind(controller),
|
||||
//
|
||||
// /**
|
||||
// * Get callback for monitoring tool call status updates
|
||||
// *
|
||||
// * Returns callback function for integration with CoreToolScheduler.
|
||||
// *
|
||||
// * @returns Callback function for tool call updates
|
||||
// */
|
||||
// getToolCallUpdateCallback:
|
||||
// controller.getToolCallUpdateCallback.bind(controller),
|
||||
// };
|
||||
// }
|
||||
get permission(): PermissionServiceAPI {
|
||||
const controller = this.dispatcher.permissionController;
|
||||
return {
|
||||
/**
|
||||
* Build UI suggestions for tool confirmation dialogs
|
||||
*
|
||||
* Creates actionable permission suggestions based on tool confirmation details.
|
||||
*
|
||||
* @param confirmationDetails - Tool confirmation details
|
||||
* @returns Array of permission suggestions or null
|
||||
*/
|
||||
buildPermissionSuggestions:
|
||||
controller.buildPermissionSuggestions.bind(controller),
|
||||
|
||||
/**
|
||||
* Get callback for monitoring tool call status updates
|
||||
*
|
||||
* Returns callback function for integration with CoreToolScheduler.
|
||||
*
|
||||
* @returns Callback function for tool call updates
|
||||
*/
|
||||
getToolCallUpdateCallback:
|
||||
controller.getToolCallUpdateCallback.bind(controller),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* System Domain API
|
||||
|
||||
@@ -117,16 +117,41 @@ export abstract class BaseController {
|
||||
* Send an outgoing control request to SDK
|
||||
*
|
||||
* Manages lifecycle: register -> send -> wait for response -> deregister
|
||||
* Respects the provided AbortSignal for cancellation.
|
||||
*/
|
||||
async sendControlRequest(
|
||||
payload: ControlRequestPayload,
|
||||
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ControlResponse> {
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
|
||||
return new Promise<ControlResponse>((resolve, reject) => {
|
||||
// Setup abort handler
|
||||
const abortHandler = () => {
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(new Error('Request aborted'));
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[${this.controllerName}] Outgoing request aborted: ${requestId}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
|
||||
// Setup timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(new Error('Control request timeout'));
|
||||
if (this.context.debugMode) {
|
||||
@@ -136,12 +161,27 @@ export abstract class BaseController {
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// Wrap resolve/reject to clean up abort listener
|
||||
const wrappedResolve = (response: ControlResponse) => {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
resolve(response);
|
||||
};
|
||||
|
||||
const wrappedReject = (error: Error) => {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
|
||||
// Register with central registry
|
||||
this.registry.registerOutgoingRequest(
|
||||
requestId,
|
||||
this.controllerName,
|
||||
resolve,
|
||||
reject,
|
||||
wrappedResolve,
|
||||
wrappedReject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
@@ -155,6 +195,9 @@ export abstract class BaseController {
|
||||
try {
|
||||
this.context.streamJson.send(request);
|
||||
} catch (error) {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(error);
|
||||
}
|
||||
@@ -174,7 +217,5 @@ export abstract class BaseController {
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
// Subclasses can override to add cleanup logic
|
||||
}
|
||||
cleanup(): void {}
|
||||
}
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP Controller
|
||||
*
|
||||
* Handles MCP-related control requests:
|
||||
* - mcp_message: Route MCP messages
|
||||
* - mcp_server_status: Return MCP server status
|
||||
*/
|
||||
|
||||
import { BaseController } from './baseController.js';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlMcpMessageRequest,
|
||||
} from '../../types.js';
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
WorkspaceContext,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
connectToMcpServer,
|
||||
MCP_DEFAULT_TIMEOUT_MSEC,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class MCPController extends BaseController {
|
||||
/**
|
||||
* Handle MCP control requests
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (payload.subtype) {
|
||||
case 'mcp_message':
|
||||
return this.handleMcpMessage(payload as CLIControlMcpMessageRequest);
|
||||
|
||||
case 'mcp_server_status':
|
||||
return this.handleMcpStatus();
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in MCPController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_message request
|
||||
*
|
||||
* Routes JSON-RPC messages to MCP servers
|
||||
*/
|
||||
private async handleMcpMessage(
|
||||
payload: CLIControlMcpMessageRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const serverNameRaw = payload.server_name;
|
||||
if (
|
||||
typeof serverNameRaw !== 'string' ||
|
||||
serverNameRaw.trim().length === 0
|
||||
) {
|
||||
throw new Error('Missing server_name in mcp_message request');
|
||||
}
|
||||
|
||||
const message = payload.message;
|
||||
if (!message || typeof message !== 'object') {
|
||||
throw new Error(
|
||||
'Missing or invalid message payload for mcp_message request',
|
||||
);
|
||||
}
|
||||
|
||||
// Get or create MCP client
|
||||
let clientEntry: { client: Client; config: MCPServerConfig };
|
||||
try {
|
||||
clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim());
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to connect to MCP server',
|
||||
);
|
||||
}
|
||||
|
||||
const method = message.method;
|
||||
if (typeof method !== 'string' || method.trim().length === 0) {
|
||||
throw new Error('Invalid MCP message: missing method');
|
||||
}
|
||||
|
||||
const jsonrpcVersion =
|
||||
typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0';
|
||||
const messageId = message.id;
|
||||
const params = message.params;
|
||||
const timeout =
|
||||
typeof clientEntry.config.timeout === 'number'
|
||||
? clientEntry.config.timeout
|
||||
: MCP_DEFAULT_TIMEOUT_MSEC;
|
||||
|
||||
try {
|
||||
// Handle notification (no id)
|
||||
if (messageId === undefined) {
|
||||
await clientEntry.client.notification({
|
||||
method,
|
||||
params,
|
||||
});
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: null,
|
||||
result: { success: true, acknowledged: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle request (with id)
|
||||
const result = await clientEntry.client.request(
|
||||
{
|
||||
method,
|
||||
params,
|
||||
},
|
||||
ResultSchema,
|
||||
{ timeout },
|
||||
);
|
||||
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: messageId,
|
||||
result,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If connection closed, remove from cache
|
||||
if (error instanceof Error && /closed/i.test(error.message)) {
|
||||
this.context.mcpClients.delete(serverNameRaw.trim());
|
||||
}
|
||||
|
||||
const errorCode =
|
||||
typeof (error as { code?: unknown })?.code === 'number'
|
||||
? ((error as { code: number }).code as number)
|
||||
: -32603;
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to execute MCP request';
|
||||
const errorData = (error as { data?: unknown })?.data;
|
||||
|
||||
const errorBody: Record<string, unknown> = {
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
};
|
||||
if (errorData !== undefined) {
|
||||
errorBody['data'] = errorData;
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: messageId ?? null,
|
||||
error: errorBody,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_server_status request
|
||||
*
|
||||
* Returns status of registered MCP servers
|
||||
*/
|
||||
private async handleMcpStatus(): Promise<Record<string, unknown>> {
|
||||
const status: Record<string, string> = {};
|
||||
|
||||
// Include SDK MCP servers
|
||||
for (const serverName of this.context.sdkMcpServers) {
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
// Include CLI-managed MCP clients
|
||||
for (const serverName of this.context.mcpClients.keys()) {
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] MCP status: ${Object.keys(status).length} servers`,
|
||||
);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create MCP client for a server
|
||||
*
|
||||
* Implements lazy connection and caching
|
||||
*/
|
||||
private async getOrCreateMcpClient(
|
||||
serverName: string,
|
||||
): Promise<{ client: Client; config: MCPServerConfig }> {
|
||||
// Check cache first
|
||||
const cached = this.context.mcpClients.get(serverName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get server configuration
|
||||
const provider = this.context.config as unknown as {
|
||||
getMcpServers?: () => Record<string, MCPServerConfig> | undefined;
|
||||
getDebugMode?: () => boolean;
|
||||
getWorkspaceContext?: () => unknown;
|
||||
};
|
||||
|
||||
if (typeof provider.getMcpServers !== 'function') {
|
||||
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||
}
|
||||
|
||||
const servers = provider.getMcpServers() ?? {};
|
||||
const serverConfig = servers[serverName];
|
||||
if (!serverConfig) {
|
||||
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||
}
|
||||
|
||||
const debugMode =
|
||||
typeof provider.getDebugMode === 'function'
|
||||
? provider.getDebugMode()
|
||||
: false;
|
||||
|
||||
const workspaceContext =
|
||||
typeof provider.getWorkspaceContext === 'function'
|
||||
? provider.getWorkspaceContext()
|
||||
: undefined;
|
||||
|
||||
if (!workspaceContext) {
|
||||
throw new Error('Workspace context is not available for MCP connection');
|
||||
}
|
||||
|
||||
// Connect to MCP server
|
||||
const client = await connectToMcpServer(
|
||||
serverName,
|
||||
serverConfig,
|
||||
debugMode,
|
||||
workspaceContext as WorkspaceContext,
|
||||
);
|
||||
|
||||
// Cache the client
|
||||
const entry = { client, config: serverConfig };
|
||||
this.context.mcpClients.set(serverName, entry);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(`[MCPController] Connected to MCP server: ${serverName}`);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup MCP clients
|
||||
*/
|
||||
override cleanup(): void {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`,
|
||||
);
|
||||
}
|
||||
|
||||
// Close all MCP clients
|
||||
for (const [serverName, { client }] of this.context.mcpClients.entries()) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] Failed to close MCP client ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.context.mcpClients.clear();
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,10 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
WaitingToolCall,
|
||||
ToolExecuteConfirmationDetails,
|
||||
ToolMcpConfirmationDetails,
|
||||
ApprovalMode,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
InputFormat,
|
||||
@@ -42,15 +44,23 @@ export class PermissionController extends BaseController {
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
switch (payload.subtype) {
|
||||
case 'can_use_tool':
|
||||
return this.handleCanUseTool(payload as CLIControlPermissionRequest);
|
||||
return this.handleCanUseTool(
|
||||
payload as CLIControlPermissionRequest,
|
||||
signal,
|
||||
);
|
||||
|
||||
case 'set_permission_mode':
|
||||
return this.handleSetPermissionMode(
|
||||
payload as CLIControlSetPermissionModeRequest,
|
||||
signal,
|
||||
);
|
||||
|
||||
default:
|
||||
@@ -68,7 +78,12 @@ export class PermissionController extends BaseController {
|
||||
*/
|
||||
private async handleCanUseTool(
|
||||
payload: CLIControlPermissionRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const toolName = payload.tool_name;
|
||||
if (
|
||||
!toolName ||
|
||||
@@ -190,7 +205,12 @@ export class PermissionController extends BaseController {
|
||||
*/
|
||||
private async handleSetPermissionMode(
|
||||
payload: CLIControlSetPermissionModeRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const mode = payload.mode;
|
||||
const validModes: PermissionMode[] = [
|
||||
'default',
|
||||
@@ -206,6 +226,7 @@ export class PermissionController extends BaseController {
|
||||
}
|
||||
|
||||
this.context.permissionMode = mode;
|
||||
this.context.config.setApprovalMode(mode as ApprovalMode);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
@@ -334,47 +355,6 @@ export class PermissionController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool should be executed based on current permission settings
|
||||
*
|
||||
* This is a convenience method for direct tool execution checks without
|
||||
* going through the control request flow.
|
||||
*/
|
||||
async shouldAllowTool(
|
||||
toolRequest: ToolCallRequestInfo,
|
||||
confirmationDetails?: unknown,
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
updatedArgs?: Record<string, unknown>;
|
||||
}> {
|
||||
// Check permission mode
|
||||
const modeResult = this.checkPermissionMode();
|
||||
if (!modeResult.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: modeResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Check tool registry
|
||||
const registryResult = this.checkToolRegistry(toolRequest.name);
|
||||
if (!registryResult.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: registryResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// If we have confirmation details, we could potentially modify args
|
||||
// This is a hook for future enhancement
|
||||
if (confirmationDetails) {
|
||||
// Future: handle argument modifications based on confirmation details
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get callback for monitoring tool calls and handling outgoing permission requests
|
||||
* This is passed to executeToolCall to hook into CoreToolScheduler updates
|
||||
@@ -411,6 +391,14 @@ export class PermissionController extends BaseController {
|
||||
toolCall: WaitingToolCall,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if already aborted
|
||||
if (this.context.abortSignal?.aborted) {
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const inputFormat = this.context.config.getInputFormat?.();
|
||||
const isStreamJsonMode = inputFormat === InputFormat.STREAM_JSON;
|
||||
|
||||
@@ -439,7 +427,8 @@ export class PermissionController extends BaseController {
|
||||
permission_suggestions: permissionSuggestions,
|
||||
blocked_path: null,
|
||||
} as CLIControlPermissionRequest,
|
||||
30000,
|
||||
undefined, // use default timeout
|
||||
this.context.abortSignal,
|
||||
);
|
||||
|
||||
if (response.subtype !== 'success') {
|
||||
@@ -462,8 +451,15 @@ export class PermissionController extends BaseController {
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
} else {
|
||||
// Extract cancel message from response if available
|
||||
const cancelMessage =
|
||||
typeof payload['message'] === 'string'
|
||||
? payload['message']
|
||||
: undefined;
|
||||
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
cancelMessage ? { cancelMessage } : undefined,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -473,9 +469,23 @@ export class PermissionController extends BaseController {
|
||||
error,
|
||||
);
|
||||
}
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
// On error, use default cancel message
|
||||
// Only pass payload for exec and mcp types that support it
|
||||
const confirmationType = toolCall.confirmationDetails.type;
|
||||
if (['edit', 'exec', 'mcp'].includes(confirmationType)) {
|
||||
const execOrMcpDetails = toolCall.confirmationDetails as
|
||||
| ToolExecuteConfirmationDetails
|
||||
| ToolMcpConfirmationDetails;
|
||||
await execOrMcpDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
undefined,
|
||||
);
|
||||
} else {
|
||||
// For other types, don't pass payload (backward compatible)
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.pendingOutgoingRequests.delete(toolCall.request.callId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* SDK MCP Controller
|
||||
*
|
||||
* Handles MCP communication between CLI MCP clients and SDK MCP servers:
|
||||
* - Provides sendSdkMcpMessage callback for CLI → SDK MCP message routing
|
||||
* - mcp_server_status: Returns status of SDK MCP servers
|
||||
*
|
||||
* Message Flow (CLI MCP Client → SDK MCP Server):
|
||||
* CLI MCP Client → SdkControlClientTransport.send() →
|
||||
* sendSdkMcpMessage callback → control_request (mcp_message) → SDK →
|
||||
* SDK MCP Server processes → control_response → CLI MCP Client
|
||||
*/
|
||||
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { BaseController } from './baseController.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlMcpMessageRequest,
|
||||
} from '../../types.js';
|
||||
|
||||
const MCP_REQUEST_TIMEOUT = 30_000; // 30 seconds
|
||||
|
||||
export class SdkMcpController extends BaseController {
|
||||
/**
|
||||
* Handle SDK MCP control requests from ControlDispatcher
|
||||
*
|
||||
* Note: mcp_message requests are NOT handled here. CLI MCP clients
|
||||
* send messages via the sendSdkMcpMessage callback directly, not
|
||||
* through the control dispatcher.
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
switch (payload.subtype) {
|
||||
case 'mcp_server_status':
|
||||
return this.handleMcpStatus();
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in SdkMcpController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_server_status request
|
||||
*
|
||||
* Returns status of all registered SDK MCP servers.
|
||||
* SDK servers are considered "connected" if they are registered.
|
||||
*/
|
||||
private async handleMcpStatus(): Promise<Record<string, unknown>> {
|
||||
const status: Record<string, string> = {};
|
||||
|
||||
for (const serverName of this.context.sdkMcpServers) {
|
||||
// SDK MCP servers are "connected" once registered since they run in SDK process
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'mcp_server_status',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send MCP message to SDK server via control plane
|
||||
*
|
||||
* @param serverName - Name of the SDK MCP server
|
||||
* @param message - MCP JSON-RPC message to send
|
||||
* @returns MCP JSON-RPC response from SDK server
|
||||
*/
|
||||
private async sendMcpMessageToSdk(
|
||||
serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
): Promise<JSONRPCMessage> {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SdkMcpController] Sending MCP message to SDK server '${serverName}':`,
|
||||
JSON.stringify(message),
|
||||
);
|
||||
}
|
||||
|
||||
// Send control request to SDK with the MCP message
|
||||
const response = await this.sendControlRequest(
|
||||
{
|
||||
subtype: 'mcp_message',
|
||||
server_name: serverName,
|
||||
message: message as CLIControlMcpMessageRequest['message'],
|
||||
},
|
||||
MCP_REQUEST_TIMEOUT,
|
||||
this.context.abortSignal,
|
||||
);
|
||||
|
||||
// Extract MCP response from control response
|
||||
const responsePayload = response.response as Record<string, unknown>;
|
||||
const mcpResponse = responsePayload?.['mcp_response'] as JSONRPCMessage;
|
||||
|
||||
if (!mcpResponse) {
|
||||
throw new Error(
|
||||
`Invalid MCP response from SDK for server '${serverName}'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SdkMcpController] Received MCP response from SDK server '${serverName}':`,
|
||||
JSON.stringify(mcpResponse),
|
||||
);
|
||||
}
|
||||
|
||||
return mcpResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a callback function for sending MCP messages to SDK servers.
|
||||
*
|
||||
* This callback is used by McpClientManager/SdkControlClientTransport to send
|
||||
* MCP messages from CLI MCP clients to SDK MCP servers via the control plane.
|
||||
*
|
||||
* @returns A function that sends MCP messages to SDK and returns the response
|
||||
*/
|
||||
createSendSdkMcpMessage(): (
|
||||
serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
) => Promise<JSONRPCMessage> {
|
||||
return (serverName: string, message: JSONRPCMessage) =>
|
||||
this.sendMcpMessageToSdk(serverName, message);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,15 @@ import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlInitializeRequest,
|
||||
CLIControlSetModelRequest,
|
||||
CLIMcpServerConfig,
|
||||
} from '../../types.js';
|
||||
import { CommandService } from '../../../services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js';
|
||||
import {
|
||||
MCPServerConfig,
|
||||
AuthProviderType,
|
||||
type MCPOAuthConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class SystemController extends BaseController {
|
||||
/**
|
||||
@@ -26,20 +34,30 @@ export class SystemController extends BaseController {
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
switch (payload.subtype) {
|
||||
case 'initialize':
|
||||
return this.handleInitialize(payload as CLIControlInitializeRequest);
|
||||
return this.handleInitialize(
|
||||
payload as CLIControlInitializeRequest,
|
||||
signal,
|
||||
);
|
||||
|
||||
case 'interrupt':
|
||||
return this.handleInterrupt();
|
||||
|
||||
case 'set_model':
|
||||
return this.handleSetModel(payload as CLIControlSetModelRequest);
|
||||
return this.handleSetModel(
|
||||
payload as CLIControlSetModelRequest,
|
||||
signal,
|
||||
);
|
||||
|
||||
case 'supported_commands':
|
||||
return this.handleSupportedCommands();
|
||||
return this.handleSupportedCommands(signal);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in SystemController`);
|
||||
@@ -49,15 +67,130 @@ export class SystemController extends BaseController {
|
||||
/**
|
||||
* Handle initialize request
|
||||
*
|
||||
* Registers SDK MCP servers and returns capabilities
|
||||
* Processes SDK MCP servers config.
|
||||
* SDK servers are registered in context.sdkMcpServers
|
||||
* and added to config.mcpServers with the sdk type flag.
|
||||
* External MCP servers are configured separately in settings.
|
||||
*/
|
||||
private async handleInitialize(
|
||||
payload: CLIControlInitializeRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Register SDK MCP servers if provided
|
||||
if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) {
|
||||
for (const serverName of payload.sdkMcpServers) {
|
||||
this.context.sdkMcpServers.add(serverName);
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
this.context.config.setSdkMode(true);
|
||||
|
||||
// Process SDK MCP servers
|
||||
if (
|
||||
payload.sdkMcpServers &&
|
||||
typeof payload.sdkMcpServers === 'object' &&
|
||||
payload.sdkMcpServers !== null
|
||||
) {
|
||||
const sdkServers: Record<string, MCPServerConfig> = {};
|
||||
for (const [key, wireConfig] of Object.entries(payload.sdkMcpServers)) {
|
||||
const name =
|
||||
typeof wireConfig?.name === 'string' && wireConfig.name.trim().length
|
||||
? wireConfig.name
|
||||
: key;
|
||||
|
||||
this.context.sdkMcpServers.add(name);
|
||||
sdkServers[name] = new MCPServerConfig(
|
||||
undefined, // command
|
||||
undefined, // args
|
||||
undefined, // env
|
||||
undefined, // cwd
|
||||
undefined, // url
|
||||
undefined, // httpUrl
|
||||
undefined, // headers
|
||||
undefined, // tcp
|
||||
undefined, // timeout
|
||||
true, // trust - SDK servers are trusted
|
||||
undefined, // description
|
||||
undefined, // includeTools
|
||||
undefined, // excludeTools
|
||||
undefined, // extensionName
|
||||
undefined, // oauth
|
||||
undefined, // authProviderType
|
||||
undefined, // targetAudience
|
||||
undefined, // targetServiceAccount
|
||||
'sdk', // type
|
||||
);
|
||||
}
|
||||
|
||||
const sdkServerCount = Object.keys(sdkServers).length;
|
||||
if (sdkServerCount > 0) {
|
||||
try {
|
||||
this.context.config.addMcpServers(sdkServers);
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${sdkServerCount} SDK MCP servers to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to add SDK MCP servers:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
payload.mcpServers &&
|
||||
typeof payload.mcpServers === 'object' &&
|
||||
payload.mcpServers !== null
|
||||
) {
|
||||
const externalServers: Record<string, MCPServerConfig> = {};
|
||||
for (const [name, serverConfig] of Object.entries(payload.mcpServers)) {
|
||||
const normalized = this.normalizeMcpServerConfig(
|
||||
name,
|
||||
serverConfig as CLIMcpServerConfig | undefined,
|
||||
);
|
||||
if (normalized) {
|
||||
externalServers[name] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
const externalCount = Object.keys(externalServers).length;
|
||||
if (externalCount > 0) {
|
||||
try {
|
||||
this.context.config.addMcpServers(externalServers);
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${externalCount} external MCP servers to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to add external MCP servers:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.agents && Array.isArray(payload.agents)) {
|
||||
try {
|
||||
this.context.config.setSessionSubagents(payload.agents);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Added ${payload.agents.length} session subagents to config`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to add session subagents:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,36 +219,98 @@ export class SystemController extends BaseController {
|
||||
buildControlCapabilities(): Record<string, unknown> {
|
||||
const capabilities: Record<string, unknown> = {
|
||||
can_handle_can_use_tool: true,
|
||||
can_handle_hook_callback: true,
|
||||
can_handle_hook_callback: false,
|
||||
can_set_permission_mode:
|
||||
typeof this.context.config.setApprovalMode === 'function',
|
||||
can_set_model: typeof this.context.config.setModel === 'function',
|
||||
// SDK MCP servers are supported - messages routed through control plane
|
||||
can_handle_mcp_message: true,
|
||||
};
|
||||
|
||||
// Check if MCP message handling is available
|
||||
try {
|
||||
const mcpProvider = this.context.config as unknown as {
|
||||
getMcpServers?: () => Record<string, unknown> | undefined;
|
||||
};
|
||||
if (typeof mcpProvider.getMcpServers === 'function') {
|
||||
const servers = mcpProvider.getMcpServers();
|
||||
capabilities['can_handle_mcp_message'] = Boolean(
|
||||
servers && Object.keys(servers).length > 0,
|
||||
);
|
||||
} else {
|
||||
capabilities['can_handle_mcp_message'] = false;
|
||||
}
|
||||
} catch (error) {
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
private normalizeMcpServerConfig(
|
||||
serverName: string,
|
||||
config?: CLIMcpServerConfig,
|
||||
): MCPServerConfig | null {
|
||||
if (!config || typeof config !== 'object') {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to determine MCP capability:',
|
||||
error,
|
||||
`[SystemController] Ignoring invalid MCP server config for '${serverName}'`,
|
||||
);
|
||||
}
|
||||
capabilities['can_handle_mcp_message'] = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
const authProvider = this.normalizeAuthProviderType(
|
||||
config.authProviderType,
|
||||
);
|
||||
const oauthConfig = this.normalizeOAuthConfig(config.oauth);
|
||||
|
||||
return new MCPServerConfig(
|
||||
config.command,
|
||||
config.args,
|
||||
config.env,
|
||||
config.cwd,
|
||||
config.url,
|
||||
config.httpUrl,
|
||||
config.headers,
|
||||
config.tcp,
|
||||
config.timeout,
|
||||
config.trust,
|
||||
config.description,
|
||||
config.includeTools,
|
||||
config.excludeTools,
|
||||
config.extensionName,
|
||||
oauthConfig,
|
||||
authProvider,
|
||||
config.targetAudience,
|
||||
config.targetServiceAccount,
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeAuthProviderType(
|
||||
value?: string,
|
||||
): AuthProviderType | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
case AuthProviderType.DYNAMIC_DISCOVERY:
|
||||
case AuthProviderType.GOOGLE_CREDENTIALS:
|
||||
case AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION:
|
||||
return value;
|
||||
default:
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Unsupported authProviderType '${value}', skipping`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeOAuthConfig(
|
||||
oauth?: CLIMcpServerConfig['oauth'],
|
||||
): MCPOAuthConfig | undefined {
|
||||
if (!oauth) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: oauth.enabled,
|
||||
clientId: oauth.clientId,
|
||||
clientSecret: oauth.clientSecret,
|
||||
authorizationUrl: oauth.authorizationUrl,
|
||||
tokenUrl: oauth.tokenUrl,
|
||||
scopes: oauth.scopes,
|
||||
audiences: oauth.audiences,
|
||||
redirectUri: oauth.redirectUri,
|
||||
tokenParamName: oauth.tokenParamName,
|
||||
registrationUrl: oauth.registrationUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,7 +346,12 @@ export class SystemController extends BaseController {
|
||||
*/
|
||||
private async handleSetModel(
|
||||
payload: CLIControlSetModelRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const model = payload.model;
|
||||
|
||||
// Validate model parameter
|
||||
@@ -189,27 +389,63 @@ export class SystemController extends BaseController {
|
||||
/**
|
||||
* Handle supported_commands request
|
||||
*
|
||||
* Returns list of supported control commands
|
||||
*
|
||||
* Note: This list should match the ControlRequestType enum in
|
||||
* packages/sdk/typescript/src/types/controlRequests.ts
|
||||
* Returns list of supported slash commands loaded dynamically
|
||||
*/
|
||||
private async handleSupportedCommands(): Promise<Record<string, unknown>> {
|
||||
const commands = [
|
||||
'initialize',
|
||||
'interrupt',
|
||||
'set_model',
|
||||
'supported_commands',
|
||||
'can_use_tool',
|
||||
'set_permission_mode',
|
||||
'mcp_message',
|
||||
'mcp_server_status',
|
||||
'hook_callback',
|
||||
];
|
||||
private async handleSupportedCommands(
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
}
|
||||
|
||||
const slashCommands = await this.loadSlashCommandNames(signal);
|
||||
|
||||
return {
|
||||
subtype: 'supported_commands',
|
||||
commands,
|
||||
commands: slashCommands,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load slash command names using CommandService
|
||||
*
|
||||
* @param signal - AbortSignal to respect for cancellation
|
||||
* @returns Promise resolving to array of slash command names
|
||||
*/
|
||||
private async loadSlashCommandNames(signal: AbortSignal): Promise<string[]> {
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const service = await CommandService.create(
|
||||
[new BuiltinCommandLoader(this.context.config)],
|
||||
signal,
|
||||
);
|
||||
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const names = new Set<string>();
|
||||
const commands = service.getCommands();
|
||||
for (const command of commands) {
|
||||
names.add(command.name);
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
} catch (error) {
|
||||
// Check if the error is due to abort
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to load slash commands:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,7 @@
|
||||
*/
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
MCPServerConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
import type { PermissionSuggestion } from '../../types.js';
|
||||
|
||||
/**
|
||||
@@ -26,25 +23,6 @@ import type { PermissionSuggestion } from '../../types.js';
|
||||
* permission suggestions, and tool call monitoring callbacks.
|
||||
*/
|
||||
export interface PermissionServiceAPI {
|
||||
/**
|
||||
* Check if a tool should be allowed based on current permission settings
|
||||
*
|
||||
* Evaluates permission mode and tool registry to determine if execution
|
||||
* should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||
*
|
||||
* @param toolRequest - Tool call request information containing name, args, and call ID
|
||||
* @param confirmationDetails - Optional confirmation details for UI-driven approvals
|
||||
* @returns Promise resolving to permission decision with optional updated arguments
|
||||
*/
|
||||
shouldAllowTool(
|
||||
toolRequest: ToolCallRequestInfo,
|
||||
confirmationDetails?: unknown,
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
updatedArgs?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Build UI suggestions for tool confirmation dialogs
|
||||
*
|
||||
|
||||
@@ -13,7 +13,11 @@ import type {
|
||||
ServerGeminiStreamEvent,
|
||||
TaskResultDisplay,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { GeminiEventType, ToolErrorType } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
GeminiEventType,
|
||||
ToolErrorType,
|
||||
parseAndFormatApiError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type {
|
||||
CLIAssistantMessage,
|
||||
@@ -600,6 +604,18 @@ export abstract class BaseJsonOutputAdapter {
|
||||
}
|
||||
this.finalizePendingBlocks(state, null);
|
||||
break;
|
||||
case GeminiEventType.Error: {
|
||||
// Format the error message using parseAndFormatApiError for consistency
|
||||
// with interactive mode error display
|
||||
const errorText = parseAndFormatApiError(
|
||||
event.value.error,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
undefined,
|
||||
this.config.getModel(),
|
||||
);
|
||||
this.appendText(state, errorText, null);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -939,9 +955,25 @@ export abstract class BaseJsonOutputAdapter {
|
||||
this.emitMessageImpl(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if responseParts contain any functionResponse with an error.
|
||||
* This handles cancelled responses and other error cases where the error
|
||||
* is embedded in responseParts rather than the top-level error field.
|
||||
* @param responseParts - Array of Part objects
|
||||
* @returns Error message if found, undefined otherwise
|
||||
*/
|
||||
private checkResponsePartsForError(
|
||||
responseParts: Part[] | undefined,
|
||||
): string | undefined {
|
||||
// Use the shared helper function defined at file level
|
||||
return checkResponsePartsForError(responseParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a tool result message.
|
||||
* Collects execution denied tool calls for inclusion in result messages.
|
||||
* Handles both explicit errors (response.error) and errors embedded in
|
||||
* responseParts (e.g., cancelled responses).
|
||||
* @param request - Tool call request info
|
||||
* @param response - Tool call response info
|
||||
* @param parentToolUseId - Parent tool use ID (null for main agent)
|
||||
@@ -951,6 +983,14 @@ export abstract class BaseJsonOutputAdapter {
|
||||
response: ToolCallResponseInfo,
|
||||
parentToolUseId: string | null = null,
|
||||
): void {
|
||||
// Check for errors in responseParts (e.g., cancelled responses)
|
||||
const responsePartsError = this.checkResponsePartsForError(
|
||||
response.responseParts,
|
||||
);
|
||||
|
||||
// Determine if this is an error response
|
||||
const hasError = Boolean(response.error) || Boolean(responsePartsError);
|
||||
|
||||
// Track permission denials (execution denied errors)
|
||||
if (
|
||||
response.error &&
|
||||
@@ -967,7 +1007,7 @@ export abstract class BaseJsonOutputAdapter {
|
||||
const block: ToolResultBlock = {
|
||||
type: 'tool_result',
|
||||
tool_use_id: request.callId,
|
||||
is_error: Boolean(response.error),
|
||||
is_error: hasError,
|
||||
};
|
||||
const content = toolResultContent(response);
|
||||
if (content !== undefined) {
|
||||
@@ -1173,11 +1213,41 @@ export function partsToString(parts: Part[]): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if responseParts contain any functionResponse with an error.
|
||||
* Helper function for extracting error messages from responseParts.
|
||||
* @param responseParts - Array of Part objects
|
||||
* @returns Error message if found, undefined otherwise
|
||||
*/
|
||||
function checkResponsePartsForError(
|
||||
responseParts: Part[] | undefined,
|
||||
): string | undefined {
|
||||
if (!responseParts || responseParts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const part of responseParts) {
|
||||
if (
|
||||
'functionResponse' in part &&
|
||||
part.functionResponse?.response &&
|
||||
typeof part.functionResponse.response === 'object' &&
|
||||
'error' in part.functionResponse.response &&
|
||||
part.functionResponse.response['error']
|
||||
) {
|
||||
const error = part.functionResponse.response['error'];
|
||||
return typeof error === 'string' ? error : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts content from tool response.
|
||||
* Uses functionResponsePartsToString to properly handle functionResponse parts,
|
||||
* which correctly extracts output content from functionResponse objects rather
|
||||
* than simply concatenating text or JSON.stringify.
|
||||
* Also handles errors embedded in responseParts (e.g., cancelled responses).
|
||||
*
|
||||
* @param response - Tool call response
|
||||
* @returns String content or undefined
|
||||
@@ -1188,6 +1258,11 @@ export function toolResultContent(
|
||||
if (response.error) {
|
||||
return response.error.message;
|
||||
}
|
||||
// Check for errors in responseParts (e.g., cancelled responses)
|
||||
const responsePartsError = checkResponsePartsForError(response.responseParts);
|
||||
if (responsePartsError) {
|
||||
return responsePartsError;
|
||||
}
|
||||
if (
|
||||
typeof response.resultDisplay === 'string' &&
|
||||
response.resultDisplay.trim().length > 0
|
||||
|
||||
@@ -69,6 +69,7 @@ function createConfig(overrides: ConfigOverrides = {}): Config {
|
||||
getDebugMode: () => false,
|
||||
getApprovalMode: () => 'auto',
|
||||
getOutputFormat: () => 'stream-json',
|
||||
initialize: vi.fn(),
|
||||
};
|
||||
return { ...base, ...overrides } as unknown as Config;
|
||||
}
|
||||
@@ -152,6 +153,11 @@ describe('runNonInteractiveStreamJson', () => {
|
||||
handleControlResponse: ReturnType<typeof vi.fn>;
|
||||
handleCancel: ReturnType<typeof vi.fn>;
|
||||
shutdown: ReturnType<typeof vi.fn>;
|
||||
getPendingIncomingRequestCount: ReturnType<typeof vi.fn>;
|
||||
waitForPendingIncomingRequests: ReturnType<typeof vi.fn>;
|
||||
sdkMcpController: {
|
||||
createSendSdkMcpMessage: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
let mockConsolePatcher: {
|
||||
patch: ReturnType<typeof vi.fn>;
|
||||
@@ -186,6 +192,11 @@ describe('runNonInteractiveStreamJson', () => {
|
||||
handleControlResponse: vi.fn(),
|
||||
handleCancel: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
getPendingIncomingRequestCount: vi.fn().mockReturnValue(0),
|
||||
waitForPendingIncomingRequests: vi.fn().mockResolvedValue(undefined),
|
||||
sdkMcpController: {
|
||||
createSendSdkMcpMessage: vi.fn().mockReturnValue(vi.fn()),
|
||||
},
|
||||
};
|
||||
(
|
||||
ControlDispatcher as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { SubagentConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Annotation for attaching metadata to content blocks
|
||||
@@ -137,9 +138,8 @@ export interface CLISystemMessage {
|
||||
status: string;
|
||||
}>;
|
||||
model?: string;
|
||||
permissionMode?: string;
|
||||
permission_mode?: string;
|
||||
slash_commands?: string[];
|
||||
apiKeySource?: string;
|
||||
qwen_code_version?: string;
|
||||
output_style?: string;
|
||||
agents?: string[];
|
||||
@@ -295,10 +295,69 @@ export interface CLIControlPermissionRequest {
|
||||
blocked_path: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire format for SDK MCP server config in initialization request.
|
||||
* The actual Server instance stays in the SDK process.
|
||||
*/
|
||||
export interface SDKMcpServerConfig {
|
||||
type: 'sdk';
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire format for external MCP server config in initialization request.
|
||||
* Represents stdio/SSE/HTTP/TCP transports that must run in the CLI process.
|
||||
*/
|
||||
export interface CLIMcpServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
httpUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
tcp?: string;
|
||||
timeout?: number;
|
||||
trust?: boolean;
|
||||
description?: string;
|
||||
includeTools?: string[];
|
||||
excludeTools?: string[];
|
||||
extensionName?: string;
|
||||
oauth?: {
|
||||
enabled?: boolean;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
scopes?: string[];
|
||||
audiences?: string[];
|
||||
redirectUri?: string;
|
||||
tokenParamName?: string;
|
||||
registrationUrl?: string;
|
||||
};
|
||||
authProviderType?:
|
||||
| 'dynamic_discovery'
|
||||
| 'google_credentials'
|
||||
| 'service_account_impersonation';
|
||||
targetAudience?: string;
|
||||
targetServiceAccount?: string;
|
||||
}
|
||||
|
||||
export interface CLIControlInitializeRequest {
|
||||
subtype: 'initialize';
|
||||
hooks?: HookRegistration[] | null;
|
||||
sdkMcpServers?: string[];
|
||||
/**
|
||||
* SDK MCP servers config
|
||||
* These are MCP servers running in the SDK process, connected via control plane.
|
||||
* External MCP servers are configured separately in settings, not via initialization.
|
||||
*/
|
||||
sdkMcpServers?: Record<string, Omit<SDKMcpServerConfig, 'instance'>>;
|
||||
/**
|
||||
* External MCP servers that the SDK wants the CLI to manage.
|
||||
* These run outside the SDK process and require CLI-side transport setup.
|
||||
*/
|
||||
mcpServers?: Record<string, CLIMcpServerConfig>;
|
||||
agents?: SubagentConfig[];
|
||||
}
|
||||
|
||||
export interface CLIControlSetPermissionModeRequest {
|
||||
|
||||
@@ -245,6 +245,7 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Hello');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(' World');
|
||||
@@ -293,11 +294,21 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
);
|
||||
// Verify first call has isContinuation: false
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[{ text: 'Use a tool' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
// Verify second call (after tool execution) has isContinuation: true
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
{ isContinuation: true },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
|
||||
@@ -372,6 +383,7 @@ describe('runNonInteractive', () => {
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
{ isContinuation: true },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
|
||||
});
|
||||
@@ -497,6 +509,7 @@ describe('runNonInteractive', () => {
|
||||
processedParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
// 6. Assert the final output is correct
|
||||
@@ -528,6 +541,7 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
// JSON adapter emits array of messages, last one is result with stats
|
||||
@@ -680,6 +694,7 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Empty response test' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
// JSON adapter emits array of messages, last one is result with stats
|
||||
@@ -831,6 +846,7 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
||||
@@ -887,6 +903,7 @@ describe('runNonInteractive', () => {
|
||||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
||||
@@ -1217,6 +1234,7 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Message from stream-json input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-envelope',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1692,6 +1710,7 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Simple string content' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-string-content',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
|
||||
// UserMessage with array of text blocks
|
||||
@@ -1724,6 +1743,7 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'First part' }, { text: 'Second part' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-blocks-content',
|
||||
{ isContinuation: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
FatalInputError,
|
||||
promptIdContext,
|
||||
OutputFormat,
|
||||
InputFormat,
|
||||
uiTelemetryService,
|
||||
parseAndFormatApiError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
||||
@@ -170,6 +172,7 @@ export async function runNonInteractive(
|
||||
adapter.emitMessage(systemMessage);
|
||||
}
|
||||
|
||||
let isFirstTurn = true;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
@@ -185,7 +188,9 @@ export async function runNonInteractive(
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
{ isContinuation: !isFirstTurn },
|
||||
);
|
||||
isFirstTurn = false;
|
||||
|
||||
// Start assistant message for this turn
|
||||
if (adapter) {
|
||||
@@ -205,10 +210,21 @@ export async function runNonInteractive(
|
||||
}
|
||||
} else {
|
||||
// Text output mode - direct stdout
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
if (event.type === GeminiEventType.Thought) {
|
||||
process.stdout.write(event.value.description);
|
||||
} else if (event.type === GeminiEventType.Content) {
|
||||
process.stdout.write(event.value);
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCallRequests.push(event.value);
|
||||
} else if (event.type === GeminiEventType.Error) {
|
||||
// Format and output the error message for text mode
|
||||
const errorText = parseAndFormatApiError(
|
||||
event.value.error,
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
undefined,
|
||||
config.getModel(),
|
||||
);
|
||||
process.stderr.write(`${errorText}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,40 +241,14 @@ export async function runNonInteractive(
|
||||
for (const requestInfo of toolCallRequests) {
|
||||
const finalRequestInfo = requestInfo;
|
||||
|
||||
/*
|
||||
if (options.controlService) {
|
||||
const permissionResult =
|
||||
await options.controlService.permission.shouldAllowTool(
|
||||
requestInfo,
|
||||
);
|
||||
if (!permissionResult.allowed) {
|
||||
if (config.getDebugMode()) {
|
||||
console.error(
|
||||
`[runNonInteractive] Tool execution denied: ${requestInfo.name}`,
|
||||
permissionResult.message ?? '',
|
||||
);
|
||||
}
|
||||
if (adapter && permissionResult.message) {
|
||||
adapter.emitSystemMessage('tool_denied', {
|
||||
tool: requestInfo.name,
|
||||
message: permissionResult.message,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (permissionResult.updatedArgs) {
|
||||
finalRequestInfo = {
|
||||
...requestInfo,
|
||||
args: permissionResult.updatedArgs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const toolCallUpdateCallback = options.controlService
|
||||
? options.controlService.permission.getToolCallUpdateCallback()
|
||||
: undefined;
|
||||
*/
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
const toolCallUpdateCallback =
|
||||
inputFormat === InputFormat.STREAM_JSON && options.controlService
|
||||
? options.controlService.permission.getToolCallUpdateCallback()
|
||||
: undefined;
|
||||
|
||||
// Only pass outputUpdateHandler for Task tool
|
||||
const isTaskTool = finalRequestInfo.name === 'task';
|
||||
@@ -277,13 +267,13 @@ export async function runNonInteractive(
|
||||
isTaskTool && taskToolProgressHandler
|
||||
? {
|
||||
outputUpdateHandler: taskToolProgressHandler,
|
||||
/*
|
||||
toolCallUpdateCallback
|
||||
? { onToolCallsUpdate: toolCallUpdateCallback }
|
||||
: undefined,
|
||||
*/
|
||||
onToolCallsUpdate: toolCallUpdateCallback,
|
||||
}
|
||||
: undefined,
|
||||
: toolCallUpdateCallback
|
||||
? {
|
||||
onToolCallsUpdate: toolCallUpdateCallback,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||
@@ -303,9 +293,6 @@ export async function runNonInteractive(
|
||||
? toolResponse.resultDisplay
|
||||
: undefined,
|
||||
);
|
||||
// Note: We no longer emit a separate system message for tool errors
|
||||
// in JSON/STREAM_JSON mode, as the error is already captured in the
|
||||
// tool_result block with is_error=true.
|
||||
}
|
||||
|
||||
if (adapter) {
|
||||
|
||||
@@ -58,7 +58,6 @@ vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
|
||||
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
|
||||
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
|
||||
vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} }));
|
||||
vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} }));
|
||||
vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} }));
|
||||
vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} }));
|
||||
vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
||||
@@ -71,7 +70,6 @@ vi.mock('../ui/commands/modelCommand.js', () => ({
|
||||
}));
|
||||
vi.mock('../ui/commands/quitCommand.js', () => ({
|
||||
quitCommand: {},
|
||||
quitConfirmCommand: {},
|
||||
}));
|
||||
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
|
||||
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));
|
||||
|
||||
@@ -15,7 +15,6 @@ import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { compressCommand } from '../ui/commands/compressCommand.js';
|
||||
import { copyCommand } from '../ui/commands/copyCommand.js';
|
||||
import { corgiCommand } from '../ui/commands/corgiCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
@@ -28,7 +27,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { quitCommand, quitConfirmCommand } from '../ui/commands/quitCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
@@ -63,7 +62,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
clearCommand,
|
||||
compressCommand,
|
||||
copyCommand,
|
||||
corgiCommand,
|
||||
docsCommand,
|
||||
directoryCommand,
|
||||
editorCommand,
|
||||
@@ -77,7 +75,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
modelCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
quitCommand,
|
||||
quitConfirmCommand,
|
||||
restoreCommand(this.config),
|
||||
statsCommand,
|
||||
summaryCommand,
|
||||
|
||||
@@ -56,10 +56,10 @@ export const createMockCommandContext = (
|
||||
pendingItem: null,
|
||||
setPendingItem: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
toggleVimEnabled: vi.fn(),
|
||||
extensionsUpdateState: new Map(),
|
||||
setExtensionsUpdateState: vi.fn(),
|
||||
reloadCommands: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
session: {
|
||||
|
||||
@@ -89,7 +89,6 @@ import { useSessionStats } from './contexts/SessionContext.js';
|
||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
|
||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
||||
import { useDialogClose } from './hooks/useDialogClose.js';
|
||||
@@ -137,7 +136,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const { settings, config, initializationResult } = props;
|
||||
const historyManager = useHistory();
|
||||
useMemoryMonitor(historyManager);
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
const [quittingMessages, setQuittingMessages] = useState<
|
||||
HistoryItem[] | null
|
||||
@@ -446,8 +444,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const { toggleVimEnabled } = useVimMode();
|
||||
|
||||
const { showQuitConfirmation } = useQuitConfirmation();
|
||||
|
||||
const {
|
||||
isSubagentCreateDialogOpen,
|
||||
openSubagentCreateDialog,
|
||||
@@ -488,12 +484,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
}, 100);
|
||||
},
|
||||
setDebugMessage,
|
||||
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
_showQuitConfirmation: showQuitConfirmation,
|
||||
}),
|
||||
[
|
||||
openAuthDialog,
|
||||
@@ -502,12 +496,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openSettingsDialog,
|
||||
openModelDialog,
|
||||
setDebugMessage,
|
||||
setCorgiMode,
|
||||
dispatchExtensionStateUpdate,
|
||||
openPermissionsDialog,
|
||||
openApprovalModeDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
showQuitConfirmation,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
],
|
||||
@@ -520,7 +512,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
quitConfirmationRequest,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
@@ -951,6 +942,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isFocused,
|
||||
streamingState,
|
||||
elapsedTime,
|
||||
settings,
|
||||
});
|
||||
|
||||
// Dialog close functionality
|
||||
@@ -969,7 +961,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isFolderTrustDialogOpen,
|
||||
showWelcomeBackDialog,
|
||||
handleWelcomeBackClose,
|
||||
quitConfirmationRequest,
|
||||
});
|
||||
|
||||
const handleExit = useCallback(
|
||||
@@ -983,25 +974,18 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
// Exit directly without showing confirmation dialog
|
||||
// Exit directly
|
||||
handleSlashCommand('/quit');
|
||||
return;
|
||||
}
|
||||
|
||||
// First press: Prioritize cleanup tasks
|
||||
|
||||
// Special case: If quit-confirm dialog is open, Ctrl+C means "quit immediately"
|
||||
if (quitConfirmationRequest) {
|
||||
handleSlashCommand('/quit');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Close other dialogs (highest priority)
|
||||
/**
|
||||
* For AuthDialog it is required to complete the authentication process,
|
||||
* otherwise user cannot proceed to the next step.
|
||||
* So a quit on AuthDialog should go with normal two press quit
|
||||
* and without quit-confirm dialog.
|
||||
* So a quit on AuthDialog should go with normal two press quit.
|
||||
*/
|
||||
if (isAuthDialogOpen) {
|
||||
setPressedOnce(true);
|
||||
@@ -1022,14 +1006,17 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
return; // Request cancelled, end processing
|
||||
}
|
||||
|
||||
// 3. Clear input buffer (if has content)
|
||||
// 4. Clear input buffer (if has content)
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
return; // Input cleared, end processing
|
||||
}
|
||||
|
||||
// All cleanup tasks completed, show quit confirmation dialog
|
||||
handleSlashCommand('/quit-confirm');
|
||||
// All cleanup tasks completed, set flag for double-press to quit
|
||||
setPressedOnce(true);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setPressedOnce(false);
|
||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||
},
|
||||
[
|
||||
isAuthDialogOpen,
|
||||
@@ -1037,7 +1024,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
closeAnyOpenDialog,
|
||||
streamingState,
|
||||
cancelOngoingRequest,
|
||||
quitConfirmationRequest,
|
||||
buffer,
|
||||
],
|
||||
);
|
||||
@@ -1054,8 +1040,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// On first press: set flag, start timer, and call handleExit for cleanup/quit-confirm
|
||||
// On second press (within 500ms): handleExit sees flag and does fast quit
|
||||
// On first press: set flag, start timer, and call handleExit for cleanup
|
||||
// On second press (within timeout): handleExit sees flag and does fast quit
|
||||
if (!ctrlCPressedOnce) {
|
||||
setCtrlCPressedOnce(true);
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
@@ -1196,7 +1182,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
!!confirmationRequest ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
!!loopDetectionConfirmationRequest ||
|
||||
!!quitConfirmationRequest ||
|
||||
isThemeDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
isModelDialogOpen ||
|
||||
@@ -1231,7 +1216,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
qwenAuthState,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
corgiMode,
|
||||
debugMessage,
|
||||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
@@ -1245,7 +1229,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
quitConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
streamingState,
|
||||
initError,
|
||||
@@ -1323,7 +1306,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
qwenAuthState,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
corgiMode,
|
||||
debugMessage,
|
||||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
@@ -1337,7 +1319,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
confirmationRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
quitConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
streamingState,
|
||||
initError,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { corgiCommand } from './corgiCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('corgiCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
vi.spyOn(mockContext.ui, 'toggleCorgiMode');
|
||||
});
|
||||
|
||||
it('should call the toggleCorgiMode function on the UI context', async () => {
|
||||
if (!corgiCommand.action) {
|
||||
throw new Error('The corgi command must have an action.');
|
||||
}
|
||||
|
||||
await corgiCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(corgiCommand.name).toBe('corgi');
|
||||
expect(corgiCommand.description).toBe('Toggles corgi mode.');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const corgiCommand: SlashCommand = {
|
||||
name: 'corgi',
|
||||
description: 'Toggles corgi mode.',
|
||||
hidden: true,
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context, _args) => {
|
||||
context.ui.toggleCorgiMode();
|
||||
},
|
||||
};
|
||||
600
packages/cli/src/ui/commands/languageCommand.test.ts
Normal file
600
packages/cli/src/ui/commands/languageCommand.test.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import { type CommandContext, CommandKind } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../i18n/index.js', () => ({
|
||||
setLanguageAsync: vi.fn().mockResolvedValue(undefined),
|
||||
getCurrentLanguage: vi.fn().mockReturnValue('en'),
|
||||
t: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
// Mock settings module to avoid Storage side effect
|
||||
vi.mock('../../config/settings.js', () => ({
|
||||
SettingScope: {
|
||||
User: 'user',
|
||||
Workspace: 'workspace',
|
||||
Default: 'default',
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fs module
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
default: {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Storage from core
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
Storage: {
|
||||
getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'),
|
||||
getGlobalSettingsPath: vi
|
||||
.fn()
|
||||
.mockReturnValue('/mock/.qwen/settings.json'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Import modules after mocking
|
||||
import * as i18n from '../../i18n/index.js';
|
||||
import { languageCommand } from './languageCommand.js';
|
||||
|
||||
describe('languageCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
},
|
||||
settings: {
|
||||
merged: {},
|
||||
setValue: vi.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Reset i18n mocks
|
||||
vi.mocked(i18n.getCurrentLanguage).mockReturnValue('en');
|
||||
vi.mocked(i18n.t).mockImplementation((key: string) => key);
|
||||
|
||||
// Reset fs mocks
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('command metadata', () => {
|
||||
it('should have the correct name', () => {
|
||||
expect(languageCommand.name).toBe('language');
|
||||
});
|
||||
|
||||
it('should have a description', () => {
|
||||
expect(languageCommand.description).toBeDefined();
|
||||
expect(typeof languageCommand.description).toBe('string');
|
||||
});
|
||||
|
||||
it('should be a built-in command', () => {
|
||||
expect(languageCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should have subcommands', () => {
|
||||
expect(languageCommand.subCommands).toBeDefined();
|
||||
expect(languageCommand.subCommands?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should have ui and output subcommands', () => {
|
||||
const subCommandNames = languageCommand.subCommands?.map((c) => c.name);
|
||||
expect(subCommandNames).toContain('ui');
|
||||
expect(subCommandNames).toContain('output');
|
||||
});
|
||||
});
|
||||
|
||||
describe('main command action - no arguments', () => {
|
||||
it('should show current language settings when no arguments provided', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Current UI language:'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show available subcommands in help', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('/language ui'),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('/language output'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show LLM output language when set', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY',
|
||||
);
|
||||
|
||||
// Make t() function handle interpolation for this test
|
||||
vi.mocked(i18n.t).mockImplementation(
|
||||
(key: string, params?: Record<string, string>) => {
|
||||
if (params && key.includes('{{lang}}')) {
|
||||
return key.replace('{{lang}}', params['lang'] || '');
|
||||
}
|
||||
return key;
|
||||
},
|
||||
);
|
||||
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Current UI language:'),
|
||||
});
|
||||
// Verify it correctly parses "Chinese" from the template format
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Chinese'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('main command action - config not available', () => {
|
||||
it('should return error when config is null', async () => {
|
||||
mockContext.services.config = null;
|
||||
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Configuration not available'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/language ui subcommand', () => {
|
||||
it('should show help when no language argument provided', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Usage: /language ui'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set English with "en"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui en');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalled();
|
||||
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set English with "en-US"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui en-US');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set English with "english"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui english');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set Chinese with "zh"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui zh');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set Chinese with "zh-CN"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui zh-CN');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set Chinese with "chinese"', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui chinese');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error for invalid language', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'ui invalid');
|
||||
|
||||
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Invalid language'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist setting to user scope', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
await languageCommand.action(mockContext, 'ui en');
|
||||
|
||||
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
|
||||
expect.anything(), // SettingScope.User
|
||||
'general.language',
|
||||
'en',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/language output subcommand', () => {
|
||||
it('should show help when no language argument provided', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'output');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Usage: /language output'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create LLM output language rule file', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(
|
||||
mockContext,
|
||||
'output Chinese',
|
||||
);
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('output-language.md'),
|
||||
expect.stringContaining('Chinese'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining(
|
||||
'LLM output language rule file generated',
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('should include restart notice in success message', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(
|
||||
mockContext,
|
||||
'output Japanese',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('restart'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle file write errors gracefully', async () => {
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'output German');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Failed to generate'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility - direct language arguments', () => {
|
||||
it('should set Chinese with direct "zh" argument', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'zh');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set English with direct "en" argument', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'en');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error for unknown direct argument', async () => {
|
||||
if (!languageCommand.action) {
|
||||
throw new Error('The language command must have an action.');
|
||||
}
|
||||
|
||||
const result = await languageCommand.action(mockContext, 'unknown');
|
||||
|
||||
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('Invalid command'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui subcommand object', () => {
|
||||
const uiSubcommand = languageCommand.subCommands?.find(
|
||||
(c) => c.name === 'ui',
|
||||
);
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(uiSubcommand).toBeDefined();
|
||||
expect(uiSubcommand?.name).toBe('ui');
|
||||
expect(uiSubcommand?.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should have nested language subcommands', () => {
|
||||
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
|
||||
expect(nestedNames).toContain('zh-CN');
|
||||
expect(nestedNames).toContain('en-US');
|
||||
});
|
||||
|
||||
it('should have action that sets language', async () => {
|
||||
if (!uiSubcommand?.action) {
|
||||
throw new Error('UI subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = await uiSubcommand.action(mockContext, 'en');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('output subcommand object', () => {
|
||||
const outputSubcommand = languageCommand.subCommands?.find(
|
||||
(c) => c.name === 'output',
|
||||
);
|
||||
|
||||
it('should have correct metadata', () => {
|
||||
expect(outputSubcommand).toBeDefined();
|
||||
expect(outputSubcommand?.name).toBe('output');
|
||||
expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should have action that generates rule file', async () => {
|
||||
if (!outputSubcommand?.action) {
|
||||
throw new Error('Output subcommand must have an action.');
|
||||
}
|
||||
|
||||
// Ensure mocks are properly set for this test
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
|
||||
const result = await outputSubcommand.action(mockContext, 'French');
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining(
|
||||
'LLM output language rule file generated',
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested ui language subcommands', () => {
|
||||
const uiSubcommand = languageCommand.subCommands?.find(
|
||||
(c) => c.name === 'ui',
|
||||
);
|
||||
const zhCNSubcommand = uiSubcommand?.subCommands?.find(
|
||||
(c) => c.name === 'zh-CN',
|
||||
);
|
||||
const enUSSubcommand = uiSubcommand?.subCommands?.find(
|
||||
(c) => c.name === 'en-US',
|
||||
);
|
||||
|
||||
it('zh-CN should have aliases', () => {
|
||||
expect(zhCNSubcommand?.altNames).toContain('zh');
|
||||
expect(zhCNSubcommand?.altNames).toContain('chinese');
|
||||
});
|
||||
|
||||
it('en-US should have aliases', () => {
|
||||
expect(enUSSubcommand?.altNames).toContain('en');
|
||||
expect(enUSSubcommand?.altNames).toContain('english');
|
||||
});
|
||||
|
||||
it('zh-CN action should set Chinese', async () => {
|
||||
if (!zhCNSubcommand?.action) {
|
||||
throw new Error('zh-CN subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = await zhCNSubcommand.action(mockContext, '');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('en-US action should set English', async () => {
|
||||
if (!enUSSubcommand?.action) {
|
||||
throw new Error('en-US subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = await enUSSubcommand.action(mockContext, '');
|
||||
|
||||
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('UI language changed'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject extra arguments', async () => {
|
||||
if (!zhCNSubcommand?.action) {
|
||||
throw new Error('zh-CN subcommand must have an action.');
|
||||
}
|
||||
|
||||
const result = await zhCNSubcommand.action(mockContext, 'extra args');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('do not accept additional arguments'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -81,8 +81,9 @@ function getCurrentLlmOutputLanguage(): string | null {
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
// Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese")
|
||||
const match = content.match(/^#\s+(.+?)\s+Response Rules/i);
|
||||
// Extract language name from the first line
|
||||
// Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
|
||||
const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
@@ -127,16 +128,17 @@ async function setUiLanguage(
|
||||
context.ui.reloadCommands();
|
||||
|
||||
// Map language codes to friendly display names
|
||||
const langDisplayNames: Record<SupportedLanguage, string> = {
|
||||
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
|
||||
zh: '中文(zh-CN)',
|
||||
en: 'English(en-US)',
|
||||
ru: 'Русский (ru-RU)',
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('UI language changed to {{lang}}', {
|
||||
lang: langDisplayNames[lang],
|
||||
lang: langDisplayNames[lang] || lang,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -216,7 +218,7 @@ export const languageCommand: SlashCommand = {
|
||||
: t('LLM output language not set'),
|
||||
'',
|
||||
t('Available subcommands:'),
|
||||
` /language ui [zh-CN|en-US] - ${t('Set UI language')}`,
|
||||
` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`,
|
||||
` /language output <language> - ${t('Set LLM output language')}`,
|
||||
].join('\n');
|
||||
|
||||
@@ -232,7 +234,7 @@ export const languageCommand: SlashCommand = {
|
||||
const subcommand = parts[0].toLowerCase();
|
||||
|
||||
if (subcommand === 'ui') {
|
||||
// Handle /language ui [zh-CN|en-US]
|
||||
// Handle /language ui [zh-CN|en-US|ru-RU]
|
||||
if (parts.length === 1) {
|
||||
// Show UI language subcommand help
|
||||
return {
|
||||
@@ -241,11 +243,12 @@ export const languageCommand: SlashCommand = {
|
||||
content: [
|
||||
t('Set UI language'),
|
||||
'',
|
||||
t('Usage: /language ui [zh-CN|en-US]'),
|
||||
t('Usage: /language ui [zh-CN|en-US|ru-RU]'),
|
||||
'',
|
||||
t('Available options:'),
|
||||
t(' - zh-CN: Simplified Chinese'),
|
||||
t(' - en-US: English'),
|
||||
t(' - ru-RU: Russian'),
|
||||
'',
|
||||
t(
|
||||
'To request additional UI language packs, please open an issue on GitHub.',
|
||||
@@ -266,11 +269,18 @@ export const languageCommand: SlashCommand = {
|
||||
langArg === 'zh-cn'
|
||||
) {
|
||||
targetLang = 'zh';
|
||||
} else if (
|
||||
langArg === 'ru' ||
|
||||
langArg === 'ru-RU' ||
|
||||
langArg === 'russian' ||
|
||||
langArg === 'русский'
|
||||
) {
|
||||
targetLang = 'ru';
|
||||
} else {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Invalid language. Available: en-US, zh-CN'),
|
||||
content: t('Invalid language. Available: en-US, zh-CN, ru-RU'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,13 +317,20 @@ export const languageCommand: SlashCommand = {
|
||||
langArg === 'zh-cn'
|
||||
) {
|
||||
targetLang = 'zh';
|
||||
} else if (
|
||||
langArg === 'ru' ||
|
||||
langArg === 'ru-RU' ||
|
||||
langArg === 'russian' ||
|
||||
langArg === 'русский'
|
||||
) {
|
||||
targetLang = 'ru';
|
||||
} else {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: [
|
||||
t('Invalid command. Available subcommands:'),
|
||||
' - /language ui [zh-CN|en-US] - ' + t('Set UI language'),
|
||||
' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'),
|
||||
' - /language output <language> - ' + t('Set LLM output language'),
|
||||
].join('\n'),
|
||||
};
|
||||
@@ -423,6 +440,29 @@ export const languageCommand: SlashCommand = {
|
||||
return setUiLanguage(context, 'en');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ru-RU',
|
||||
altNames: ['ru', 'russian', 'русский'],
|
||||
get description() {
|
||||
return t('Set UI language to Russian (ru-RU)');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
if (args.trim().length > 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Language subcommands do not accept additional arguments.',
|
||||
),
|
||||
};
|
||||
}
|
||||
return setUiLanguage(context, 'ru');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,35 +8,6 @@ import { formatDuration } from '../utils/formatters.js';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const quitConfirmCommand: SlashCommand = {
|
||||
name: 'quit-confirm',
|
||||
get description() {
|
||||
return t('Show quit confirmation dialog');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context) => {
|
||||
const now = Date.now();
|
||||
const { sessionStartTime } = context.session.stats;
|
||||
const wallDuration = now - sessionStartTime.getTime();
|
||||
|
||||
return {
|
||||
type: 'quit_confirmation',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
text: `/quit-confirm`,
|
||||
id: now - 1,
|
||||
},
|
||||
{
|
||||
type: 'quit_confirmation',
|
||||
duration: formatDuration(wallDuration),
|
||||
id: now,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const quitCommand: SlashCommand = {
|
||||
name: 'quit',
|
||||
altNames: ['exit'],
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('setupGithubCommand', async () => {
|
||||
|
||||
const expectedSubstrings = [
|
||||
`set -eEuo pipefail`,
|
||||
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
|
||||
`fakeOpenCommand "https://github.com/QwenLM/qwen-code-action`,
|
||||
];
|
||||
|
||||
for (const substring of expectedSubstrings) {
|
||||
@@ -112,7 +112,7 @@ describe('setupGithubCommand', async () => {
|
||||
|
||||
if (gitignoreExists) {
|
||||
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
|
||||
expect(gitignoreContent).toContain('.gemini/');
|
||||
expect(gitignoreContent).toContain('.qwen/');
|
||||
expect(gitignoreContent).toContain('gha-creds-*.json');
|
||||
}
|
||||
});
|
||||
@@ -135,7 +135,7 @@ describe('updateGitignore', () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
expect(content).toBe('.gemini/\ngha-creds-*.json\n');
|
||||
expect(content).toBe('.qwen/\ngha-creds-*.json\n');
|
||||
});
|
||||
|
||||
it('appends entries to existing .gitignore file', async () => {
|
||||
@@ -148,13 +148,13 @@ describe('updateGitignore', () => {
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
expect(content).toBe(
|
||||
'# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n',
|
||||
'# Existing content\nnode_modules/\n\n.qwen/\ngha-creds-*.json\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add duplicate entries', async () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n';
|
||||
const existingContent = '.qwen/\nsome-other-file\ngha-creds-*.json\n';
|
||||
await fs.writeFile(gitignorePath, existingContent);
|
||||
|
||||
await updateGitignore(scratchDir);
|
||||
@@ -166,7 +166,7 @@ describe('updateGitignore', () => {
|
||||
|
||||
it('adds only missing entries when some already exist', async () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const existingContent = '.gemini/\nsome-other-file\n';
|
||||
const existingContent = '.qwen/\nsome-other-file\n';
|
||||
await fs.writeFile(gitignorePath, existingContent);
|
||||
|
||||
await updateGitignore(scratchDir);
|
||||
@@ -174,17 +174,17 @@ describe('updateGitignore', () => {
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
// Should add only the missing gha-creds-*.json entry
|
||||
expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n');
|
||||
expect(content).toBe('.qwen/\nsome-other-file\n\ngha-creds-*.json\n');
|
||||
expect(content).toContain('gha-creds-*.json');
|
||||
// Should not duplicate .gemini/ entry
|
||||
expect((content.match(/\.gemini\//g) || []).length).toBe(1);
|
||||
// Should not duplicate .qwen/ entry
|
||||
expect((content.match(/\.qwen\//g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not get confused by entries in comments or as substrings', async () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const existingContent = [
|
||||
'# This is a comment mentioning .gemini/ folder',
|
||||
'my-app.gemini/config',
|
||||
'# This is a comment mentioning .qwen/ folder',
|
||||
'my-app.qwen/config',
|
||||
'# Another comment with gha-creds-*.json pattern',
|
||||
'some-other-gha-creds-file.json',
|
||||
'',
|
||||
@@ -196,7 +196,7 @@ describe('updateGitignore', () => {
|
||||
const content = await fs.readFile(gitignorePath, 'utf8');
|
||||
|
||||
// Should add both entries since they don't actually exist as gitignore rules
|
||||
expect(content).toContain('.gemini/');
|
||||
expect(content).toContain('.qwen/');
|
||||
expect(content).toContain('gha-creds-*.json');
|
||||
|
||||
// Verify the entries were added (not just mentioned in comments)
|
||||
@@ -204,9 +204,9 @@ describe('updateGitignore', () => {
|
||||
.split('\n')
|
||||
.map((line) => line.split('#')[0].trim())
|
||||
.filter((line) => line);
|
||||
expect(lines).toContain('.gemini/');
|
||||
expect(lines).toContain('.qwen/');
|
||||
expect(lines).toContain('gha-creds-*.json');
|
||||
expect(lines).toContain('my-app.gemini/config');
|
||||
expect(lines).toContain('my-app.qwen/config');
|
||||
expect(lines).toContain('some-other-gha-creds-file.json');
|
||||
});
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const GITHUB_WORKFLOW_PATHS = [
|
||||
'gemini-dispatch/gemini-dispatch.yml',
|
||||
'gemini-assistant/gemini-invoke.yml',
|
||||
'issue-triage/gemini-triage.yml',
|
||||
'issue-triage/gemini-scheduled-triage.yml',
|
||||
'pr-review/gemini-review.yml',
|
||||
'qwen-dispatch/qwen-dispatch.yml',
|
||||
'qwen-assistant/qwen-invoke.yml',
|
||||
'issue-triage/qwen-triage.yml',
|
||||
'issue-triage/qwen-scheduled-triage.yml',
|
||||
'pr-review/qwen-review.yml',
|
||||
];
|
||||
|
||||
// Generate OS-specific commands to open the GitHub pages needed for setup.
|
||||
@@ -50,9 +50,9 @@ function getOpenUrlsCommands(readmeUrl: string): string[] {
|
||||
return commands;
|
||||
}
|
||||
|
||||
// Add Gemini CLI specific entries to .gitignore file
|
||||
// Add Qwen Code specific entries to .gitignore file
|
||||
export async function updateGitignore(gitRepoRoot: string): Promise<void> {
|
||||
const gitignoreEntries = ['.gemini/', 'gha-creds-*.json'];
|
||||
const gitignoreEntries = ['.qwen/', 'gha-creds-*.json'];
|
||||
|
||||
const gitignorePath = path.join(gitRepoRoot, '.gitignore');
|
||||
try {
|
||||
@@ -121,7 +121,7 @@ export const setupGithubCommand: SlashCommand = {
|
||||
// Get the latest release tag from GitHub
|
||||
const proxy = context?.services?.config?.getProxy();
|
||||
const releaseTag = await getLatestGitHubRelease(proxy);
|
||||
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
|
||||
const readmeUrl = `https://github.com/QwenLM/qwen-code-action/blob/${releaseTag}/README.md#quick-start`;
|
||||
|
||||
// Create the .github/workflows directory to download the files into
|
||||
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
|
||||
@@ -143,7 +143,7 @@ export const setupGithubCommand: SlashCommand = {
|
||||
for (const workflow of GITHUB_WORKFLOW_PATHS) {
|
||||
downloads.push(
|
||||
(async () => {
|
||||
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
|
||||
const endpoint = `https://raw.githubusercontent.com/QwenLM/qwen-code-action/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
@@ -204,8 +204,9 @@ export const setupGithubCommand: SlashCommand = {
|
||||
toolName: 'run_shell_command',
|
||||
toolArgs: {
|
||||
description:
|
||||
'Setting up GitHub Actions to triage issues and review PRs with Gemini.',
|
||||
'Setting up GitHub Actions to triage issues and review PRs with Qwen.',
|
||||
command,
|
||||
is_background: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -64,8 +64,6 @@ export interface CommandContext {
|
||||
* @param history The array of history items to load.
|
||||
*/
|
||||
loadHistory: UseHistoryManagerReturn['loadHistory'];
|
||||
/** Toggles a special display mode. */
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
setGeminiMdFileCount: (count: number) => void;
|
||||
reloadCommands: () => void;
|
||||
@@ -100,12 +98,6 @@ export interface QuitActionReturn {
|
||||
messages: HistoryItem[];
|
||||
}
|
||||
|
||||
/** The return type for a command action that requests quit confirmation. */
|
||||
export interface QuitConfirmationActionReturn {
|
||||
type: 'quit_confirmation';
|
||||
messages: HistoryItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that results in a simple message
|
||||
* being displayed to the user.
|
||||
@@ -182,7 +174,6 @@ export type SlashCommandActionReturn =
|
||||
| ToolActionReturn
|
||||
| MessageActionReturn
|
||||
| QuitActionReturn
|
||||
| QuitConfirmationActionReturn
|
||||
| OpenDialogActionReturn
|
||||
| LoadHistoryActionReturn
|
||||
| SubmitPromptActionReturn
|
||||
|
||||
@@ -120,7 +120,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
},
|
||||
branchName: 'main',
|
||||
debugMessage: '',
|
||||
corgiMode: false,
|
||||
errorCount: 0,
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
@@ -183,6 +182,7 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
// Smoke check that the Footer renders when enabled.
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
@@ -200,7 +200,6 @@ describe('Composer', () => {
|
||||
it('passes correct props to Footer including vim mode when enabled', async () => {
|
||||
const uiState = createMockUIState({
|
||||
branchName: 'feature-branch',
|
||||
corgiMode: true,
|
||||
errorCount: 2,
|
||||
sessionStats: {
|
||||
sessionId: 'test-session',
|
||||
|
||||
@@ -36,10 +36,6 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
||||
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
|
||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import {
|
||||
QuitConfirmationDialog,
|
||||
QuitChoice,
|
||||
} from './QuitConfirmationDialog.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
@@ -127,24 +123,6 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quitConfirmationRequest) {
|
||||
return (
|
||||
<QuitConfirmationDialog
|
||||
onSelect={(choice: QuitChoice) => {
|
||||
if (choice === QuitChoice.CANCEL) {
|
||||
uiState.quitConfirmationRequest?.onConfirm(false, 'cancel');
|
||||
} else if (choice === QuitChoice.QUIT) {
|
||||
uiState.quitConfirmationRequest?.onConfirm(true, 'quit');
|
||||
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
|
||||
uiState.quitConfirmationRequest?.onConfirm(
|
||||
true,
|
||||
'summary_and_quit',
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.confirmationRequest) {
|
||||
return (
|
||||
<ConsentPrompt
|
||||
|
||||
@@ -33,7 +33,6 @@ export const Footer: React.FC = () => {
|
||||
debugMode,
|
||||
branchName,
|
||||
debugMessage,
|
||||
corgiMode,
|
||||
errorCount,
|
||||
showErrorDetails,
|
||||
promptTokenCount,
|
||||
@@ -45,7 +44,6 @@ export const Footer: React.FC = () => {
|
||||
debugMode: config.getDebugMode(),
|
||||
branchName: uiState.branchName,
|
||||
debugMessage: uiState.debugMessage,
|
||||
corgiMode: uiState.corgiMode,
|
||||
errorCount: uiState.errorCount,
|
||||
showErrorDetails: uiState.showErrorDetails,
|
||||
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
||||
@@ -153,16 +151,6 @@ export const Footer: React.FC = () => {
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
<Box alignItems="center" paddingLeft={2}>
|
||||
{corgiMode && (
|
||||
<Text>
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
<Text color={theme.text.primary}>(´</Text>
|
||||
<Text color={theme.status.error}>ᴥ</Text>
|
||||
<Text color={theme.text.primary}>`)</Text>
|
||||
<Text color={theme.status.error}>▼ </Text>
|
||||
</Text>
|
||||
)}
|
||||
{!showErrorDetails && errorCount > 0 && (
|
||||
<Box>
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./messages/ToolGroupMessage.js', () => ({
|
||||
@@ -22,7 +23,9 @@ vi.mock('./messages/ToolGroupMessage.js', () => ({
|
||||
}));
|
||||
|
||||
describe('<HistoryItemDisplay />', () => {
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const mockConfig = {
|
||||
getChatRecordingService: () => undefined,
|
||||
} as unknown as Config;
|
||||
const baseItem = {
|
||||
id: 1,
|
||||
timestamp: 12345,
|
||||
@@ -133,9 +136,11 @@ describe('<HistoryItemDisplay />', () => {
|
||||
duration: '1s',
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<SessionStatsProvider>
|
||||
<HistoryItemDisplay {...baseItem} item={item} />
|
||||
</SessionStatsProvider>,
|
||||
<ConfigContext.Provider value={mockConfig as never}>
|
||||
<SessionStatsProvider>
|
||||
<HistoryItemDisplay {...baseItem} item={item} />
|
||||
</SessionStatsProvider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Agent powering down. Goodbye!');
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import { InfoMessage } from './messages/InfoMessage.js';
|
||||
import { ErrorMessage } from './messages/ErrorMessage.js';
|
||||
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
|
||||
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
|
||||
import { GeminiThoughtMessage } from './messages/GeminiThoughtMessage.js';
|
||||
import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageContent.js';
|
||||
import { CompressionMessage } from './messages/CompressionMessage.js';
|
||||
import { SummaryMessage } from './messages/SummaryMessage.js';
|
||||
import { WarningMessage } from './messages/WarningMessage.js';
|
||||
@@ -85,6 +87,26 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought' && (
|
||||
<GeminiThoughtMessage
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought_content' && (
|
||||
<GeminiThoughtMessageContent
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'info' && (
|
||||
<InfoMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
@@ -108,9 +130,6 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
{itemForDisplay.type === 'quit' && (
|
||||
<SessionSummaryDisplay duration={itemForDisplay.duration} />
|
||||
)}
|
||||
{itemForDisplay.type === 'quit_confirmation' && (
|
||||
<SessionSummaryDisplay duration={itemForDisplay.duration} />
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_group' && (
|
||||
<ToolGroupMessage
|
||||
toolCalls={itemForDisplay.tools}
|
||||
|
||||
@@ -1307,7 +1307,7 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji)
|
||||
mockBuffer.visualCursor = [0, 7]; // cursor after '👍' (emoji is 1 code point, so total is 7)
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
|
||||
@@ -707,15 +707,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
statusText = t('Accepting edits');
|
||||
}
|
||||
|
||||
const borderColor =
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default
|
||||
}
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
borderBottom={true}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text
|
||||
@@ -829,9 +834,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { Colors } from '../colors.js';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export enum QuitChoice {
|
||||
CANCEL = 'cancel',
|
||||
QUIT = 'quit',
|
||||
SUMMARY_AND_QUIT = 'summary_and_quit',
|
||||
}
|
||||
|
||||
interface QuitConfirmationDialogProps {
|
||||
onSelect: (choice: QuitChoice) => void;
|
||||
}
|
||||
|
||||
export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onSelect(QuitChoice.CANCEL);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const options: Array<RadioSelectItem<QuitChoice>> = [
|
||||
{
|
||||
key: 'quit',
|
||||
label: t('Quit immediately (/quit)'),
|
||||
value: QuitChoice.QUIT,
|
||||
},
|
||||
{
|
||||
key: 'summary-and-quit',
|
||||
label: t('Generate summary and quit (/summary)'),
|
||||
value: QuitChoice.SUMMARY_AND_QUIT,
|
||||
},
|
||||
{
|
||||
key: 'cancel',
|
||||
label: t('Cancel (stay in application)'),
|
||||
value: QuitChoice.CANCEL,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
padding={1}
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>{t('What would you like to do before exiting?')}</Text>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
|
||||
import * as SessionContext from '../contexts/SessionContext.js';
|
||||
import type { SessionMetrics } from '../contexts/SessionContext.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
|
||||
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof SessionContext>();
|
||||
@@ -20,20 +21,36 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
|
||||
const renderWithMockedStats = (metrics: SessionMetrics) => {
|
||||
const renderWithMockedStats = (
|
||||
metrics: SessionMetrics,
|
||||
sessionId: string = 'test-session-id-12345',
|
||||
promptCount: number = 5,
|
||||
chatRecordingEnabled: boolean = true,
|
||||
) => {
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: {
|
||||
sessionId,
|
||||
sessionStartTime: new Date(),
|
||||
metrics,
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 5,
|
||||
promptCount,
|
||||
},
|
||||
|
||||
getPromptCount: () => 5,
|
||||
getPromptCount: () => promptCount,
|
||||
startNewPrompt: vi.fn(),
|
||||
});
|
||||
|
||||
return render(<SessionSummaryDisplay duration="1h 23m 45s" />);
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn(() =>
|
||||
chatRecordingEnabled ? ({} as never) : undefined,
|
||||
),
|
||||
};
|
||||
|
||||
return render(
|
||||
<ConfigContext.Provider value={mockConfig as never}>
|
||||
<SessionSummaryDisplay duration="1h 23m 45s" />
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<SessionSummaryDisplay />', () => {
|
||||
@@ -70,6 +87,68 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Agent powering down. Goodbye!');
|
||||
expect(output).toContain('To continue this session, run');
|
||||
expect(output).toContain('qwen --resume test-session-id-12345');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not show resume message when there are no messages', () => {
|
||||
const metrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
files: {
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Pass promptCount = 0 to simulate no messages
|
||||
const { lastFrame } = renderWithMockedStats(
|
||||
metrics,
|
||||
'test-session-id-12345',
|
||||
0,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Agent powering down. Goodbye!');
|
||||
expect(output).not.toContain('To continue this session, run');
|
||||
expect(output).not.toContain('qwen --resume');
|
||||
});
|
||||
|
||||
it('does not show resume message when chat recording is disabled', () => {
|
||||
const metrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
files: {
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithMockedStats(
|
||||
metrics,
|
||||
'test-session-id-12345',
|
||||
5,
|
||||
false,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Agent powering down. Goodbye!');
|
||||
expect(output).not.toContain('To continue this session, run');
|
||||
expect(output).not.toContain('qwen --resume');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface SessionSummaryDisplayProps {
|
||||
@@ -14,9 +18,31 @@ interface SessionSummaryDisplayProps {
|
||||
|
||||
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
duration,
|
||||
}) => (
|
||||
<StatsDisplay
|
||||
title={t('Agent powering down. Goodbye!')}
|
||||
duration={duration}
|
||||
/>
|
||||
);
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const { stats } = useSessionStats();
|
||||
|
||||
// Only show the resume message if there were messages in the session AND
|
||||
// chat recording is enabled (otherwise there is nothing to resume).
|
||||
const hasMessages = stats.promptCount > 0;
|
||||
const canResume = !!config.getChatRecordingService();
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatsDisplay
|
||||
title={t('Agent powering down. Goodbye!')}
|
||||
duration={duration}
|
||||
/>
|
||||
{hasMessages && canResume && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('To continue this session, run')}{' '}
|
||||
<Text color={theme.text.accent}>
|
||||
qwen --resume {stats.sessionId}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1461,7 +1461,7 @@ describe('SettingsDialog', () => {
|
||||
context: {
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
respectQwemIgnore: true,
|
||||
respectQwenIgnore: true,
|
||||
enableRecursiveFileSearch: false,
|
||||
disableFuzzySearch: true,
|
||||
},
|
||||
@@ -1535,7 +1535,7 @@ describe('SettingsDialog', () => {
|
||||
loadMemoryFromIncludeDirectories: false,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
respectQwemIgnore: false,
|
||||
respectQwenIgnore: false,
|
||||
enableRecursiveFileSearch: false,
|
||||
disableFuzzySearch: false,
|
||||
},
|
||||
|
||||
@@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ! Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
! Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ * Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
* Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
@@ -6,7 +6,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
|
||||
│ Agent powering down. Goodbye! │
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Session ID: │
|
||||
│ Session ID: test-session-id-12345 │
|
||||
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
|
||||
│ Success Rate: 0.0% │
|
||||
│ Code Changes: +42 -15 │
|
||||
@@ -26,5 +26,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
|
||||
│ │
|
||||
│ » Tip: For a full token breakdown, run \`/stats model\`. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
To continue this session, run qwen --resume test-session-id-12345"
|
||||
`;
|
||||
|
||||
@@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false* │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title true* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips true* │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface GeminiThoughtMessageProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays model thinking/reasoning text with a softer, dimmed style
|
||||
* to visually distinguish it from regular content output.
|
||||
*/
|
||||
export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
|
||||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.secondary}>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface GeminiThoughtMessageContentProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuation component for thought messages, similar to GeminiMessageContent.
|
||||
* Used when a thought response gets too long and needs to be split for performance.
|
||||
*/
|
||||
export const GeminiThoughtMessageContent: React.FC<
|
||||
GeminiThoughtMessageContentProps
|
||||
> = ({ text, isPending, availableTerminalHeight, terminalWidth }) => {
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={prefixWidth} marginBottom={1}>
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -69,7 +69,10 @@ export function EditOptionsStep({
|
||||
if (selectedValue === 'editor') {
|
||||
// Launch editor directly
|
||||
try {
|
||||
await launchEditor(selectedAgent?.filePath);
|
||||
if (!selectedAgent.filePath) {
|
||||
throw new Error('Agent has no file path');
|
||||
}
|
||||
await launchEditor(selectedAgent.filePath);
|
||||
} catch (err) {
|
||||
setError(
|
||||
t('Failed to launch editor: {{error}}', {
|
||||
|
||||
@@ -218,7 +218,7 @@ export const AgentSelectionStep = ({
|
||||
const renderAgentItem = (
|
||||
agent: {
|
||||
name: string;
|
||||
level: 'project' | 'user' | 'builtin';
|
||||
level: 'project' | 'user' | 'builtin' | 'session';
|
||||
isBuiltin?: boolean;
|
||||
},
|
||||
index: number,
|
||||
@@ -267,7 +267,7 @@ export const AgentSelectionStep = ({
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{t('Project Level ({{path}})', {
|
||||
path: projectAgents[0].filePath.replace(/\/[^/]+$/, ''),
|
||||
path: projectAgents[0].filePath?.replace(/\/[^/]+$/, '') || '',
|
||||
})}
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
@@ -289,7 +289,7 @@ export const AgentSelectionStep = ({
|
||||
>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{t('User Level ({{path}})', {
|
||||
path: userAgents[0].filePath.replace(/\/[^/]+$/, ''),
|
||||
path: userAgents[0].filePath?.replace(/\/[^/]+$/, '') || '',
|
||||
})}
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
ShellConfirmationRequest,
|
||||
ConfirmationRequest,
|
||||
LoopDetectionConfirmationRequest,
|
||||
QuitConfirmationRequest,
|
||||
HistoryItemWithoutId,
|
||||
StreamingState,
|
||||
} from '../types.js';
|
||||
@@ -55,7 +54,6 @@ export interface UIState {
|
||||
qwenAuthState: QwenAuthState;
|
||||
editorError: string | null;
|
||||
isEditorDialogOpen: boolean;
|
||||
corgiMode: boolean;
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
@@ -69,7 +67,6 @@ export interface UIState {
|
||||
confirmationRequest: ConfirmationRequest | null;
|
||||
confirmUpdateExtensionRequests: ConfirmationRequest[];
|
||||
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||
quitConfirmationRequest: QuitConfirmationRequest | null;
|
||||
geminiMdFileCount: number;
|
||||
streamingState: StreamingState;
|
||||
initError: string | null;
|
||||
|
||||
@@ -153,7 +153,6 @@ describe('useSlashCommandProcessor', () => {
|
||||
openModelDialog: mockOpenModelDialog,
|
||||
quit: mockSetQuittingMessages,
|
||||
setDebugMessage: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -909,7 +908,6 @@ describe('useSlashCommandProcessor', () => {
|
||||
vi.fn(), // openThemeDialog
|
||||
mockOpenAuthDialog,
|
||||
vi.fn(), // openEditorDialog
|
||||
vi.fn(), // toggleCorgiMode
|
||||
mockSetQuittingMessages,
|
||||
vi.fn(), // openSettingsDialog
|
||||
vi.fn(), // openModelSelectionDialog
|
||||
@@ -918,7 +916,6 @@ describe('useSlashCommandProcessor', () => {
|
||||
vi.fn(), // toggleVimEnabled
|
||||
vi.fn(), // setIsProcessing
|
||||
vi.fn(), // setGeminiMdFileCount
|
||||
vi.fn(), // _showQuitConfirmation
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
IdeClient,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import type {
|
||||
Message,
|
||||
HistoryItemWithoutId,
|
||||
@@ -53,7 +52,6 @@ function serializeHistoryItemForRecording(
|
||||
|
||||
const SLASH_COMMANDS_SKIP_RECORDING = new Set([
|
||||
'quit',
|
||||
'quit-confirm',
|
||||
'exit',
|
||||
'clear',
|
||||
'reset',
|
||||
@@ -70,12 +68,10 @@ interface SlashCommandProcessorActions {
|
||||
openApprovalModeDialog: () => void;
|
||||
quit: (messages: HistoryItem[]) => void;
|
||||
setDebugMessage: (message: string) => void;
|
||||
toggleCorgiMode: () => void;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||
openSubagentCreateDialog: () => void;
|
||||
openAgentsManagerDialog: () => void;
|
||||
_showQuitConfirmation: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,10 +111,6 @@ export const useSlashCommandProcessor = (
|
||||
prompt: React.ReactNode;
|
||||
onConfirm: (confirmed: boolean) => void;
|
||||
}>(null);
|
||||
const [quitConfirmationRequest, setQuitConfirmationRequest] =
|
||||
useState<null | {
|
||||
onConfirm: (shouldQuit: boolean, action?: string) => void;
|
||||
}>(null);
|
||||
|
||||
const [sessionShellAllowlist, setSessionShellAllowlist] = useState(
|
||||
new Set<string>(),
|
||||
@@ -174,11 +166,6 @@ export const useSlashCommandProcessor = (
|
||||
type: 'quit',
|
||||
duration: message.duration,
|
||||
};
|
||||
} else if (message.type === MessageType.QUIT_CONFIRMATION) {
|
||||
historyItemContent = {
|
||||
type: 'quit_confirmation',
|
||||
duration: message.duration,
|
||||
};
|
||||
} else if (message.type === MessageType.COMPRESSION) {
|
||||
historyItemContent = {
|
||||
type: 'compression',
|
||||
@@ -218,7 +205,6 @@ export const useSlashCommandProcessor = (
|
||||
setDebugMessage: actions.setDebugMessage,
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
toggleCorgiMode: actions.toggleCorgiMode,
|
||||
toggleVimEnabled,
|
||||
setGeminiMdFileCount,
|
||||
reloadCommands,
|
||||
@@ -449,66 +435,6 @@ export const useSlashCommandProcessor = (
|
||||
});
|
||||
return { type: 'handled' };
|
||||
}
|
||||
case 'quit_confirmation':
|
||||
// Show quit confirmation dialog instead of immediately quitting
|
||||
setQuitConfirmationRequest({
|
||||
onConfirm: (shouldQuit: boolean, action?: string) => {
|
||||
setQuitConfirmationRequest(null);
|
||||
if (!shouldQuit) {
|
||||
// User cancelled the quit operation - do nothing
|
||||
return;
|
||||
}
|
||||
if (shouldQuit) {
|
||||
if (action === 'summary_and_quit') {
|
||||
// Generate summary and then quit
|
||||
handleSlashCommand('/summary')
|
||||
.then(() => {
|
||||
// Wait for user to see the summary result
|
||||
setTimeout(() => {
|
||||
handleSlashCommand('/quit');
|
||||
}, 1200);
|
||||
})
|
||||
.catch((error) => {
|
||||
// If summary fails, still quit but show error
|
||||
addItemWithRecording(
|
||||
{
|
||||
type: 'error',
|
||||
text: `Failed to generate summary before quit: ${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
// Give user time to see the error message
|
||||
setTimeout(() => {
|
||||
handleSlashCommand('/quit');
|
||||
}, 1000);
|
||||
});
|
||||
} else {
|
||||
// Just quit immediately - trigger the actual quit action
|
||||
const now = Date.now();
|
||||
const { sessionStartTime } = sessionStats;
|
||||
const wallDuration = now - sessionStartTime.getTime();
|
||||
|
||||
actions.quit([
|
||||
{
|
||||
type: 'user',
|
||||
text: `/quit`,
|
||||
id: now - 1,
|
||||
},
|
||||
{
|
||||
type: 'quit',
|
||||
duration: formatDuration(wallDuration),
|
||||
id: now,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
return { type: 'handled' };
|
||||
|
||||
case 'quit':
|
||||
actions.quit(result.messages);
|
||||
@@ -692,7 +618,6 @@ export const useSlashCommandProcessor = (
|
||||
setSessionShellAllowlist,
|
||||
setIsProcessing,
|
||||
setConfirmationRequest,
|
||||
sessionStats,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -703,6 +628,5 @@ export const useSlashCommandProcessor = (
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
confirmationRequest,
|
||||
quitConfirmationRequest,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,6 +15,23 @@ import {
|
||||
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS,
|
||||
useAttentionNotifications,
|
||||
} from './useAttentionNotifications.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
const mockSettings: LoadedSettings = {
|
||||
merged: {
|
||||
general: {
|
||||
terminalBell: true,
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
const mockSettingsDisabled: LoadedSettings = {
|
||||
merged: {
|
||||
general: {
|
||||
terminalBell: false,
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
vi.mock('../../utils/attentionNotification.js', () => ({
|
||||
notifyTerminalAttention: vi.fn(),
|
||||
@@ -40,6 +57,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
...props,
|
||||
},
|
||||
},
|
||||
@@ -53,11 +71,13 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
{ enabled: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,6 +92,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,6 +107,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,11 +116,13 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.LongTaskComplete,
|
||||
{ enabled: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -110,6 +134,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -118,6 +143,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -135,6 +161,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: 5,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -143,9 +170,30 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not notify when terminalBell setting is disabled', () => {
|
||||
const { rerender } = render({
|
||||
settings: mockSettingsDisabled,
|
||||
});
|
||||
|
||||
rerender({
|
||||
hookProps: {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettingsDisabled,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
{ enabled: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
notifyTerminalAttention,
|
||||
AttentionNotificationReason,
|
||||
} from '../../utils/attentionNotification.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
|
||||
|
||||
@@ -17,13 +18,16 @@ interface UseAttentionNotificationsOptions {
|
||||
isFocused: boolean;
|
||||
streamingState: StreamingState;
|
||||
elapsedTime: number;
|
||||
settings: LoadedSettings;
|
||||
}
|
||||
|
||||
export const useAttentionNotifications = ({
|
||||
isFocused,
|
||||
streamingState,
|
||||
elapsedTime,
|
||||
settings,
|
||||
}: UseAttentionNotificationsOptions) => {
|
||||
const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true;
|
||||
const awaitingNotificationSentRef = useRef(false);
|
||||
const respondingElapsedRef = useRef(0);
|
||||
|
||||
@@ -33,14 +37,16 @@ export const useAttentionNotifications = ({
|
||||
!isFocused &&
|
||||
!awaitingNotificationSentRef.current
|
||||
) {
|
||||
notifyTerminalAttention(AttentionNotificationReason.ToolApproval);
|
||||
notifyTerminalAttention(AttentionNotificationReason.ToolApproval, {
|
||||
enabled: terminalBellEnabled,
|
||||
});
|
||||
awaitingNotificationSentRef.current = true;
|
||||
}
|
||||
|
||||
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
|
||||
awaitingNotificationSentRef.current = false;
|
||||
}
|
||||
}, [isFocused, streamingState]);
|
||||
}, [isFocused, streamingState, terminalBellEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
@@ -53,11 +59,13 @@ export const useAttentionNotifications = ({
|
||||
respondingElapsedRef.current >=
|
||||
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
|
||||
if (wasLongTask && !isFocused) {
|
||||
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete);
|
||||
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete, {
|
||||
enabled: terminalBellEnabled,
|
||||
});
|
||||
}
|
||||
// Reset tracking for next task
|
||||
respondingElapsedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
}, [streamingState, elapsedTime, isFocused]);
|
||||
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
|
||||
};
|
||||
|
||||
@@ -44,11 +44,6 @@ export interface DialogCloseOptions {
|
||||
// Welcome back dialog
|
||||
showWelcomeBackDialog: boolean;
|
||||
handleWelcomeBackClose: () => void;
|
||||
|
||||
// Quit confirmation dialog
|
||||
quitConfirmationRequest: {
|
||||
onConfirm: (shouldQuit: boolean, action?: string) => void;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,9 +91,6 @@ export function useDialogClose(options: DialogCloseOptions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Note: quitConfirmationRequest is NOT handled here anymore
|
||||
// It's handled specially in handleExit - ctrl+c in quit-confirm should exit immediately
|
||||
|
||||
// No dialog was open
|
||||
return false;
|
||||
}, [options]);
|
||||
|
||||
@@ -2261,6 +2261,57 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should accumulate streamed thought descriptions', async () => {
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Thought,
|
||||
value: { subject: '', description: 'thinking ' },
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Thought,
|
||||
value: { subject: '', description: 'more' },
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Finished,
|
||||
value: { reason: 'STOP', usageMetadata: undefined },
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
mockLoadedSettings,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
false, // visionModelPreviewEnabled
|
||||
() => {},
|
||||
80,
|
||||
24,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Streamed thought');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.thought?.description).toBe('thinking more');
|
||||
});
|
||||
});
|
||||
|
||||
it('should memoize pendingHistoryItems', () => {
|
||||
mockUseReactToolScheduler.mockReturnValue([
|
||||
[],
|
||||
|
||||
@@ -497,6 +497,61 @@ export const useGeminiStream = (
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
||||
);
|
||||
|
||||
const mergeThought = useCallback(
|
||||
(incoming: ThoughtSummary) => {
|
||||
setThought((prev) => {
|
||||
if (!prev) {
|
||||
return incoming;
|
||||
}
|
||||
const subject = incoming.subject || prev.subject;
|
||||
const description = `${prev.description ?? ''}${incoming.description ?? ''}`;
|
||||
return { subject, description };
|
||||
});
|
||||
},
|
||||
[setThought],
|
||||
);
|
||||
|
||||
const handleThoughtEvent = useCallback(
|
||||
(
|
||||
eventValue: ThoughtSummary,
|
||||
currentThoughtBuffer: string,
|
||||
userMessageTimestamp: number,
|
||||
): string => {
|
||||
if (turnCancelledRef.current) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Extract the description text from the thought summary
|
||||
const thoughtText = eventValue.description ?? '';
|
||||
if (!thoughtText) {
|
||||
return currentThoughtBuffer;
|
||||
}
|
||||
|
||||
const newThoughtBuffer = currentThoughtBuffer + thoughtText;
|
||||
|
||||
// If we're not already showing a thought, start a new one
|
||||
if (pendingHistoryItemRef.current?.type !== 'gemini_thought') {
|
||||
// If there's a pending non-thought item, finalize it first
|
||||
if (pendingHistoryItemRef.current) {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
}
|
||||
setPendingHistoryItem({ type: 'gemini_thought', text: '' });
|
||||
}
|
||||
|
||||
// Update the existing thought message with accumulated content
|
||||
setPendingHistoryItem({
|
||||
type: 'gemini_thought',
|
||||
text: newThoughtBuffer,
|
||||
});
|
||||
|
||||
// Also update the thought state for the loading indicator
|
||||
mergeThought(eventValue);
|
||||
|
||||
return newThoughtBuffer;
|
||||
},
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, mergeThought],
|
||||
);
|
||||
|
||||
const handleUserCancelledEvent = useCallback(
|
||||
(userMessageTimestamp: number) => {
|
||||
if (turnCancelledRef.current) {
|
||||
@@ -710,11 +765,16 @@ export const useGeminiStream = (
|
||||
signal: AbortSignal,
|
||||
): Promise<StreamProcessingStatus> => {
|
||||
let geminiMessageBuffer = '';
|
||||
let thoughtBuffer = '';
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
for await (const event of stream) {
|
||||
switch (event.type) {
|
||||
case ServerGeminiEventType.Thought:
|
||||
setThought(event.value);
|
||||
thoughtBuffer = handleThoughtEvent(
|
||||
event.value,
|
||||
thoughtBuffer,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
break;
|
||||
case ServerGeminiEventType.Content:
|
||||
geminiMessageBuffer = handleContentEvent(
|
||||
@@ -776,6 +836,7 @@ export const useGeminiStream = (
|
||||
},
|
||||
[
|
||||
handleContentEvent,
|
||||
handleThoughtEvent,
|
||||
handleUserCancelledEvent,
|
||||
handleErrorEvent,
|
||||
scheduleToolCalls,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { QuitChoice } from '../components/QuitConfirmationDialog.js';
|
||||
|
||||
export const useQuitConfirmation = () => {
|
||||
const [isQuitConfirmationOpen, setIsQuitConfirmationOpen] = useState(false);
|
||||
|
||||
const showQuitConfirmation = useCallback(() => {
|
||||
setIsQuitConfirmationOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleQuitConfirmationSelect = useCallback((choice: QuitChoice) => {
|
||||
setIsQuitConfirmationOpen(false);
|
||||
|
||||
if (choice === QuitChoice.CANCEL) {
|
||||
return { shouldQuit: false, action: 'cancel' };
|
||||
} else if (choice === QuitChoice.QUIT) {
|
||||
return { shouldQuit: true, action: 'quit' };
|
||||
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
|
||||
return { shouldQuit: true, action: 'summary_and_quit' };
|
||||
}
|
||||
|
||||
// Default to cancel if unknown choice
|
||||
return { shouldQuit: false, action: 'cancel' };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isQuitConfirmationOpen,
|
||||
showQuitConfirmation,
|
||||
handleQuitConfirmationSelect,
|
||||
};
|
||||
};
|
||||
@@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
loadHistory: (_newHistory) => {},
|
||||
pendingItem: null,
|
||||
setPendingItem: (_item) => {},
|
||||
toggleCorgiMode: () => {},
|
||||
toggleVimEnabled: async () => false,
|
||||
setGeminiMdFileCount: (_count) => {},
|
||||
reloadCommands: () => {},
|
||||
|
||||
@@ -103,6 +103,16 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemGeminiThought = HistoryItemBase & {
|
||||
type: 'gemini_thought';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemGeminiThoughtContent = HistoryItemBase & {
|
||||
type: 'gemini_thought_content';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type HistoryItemInfo = HistoryItemBase & {
|
||||
type: 'info';
|
||||
text: string;
|
||||
@@ -161,11 +171,6 @@ export type HistoryItemQuit = HistoryItemBase & {
|
||||
duration: string;
|
||||
};
|
||||
|
||||
export type HistoryItemQuitConfirmation = HistoryItemBase & {
|
||||
type: 'quit_confirmation';
|
||||
duration: string;
|
||||
};
|
||||
|
||||
export type HistoryItemToolGroup = HistoryItemBase & {
|
||||
type: 'tool_group';
|
||||
tools: IndividualToolCallDisplay[];
|
||||
@@ -246,6 +251,8 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemUserShell
|
||||
| HistoryItemGemini
|
||||
| HistoryItemGeminiContent
|
||||
| HistoryItemGeminiThought
|
||||
| HistoryItemGeminiThoughtContent
|
||||
| HistoryItemInfo
|
||||
| HistoryItemError
|
||||
| HistoryItemWarning
|
||||
@@ -256,7 +263,6 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemModelStats
|
||||
| HistoryItemToolStats
|
||||
| HistoryItemQuit
|
||||
| HistoryItemQuitConfirmation
|
||||
| HistoryItemCompression
|
||||
| HistoryItemSummary
|
||||
| HistoryItemCompression
|
||||
@@ -278,7 +284,6 @@ export enum MessageType {
|
||||
MODEL_STATS = 'model_stats',
|
||||
TOOL_STATS = 'tool_stats',
|
||||
QUIT = 'quit',
|
||||
QUIT_CONFIRMATION = 'quit_confirmation',
|
||||
GEMINI = 'gemini',
|
||||
COMPRESSION = 'compression',
|
||||
SUMMARY = 'summary',
|
||||
@@ -342,12 +347,6 @@ export type Message =
|
||||
duration: string;
|
||||
content?: string;
|
||||
}
|
||||
| {
|
||||
type: MessageType.QUIT_CONFIRMATION;
|
||||
timestamp: Date;
|
||||
duration: string;
|
||||
content?: string;
|
||||
}
|
||||
| {
|
||||
type: MessageType.COMPRESSION;
|
||||
compression: CompressionProps;
|
||||
@@ -404,7 +403,3 @@ export interface ConfirmationRequest {
|
||||
export interface LoopDetectionConfirmationRequest {
|
||||
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
||||
}
|
||||
|
||||
export interface QuitConfirmationRequest {
|
||||
onConfirm: (shouldQuit: boolean, action?: string) => void;
|
||||
}
|
||||
|
||||
@@ -19,12 +19,16 @@ const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
|
||||
|
||||
interface RenderInlineProps {
|
||||
text: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
|
||||
const RenderInlineInternal: React.FC<RenderInlineProps> = ({
|
||||
text,
|
||||
textColor = theme.text.primary,
|
||||
}) => {
|
||||
// Early return for plain text without markdown or URLs
|
||||
if (!/[*_~`<[https?:]/.test(text)) {
|
||||
return <Text color={theme.text.primary}>{text}</Text>;
|
||||
return <Text color={textColor}>{text}</Text>;
|
||||
}
|
||||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
|
||||
@@ -17,6 +17,7 @@ interface MarkdownDisplayProps {
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
// Constants for Markdown parsing and rendering
|
||||
@@ -31,6 +32,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
textColor = theme.text.primary,
|
||||
}) => {
|
||||
if (!text) return <></>;
|
||||
|
||||
@@ -116,7 +118,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
addContentBlock(
|
||||
<Box key={key}>
|
||||
<Text wrap="wrap">
|
||||
<RenderInline text={line} />
|
||||
<RenderInline text={line} textColor={textColor} />
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
@@ -155,7 +157,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
addContentBlock(
|
||||
<Box key={key}>
|
||||
<Text wrap="wrap">
|
||||
<RenderInline text={line} />
|
||||
<RenderInline text={line} textColor={textColor} />
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
@@ -173,36 +175,36 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
switch (level) {
|
||||
case 1:
|
||||
headerNode = (
|
||||
<Text bold color={theme.text.link}>
|
||||
<RenderInline text={headerText} />
|
||||
<Text bold color={textColor}>
|
||||
<RenderInline text={headerText} textColor={textColor} />
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
headerNode = (
|
||||
<Text bold color={theme.text.link}>
|
||||
<RenderInline text={headerText} />
|
||||
<Text bold color={textColor}>
|
||||
<RenderInline text={headerText} textColor={textColor} />
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
case 3:
|
||||
headerNode = (
|
||||
<Text bold color={theme.text.primary}>
|
||||
<RenderInline text={headerText} />
|
||||
<Text bold color={textColor}>
|
||||
<RenderInline text={headerText} textColor={textColor} />
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
case 4:
|
||||
headerNode = (
|
||||
<Text italic color={theme.text.secondary}>
|
||||
<RenderInline text={headerText} />
|
||||
<Text italic color={textColor}>
|
||||
<RenderInline text={headerText} textColor={textColor} />
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
headerNode = (
|
||||
<Text color={theme.text.primary}>
|
||||
<RenderInline text={headerText} />
|
||||
<Text color={textColor}>
|
||||
<RenderInline text={headerText} textColor={textColor} />
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
@@ -219,6 +221,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
type="ul"
|
||||
marker={marker}
|
||||
leadingWhitespace={leadingWhitespace}
|
||||
textColor={textColor}
|
||||
/>,
|
||||
);
|
||||
} else if (olMatch) {
|
||||
@@ -232,6 +235,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
type="ol"
|
||||
marker={marker}
|
||||
leadingWhitespace={leadingWhitespace}
|
||||
textColor={textColor}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
@@ -245,8 +249,8 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
|
||||
} else {
|
||||
addContentBlock(
|
||||
<Box key={key}>
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
<RenderInline text={line} />
|
||||
<Text wrap="wrap" color={textColor}>
|
||||
<RenderInline text={line} textColor={textColor} />
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
@@ -367,6 +371,7 @@ interface RenderListItemProps {
|
||||
type: 'ul' | 'ol';
|
||||
marker: string;
|
||||
leadingWhitespace?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
const RenderListItemInternal: React.FC<RenderListItemProps> = ({
|
||||
@@ -374,6 +379,7 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
|
||||
type,
|
||||
marker,
|
||||
leadingWhitespace = '',
|
||||
textColor = theme.text.primary,
|
||||
}) => {
|
||||
const prefix = type === 'ol' ? `${marker}. ` : `${marker} `;
|
||||
const prefixWidth = prefix.length;
|
||||
@@ -385,11 +391,11 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
|
||||
flexDirection="row"
|
||||
>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.primary}>{prefix}</Text>
|
||||
<Text color={textColor}>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
<RenderInline text={itemText} />
|
||||
<Text wrap="wrap" color={textColor}>
|
||||
<RenderInline text={itemText} textColor={textColor} />
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('resumeHistoryUtils', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks tool results as error, skips thought text, and falls back when tool is missing', () => {
|
||||
it('marks tool results as error, captures thought text, and falls back when tool is missing', () => {
|
||||
const conversation = {
|
||||
messages: [
|
||||
{
|
||||
@@ -142,6 +142,11 @@ describe('resumeHistoryUtils', () => {
|
||||
const items = buildResumedHistoryItems(session, makeConfig({}));
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
id: expect.any(Number),
|
||||
type: 'gemini_thought',
|
||||
text: 'should be skipped',
|
||||
},
|
||||
{ id: expect.any(Number), type: 'gemini', text: 'visible text' },
|
||||
{
|
||||
id: expect.any(Number),
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
* Extracts text content from a Content object's parts.
|
||||
* Extracts text content from a Content object's parts (excluding thought parts).
|
||||
*/
|
||||
function extractTextFromParts(parts: Part[] | undefined): string {
|
||||
if (!parts) return '';
|
||||
@@ -34,6 +34,22 @@ function extractTextFromParts(parts: Part[] | undefined): string {
|
||||
return textParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts thought text content from a Content object's parts.
|
||||
* Thought parts are identified by having `thought: true`.
|
||||
*/
|
||||
function extractThoughtTextFromParts(parts: Part[] | undefined): string {
|
||||
if (!parts) return '';
|
||||
|
||||
const thoughtParts: string[] = [];
|
||||
for (const part of parts) {
|
||||
if ('text' in part && part.text && 'thought' in part && part.thought) {
|
||||
thoughtParts.push(part.text);
|
||||
}
|
||||
}
|
||||
return thoughtParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts function calls from a Content object's parts.
|
||||
*/
|
||||
@@ -187,12 +203,28 @@ function convertToHistoryItems(
|
||||
case 'assistant': {
|
||||
const parts = record.message?.parts as Part[] | undefined;
|
||||
|
||||
// Extract thought content
|
||||
const thoughtText = extractThoughtTextFromParts(parts);
|
||||
|
||||
// Extract text content (non-function-call, non-thought)
|
||||
const text = extractTextFromParts(parts);
|
||||
|
||||
// Extract function calls
|
||||
const functionCalls = extractFunctionCalls(parts);
|
||||
|
||||
// If there's thought content, add it as a gemini_thought message
|
||||
if (thoughtText) {
|
||||
// Flush any pending tool group before thought
|
||||
if (currentToolGroup.length > 0) {
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: [...currentToolGroup],
|
||||
});
|
||||
currentToolGroup = [];
|
||||
}
|
||||
items.push({ type: 'gemini_thought', text: thoughtText });
|
||||
}
|
||||
|
||||
// If there's text content, add it as a gemini message
|
||||
if (text) {
|
||||
// Flush any pending tool group before text
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum AttentionNotificationReason {
|
||||
|
||||
export interface TerminalNotificationOptions {
|
||||
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const TERMINAL_BELL = '\u0007';
|
||||
@@ -28,6 +29,11 @@ export function notifyTerminalAttention(
|
||||
_reason: AttentionNotificationReason,
|
||||
options: TerminalNotificationOptions = {},
|
||||
): boolean {
|
||||
// Check if terminal bell is enabled (default true for backwards compatibility)
|
||||
if (options.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stream = options.stream ?? process.stdout;
|
||||
if (!stream?.write || stream.isTTY === false) {
|
||||
return false;
|
||||
|
||||
@@ -58,7 +58,7 @@ export const getLatestGitHubRelease = async (
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
|
||||
const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`;
|
||||
const endpoint = `https://api.github.com/repos/QwenLM/qwen-code-action/releases/latest`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
@@ -83,9 +83,12 @@ export const getLatestGitHubRelease = async (
|
||||
}
|
||||
return releaseTag;
|
||||
} catch (_error) {
|
||||
console.debug(`Failed to determine latest run-gemini-cli release:`, _error);
|
||||
console.debug(
|
||||
`Failed to determine latest qwen-code-action release:`,
|
||||
_error,
|
||||
);
|
||||
throw new Error(
|
||||
`Unable to determine the latest run-gemini-cli release on GitHub.`,
|
||||
`Unable to determine the latest qwen-code-action release on GitHub.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -529,7 +529,7 @@ describe('buildSystemMessage', () => {
|
||||
{ name: 'mcp-server-2', status: 'connected' },
|
||||
],
|
||||
model: 'test-model',
|
||||
permissionMode: 'auto',
|
||||
permission_mode: 'auto',
|
||||
slash_commands: ['commit', 'help', 'memory'],
|
||||
qwen_code_version: '1.0.0',
|
||||
agents: [],
|
||||
|
||||
@@ -275,7 +275,7 @@ export async function buildSystemMessage(
|
||||
tools,
|
||||
mcp_servers: mcpServerList,
|
||||
model: config.getModel(),
|
||||
permissionMode,
|
||||
permission_mode: permissionMode,
|
||||
slash_commands: slashCommands,
|
||||
qwen_code_version: config.getCliVersion() || 'unknown',
|
||||
agents: agentNames,
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function validateNonInteractiveAuth(
|
||||
}
|
||||
|
||||
const effectiveAuthType =
|
||||
enforcedType || getAuthTypeFromEnv() || configuredAuthType;
|
||||
enforcedType || configuredAuthType || getAuthTypeFromEnv();
|
||||
|
||||
if (!effectiveAuthType) {
|
||||
const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: QWEN_OAUTH, OPENAI_API_KEY`;
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"src/ui/commands/clearCommand.test.ts",
|
||||
"src/ui/commands/compressCommand.test.ts",
|
||||
"src/ui/commands/copyCommand.test.ts",
|
||||
"src/ui/commands/corgiCommand.test.ts",
|
||||
"src/ui/commands/docsCommand.test.ts",
|
||||
"src/ui/commands/editorCommand.test.ts",
|
||||
"src/ui/commands/extensionsCommand.test.ts",
|
||||
|
||||
Reference in New Issue
Block a user