Session-Level Conversation History Management (#1113)

This commit is contained in:
tanzhenxin
2025-12-03 18:04:48 +08:00
committed by GitHub
parent a7abd8d09f
commit 0a75d85ac9
114 changed files with 9257 additions and 4039 deletions

View File

@@ -42,6 +42,14 @@ export class AgentSideConnection implements Client {
const validatedParams = schema.loadSessionRequestSchema.parse(params);
return agent.loadSession(validatedParams);
}
case schema.AGENT_METHODS.session_list: {
if (!agent.listSessions) {
throw RequestError.methodNotFound();
}
const validatedParams =
schema.listSessionsRequestSchema.parse(params);
return agent.listSessions(validatedParams);
}
case schema.AGENT_METHODS.authenticate: {
const validatedParams =
schema.authenticateRequestSchema.parse(params);
@@ -55,6 +63,13 @@ export class AgentSideConnection implements Client {
const validatedParams = schema.cancelNotificationSchema.parse(params);
return agent.cancel(validatedParams);
}
case schema.AGENT_METHODS.session_set_mode: {
if (!agent.setMode) {
throw RequestError.methodNotFound();
}
const validatedParams = schema.setModeRequestSchema.parse(params);
return agent.setMode(validatedParams);
}
default:
throw RequestError.methodNotFound(method);
}
@@ -360,7 +375,11 @@ export interface Agent {
loadSession?(
params: schema.LoadSessionRequest,
): Promise<schema.LoadSessionResponse>;
listSessions?(
params: schema.ListSessionsRequest,
): Promise<schema.ListSessionsResponse>;
authenticate(params: schema.AuthenticateRequest): Promise<void>;
prompt(params: schema.PromptRequest): Promise<schema.PromptResponse>;
cancel(params: schema.CancelNotification): Promise<void>;
setMode?(params: schema.SetModeRequest): Promise<schema.SetModeResponse>;
}

View File

@@ -0,0 +1,329 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
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,
MCPServerConfig,
SessionService,
buildApiHistoryFromConversation,
} from '@qwen-code/qwen-code-core';
import type { ApprovalModeValue } from './schema.js';
import * as acp from './acp.js';
import { AcpFileSystemService } from './service/filesystem.js';
import { Readable, Writable } from 'node:stream';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope } from '../config/settings.js';
import { z } from 'zod';
import { ExtensionStorage, type Extension } from '../config/extension.js';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
// Import the modular Session class
import { Session } from './session/Session.js';
export async function runAcpAgent(
config: Config,
settings: LoadedSettings,
extensions: Extension[],
argv: CliArgs,
) {
const stdout = Writable.toWeb(process.stdout) as WritableStream;
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
// Stdout is used to send messages to the client, so console.log/console.info
// messages to stderr so that they don't interfere with ACP.
console.log = console.error;
console.info = console.error;
console.debug = console.error;
new acp.AgentSideConnection(
(client: acp.Client) =>
new GeminiAgent(config, settings, extensions, argv, client),
stdout,
stdin,
);
}
class GeminiAgent {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
constructor(
private config: Config,
private settings: LoadedSettings,
private extensions: Extension[],
private argv: CliArgs,
private client: acp.Client,
) {}
async initialize(
args: acp.InitializeRequest,
): Promise<acp.InitializeResponse> {
this.clientCapabilities = args.clientCapabilities;
const authMethods = [
{
id: AuthType.USE_OPENAI,
name: 'Use OpenAI API key',
description:
'Requires setting the `OPENAI_API_KEY` environment variable',
},
{
id: AuthType.QWEN_OAUTH,
name: 'Qwen OAuth',
description:
'OAuth authentication for Qwen models with 2000 daily requests',
},
];
// Get current approval mode from config
const currentApprovalMode = this.config.getApprovalMode();
// Build available modes from shared APPROVAL_MODE_INFO
const availableModes = APPROVAL_MODES.map((mode) => ({
id: mode as ApprovalModeValue,
name: APPROVAL_MODE_INFO[mode].name,
description: APPROVAL_MODE_INFO[mode].description,
}));
const version = process.env['CLI_VERSION'] || process.version;
return {
protocolVersion: acp.PROTOCOL_VERSION,
agentInfo: {
name: 'qwen-code',
title: 'Qwen Code',
version,
},
authMethods,
modes: {
currentModeId: currentApprovalMode as ApprovalModeValue,
availableModes,
},
agentCapabilities: {
loadSession: true,
promptCapabilities: {
image: true,
audio: true,
embeddedContext: true,
},
},
};
}
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
const method = z.nativeEnum(AuthType).parse(methodId);
await clearCachedCredentialFile();
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
}
async newSession({
cwd,
mcpServers,
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
const config = await this.newSessionConfig(cwd, mcpServers);
await this.ensureAuthenticated(config);
this.setupFileSystem(config);
const session = await this.createAndStoreSession(config);
return {
sessionId: session.getId(),
};
}
async newSessionConfig(
cwd: string,
mcpServers: acp.McpServer[],
sessionId?: string,
): Promise<Config> {
const mergedMcpServers = { ...this.settings.merged.mcpServers };
for (const { command, args, env: rawEnv, name } of mcpServers) {
const env: Record<string, string> = {};
for (const { name: envName, value } of rawEnv) {
env[envName] = value;
}
mergedMcpServers[name] = new MCPServerConfig(command, args, env, cwd);
}
const settings = { ...this.settings.merged, mcpServers: mergedMcpServers };
const argvForSession = {
...this.argv,
resume: sessionId,
continue: false,
};
const config = await loadCliConfig(
settings,
this.extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
this.argv.extensions,
),
argvForSession,
cwd,
);
await config.initialize();
return config;
}
async cancel(params: acp.CancelNotification): Promise<void> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
await session.cancelPendingPrompt();
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
return session.prompt(params);
}
async loadSession(
params: acp.LoadSessionRequest,
): Promise<acp.LoadSessionResponse> {
const sessionService = new SessionService(params.cwd);
const exists = await sessionService.sessionExists(params.sessionId);
if (!exists) {
throw acp.RequestError.invalidParams(
`Session not found for id: ${params.sessionId}`,
);
}
const config = await this.newSessionConfig(
params.cwd,
params.mcpServers,
params.sessionId,
);
await this.ensureAuthenticated(config);
this.setupFileSystem(config);
const sessionData = config.getResumedSessionData();
if (!sessionData) {
throw acp.RequestError.internalError(
`Failed to load session data for id: ${params.sessionId}`,
);
}
await this.createAndStoreSession(config, sessionData.conversation);
return null;
}
async listSessions(
params: acp.ListSessionsRequest,
): Promise<acp.ListSessionsResponse> {
const sessionService = new SessionService(params.cwd);
const result = await sessionService.listSessions({
cursor: params.cursor,
size: params.size,
});
return {
items: result.items.map((item) => ({
sessionId: item.sessionId,
cwd: item.cwd,
startTime: item.startTime,
mtime: item.mtime,
prompt: item.prompt,
gitBranch: item.gitBranch,
filePath: item.filePath,
messageCount: item.messageCount,
})),
nextCursor: result.nextCursor,
hasMore: result.hasMore,
};
}
async setMode(params: acp.SetModeRequest): Promise<acp.SetModeResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
return session.setMode(params);
}
private async ensureAuthenticated(config: Config): Promise<void> {
const selectedType = this.settings.merged.security?.auth?.selectedType;
if (!selectedType) {
throw acp.RequestError.authRequired();
}
try {
await config.refreshAuth(selectedType);
} catch (e) {
console.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
}
}
private setupFileSystem(config: Config): void {
if (!this.clientCapabilities?.fs) {
return;
}
const acpFileSystemService = new AcpFileSystemService(
this.client,
config.getSessionId(),
this.clientCapabilities.fs,
config.getFileSystemService(),
);
config.setFileSystemService(acpFileSystemService);
}
private async createAndStoreSession(
config: Config,
conversation?: ConversationRecord,
): Promise<Session> {
const sessionId = config.getSessionId();
const geminiClient = config.getGeminiClient();
const history = conversation
? buildApiHistoryFromConversation(conversation)
: undefined;
const chat = history
? await geminiClient.startChat(history)
: await geminiClient.startChat();
const session = new Session(
sessionId,
chat,
config,
this.client,
this.settings,
);
this.sessions.set(sessionId, session);
setTimeout(async () => {
await session.sendAvailableCommandsUpdate();
}, 0);
if (conversation && conversation.messages) {
await session.replayHistory(conversation.messages);
}
return session;
}
}

View File

