mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Session-Level Conversation History Management (#1113)
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
329
packages/cli/src/acp-integration/acpAgent.ts
Normal file
329
packages/cli/src/acp-integration/acpAgent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
414
packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
Normal file
414
packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
137
packages/cli/src/acp-integration/session/HistoryReplayer.ts
Normal file
137
packages/cli/src/acp-integration/session/HistoryReplayer.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
981
packages/cli/src/acp-integration/session/Session.ts
Normal file
981
packages/cli/src/acp-integration/session/Session.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
525
packages/cli/src/acp-integration/session/SubAgentTracker.test.ts
Normal file
525
packages/cli/src/acp-integration/session/SubAgentTracker.test.ts
Normal 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
318
packages/cli/src/acp-integration/session/SubAgentTracker.ts
Normal file
318
packages/cli/src/acp-integration/session/SubAgentTracker.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
10
packages/cli/src/acp-integration/session/emitters/index.ts
Normal file
10
packages/cli/src/acp-integration/session/emitters/index.ts
Normal 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';
|
||||
40
packages/cli/src/acp-integration/session/index.ts
Normal file
40
packages/cli/src/acp-integration/session/index.ts
Normal 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';
|
||||
76
packages/cli/src/acp-integration/session/types.ts
Normal file
76
packages/cli/src/acp-integration/session/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -479,6 +479,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
inputFormat: undefined,
|
||||
outputFormat: undefined,
|
||||
includePartialMessages: undefined,
|
||||
continue: undefined,
|
||||
resume: undefined,
|
||||
});
|
||||
|
||||
await main();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {} }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(', ')})`;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)'),
|
||||
|
||||
436
packages/cli/src/ui/components/ResumeSessionPicker.tsx
Normal file
436
packages/cli/src/ui/components/ResumeSessionPicker.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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(', ')})`;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
279
packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
Normal file
279
packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
Normal 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
299
packages/cli/src/ui/utils/resumeHistoryUtils.ts
Normal file
299
packages/cli/src/ui/utils/resumeHistoryUtils.ts
Normal 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
Reference in New Issue
Block a user