@@ -13,6 +13,8 @@ export const AGENT_METHODS = {
session_load: 'session/load',
session_new: 'session/new',
session_prompt: 'session/prompt',
session_list: 'session/list',
session_set_mode: 'session/set_mode',
};
export const CLIENT_METHODS = {
@@ -47,6 +49,9 @@ export type ReadTextFileResponse = z.infer<typeof readTextFileResponseSchema>;
export type RequestPermissionOutcome = z.infer<
typeof requestPermissionOutcomeSchema
>;
export type SessionListItem = z.infer<typeof sessionListItemSchema>;
export type ListSessionsRequest = z.infer<typeof listSessionsRequestSchema>;
export type ListSessionsResponse = z.infer<typeof listSessionsResponseSchema>;
export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
@@ -84,6 +89,12 @@ export type AgentCapabilities = z.infer<typeof agentCapabilitiesSchema>;
export type AuthMethod = z.infer<typeof authMethodSchema>;
export type ModeInfo = z.infer<typeof modeInfoSchema>;
export type ModesData = z.infer<typeof modesDataSchema>;
export type AgentInfo = z.infer<typeof agentInfoSchema>;
export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>;
export type ClientResponse = z.infer<typeof clientResponseSchema>;
@@ -128,6 +139,12 @@ export type AgentRequest = z.infer<typeof agentRequestSchema>;
export type AgentNotification = z.infer<typeof agentNotificationSchema>;
export type ApprovalModeValue = z.infer<typeof approvalModeValueSchema>;
export type SetModeRequest = z.infer<typeof setModeRequestSchema>;
export type SetModeResponse = z.infer<typeof setModeResponseSchema>;
export type AvailableCommandInput = z.infer<typeof availableCommandInputSchema>;
export type AvailableCommand = z.infer<typeof availableCommandSchema>;
@@ -179,6 +196,7 @@ export const toolKindSchema = z.union([
z.literal('execute'),
z.literal('think'),
z.literal('fetch'),
z.literal('switch_mode'),
z.literal('other'),
]);
@@ -209,6 +227,22 @@ export const cancelNotificationSchema = z.object({
sessionId: z.string(),
});
export const approvalModeValueSchema = z.union([
z.literal('plan'),
z.literal('default'),
z.literal('auto-edit'),
z.literal('yolo'),
]);
export const setModeRequestSchema = z.object({
sessionId: z.string(),
modeId: approvalModeValueSchema,
});
export const setModeResponseSchema = z.object({
modeId: approvalModeValueSchema,
});
export const authenticateRequestSchema = z.object({
methodId: z.string(),
});
@@ -221,6 +255,29 @@ export const newSessionResponseSchema = z.object({
export const loadSessionResponseSchema = z.null();
export const sessionListItemSchema = z.object({
cwd: z.string(),
filePath: z.string(),
gitBranch: z.string().optional(),
messageCount: z.number(),
mtime: z.number(),
prompt: z.string(),
sessionId: z.string(),
startTime: z.string(),
});
export const listSessionsResponseSchema = z.object({
hasMore: z.boolean(),
items: z.array(sessionListItemSchema),
nextCursor: z.number().optional(),
});
export const listSessionsRequestSchema = z.object({
cursor: z.number().optional(),
cwd: z.string(),
size: z.number().optional(),
});
export const stopReasonSchema = z.union([
z.literal('end_turn'),
z.literal('max_tokens'),
@@ -321,9 +378,28 @@ export const loadSessionRequestSchema = z.object({
sessionId: z.string(),
});
export const modeInfoSchema = z.object({
id: approvalModeValueSchema,
name: z.string(),
description: z.string(),
});
export const modesDataSchema = z.object({
currentModeId: approvalModeValueSchema,
availableModes: z.array(modeInfoSchema),
});
export const agentInfoSchema = z.object({
name: z.string(),
title: z.string(),
version: z.string(),
});
export const initializeResponseSchema = z.object({
agentCapabilities: agentCapabilitiesSchema,
agentInfo: agentInfoSchema,
authMethods: z.array(authMethodSchema),
modes: modesDataSchema,
protocolVersion: z.number(),
});
@@ -409,6 +485,13 @@ export const availableCommandsUpdateSchema = z.object({
sessionUpdate: z.literal('available_commands_update'),
});
export const currentModeUpdateSchema = z.object({
sessionUpdate: z.literal('current_mode_update'),
modeId: approvalModeValueSchema,
});
export type CurrentModeUpdate = z.infer<typeof currentModeUpdateSchema>;
export const sessionUpdateSchema = z.union([
z.object({
content: contentBlockSchema,
@@ -437,6 +520,7 @@ export const sessionUpdateSchema = z.union([
kind: toolKindSchema.optional().nullable(),
locations: z.array(toolCallLocationSchema).optional().nullable(),
rawInput: z.unknown().optional(),
rawOutput: z.unknown().optional(),
sessionUpdate: z.literal('tool_call_update'),
status: toolCallStatusSchema.optional().nullable(),
title: z.string().optional().nullable(),
@@ -446,6 +530,7 @@ export const sessionUpdateSchema = z.union([
entries: z.array(planEntrySchema),
sessionUpdate: z.literal('plan'),
}),
currentModeUpdateSchema,
availableCommandsUpdateSchema,
]);
@@ -455,6 +540,8 @@ export const agentResponseSchema = z.union([
newSessionResponseSchema,
loadSessionResponseSchema,
promptResponseSchema,
listSessionsResponseSchema,
setModeResponseSchema,
]);
export const requestPermissionRequestSchema = z.object({
@@ -485,6 +572,8 @@ export const agentRequestSchema = z.union([
newSessionRequestSchema,
loadSessionRequestSchema,
promptRequestSchema,
listSessionsRequestSchema,
setModeRequestSchema,
]);
export const agentNotificationSchema = sessionNotificationSchema;

View File

@@ -5,7 +5,7 @@
*/
import type { FileSystemService } from '@qwen-code/qwen-code-core';
import type * as acp from './acp.js';
import type * as acp from '../acp.js';
/**
* ACP client-based implementation of FileSystemService

View File

@@ -0,0 +1,414 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HistoryReplayer } from './HistoryReplayer.js';
import type { SessionContext } from './types.js';
import type {
Config,
ChatRecord,
ToolRegistry,
ToolResultDisplay,
TodoResultDisplay,
} from '@qwen-code/qwen-code-core';
describe('HistoryReplayer', () => {
let mockContext: SessionContext;
let sendUpdateSpy: ReturnType<typeof vi.fn>;
let replayer: HistoryReplayer;
beforeEach(() => {
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
const mockToolRegistry = {
getTool: vi.fn().mockReturnValue(null),
} as unknown as ToolRegistry;
mockContext = {
sessionId: 'test-session-id',
config: {
getToolRegistry: () => mockToolRegistry,
} as unknown as Config,
sendUpdate: sendUpdateSpy,
};
replayer = new HistoryReplayer(mockContext);
});
const createUserRecord = (text: string): ChatRecord => ({
uuid: 'user-uuid',
parentUuid: null,
sessionId: 'test-session',
timestamp: new Date().toISOString(),
type: 'user',
cwd: '/test',
version: '1.0.0',
message: {
role: 'user',
parts: [{ text }],
},
});
const createAssistantRecord = (
text: string,
thought = false,
): 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, thought }],
},
});
const createToolResultRecord = (
toolName: string,
resultDisplay?: ToolResultDisplay,
hasError = false,
): ChatRecord => ({
uuid: 'tool-uuid',
parentUuid: 'assistant-uuid',
sessionId: 'test-session',
timestamp: new Date().toISOString(),
type: 'tool_result',
cwd: '/test',
version: '1.0.0',
message: {
role: 'user',
parts: [
{
functionResponse: {
name: toolName,
response: { result: 'ok' },
},
},
],
},
toolCallResult: {
callId: 'call-123',
responseParts: [],
resultDisplay,
error: hasError ? new Error('Tool failed') : undefined,
errorType: undefined,
},
});
describe('replay', () => {
it('should replay empty records array', async () => {
await replayer.replay([]);
expect(sendUpdateSpy).not.toHaveBeenCalled();
});
it('should replay records in order', async () => {
const records = [
createUserRecord('Hello'),
createAssistantRecord('Hi there'),
];
await replayer.replay(records);
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
expect(sendUpdateSpy.mock.calls[0][0].sessionUpdate).toBe(
'user_message_chunk',
);
expect(sendUpdateSpy.mock.calls[1][0].sessionUpdate).toBe(
'agent_message_chunk',
);
});
});
describe('user message replay', () => {
it('should emit user_message_chunk for user records', async () => {
const records = [createUserRecord('Hello, world!')];
await replayer.replay(records);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'Hello, world!' },
});
});
it('should skip user records without message', async () => {
const record: ChatRecord = {
...createUserRecord('test'),
message: undefined,
};
await replayer.replay([record]);
expect(sendUpdateSpy).not.toHaveBeenCalled();
});
});
describe('assistant message replay', () => {
it('should emit agent_message_chunk for assistant records', async () => {
const records = [createAssistantRecord('I can help with that.')];
await replayer.replay(records);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'I can help with that.' },
});
});
it('should emit agent_thought_chunk for thought parts', async () => {
const records = [createAssistantRecord('Thinking about this...', true)];
await replayer.replay(records);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'Thinking about this...' },
});
});
it('should handle assistant records with multiple parts', async () => {
const record: ChatRecord = {
...createAssistantRecord('First'),
message: {
role: 'model',
parts: [
{ text: 'First part' },
{ text: 'Second part', thought: true },
{ text: 'Third part' },
],
},
};
await replayer.replay([record]);
expect(sendUpdateSpy).toHaveBeenCalledTimes(3);
expect(sendUpdateSpy.mock.calls[0][0]).toEqual({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'First part' },
});
expect(sendUpdateSpy.mock.calls[1][0]).toEqual({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'Second part' },
});
expect(sendUpdateSpy.mock.calls[2][0]).toEqual({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Third part' },
});
});
});
describe('function call replay', () => {
it('should emit tool_call for function call parts', async () => {
const record: ChatRecord = {
...createAssistantRecord(''),
message: {
role: 'model',
parts: [
{
functionCall: {
name: 'read_file',
args: { path: '/test.ts' },
},
},
],
},
};
await replayer.replay([record]);
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call',
status: 'in_progress',
title: 'read_file',
rawInput: { path: '/test.ts' },
}),
);
});
it('should use function call id as callId when available', async () => {
const record: ChatRecord = {
...createAssistantRecord(''),
message: {
role: 'model',
parts: [
{
functionCall: {
id: 'custom-call-id',
name: 'read_file',
args: {},
},
},
],
},
};
await replayer.replay([record]);
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'custom-call-id',
}),
);
});
});
describe('tool result replay', () => {
it('should emit tool_call_update for tool result records', async () => {
const records = [
createToolResultRecord('read_file', 'File contents here'),
];
await replayer.replay(records);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-123',
status: 'completed',
content: [
{
type: 'content',
// Content comes from functionResponse.response (stringified)
content: { type: 'text', text: '{"result":"ok"}' },
},
],
// resultDisplay is included as rawOutput
rawOutput: 'File contents here',
});
});
it('should emit failed status for tool results with errors', async () => {
const records = [createToolResultRecord('failing_tool', undefined, true)];
await replayer.replay(records);
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'failed',
}),
);
});
it('should emit plan update for TodoWriteTool results', async () => {
const todoDisplay: TodoResultDisplay = {
type: 'todo_list',
todos: [
{ id: '1', content: 'Task 1', status: 'pending' },
{ id: '2', content: 'Task 2', status: 'completed' },
],
};
const record = createToolResultRecord('todo_write', todoDisplay);
// Override the function response name
record.message = {
role: 'user',
parts: [
{
functionResponse: {
name: 'todo_write',
response: { result: 'ok' },
},
},
],
};
await replayer.replay([record]);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'plan',
entries: [
{ content: 'Task 1', priority: 'medium', status: 'pending' },
{ content: 'Task 2', priority: 'medium', status: 'completed' },
],
});
});
it('should use record uuid as callId when toolCallResult.callId is missing', async () => {
const record: ChatRecord = {
...createToolResultRecord('test_tool'),
uuid: 'fallback-uuid',
toolCallResult: {
callId: undefined as unknown as string,
responseParts: [],
resultDisplay: 'Result',
error: undefined,
errorType: undefined,
},
};
await replayer.replay([record]);
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'fallback-uuid',
}),
);
});
});
describe('system records', () => {
it('should skip system records', async () => {
const systemRecord: ChatRecord = {
uuid: 'system-uuid',
parentUuid: null,
sessionId: 'test-session',
timestamp: new Date().toISOString(),
type: 'system',
subtype: 'chat_compression',
cwd: '/test',
version: '1.0.0',
};
await replayer.replay([systemRecord]);
expect(sendUpdateSpy).not.toHaveBeenCalled();
});
});
describe('mixed record types', () => {
it('should handle a complete conversation replay', async () => {
const records: ChatRecord[] = [
createUserRecord('Read the file test.ts'),
{
...createAssistantRecord(''),
message: {
role: 'model',
parts: [
{ text: "I'll read that file for you.", thought: true },
{
functionCall: {
id: 'call-read',
name: 'read_file',
args: { path: 'test.ts' },
},
},
],
},
},
createToolResultRecord('read_file', 'export const x = 1;'),
createAssistantRecord('The file contains a simple export.'),
];
await replayer.replay(records);
// Verify order and types of updates
const updateTypes = sendUpdateSpy.mock.calls.map(
(call: unknown[]) =>
(call[0] as { sessionUpdate: string }).sessionUpdate,
);
expect(updateTypes).toEqual([
'user_message_chunk',
'agent_thought_chunk',
'tool_call',
'tool_call_update',
'agent_message_chunk',
]);
});
});
});

View File

@@ -0,0 +1,137 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { ChatRecord } from '@qwen-code/qwen-code-core';
import type { Content } from '@google/genai';
import type { SessionContext } from './types.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
/**
* Handles replaying session history on session load.
*
* Uses the unified emitters to ensure consistency with normal flow.
* This ensures that replayed history looks identical to how it would
* have appeared during the original session.
*/
export class HistoryReplayer {
private readonly messageEmitter: MessageEmitter;
private readonly toolCallEmitter: ToolCallEmitter;
constructor(ctx: SessionContext) {
this.messageEmitter = new MessageEmitter(ctx);
this.toolCallEmitter = new ToolCallEmitter(ctx);
}
/**
* Replays all chat records from a loaded session.
*
* @param records - Array of chat records to replay
*/
async replay(records: ChatRecord[]): Promise<void> {
for (const record of records) {
await this.replayRecord(record);
}
}
/**
* Replays a single chat record.
*/
private async replayRecord(record: ChatRecord): Promise<void> {
switch (record.type) {
case 'user':
if (record.message) {
await this.replayContent(record.message, 'user');
}
break;
case 'assistant':
if (record.message) {
await this.replayContent(record.message, 'assistant');
}
break;
case 'tool_result':
await this.replayToolResult(record);
break;
default:
// Skip system records (compression, telemetry, slash commands)
break;
}
}
/**
* Replays content from a message (user or assistant).
* Handles text parts, thought parts, and function calls.
*/
private async replayContent(
content: Content,
role: 'user' | 'assistant',
): Promise<void> {
for (const part of content.parts ?? []) {
// Text content
if ('text' in part && part.text) {
const isThought = (part as { thought?: boolean }).thought ?? false;
await this.messageEmitter.emitMessage(part.text, role, isThought);
}
// Function call (tool start)
if ('functionCall' in part && part.functionCall) {
const functionName = part.functionCall.name ?? '';
const callId = part.functionCall.id ?? `${functionName}-${Date.now()}`;
await this.toolCallEmitter.emitStart({
toolName: functionName,
callId,
args: part.functionCall.args as Record<string, unknown>,
});
}
}
}
/**
* Replays a tool result record.
*/
private async replayToolResult(record: ChatRecord): Promise<void> {
// message is required - skip if not present
if (!record.message?.parts) {
return;
}
const result = record.toolCallResult;
const callId = result?.callId ?? record.uuid;
// Extract tool name from the function response in message if available
const toolName = this.extractToolNameFromRecord(record);
await this.toolCallEmitter.emitResult({
toolName,
callId,
success: !result?.error,
message: record.message.parts,
resultDisplay: result?.resultDisplay,
// For TodoWriteTool fallback, try to extract args from the record
// Note: args aren't stored in tool_result records by default
args: undefined,
});
}
/**
* Extracts tool name from a chat record's function response.
*/
private extractToolNameFromRecord(record: ChatRecord): string {
// Try to get from functionResponse in message
if (record.message?.parts) {
for (const part of record.message.parts) {
if ('functionResponse' in part && part.functionResponse?.name) {
return part.functionResponse.name;
}
}
}
return '';
}
}

View File

@@ -0,0 +1,981 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, FunctionCall, Part } from '@google/genai';
import type {
Config,
GeminiChat,
ToolCallConfirmationDetails,
ToolResult,
ChatRecord,
SubAgentEventEmitter,
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
convertToFunctionResponse,
DiscoveredMCPTool,
StreamEventType,
ToolConfirmationOutcome,
logToolCall,
logUserPrompt,
getErrorStatus,
isWithinRoot,
isNodeError,
TaskTool,
UserPromptEvent,
TodoWriteTool,
ExitPlanModeTool,
} from '@qwen-code/qwen-code-core';
import * as acp from '../acp.js';
import type { LoadedSettings } from '../../config/settings.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { z } from 'zod';
import { getErrorMessage } from '../../utils/errors.js';
import {
handleSlashCommand,
getAvailableCommands,
} from '../../nonInteractiveCliCommands.js';
import type {
AvailableCommand,
AvailableCommandsUpdate,
SetModeRequest,
SetModeResponse,
ApprovalModeValue,
CurrentModeUpdate,
} from '../schema.js';
import { isSlashCommand } from '../../ui/utils/commandUtils.js';
// Import modular session components
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 { SubAgentTracker } from './SubAgentTracker.js';
/**
* Built-in commands that are allowed in ACP integration mode.
* Only safe, read-only commands that don't require interactive UI.
*/
export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
/**
* Session represents an active conversation session with the AI model.
* It uses modular components for consistent event emission:
* - HistoryReplayer for replaying past conversations
* - ToolCallEmitter for tool-related session updates
* - PlanEmitter for todo/plan updates
* - SubAgentTracker for tracking sub-agent tool calls
*/
export class Session implements SessionContext {
private pendingPrompt: AbortController | null = null;
private turn: number = 0;
// Modular components
private readonly historyReplayer: HistoryReplayer;
private readonly toolCallEmitter: ToolCallEmitter;
private readonly planEmitter: PlanEmitter;
// Implement SessionContext interface
readonly sessionId: string;
constructor(
id: string,
private readonly chat: GeminiChat,
readonly config: Config,
private readonly client: acp.Client,
private readonly settings: LoadedSettings,
) {
this.sessionId = id;
// Initialize modular components with this session as context
this.toolCallEmitter = new ToolCallEmitter(this);
this.planEmitter = new PlanEmitter(this);
this.historyReplayer = new HistoryReplayer(this);
}
getId(): string {
return this.sessionId;
}
getConfig(): Config {
return this.config;
}
/**
* Replays conversation history to the client using modular components.
* Delegates to HistoryReplayer for consistent event emission.
*/
async replayHistory(records: ChatRecord[]): Promise<void> {
await this.historyReplayer.replay(records);
}
async cancelPendingPrompt(): Promise<void> {
if (!this.pendingPrompt) {
throw new Error('Not currently generating');
}
this.pendingPrompt.abort();
this.pendingPrompt = null;
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
this.pendingPrompt?.abort();
const pendingSend = new AbortController();
this.pendingPrompt = pendingSend;
// Increment turn counter for each user prompt
this.turn += 1;
const chat = this.chat;
const promptId = this.config.getSessionId() + '########' + this.turn;
// Extract text from all text blocks to construct the full prompt text for logging
const promptText = params.prompt
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join(' ');
// Log user prompt
logUserPrompt(
this.config,
new UserPromptEvent(
promptText.length,
promptId,
this.config.getContentGeneratorConfig()?.authType,
promptText,
),
);
// record user message for session management
this.config.getChatRecordingService()?.recordUserMessage(promptText);
// Check if the input contains a slash command
// Extract text from the first text block if present
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
const inputText = firstTextBlock?.text || '';
let parts: Part[];
if (isSlashCommand(inputText)) {
// Handle slash command - allow specific built-in commands for ACP integration
const slashCommandResult = await handleSlashCommand(
inputText,
pendingSend,
this.config,
this.settings,
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
);
if (slashCommandResult) {
// Use the result from the slash command
parts = slashCommandResult as Part[];
} else {
// Slash command didn't return a prompt, continue with normal processing
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
}
} else {
// Normal processing for non-slash commands
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
}
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {
if (pendingSend.signal.aborted) {
chat.addHistory(nextMessage);
return { stopReason: 'cancelled' };
}
const functionCalls: FunctionCall[] = [];
try {
const responseStream = await chat.sendMessageStream(
this.config.getModel(),
{
message: nextMessage?.parts ?? [],
config: {
abortSignal: pendingSend.signal,
},
},
promptId,
);
nextMessage = null;
for await (const resp of responseStream) {
if (pendingSend.signal.aborted) {
return { stopReason: 'cancelled' };
}
if (
resp.type === StreamEventType.CHUNK &&
resp.value.candidates &&
resp.value.candidates.length > 0
) {
const candidate = resp.value.candidates[0];
for (const part of candidate.content?.parts ?? []) {
if (!part.text) {
continue;
}
const content: acp.ContentBlock = {
type: 'text',
text: part.text,
};
this.sendUpdate({
sessionUpdate: part.thought
? 'agent_thought_chunk'
: 'agent_message_chunk',
content,
});
}
}
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
functionCalls.push(...resp.value.functionCalls);
}
}
} catch (error) {
if (getErrorStatus(error) === 429) {
throw new acp.RequestError(
429,
'Rate limit exceeded. Try again later.',
);
}
throw error;
}
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const response = await this.runTool(pendingSend.signal, promptId, fc);
toolResponseParts.push(...response);
}
nextMessage = { role: 'user', parts: toolResponseParts };
}
}
return { stopReason: 'end_turn' };
}
async sendUpdate(update: acp.SessionUpdate): Promise<void> {
const params: acp.SessionNotification = {
sessionId: this.sessionId,
update,
};
await this.client.sessionUpdate(params);
}
async sendAvailableCommandsUpdate(): Promise<void> {
const abortController = new AbortController();
try {
const slashCommands = await getAvailableCommands(
this.config,
this.settings,
abortController.signal,
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
);
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
const availableCommands: AvailableCommand[] = slashCommands.map(
(cmd) => ({
name: cmd.name,
description: cmd.description,
input: null,
}),
);
const update: AvailableCommandsUpdate = {
sessionUpdate: 'available_commands_update',
availableCommands,
};
await this.sendUpdate(update);
} catch (error) {
// Log error but don't fail session creation
console.error('Error sending available commands update:', error);
}
}
/**
* Requests permission from the client for a tool call.
* Used by SubAgentTracker for sub-agent approval requests.
*/
async requestPermission(
params: acp.RequestPermissionRequest,
): Promise<acp.RequestPermissionResponse> {
return this.client.requestPermission(params);
}
/**
* Sets the approval mode for the current session.
* Maps ACP approval mode values to core ApprovalMode enum.
*/
async setMode(params: SetModeRequest): Promise<SetModeResponse> {
const modeMap: Record<ApprovalModeValue, ApprovalMode> = {
plan: ApprovalMode.PLAN,
default: ApprovalMode.DEFAULT,
'auto-edit': ApprovalMode.AUTO_EDIT,
yolo: ApprovalMode.YOLO,
};
const approvalMode = modeMap[params.modeId];
this.config.setApprovalMode(approvalMode);
return { modeId: params.modeId };
}
/**
* Sends a current_mode_update notification to the client.
* Called after the agent switches modes (e.g., from exit_plan_mode tool).
*/
private async sendCurrentModeUpdateNotification(
outcome: ToolConfirmationOutcome,
): Promise<void> {
// Determine the new mode based on the approval outcome
// This mirrors the logic in ExitPlanModeTool.onConfirm
let newModeId: ApprovalModeValue;
switch (outcome) {
case ToolConfirmationOutcome.ProceedAlways:
newModeId = 'auto-edit';
break;
case ToolConfirmationOutcome.ProceedOnce:
default:
newModeId = 'default';
break;
}
const update: CurrentModeUpdate = {
sessionUpdate: 'current_mode_update',
modeId: newModeId,
};
await this.sendUpdate(update);
}
private async runTool(
abortSignal: AbortSignal,
promptId: string,
fc: FunctionCall,
): Promise<Part[]> {
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
const args = (fc.args ?? {}) as Record<string, unknown>;
const startTime = Date.now();
const errorResponse = (error: Error) => {
const durationMs = Date.now() - startTime;
logToolCall(this.config, {
'event.name': 'tool_call',
'event.timestamp': new Date().toISOString(),
prompt_id: promptId,
function_name: fc.name ?? '',
function_args: args,
duration_ms: durationMs,
status: 'error',
success: false,
error: error.message,
tool_type:
typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
? 'mcp'
: 'native',
});
return [
{
functionResponse: {
id: callId,
name: fc.name ?? '',
response: { error: error.message },
},
},
];
};
if (!fc.name) {
return errorResponse(new Error('Missing function name'));
}
const toolRegistry = this.config.getToolRegistry();
const tool = toolRegistry.getTool(fc.name as string);
if (!tool) {
return errorResponse(
new Error(`Tool "${fc.name}" not found in registry.`),
);
}
// Detect TodoWriteTool early - route to plan updates instead of tool_call events
const isTodoWriteTool = tool.name === TodoWriteTool.Name;
const isTaskTool = tool.name === TaskTool.Name;
const isExitPlanModeTool = tool.name === ExitPlanModeTool.Name;
// Track cleanup functions for sub-agent event listeners
let subAgentCleanupFunctions: Array<() => void> = [];
try {
const invocation = tool.build(args);
if (isTaskTool && 'eventEmitter' in invocation) {
// Access eventEmitter from TaskTool invocation
const taskEventEmitter = (
invocation as {
eventEmitter: SubAgentEventEmitter;
}
).eventEmitter;
// Create a SubAgentTracker for this tool execution
const subAgentTracker = new SubAgentTracker(this, this.client);
// Set up sub-agent tool tracking
subAgentCleanupFunctions = subAgentTracker.setup(
taskEventEmitter,
abortSignal,
);
}
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
if (confirmationDetails) {
const content: acp.ToolCallContent[] = [];
if (confirmationDetails.type === 'edit') {
content.push({
type: 'diff',
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
});
}
// Add plan content for exit_plan_mode
if (confirmationDetails.type === 'plan') {
content.push({
type: 'content',
content: {
type: 'text',
text: confirmationDetails.plan,
},
});
}
// Map tool kind, using switch_mode for exit_plan_mode per ACP spec
const mappedKind = this.toolCallEmitter.mapToolKind(tool.kind, fc.name);
const params: acp.RequestPermissionRequest = {
sessionId: this.sessionId,
options: toPermissionOptions(confirmationDetails),
toolCall: {
toolCallId: callId,
status: 'pending',
title: invocation.getDescription(),
content,
locations: invocation.toolLocations(),
kind: mappedKind,
},
};
const output = await this.client.requestPermission(params);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
await confirmationDetails.onConfirm(outcome);
// After exit_plan_mode confirmation, send current_mode_update notification
if (isExitPlanModeTool && outcome !== ToolConfirmationOutcome.Cancel) {
await this.sendCurrentModeUpdateNotification(outcome);
}
switch (outcome) {
case ToolConfirmationOutcome.Cancel:
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case ToolConfirmationOutcome.ProceedOnce:
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ModifyWithEditor:
break;
default: {
const resultOutcome: never = outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
} else if (!isTodoWriteTool) {
// Skip tool_call event for TodoWriteTool - use ToolCallEmitter
const startParams: ToolCallStartParams = {
callId,
toolName: fc.name,
args,
};
await this.toolCallEmitter.emitStart(startParams);
}
const toolResult: ToolResult = await invocation.execute(abortSignal);
// Clean up event listeners
subAgentCleanupFunctions.forEach((cleanup) => cleanup());
// Create response parts first (needed for emitResult and recordToolResult)
const responseParts = convertToFunctionResponse(
fc.name,
callId,
toolResult.llmContent,
);
// Handle TodoWriteTool: extract todos and send plan update
if (isTodoWriteTool) {
const todos = this.planEmitter.extractTodos(
toolResult.returnDisplay,
args,
);
// Match original logic: emit plan if todos.length > 0 OR if args had todos
if ((todos && todos.length > 0) || Array.isArray(args['todos'])) {
await this.planEmitter.emitPlan(todos ?? []);
}
// Skip tool_call_update event for TodoWriteTool
// Still log and return function response for LLM
} else {
// Normal tool handling: emit result using ToolCallEmitter
// Convert toolResult.error to Error type if present
const error = toolResult.error
? new Error(toolResult.error.message)
: undefined;
await this.toolCallEmitter.emitResult({
callId,
toolName: fc.name,
args,
message: responseParts,
resultDisplay: toolResult.returnDisplay,
error,
success: !toolResult.error,
});
}
const durationMs = Date.now() - startTime;
logToolCall(this.config, {
'event.name': 'tool_call',
'event.timestamp': new Date().toISOString(),
function_name: fc.name,
function_args: args,
duration_ms: durationMs,
status: 'success',
success: true,
prompt_id: promptId,
tool_type:
typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
? 'mcp'
: 'native',
});
// Record tool result for session management
this.config.getChatRecordingService()?.recordToolResult(responseParts, {
callId,
status: 'success',
resultDisplay: toolResult.returnDisplay,
error: undefined,
errorType: undefined,
});
return responseParts;
} catch (e) {
// Ensure cleanup on error
subAgentCleanupFunctions.forEach((cleanup) => cleanup());
const error = e instanceof Error ? e : new Error(String(e));
// Use ToolCallEmitter for error handling
await this.toolCallEmitter.emitError(callId, error);
// Record tool error for session management
const errorParts = [
{
functionResponse: {
id: callId,
name: fc.name ?? '',
response: { error: error.message },
},
},
];
this.config.getChatRecordingService()?.recordToolResult(errorParts, {
callId,
status: 'error',
resultDisplay: undefined,
error,
errorType: undefined,
});
return errorResponse(error);
}
}
async #resolvePrompt(
message: acp.ContentBlock[],
abortSignal: AbortSignal,
): Promise<Part[]> {
const FILE_URI_SCHEME = 'file://';
const embeddedContext: acp.EmbeddedResourceResource[] = [];
const parts = message.map((part) => {
switch (part.type) {
case 'text':
return { text: part.text };
case 'image':
case 'audio':
return {
inlineData: {
mimeType: part.mimeType,
data: part.data,
},
};
case 'resource_link': {
if (part.uri.startsWith(FILE_URI_SCHEME)) {
return {
fileData: {
mimeData: part.mimeType,
name: part.name,
fileUri: part.uri.slice(FILE_URI_SCHEME.length),
},
};
} else {
return { text: `@${part.uri}` };
}
}
case 'resource': {
embeddedContext.push(part.resource);
return { text: `@${part.resource.uri}` };
}
default: {
const unreachable: never = part;
throw new Error(`Unexpected chunk type: '${unreachable}'`);
}
}
});
const atPathCommandParts = parts.filter((part) => 'fileData' in part);
if (atPathCommandParts.length === 0 && embeddedContext.length === 0) {
return parts;
}
const atPathToResolvedSpecMap = new Map<string, string>();
// Get centralized file discovery service
const fileDiscovery = this.config.getFileService();
const respectGitIgnore = this.config.getFileFilteringRespectGitIgnore();
const pathSpecsToRead: string[] = [];
const contentLabelsForDisplay: string[] = [];
const ignoredPaths: string[] = [];
const toolRegistry = this.config.getToolRegistry();
const readManyFilesTool = toolRegistry.getTool('read_many_files');
const globTool = toolRegistry.getTool('glob');
if (!readManyFilesTool) {
throw new Error('Error: read_many_files tool not found.');
}
for (const atPathPart of atPathCommandParts) {
const pathName = atPathPart.fileData!.fileUri;
// Check if path should be ignored by git
if (fileDiscovery.shouldGitIgnoreFile(pathName)) {
ignoredPaths.push(pathName);
const reason = respectGitIgnore
? 'git-ignored and will be skipped'
: 'ignored by custom patterns';
console.warn(`Path ${pathName} is ${reason}.`);
continue;
}
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
try {
const absolutePath = path.resolve(this.config.getTargetDir(), pathName);
if (isWithinRoot(absolutePath, this.config.getTargetDir())) {
const stats = await fs.stat(absolutePath);
if (stats.isDirectory()) {
currentPathSpec = pathName.endsWith('/')
? `${pathName}**`
: `${pathName}/**`;
this.debug(
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
);
} else {
this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`);
}
resolvedSuccessfully = true;
} else {
this.debug(
`Path ${pathName} is outside the project directory. Skipping.`,
);
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
if (this.config.getEnableRecursiveFileSearch() && globTool) {
this.debug(
`Path ${pathName} not found directly, attempting glob search.`,
);
try {
const globResult = await globTool.buildAndExecute(
{
pattern: `**/*${pathName}*`,
path: this.config.getTargetDir(),
},
abortSignal,
);
if (
globResult.llmContent &&
typeof globResult.llmContent === 'string' &&
!globResult.llmContent.startsWith('No files found') &&
!globResult.llmContent.startsWith('Error:')
) {
const lines = globResult.llmContent.split('\n');
if (lines.length > 1 && lines[1]) {
const firstMatchAbsolute = lines[1].trim();
currentPathSpec = path.relative(
this.config.getTargetDir(),
firstMatchAbsolute,
);
this.debug(
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
);
resolvedSuccessfully = true;
} else {
this.debug(
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
);
}
} else {
this.debug(
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
);
}
} catch (globError) {
console.error(
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
);
}
} else {
this.debug(
`Glob tool not found. Path ${pathName} will be skipped.`,
);
}
} else {
console.error(
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
);
}
}
if (resolvedSuccessfully) {
pathSpecsToRead.push(currentPathSpec);
atPathToResolvedSpecMap.set(pathName, currentPathSpec);
contentLabelsForDisplay.push(pathName);
}
}
// Construct the initial part of the query for the LLM
let initialQueryText = '';
for (let i = 0; i < parts.length; i++) {
const chunk = parts[i];
if ('text' in chunk) {
initialQueryText += chunk.text;
} else {
// type === 'atPath'
const resolvedSpec =
chunk.fileData && atPathToResolvedSpecMap.get(chunk.fileData.fileUri);
if (
i > 0 &&
initialQueryText.length > 0 &&
!initialQueryText.endsWith(' ') &&
resolvedSpec
) {
// Add space if previous part was text and didn't end with space, or if previous was @path
const prevPart = parts[i - 1];
if (
'text' in prevPart ||
('fileData' in prevPart &&
atPathToResolvedSpecMap.has(prevPart.fileData!.fileUri))
) {
initialQueryText += ' ';
}
}
// Append the resolved path spec for display purposes
if (resolvedSpec) {
initialQueryText += `@${resolvedSpec}`;
}
}
}
// Handle ignored paths message
let ignoredPathsMessage = '';
if (ignoredPaths.length > 0) {
const pathList = ignoredPaths.map((p) => `- ${p}`).join('\n');
ignoredPathsMessage = `Note: The following paths were skipped because they are ignored:\n${pathList}\n\n`;
}
const processedQueryParts: Part[] = [];
// Read files using read_many_files tool
if (pathSpecsToRead.length > 0) {
const readResult = await readManyFilesTool.buildAndExecute(
{
paths_with_line_ranges: pathSpecsToRead,
},
abortSignal,
);
const contentForLlm =
typeof readResult.llmContent === 'string'
? readResult.llmContent
: JSON.stringify(readResult.llmContent);
// Combine content label, ignored paths message, file content, and user query
const combinedText = `${ignoredPathsMessage}${contentForLlm}`.trim();
processedQueryParts.push({ text: combinedText });
processedQueryParts.push({ text: initialQueryText });
} else if (embeddedContext.length > 0) {
// No @path files to read, but we have embedded context
processedQueryParts.push({
text: `${ignoredPathsMessage}${initialQueryText}`.trim(),
});
} else {
// No @path files found or resolved
processedQueryParts.push({
text: `${ignoredPathsMessage}${initialQueryText}`.trim(),
});
}
// Process embedded context from resource blocks
for (const contextPart of embeddedContext) {
// Type guard for text resources
if ('text' in contextPart && contextPart.text) {
processedQueryParts.push({
text: `File: ${contextPart.uri}\n${contextPart.text}`,
});
}
// Type guard for blob resources
if ('blob' in contextPart && contextPart.blob) {
processedQueryParts.push({
inlineData: {
mimeType: contextPart.mimeType ?? 'application/octet-stream',
data: contextPart.blob,
},
});
}
}
return processedQueryParts;
}
debug(msg: string): void {
if (this.config.getDebugMode()) {
console.warn(msg);
}
}
}
// ============================================================================
// Helper functions
// ============================================================================
const basicPermissionOptions = [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Reject',
kind: 'reject_once',
},
] as const;
function toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
): acp.PermissionOption[] {
switch (confirmation.type) {
case 'edit':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits',
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'exec':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${confirmation.rootCommand}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${confirmation.serverName}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${confirmation.toolName}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'plan':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Yes, and auto-accept edits`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: `Yes, and manually approve edits`,
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: `No, keep planning (esc)`,
kind: 'reject_once',
},
];
default: {
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);
}
}
}

View File

@@ -0,0 +1,525 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SubAgentTracker } from './SubAgentTracker.js';
import type { SessionContext } from './types.js';
import type {
Config,
ToolRegistry,
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
ToolEditConfirmationDetails,
ToolInfoConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import {
SubAgentEventType,
ToolConfirmationOutcome,
TodoWriteTool,
} from '@qwen-code/qwen-code-core';
import type * as acp from '../acp.js';
import { EventEmitter } from 'node:events';
// Helper to create a mock SubAgentToolCallEvent with required fields
function createToolCallEvent(
overrides: Partial<SubAgentToolCallEvent> & { name: string; callId: string },
): SubAgentToolCallEvent {
return {
subagentId: 'test-subagent',
round: 1,
timestamp: Date.now(),
description: `Calling ${overrides.name}`,
args: {},
...overrides,
};
}
// Helper to create a mock SubAgentToolResultEvent with required fields
function createToolResultEvent(
overrides: Partial<SubAgentToolResultEvent> & {
name: string;
callId: string;
success: boolean;
},
): SubAgentToolResultEvent {
return {
subagentId: 'test-subagent',
round: 1,
timestamp: Date.now(),
...overrides,
};
}
// Helper to create a mock SubAgentApprovalRequestEvent with required fields
function createApprovalEvent(
overrides: Partial<SubAgentApprovalRequestEvent> & {
name: string;
callId: string;
confirmationDetails: SubAgentApprovalRequestEvent['confirmationDetails'];
respond: SubAgentApprovalRequestEvent['respond'];
},
): SubAgentApprovalRequestEvent {
return {
subagentId: 'test-subagent',
round: 1,
timestamp: Date.now(),
description: `Awaiting approval for ${overrides.name}`,
...overrides,
};
}
// Helper to create edit confirmation details
function createEditConfirmation(
overrides: Partial<Omit<ToolEditConfirmationDetails, 'onConfirm' | 'type'>>,
): Omit<ToolEditConfirmationDetails, 'onConfirm'> {
return {
type: 'edit',
title: 'Edit file',
fileName: '/test.ts',
filePath: '/test.ts',
fileDiff: '',
originalContent: '',
newContent: '',
...overrides,
};
}
// Helper to create info confirmation details
function createInfoConfirmation(
overrides?: Partial<Omit<ToolInfoConfirmationDetails, 'onConfirm' | 'type'>>,
): Omit<ToolInfoConfirmationDetails, 'onConfirm'> {
return {
type: 'info',
title: 'Tool requires approval',
prompt: 'Allow this action?',
...overrides,
};
}
describe('SubAgentTracker', () => {
let mockContext: SessionContext;
let mockClient: acp.Client;
let sendUpdateSpy: ReturnType<typeof vi.fn>;
let requestPermissionSpy: ReturnType<typeof vi.fn>;
let tracker: SubAgentTracker;
let eventEmitter: SubAgentEventEmitter;
let abortController: AbortController;
beforeEach(() => {
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
requestPermissionSpy = vi.fn().mockResolvedValue({
outcome: { optionId: ToolConfirmationOutcome.ProceedOnce },
});
const mockToolRegistry = {
getTool: vi.fn().mockReturnValue(null),
} as unknown as ToolRegistry;
mockContext = {
sessionId: 'test-session-id',
config: {
getToolRegistry: () => mockToolRegistry,
} as unknown as Config,
sendUpdate: sendUpdateSpy,
};
mockClient = {
requestPermission: requestPermissionSpy,
} as unknown as acp.Client;
tracker = new SubAgentTracker(mockContext, mockClient);
eventEmitter = new EventEmitter() as unknown as SubAgentEventEmitter;
abortController = new AbortController();
});
describe('setup', () => {
it('should return cleanup function', () => {
const cleanups = tracker.setup(eventEmitter, abortController.signal);
expect(cleanups).toHaveLength(1);
expect(typeof cleanups[0]).toBe('function');
});
it('should register event listeners', () => {
const onSpy = vi.spyOn(eventEmitter, 'on');
tracker.setup(eventEmitter, abortController.signal);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_CALL,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_RESULT,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
});
it('should remove event listeners on cleanup', () => {
const offSpy = vi.spyOn(eventEmitter, 'off');
const cleanups = tracker.setup(eventEmitter, abortController.signal);
cleanups[0]();
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_CALL,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_RESULT,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
});
});
describe('tool call handling', () => {
it('should emit tool_call on TOOL_CALL event', async () => {
tracker.setup(eventEmitter, abortController.signal);
const event = createToolCallEvent({
name: 'read_file',
callId: 'call-123',
args: { path: '/test.ts' },
description: 'Reading file',
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
// Allow async operations to complete
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
});
// ToolCallEmitter resolves metadata from registry - uses toolName when tool not found
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-123',
status: 'in_progress',
title: 'read_file',
content: [],
locations: [],
kind: 'other',
rawInput: { path: '/test.ts' },
}),
);
});
it('should skip tool_call for TodoWriteTool', async () => {
tracker.setup(eventEmitter, abortController.signal);
const event = createToolCallEvent({
name: TodoWriteTool.Name,
callId: 'call-todo',
args: { todos: [] },
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
// Give time for any async operation
await new Promise((resolve) => setTimeout(resolve, 10));
expect(sendUpdateSpy).not.toHaveBeenCalled();
});
it('should not emit when aborted', async () => {
tracker.setup(eventEmitter, abortController.signal);
abortController.abort();
const event = createToolCallEvent({
name: 'read_file',
callId: 'call-123',
args: {},
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(sendUpdateSpy).not.toHaveBeenCalled();
});
});
describe('tool result handling', () => {
it('should emit tool_call_update on TOOL_RESULT event', async () => {
tracker.setup(eventEmitter, abortController.signal);
// First emit tool call to store state
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
createToolCallEvent({
name: 'read_file',
callId: 'call-123',
args: { path: '/test.ts' },
}),
);
// Then emit result
const resultEvent = createToolResultEvent({
name: 'read_file',
callId: 'call-123',
success: true,
resultDisplay: 'File contents',
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-123',
status: 'completed',
}),
);
});
});
it('should emit failed status on unsuccessful result', async () => {
tracker.setup(eventEmitter, abortController.signal);
const resultEvent = createToolResultEvent({
name: 'read_file',
callId: 'call-fail',
success: false,
resultDisplay: undefined,
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'failed',
}),
);
});
});
it('should emit plan update for TodoWriteTool results', async () => {
tracker.setup(eventEmitter, abortController.signal);
// Store args via tool call
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
createToolCallEvent({
name: TodoWriteTool.Name,
callId: 'call-todo',
args: {
todos: [{ id: '1', content: 'Task 1', status: 'pending' }],
},
}),
);
// Emit result with todo_list display
const resultEvent = createToolResultEvent({
name: TodoWriteTool.Name,
callId: 'call-todo',
success: true,
resultDisplay: JSON.stringify({
type: 'todo_list',
todos: [{ id: '1', content: 'Task 1', status: 'completed' }],
}),
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'plan',
entries: [
{ content: 'Task 1', priority: 'medium', status: 'completed' },
],
});
});
});
it('should clean up state after result', async () => {
tracker.setup(eventEmitter, abortController.signal);
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
createToolCallEvent({
name: 'test_tool',
callId: 'call-cleanup',
args: { test: true },
}),
);
eventEmitter.emit(
SubAgentEventType.TOOL_RESULT,
createToolResultEvent({
name: 'test_tool',
callId: 'call-cleanup',
success: true,
}),
);
// Emit another result for same callId - should not have stored args
sendUpdateSpy.mockClear();
eventEmitter.emit(
SubAgentEventType.TOOL_RESULT,
createToolResultEvent({
name: 'test_tool',
callId: 'call-cleanup',
success: true,
}),
);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
});
// Second call should not have args from first call
// (state was cleaned up)
});
});
describe('approval handling', () => {
it('should request permission from client', async () => {
tracker.setup(eventEmitter, abortController.signal);
const respondSpy = vi.fn().mockResolvedValue(undefined);
const event = createApprovalEvent({
name: 'edit_file',
callId: 'call-edit',
description: 'Editing file',
confirmationDetails: createEditConfirmation({
fileName: '/test.ts',
originalContent: 'old',
newContent: 'new',
}),
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(requestPermissionSpy).toHaveBeenCalled();
});
expect(requestPermissionSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'test-session-id',
toolCall: expect.objectContaining({
toolCallId: 'call-edit',
status: 'pending',
content: [
{
type: 'diff',
path: '/test.ts',
oldText: 'old',
newText: 'new',
},
],
}),
}),
);
});
it('should respond to subagent with permission outcome', async () => {
tracker.setup(eventEmitter, abortController.signal);
const respondSpy = vi.fn().mockResolvedValue(undefined);
const event = createApprovalEvent({
name: 'test_tool',
callId: 'call-123',
confirmationDetails: createInfoConfirmation(),
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
});
});
it('should cancel on permission request failure', async () => {
requestPermissionSpy.mockRejectedValue(new Error('Network error'));
tracker.setup(eventEmitter, abortController.signal);
const respondSpy = vi.fn().mockResolvedValue(undefined);
const event = createApprovalEvent({
name: 'test_tool',
callId: 'call-123',
confirmationDetails: createInfoConfirmation(),
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
});
});
it('should handle cancelled outcome from client', async () => {
requestPermissionSpy.mockResolvedValue({
outcome: { outcome: 'cancelled' },
});
tracker.setup(eventEmitter, abortController.signal);
const respondSpy = vi.fn().mockResolvedValue(undefined);
const event = createApprovalEvent({
name: 'test_tool',
callId: 'call-123',
confirmationDetails: createInfoConfirmation(),
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
});
});
});
describe('permission options', () => {
it('should include "Allow All Edits" for edit type', async () => {
tracker.setup(eventEmitter, abortController.signal);
const event = createApprovalEvent({
name: 'edit_file',
callId: 'call-123',
confirmationDetails: createEditConfirmation({
fileName: '/test.ts',
originalContent: '',
newContent: 'new',
}),
respond: vi.fn(),
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(requestPermissionSpy).toHaveBeenCalled();
});
const call = requestPermissionSpy.mock.calls[0][0];
expect(call.options).toContainEqual(
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits',
}),
);
});
});
});

View File

@@ -0,0 +1,318 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
ToolCallConfirmationDetails,
AnyDeclarativeTool,
AnyToolInvocation,
} from '@qwen-code/qwen-code-core';
import {
SubAgentEventType,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
import { z } from 'zod';
import type { SessionContext } from './types.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
import type * as acp from '../acp.js';
/**
* Permission option kind type matching ACP schema.
*/
type PermissionKind =
| 'allow_once'
| 'reject_once'
| 'allow_always'
| 'reject_always';
/**
* Configuration for permission options displayed to users.
*/
interface PermissionOptionConfig {
optionId: ToolConfirmationOutcome;
name: string;
kind: PermissionKind;
}
const basicPermissionOptions: readonly PermissionOptionConfig[] = [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Reject',
kind: 'reject_once',
},
] as const;
/**
* Tracks and emits events for sub-agent tool calls within TaskTool execution.
*
* Uses the unified ToolCallEmitter for consistency with normal flow
* and history replay. Also handles permission requests for tools that
* require user approval.
*/
export class SubAgentTracker {
private readonly toolCallEmitter: ToolCallEmitter;
private readonly toolStates = new Map<
string,
{
tool?: AnyDeclarativeTool;
invocation?: AnyToolInvocation;
args?: Record<string, unknown>;
}
>();
constructor(
private readonly ctx: SessionContext,
private readonly client: acp.Client,
) {
this.toolCallEmitter = new ToolCallEmitter(ctx);
}
/**
* Sets up event listeners for a sub-agent's tool events.
*
* @param eventEmitter - The SubAgentEventEmitter from TaskTool
* @param abortSignal - Signal to abort tracking if parent is cancelled
* @returns Array of cleanup functions to remove listeners
*/
setup(
eventEmitter: SubAgentEventEmitter,
abortSignal: AbortSignal,
): Array<() => void> {
const onToolCall = this.createToolCallHandler(abortSignal);
const onToolResult = this.createToolResultHandler(abortSignal);
const onApproval = this.createApprovalHandler(abortSignal);
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
return [
() => {
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
// Clean up any remaining states
this.toolStates.clear();
},
];
}
/**
* Creates a handler for tool call start events.
*/
private createToolCallHandler(
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentToolCallEvent;
if (abortSignal.aborted) return;
// Look up tool and build invocation for metadata
const toolRegistry = this.ctx.config.getToolRegistry();
const tool = toolRegistry.getTool(event.name);
let invocation: AnyToolInvocation | undefined;
if (tool) {
try {
invocation = tool.build(event.args);
} catch (e) {
// If building fails, continue with defaults
console.warn(`Failed to build subagent tool ${event.name}:`, e);
}
}
// Store tool, invocation, and args for result handling
this.toolStates.set(event.callId, {
tool,
invocation,
args: event.args,
});
// Use unified emitter - handles TodoWriteTool skipping internally
void this.toolCallEmitter.emitStart({
toolName: event.name,
callId: event.callId,
args: event.args,
});
};
}
/**
* Creates a handler for tool result events.
*/
private createToolResultHandler(
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentToolResultEvent;
if (abortSignal.aborted) return;
const state = this.toolStates.get(event.callId);
// Use unified emitter - handles TodoWriteTool plan updates internally
void this.toolCallEmitter.emitResult({
toolName: event.name,
callId: event.callId,
success: event.success,
message: event.responseParts ?? [],
resultDisplay: event.resultDisplay,
args: state?.args,
});
// Clean up state
this.toolStates.delete(event.callId);
};
}
/**
* Creates a handler for tool approval request events.
*/
private createApprovalHandler(
abortSignal: AbortSignal,
): (...args: unknown[]) => Promise<void> {
return async (...args: unknown[]) => {
const event = args[0] as SubAgentApprovalRequestEvent;
if (abortSignal.aborted) return;
const state = this.toolStates.get(event.callId);
const content: acp.ToolCallContent[] = [];
// Handle edit confirmation type - show diff
if (event.confirmationDetails.type === 'edit') {
const editDetails = event.confirmationDetails as unknown as {
type: 'edit';
fileName: string;
originalContent: string | null;
newContent: string;
};
content.push({
type: 'diff',
path: editDetails.fileName,
oldText: editDetails.originalContent ?? '',
newText: editDetails.newContent,
});
}
// Build permission request
const fullConfirmationDetails = {
...event.confirmationDetails,
onConfirm: async () => {
// Placeholder - actual response handled via event.respond
},
} as unknown as ToolCallConfirmationDetails;
const { title, locations, kind } =
this.toolCallEmitter.resolveToolMetadata(event.name, state?.args);
const params: acp.RequestPermissionRequest = {
sessionId: this.ctx.sessionId,
options: this.toPermissionOptions(fullConfirmationDetails),
toolCall: {
toolCallId: event.callId,
status: 'pending',
title,
content,
locations,
kind,
rawInput: state?.args,
},
};
try {
// Request permission from client
const output = await this.client.requestPermission(params);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
// Respond to subagent with the outcome
await event.respond(outcome);
} catch (error) {
// If permission request fails, cancel the tool call
console.error(
`Permission request failed for subagent tool ${event.name}:`,
error,
);
await event.respond(ToolConfirmationOutcome.Cancel);
}
};
}
/**
* Converts confirmation details to permission options for the client.
*/
private toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
): acp.PermissionOption[] {
switch (confirmation.type) {
case 'edit':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits',
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'exec':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${(confirmation as { serverName?: string }).serverName ?? 'server'}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Always Allow',
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'plan':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Always Allow Plans',
kind: 'allow_always',
},
...basicPermissionOptions,
];
default: {
// Fallback for unknown types
return [...basicPermissionOptions];
}
}
}
}

View File

@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { SessionContext } from '../types.js';
import type * as acp from '../../acp.js';
/**
* Abstract base class for all session event emitters.
* Provides common functionality and access to session context.
*/
export abstract class BaseEmitter {
constructor(protected readonly ctx: SessionContext) {}
/**
* Sends a session update to the ACP client.
*/
protected async sendUpdate(update: acp.SessionUpdate): Promise<void> {
return this.ctx.sendUpdate(update);
}
/**
* Gets the session configuration.
*/
protected get config() {
return this.ctx.config;
}
/**
* Gets the session ID.
*/
protected get sessionId() {
return this.ctx.sessionId;
}
}

View File

@@ -0,0 +1,151 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MessageEmitter } from './MessageEmitter.js';
import type { SessionContext } from '../types.js';
import type { Config } from '@qwen-code/qwen-code-core';
describe('MessageEmitter', () => {
let mockContext: SessionContext;
let sendUpdateSpy: ReturnType<typeof vi.fn>;
let emitter: MessageEmitter;
beforeEach(() => {
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
mockContext = {
sessionId: 'test-session-id',
config: {} as Config,
sendUpdate: sendUpdateSpy,
};
emitter = new MessageEmitter(mockContext);
});
describe('emitUserMessage', () => {
it('should send user_message_chunk update with text content', async () => {
await emitter.emitUserMessage('Hello, world!');
expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'Hello, world!' },
});
});
it('should handle empty text', async () => {
await emitter.emitUserMessage('');
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: '' },
});
});
it('should handle multiline text', async () => {
const multilineText = 'Line 1\nLine 2\nLine 3';
await emitter.emitUserMessage(multilineText);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: multilineText },
});
});
});
describe('emitAgentMessage', () => {
it('should send agent_message_chunk update with text content', async () => {
await emitter.emitAgentMessage('I can help you with that.');
expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'I can help you with that.' },
});
});
});
describe('emitAgentThought', () => {
it('should send agent_thought_chunk update with text content', async () => {
await emitter.emitAgentThought('Let me think about this...');
expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'Let me think about this...' },
});
});
});
describe('emitMessage', () => {
it('should emit user message when role is user', async () => {
await emitter.emitMessage('User input', 'user');
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'User input' },
});
});
it('should emit agent message when role is assistant and isThought is false', async () => {
await emitter.emitMessage('Agent response', 'assistant', false);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Agent response' },
});
});
it('should emit agent message when role is assistant and isThought is not provided', async () => {
await emitter.emitMessage('Agent response', 'assistant');
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Agent response' },
});
});
it('should emit agent thought when role is assistant and isThought is true', async () => {
await emitter.emitAgentThought('Thinking...');
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'Thinking...' },
});
});
it('should ignore isThought when role is user', async () => {
// Even if isThought is true, user messages should still be user_message_chunk
await emitter.emitMessage('User input', 'user', true);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'User input' },
});
});
});
describe('multiple emissions', () => {
it('should handle multiple sequential emissions', async () => {
await emitter.emitUserMessage('First');
await emitter.emitAgentMessage('Second');
await emitter.emitAgentThought('Third');
expect(sendUpdateSpy).toHaveBeenCalledTimes(3);
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'First' },
});
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Second' },
});
expect(sendUpdateSpy).toHaveBeenNthCalledWith(3, {
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'Third' },
});
});
});
});

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { BaseEmitter } from './BaseEmitter.js';
/**
* Handles emission of text message chunks (user, agent, thought).
*
* This emitter is responsible for sending message content to the ACP client
* in a consistent format, regardless of whether the message comes from
* normal flow, history replay, or other sources.
*/
export class MessageEmitter extends BaseEmitter {
/**
* Emits a user message chunk.
*/
async emitUserMessage(text: string): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text },
});
}
/**
* Emits an agent message chunk.
*/
async emitAgentMessage(text: string): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
});
}
/**
* Emits an agent thought chunk.
*/
async emitAgentThought(text: string): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text },
});
}
/**
* Emits a message chunk based on role and thought flag.
* This is the unified method that handles all message types.
*
* @param text - The message text content
* @param role - Whether this is a user or assistant message
* @param isThought - Whether this is an assistant thought (only applies to assistant role)
*/
async emitMessage(
text: string,
role: 'user' | 'assistant',
isThought: boolean = false,
): Promise<void> {
if (role === 'user') {
return this.emitUserMessage(text);
}
return isThought
? this.emitAgentThought(text)
: this.emitAgentMessage(text);
}
}

View File

@@ -0,0 +1,228 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PlanEmitter } from './PlanEmitter.js';
import type { SessionContext, TodoItem } from '../types.js';
import type { Config } from '@qwen-code/qwen-code-core';
describe('PlanEmitter', () => {
let mockContext: SessionContext;
let sendUpdateSpy: ReturnType<typeof vi.fn>;
let emitter: PlanEmitter;
beforeEach(() => {
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
mockContext = {
sessionId: 'test-session-id',
config: {} as Config,
sendUpdate: sendUpdateSpy,
};
emitter = new PlanEmitter(mockContext);
});
describe('emitPlan', () => {
it('should send plan update with converted todo entries', async () => {
const todos: TodoItem[] = [
{ id: '1', content: 'First task', status: 'pending' },
{ id: '2', content: 'Second task', status: 'in_progress' },
{ id: '3', content: 'Third task', status: 'completed' },
];
await emitter.emitPlan(todos);
expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'plan',
entries: [
{ content: 'First task', priority: 'medium', status: 'pending' },
{ content: 'Second task', priority: 'medium', status: 'in_progress' },
{ content: 'Third task', priority: 'medium', status: 'completed' },
],
});
});
it('should handle empty todos array', async () => {
await emitter.emitPlan([]);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'plan',
entries: [],
});
});
it('should set default priority to medium for all entries', async () => {
const todos: TodoItem[] = [
{ id: '1', content: 'Task', status: 'pending' },
];
await emitter.emitPlan(todos);
const call = sendUpdateSpy.mock.calls[0][0];
expect(call.entries[0].priority).toBe('medium');
});
});
describe('extractTodos', () => {
describe('from resultDisplay object', () => {
it('should extract todos from valid todo_list object', () => {
const resultDisplay = {
type: 'todo_list',
todos: [
{ id: '1', content: 'Task 1', status: 'pending' as const },
{ id: '2', content: 'Task 2', status: 'completed' as const },
],
};
const result = emitter.extractTodos(resultDisplay);
expect(result).toEqual([
{ id: '1', content: 'Task 1', status: 'pending' },
{ id: '2', content: 'Task 2', status: 'completed' },
]);
});
it('should return null for object without type todo_list', () => {
const resultDisplay = {
type: 'other',
todos: [],
};
const result = emitter.extractTodos(resultDisplay);
expect(result).toBeNull();
});
it('should return null for object without todos array', () => {
const resultDisplay = {
type: 'todo_list',
items: [], // wrong key
};
const result = emitter.extractTodos(resultDisplay);
expect(result).toBeNull();
});
});
describe('from resultDisplay JSON string', () => {
it('should extract todos from valid JSON string', () => {
const resultDisplay = JSON.stringify({
type: 'todo_list',
todos: [{ id: '1', content: 'Task', status: 'pending' }],
});
const result = emitter.extractTodos(resultDisplay);
expect(result).toEqual([
{ id: '1', content: 'Task', status: 'pending' },
]);
});
it('should return null for invalid JSON string', () => {
const resultDisplay = 'not valid json';
const result = emitter.extractTodos(resultDisplay);
expect(result).toBeNull();
});
it('should return null for JSON without todo_list type', () => {
const resultDisplay = JSON.stringify({
type: 'other',
data: {},
});
const result = emitter.extractTodos(resultDisplay);
expect(result).toBeNull();
});
});
describe('from args fallback', () => {
it('should extract todos from args when resultDisplay is null', () => {
const args = {
todos: [{ id: '1', content: 'From args', status: 'pending' }],
};
const result = emitter.extractTodos(null, args);
expect(result).toEqual([
{ id: '1', content: 'From args', status: 'pending' },
]);
});
it('should extract todos from args when resultDisplay is undefined', () => {
const args = {
todos: [{ id: '1', content: 'From args', status: 'pending' }],
};
const result = emitter.extractTodos(undefined, args);
expect(result).toEqual([
{ id: '1', content: 'From args', status: 'pending' },
]);
});
it('should prefer resultDisplay over args', () => {
const resultDisplay = {
type: 'todo_list',
todos: [{ id: '1', content: 'From display', status: 'completed' }],
};
const args = {
todos: [{ id: '2', content: 'From args', status: 'pending' }],
};
const result = emitter.extractTodos(resultDisplay, args);
expect(result).toEqual([
{ id: '1', content: 'From display', status: 'completed' },
]);
});
it('should return null when args has no todos array', () => {
const args = { other: 'value' };
const result = emitter.extractTodos(null, args);
expect(result).toBeNull();
});
it('should return null when args.todos is not an array', () => {
const args = { todos: 'not an array' };
const result = emitter.extractTodos(null, args);
expect(result).toBeNull();
});
});
describe('edge cases', () => {
it('should return null when both resultDisplay and args are undefined', () => {
const result = emitter.extractTodos(undefined, undefined);
expect(result).toBeNull();
});
it('should return null when resultDisplay is empty object', () => {
const result = emitter.extractTodos({});
expect(result).toBeNull();
});
it('should handle resultDisplay with todos but wrong type', () => {
const resultDisplay = {
type: 'not_todo_list',
todos: [{ id: '1', content: 'Task', status: 'pending' }],
};
const result = emitter.extractTodos(resultDisplay);
expect(result).toBeNull();
});
});
});
});

View File

@@ -0,0 +1,96 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { BaseEmitter } from './BaseEmitter.js';
import type { TodoItem } from '../types.js';
import type * as acp from '../../acp.js';
/**
* Handles emission of plan/todo updates.
*
* This emitter is responsible for converting todo items to ACP plan entries
* and sending plan updates to the client. It also provides utilities for
* extracting todos from various sources (tool result displays, args, etc.).
*/
export class PlanEmitter extends BaseEmitter {
/**
* Emits a plan update with the given todo items.
*
* @param todos - Array of todo items to send as plan entries
*/
async emitPlan(todos: TodoItem[]): Promise<void> {
const entries: acp.PlanEntry[] = todos.map((todo) => ({
content: todo.content,
priority: 'medium' as const, // Default priority since todos don't have priority
status: todo.status,
}));
await this.sendUpdate({
sessionUpdate: 'plan',
entries,
});
}
/**
* Extracts todos from tool result display or args.
* Tries multiple sources in priority order:
* 1. Result display object with type 'todo_list'
* 2. Result display as JSON string
* 3. Args with 'todos' array
*
* @param resultDisplay - The tool result display (object, string, or undefined)
* @param args - The tool call arguments (fallback source)
* @returns Array of todos if found, null otherwise
*/
extractTodos(
resultDisplay: unknown,
args?: Record<string, unknown>,
): TodoItem[] | null {
// Try resultDisplay first (final state from tool execution)
const fromDisplay = this.extractFromResultDisplay(resultDisplay);
if (fromDisplay) return fromDisplay;
// Fallback to args (initial state)
if (args && Array.isArray(args['todos'])) {
return args['todos'] as TodoItem[];
}
return null;
}
/**
* Extracts todos from a result display value.
* Handles both object and JSON string formats.
*/
private extractFromResultDisplay(resultDisplay: unknown): TodoItem[] | null {
if (!resultDisplay) return null;
// Handle direct object with type 'todo_list'
if (typeof resultDisplay === 'object') {
const obj = resultDisplay as Record<string, unknown>;
if (obj['type'] === 'todo_list' && Array.isArray(obj['todos'])) {
return obj['todos'] as TodoItem[];
}
}
// Handle JSON string (from subagent events)
if (typeof resultDisplay === 'string') {
try {
const parsed = JSON.parse(resultDisplay) as Record<string, unknown>;
if (
parsed?.['type'] === 'todo_list' &&
Array.isArray(parsed['todos'])
) {
return parsed['todos'] as TodoItem[];
}
} catch {
// Not JSON, ignore
}
}
return null;
}
}

View File

@@ -0,0 +1,662 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ToolCallEmitter } from './ToolCallEmitter.js';
import type { SessionContext } from '../types.js';
import type {
Config,
ToolRegistry,
AnyDeclarativeTool,
AnyToolInvocation,
} from '@qwen-code/qwen-code-core';
import { Kind, TodoWriteTool } from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
// Helper to create mock message parts for tests
const createMockMessage = (text?: string): Part[] =>
text
? [{ functionResponse: { name: 'test', response: { output: text } } }]
: [];
describe('ToolCallEmitter', () => {
let mockContext: SessionContext;
let sendUpdateSpy: ReturnType<typeof vi.fn>;
let mockToolRegistry: ToolRegistry;
let emitter: ToolCallEmitter;
// Helper to create mock tool
const createMockTool = (
overrides: Partial<AnyDeclarativeTool> = {},
): AnyDeclarativeTool =>
({
name: 'test_tool',
kind: Kind.Other,
build: vi.fn().mockReturnValue({
getDescription: () => 'Test tool description',
toolLocations: () => [{ path: '/test/file.ts', line: 10 }],
} as unknown as AnyToolInvocation),
...overrides,
}) as unknown as AnyDeclarativeTool;
beforeEach(() => {
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
mockToolRegistry = {
getTool: vi.fn().mockReturnValue(null),
} as unknown as ToolRegistry;
mockContext = {
sessionId: 'test-session-id',
config: {
getToolRegistry: () => mockToolRegistry,
} as unknown as Config,
sendUpdate: sendUpdateSpy,
};
emitter = new ToolCallEmitter(mockContext);
});
describe('emitStart', () => {
it('should emit tool_call update with basic params when tool not in registry', async () => {
const result = await emitter.emitStart({
toolName: 'unknown_tool',
callId: 'call-123',
args: { arg1: 'value1' },
});
expect(result).toBe(true);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-123',
status: 'in_progress',
title: 'unknown_tool', // Falls back to tool name
content: [],
locations: [],
kind: 'other',
rawInput: { arg1: 'value1' },
});
});
it('should emit tool_call with resolved metadata when tool is in registry', async () => {
const mockTool = createMockTool({ kind: Kind.Edit });
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
const result = await emitter.emitStart({
toolName: 'edit_file',
callId: 'call-456',
args: { path: '/test.ts' },
});
expect(result).toBe(true);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-456',
status: 'in_progress',
title: 'edit_file: Test tool description',
content: [],
locations: [{ path: '/test/file.ts', line: 10 }],
kind: 'edit',
rawInput: { path: '/test.ts' },
});
});
it('should skip emit for TodoWriteTool and return false', async () => {
const result = await emitter.emitStart({
toolName: TodoWriteTool.Name,
callId: 'call-todo',
args: { todos: [] },
});
expect(result).toBe(false);
expect(sendUpdateSpy).not.toHaveBeenCalled();
});
it('should handle empty args', async () => {
await emitter.emitStart({
toolName: 'test_tool',
callId: 'call-empty',
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
rawInput: {},
}),
);
});
it('should fall back gracefully when tool build fails', async () => {
const mockTool = createMockTool();
vi.mocked(mockTool.build).mockImplementation(() => {
throw new Error('Build failed');
});
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
await emitter.emitStart({
toolName: 'failing_tool',
callId: 'call-fail',
args: { invalid: true },
});
// Should use fallback values
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-fail',
status: 'in_progress',
title: 'failing_tool', // Fallback to tool name
content: [],
locations: [], // Fallback to empty
kind: 'other', // Fallback to other
rawInput: { invalid: true },
});
});
});
describe('emitResult', () => {
it('should emit tool_call_update with completed status on success', async () => {
await emitter.emitResult({
toolName: 'test_tool',
callId: 'call-123',
success: true,
message: createMockMessage('Tool completed successfully'),
resultDisplay: 'Tool completed successfully',
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-123',
status: 'completed',
rawOutput: 'Tool completed successfully',
}),
);
});
it('should emit tool_call_update with failed status on failure', async () => {
await emitter.emitResult({
toolName: 'test_tool',
callId: 'call-123',
success: false,
message: [],
error: new Error('Something went wrong'),
});
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-123',
status: 'failed',
content: [
{
type: 'content',
content: { type: 'text', text: 'Something went wrong' },
},
],
});
});
it('should handle diff display format', async () => {
await emitter.emitResult({
toolName: 'edit_file',
callId: 'call-edit',
success: true,
message: [],
resultDisplay: {
fileName: '/test/file.ts',
originalContent: 'old content',
newContent: 'new content',
},
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-edit',
status: 'completed',
content: [
{
type: 'diff',
path: '/test/file.ts',
oldText: 'old content',
newText: 'new content',
},
],
}),
);
});
it('should transform message parts to content', async () => {
await emitter.emitResult({
toolName: 'test_tool',
callId: 'call-123',
success: true,
message: [{ text: 'Some text output' }],
resultDisplay: 'raw output',
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-123',
status: 'completed',
content: [
{
type: 'content',
content: { type: 'text', text: 'Some text output' },
},
],
rawOutput: 'raw output',
}),
);
});
it('should handle empty message parts', async () => {
await emitter.emitResult({
toolName: 'test_tool',
callId: 'call-empty',
success: true,
message: [],
});
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-empty',
status: 'completed',
content: [],
});
});
describe('TodoWriteTool handling', () => {
it('should emit plan update instead of tool_call_update for TodoWriteTool', async () => {
await emitter.emitResult({
toolName: TodoWriteTool.Name,
callId: 'call-todo',
success: true,
message: [],
resultDisplay: {
type: 'todo_list',
todos: [
{ id: '1', content: 'Task 1', status: 'pending' },
{ id: '2', content: 'Task 2', status: 'in_progress' },
],
},
});
expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'plan',
entries: [
{ content: 'Task 1', priority: 'medium', status: 'pending' },
{ content: 'Task 2', priority: 'medium', status: 'in_progress' },
],
});
});
it('should use args as fallback for TodoWriteTool todos', async () => {
await emitter.emitResult({
toolName: TodoWriteTool.Name,
callId: 'call-todo',
success: true,
message: [],
resultDisplay: null,
args: {
todos: [{ id: '1', content: 'From args', status: 'completed' }],
},
});
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'plan',
entries: [
{ content: 'From args', priority: 'medium', status: 'completed' },
],
});
});
it('should not emit anything for TodoWriteTool with empty todos', async () => {
await emitter.emitResult({
toolName: TodoWriteTool.Name,
callId: 'call-todo',
success: true,
message: [],
resultDisplay: { type: 'todo_list', todos: [] },
});
expect(sendUpdateSpy).not.toHaveBeenCalled();
});
it('should not emit anything for TodoWriteTool with no extractable todos', async () => {
await emitter.emitResult({
toolName: TodoWriteTool.Name,
callId: 'call-todo',
success: true,
message: [],
resultDisplay: 'Some string result',
});
expect(sendUpdateSpy).not.toHaveBeenCalled();
});
});
});
describe('emitError', () => {
it('should emit tool_call_update with failed status and error message', async () => {
const error = new Error('Connection timeout');
await emitter.emitError('call-123', error);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-123',
status: 'failed',
content: [
{
type: 'content',
content: { type: 'text', text: 'Connection timeout' },
},
],
});
});
});
describe('isTodoWriteTool', () => {
it('should return true for TodoWriteTool.Name', () => {
expect(emitter.isTodoWriteTool(TodoWriteTool.Name)).toBe(true);
});
it('should return false for other tool names', () => {
expect(emitter.isTodoWriteTool('read_file')).toBe(false);
expect(emitter.isTodoWriteTool('edit_file')).toBe(false);
expect(emitter.isTodoWriteTool('')).toBe(false);
});
});
describe('mapToolKind', () => {
it('should map all Kind values correctly', () => {
expect(emitter.mapToolKind(Kind.Read)).toBe('read');
expect(emitter.mapToolKind(Kind.Edit)).toBe('edit');
expect(emitter.mapToolKind(Kind.Delete)).toBe('delete');
expect(emitter.mapToolKind(Kind.Move)).toBe('move');
expect(emitter.mapToolKind(Kind.Search)).toBe('search');
expect(emitter.mapToolKind(Kind.Execute)).toBe('execute');
expect(emitter.mapToolKind(Kind.Think)).toBe('think');
expect(emitter.mapToolKind(Kind.Fetch)).toBe('fetch');
expect(emitter.mapToolKind(Kind.Other)).toBe('other');
});
it('should map exit_plan_mode tool to switch_mode kind', () => {
// exit_plan_mode uses Kind.Think internally, but should map to switch_mode per ACP spec
expect(emitter.mapToolKind(Kind.Think, 'exit_plan_mode')).toBe(
'switch_mode',
);
});
it('should not affect other tools with Kind.Think', () => {
// Other tools with Kind.Think should still map to think
expect(emitter.mapToolKind(Kind.Think, 'todo_write')).toBe('think');
expect(emitter.mapToolKind(Kind.Think, 'some_other_tool')).toBe('think');
});
});
describe('isExitPlanModeTool', () => {
it('should return true for exit_plan_mode tool name', () => {
expect(emitter.isExitPlanModeTool('exit_plan_mode')).toBe(true);
});
it('should return false for other tool names', () => {
expect(emitter.isExitPlanModeTool('read_file')).toBe(false);
expect(emitter.isExitPlanModeTool('edit_file')).toBe(false);
expect(emitter.isExitPlanModeTool('todo_write')).toBe(false);
expect(emitter.isExitPlanModeTool('')).toBe(false);
});
});
describe('resolveToolMetadata', () => {
it('should return defaults when tool not found', () => {
const metadata = emitter.resolveToolMetadata('unknown_tool', {
arg: 'value',
});
expect(metadata).toEqual({
title: 'unknown_tool',
locations: [],
kind: 'other',
});
});
it('should return tool metadata when tool found and built successfully', () => {
const mockTool = createMockTool({ kind: Kind.Search });
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
const metadata = emitter.resolveToolMetadata('search_tool', {
query: 'test',
});
expect(metadata).toEqual({
title: 'search_tool: Test tool description',
locations: [{ path: '/test/file.ts', line: 10 }],
kind: 'search',
});
});
});
describe('integration: consistent behavior across flows', () => {
it('should handle the same params consistently regardless of source', async () => {
// This test verifies that the emitter produces consistent output
// whether called from normal flow, replay, or subagent
const params = {
toolName: 'read_file',
callId: 'consistent-call',
args: { path: '/test.ts' },
};
// First call (e.g., from normal flow)
await emitter.emitStart(params);
const firstCall = sendUpdateSpy.mock.calls[0][0];
// Reset and call again (e.g., from replay)
sendUpdateSpy.mockClear();
await emitter.emitStart(params);
const secondCall = sendUpdateSpy.mock.calls[0][0];
// Both should produce identical output
expect(firstCall).toEqual(secondCall);
});
});
describe('fixes verification', () => {
describe('Fix 2: functionResponse parts are stringified', () => {
it('should stringify functionResponse parts in message', async () => {
await emitter.emitResult({
toolName: 'test_tool',
callId: 'call-func',
success: true,
message: [
{
functionResponse: {
name: 'test',
response: { output: 'test output' },
},
},
],
resultDisplay: { unknownField: 'value', nested: { data: 123 } },
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-func',
status: 'completed',
content: [
{
type: 'content',
content: {
type: 'text',
text: '{"output":"test output"}',
},
},
],
rawOutput: { unknownField: 'value', nested: { data: 123 } },
}),
);
});
});
describe('Fix 3: rawOutput is included in emitResult', () => {
it('should include rawOutput when resultDisplay is provided', async () => {
await emitter.emitResult({
toolName: 'test_tool',
callId: 'call-extra',
success: true,
message: [{ text: 'Result text' }],
resultDisplay: 'Result text',
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-extra',
status: 'completed',
rawOutput: 'Result text',
}),
);
});
it('should not include rawOutput when resultDisplay is undefined', async () => {
await emitter.emitResult({
toolName: 'test_tool',
callId: 'call-null',
success: true,
message: [],
});
const call = sendUpdateSpy.mock.calls[0][0];
expect(call.rawOutput).toBeUndefined();
});
});
describe('Fix 5: Line null mapping in resolveToolMetadata', () => {
it('should map undefined line to null in locations', () => {
const mockTool = createMockTool();
// Override toolLocations to return undefined line
vi.mocked(mockTool.build).mockReturnValue({
getDescription: () => 'Description',
toolLocations: () => [
{ path: '/file1.ts', line: 10 },
{ path: '/file2.ts', line: undefined },
{ path: '/file3.ts' }, // no line property
],
} as unknown as AnyToolInvocation);
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
const metadata = emitter.resolveToolMetadata('test_tool', {
arg: 'value',
});
expect(metadata.locations).toEqual([
{ path: '/file1.ts', line: 10 },
{ path: '/file2.ts', line: null },
{ path: '/file3.ts', line: null },
]);
});
});
describe('Fix 6: Empty plan emission when args has todos', () => {
it('should emit empty plan when args had todos but result has none', async () => {
await emitter.emitResult({
toolName: TodoWriteTool.Name,
callId: 'call-todo-empty',
success: true,
message: [],
resultDisplay: null, // No result display
args: {
todos: [], // Empty array in args
},
});
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'plan',
entries: [],
});
});
it('should emit empty plan when result todos is empty but args had todos', async () => {
await emitter.emitResult({
toolName: TodoWriteTool.Name,
callId: 'call-todo-cleared',
success: true,
message: [],
resultDisplay: {
type: 'todo_list',
todos: [], // Empty result
},
args: {
todos: [{ id: '1', content: 'Was here', status: 'pending' }],
},
});
// Should still emit empty plan (result takes precedence but we emit empty)
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'plan',
entries: [],
});
});
});
describe('Message transformation', () => {
it('should transform text parts from message', async () => {
await emitter.emitResult({
toolName: 'test_tool',
callId: 'call-text',
success: true,
message: [{ text: 'Text content from message' }],
});
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-text',
status: 'completed',
content: [
{
type: 'content',
content: { type: 'text', text: 'Text content from message' },
},
],
});
});
it('should transform functionResponse parts from message', async () => {
await emitter.emitResult({
toolName: 'test_tool',
callId: 'call-func-resp',
success: true,
message: [
{
functionResponse: {
name: 'test_tool',
response: { output: 'Function output' },
},
},
],
resultDisplay: 'raw result',
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-func-resp',
status: 'completed',
content: [
{
type: 'content',
content: { type: 'text', text: '{"output":"Function output"}' },
},
],
rawOutput: 'raw result',
}),
);
});
});
});
});

View File

@@ -0,0 +1,291 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { BaseEmitter } from './BaseEmitter.js';
import { PlanEmitter } from './PlanEmitter.js';
import type {
SessionContext,
ToolCallStartParams,
ToolCallResultParams,
ResolvedToolMetadata,
} from '../types.js';
import type * as acp from '../../acp.js';
import type { Part } from '@google/genai';
import {
TodoWriteTool,
Kind,
ExitPlanModeTool,
} from '@qwen-code/qwen-code-core';
/**
* Unified tool call event emitter.
*
* Handles tool_call and tool_call_update for ALL flows:
* - Normal tool execution in runTool()
* - History replay in HistoryReplayer
* - SubAgent tool tracking in SubAgentTracker
*
* This ensures consistent behavior across all tool event sources,
* including special handling for tools like TodoWriteTool.
*/
export class ToolCallEmitter extends BaseEmitter {
private readonly planEmitter: PlanEmitter;
constructor(ctx: SessionContext) {
super(ctx);
this.planEmitter = new PlanEmitter(ctx);
}
/**
* Emits a tool call start event.
*
* @param params - Tool call start parameters
* @returns true if event was emitted, false if skipped (e.g., TodoWriteTool)
*/
async emitStart(params: ToolCallStartParams): Promise<boolean> {
// Skip tool_call for TodoWriteTool - plan updates sent on result
if (this.isTodoWriteTool(params.toolName)) {
return false;
}
const { title, locations, kind } = this.resolveToolMetadata(
params.toolName,
params.args,
);
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: params.callId,
status: 'in_progress',
title,
content: [],
locations,
kind,
rawInput: params.args ?? {},
});
return true;
}
/**
* Emits a tool call result event.
* Handles TodoWriteTool specially by routing to plan updates.
*
* @param params - Tool call result parameters
*/
async emitResult(params: ToolCallResultParams): Promise<void> {
// Handle TodoWriteTool specially - send plan update instead
if (this.isTodoWriteTool(params.toolName)) {
const todos = this.planEmitter.extractTodos(
params.resultDisplay,
params.args,
);
// Match original behavior: send plan even if empty when args['todos'] exists
// This ensures the UI is updated even when all todos are removed
if (todos && todos.length > 0) {
await this.planEmitter.emitPlan(todos);
} else if (params.args && Array.isArray(params.args['todos'])) {
// Send empty plan when args had todos but result has none
await this.planEmitter.emitPlan([]);
}
return; // Skip tool_call_update for TodoWriteTool
}
// Determine content for the update
let contentArray: acp.ToolCallContent[] = [];
// Special case: diff result from edit tools (format from resultDisplay)
const diffContent = this.extractDiffContent(params.resultDisplay);
if (diffContent) {
contentArray = [diffContent];
} else if (params.error) {
// Error case: show error message
contentArray = [
{
type: 'content',
content: { type: 'text', text: params.error.message },
},
];
} else {
// Normal case: transform message parts to ToolCallContent[]
contentArray = this.transformPartsToToolCallContent(params.message);
}
// Build the update
const update: Parameters<typeof this.sendUpdate>[0] = {
sessionUpdate: 'tool_call_update',
toolCallId: params.callId,
status: params.success ? 'completed' : 'failed',
content: contentArray,
};
// Add rawOutput from resultDisplay
if (params.resultDisplay !== undefined) {
(update as Record<string, unknown>)['rawOutput'] = params.resultDisplay;
}
await this.sendUpdate(update);
}
/**
* Emits a tool call error event.
* Use this for explicit error handling when not using emitResult.
*
* @param callId - The tool call ID
* @param error - The error that occurred
*/
async emitError(callId: string, error: Error): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'failed',
content: [
{ type: 'content', content: { type: 'text', text: error.message } },
],
});
}
// ==================== Public Utilities ====================
/**
* Checks if a tool name is the TodoWriteTool.
* Exposed for external use in components that need to check this.
*/
isTodoWriteTool(toolName: string): boolean {
return toolName === TodoWriteTool.Name;
}
/**
* Checks if a tool name is the ExitPlanModeTool.
*/
isExitPlanModeTool(toolName: string): boolean {
return toolName === ExitPlanModeTool.Name;
}
/**
* Resolves tool metadata from the registry.
* Falls back to defaults if tool not found or build fails.
*
* @param toolName - Name of the tool
* @param args - Tool call arguments (used to build invocation)
*/
resolveToolMetadata(
toolName: string,
args?: Record<string, unknown>,
): ResolvedToolMetadata {
const toolRegistry = this.config.getToolRegistry();
const tool = toolRegistry.getTool(toolName);
let title = tool?.displayName ?? toolName;
let locations: acp.ToolCallLocation[] = [];
let kind: acp.ToolKind = 'other';
if (tool && args) {
try {
const invocation = tool.build(args);
title = `${title}: ${invocation.getDescription()}`;
// Map locations to ensure line is null instead of undefined (for ACP consistency)
locations = invocation.toolLocations().map((loc) => ({
path: loc.path,
line: loc.line ?? null,
}));
// Pass tool name to handle special cases like exit_plan_mode -> switch_mode
kind = this.mapToolKind(tool.kind, toolName);
} catch {
// Use defaults on build failure
}
}
return { title, locations, kind };
}
/**
* Maps core Tool Kind enum to ACP ToolKind string literals.
*
* @param kind - The core Kind enum value
* @param toolName - Optional tool name to handle special cases like exit_plan_mode
*/
mapToolKind(kind: Kind, toolName?: string): acp.ToolKind {
// Special case: exit_plan_mode uses 'switch_mode' kind per ACP spec
if (toolName && this.isExitPlanModeTool(toolName)) {
return 'switch_mode';
}
const kindMap: Record<Kind, acp.ToolKind> = {
[Kind.Read]: 'read',
[Kind.Edit]: 'edit',
[Kind.Delete]: 'delete',
[Kind.Move]: 'move',
[Kind.Search]: 'search',
[Kind.Execute]: 'execute',
[Kind.Think]: 'think',
[Kind.Fetch]: 'fetch',
[Kind.Other]: 'other',
};
return kindMap[kind] ?? 'other';
}
// ==================== Private Helpers ====================
/**
* Extracts diff content from resultDisplay if it's a diff type (edit tool result).
* Returns null if not a diff.
*/
private extractDiffContent(
resultDisplay: unknown,
): acp.ToolCallContent | null {
if (!resultDisplay || typeof resultDisplay !== 'object') return null;
const obj = resultDisplay as Record<string, unknown>;
// Check if this is a diff display (edit tool result)
if ('fileName' in obj && 'newContent' in obj) {
return {
type: 'diff',
path: obj['fileName'] as string,
oldText: (obj['originalContent'] as string) ?? '',
newText: obj['newContent'] as string,
};
}
return null;
}
/**
* Transforms Part[] to ToolCallContent[].
* Extracts text from functionResponse parts and text parts.
*/
private transformPartsToToolCallContent(
parts: Part[],
): acp.ToolCallContent[] {
const result: acp.ToolCallContent[] = [];
for (const part of parts) {
// Handle text parts
if ('text' in part && part.text) {
result.push({
type: 'content',
content: { type: 'text', text: part.text },
});
}
// Handle functionResponse parts - stringify the response
if ('functionResponse' in part && part.functionResponse) {
try {
const responseText = JSON.stringify(part.functionResponse.response);
result.push({
type: 'content',
content: { type: 'text', text: responseText },
});
} catch {
// Ignore serialization errors
}
}
}
return result;
}
}

View File

@@ -0,0 +1,10 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
export { BaseEmitter } from './BaseEmitter.js';
export { MessageEmitter } from './MessageEmitter.js';
export { PlanEmitter } from './PlanEmitter.js';
export { ToolCallEmitter } from './ToolCallEmitter.js';

View File

@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Session module for ACP/Zed integration.
*
* This module provides a modular architecture for handling session events:
* - **Emitters**: Unified event emission (MessageEmitter, ToolCallEmitter, PlanEmitter)
* - **HistoryReplayer**: Replays session history using unified emitters
* - **SubAgentTracker**: Tracks sub-agent tool events using unified emitters
*
* The key benefit is that all event emission goes through the same emitters,
* ensuring consistency between normal flow, history replay, and sub-agent events.
*/
// Types
export type {
SessionContext,
SessionUpdateSender,
ToolCallStartParams,
ToolCallResultParams,
TodoItem,
ResolvedToolMetadata,
} from './types.js';
// Emitters
export { BaseEmitter } from './emitters/BaseEmitter.js';
export { MessageEmitter } from './emitters/MessageEmitter.js';
export { PlanEmitter } from './emitters/PlanEmitter.js';
export { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
// Components
export { HistoryReplayer } from './HistoryReplayer.js';
export { SubAgentTracker } from './SubAgentTracker.js';
// Main Session class
export { Session } from './Session.js';

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
import type * as acp from '../acp.js';
/**
* Interface for sending session updates to the ACP client.
* Implemented by Session class and used by all emitters.
*/
export interface SessionUpdateSender {
sendUpdate(update: acp.SessionUpdate): Promise<void>;
}
/**
* Session context shared across all emitters.
* Provides access to session state and configuration.
*/
export interface SessionContext extends SessionUpdateSender {
readonly sessionId: string;
readonly config: Config;
}
/**
* Parameters for emitting a tool call start event.
*/
export interface ToolCallStartParams {
/** Name of the tool being called */
toolName: string;
/** Unique identifier for this tool call */
callId: string;
/** Arguments passed to the tool */
args?: Record<string, unknown>;
}
/**
* Parameters for emitting a tool call result event.
*/
export interface ToolCallResultParams {
/** Name of the tool that was called */
toolName: string;
/** Unique identifier for this tool call */
callId: string;
/** Whether the tool execution succeeded */
success: boolean;
/** The response parts from tool execution (maps to content in update event) */
message: Part[];
/** Display result from tool execution (maps to rawOutput in update event) */
resultDisplay?: unknown;
/** Error if tool execution failed */
error?: Error;
/** Original args (fallback for TodoWriteTool todos extraction) */
args?: Record<string, unknown>;
}
/**
* Todo item structure for plan updates.
*/
export interface TodoItem {
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}
/**
* Resolved tool metadata from the registry.
*/
export interface ResolvedToolMetadata {
title: string;
locations: acp.ToolCallLocation[];
kind: acp.ToolKind;
}

View File

@@ -15,6 +15,7 @@ import type {
import { Config } from '@qwen-code/qwen-code-core';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import type { Settings } from './settings.js';
export const server = setupServer();
@@ -73,12 +74,10 @@ describe('Configuration Integration Tests', () => {
it('should load default file filtering settings', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFilteringRespectGitIgnore: undefined, // Should default to true
};
const config = new Config(configParams);
@@ -89,9 +88,8 @@ describe('Configuration Integration Tests', () => {
it('should load custom file filtering settings from configuration', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
@@ -107,12 +105,10 @@ describe('Configuration Integration Tests', () => {
it('should merge user and workspace file filtering settings', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFilteringRespectGitIgnore: true,
};
const config = new Config(configParams);
@@ -125,9 +121,8 @@ describe('Configuration Integration Tests', () => {
it('should handle partial configuration objects gracefully', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
@@ -144,12 +139,10 @@ describe('Configuration Integration Tests', () => {
it('should handle empty configuration objects gracefully', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFilteringRespectGitIgnore: undefined,
};
const config = new Config(configParams);
@@ -161,9 +154,8 @@ describe('Configuration Integration Tests', () => {
it('should handle missing configuration sections gracefully', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
// Missing fileFiltering configuration
@@ -180,12 +172,10 @@ describe('Configuration Integration Tests', () => {
it('should handle a security-focused configuration', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFilteringRespectGitIgnore: true,
};
const config = new Config(configParams);
@@ -196,9 +186,8 @@ describe('Configuration Integration Tests', () => {
it('should handle a CI/CD environment configuration', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
@@ -216,9 +205,8 @@ describe('Configuration Integration Tests', () => {
it('should enable checkpointing when the setting is true', async () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
checkpointing: true,
@@ -234,9 +222,8 @@ describe('Configuration Integration Tests', () => {
it('should have an empty array for extension context files by default', () => {
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
};
@@ -248,9 +235,8 @@ describe('Configuration Integration Tests', () => {
const contextFiles = ['/path/to/file1.txt', '/path/to/file2.js'];
const configParams: ConfigParameters = {
cwd: '/tmp',
contentGeneratorConfig: TEST_CONTENT_GENERATOR_CONFIG,
generationConfig: TEST_CONTENT_GENERATOR_CONFIG,
embeddingModel: 'test-embedding-model',
sandbox: false,
targetDir: tempDir,
debugMode: false,
extensionContextFilePaths: contextFiles,
@@ -261,11 +247,11 @@ describe('Configuration Integration Tests', () => {
});
describe('Approval Mode Integration Tests', () => {
let parseArguments: typeof import('./config').parseArguments;
let parseArguments: typeof import('./config.js').parseArguments;
beforeEach(async () => {
// Import the argument parsing function for integration testing
const { parseArguments: parseArgs } = await import('./config');
const { parseArguments: parseArgs } = await import('./config.js');
parseArguments = parseArgs;
});

View File

@@ -535,7 +535,6 @@ describe('loadCliConfig', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -555,7 +554,6 @@ describe('loadCliConfig', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getShowMemoryUsage()).toBe(true);
@@ -572,7 +570,6 @@ describe('loadCliConfig', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getShowMemoryUsage()).toBe(false);
@@ -589,7 +586,6 @@ describe('loadCliConfig', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getShowMemoryUsage()).toBe(false);
@@ -606,7 +602,6 @@ describe('loadCliConfig', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getShowMemoryUsage()).toBe(true);
@@ -649,7 +644,6 @@ describe('loadCliConfig', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getProxy()).toBeFalsy();
@@ -699,7 +693,6 @@ describe('loadCliConfig', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getProxy()).toBe(expected);
@@ -717,7 +710,6 @@ describe('loadCliConfig', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getProxy()).toBe('http://localhost:7890');
@@ -735,7 +727,6 @@ describe('loadCliConfig', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getProxy()).toBe('http://localhost:7890');
@@ -769,7 +760,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryEnabled()).toBe(false);
@@ -786,7 +776,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryEnabled()).toBe(true);
@@ -803,7 +792,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryEnabled()).toBe(false);
@@ -820,7 +808,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryEnabled()).toBe(true);
@@ -837,7 +824,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryEnabled()).toBe(false);
@@ -854,7 +840,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryEnabled()).toBe(true);
@@ -871,7 +856,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryEnabled()).toBe(false);
@@ -890,7 +874,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryOtlpEndpoint()).toBe(
@@ -916,7 +899,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryOtlpEndpoint()).toBe('http://cli.example.com');
@@ -933,7 +915,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryOtlpEndpoint()).toBe('http://localhost:4317');
@@ -952,7 +933,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryTarget()).toBe(
@@ -973,7 +953,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryTarget()).toBe('gcp');
@@ -990,7 +969,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryTarget()).toBe(
@@ -1009,7 +987,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
@@ -1026,7 +1003,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryLogPromptsEnabled()).toBe(true);
@@ -1043,7 +1019,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
@@ -1060,7 +1035,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryLogPromptsEnabled()).toBe(true);
@@ -1079,7 +1053,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryOtlpProtocol()).toBe('http');
@@ -1098,7 +1071,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryOtlpProtocol()).toBe('http');
@@ -1115,7 +1087,6 @@ describe('loadCliConfig telemetry', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryOtlpProtocol()).toBe('grpc');
@@ -1197,12 +1168,10 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'session-id',
argv,
);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
@@ -1283,7 +1252,6 @@ describe('mergeMcpServers', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(settings).toEqual(originalSettings);
@@ -1333,7 +1301,6 @@ describe('mergeExcludeTools', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExcludeTools()).toEqual(
@@ -1364,7 +1331,6 @@ describe('mergeExcludeTools', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExcludeTools()).toEqual(
@@ -1404,7 +1370,6 @@ describe('mergeExcludeTools', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExcludeTools()).toEqual(
@@ -1426,7 +1391,6 @@ describe('mergeExcludeTools', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExcludeTools()).toEqual([]);
@@ -1445,7 +1409,6 @@ describe('mergeExcludeTools', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExcludeTools()).toEqual(defaultExcludes);
@@ -1463,7 +1426,6 @@ describe('mergeExcludeTools', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExcludeTools()).toEqual(
@@ -1494,7 +1456,6 @@ describe('mergeExcludeTools', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExcludeTools()).toEqual(
@@ -1526,7 +1487,6 @@ describe('mergeExcludeTools', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(settings).toEqual(originalSettings);
@@ -1558,7 +1518,6 @@ describe('Approval mode tool exclusion logic', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -1588,7 +1547,6 @@ describe('Approval mode tool exclusion logic', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -1618,7 +1576,6 @@ describe('Approval mode tool exclusion logic', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -1648,7 +1605,6 @@ describe('Approval mode tool exclusion logic', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -1678,7 +1634,6 @@ describe('Approval mode tool exclusion logic', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -1701,7 +1656,6 @@ describe('Approval mode tool exclusion logic', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -1736,7 +1690,7 @@ describe('Approval mode tool exclusion logic', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -1767,7 +1721,6 @@ describe('Approval mode tool exclusion logic', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -1797,7 +1750,7 @@ describe('Approval mode tool exclusion logic', () => {
ExtensionStorage.getUserExtensionsDir(),
invalidArgv.extensions,
),
'test-session',
invalidArgv as CliArgs,
),
).rejects.toThrow(
@@ -1839,7 +1792,6 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getMcpServers()).toEqual(baseSettings.mcpServers);
@@ -1860,7 +1812,6 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getMcpServers()).toEqual({
@@ -1885,7 +1836,6 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getMcpServers()).toEqual({
@@ -1911,7 +1861,6 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getMcpServers()).toEqual({
@@ -1929,7 +1878,6 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getMcpServers()).toEqual({});
@@ -1949,7 +1897,6 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getMcpServers()).toEqual({
@@ -1972,7 +1919,6 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getMcpServers()).toEqual({
@@ -1997,7 +1943,6 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getMcpServers()).toEqual({
@@ -2027,7 +1972,6 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getMcpServers()).toEqual({
@@ -2059,7 +2003,6 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getMcpServers()).toEqual({
@@ -2094,7 +2037,6 @@ describe('loadCliConfig extensions', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExtensionContextFilePaths()).toEqual([
@@ -2114,7 +2056,6 @@ describe('loadCliConfig extensions', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExtensionContextFilePaths()).toEqual(['/path/to/ext1.md']);
@@ -2136,7 +2077,6 @@ describe('loadCliConfig model selection', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -2155,7 +2095,6 @@ describe('loadCliConfig model selection', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -2176,7 +2115,6 @@ describe('loadCliConfig model selection', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -2195,7 +2133,6 @@ describe('loadCliConfig model selection', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
@@ -2235,7 +2172,6 @@ describe('loadCliConfig folderTrust', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getFolderTrust()).toBe(false);
@@ -2258,7 +2194,6 @@ describe('loadCliConfig folderTrust', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getFolderTrust()).toBe(true);
@@ -2275,7 +2210,6 @@ describe('loadCliConfig folderTrust', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getFolderTrust()).toBe(false);
@@ -2325,7 +2259,6 @@ describe('loadCliConfig with includeDirectories', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
const expected = [
@@ -2377,7 +2310,6 @@ describe('loadCliConfig chatCompression', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getChatCompression()).toEqual({
@@ -2396,7 +2328,6 @@ describe('loadCliConfig chatCompression', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getChatCompression()).toBeUndefined();
@@ -2429,7 +2360,6 @@ describe('loadCliConfig useRipgrep', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getUseRipgrep()).toBe(true);
@@ -2446,7 +2376,6 @@ describe('loadCliConfig useRipgrep', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getUseRipgrep()).toBe(false);
@@ -2463,7 +2392,6 @@ describe('loadCliConfig useRipgrep', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getUseRipgrep()).toBe(true);
@@ -2496,7 +2424,6 @@ describe('loadCliConfig useBuiltinRipgrep', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getUseBuiltinRipgrep()).toBe(true);
@@ -2513,7 +2440,6 @@ describe('loadCliConfig useBuiltinRipgrep', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getUseBuiltinRipgrep()).toBe(false);
@@ -2530,7 +2456,6 @@ describe('loadCliConfig useBuiltinRipgrep', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getUseBuiltinRipgrep()).toBe(true);
@@ -2565,7 +2490,6 @@ describe('screenReader configuration', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getScreenReader()).toBe(true);
@@ -2584,7 +2508,6 @@ describe('screenReader configuration', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getScreenReader()).toBe(false);
@@ -2603,7 +2526,6 @@ describe('screenReader configuration', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getScreenReader()).toBe(true);
@@ -2620,7 +2542,6 @@ describe('screenReader configuration', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getScreenReader()).toBe(false);
@@ -2657,7 +2578,6 @@ describe('loadCliConfig tool exclusions', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
@@ -2676,7 +2596,6 @@ describe('loadCliConfig tool exclusions', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
@@ -2695,7 +2614,6 @@ describe('loadCliConfig tool exclusions', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExcludeTools()).toContain('run_shell_command');
@@ -2714,7 +2632,6 @@ describe('loadCliConfig tool exclusions', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
@@ -2752,7 +2669,6 @@ describe('loadCliConfig interactive', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.isInteractive()).toBe(true);
@@ -2769,7 +2685,6 @@ describe('loadCliConfig interactive', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.isInteractive()).toBe(true);
@@ -2786,7 +2701,6 @@ describe('loadCliConfig interactive', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.isInteractive()).toBe(false);
@@ -2803,7 +2717,6 @@ describe('loadCliConfig interactive', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.isInteractive()).toBe(false);
@@ -2820,7 +2733,6 @@ describe('loadCliConfig interactive', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.isInteractive()).toBe(false);
@@ -2844,7 +2756,6 @@ describe('loadCliConfig interactive', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.isInteractive()).toBe(false);
@@ -2864,7 +2775,6 @@ describe('loadCliConfig interactive', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.isInteractive()).toBe(true);
@@ -2898,7 +2808,6 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
@@ -2914,7 +2823,6 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
@@ -2930,7 +2838,6 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
@@ -2946,7 +2853,6 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
@@ -2962,7 +2868,6 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
@@ -2978,7 +2883,6 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT);
@@ -2994,7 +2898,6 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
@@ -3011,7 +2914,6 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
@@ -3028,7 +2930,6 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT);
@@ -3046,7 +2947,7 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
),
).rejects.toThrow(
@@ -3068,7 +2969,6 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
@@ -3084,7 +2984,6 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
@@ -3109,7 +3008,7 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
@@ -3125,7 +3024,7 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
@@ -3141,7 +3040,7 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
@@ -3157,7 +3056,7 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
@@ -3173,7 +3072,7 @@ describe('loadCliConfig approval mode', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
@@ -3260,7 +3159,7 @@ describe('loadCliConfig fileFiltering', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(getter(config)).toBe(value);
@@ -3279,7 +3178,6 @@ describe('Output format', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getOutputFormat()).toBe(OutputFormat.TEXT);
@@ -3295,7 +3193,6 @@ describe('Output format', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getOutputFormat()).toBe(OutputFormat.JSON);
@@ -3311,7 +3208,6 @@ describe('Output format', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getOutputFormat()).toBe(OutputFormat.JSON);
@@ -3404,7 +3300,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryEnabled()).toBe(true);
@@ -3422,7 +3317,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryTarget()).toBe('gcp');
@@ -3441,7 +3335,7 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
),
).rejects.toThrow(
@@ -3465,7 +3359,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryOtlpEndpoint()).toBe('http://gemini.env.com');
@@ -3483,7 +3376,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryOtlpProtocol()).toBe('http');
@@ -3501,7 +3393,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
@@ -3521,7 +3412,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryOutfile()).toBe('/gemini/env/telemetry.log');
@@ -3539,7 +3429,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryUseCollector()).toBe(true);
@@ -3557,7 +3446,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryEnabled()).toBe(true);
@@ -3575,7 +3463,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryTarget()).toBe('local');
@@ -3592,7 +3479,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryEnabled()).toBe(true);
@@ -3609,7 +3495,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryEnabled()).toBe(false);
@@ -3626,7 +3511,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryLogPromptsEnabled()).toBe(true);
@@ -3643,7 +3527,6 @@ describe('Telemetry configuration via environment variables', () => {
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);

View File

@@ -4,11 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
FileFilteringOptions,
MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import {
ApprovalMode,
Config,
@@ -26,7 +21,12 @@ import {
Storage,
InputFormat,
OutputFormat,
SessionService,
type ResumedSessionData,
type FileFilteringOptions,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import type { Settings } from './settings.js';
import yargs, { type Argv } from 'yargs';
import { hideBin } from 'yargs/helpers';
@@ -129,6 +129,10 @@ export interface CliArgs {
inputFormat?: string | undefined;
outputFormat: string | undefined;
includePartialMessages?: boolean;
/** Resume the most recent session for the current project */
continue: boolean | undefined;
/** Resume a specific session by its ID */
resume: string | undefined;
}
function normalizeOutputFormat(
@@ -396,6 +400,17 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
'Include partial assistant messages when using stream-json output.',
default: false,
})
.option('continue', {
type: 'boolean',
description:
'Resume the most recent session for the current project.',
default: false,
})
.option('resume', {
type: 'string',
description:
'Resume a specific session by its ID. Use without an ID to show session picker.',
})
.deprecateOption(
'show-memory-usage',
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
@@ -451,6 +466,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
) {
return '--input-format stream-json requires --output-format stream-json';
}
if (argv['continue'] && argv['resume']) {
return 'Cannot use both --continue and --resume together. Use --continue to resume the latest session, or --resume <sessionId> to resume a specific session.';
}
return true;
}),
)
@@ -565,7 +583,6 @@ export async function loadCliConfig(
settings: Settings,
extensions: Extension[],
extensionEnablementManager: ExtensionEnablementManager,
sessionId: string,
argv: CliArgs,
cwd: string = process.cwd(),
): Promise<Config> {
@@ -797,8 +814,33 @@ export async function loadCliConfig(
const vlmSwitchMode =
argv.vlmSwitchMode || settings.experimental?.vlmSwitchMode;
let sessionId: string | undefined;
let sessionData: ResumedSessionData | undefined;
if (argv.continue || argv.resume) {
const sessionService = new SessionService(cwd);
if (argv.continue) {
sessionData = await sessionService.loadLastSession();
if (sessionData) {
sessionId = sessionData.conversation.sessionId;
}
}
if (argv.resume) {
sessionId = argv.resume;
sessionData = await sessionService.loadSession(argv.resume);
if (!sessionData) {
const message = `No saved session found with ID ${argv.resume}. Run \`qwen --resume\` without an ID to choose from existing sessions.`;
console.log(message);
process.exit(1);
}
}
}
return new Config({
sessionId,
sessionData,
embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: cwd,

View File

@@ -30,7 +30,6 @@ import { getErrorMessage } from '../utils/errors.js';
import { recursivelyHydrateStrings } from './extensions/variables.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { randomUUID } from 'node:crypto';
import {
cloneFromGit,
downloadFromGitHubRelease,
@@ -134,7 +133,6 @@ function getTelemetryConfig(cwd: string) {
const config = new Config({
telemetry: settings.merged.telemetry,
interactive: false,
sessionId: randomUUID(),
targetDir: cwd,
cwd,
model: '',

View File

@@ -479,6 +479,8 @@ describe('gemini.tsx main function kitty protocol', () => {
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
continue: undefined,
resume: undefined,
});
await main();

View File

@@ -12,7 +12,6 @@ import {
logUserPrompt,
} from '@qwen-code/qwen-code-core';
import { render } from 'ink';
import { randomUUID } from 'node:crypto';
import dns from 'node:dns';
import os from 'node:os';
import { basename } from 'node:path';
@@ -59,6 +58,7 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { getCliVersion } from './utils/version.js';
import { computeWindowTitle } from './utils/windowTitle.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { showResumeSessionPicker } from './ui/components/ResumeSessionPicker.js';
export function validateDnsResolutionOrder(
order: string | undefined,
@@ -110,7 +110,7 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { runZedIntegration } from './zed-integration/zedIntegration.js';
import { runAcpAgent } from './acp-integration/acpAgent.js';
export function setupUnhandledRejectionHandler() {
let unhandledRejectionOccurred = false;
@@ -158,7 +158,7 @@ export async function startInteractiveUI(
process.platform === 'win32' || nodeMajorVersion < 20
}
>
<SessionStatsProvider>
<SessionStatsProvider sessionId={config.getSessionId()}>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
@@ -207,9 +207,8 @@ export async function main() {
const settings = loadSettings();
migrateDeprecatedSettings(settings);
await cleanupCheckpoints();
const sessionId = randomUUID();
const argv = await parseArguments(settings.merged);
let argv = await parseArguments(settings.merged);
// Check for invalid input combinations early to prevent crashes
if (argv.promptInteractive && !process.stdin.isTTY) {
@@ -253,7 +252,6 @@ export async function main() {
settings.merged,
[],
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
sessionId,
argv,
);
@@ -319,6 +317,18 @@ export async function main() {
}
}
// Handle --resume without a session ID by showing the session picker
if (argv.resume === '') {
const selectedSessionId = await showResumeSessionPicker();
if (!selectedSessionId) {
// User cancelled or no sessions available
process.exit(0);
}
// Update argv with the selected session ID
argv = { ...argv, resume: selectedSessionId };
}
// We are now past the logic handling potentially launching a child process
// to run Gemini CLI. It is now safe to perform expensive initialization that
// may have side effects.
@@ -332,7 +342,6 @@ export async function main() {
settings.merged,
extensions,
extensionEnablementManager,
sessionId,
argv,
);
@@ -386,7 +395,7 @@ export async function main() {
}
if (config.getExperimentalZedIntegration()) {
return runZedIntegration(config, settings, extensions, argv);
return runAcpAgent(config, settings, extensions, argv);
}
let input = config.getQuestion();
@@ -469,7 +478,7 @@ export async function main() {
});
if (config.getDebugMode()) {
console.log('Session ID: %s', sessionId);
console.log('Session ID: %s', config.getSessionId());
}
await runNonInteractive(nonInteractiveConfig, settings, input, prompt_id);

View File

@@ -56,7 +56,6 @@ import { restoreCommand } from '../ui/commands/restoreCommand.js';
vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
vi.mock('../ui/commands/chatCommand.js', () => ({ chatCommand: {} }));
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} }));
vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} }));

View File

@@ -12,7 +12,6 @@ import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { chatCommand } from '../ui/commands/chatCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
@@ -61,7 +60,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
approvalModeCommand,
authCommand,
bugCommand,
chatCommand,
clearCommand,
compressCommand,
copyCommand,

View File

@@ -9,6 +9,7 @@ import type { CommandContext } from '../ui/commands/types.js';
import type { LoadedSettings } from '../config/settings.js';
import type { GitService } from '@qwen-code/qwen-code-core';
import type { SessionStatsState } from '../ui/contexts/SessionContext.js';
import { ToolCallDecision } from '../ui/contexts/SessionContext.js';
// A utility type to make all properties of an object, and its nested objects, partial.
type DeepPartial<T> = T extends object
@@ -63,7 +64,9 @@ export const createMockCommandContext = (
} as any,
session: {
sessionShellAllowlist: new Set<string>(),
startNewSession: vi.fn(),
stats: {
sessionId: '',
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
metrics: {
@@ -73,9 +76,15 @@ export const createMockCommandContext = (
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
[ToolCallDecision.ACCEPT]: 0,
[ToolCallDecision.REJECT]: 0,
[ToolCallDecision.MODIFY]: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
},
promptCount: 0,
} as SessionStatsState,

View File

@@ -40,6 +40,7 @@ import {
getAllGeminiMdFilenames,
ShellExecutionService,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { validateAuthMethod } from '../config/auth.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import process from 'node:process';
@@ -196,7 +197,6 @@ export const AppContainer = (props: AppContainerProps) => {
const [isConfigInitialized, setConfigInitialized] = useState(false);
const logger = useLogger(config.storage);
const [userMessages, setUserMessages] = useState<string[]>([]);
// Terminal and layout hooks
@@ -206,6 +206,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Additional hooks moved from App.tsx
const { stats: sessionStats } = useSessionStats();
const logger = useLogger(config.storage, sessionStats.sessionId);
const branchName = useGitBranchName(config.getTargetDir());
// Layout measurements
@@ -216,17 +217,28 @@ export const AppContainer = (props: AppContainerProps) => {
const lastTitleRef = useRef<string | null>(null);
const staticExtraHeight = 3;
// Initialize config (runs once on mount)
useEffect(() => {
(async () => {
// Note: the program will not work if this fails so let errors be
// handled by the global catch.
await config.initialize();
setConfigInitialized(true);
const resumedSessionData = config.getResumedSessionData();
if (resumedSessionData) {
const historyItems = buildResumedHistoryItems(
resumedSessionData,
config,
);
historyManager.loadHistory(historyItems);
}
})();
registerCleanup(async () => {
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
useEffect(
@@ -522,6 +534,7 @@ export const AppContainer = (props: AppContainerProps) => {
slashCommandActions,
extensionsUpdateStateInternal,
isConfigInitialized,
logger,
);
// Vision switch handlers

View File

@@ -1,701 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mocked } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import type {
MessageActionReturn,
SlashCommand,
CommandContext,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { Content } from '@google/genai';
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import * as fsPromises from 'node:fs/promises';
import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js';
import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
import path from 'node:path';
vi.mock('fs/promises', () => ({
stat: vi.fn(),
readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
writeFile: vi.fn(),
}));
describe('chatCommand', () => {
const mockFs = fsPromises as Mocked<typeof fsPromises>;
let mockContext: CommandContext;
let mockGetChat: ReturnType<typeof vi.fn>;
let mockSaveCheckpoint: ReturnType<typeof vi.fn>;
let mockLoadCheckpoint: ReturnType<typeof vi.fn>;
let mockDeleteCheckpoint: ReturnType<typeof vi.fn>;
let mockGetHistory: ReturnType<typeof vi.fn>;
const getSubCommand = (
name: 'list' | 'save' | 'resume' | 'delete' | 'share',
): SlashCommand => {
const subCommand = chatCommand.subCommands?.find(
(cmd) => cmd.name === name,
);
if (!subCommand) {
throw new Error(`/chat ${name} command not found.`);
}
return subCommand;
};
beforeEach(() => {
mockGetHistory = vi.fn().mockReturnValue([]);
mockGetChat = vi.fn().mockResolvedValue({
getHistory: mockGetHistory,
});
mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined);
mockLoadCheckpoint = vi.fn().mockResolvedValue([]);
mockDeleteCheckpoint = vi.fn().mockResolvedValue(true);
mockContext = createMockCommandContext({
services: {
config: {
getProjectRoot: () => '/project/root',
getGeminiClient: () =>
({
getChat: mockGetChat,
}) as unknown as GeminiClient,
storage: {
getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash',
},
},
logger: {
saveCheckpoint: mockSaveCheckpoint,
loadCheckpoint: mockLoadCheckpoint,
deleteCheckpoint: mockDeleteCheckpoint,
initialize: vi.fn().mockResolvedValue(undefined),
},
},
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should have the correct main command definition', () => {
expect(chatCommand.name).toBe('chat');
expect(chatCommand.description).toBe('Manage conversation history.');
expect(chatCommand.subCommands).toHaveLength(5);
});
describe('list subcommand', () => {
let listCommand: SlashCommand;
beforeEach(() => {
listCommand = getSubCommand('list');
});
it('should inform when no checkpoints are found', async () => {
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
[] as string[]) as unknown as typeof fsPromises.readdir,
);
const result = await listCommand?.action?.(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No saved conversation checkpoints found.',
});
});
it('should list found checkpoints', async () => {
const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json'];
const date = new Date();
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation((async (path: string): Promise<Stats> => {
if (path.endsWith('test1.json')) {
return { mtime: date } as Stats;
}
return { mtime: new Date(date.getTime() + 1000) } as Stats;
}) as unknown as typeof fsPromises.stat);
const result = (await listCommand?.action?.(
mockContext,
'',
)) as MessageActionReturn;
const content = result?.content ?? '';
expect(result?.type).toBe('message');
expect(content).toContain('List of saved conversations:');
const isoDate = date
.toISOString()
.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
const formattedDate = isoDate ? `${isoDate[1]} ${isoDate[2]}` : '';
expect(content).toContain(formattedDate);
const index1 = content.indexOf('- test1');
const index2 = content.indexOf('- test2');
expect(index1).toBeGreaterThanOrEqual(0);
expect(index2).toBeGreaterThan(index1);
});
it('should handle invalid date formats gracefully', async () => {
const fakeFiles = ['checkpoint-baddate.json'];
const badDate = {
toISOString: () => 'an-invalid-date-string',
} as Date;
mockFs.readdir.mockResolvedValue(fakeFiles);
mockFs.stat.mockResolvedValue({ mtime: badDate } as Stats);
const result = (await listCommand?.action?.(
mockContext,
'',
)) as MessageActionReturn;
const content = result?.content ?? '';
expect(content).toContain('(saved on Invalid Date)');
});
});
describe('save subcommand', () => {
let saveCommand: SlashCommand;
const tag = 'my-tag';
let mockCheckpointExists: ReturnType<typeof vi.fn>;
beforeEach(() => {
saveCommand = getSubCommand('save');
mockCheckpointExists = vi.fn().mockResolvedValue(false);
mockContext.services.logger.checkpointExists = mockCheckpointExists;
});
it('should return an error if tag is missing', async () => {
const result = await saveCommand?.action?.(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat save <tag>',
});
});
it('should inform if conversation history is empty or only contains system context', async () => {
mockGetHistory.mockReturnValue([]);
let result = await saveCommand?.action?.(mockContext, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found to save.',
});
mockGetHistory.mockReturnValue([
{ role: 'user', parts: [{ text: 'context for our chat' }] },
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
]);
result = await saveCommand?.action?.(mockContext, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found to save.',
});
mockGetHistory.mockReturnValue([
{ role: 'user', parts: [{ text: 'context for our chat' }] },
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
{ role: 'user', parts: [{ text: 'Hello, how are you?' }] },
]);
result = await saveCommand?.action?.(mockContext, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${tag}.`,
});
});
it('should return confirm_action if checkpoint already exists', async () => {
mockCheckpointExists.mockResolvedValue(true);
mockContext.invocation = {
raw: `/chat save ${tag}`,
name: 'save',
args: tag,
};
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
expect(mockSaveCheckpoint).not.toHaveBeenCalled();
expect(result).toMatchObject({
type: 'confirm_action',
originalInvocation: { raw: `/chat save ${tag}` },
});
// Check that prompt is a React element
expect(result).toHaveProperty('prompt');
});
it('should save the conversation if overwrite is confirmed', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'context for our chat' }] },
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
];
mockGetHistory.mockReturnValue(history);
mockContext.overwriteConfirmed = true;
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${tag}.`,
});
});
});
describe('resume subcommand', () => {
const goodTag = 'good-tag';
const badTag = 'bad-tag';
let resumeCommand: SlashCommand;
beforeEach(() => {
resumeCommand = getSubCommand('resume');
});
it('should return an error if tag is missing', async () => {
const result = await resumeCommand?.action?.(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat resume <tag>',
});
});
it('should inform if checkpoint is not found', async () => {
mockLoadCheckpoint.mockResolvedValue([]);
const result = await resumeCommand?.action?.(mockContext, badTag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `No saved checkpoint found with tag: ${badTag}.`,
});
});
it('should resume a conversation', async () => {
const conversation: Content[] = [
{ role: 'user', parts: [{ text: 'hello gemini' }] },
{ role: 'model', parts: [{ text: 'hello world' }] },
];
mockLoadCheckpoint.mockResolvedValue(conversation);
const result = await resumeCommand?.action?.(mockContext, goodTag);
expect(result).toEqual({
type: 'load_history',
history: [
{ type: 'user', text: 'hello gemini' },
{ type: 'gemini', text: 'hello world' },
] as HistoryItemWithoutId[],
clientHistory: conversation,
});
});
describe('completion', () => {
it('should provide completion suggestions', async () => {
const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation(
(async (_: string): Promise<Stats> =>
({
mtime: new Date(),
}) as Stats) as unknown as typeof fsPromises.stat,
);
const result = await resumeCommand?.completion?.(mockContext, 'a');
expect(result).toEqual(['alpha']);
});
it('should suggest filenames sorted by modified time (newest first)', async () => {
const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json'];
const date = new Date();
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation((async (
path: string,
): Promise<Stats> => {
if (path.endsWith('test1.json')) {
return { mtime: date } as Stats;
}
return { mtime: new Date(date.getTime() + 1000) } as Stats;
}) as unknown as typeof fsPromises.stat);
const result = await resumeCommand?.completion?.(mockContext, '');
// Sort items by last modified time (newest first)
expect(result).toEqual(['test2', 'test1']);
});
});
});
describe('delete subcommand', () => {
let deleteCommand: SlashCommand;
const tag = 'my-tag';
beforeEach(() => {
deleteCommand = getSubCommand('delete');
});
it('should return an error if tag is missing', async () => {
const result = await deleteCommand?.action?.(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
});
});
it('should return an error if checkpoint is not found', async () => {
mockDeleteCheckpoint.mockResolvedValue(false);
const result = await deleteCommand?.action?.(mockContext, tag);
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `Error: No checkpoint found with tag '${tag}'.`,
});
});
it('should delete the conversation', async () => {
const result = await deleteCommand?.action?.(mockContext, tag);
expect(mockDeleteCheckpoint).toHaveBeenCalledWith(tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation checkpoint '${tag}' has been deleted.`,
});
});
describe('completion', () => {
it('should provide completion suggestions', async () => {
const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation(
(async (_: string): Promise<Stats> =>
({
mtime: new Date(),
}) as Stats) as unknown as typeof fsPromises.stat,
);
const result = await deleteCommand?.completion?.(mockContext, 'a');
expect(result).toEqual(['alpha']);
});
});
});
describe('share subcommand', () => {
let shareCommand: SlashCommand;
const mockHistory = [
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
];
beforeEach(() => {
shareCommand = getSubCommand('share');
vi.spyOn(process, 'cwd').mockReturnValue(
path.resolve('/usr/local/google/home/myuser/gemini-cli'),
);
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
mockGetHistory.mockReturnValue(mockHistory);
mockFs.writeFile.mockClear();
});
it('should default to a json file if no path is provided', async () => {
const result = await shareCommand?.action?.(mockContext, '');
const expectedPath = path.join(
process.cwd(),
'gemini-conversation-1234567890.json',
);
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a JSON file', async () => {
const filePath = 'my-chat.json';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a Markdown file', async () => {
const filePath = 'my-chat.md';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const expectedContent = `🧑‍💻 ## USER
context
---
✨ ## MODEL
context response
---
🧑‍💻 ## USER
Hello
---
✨ ## MODEL
Hi there!`;
expect(actualContent).toEqual(expectedContent);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should return an error for unsupported file extensions', async () => {
const filePath = 'my-chat.txt';
const result = await shareCommand?.action?.(mockContext, filePath);
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
});
});
it('should inform if there is no conversation to share', async () => {
mockGetHistory.mockReturnValue([
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
]);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
});
});
it('should handle errors during file writing', async () => {
const error = new Error('Permission denied');
mockFs.writeFile.mockRejectedValue(error);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${error.message}`,
});
});
it('should output valid JSON schema', async () => {
const filePath = 'my-chat.json';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const parsedContent = JSON.parse(actualContent);
expect(Array.isArray(parsedContent)).toBe(true);
parsedContent.forEach((item: Content) => {
expect(item).toHaveProperty('role');
expect(item).toHaveProperty('parts');
expect(Array.isArray(item.parts)).toBe(true);
});
});
it('should output correct markdown format', async () => {
const filePath = 'my-chat.md';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const entries = actualContent.split('\n\n---\n\n');
expect(entries.length).toBe(mockHistory.length);
entries.forEach((entry, index) => {
const { role, parts } = mockHistory[index];
const text = parts.map((p) => p.text).join('');
const roleIcon = role === 'user' ? '🧑‍💻' : '✨';
expect(entry).toBe(`${roleIcon} ## ${role.toUpperCase()}\n\n${text}`);
});
});
});
describe('serializeHistoryToMarkdown', () => {
it('should correctly serialize chat history to Markdown with icons', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown =
'🧑‍💻 ## USER\n\nHello\n\n---\n\n' +
'✨ ## MODEL\n\nHi there!\n\n---\n\n' +
'🧑‍💻 ## USER\n\nHow are you?';
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should handle empty history', () => {
const history: Content[] = [];
const result = serializeHistoryToMarkdown(history);
expect(result).toBe('');
});
it('should handle items with no text parts', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown = `🧑‍💻 ## USER
Hello
---
✨ ## MODEL
---
🧑‍💻 ## USER
How are you?`;
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should correctly serialize function calls and responses', () => {
const history: Content[] = [
{
role: 'user',
parts: [{ text: 'Please call a function.' }],
},
{
role: 'model',
parts: [
{
functionCall: {
name: 'my-function',
args: { arg1: 'value1' },
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'my-function',
response: { result: 'success' },
},
},
],
},
];
const expectedMarkdown = `🧑‍💻 ## USER
Please call a function.
---
✨ ## MODEL
**Tool Command**:
\`\`\`json
{
"name": "my-function",
"args": {
"arg1": "value1"
}
}
\`\`\`
---
🧑‍💻 ## USER
**Tool Response**:
\`\`\`json
{
"name": "my-function",
"response": {
"result": "success"
}
}
\`\`\``;
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should handle items with undefined role', () => {
const history: Array<Partial<Content>> = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ parts: [{ text: 'Hi there!' }] },
];
const expectedMarkdown = `🧑‍💻 ## USER
Hello
---
✨ ## MODEL
Hi there!`;
const result = serializeHistoryToMarkdown(history as Content[]);
expect(result).toBe(expectedMarkdown);
});
});
});

View File

@@ -1,419 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fsPromises from 'node:fs/promises';
import React from 'react';
import { Text } from 'ink';
import type {
CommandContext,
SlashCommand,
MessageActionReturn,
SlashCommandActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { decodeTagName } from '@qwen-code/qwen-code-core';
import path from 'node:path';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
import type { Content } from '@google/genai';
import { t } from '../../i18n/index.js';
interface ChatDetail {
name: string;
mtime: Date;
}
const getSavedChatTags = async (
context: CommandContext,
mtSortDesc: boolean,
): Promise<ChatDetail[]> => {
const cfg = context.services.config;
const geminiDir = cfg?.storage?.getProjectTempDir();
if (!geminiDir) {
return [];
}
try {
const file_head = 'checkpoint-';
const file_tail = '.json';
const files = await fsPromises.readdir(geminiDir);
const chatDetails: Array<{ name: string; mtime: Date }> = [];
for (const file of files) {
if (file.startsWith(file_head) && file.endsWith(file_tail)) {
const filePath = path.join(geminiDir, file);
const stats = await fsPromises.stat(filePath);
const tagName = file.slice(file_head.length, -file_tail.length);
chatDetails.push({
name: decodeTagName(tagName),
mtime: stats.mtime,
});
}
}
chatDetails.sort((a, b) =>
mtSortDesc
? b.mtime.getTime() - a.mtime.getTime()
: a.mtime.getTime() - b.mtime.getTime(),
);
return chatDetails;
} catch (_err) {
return [];
}
};
const listCommand: SlashCommand = {
name: 'list',
get description() {
return t('List saved conversation checkpoints');
},
kind: CommandKind.BUILT_IN,
action: async (context): Promise<MessageActionReturn> => {
const chatDetails = await getSavedChatTags(context, false);
if (chatDetails.length === 0) {
return {
type: 'message',
messageType: 'info',
content: t('No saved conversation checkpoints found.'),
};
}
const maxNameLength = Math.max(
...chatDetails.map((chat) => chat.name.length),
);
let message = t('List of saved conversations:') + '\n\n';
for (const chat of chatDetails) {
const paddedName = chat.name.padEnd(maxNameLength, ' ');
const isoString = chat.mtime.toISOString();
const match = isoString.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date';
message += ` - ${paddedName} (saved on ${formattedDate})\n`;
}
message += `\n${t('Note: Newest last, oldest first')}`;
return {
type: 'message',
messageType: 'info',
content: message,
};
},
};
const saveCommand: SlashCommand = {
name: 'save',
get description() {
return t(
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
);
},
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: t('Missing tag. Usage: /chat save <tag>'),
};
}
const { logger, config } = context.services;
await logger.initialize();
if (!context.overwriteConfirmed) {
const exists = await logger.checkpointExists(tag);
if (exists) {
return {
type: 'confirm_action',
prompt: React.createElement(
Text,
null,
t(
'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?',
{
tag,
},
),
),
originalInvocation: {
raw: context.invocation?.raw || `/chat save ${tag}`,
},
};
}
}
const chat = await config?.getGeminiClient()?.getChat();
if (!chat) {
return {
type: 'message',
messageType: 'error',
content: t('No chat client available to save conversation.'),
};
}
const history = chat.getHistory();
if (history.length > 2) {
await logger.saveCheckpoint(history, tag);
return {
type: 'message',
messageType: 'info',
content: t('Conversation checkpoint saved with tag: {{tag}}.', {
tag: decodeTagName(tag),
}),
};
} else {
return {
type: 'message',
messageType: 'info',
content: t('No conversation found to save.'),
};
}
},
};
const resumeCommand: SlashCommand = {
name: 'resume',
altNames: ['load'],
get description() {
return t(
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
);
},
kind: CommandKind.BUILT_IN,
action: async (context, args) => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: t('Missing tag. Usage: /chat resume <tag>'),
};
}
const { logger } = context.services;
await logger.initialize();
const conversation = await logger.loadCheckpoint(tag);
if (conversation.length === 0) {
return {
type: 'message',
messageType: 'info',
content: t('No saved checkpoint found with tag: {{tag}}.', {
tag: decodeTagName(tag),
}),
};
}
const rolemap: { [key: string]: MessageType } = {
user: MessageType.USER,
model: MessageType.GEMINI,
};
const uiHistory: HistoryItemWithoutId[] = [];
let hasSystemPrompt = false;
let i = 0;
for (const item of conversation) {
i += 1;
const text =
item.parts
?.filter((m) => !!m.text)
.map((m) => m.text)
.join('') || '';
if (!text) {
continue;
}
if (i === 1 && text.match(/context for our chat/)) {
hasSystemPrompt = true;
}
if (i > 2 || !hasSystemPrompt) {
uiHistory.push({
type: (item.role && rolemap[item.role]) || MessageType.GEMINI,
text,
} as HistoryItemWithoutId);
}
}
return {
type: 'load_history',
history: uiHistory,
clientHistory: conversation,
};
},
completion: async (context, partialArg) => {
const chatDetails = await getSavedChatTags(context, true);
return chatDetails
.map((chat) => chat.name)
.filter((name) => name.startsWith(partialArg));
},
};
const deleteCommand: SlashCommand = {
name: 'delete',
get description() {
return t('Delete a conversation checkpoint. Usage: /chat delete <tag>');
},
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: t('Missing tag. Usage: /chat delete <tag>'),
};
}
const { logger } = context.services;
await logger.initialize();
const deleted = await logger.deleteCheckpoint(tag);
if (deleted) {
return {
type: 'message',
messageType: 'info',
content: t("Conversation checkpoint '{{tag}}' has been deleted.", {
tag: decodeTagName(tag),
}),
};
} else {
return {
type: 'message',
messageType: 'error',
content: t("Error: No checkpoint found with tag '{{tag}}'.", {
tag: decodeTagName(tag),
}),
};
}
},
completion: async (context, partialArg) => {
const chatDetails = await getSavedChatTags(context, true);
return chatDetails
.map((chat) => chat.name)
.filter((name) => name.startsWith(partialArg));
},
};
export function serializeHistoryToMarkdown(history: Content[]): string {
return history
.map((item) => {
const text =
item.parts
?.map((part) => {
if (part.text) {
return part.text;
}
if (part.functionCall) {
return `**Tool Command**:\n\`\`\`json\n${JSON.stringify(
part.functionCall,
null,
2,
)}\n\`\`\``;
}
if (part.functionResponse) {
return `**Tool Response**:\n\`\`\`json\n${JSON.stringify(
part.functionResponse,
null,
2,
)}\n\`\`\``;
}
return '';
})
.join('') || '';
const roleIcon = item.role === 'user' ? '🧑‍💻' : '✨';
return `${roleIcon} ## ${(item.role || 'model').toUpperCase()}\n\n${text}`;
})
.join('\n\n---\n\n');
}
const shareCommand: SlashCommand = {
name: 'share',
get description() {
return t(
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
);
},
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
let filePathArg = args.trim();
if (!filePathArg) {
filePathArg = `gemini-conversation-${Date.now()}.json`;
}
const filePath = path.resolve(filePathArg);
const extension = path.extname(filePath);
if (extension !== '.md' && extension !== '.json') {
return {
type: 'message',
messageType: 'error',
content: t('Invalid file format. Only .md and .json are supported.'),
};
}
const chat = await context.services.config?.getGeminiClient()?.getChat();
if (!chat) {
return {
type: 'message',
messageType: 'error',
content: t('No chat client available to share conversation.'),
};
}
const history = chat.getHistory();
// An empty conversation has two hidden messages that setup the context for
// the chat. Thus, to check whether a conversation has been started, we
// can't check for length 0.
if (history.length <= 2) {
return {
type: 'message',
messageType: 'info',
content: t('No conversation found to share.'),
};
}
let content = '';
if (extension === '.json') {
content = JSON.stringify(history, null, 2);
} else {
content = serializeHistoryToMarkdown(history);
}
try {
await fsPromises.writeFile(filePath, content);
return {
type: 'message',
messageType: 'info',
content: t('Conversation shared to {{filePath}}', {
filePath,
}),
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
type: 'message',
messageType: 'error',
content: t('Error sharing conversation: {{error}}', {
error: errorMessage,
}),
};
}
},
};
export const chatCommand: SlashCommand = {
name: 'chat',
get description() {
return t('Manage conversation history.');
},
kind: CommandKind.BUILT_IN,
subCommands: [
listCommand,
saveCommand,
resumeCommand,
deleteCommand,
shareCommand,
],
};

View File

@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { clearCommand } from './clearCommand.js';
import { type CommandContext } from './types.js';
@@ -16,20 +15,21 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
return {
...actual,
uiTelemetryService: {
setLastPromptTokenCount: vi.fn(),
reset: vi.fn(),
},
};
});
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
describe('clearCommand', () => {
let mockContext: CommandContext;
let mockResetChat: ReturnType<typeof vi.fn>;
let mockStartNewSession: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockResetChat = vi.fn().mockResolvedValue(undefined);
mockStartNewSession = vi.fn().mockReturnValue('new-session-id');
vi.clearAllMocks();
mockContext = createMockCommandContext({
@@ -39,12 +39,16 @@ describe('clearCommand', () => {
({
resetChat: mockResetChat,
}) as unknown as GeminiClient,
startNewSession: mockStartNewSession,
},
},
session: {
startNewSession: vi.fn(),
},
});
});
it('should set debug message, reset chat, reset telemetry, and clear UI when config is available', async () => {
it('should set debug message, start a new session, reset chat, and clear UI when config is available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
@@ -52,28 +56,23 @@ describe('clearCommand', () => {
await clearCommand.action(mockContext, '');
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledWith(
'Clearing terminal and resetting chat.',
'Starting a new session, resetting chat, and clearing terminal.',
);
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
expect(mockStartNewSession).toHaveBeenCalledTimes(1);
expect(mockContext.session.startNewSession).toHaveBeenCalledWith(
'new-session-id',
);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
// Check the order of operations.
const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock
.invocationCallOrder[0];
const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
const resetTelemetryOrder = (
uiTelemetryService.setLastPromptTokenCount as Mock
).mock.invocationCallOrder[0];
const clearOrder = (mockContext.ui.clear as Mock).mock
.invocationCallOrder[0];
expect(setDebugMessageOrder).toBeLessThan(resetChatOrder);
expect(resetChatOrder).toBeLessThan(resetTelemetryOrder);
expect(resetTelemetryOrder).toBeLessThan(clearOrder);
// Check that all expected operations were called
expect(mockContext.ui.setDebugMessage).toHaveBeenCalled();
expect(mockStartNewSession).toHaveBeenCalled();
expect(mockContext.session.startNewSession).toHaveBeenCalled();
expect(mockResetChat).toHaveBeenCalled();
expect(mockContext.ui.clear).toHaveBeenCalled();
});
it('should not attempt to reset chat if config service is not available', async () => {
@@ -85,16 +84,17 @@ describe('clearCommand', () => {
services: {
config: null,
},
session: {
startNewSession: vi.fn(),
},
});
await clearCommand.action(nullConfigContext, '');
expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith(
'Clearing terminal.',
'Starting a new session and clearing.',
);
expect(mockResetChat).not.toHaveBeenCalled();
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
});
});

View File

@@ -4,30 +4,46 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
export const clearCommand: SlashCommand = {
name: 'clear',
altNames: ['reset', 'new'],
get description() {
return t('clear the screen and conversation history');
return t('Clear conversation history and free up context');
},
kind: CommandKind.BUILT_IN,
action: async (context, _args) => {
const geminiClient = context.services.config?.getGeminiClient();
const { config } = context.services;
if (geminiClient) {
context.ui.setDebugMessage(t('Clearing terminal and resetting chat.'));
// If resetChat fails, the exception will propagate and halt the command,
// which is the correct behavior to signal a failure to the user.
await geminiClient.resetChat();
if (config) {
const newSessionId = config.startNewSession();
// Reset UI telemetry metrics for the new session
uiTelemetryService.reset();
if (newSessionId && context.session.startNewSession) {
context.session.startNewSession(newSessionId);
}
const geminiClient = config.getGeminiClient();
if (geminiClient) {
context.ui.setDebugMessage(
t('Starting a new session, resetting chat, and clearing terminal.'),
);
// If resetChat fails, the exception will propagate and halt the command,
// which is the correct behavior to signal a failure to the user.
await geminiClient.resetChat();
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}
} else {
context.ui.setDebugMessage(t('Clearing terminal.'));
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}
uiTelemetryService.setLastPromptTokenCount(0);
context.ui.clear();
},
};

View File

@@ -37,7 +37,7 @@ export interface CommandContext {
config: Config | null;
settings: LoadedSettings;
git: GitService | undefined;
logger: Logger;
logger: Logger | null;
};
// UI state and history management
ui: {
@@ -78,6 +78,8 @@ export interface CommandContext {
stats: SessionStatsState;
/** A transient list of shell commands the user has approved for this session. */
sessionShellAllowlist: Set<string>;
/** Reset session metrics and prompt counters for a fresh session. */
startNewSession?: (sessionId: string) => void;
};
// Flag to indicate if an overwrite has been confirmed
overwriteConfirmed?: boolean;
@@ -214,7 +216,7 @@ export interface SlashCommand {
| SlashCommandActionReturn
| Promise<void | SlashCommandActionReturn>;
// Provides argument completion (e.g., completing a tag for `/chat resume <tag>`).
// Provides argument completion
completion?: (
context: CommandContext,
partialArg: string,

View File

@@ -135,8 +135,6 @@ export const DialogManager = ({
uiState.quitConfirmationRequest?.onConfirm(false, 'cancel');
} else if (choice === QuitChoice.QUIT) {
uiState.quitConfirmationRequest?.onConfirm(true, 'quit');
} else if (choice === QuitChoice.SAVE_AND_QUIT) {
uiState.quitConfirmationRequest?.onConfirm(true, 'save_and_quit');
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
uiState.quitConfirmationRequest?.onConfirm(
true,

View File

@@ -17,6 +17,7 @@ const mockCommands: readonly SlashCommand[] = [
name: 'test',
description: 'A test command',
kind: CommandKind.BUILT_IN,
altNames: ['alias-one', 'alias-two'],
},
{
name: 'hidden',
@@ -60,4 +61,11 @@ describe('Help Component', () => {
expect(output).toContain('visible-child');
expect(output).not.toContain('hidden-child');
});
it('should render alt names for commands when available', () => {
const { lastFrame } = render(<Help commands={mockCommands} />);
const output = lastFrame();
expect(output).toContain('/test (alias-one, alias-two)');
});
});

View File

@@ -67,7 +67,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{' '}
/{command.name}
{formatCommandLabel(command, '/')}
</Text>
{command.kind === CommandKind.MCP_PROMPT && (
<Text color={theme.text.secondary}> [MCP]</Text>
@@ -81,7 +81,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Text key={subCommand.name} color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{' '}
{subCommand.name}
{formatCommandLabel(subCommand)}
</Text>
{subCommand.description && ' - ' + subCommand.description}
</Text>
@@ -171,3 +171,17 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>
</Box>
);
/**
* Builds a display label for a slash command, including any alternate names.
*/
function formatCommandLabel(command: SlashCommand, prefix = ''): string {
const altNames = command.altNames?.filter(Boolean);
const baseLabel = `${prefix}${command.name}`;
if (!altNames || altNames.length === 0) {
return baseLabel;
}
return `${baseLabel} (${altNames.join(', ')})`;
}

View File

@@ -66,20 +66,6 @@ const mockSlashCommands: SlashCommand[] = [
},
],
},
{
name: 'chat',
description: 'Manage chats',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'resume',
description: 'Resume a chat',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
completion: async () => ['fix-foo', 'fix-bar'],
},
],
},
];
describe('InputPrompt', () => {
@@ -571,14 +557,14 @@ describe('InputPrompt', () => {
});
it('should complete a partial argument for a command', async () => {
// SCENARIO: /chat resume fi- -> Tab
// SCENARIO: /memory add fi- -> Tab
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('/chat resume fi-');
props.buffer.setText('/memory add fi-');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();

View File

@@ -17,7 +17,6 @@ import { t } from '../../i18n/index.js';
export enum QuitChoice {
CANCEL = 'cancel',
QUIT = 'quit',
SAVE_AND_QUIT = 'save_and_quit',
SUMMARY_AND_QUIT = 'summary_and_quit',
}
@@ -48,11 +47,6 @@ export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
label: t('Generate summary and quit (/summary)'),
value: QuitChoice.SUMMARY_AND_QUIT,
},
{
key: 'save-and-quit',
label: t('Save conversation and quit (/chat save)'),
value: QuitChoice.SAVE_AND_QUIT,
},
{
key: 'cancel',
label: t('Cancel (stay in application)'),

View File

@@ -0,0 +1,436 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
import {
SessionService,
type SessionListItem,
type ListSessionsResult,
getGitBranch,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { formatRelativeTime } from '../utils/formatters.js';
const PAGE_SIZE = 20;
interface SessionPickerProps {
sessionService: SessionService;
currentBranch?: string;
onSelect: (sessionId: string) => void;
onCancel: () => void;
}
/**
* Truncates text to fit within a given width, adding ellipsis if needed.
*/
function truncateText(text: string, maxWidth: number): string {
if (text.length <= maxWidth) return text;
if (maxWidth <= 3) return text.slice(0, maxWidth);
return text.slice(0, maxWidth - 3) + '...';
}
function SessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
}: SessionPickerProps): React.JSX.Element {
const { exit } = useApp();
const [selectedIndex, setSelectedIndex] = useState(0);
const [sessionState, setSessionState] = useState<{
sessions: SessionListItem[];
hasMore: boolean;
nextCursor?: number;
}>({
sessions: [],
hasMore: true,
nextCursor: undefined,
});
const isLoadingMoreRef = useRef(false);
const [filterByBranch, setFilterByBranch] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [terminalSize, setTerminalSize] = useState({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
// Update terminal size on resize
useEffect(() => {
const handleResize = () => {
setTerminalSize({
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
});
};
process.stdout.on('resize', handleResize);
return () => {
process.stdout.off('resize', handleResize);
};
}, []);
// Filter sessions by current branch if filter is enabled
const filteredSessions =
filterByBranch && currentBranch
? sessionState.sessions.filter(
(session) => session.gitBranch === currentBranch,
)
: sessionState.sessions;
const hasSentinel = sessionState.hasMore;
// Reset selection when filter changes
useEffect(() => {
setSelectedIndex(0);
}, [filterByBranch]);
const loadMoreSessions = useCallback(async () => {
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
isLoadingMoreRef.current = true;
try {
const result: ListSessionsResult = await sessionService.listSessions({
size: PAGE_SIZE,
cursor: sessionState.nextCursor,
});
setSessionState((prev) => ({
sessions: [...prev.sessions, ...result.items],
hasMore: result.hasMore && result.nextCursor !== undefined,
nextCursor: result.nextCursor,
}));
} finally {
isLoadingMoreRef.current = false;
}
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
// Calculate visible items
// Reserved space: header (1), footer (1), separators (2), borders (2)
const reservedLines = 6;
// Each item takes 2 lines (prompt + metadata) + 1 line margin between items
// On average, this is ~3 lines per item, but the last item has no margin
const itemHeight = 3;
const maxVisibleItems = Math.max(
1,
Math.floor((terminalSize.height - reservedLines) / itemHeight),
);
// Calculate scroll offset
const scrollOffset = (() => {
if (filteredSessions.length <= maxVisibleItems) return 0;
const halfVisible = Math.floor(maxVisibleItems / 2);
let offset = selectedIndex - halfVisible;
offset = Math.max(0, offset);
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
return offset;
})();
const visibleSessions = filteredSessions.slice(
scrollOffset,
scrollOffset + maxVisibleItems,
);
const showScrollUp = scrollOffset > 0;
const showScrollDown =
scrollOffset + maxVisibleItems < filteredSessions.length;
// Sentinel (invisible) sits after the last session item; consider it visible
// once the viewport reaches the final real item.
const sentinelVisible =
hasSentinel && scrollOffset + maxVisibleItems >= filteredSessions.length;
// Load more when sentinel enters view or when filtered list is empty.
useEffect(() => {
if (!sessionState.hasMore || isLoadingMoreRef.current) return;
const shouldLoadMore =
filteredSessions.length === 0 ||
sentinelVisible ||
isLoadingMoreRef.current;
if (shouldLoadMore) {
void loadMoreSessions();
}
}, [
filteredSessions.length,
loadMoreSessions,
sessionState.hasMore,
sentinelVisible,
]);
// Handle keyboard input
useInput((input, key) => {
// Ignore input if already exiting
if (isExiting) {
return;
}
// Escape or Ctrl+C to cancel
if (key.escape || (key.ctrl && input === 'c')) {
setIsExiting(true);
onCancel();
exit();
return;
}
if (key.return) {
const session = filteredSessions[selectedIndex];
if (session) {
setIsExiting(true);
onSelect(session.sessionId);
exit();
}
return;
}
if (key.upArrow || input === 'k') {
setSelectedIndex((prev) => Math.max(0, prev - 1));
return;
}
if (key.downArrow || input === 'j') {
if (filteredSessions.length === 0) {
return;
}
setSelectedIndex((prev) =>
Math.min(filteredSessions.length - 1, prev + 1),
);
return;
}
if (input === 'b' || input === 'B') {
if (currentBranch) {
setFilterByBranch((prev) => !prev);
}
return;
}
});
// Filtered sessions may have changed, ensure selectedIndex is valid
useEffect(() => {
if (
selectedIndex >= filteredSessions.length &&
filteredSessions.length > 0
) {
setSelectedIndex(filteredSessions.length - 1);
}
}, [filteredSessions.length, selectedIndex]);
// Calculate content width (terminal width minus border padding)
const contentWidth = terminalSize.width - 4;
const promptMaxWidth = contentWidth - 4; // Account for " " prefix
// Return empty while exiting to prevent visual glitches
if (isExiting) {
return <Box />;
}
return (
<Box
flexDirection="column"
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Main container with single border */}
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={terminalSize.width}
height={terminalSize.height - 1}
overflow="hidden"
>
{/* Header row */}
<Box paddingX={1}>
<Text bold color={theme.text.primary}>
Resume Session
</Text>
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Session list with auto-scrolling */}
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{filteredSessions.length === 0 ? (
<Box paddingY={1} justifyContent="center">
<Text color={theme.text.secondary}>
{filterByBranch
? `No sessions found for branch "${currentBranch}"`
: 'No sessions found'}
</Text>
</Box>
) : (
visibleSessions.map((session, visibleIndex) => {
const actualIndex = scrollOffset + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const isFirst = visibleIndex === 0;
const isLast = visibleIndex === visibleSessions.length - 1;
const timeAgo = formatRelativeTime(session.mtime);
const messageText =
session.messageCount === 1
? '1 message'
: `${session.messageCount} messages`;
// Show scroll indicator on first/last visible items
const showUpIndicator = isFirst && showScrollUp;
const showDownIndicator = isLast && showScrollDown;
// Determine the prefix: selector takes priority over scroll indicator
const prefix = isSelected
? ' '
: showUpIndicator
? '↑ '
: showDownIndicator
? '↓ '
: ' ';
return (
<Box
key={session.sessionId}
flexDirection="column"
marginBottom={isLast ? 0 : 1}
>
{/* First line: prefix (selector or scroll indicator) + prompt text */}
<Box>
<Text
color={
isSelected
? theme.text.accent
: showUpIndicator || showDownIndicator
? theme.text.secondary
: undefined
}
>
{prefix}
</Text>
<Text
bold={isSelected}
color={
isSelected ? theme.text.accent : theme.text.primary
}
>
{truncateText(
session.prompt || '(empty prompt)',
promptMaxWidth,
)}
</Text>
</Box>
{/* Second line: metadata (aligned with prompt text) */}
<Box>
<Text>{' '}</Text>
<Text color={theme.text.secondary}>
{timeAgo} · {messageText}
{session.gitBranch && ` · ${session.gitBranch}`}
</Text>
</Box>
</Box>
);
})
)}
</Box>
{/* Separator line */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat(terminalSize.width - 2)}
</Text>
</Box>
{/* Footer with keyboard shortcuts */}
<Box paddingX={1}>
<Text color={theme.text.secondary}>
{currentBranch && (
<>
<Text
bold={filterByBranch}
color={filterByBranch ? theme.text.accent : undefined}
>
B
</Text>
{' to toggle branch · '}
</>
)}
{'↑↓ to navigate · Esc to cancel'}
</Text>
</Box>
</Box>
</Box>
);
}
/**
* Clears the terminal screen.
*/
function clearScreen(): void {
// Move cursor to home position and clear screen
process.stdout.write('\x1b[2J\x1b[H');
}
/**
* Shows an interactive session picker and returns the selected session ID.
* Returns undefined if the user cancels or no sessions are available.
*/
export async function showResumeSessionPicker(
cwd: string = process.cwd(),
): Promise<string | undefined> {
const sessionService = new SessionService(cwd);
const hasSession = await sessionService.loadLastSession();
if (!hasSession) {
console.log('No sessions found. Start a new session with `qwen`.');
return undefined;
}
const currentBranch = getGitBranch(cwd);
// Clear the screen before showing the picker for a clean fullscreen experience
clearScreen();
// Enable raw mode for keyboard input if not already enabled
const wasRaw = process.stdin.isRaw;
if (process.stdin.isTTY && !wasRaw) {
process.stdin.setRawMode(true);
}
return new Promise<string | undefined>((resolve) => {
let selectedId: string | undefined;
const { unmount, waitUntilExit } = render(
<SessionPicker
sessionService={sessionService}
currentBranch={currentBranch}
onSelect={(id) => {
selectedId = id;
}}
onCancel={() => {
selectedId = undefined;
}}
/>,
{
exitOnCtrlC: false,
},
);
waitUntilExit().then(() => {
unmount();
// Clear the screen after the picker closes for a clean fullscreen experience
clearScreen();
// Restore raw mode state only if we changed it and user cancelled
// (if user selected a session, main app will handle raw mode)
if (process.stdin.isTTY && !wasRaw && !selectedId) {
process.stdin.setRawMode(false);
}
resolve(selectedId);
});
});
}

View File

@@ -78,10 +78,11 @@ export function SuggestionsDisplay({
const isActive = originalIndex === activeIndex;
const isExpanded = originalIndex === expandedIndex;
const textColor = isActive ? theme.text.accent : theme.text.secondary;
const isLong = suggestion.value.length >= MAX_WIDTH;
const displayLabel = suggestion.label ?? suggestion.value;
const isLong = displayLabel.length >= MAX_WIDTH;
const labelElement = (
<PrepareLabel
label={suggestion.value}
label={displayLabel}
matchedIndex={suggestion.matchedIndex}
userInput={userInput}
textColor={textColor}

View File

@@ -84,6 +84,7 @@ describe('SessionStatsContext', () => {
accept: 1,
reject: 0,
modify: 0,
auto_accept: 0,
},
byName: {
'test-tool': {
@@ -95,10 +96,15 @@ describe('SessionStatsContext', () => {
accept: 1,
reject: 0,
modify: 0,
auto_accept: 0,
},
},
},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
act(() => {
@@ -152,9 +158,13 @@ describe('SessionStatsContext', () => {
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
act(() => {

View File

@@ -19,7 +19,7 @@ import type {
ModelMetrics,
ToolCallStats,
} from '@qwen-code/qwen-code-core';
import { uiTelemetryService, sessionId } from '@qwen-code/qwen-code-core';
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
export enum ToolCallDecision {
ACCEPT = 'accept',
@@ -168,6 +168,7 @@ export interface ComputedSessionStats {
// and the functions to update it.
interface SessionStatsContextValue {
stats: SessionStatsState;
startNewSession: (sessionId: string) => void;
startNewPrompt: () => void;
getPromptCount: () => number;
}
@@ -178,18 +179,23 @@ const SessionStatsContext = createContext<SessionStatsContextValue | undefined>(
undefined,
);
const createDefaultStats = (sessionId: string = ''): SessionStatsState => ({
sessionId,
sessionStartTime: new Date(),
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: 0,
promptCount: 0,
});
// --- Provider Component ---
export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [stats, setStats] = useState<SessionStatsState>({
sessionId,
sessionStartTime: new Date(),
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: 0,
promptCount: 0,
});
export const SessionStatsProvider: React.FC<{
sessionId?: string;
children: React.ReactNode;
}> = ({ sessionId, children }) => {
const [stats, setStats] = useState<SessionStatsState>(() =>
createDefaultStats(sessionId ?? ''),
);
useEffect(() => {
const handleUpdate = ({
@@ -226,6 +232,13 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
};
}, []);
const startNewSession = useCallback((sessionId: string) => {
setStats(() => ({
...createDefaultStats(sessionId),
lastPromptTokenCount: uiTelemetryService.getLastPromptTokenCount(),
}));
}, []);
const startNewPrompt = useCallback(() => {
setStats((prevState) => ({
...prevState,
@@ -241,10 +254,11 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
const value = useMemo(
() => ({
stats,
startNewSession,
startNewPrompt,
getPromptCount,
}),
[stats, startNewPrompt, getPromptCount],
[stats, startNewSession, startNewPrompt, getPromptCount],
);
return (

View File

@@ -110,6 +110,9 @@ describe('useSlashCommandProcessor', () => {
const mockSetQuittingMessages = vi.fn();
const mockConfig = makeFakeConfig({});
mockConfig.getChatRecordingService = vi.fn().mockReturnValue({
recordSlashCommand: vi.fn(),
});
const mockSettings = {} as LoadedSettings;
beforeEach(() => {
@@ -305,11 +308,15 @@ describe('useSlashCommandProcessor', () => {
expect(childAction).toHaveBeenCalledWith(
expect.objectContaining({
invocation: expect.objectContaining({
name: 'child',
args: 'with args',
}),
services: expect.objectContaining({
config: mockConfig,
}),
ui: expect.objectContaining({
addItem: mockAddItem,
addItem: expect.any(Function),
}),
}),
'with args',

View File

@@ -6,17 +6,15 @@
import { useCallback, useMemo, useEffect, useState } from 'react';
import { type PartListUnion } from '@google/genai';
import process from 'node:process';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
type Logger,
type Config,
GitService,
Logger,
logSlashCommand,
makeSlashCommandEvent,
SlashCommandStatus,
ToolConfirmationOutcome,
Storage,
IdeClient,
} from '@qwen-code/qwen-code-core';
import { useSessionStats } from '../contexts/SessionContext.js';
@@ -41,6 +39,27 @@ import {
type ExtensionUpdateStatus,
} from '../state/extensions.js';
type SerializableHistoryItem = Record<string, unknown>;
function serializeHistoryItemForRecording(
item: Omit<HistoryItem, 'id'>,
): SerializableHistoryItem {
const clone: SerializableHistoryItem = { ...item };
if ('timestamp' in clone && clone['timestamp'] instanceof Date) {
clone['timestamp'] = clone['timestamp'].toISOString();
}
return clone;
}
const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'quit',
'quit-confirm',
'exit',
'clear',
'reset',
'new',
]);
interface SlashCommandProcessorActions {
openAuthDialog: () => void;
openThemeDialog: () => void;
@@ -75,8 +94,9 @@ export const useSlashCommandProcessor = (
actions: SlashCommandProcessorActions,
extensionsUpdateState: Map<string, ExtensionUpdateStatus>,
isConfigInitialized: boolean,
logger: Logger | null,
) => {
const session = useSessionStats();
const { stats: sessionStats, startNewSession } = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
const [reloadTrigger, setReloadTrigger] = useState(0);
@@ -110,16 +130,6 @@ export const useSlashCommandProcessor = (
return new GitService(config.getProjectRoot(), config.storage);
}, [config]);
const logger = useMemo(() => {
const l = new Logger(
config?.getSessionId() || '',
config?.storage ?? new Storage(process.cwd()),
);
// The logger's initialize is async, but we can create the instance
// synchronously. Commands that use it will await its initialization.
return l;
}, [config]);
const [pendingItem, setPendingItem] = useState<HistoryItemWithoutId | null>(
null,
);
@@ -218,8 +228,9 @@ export const useSlashCommandProcessor = (
actions.addConfirmUpdateExtensionRequest,
},
session: {
stats: session.stats,
stats: sessionStats,
sessionShellAllowlist,
startNewSession,
},
}),
[
@@ -231,7 +242,8 @@ export const useSlashCommandProcessor = (
addItem,
clearItems,
refreshStatic,
session.stats,
sessionStats,
startNewSession,
actions,
pendingItem,
setPendingItem,
@@ -302,10 +314,25 @@ export const useSlashCommandProcessor = (
return false;
}
const recordedItems: Array<Omit<HistoryItem, 'id'>> = [];
const recordItem = (item: Omit<HistoryItem, 'id'>) => {
recordedItems.push(item);
};
const addItemWithRecording: UseHistoryManagerReturn['addItem'] = (
item,
timestamp,
) => {
recordItem(item);
return addItem(item, timestamp);
};
setIsProcessing(true);
const userMessageTimestamp = Date.now();
addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp);
addItemWithRecording(
{ type: MessageType.USER, text: trimmed },
userMessageTimestamp,
);
let hasError = false;
const {
@@ -324,6 +351,10 @@ export const useSlashCommandProcessor = (
if (commandToExecute.action) {
const fullCommandContext: CommandContext = {
...commandContext,
ui: {
...commandContext.ui,
addItem: addItemWithRecording,
},
invocation: {
raw: trimmed,
name: commandToExecute.name,
@@ -428,15 +459,7 @@ export const useSlashCommandProcessor = (
return;
}
if (shouldQuit) {
if (action === 'save_and_quit') {
// First save conversation with auto-generated tag, then quit
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-');
const autoSaveTag = `auto-save chat ${timestamp}`;
handleSlashCommand(`/chat save "${autoSaveTag}"`);
setTimeout(() => handleSlashCommand('/quit'), 100);
} else if (action === 'summary_and_quit') {
if (action === 'summary_and_quit') {
// Generate summary and then quit
handleSlashCommand('/summary')
.then(() => {
@@ -447,7 +470,7 @@ export const useSlashCommandProcessor = (
})
.catch((error) => {
// If summary fails, still quit but show error
addItem(
addItemWithRecording(
{
type: 'error',
text: `Failed to generate summary before quit: ${
@@ -466,7 +489,7 @@ export const useSlashCommandProcessor = (
} else {
// Just quit immediately - trigger the actual quit action
const now = Date.now();
const { sessionStartTime } = session.stats;
const { sessionStartTime } = sessionStats;
const wallDuration = now - sessionStartTime.getTime();
actions.quit([
@@ -550,7 +573,7 @@ export const useSlashCommandProcessor = (
});
if (!confirmed) {
addItem(
addItemWithRecording(
{
type: MessageType.INFO,
text: 'Operation cancelled.',
@@ -606,7 +629,7 @@ export const useSlashCommandProcessor = (
});
logSlashCommand(config, event);
}
addItem(
addItemWithRecording(
{
type: MessageType.ERROR,
text: e instanceof Error ? e.message : String(e),
@@ -615,6 +638,38 @@ export const useSlashCommandProcessor = (
);
return { type: 'handled' };
} finally {
if (config?.getChatRecordingService) {
const chatRecorder = config.getChatRecordingService();
const primaryCommand =
resolvedCommandPath[0] ||
trimmed.replace(/^[/?]/, '').split(/\s+/)[0] ||
trimmed;
const shouldRecord =
!SLASH_COMMANDS_SKIP_RECORDING.has(primaryCommand);
try {
if (shouldRecord) {
chatRecorder?.recordSlashCommand({
phase: 'invocation',
rawCommand: trimmed,
});
const outputItems = recordedItems
.filter((item) => item.type !== 'user')
.map(serializeHistoryItemForRecording);
chatRecorder?.recordSlashCommand({
phase: 'result',
rawCommand: trimmed,
outputHistoryItems: outputItems,
});
}
} catch (recordError) {
if (config.getDebugMode()) {
console.error(
'[slashCommand] Failed to record slash command:',
recordError,
);
}
}
}
if (config && resolvedCommandPath[0] && !hasError) {
const event = makeSlashCommandEvent({
command: resolvedCommandPath[0],
@@ -637,7 +692,7 @@ export const useSlashCommandProcessor = (
setSessionShellAllowlist,
setIsProcessing,
setConfirmationRequest,
session.stats,
sessionStats,
],
);

View File

@@ -152,6 +152,9 @@ vi.mock('../contexts/SessionContext.js', () => ({
startNewPrompt: mockStartNewPrompt,
addUsage: mockAddUsage,
getPromptCount: vi.fn(() => 5),
stats: {
sessionId: 'test-session-id',
},
})),
}));
@@ -514,6 +517,7 @@ describe('useGeminiStream', () => {
expectedMergedResponse,
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: true },
);
});
@@ -840,6 +844,7 @@ describe('useGeminiStream', () => {
toolCallResponseParts,
expect.any(AbortSignal),
'prompt-id-4',
{ isContinuation: true },
);
});
@@ -1165,6 +1170,7 @@ describe('useGeminiStream', () => {
'This is the actual prompt from the command file.',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
@@ -1191,6 +1197,7 @@ describe('useGeminiStream', () => {
'',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});
@@ -1209,6 +1216,7 @@ describe('useGeminiStream', () => {
'// This is a line comment',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});
@@ -1227,6 +1235,7 @@ describe('useGeminiStream', () => {
'/* This is a block comment */',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});
@@ -2151,6 +2160,7 @@ describe('useGeminiStream', () => {
processedQueryParts, // Argument 1: The parts array directly
expect.any(AbortSignal), // Argument 2: An AbortSignal
expect.any(String), // Argument 3: The prompt_id string
undefined, // Argument 4: Options (undefined for normal prompts)
);
});
@@ -2509,6 +2519,7 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
// Verify only the first query was added to history
@@ -2560,12 +2571,14 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
2,
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
@@ -2588,6 +2601,7 @@ describe('useGeminiStream', () => {
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
);
});
});

View File

@@ -124,9 +124,13 @@ export const useGeminiStream = (
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const { startNewPrompt, getPromptCount } = useSessionStats();
const {
startNewPrompt,
getPromptCount,
stats: sessionStates,
} = useSessionStats();
const storage = config.storage;
const logger = useLogger(storage);
const logger = useLogger(storage, sessionStates.sessionId);
const gitService = useMemo(() => {
if (!config.getProjectRoot()) {
return;
@@ -849,21 +853,24 @@ export const useGeminiStream = (
const finalQueryToSend = queryToSend;
if (!options?.isContinuation) {
// trigger new prompt event for session stats in CLI
startNewPrompt();
// log user prompt event for telemetry, only text prompts for now
if (typeof queryToSend === 'string') {
// logging the text prompts only for now
const promptText = queryToSend;
logUserPrompt(
config,
new UserPromptEvent(
promptText.length,
queryToSend.length,
prompt_id,
config.getContentGeneratorConfig()?.authType,
promptText,
queryToSend,
),
);
}
startNewPrompt();
setThought(null); // Reset thought when starting a new prompt
// Reset thought when starting a new prompt
setThought(null);
}
setIsResponding(true);
@@ -874,6 +881,7 @@ export const useGeminiStream = (
finalQueryToSend,
abortSignal,
prompt_id!,
options,
);
const processingStatus = await processGeminiStreamEvents(
stream,

View File

@@ -6,15 +6,19 @@
import { useState, useEffect } from 'react';
import type { Storage } from '@qwen-code/qwen-code-core';
import { sessionId, Logger } from '@qwen-code/qwen-code-core';
import { Logger } from '@qwen-code/qwen-code-core';
/**
* Hook to manage the logger instance.
*/
export const useLogger = (storage: Storage) => {
export const useLogger = (storage: Storage, sessionId: string) => {
const [logger, setLogger] = useState<Logger | null>(null);
useEffect(() => {
if (!sessionId) {
return;
}
const newLogger = new Logger(sessionId, storage);
/**
* Start async initialization, no need to await. Using await slows down the
@@ -27,7 +31,7 @@ export const useLogger = (storage: Storage) => {
setLogger(newLogger);
})
.catch(() => {});
}, [storage]);
}, [storage, sessionId]);
return logger;
};

View File

@@ -21,8 +21,6 @@ export const useQuitConfirmation = () => {
return { shouldQuit: false, action: 'cancel' };
} else if (choice === QuitChoice.QUIT) {
return { shouldQuit: true, action: 'quit' };
} else if (choice === QuitChoice.SAVE_AND_QUIT) {
return { shouldQuit: true, action: 'save_and_quit' };
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
return { shouldQuit: true, action: 'summary_and_quit' };
}

View File

@@ -133,14 +133,14 @@ export function useReactToolScheduler(
const scheduler = useMemo(
() =>
new CoreToolScheduler({
config,
chatRecordingService: config.getChatRecordingService(),
outputUpdateHandler,
onAllToolCallsComplete: allToolCallsCompleteHandler,
onToolCallsUpdate: toolCallsUpdateHandler,
getPreferredEditor,
config,
onEditorClose,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
}),
[
config,
outputUpdateHandler,

View File

@@ -186,7 +186,11 @@ describe('useSlashCompletion', () => {
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
}),
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
createTestCommand({
name: 'clear',
altNames: ['reset', 'new'],
description: 'Clear the screen',
}),
createTestCommand({
name: 'memory',
description: 'Manage memory',
@@ -207,7 +211,13 @@ describe('useSlashCompletion', () => {
expect(result.current.suggestions.length).toBe(slashCommands.length);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
expect.arrayContaining([
'help (?)',
'clear (reset, new)',
'memory',
'chat',
'stats (usage)',
]),
);
});
@@ -256,7 +266,7 @@ describe('useSlashCompletion', () => {
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
label: 'stats',
label: 'stats (usage)',
value: 'stats',
description: 'check session stats. Usage: /stats [model|tools]',
commandKind: CommandKind.BUILT_IN,
@@ -512,11 +522,7 @@ describe('useSlashCompletion', () => {
describe('Argument Completion', () => {
it('should call the command.completion function for argument suggestions', async () => {
const availableTags = [
'my-chat-tag-1',
'my-chat-tag-2',
'another-channel',
];
const availableTags = ['--project', '--global'];
const mockCompletionFn = vi
.fn()
.mockImplementation(
@@ -526,12 +532,12 @@ describe('useSlashCompletion', () => {
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
name: 'memory',
description: 'Manage memory',
subCommands: [
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
name: 'show',
description: 'Show memory',
completion: mockCompletionFn,
}),
],
@@ -541,7 +547,7 @@ describe('useSlashCompletion', () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume my-ch',
'/memory show --project',
slashCommands,
mockCommandContext,
),
@@ -551,19 +557,18 @@ describe('useSlashCompletion', () => {
expect(mockCompletionFn).toHaveBeenCalledWith(
expect.objectContaining({
invocation: {
raw: '/chat resume my-ch',
name: 'resume',
args: 'my-ch',
raw: '/memory show --project',
name: 'show',
args: '--project',
},
}),
'my-ch',
'--project',
);
});
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{ label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
{ label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
{ label: '--project', value: '--project' },
]);
});
});
@@ -575,12 +580,12 @@ describe('useSlashCompletion', () => {
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
name: 'workspace',
description: 'Manage workspaces',
subCommands: [
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
name: 'switch',
description: 'Switch workspace',
completion: mockCompletionFn,
}),
],
@@ -590,7 +595,7 @@ describe('useSlashCompletion', () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume ',
'/workspace switch ',
slashCommands,
mockCommandContext,
),
@@ -600,8 +605,8 @@ describe('useSlashCompletion', () => {
expect(mockCompletionFn).toHaveBeenCalledWith(
expect.objectContaining({
invocation: {
raw: '/chat resume',
name: 'resume',
raw: '/workspace switch',
name: 'switch',
args: '',
},
}),
@@ -618,12 +623,12 @@ describe('useSlashCompletion', () => {
const completionFn = vi.fn().mockResolvedValue(null);
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
name: 'workspace',
description: 'Manage workspaces',
subCommands: [
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
name: 'switch',
description: 'Switch workspace',
completion: completionFn,
}),
],
@@ -633,7 +638,7 @@ describe('useSlashCompletion', () => {
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume ',
'/workspace switch ',
slashCommands,
mockCommandContext,
),

View File

@@ -282,7 +282,7 @@ function useCommandSuggestions(
if (!signal.aborted) {
const finalSuggestions = potentialSuggestions.map((cmd) => ({
label: cmd.name,
label: formatSlashCommandLabel(cmd),
value: cmd.name,
description: cmd.description,
commandKind: cmd.kind,
@@ -525,3 +525,14 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
completionEnd,
};
}
function formatSlashCommandLabel(command: SlashCommand): string {
const baseLabel = command.name;
const altNames = command.altNames?.filter(Boolean);
if (!altNames || altNames.length === 0) {
return baseLabel;
}
return `${baseLabel} (${altNames.join(', ')})`;
}

View File

@@ -68,6 +68,7 @@ const mockConfig = {
getUseModelRouter: () => false,
getGeminiClient: () => null, // No client needed for these tests
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
getChatRecordingService: () => undefined,
} as unknown as Config;
const mockTool = new MockTool({

View File

@@ -4,10 +4,95 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { formatDuration, formatMemoryUsage } from './formatters.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
formatDuration,
formatMemoryUsage,
formatRelativeTime,
} from './formatters.js';
describe('formatters', () => {
describe('formatRelativeTime', () => {
const NOW = 1700000000000; // Fixed timestamp for testing
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('should return "just now" for timestamps less than a minute ago', () => {
expect(formatRelativeTime(NOW - 30 * 1000)).toBe('just now');
expect(formatRelativeTime(NOW - 59 * 1000)).toBe('just now');
});
it('should return "1 minute ago" for exactly one minute', () => {
expect(formatRelativeTime(NOW - 60 * 1000)).toBe('1 minute ago');
});
it('should return plural minutes for multiple minutes', () => {
expect(formatRelativeTime(NOW - 5 * 60 * 1000)).toBe('5 minutes ago');
expect(formatRelativeTime(NOW - 30 * 60 * 1000)).toBe('30 minutes ago');
});
it('should return "1 hour ago" for exactly one hour', () => {
expect(formatRelativeTime(NOW - 60 * 60 * 1000)).toBe('1 hour ago');
});
it('should return plural hours for multiple hours', () => {
expect(formatRelativeTime(NOW - 3 * 60 * 60 * 1000)).toBe('3 hours ago');
expect(formatRelativeTime(NOW - 23 * 60 * 60 * 1000)).toBe(
'23 hours ago',
);
});
it('should return "1 day ago" for exactly one day', () => {
expect(formatRelativeTime(NOW - 24 * 60 * 60 * 1000)).toBe('1 day ago');
});
it('should return plural days for multiple days', () => {
expect(formatRelativeTime(NOW - 3 * 24 * 60 * 60 * 1000)).toBe(
'3 days ago',
);
expect(formatRelativeTime(NOW - 6 * 24 * 60 * 60 * 1000)).toBe(
'6 days ago',
);
});
it('should return "1 week ago" for exactly one week', () => {
expect(formatRelativeTime(NOW - 7 * 24 * 60 * 60 * 1000)).toBe(
'1 week ago',
);
});
it('should return plural weeks for multiple weeks', () => {
expect(formatRelativeTime(NOW - 14 * 24 * 60 * 60 * 1000)).toBe(
'2 weeks ago',
);
expect(formatRelativeTime(NOW - 21 * 24 * 60 * 60 * 1000)).toBe(
'3 weeks ago',
);
});
it('should return "1 month ago" for exactly one month (30 days)', () => {
expect(formatRelativeTime(NOW - 30 * 24 * 60 * 60 * 1000)).toBe(
'1 month ago',
);
});
it('should return plural months for multiple months', () => {
expect(formatRelativeTime(NOW - 60 * 24 * 60 * 60 * 1000)).toBe(
'2 months ago',
);
expect(formatRelativeTime(NOW - 90 * 24 * 60 * 60 * 1000)).toBe(
'3 months ago',
);
});
});
describe('formatMemoryUsage', () => {
it('should format bytes into KB', () => {
expect(formatMemoryUsage(12345)).toBe('12.1 KB');

View File

@@ -21,6 +21,40 @@ export const formatMemoryUsage = (bytes: number): string => {
* @param milliseconds The duration in milliseconds.
* @returns A formatted string representing the duration.
*/
/**
* Formats a timestamp into a human-readable relative time string.
* @param timestamp The timestamp in milliseconds since epoch.
* @returns A formatted string like "just now", "5 minutes ago", "2 days ago".
*/
export const formatRelativeTime = (timestamp: number): string => {
const now = Date.now();
const diffMs = now - timestamp;
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
if (months > 0) {
return months === 1 ? '1 month ago' : `${months} months ago`;
}
if (weeks > 0) {
return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`;
}
if (days > 0) {
return days === 1 ? '1 day ago' : `${days} days ago`;
}
if (hours > 0) {
return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
}
if (minutes > 0) {
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
}
return 'just now';
};
export const formatDuration = (milliseconds: number): string => {
if (milliseconds <= 0) {
return '0s';

View File

@@ -0,0 +1,279 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { buildResumedHistoryItems } from './resumeHistoryUtils.js';
import { ToolCallStatus } from '../types.js';
import type {
AnyDeclarativeTool,
Config,
ConversationRecord,
ResumedSessionData,
} from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
const makeConfig = (tools: Record<string, AnyDeclarativeTool>) =>
({
getToolRegistry: () => ({
getTool: (name: string) => tools[name],
}),
}) as unknown as Config;
describe('resumeHistoryUtils', () => {
let mockTool: AnyDeclarativeTool;
beforeEach(() => {
const mockInvocation = {
getDescription: () => 'Mocked description',
};
mockTool = {
name: 'replace',
displayName: 'Replace',
description: 'Replace text',
build: vi.fn().mockReturnValue(mockInvocation),
} as unknown as AnyDeclarativeTool;
});
it('converts conversation into history items with incremental ids', () => {
const conversation = {
messages: [
{
type: 'user',
message: { parts: [{ text: 'Hello' } as Part] },
},
{
type: 'assistant',
message: {
parts: [
{ text: 'Hi there' } as Part,
{
functionCall: {
id: 'call-1',
name: 'replace',
args: { old: 'a', new: 'b' },
},
} as unknown as Part,
],
},
},
{
type: 'tool_result',
toolCallResult: {
callId: 'call-1',
resultDisplay: 'All set',
status: 'success',
},
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const baseTimestamp = 1_000;
const items = buildResumedHistoryItems(
session,
makeConfig({ replace: mockTool }),
baseTimestamp,
);
expect(items).toEqual([
{ id: baseTimestamp + 1, type: 'user', text: 'Hello' },
{ id: baseTimestamp + 2, type: 'gemini', text: 'Hi there' },
{
id: baseTimestamp + 3,
type: 'tool_group',
tools: [
{
callId: 'call-1',
name: 'Replace',
description: 'Mocked description',
resultDisplay: 'All set',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
},
]);
});
it('marks tool results as error, skips thought text, and falls back when tool is missing', () => {
const conversation = {
messages: [
{
type: 'assistant',
message: {
parts: [
{
text: 'should be skipped',
thought: { subject: 'hidden' },
} as unknown as Part,
{ text: 'visible text' } as Part,
{
functionCall: {
id: 'missing-call',
name: 'unknown_tool',
args: { foo: 'bar' },
},
} as unknown as Part,
],
},
},
{
type: 'tool_result',
toolCallResult: {
callId: 'missing-call',
resultDisplay: { summary: 'failure' },
status: 'error',
},
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const items = buildResumedHistoryItems(session, makeConfig({}));
expect(items).toEqual([
{ id: expect.any(Number), type: 'gemini', text: 'visible text' },
{
id: expect.any(Number),
type: 'tool_group',
tools: [
{
callId: 'missing-call',
name: 'unknown_tool',
description: '',
resultDisplay: { summary: 'failure' },
status: ToolCallStatus.Error,
confirmationDetails: undefined,
},
],
},
]);
});
it('flushes pending tool groups before subsequent user messages', () => {
const conversation = {
messages: [
{
type: 'assistant',
message: {
parts: [
{
functionCall: {
id: 'call-2',
name: 'replace',
args: { target: 'a' },
},
} as unknown as Part,
],
},
},
{
type: 'user',
message: { parts: [{ text: 'next user message' } as Part] },
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const items = buildResumedHistoryItems(
session,
makeConfig({ replace: mockTool }),
10,
);
expect(items[0]).toEqual({
id: 11,
type: 'tool_group',
tools: [
{
callId: 'call-2',
name: 'Replace',
description: 'Mocked description',
resultDisplay: undefined,
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
});
expect(items[1]).toEqual({
id: 12,
type: 'user',
text: 'next user message',
});
});
it('replays slash command history items (e.g., /about) on resume', () => {
const conversation = {
messages: [
{
type: 'system',
subtype: 'slash_command',
systemPayload: {
phase: 'invocation',
rawCommand: '/about',
},
},
{
type: 'system',
subtype: 'slash_command',
systemPayload: {
phase: 'result',
rawCommand: '/about',
outputHistoryItems: [
{
type: 'about',
systemInfo: {
cliVersion: '1.2.3',
osPlatform: 'darwin',
osArch: 'arm64',
osRelease: 'test',
nodeVersion: '20.x',
npmVersion: '10.x',
sandboxEnv: 'none',
modelVersion: 'qwen',
selectedAuthType: 'none',
ideClient: 'none',
sessionId: 'abc',
memoryUsage: '0 MB',
},
},
],
},
},
{
type: 'assistant',
message: { parts: [{ text: 'Follow-up' } as Part] },
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const items = buildResumedHistoryItems(session, makeConfig({}), 5);
expect(items).toEqual([
{ id: 6, type: 'user', text: '/about' },
{
id: 7,
type: 'about',
systemInfo: expect.objectContaining({ cliVersion: '1.2.3' }),
},
{ id: 8, type: 'gemini', text: 'Follow-up' },
]);
});
});

View File

@@ -0,0 +1,299 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part, FunctionCall } from '@google/genai';
import type {
ResumedSessionData,
ConversationRecord,
Config,
AnyDeclarativeTool,
ToolResultDisplay,
SlashCommandRecordPayload,
} from '@qwen-code/qwen-code-core';
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { ToolCallStatus } from '../types.js';
/**
* Extracts text content from a Content object's parts.
*/
function extractTextFromParts(parts: Part[] | undefined): string {
if (!parts) return '';
const textParts: string[] = [];
for (const part of parts) {
if ('text' in part && part.text) {
// Skip thought parts - they have a 'thought' property
if (!('thought' in part && part.thought)) {
textParts.push(part.text);
}
}
}
return textParts.join('\n');
}
/**
* Extracts function calls from a Content object's parts.
*/
function extractFunctionCalls(
parts: Part[] | undefined,
): Array<{ id: string; name: string; args: Record<string, unknown> }> {
if (!parts) return [];
const calls: Array<{
id: string;
name: string;
args: Record<string, unknown>;
}> = [];
for (const part of parts) {
if ('functionCall' in part && part.functionCall) {
const fc = part.functionCall as FunctionCall;
calls.push({
id: fc.id || `call-${calls.length}`,
name: fc.name || 'unknown',
args: (fc.args as Record<string, unknown>) || {},
});
}
}
return calls;
}
function getTool(config: Config, name: string): AnyDeclarativeTool | undefined {
const toolRegistry = config.getToolRegistry();
return toolRegistry.getTool(name);
}
/**
* Formats a tool description from its name and arguments using actual tool instances.
* This ensures we get the exact same descriptions as during normal operation.
*/
function formatToolDescription(
tool: AnyDeclarativeTool,
args: Record<string, unknown>,
): string {
try {
// Create tool invocation instance and get description
const invocation = tool.build(args);
return invocation.getDescription();
} catch {
return '';
}
}
/**
* Restores a HistoryItemWithoutId from the serialized shape stored in
* SlashCommandRecordPayload.outputHistoryItems.
*/
function restoreHistoryItem(raw: unknown): HistoryItemWithoutId | undefined {
if (!raw || typeof raw !== 'object') {
return;
}
const clone = { ...(raw as Record<string, unknown>) };
if ('timestamp' in clone) {
const ts = clone['timestamp'];
if (typeof ts === 'string' || typeof ts === 'number') {
clone['timestamp'] = new Date(ts);
}
}
if (typeof clone['type'] !== 'string') {
return;
}
return clone as unknown as HistoryItemWithoutId;
}
/**
* Converts ChatRecord messages to UI history items for display.
*
* This function transforms the raw ChatRecords into a format suitable
* for the CLI's HistoryItemDisplay component.
*
* @param conversation The conversation record from a resumed session
* @param config The config object for accessing tool registry
* @returns Array of history items for UI display
*/
function convertToHistoryItems(
conversation: ConversationRecord,
config: Config,
): HistoryItemWithoutId[] {
const items: HistoryItemWithoutId[] = [];
// Track pending tool calls for grouping with results
const pendingToolCalls = new Map<
string,
{ name: string; args: Record<string, unknown> }
>();
let currentToolGroup: Array<{
callId: string;
name: string;
description: string;
resultDisplay: ToolResultDisplay | undefined;
status: ToolCallStatus;
confirmationDetails: undefined;
}> = [];
for (const record of conversation.messages) {
if (record.type === 'system') {
if (record.subtype === 'slash_command') {
// Flush any pending tool group to avoid mixing contexts.
if (currentToolGroup.length > 0) {
items.push({
type: 'tool_group',
tools: [...currentToolGroup],
});
currentToolGroup = [];
}
const payload = record.systemPayload as
| SlashCommandRecordPayload
| undefined;
if (!payload) continue;
if (payload.phase === 'invocation' && payload.rawCommand) {
items.push({ type: 'user', text: payload.rawCommand });
}
if (payload.phase === 'result') {
const outputs = payload.outputHistoryItems ?? [];
for (const raw of outputs) {
const restored = restoreHistoryItem(raw);
if (restored) {
items.push(restored);
}
}
}
}
continue;
}
switch (record.type) {
case 'user': {
// Flush any pending tool group before user message
if (currentToolGroup.length > 0) {
items.push({
type: 'tool_group',
tools: [...currentToolGroup],
});
currentToolGroup = [];
}
const text = extractTextFromParts(record.message?.parts as Part[]);
if (text) {
items.push({ type: 'user', text });
}
break;
}
case 'assistant': {
const parts = record.message?.parts as Part[] | undefined;
// Extract text content (non-function-call, non-thought)
const text = extractTextFromParts(parts);
// Extract function calls
const functionCalls = extractFunctionCalls(parts);
// If there's text content, add it as a gemini message
if (text) {
// Flush any pending tool group before text
if (currentToolGroup.length > 0) {
items.push({
type: 'tool_group',
tools: [...currentToolGroup],
});
currentToolGroup = [];
}
items.push({ type: 'gemini', text });
}
// Track function calls for pairing with results
for (const fc of functionCalls) {
const tool = getTool(config, fc.name);
pendingToolCalls.set(fc.id, { name: fc.name, args: fc.args });
// Add placeholder tool call to current group
currentToolGroup.push({
callId: fc.id,
name: tool?.displayName || fc.name,
description: tool ? formatToolDescription(tool, fc.args) : '',
resultDisplay: undefined,
status: ToolCallStatus.Success, // Will be updated by tool_result
confirmationDetails: undefined,
});
}
break;
}
case 'tool_result': {
// Update the corresponding tool call in the current group
if (record.toolCallResult) {
const callId = record.toolCallResult.callId;
const toolCall = currentToolGroup.find((t) => t.callId === callId);
if (toolCall) {
// Preserve the resultDisplay as-is - it can be a string or structured object
const rawDisplay = record.toolCallResult.resultDisplay;
toolCall.resultDisplay = rawDisplay;
// Check if status exists and use it
const rawStatus = (
record.toolCallResult as Record<string, unknown>
)['status'] as string | undefined;
toolCall.status =
rawStatus === 'error'
? ToolCallStatus.Error
: ToolCallStatus.Success;
}
pendingToolCalls.delete(callId || '');
}
break;
}
default:
// Skip unknown record types
break;
}
}
// Flush any remaining tool group
if (currentToolGroup.length > 0) {
items.push({
type: 'tool_group',
tools: currentToolGroup,
});
}
return items;
}
/**
* Builds the complete UI history items for a resumed session.
*
* This function takes the resumed session data, converts it to UI history format,
* and assigns unique IDs to each item for use with loadHistory.
*
* @param sessionData The resumed session data from SessionService
* @param config The config object for accessing tool registry
* @param baseTimestamp Base timestamp for generating unique IDs
* @returns Array of HistoryItem with proper IDs
*/
export function buildResumedHistoryItems(
sessionData: ResumedSessionData,
config: Config,
baseTimestamp: number = Date.now(),
): HistoryItem[] {
const items: HistoryItem[] = [];
let idCounter = 1;
const getNextId = (): number => baseTimestamp + idCounter++;
// Convert conversation directly to history items
const historyItems = convertToHistoryItems(sessionData.conversation, config);
for (const item of historyItems) {
items.push({
...item,
id: getNextId(),
} as HistoryItem);
}
return items;
}

File diff suppressed because it is too large Load Diff