diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 983c6e5e..d258bc2d 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -11,31 +11,8 @@ Slash commands provide meta-level control over the CLI itself. - **`/bug`** - **Description:** File an issue about Qwen Code. By default, the issue is filed within the GitHub repository for Qwen Code. The string you enter after `/bug` will become the headline for the bug being filed. The default `/bug` behavior can be modified using the `advanced.bugCommand` setting in your `.qwen/settings.json` files. -- **`/chat`** - - **Description:** Save and resume conversation history for branching conversation state interactively, or resuming a previous state from a later session. - - **Sub-commands:** - - **`save`** - - **Description:** Saves the current conversation history. You must add a `` for identifying the conversation state. - - **Usage:** `/chat save ` - - **Details on Checkpoint Location:** The default locations for saved chat checkpoints are: - - Linux/macOS: `~/.qwen/tmp//` - - Windows: `C:\Users\\.qwen\tmp\\` - - When you run `/chat list`, the CLI only scans these specific directories to find available checkpoints. - - **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../checkpointing.md). - - **`resume`** - - **Description:** Resumes a conversation from a previous save. - - **Usage:** `/chat resume ` - - **`list`** - - **Description:** Lists available tags for chat state resumption. - - **`delete`** - - **Description:** Deletes a saved conversation checkpoint. - - **Usage:** `/chat delete ` - - **`share`** - - **Description** Writes the current conversation to a provided Markdown or JSON file. - - **Usage** `/chat share file.md` or `/chat share file.json`. If no filename is provided, then the CLI will generate one. - -- **`/clear`** - - **Description:** Clear the terminal screen, including the visible session history and scrollback within the CLI. The underlying session data (for history recall) might be preserved depending on the exact implementation, but the visual display is cleared. +- **`/clear`** (aliases: `reset`, `new`) + - **Description:** Clear conversation history and free up context by starting a fresh session. Also clears the terminal output and scrollback within the CLI. - **Keyboard shortcut:** Press **Ctrl+L** at any time to perform a clear action. - **`/summary`** diff --git a/docs/cli/configuration-v1.md b/docs/cli/configuration-v1.md index 73ea325c..5127bbe2 100644 --- a/docs/cli/configuration-v1.md +++ b/docs/cli/configuration-v1.md @@ -671,4 +671,4 @@ Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM - **Category:** UI - **Requires Restart:** No - **Example:** `"enableWelcomeBack": false` - - **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/chat summary` command and quit confirmation dialog. See the [Welcome Back documentation](./welcome-back.md) for more details. + - **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. See the [Welcome Back documentation](./welcome-back.md) for more details. diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index a4ee80ba..aef6bc4f 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -548,6 +548,12 @@ Arguments passed directly when running the CLI can override other configurations - The prompt is processed within the interactive session, not before it. - Cannot be used when piping input from stdin. - Example: `qwen -i "explain this code"` +- **`--continue`**: + - Resume the most recent session for the current project (current working directory). + - Works in interactive and headless modes (e.g., `qwen --continue -p "Keep going"`). +- **`--resume [sessionId]`**: + - Resume a specific session for the current project. When called without an ID, an interactive picker lists only this project's sessions with prompt preview, timestamps, message count, and optional git branch. + - If an ID is provided and not found for this project, the CLI exits with an error. - **`--output-format `** (**`-o `**): - **Description:** Specifies the format of the CLI output for non-interactive mode. - **Values:** diff --git a/docs/features/headless.md b/docs/features/headless.md index 7cf4ce4d..67d9decc 100644 --- a/docs/features/headless.md +++ b/docs/features/headless.md @@ -38,6 +38,7 @@ The headless mode provides a headless interface to Qwen Code that: - Supports file redirection and piping - Enables automation and scripting workflows - Provides consistent exit codes for error handling +- Can resume previous sessions scoped to the current project for multi-step automation ## Basic Usage @@ -65,6 +66,23 @@ Read from files and process with Qwen Code: cat README.md | qwen --prompt "Summarize this documentation" ``` +### Resume Previous Sessions (Headless) + +Reuse conversation context from the current project in headless scripts: + +```bash +# Continue the most recent session for this project and run a new prompt +qwen --continue -p "Run the tests again and summarize failures" + +# Resume a specific session ID directly (no UI) +qwen --resume 123e4567-e89b-12d3-a456-426614174000 -p "Apply the follow-up refactor" +``` + +Notes: + +- Session data is project-scoped JSONL under `~/.qwen/projects//chats`. +- Restores conversation history, tool outputs, and chat-compression checkpoints before sending the new prompt. + ## Output Formats Qwen Code supports multiple output formats for different use cases: @@ -196,17 +214,19 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq Key command-line options for headless usage: -| Option | Description | Example | -| ---------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ | -| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` | -| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` | -| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` | -| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` | -| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | -| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` | -| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` | -| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` | -| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` | +| Option | Description | Example | +| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ | +| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` | +| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` | +| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` | +| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` | +| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | +| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` | +| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` | +| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` | +| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` | +| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` | +| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` | For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./cli/configuration.md). diff --git a/docs/features/welcome-back.md b/docs/features/welcome-back.md index 0a5acfe8..7175406b 100644 --- a/docs/features/welcome-back.md +++ b/docs/features/welcome-back.md @@ -75,9 +75,9 @@ Add to your `.qwen/settings.json`: ### Project Summary Generation -The Welcome Back feature works seamlessly with the `/chat summary` command: +The Welcome Back feature works seamlessly with the `/summary` command: -1. **Generate Summary:** Use `/chat summary` to create a project summary +1. **Generate Summary:** Use `/summary` to create a project summary 2. **Automatic Detection:** Next time you start Qwen Code in this project, Welcome Back will detect the summary 3. **Resume Work:** Choose to continue and the summary will be loaded as context diff --git a/docs/index.md b/docs/index.md index 53e00dd3..07fc1db6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -72,7 +72,7 @@ Create or edit `.qwen/settings.json` in your home directory: #### Session Commands - **`/compress`** - Compress conversation history to continue within token limits -- **`/clear`** - Clear all conversation history and start fresh +- **`/clear`** (aliases: `/reset`, `/new`) - Clear conversation history, start a fresh session, and free up context - **`/stats`** - Check current token usage and limits > 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls. @@ -332,7 +332,7 @@ qwen ### Session Commands - `/help` - Display available commands -- `/clear` - Clear conversation history +- `/clear` (aliases: `/reset`, `/new`) - Clear conversation history and start a fresh session - `/compress` - Compress history to save tokens - `/stats` - Show current session information - `/exit` or `/quit` - Exit Qwen Code diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts new file mode 100644 index 00000000..d09ec54d --- /dev/null +++ b/integration-tests/acp-integration.test.ts @@ -0,0 +1,588 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { setTimeout as delay } from 'node:timers/promises'; +import { describe, expect, it } from 'vitest'; +import { TestRig } from './test-helper.js'; + +const REQUEST_TIMEOUT_MS = 60_000; +const INITIAL_PROMPT = 'Create a quick note (smoke test).'; +const RESUME_PROMPT = 'Continue the note after reload.'; +const LIST_SIZE = 5; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + timeout: NodeJS.Timeout; +}; + +type SessionUpdateNotification = { + sessionId?: string; + update?: { + sessionUpdate?: string; + availableCommands?: Array<{ + name: string; + description: string; + input?: { hint: string } | null; + }>; + content?: { + type: string; + text?: string; + }; + modeId?: string; + }; +}; + +type PermissionRequest = { + id: number; + sessionId?: string; + toolCall?: { + toolCallId: string; + title: string; + kind: string; + status: string; + content?: Array<{ + type: string; + text?: string; + path?: string; + oldText?: string; + newText?: string; + }>; + }; + options?: Array<{ + optionId: string; + name: string; + kind: string; + }>; +}; + +type PermissionHandler = ( + request: PermissionRequest, +) => { optionId: string } | { outcome: 'cancelled' }; + +/** + * Sets up an ACP test environment with all necessary utilities. + */ +function setupAcpTest( + rig: TestRig, + options?: { permissionHandler?: PermissionHandler }, +) { + const pending = new Map(); + let nextRequestId = 1; + const sessionUpdates: SessionUpdateNotification[] = []; + const permissionRequests: PermissionRequest[] = []; + const stderr: string[] = []; + + // Default permission handler: auto-approve all + const permissionHandler = + options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' })); + + const agent = spawn('node', [rig.bundlePath, '--experimental-acp'], { + cwd: rig.testDir!, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }); + + agent.stderr?.on('data', (chunk) => { + stderr.push(chunk.toString()); + }); + + const rl = createInterface({ input: agent.stdout }); + + const send = (json: unknown) => { + agent.stdin.write(`${JSON.stringify(json)}\n`); + }; + + const sendResponse = (id: number, result: unknown) => { + send({ jsonrpc: '2.0', id, result }); + }; + + const sendRequest = (method: string, params?: unknown) => + new Promise((resolve, reject) => { + const id = nextRequestId++; + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error(`Request ${id} (${method}) timed out`)); + }, REQUEST_TIMEOUT_MS); + pending.set(id, { resolve, reject, timeout }); + send({ jsonrpc: '2.0', id, method, params }); + }); + + const handleResponse = (msg: { + id: number; + result?: unknown; + error?: { message?: string }; + }) => { + const waiter = pending.get(msg.id); + if (!waiter) { + return; + } + clearTimeout(waiter.timeout); + pending.delete(msg.id); + if (msg.error) { + waiter.reject(new Error(msg.error.message ?? 'Unknown error')); + } else { + waiter.resolve(msg.result); + } + }; + + const handleMessage = (msg: { + id?: number; + method?: string; + params?: SessionUpdateNotification & { + path?: string; + content?: string; + sessionId?: string; + toolCall?: PermissionRequest['toolCall']; + options?: PermissionRequest['options']; + }; + result?: unknown; + error?: { message?: string }; + }) => { + if (typeof msg.id !== 'undefined' && ('result' in msg || 'error' in msg)) { + handleResponse( + msg as { + id: number; + result?: unknown; + error?: { message?: string }; + }, + ); + return; + } + + if (msg.method === 'session/update') { + sessionUpdates.push({ + sessionId: msg.params?.sessionId, + update: msg.params?.update, + }); + return; + } + + if ( + msg.method === 'session/request_permission' && + typeof msg.id === 'number' + ) { + // Track permission request + const permRequest: PermissionRequest = { + id: msg.id, + sessionId: msg.params?.sessionId, + toolCall: msg.params?.toolCall, + options: msg.params?.options, + }; + permissionRequests.push(permRequest); + + // Use custom handler or default + const response = permissionHandler(permRequest); + if ('outcome' in response) { + sendResponse(msg.id, { outcome: response }); + } else { + sendResponse(msg.id, { + outcome: { optionId: response.optionId, outcome: 'selected' }, + }); + } + return; + } + + if (msg.method === 'fs/read_text_file' && typeof msg.id === 'number') { + try { + const content = readFileSync(msg.params?.path ?? '', 'utf8'); + sendResponse(msg.id, { content }); + } catch (e) { + sendResponse(msg.id, { content: `ERROR: ${(e as Error).message}` }); + } + return; + } + + if (msg.method === 'fs/write_text_file' && typeof msg.id === 'number') { + try { + writeFileSync( + msg.params?.path ?? '', + msg.params?.content ?? '', + 'utf8', + ); + sendResponse(msg.id, null); + } catch (e) { + sendResponse(msg.id, { message: (e as Error).message }); + } + } + }; + + rl.on('line', (line) => { + if (!line.trim()) return; + try { + const msg = JSON.parse(line); + handleMessage(msg); + } catch { + // Ignore non-JSON output from the agent. + } + }); + + const waitForExit = () => + new Promise((resolve) => { + if (agent.exitCode !== null || agent.signalCode) { + resolve(); + return; + } + agent.once('exit', () => resolve()); + }); + + const cleanup = async () => { + rl.close(); + agent.kill(); + pending.forEach(({ timeout }) => clearTimeout(timeout)); + pending.clear(); + await waitForExit(); + }; + + return { + sendRequest, + sendResponse, + cleanup, + stderr, + sessionUpdates, + permissionRequests, + }; +} + +describe('acp integration', () => { + it('creates, lists, loads, and resumes a session', async () => { + const rig = new TestRig(); + rig.setup('acp load session'); + + const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig); + + try { + const initResult = await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + expect(initResult).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((initResult as any).agentInfo.version).toBeDefined(); + + await sendRequest('authenticate', { methodId: 'openai' }); + + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + expect(newSession.sessionId).toBeTruthy(); + + const promptResult = await sendRequest('session/prompt', { + sessionId: newSession.sessionId, + prompt: [{ type: 'text', text: INITIAL_PROMPT }], + }); + expect(promptResult).toBeDefined(); + + await delay(500); + + const listResult = (await sendRequest('session/list', { + cwd: rig.testDir!, + size: LIST_SIZE, + })) as { items?: Array<{ sessionId: string }> }; + + expect(Array.isArray(listResult.items)).toBe(true); + expect(listResult.items?.length ?? 0).toBeGreaterThan(0); + + const sessionToLoad = listResult.items![0].sessionId; + await sendRequest('session/load', { + cwd: rig.testDir!, + sessionId: sessionToLoad, + mcpServers: [], + }); + + const resumeResult = await sendRequest('session/prompt', { + sessionId: sessionToLoad, + prompt: [{ type: 'text', text: RESUME_PROMPT }], + }); + expect(resumeResult).toBeDefined(); + + const sessionsWithUpdates = sessionUpdates + .map((update) => update.sessionId) + .filter(Boolean); + expect(sessionsWithUpdates).toContain(sessionToLoad); + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); + + it('returns modes on initialize and allows setting approval mode', async () => { + const rig = new TestRig(); + rig.setup('acp approval mode'); + + const { sendRequest, cleanup, stderr } = setupAcpTest(rig); + + try { + // Test 1: Initialize and verify modes are returned + const initResult = (await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + })) as { + protocolVersion: number; + modes: { + currentModeId: string; + availableModes: Array<{ + id: string; + name: string; + description: string; + }>; + }; + }; + + expect(initResult).toBeDefined(); + expect(initResult.protocolVersion).toBe(1); + + // Verify modes data is present + expect(initResult.modes).toBeDefined(); + expect(initResult.modes.currentModeId).toBeDefined(); + expect(Array.isArray(initResult.modes.availableModes)).toBe(true); + expect(initResult.modes.availableModes.length).toBeGreaterThan(0); + + // Verify available modes have expected structure + const modeIds = initResult.modes.availableModes.map((m) => m.id); + expect(modeIds).toContain('default'); + expect(modeIds).toContain('yolo'); + expect(modeIds).toContain('auto-edit'); + expect(modeIds).toContain('plan'); + + // Verify each mode has required fields + for (const mode of initResult.modes.availableModes) { + expect(mode.id).toBeTruthy(); + expect(mode.name).toBeTruthy(); + expect(mode.description).toBeTruthy(); + } + + // Test 2: Authenticate + await sendRequest('authenticate', { methodId: 'openai' }); + + // Test 3: Create a new session + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + expect(newSession.sessionId).toBeTruthy(); + + // Test 4: Set approval mode to 'yolo' + const setModeResult = (await sendRequest('session/set_mode', { + sessionId: newSession.sessionId, + modeId: 'yolo', + })) as { modeId: string }; + expect(setModeResult).toBeDefined(); + expect(setModeResult.modeId).toBe('yolo'); + + // Test 5: Set approval mode to 'auto-edit' + const setModeResult2 = (await sendRequest('session/set_mode', { + sessionId: newSession.sessionId, + modeId: 'auto-edit', + })) as { modeId: string }; + expect(setModeResult2).toBeDefined(); + expect(setModeResult2.modeId).toBe('auto-edit'); + + // Test 6: Set approval mode back to 'default' + const setModeResult3 = (await sendRequest('session/set_mode', { + sessionId: newSession.sessionId, + modeId: 'default', + })) as { modeId: string }; + expect(setModeResult3).toBeDefined(); + expect(setModeResult3.modeId).toBe('default'); + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); + + it('receives available_commands_update with slash commands after session creation', async () => { + const rig = new TestRig(); + rig.setup('acp slash commands'); + + const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig); + + try { + // Initialize + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + + await sendRequest('authenticate', { methodId: 'openai' }); + + // Create a new session + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + expect(newSession.sessionId).toBeTruthy(); + + // Wait for available_commands_update to be received + await delay(1000); + + // Verify available_commands_update is received + const commandsUpdate = sessionUpdates.find( + (update) => + update.update?.sessionUpdate === 'available_commands_update', + ); + + expect(commandsUpdate).toBeDefined(); + expect(commandsUpdate?.update?.availableCommands).toBeDefined(); + expect(Array.isArray(commandsUpdate?.update?.availableCommands)).toBe( + true, + ); + + // Verify that the 'init' command is present (the only allowed built-in command for ACP) + const initCommand = commandsUpdate?.update?.availableCommands?.find( + (cmd) => cmd.name === 'init', + ); + expect(initCommand).toBeDefined(); + expect(initCommand?.description).toBeTruthy(); + + // Note: We don't test /init execution here because it triggers a complex + // multi-step process (listing files, reading up to 10 files, generating QWEN.md) + // that can take 30-60+ seconds, exceeding the request timeout. + // The slash command execution path is tested via simpler prompts in other tests. + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); + + it('handles exit plan mode with permission request and mode update notification', async () => { + const rig = new TestRig(); + rig.setup('acp exit plan mode'); + + // Track which permission requests we've seen + const planModeRequests: PermissionRequest[] = []; + + const { sendRequest, cleanup, stderr, sessionUpdates, permissionRequests } = + setupAcpTest(rig, { + permissionHandler: (request) => { + // Track all permission requests for later verification + // Auto-approve exit plan mode requests with "proceed_always" to trigger auto-edit mode + if (request.toolCall?.kind === 'switch_mode') { + planModeRequests.push(request); + // Return proceed_always to switch to auto-edit mode + return { optionId: 'proceed_always' }; + } + // Auto-approve all other requests + return { optionId: 'proceed_once' }; + }, + }); + + try { + // Initialize + await sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + }); + + await sendRequest('authenticate', { methodId: 'openai' }); + + // Create a new session + const newSession = (await sendRequest('session/new', { + cwd: rig.testDir!, + mcpServers: [], + })) as { sessionId: string }; + expect(newSession.sessionId).toBeTruthy(); + + // Set mode to 'plan' to enable plan mode + const setModeResult = (await sendRequest('session/set_mode', { + sessionId: newSession.sessionId, + modeId: 'plan', + })) as { modeId: string }; + expect(setModeResult.modeId).toBe('plan'); + + // Send a prompt that should trigger the LLM to call exit_plan_mode + // The prompt is designed to trigger planning behavior + const promptResult = await sendRequest('session/prompt', { + sessionId: newSession.sessionId, + prompt: [ + { + type: 'text', + text: 'Create a simple hello world function in Python. Make a brief plan and when ready, use the exit_plan_mode tool to present it for approval.', + }, + ], + }); + expect(promptResult).toBeDefined(); + + // Give time for all notifications to be processed + await delay(1000); + + // Verify: If exit_plan_mode was called, we should have received: + // 1. A permission request with kind: "switch_mode" + // 2. A current_mode_update notification after approval + + // Check for switch_mode permission requests + const switchModeRequests = permissionRequests.filter( + (req) => req.toolCall?.kind === 'switch_mode', + ); + + // Check for current_mode_update notifications + const modeUpdateNotifications = sessionUpdates.filter( + (update) => update.update?.sessionUpdate === 'current_mode_update', + ); + + // If the LLM called exit_plan_mode, verify the flow + if (switchModeRequests.length > 0) { + // Verify permission request structure + const permReq = switchModeRequests[0]; + expect(permReq.toolCall).toBeDefined(); + expect(permReq.toolCall?.kind).toBe('switch_mode'); + expect(permReq.toolCall?.status).toBe('pending'); + expect(permReq.options).toBeDefined(); + expect(Array.isArray(permReq.options)).toBe(true); + + // Verify options include appropriate choices + const optionKinds = permReq.options?.map((opt) => opt.kind) ?? []; + expect(optionKinds).toContain('allow_once'); + expect(optionKinds).toContain('allow_always'); + + // After approval, should have received current_mode_update + expect(modeUpdateNotifications.length).toBeGreaterThan(0); + + // Verify mode update structure + const modeUpdate = modeUpdateNotifications[0]; + expect(modeUpdate.sessionId).toBe(newSession.sessionId); + expect(modeUpdate.update?.modeId).toBeDefined(); + // Mode should be auto-edit since we approved with proceed_always + expect(modeUpdate.update?.modeId).toBe('auto-edit'); + } + + // Note: If the LLM didn't call exit_plan_mode, that's acceptable + // since LLM behavior is non-deterministic. The test setup and structure + // is verified regardless. + } catch (e) { + if (stderr.length) { + console.error('Agent stderr:', stderr.join('')); + } + throw e; + } finally { + await cleanup(); + } + }); +}); diff --git a/integration-tests/ctrl-c-exit.test.ts b/integration-tests/ctrl-c-exit.test.ts index bc89d045..863d5f70 100644 --- a/integration-tests/ctrl-c-exit.test.ts +++ b/integration-tests/ctrl-c-exit.test.ts @@ -6,8 +6,6 @@ import { describe, it, expect } from 'vitest'; import { TestRig } from './test-helper.js'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; describe('Ctrl+C exit', () => { // (#9782) Temporarily disabling on windows because it is failing on main and every @@ -25,23 +23,44 @@ describe('Ctrl+C exit', () => { output += data; }); - // Wait for the app to be ready by looking for the initial prompt indicator - await rig.poll(() => output.includes('▶'), 5000, 100); + const isReady = await rig.waitForText('Type your message', 15000); + expect( + isReady, + 'CLI did not start up in interactive mode correctly', + ).toBe(true); // Send first Ctrl+C ptyProcess.write(String.fromCharCode(3)); // Wait for the exit prompt - await rig.poll( + const showedExitPrompt = await rig.poll( () => output.includes('Press Ctrl+C again to exit'), 1500, 50, ); + expect(showedExitPrompt, `Exit prompt not shown. Output: ${output}`).toBe( + true, + ); // Send second Ctrl+C ptyProcess.write(String.fromCharCode(3)); - const result = await promise; + // Wait for process exit with timeout to fail fast + const EXIT_TIMEOUT = 5000; + const result = await Promise.race([ + promise, + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `Process did not exit within ${EXIT_TIMEOUT}ms. Output: ${output}`, + ), + ), + EXIT_TIMEOUT, + ), + ), + ]); // Expect a graceful exit (code 0) expect( @@ -58,72 +77,4 @@ describe('Ctrl+C exit', () => { expect(cleanOutput).toContain(quittingMessage); }, ); - - it.skipIf(process.platform === 'win32')( - 'should exit gracefully on second Ctrl+C when calling a tool', - async () => { - const rig = new TestRig(); - await rig.setup( - 'should exit gracefully on second Ctrl+C when calling a tool', - ); - - const childProcessFile = 'child_process_file.txt'; - rig.createFile( - 'wait.js', - `setTimeout(() => require('fs').writeFileSync('${childProcessFile}', 'done'), 5000)`, - ); - - const { ptyProcess, promise } = rig.runInteractive(); - - let output = ''; - ptyProcess.onData((data) => { - output += data; - }); - - // Wait for the app to be ready by looking for the initial prompt indicator - await rig.poll(() => output.includes('▶'), 5000, 100); - - ptyProcess.write('use the tool to run "node -e wait.js"\n'); - - await rig.poll(() => output.includes('Shell'), 5000, 100); - - // Send first Ctrl+C - ptyProcess.write(String.fromCharCode(3)); - - // Wait for the exit prompt - await rig.poll( - () => output.includes('Press Ctrl+C again to exit'), - 1500, - 50, - ); - - // Send second Ctrl+C - ptyProcess.write(String.fromCharCode(3)); - - const result = await promise; - - // Expect a graceful exit (code 0) - expect( - result.exitCode, - `Process exited with code ${result.exitCode}. Output: ${result.output}`, - ).toBe(0); - - // Check that the quitting message is displayed - const quittingMessage = 'Agent powering down. Goodbye!'; - // The regex below is intentionally matching the ESC control character (\x1b) - // to strip ANSI color codes from the terminal output. - // eslint-disable-next-line no-control-regex - const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); - expect(cleanOutput).toContain(quittingMessage); - - // Check that the child process was terminated and did not create the file. - const childProcessFileExists = fs.existsSync( - path.join(rig.testDir!, childProcessFile), - ); - expect( - childProcessFileExists, - 'Child process file should not exist', - ).toBe(false); - }, - ); }); diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts index e1d432db..37dca867 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/json-output.test.ts @@ -53,82 +53,6 @@ describe('JSON output', () => { } }); - it('should return a JSON error for enforced auth mismatch before running', async () => { - const originalOpenaiApiKey = process.env['OPENAI_API_KEY']; - process.env['OPENAI_API_KEY'] = 'test-key'; - await rig.setup('json-output-auth-mismatch', { - settings: { - security: { auth: { enforcedType: 'qwen-oauth' } }, - }, - }); - - let thrown: Error | undefined; - try { - await rig.run('Hello', '--output-format', 'json'); - expect.fail('Expected process to exit with error'); - } catch (e) { - thrown = e as Error; - } finally { - process.env['OPENAI_API_KEY'] = originalOpenaiApiKey; - } - - expect(thrown).toBeDefined(); - const message = (thrown as Error).message; - - // The error JSON is written to stdout as a CLIResultMessageError - // Extract stdout from the error message - const stdoutMatch = message.match(/Stdout:\n([\s\S]*?)(?:\n\nStderr:|$)/); - expect( - stdoutMatch, - 'Expected to find stdout in the error message', - ).toBeTruthy(); - - const stdout = stdoutMatch![1]; - let parsed: unknown[]; - try { - // Parse the JSON array from stdout - parsed = JSON.parse(stdout); - } catch (parseError) { - console.error('Failed to parse the following JSON:', stdout); - throw new Error( - `Test failed: Could not parse JSON from stdout. Details: ${parseError}`, - ); - } - - // The output should be an array of messages - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - - // Find the result message with error - const resultMessage = parsed.find( - (msg: unknown) => - typeof msg === 'object' && - msg !== null && - 'type' in msg && - msg.type === 'result' && - 'is_error' in msg && - msg.is_error === true, - ) as { - type: string; - is_error: boolean; - subtype: string; - error?: { message: string; type?: string }; - }; - - expect(resultMessage).toBeDefined(); - expect(resultMessage.is_error).toBe(true); - expect(resultMessage).toHaveProperty('subtype'); - expect(resultMessage.subtype).toBe('error_during_execution'); - expect(resultMessage).toHaveProperty('error'); - expect(resultMessage.error).toBeDefined(); - expect(resultMessage.error?.message).toContain( - 'configured auth type is qwen-oauth', - ); - expect(resultMessage.error?.message).toContain( - 'current auth type is openai', - ); - }); - it('should return line-delimited JSON messages for stream-json output format', async () => { const result = await rig.run( 'What is the capital of France?', @@ -307,4 +231,80 @@ describe('JSON output', () => { expect(resultMessage).toHaveProperty('result'); expect(resultMessage.result.toLowerCase()).toContain('paris'); }); + + it('should return a JSON error for enforced auth mismatch before running', async () => { + const originalOpenaiApiKey = process.env['OPENAI_API_KEY']; + process.env['OPENAI_API_KEY'] = 'test-key'; + await rig.setup('json-output-auth-mismatch', { + settings: { + security: { auth: { enforcedType: 'qwen-oauth' } }, + }, + }); + + let thrown: Error | undefined; + try { + await rig.run('Hello', '--output-format', 'json'); + expect.fail('Expected process to exit with error'); + } catch (e) { + thrown = e as Error; + } finally { + process.env['OPENAI_API_KEY'] = originalOpenaiApiKey; + } + + expect(thrown).toBeDefined(); + const message = (thrown as Error).message; + + // The error JSON is written to stdout as a CLIResultMessageError + // Extract stdout from the error message + const stdoutMatch = message.match(/Stdout:\n([\s\S]*?)(?:\n\nStderr:|$)/); + expect( + stdoutMatch, + 'Expected to find stdout in the error message', + ).toBeTruthy(); + + const stdout = stdoutMatch![1]; + let parsed: unknown[]; + try { + // Parse the JSON array from stdout + parsed = JSON.parse(stdout); + } catch (parseError) { + console.error('Failed to parse the following JSON:', stdout); + throw new Error( + `Test failed: Could not parse JSON from stdout. Details: ${parseError}`, + ); + } + + // The output should be an array of messages + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThan(0); + + // Find the result message with error + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result' && + 'is_error' in msg && + msg.is_error === true, + ) as { + type: string; + is_error: boolean; + subtype: string; + error?: { message: string; type?: string }; + }; + + expect(resultMessage).toBeDefined(); + expect(resultMessage.is_error).toBe(true); + expect(resultMessage).toHaveProperty('subtype'); + expect(resultMessage.subtype).toBe('error_during_execution'); + expect(resultMessage).toHaveProperty('error'); + expect(resultMessage.error).toBeDefined(); + expect(resultMessage.error?.message).toContain( + 'configured auth type is qwen-oauth', + ); + expect(resultMessage.error?.message).toContain( + 'current auth type is openai', + ); + }); }); diff --git a/package-lock.json b/package-lock.json index ae277057..35cf1937 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5255,6 +5255,15 @@ "node": ">= 0.4" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -14687,7 +14696,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -16158,6 +16166,7 @@ "@xterm/headless": "5.5.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.0", + "async-mutex": "^0.5.0", "chardet": "^2.1.0", "diff": "^7.0.0", "dotenv": "^17.1.0", diff --git a/packages/cli/src/zed-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts similarity index 92% rename from packages/cli/src/zed-integration/acp.ts rename to packages/cli/src/acp-integration/acp.ts index a260c61e..2ef78bbd 100644 --- a/packages/cli/src/zed-integration/acp.ts +++ b/packages/cli/src/acp-integration/acp.ts @@ -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; + listSessions?( + params: schema.ListSessionsRequest, + ): Promise; authenticate(params: schema.AuthenticateRequest): Promise; prompt(params: schema.PromptRequest): Promise; cancel(params: schema.CancelNotification): Promise; + setMode?(params: schema.SetModeRequest): Promise; } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts new file mode 100644 index 00000000..fc3c4ccc --- /dev/null +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -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; + + // 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 = 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 { + 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 { + 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 { + 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 { + const mergedMcpServers = { ...this.settings.merged.mcpServers }; + + for (const { command, args, env: rawEnv, name } of mcpServers) { + const env: Record = {}; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/cli/src/zed-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts similarity index 84% rename from packages/cli/src/zed-integration/schema.ts rename to packages/cli/src/acp-integration/schema.ts index e5f72b50..8f21c74c 100644 --- a/packages/cli/src/zed-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -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; export type RequestPermissionOutcome = z.infer< typeof requestPermissionOutcomeSchema >; +export type SessionListItem = z.infer; +export type ListSessionsRequest = z.infer; +export type ListSessionsResponse = z.infer; export type CancelNotification = z.infer; @@ -84,6 +89,12 @@ export type AgentCapabilities = z.infer; export type AuthMethod = z.infer; +export type ModeInfo = z.infer; + +export type ModesData = z.infer; + +export type AgentInfo = z.infer; + export type PromptCapabilities = z.infer; export type ClientResponse = z.infer; @@ -128,6 +139,12 @@ export type AgentRequest = z.infer; export type AgentNotification = z.infer; +export type ApprovalModeValue = z.infer; + +export type SetModeRequest = z.infer; + +export type SetModeResponse = z.infer; + export type AvailableCommandInput = z.infer; export type AvailableCommand = z.infer; @@ -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; + 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; diff --git a/packages/cli/src/zed-integration/fileSystemService.ts b/packages/cli/src/acp-integration/service/filesystem.ts similarity index 97% rename from packages/cli/src/zed-integration/fileSystemService.ts rename to packages/cli/src/acp-integration/service/filesystem.ts index 94e6c974..c7db7235 100644 --- a/packages/cli/src/zed-integration/fileSystemService.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -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 diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts new file mode 100644 index 00000000..83451592 --- /dev/null +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts @@ -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; + 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', + ]); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts new file mode 100644 index 00000000..53a1ed8a --- /dev/null +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -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 { + for (const record of records) { + await this.replayRecord(record); + } + } + + /** + * Replays a single chat record. + */ + private async replayRecord(record: ChatRecord): Promise { + 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 { + 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, + }); + } + } + } + + /** + * Replays a tool result record. + */ + private async replayToolResult(record: ChatRecord): Promise { + // 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 ''; + } +} diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts new file mode 100644 index 00000000..b4d79433 --- /dev/null +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -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 { + await this.historyReplayer.replay(records); + } + + async cancelPendingPrompt(): Promise { + if (!this.pendingPrompt) { + throw new Error('Not currently generating'); + } + + this.pendingPrompt.abort(); + this.pendingPrompt = null; + } + + async prompt(params: acp.PromptRequest): Promise { + 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 { + const params: acp.SessionNotification = { + sessionId: this.sessionId, + update, + }; + + await this.client.sessionUpdate(params); + } + + async sendAvailableCommandsUpdate(): Promise { + 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 { + 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 { + const modeMap: Record = { + 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 { + // 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 { + const callId = fc.id ?? `${fc.name}-${Date.now()}`; + const args = (fc.args ?? {}) as Record; + + 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 { + 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(); + + // 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}`); + } + } +} diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts new file mode 100644 index 00000000..074c8162 --- /dev/null +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts @@ -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 & { 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 & { + 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 & { + 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 { + 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 { + return { + type: 'info', + title: 'Tool requires approval', + prompt: 'Allow this action?', + ...overrides, + }; +} + +describe('SubAgentTracker', () => { + let mockContext: SessionContext; + let mockClient: acp.Client; + let sendUpdateSpy: ReturnType; + let requestPermissionSpy: ReturnType; + 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', + }), + ); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.ts new file mode 100644 index 00000000..c6c83292 --- /dev/null +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.ts @@ -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; + } + >(); + + 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 { + 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]; + } + } + } +} diff --git a/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts b/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts new file mode 100644 index 00000000..0dbbc91c --- /dev/null +++ b/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts @@ -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 { + 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; + } +} diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts new file mode 100644 index 00000000..52a41a48 --- /dev/null +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts @@ -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; + 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' }, + }); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts new file mode 100644 index 00000000..9ac8943a --- /dev/null +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -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 { + await this.sendUpdate({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + }); + } + + /** + * Emits an agent message chunk. + */ + async emitAgentMessage(text: string): Promise { + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + }); + } + + /** + * Emits an agent thought chunk. + */ + async emitAgentThought(text: string): Promise { + 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 { + if (role === 'user') { + return this.emitUserMessage(text); + } + return isThought + ? this.emitAgentThought(text) + : this.emitAgentMessage(text); + } +} diff --git a/packages/cli/src/acp-integration/session/emitters/PlanEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.test.ts new file mode 100644 index 00000000..4140fb33 --- /dev/null +++ b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.test.ts @@ -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; + 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(); + }); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts new file mode 100644 index 00000000..f6453cff --- /dev/null +++ b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts @@ -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 { + 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, + ): 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; + 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; + if ( + parsed?.['type'] === 'todo_list' && + Array.isArray(parsed['todos']) + ) { + return parsed['todos'] as TodoItem[]; + } + } catch { + // Not JSON, ignore + } + } + + return null; + } +} diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts new file mode 100644 index 00000000..52e13399 --- /dev/null +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.test.ts @@ -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; + let mockToolRegistry: ToolRegistry; + let emitter: ToolCallEmitter; + + // Helper to create mock tool + const createMockTool = ( + overrides: Partial = {}, + ): 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', + }), + ); + }); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts new file mode 100644 index 00000000..4c25570a --- /dev/null +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts @@ -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 { + // 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 { + // 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[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)['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 { + 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, + ): 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.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; + + // 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; + } +} diff --git a/packages/cli/src/acp-integration/session/emitters/index.ts b/packages/cli/src/acp-integration/session/emitters/index.ts new file mode 100644 index 00000000..f99a6dc7 --- /dev/null +++ b/packages/cli/src/acp-integration/session/emitters/index.ts @@ -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'; diff --git a/packages/cli/src/acp-integration/session/index.ts b/packages/cli/src/acp-integration/session/index.ts new file mode 100644 index 00000000..ece06633 --- /dev/null +++ b/packages/cli/src/acp-integration/session/index.ts @@ -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'; diff --git a/packages/cli/src/acp-integration/session/types.ts b/packages/cli/src/acp-integration/session/types.ts new file mode 100644 index 00000000..0c8f60a0 --- /dev/null +++ b/packages/cli/src/acp-integration/session/types.ts @@ -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; +} + +/** + * 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; +} + +/** + * 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; +} + +/** + * 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; +} diff --git a/packages/cli/src/config/config.integration.test.ts b/packages/cli/src/config/config.integration.test.ts index ff503df8..a76021f8 100644 --- a/packages/cli/src/config/config.integration.test.ts +++ b/packages/cli/src/config/config.integration.test.ts @@ -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; }); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 066fdd24..8cb88290 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -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); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 5be05d39..3162638f 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 { '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 { ) { 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 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 { @@ -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, diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index fc724a54..c9fde128 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -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: '', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index d928be0d..a9bc3d9e 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -479,6 +479,8 @@ describe('gemini.tsx main function kitty protocol', () => { inputFormat: undefined, outputFormat: undefined, includePartialMessages: undefined, + continue: undefined, + resume: undefined, }); await main(); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 002f34c1..310ef6b7 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -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 } > - + ({ 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: {} })); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 514fb15b..8be63a8e 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index b638ab20..59f26cf2 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -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 extends object @@ -63,7 +64,9 @@ export const createMockCommandContext = ( } as any, session: { sessionShellAllowlist: new Set(), + 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, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 235bb3bc..ebcc14f6 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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([]); // 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(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 diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts deleted file mode 100644 index 7453976c..00000000 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ /dev/null @@ -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; - - let mockContext: CommandContext; - let mockGetChat: ReturnType; - let mockSaveCheckpoint: ReturnType; - let mockLoadCheckpoint: ReturnType; - let mockDeleteCheckpoint: ReturnType; - let mockGetHistory: ReturnType; - - 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 => - [] 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 => - fakeFiles as string[]) as unknown as typeof fsPromises.readdir, - ); - mockFs.stat.mockImplementation((async (path: string): Promise => { - 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; - - 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 ', - }); - }); - - 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 ', - }); - }); - - 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 => - fakeFiles as string[]) as unknown as typeof fsPromises.readdir, - ); - - mockFs.stat.mockImplementation( - (async (_: string): Promise => - ({ - 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 => - fakeFiles as string[]) as unknown as typeof fsPromises.readdir, - ); - mockFs.stat.mockImplementation((async ( - path: string, - ): Promise => { - 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 ', - }); - }); - - 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 => - fakeFiles as string[]) as unknown as typeof fsPromises.readdir, - ); - - mockFs.stat.mockImplementation( - (async (_: string): Promise => - ({ - 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> = [ - { 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); - }); - }); -}); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts deleted file mode 100644 index eaf156da..00000000 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ /dev/null @@ -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 => { - 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 => { - 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 ', - ); - }, - kind: CommandKind.BUILT_IN, - action: async (context, args): Promise => { - const tag = args.trim(); - if (!tag) { - return { - type: 'message', - messageType: 'error', - content: t('Missing tag. Usage: /chat save '), - }; - } - - 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 ', - ); - }, - 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 '), - }; - } - - 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 '); - }, - kind: CommandKind.BUILT_IN, - action: async (context, args): Promise => { - const tag = args.trim(); - if (!tag) { - return { - type: 'message', - messageType: 'error', - content: t('Missing tag. Usage: /chat delete '), - }; - } - - 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 ', - ); - }, - kind: CommandKind.BUILT_IN, - action: async (context, args): Promise => { - 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, - ], -}; diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 90fd7e4d..e94c974f 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -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; + let mockStartNewSession: ReturnType; 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); }); }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 8beed859..dd774934 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -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(); }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index e865c07e..69d6e9d4 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -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; + /** 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; - // Provides argument completion (e.g., completing a tag for `/chat resume `). + // Provides argument completion completion?: ( context: CommandContext, partialArg: string, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 75795797..2f6f8636 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -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, diff --git a/packages/cli/src/ui/components/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx index ff749643..0540247d 100644 --- a/packages/cli/src/ui/components/Help.test.tsx +++ b/packages/cli/src/ui/components/Help.test.tsx @@ -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(); + const output = lastFrame(); + + expect(output).toContain('/test (alias-one, alias-two)'); + }); }); diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx index f1f6fcf9..35d2b59b 100644 --- a/packages/cli/src/ui/components/Help.tsx +++ b/packages/cli/src/ui/components/Help.tsx @@ -67,7 +67,7 @@ export const Help: React.FC = ({ commands }) => ( {' '} - /{command.name} + {formatCommandLabel(command, '/')} {command.kind === CommandKind.MCP_PROMPT && ( [MCP] @@ -81,7 +81,7 @@ export const Help: React.FC = ({ commands }) => ( {' '} - {subCommand.name} + {formatCommandLabel(subCommand)} {subCommand.description && ' - ' + subCommand.description} @@ -171,3 +171,17 @@ export const Help: React.FC = ({ commands }) => ( ); + +/** + * 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(', ')})`; +} diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 5c1d3014..5449db5e 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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(); await wait(); diff --git a/packages/cli/src/ui/components/QuitConfirmationDialog.tsx b/packages/cli/src/ui/components/QuitConfirmationDialog.tsx index 9e7cd914..84162779 100644 --- a/packages/cli/src/ui/components/QuitConfirmationDialog.tsx +++ b/packages/cli/src/ui/components/QuitConfirmationDialog.tsx @@ -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 = ({ 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)'), diff --git a/packages/cli/src/ui/components/ResumeSessionPicker.tsx b/packages/cli/src/ui/components/ResumeSessionPicker.tsx new file mode 100644 index 00000000..0057d700 --- /dev/null +++ b/packages/cli/src/ui/components/ResumeSessionPicker.tsx @@ -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 ; + } + + return ( + + {/* Main container with single border */} + + {/* Header row */} + + + Resume Session + + + + {/* Separator line */} + + + {'─'.repeat(terminalSize.width - 2)} + + + + {/* Session list with auto-scrolling */} + + {filteredSessions.length === 0 ? ( + + + {filterByBranch + ? `No sessions found for branch "${currentBranch}"` + : 'No sessions found'} + + + ) : ( + 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 ( + + {/* First line: prefix (selector or scroll indicator) + prompt text */} + + + {prefix} + + + {truncateText( + session.prompt || '(empty prompt)', + promptMaxWidth, + )} + + + + {/* Second line: metadata (aligned with prompt text) */} + + {' '} + + {timeAgo} · {messageText} + {session.gitBranch && ` · ${session.gitBranch}`} + + + + ); + }) + )} + + + {/* Separator line */} + + + {'─'.repeat(terminalSize.width - 2)} + + + + {/* Footer with keyboard shortcuts */} + + + {currentBranch && ( + <> + + B + + {' to toggle branch · '} + + )} + {'↑↓ to navigate · Esc to cancel'} + + + + + ); +} + +/** + * 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 { + 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((resolve) => { + let selectedId: string | undefined; + + const { unmount, waitUntilExit } = render( + { + 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); + }); + }); +} diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 7d7c4054..d5b95fe6 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -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 = ( { 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(() => { diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx index 5612b1e8..9fdb4fa2 100644 --- a/packages/cli/src/ui/contexts/SessionContext.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.tsx @@ -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( 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({ - 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(() => + 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 ( diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index f1ab54b3..dc8fcea7 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -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', diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 45411f94..758eb972 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -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; + +function serializeHistoryItemForRecording( + item: Omit, +): 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, isConfigInitialized: boolean, + logger: Logger | null, ) => { - const session = useSessionStats(); + const { stats: sessionStats, startNewSession } = useSessionStats(); const [commands, setCommands] = useState([]); 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( 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> = []; + const recordItem = (item: Omit) => { + 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, ], ); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 0f307feb..5994cc60 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -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, ); }); }); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index d4cc4001..8e7cbc0d 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -124,9 +124,13 @@ export const useGeminiStream = ( const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] = useStateAndRef(null); const processedMemoryToolsRef = useRef>(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, diff --git a/packages/cli/src/ui/hooks/useLogger.ts b/packages/cli/src/ui/hooks/useLogger.ts index d5a8abc7..bcd980ba 100644 --- a/packages/cli/src/ui/hooks/useLogger.ts +++ b/packages/cli/src/ui/hooks/useLogger.ts @@ -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(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; }; diff --git a/packages/cli/src/ui/hooks/useQuitConfirmation.ts b/packages/cli/src/ui/hooks/useQuitConfirmation.ts index 3c2885cc..fff0d488 100644 --- a/packages/cli/src/ui/hooks/useQuitConfirmation.ts +++ b/packages/cli/src/ui/hooks/useQuitConfirmation.ts @@ -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' }; } diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index d4a1b997..5542718f 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -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, diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 371af516..2827cc45 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -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, ), diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index ba639d01..dbd9b463 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -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(', ')})`; +} diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 71069fed..cab0b5ee 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -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({ diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index cb3d1324..34bf67e2 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -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'); diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index 2b6af545..b65cefe1 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -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'; diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts new file mode 100644 index 00000000..f0c94fab --- /dev/null +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -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) => + ({ + 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' }, + ]); + }); +}); diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts new file mode 100644 index 00000000..85ae0572 --- /dev/null +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -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 }> { + if (!parts) return []; + + const calls: Array<{ + id: string; + name: string; + args: Record; + }> = []; + 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) || {}, + }); + } + } + 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 { + 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) }; + 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 } + >(); + 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 + )['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; +} diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts deleted file mode 100644 index d83395f2..00000000 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ /dev/null @@ -1,1484 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { ReadableStream, WritableStream } from 'node:stream/web'; - -import type { Content, FunctionCall, Part } from '@google/genai'; -import type { - Config, - GeminiChat, - ToolCallConfirmationDetails, - ToolResult, - SubAgentEventEmitter, - SubAgentToolCallEvent, - SubAgentToolResultEvent, - SubAgentApprovalRequestEvent, - AnyDeclarativeTool, - AnyToolInvocation, -} from '@qwen-code/qwen-code-core'; -import { - AuthType, - clearCachedCredentialFile, - convertToFunctionResponse, - DiscoveredMCPTool, - StreamEventType, - DEFAULT_GEMINI_MODEL, - DEFAULT_GEMINI_MODEL_AUTO, - DEFAULT_GEMINI_FLASH_MODEL, - MCPServerConfig, - ToolConfirmationOutcome, - logToolCall, - logUserPrompt, - getErrorStatus, - isWithinRoot, - isNodeError, - SubAgentEventType, - TaskTool, - Kind, - TodoWriteTool, - UserPromptEvent, -} from '@qwen-code/qwen-code-core'; -import * as acp from './acp.js'; -import { AcpFileSystemService } from './fileSystemService.js'; -import { Readable, Writable } from 'node:stream'; -import type { LoadedSettings } from '../config/settings.js'; -import { SettingScope } from '../config/settings.js'; -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import { z } from 'zod'; -import { randomUUID } from 'node:crypto'; -import { getErrorMessage } from '../utils/errors.js'; -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 { - handleSlashCommand, - getAvailableCommands, -} from '../nonInteractiveCliCommands.js'; -import type { AvailableCommand, AvailableCommandsUpdate } from './schema.js'; -import { isSlashCommand } from '../ui/utils/commandUtils.js'; - -/** - * Built-in commands that are allowed in ACP integration mode. - * Only these commands will be available when using handleSlashCommand - * or getAvailableCommands in ACP integration. - * - * Currently, only "init" is supported because `handleSlashCommand` in - * nonInteractiveCliCommands.ts only supports handling results where - * result.type is "submit_prompt". Other result types are either coupled - * to the UI or cannot send notifications to the client via ACP. - * - * If you have a good idea to add support for more commands, PRs are welcome! - */ -const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init']; - -/** - * Resolves the model to use based on the current configuration. - * - * If the model is set to "auto", it will use the flash model if in fallback - * mode, otherwise it will use the default model. - */ -export function resolveModel(model: string, isInFallbackMode: boolean): string { - if (model === DEFAULT_GEMINI_MODEL_AUTO) { - return isInFallbackMode ? DEFAULT_GEMINI_FLASH_MODEL : DEFAULT_GEMINI_MODEL; - } - return model; -} - -export async function runZedIntegration( - config: Config, - settings: LoadedSettings, - extensions: Extension[], - argv: CliArgs, -) { - const stdout = Writable.toWeb(process.stdout) as WritableStream; - const stdin = Readable.toWeb(process.stdin) as ReadableStream; - - // 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 = 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 { - 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', - }, - ]; - - return { - protocolVersion: acp.PROTOCOL_VERSION, - authMethods, - agentCapabilities: { - loadSession: false, - promptCapabilities: { - image: true, - audio: true, - embeddedContext: true, - }, - }, - }; - } - - async authenticate({ methodId }: acp.AuthenticateRequest): Promise { - 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 { - const sessionId = this.config.getSessionId() || randomUUID(); - const config = await this.newSessionConfig(sessionId, cwd, mcpServers); - - let isAuthenticated = false; - if (this.settings.merged.security?.auth?.selectedType) { - try { - await config.refreshAuth( - this.settings.merged.security.auth.selectedType, - ); - isAuthenticated = true; - } catch (e) { - console.error(`Authentication failed: ${e}`); - } - } - - if (!isAuthenticated) { - throw acp.RequestError.authRequired(); - } - - if (this.clientCapabilities?.fs) { - const acpFileSystemService = new AcpFileSystemService( - this.client, - sessionId, - this.clientCapabilities.fs, - config.getFileSystemService(), - ); - config.setFileSystemService(acpFileSystemService); - } - - const geminiClient = config.getGeminiClient(); - const chat = await geminiClient.startChat(); - const session = new Session( - sessionId, - chat, - config, - this.client, - this.settings, - ); - this.sessions.set(sessionId, session); - - // Send available commands update as the first session update - setTimeout(async () => { - await session.sendAvailableCommandsUpdate(); - }, 0); - - return { - sessionId, - }; - } - - async newSessionConfig( - sessionId: string, - cwd: string, - mcpServers: acp.McpServer[], - ): Promise { - const mergedMcpServers = { ...this.settings.merged.mcpServers }; - - for (const { command, args, env: rawEnv, name } of mcpServers) { - const env: Record = {}; - 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 config = await loadCliConfig( - settings, - this.extensions, - new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - this.argv.extensions, - ), - sessionId, - this.argv, - cwd, - ); - - await config.initialize(); - return config; - } - - async cancel(params: acp.CancelNotification): Promise { - 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 { - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - return session.prompt(params); - } -} - -class Session { - private pendingPrompt: AbortController | null = null; - private turn: number = 0; - - constructor( - private readonly id: string, - private readonly chat: GeminiChat, - private readonly config: Config, - private readonly client: acp.Client, - private readonly settings: LoadedSettings, - ) {} - - async cancelPendingPrompt(): Promise { - if (!this.pendingPrompt) { - throw new Error('Not currently generating'); - } - - this.pendingPrompt.abort(); - this.pendingPrompt = null; - } - - async prompt(params: acp.PromptRequest): Promise { - 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, - ), - ); - - // 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( - resolveModel(this.config.getModel(), this.config.isInFallbackMode()), - { - 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' }; - } - - private async sendUpdate(update: acp.SessionUpdate): Promise { - const params: acp.SessionNotification = { - sessionId: this.id, - update, - }; - - await this.client.sessionUpdate(params); - } - - async sendAvailableCommandsUpdate(): Promise { - 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); - } - } - - private async runTool( - abortSignal: AbortSignal, - promptId: string, - fc: FunctionCall, - ): Promise { - const callId = fc.id ?? `${fc.name}-${Date.now()}`; - const args = (fc.args ?? {}) as Record; - - 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 = - fc.name === TodoWriteTool.Name || tool.name === TodoWriteTool.Name; - - // Declare subAgentToolEventListeners outside try block for cleanup in catch - let subAgentToolEventListeners: Array<() => void> = []; - - try { - const invocation = tool.build(args); - - // Detect TaskTool and set up sub-agent tool tracking - const isTaskTool = tool.name === TaskTool.Name; - - if (isTaskTool && 'eventEmitter' in invocation) { - // Access eventEmitter from TaskTool invocation - const taskEventEmitter = ( - invocation as { - eventEmitter: SubAgentEventEmitter; - } - ).eventEmitter; - - // Set up sub-agent tool tracking - subAgentToolEventListeners = this.setupSubAgentToolTracking( - 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, - }); - } - - const params: acp.RequestPermissionRequest = { - sessionId: this.id, - options: toPermissionOptions(confirmationDetails), - toolCall: { - toolCallId: callId, - status: 'pending', - title: invocation.getDescription(), - content, - locations: invocation.toolLocations(), - kind: tool.kind, - }, - }; - - 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); - - 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 - await this.sendUpdate({ - sessionUpdate: 'tool_call', - toolCallId: callId, - status: 'in_progress', - title: invocation.getDescription(), - content: [], - locations: invocation.toolLocations(), - kind: tool.kind, - }); - } - - const toolResult: ToolResult = await invocation.execute(abortSignal); - - // Clean up event listeners - subAgentToolEventListeners.forEach((cleanup) => cleanup()); - - // Handle TodoWriteTool: extract todos and send plan update - if (isTodoWriteTool) { - // Extract todos from args (initial state) - let todos: Array<{ - id: string; - content: string; - status: 'pending' | 'in_progress' | 'completed'; - }> = []; - - if (Array.isArray(args['todos'])) { - todos = args['todos'] as Array<{ - id: string; - content: string; - status: 'pending' | 'in_progress' | 'completed'; - }>; - } - - // If returnDisplay has todos (e.g., modified by user), use those instead - if ( - toolResult.returnDisplay && - typeof toolResult.returnDisplay === 'object' && - 'type' in toolResult.returnDisplay && - toolResult.returnDisplay.type === 'todo_list' && - 'todos' in toolResult.returnDisplay && - Array.isArray(toolResult.returnDisplay.todos) - ) { - todos = toolResult.returnDisplay.todos; - } - - // Convert todos to plan entries and send plan update - if (todos.length > 0 || Array.isArray(args['todos'])) { - const planEntries = convertTodosToPlanEntries(todos); - await this.sendUpdate({ - sessionUpdate: 'plan', - entries: planEntries, - }); - } - - // Skip tool_call_update event for TodoWriteTool - // Still log and return function response for LLM - } else { - // Normal tool handling: send tool_call_update - const content = toToolCallContent(toolResult); - - await this.sendUpdate({ - sessionUpdate: 'tool_call_update', - toolCallId: callId, - status: 'completed', - content: content ? [content] : [], - }); - } - - 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', - }); - - return convertToFunctionResponse(fc.name, callId, toolResult.llmContent); - } catch (e) { - // Ensure cleanup on error - subAgentToolEventListeners.forEach((cleanup) => cleanup()); - - const error = e instanceof Error ? e : new Error(String(e)); - - await this.sendUpdate({ - sessionUpdate: 'tool_call_update', - toolCallId: callId, - status: 'failed', - content: [ - { type: 'content', content: { type: 'text', text: error.message } }, - ], - }); - - return errorResponse(error); - } - } - - /** - * Sets up event listeners to track sub-agent tool calls within a TaskTool execution. - * Converts subagent tool call events into zedIntegration session updates. - * - * @param eventEmitter - The SubAgentEventEmitter from TaskTool - * @param abortSignal - Signal to abort tracking if parent is cancelled - * @returns Array of cleanup functions to remove event listeners - */ - private setupSubAgentToolTracking( - eventEmitter: SubAgentEventEmitter, - abortSignal: AbortSignal, - ): Array<() => void> { - const cleanupFunctions: Array<() => void> = []; - const toolRegistry = this.config.getToolRegistry(); - - // Track subagent tool call states - const subAgentToolStates = new Map< - string, - { - tool?: AnyDeclarativeTool; - invocation?: AnyToolInvocation; - args?: Record; - } - >(); - - // Listen for tool call start - const onToolCall = (...args: unknown[]) => { - const event = args[0] as SubAgentToolCallEvent; - if (abortSignal.aborted) return; - - const subAgentTool = toolRegistry.getTool(event.name); - let subAgentInvocation: AnyToolInvocation | undefined; - let toolKind: acp.ToolKind = 'other'; - let locations: acp.ToolCallLocation[] = []; - - if (subAgentTool) { - try { - subAgentInvocation = subAgentTool.build(event.args); - toolKind = this.mapToolKind(subAgentTool.kind); - locations = subAgentInvocation.toolLocations().map((loc) => ({ - path: loc.path, - line: loc.line ?? null, - })); - } catch (e) { - // If building fails, continue with defaults - console.warn(`Failed to build subagent tool ${event.name}:`, e); - } - } - - // Save state for subsequent updates - subAgentToolStates.set(event.callId, { - tool: subAgentTool, - invocation: subAgentInvocation, - args: event.args, - }); - - // Check if this is TodoWriteTool - if so, skip sending tool_call event - // Plan update will be sent in onToolResult when we have the final state - if (event.name === TodoWriteTool.Name) { - return; - } - - // Send tool call start update with rawInput - void this.sendUpdate({ - sessionUpdate: 'tool_call', - toolCallId: event.callId, - status: 'in_progress', - title: event.description || event.name, - content: [], - locations, - kind: toolKind, - rawInput: event.args, - }); - }; - - // Listen for tool call result - const onToolResult = (...args: unknown[]) => { - const event = args[0] as SubAgentToolResultEvent; - if (abortSignal.aborted) return; - - const state = subAgentToolStates.get(event.callId); - - // Check if this is TodoWriteTool - if so, route to plan updates - if (event.name === TodoWriteTool.Name) { - let todos: - | Array<{ - id: string; - content: string; - status: 'pending' | 'in_progress' | 'completed'; - }> - | undefined; - - // Try to extract todos from resultDisplay first (final state) - if (event.resultDisplay) { - try { - // resultDisplay might be a JSON stringified object - const parsed = - typeof event.resultDisplay === 'string' - ? JSON.parse(event.resultDisplay) - : event.resultDisplay; - - if ( - typeof parsed === 'object' && - parsed !== null && - 'type' in parsed && - parsed.type === 'todo_list' && - 'todos' in parsed && - Array.isArray(parsed.todos) - ) { - todos = parsed.todos; - } - } catch { - // If parsing fails, ignore - resultDisplay might not be JSON - } - } - - // Fallback to args if resultDisplay doesn't have todos - if (!todos && state?.args && Array.isArray(state.args['todos'])) { - todos = state.args['todos'] as Array<{ - id: string; - content: string; - status: 'pending' | 'in_progress' | 'completed'; - }>; - } - - // Send plan update if we have todos - if (todos) { - const planEntries = convertTodosToPlanEntries(todos); - void this.sendUpdate({ - sessionUpdate: 'plan', - entries: planEntries, - }); - } - - // Skip sending tool_call_update event for TodoWriteTool - // Clean up state - subAgentToolStates.delete(event.callId); - return; - } - - let content: acp.ToolCallContent[] = []; - - // If there's a result display, try to convert to ToolCallContent - if (event.resultDisplay && state?.invocation) { - // resultDisplay is typically a string - if (typeof event.resultDisplay === 'string') { - content = [ - { - type: 'content', - content: { - type: 'text', - text: event.resultDisplay, - }, - }, - ]; - } - } - - // Send tool call completion update - void this.sendUpdate({ - sessionUpdate: 'tool_call_update', - toolCallId: event.callId, - status: event.success ? 'completed' : 'failed', - content: content.length > 0 ? content : [], - title: state?.invocation?.getDescription() ?? event.name, - kind: state?.tool ? this.mapToolKind(state.tool.kind) : null, - locations: - state?.invocation?.toolLocations().map((loc) => ({ - path: loc.path, - line: loc.line ?? null, - })) ?? null, - rawInput: state?.args, - }); - - // Clean up state - subAgentToolStates.delete(event.callId); - }; - - // Listen for permission requests - const onToolWaitingApproval = async (...args: unknown[]) => { - const event = args[0] as SubAgentApprovalRequestEvent; - if (abortSignal.aborted) return; - - const state = subAgentToolStates.get(event.callId); - const content: acp.ToolCallContent[] = []; - - // Handle different confirmation types - 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 options from confirmation details - // event.confirmationDetails already contains all fields except onConfirm, - // which we add here to satisfy the type requirement for toPermissionOptions - const fullConfirmationDetails = { - ...event.confirmationDetails, - onConfirm: async () => { - // This is a placeholder - the actual response is handled via event.respond - }, - } as unknown as ToolCallConfirmationDetails; - - const params: acp.RequestPermissionRequest = { - sessionId: this.id, - options: toPermissionOptions(fullConfirmationDetails), - toolCall: { - toolCallId: event.callId, - status: 'pending', - title: event.description || event.name, - content, - locations: - state?.invocation?.toolLocations().map((loc) => ({ - path: loc.path, - line: loc.line ?? null, - })) ?? [], - kind: state?.tool ? this.mapToolKind(state.tool.kind) : 'other', - rawInput: state?.args, - }, - }; - - try { - // Request permission from zed 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); - } - }; - - // Register event listeners - eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall); - eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult); - eventEmitter.on( - SubAgentEventType.TOOL_WAITING_APPROVAL, - onToolWaitingApproval, - ); - - // Return cleanup functions - cleanupFunctions.push(() => { - eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall); - eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult); - eventEmitter.off( - SubAgentEventType.TOOL_WAITING_APPROVAL, - onToolWaitingApproval, - ); - }); - - return cleanupFunctions; - } - - /** - * Maps core Tool Kind enum to ACP ToolKind string literals. - * - * @param kind - The core Kind enum value - * @returns The corresponding ACP ToolKind string literal - */ - private mapToolKind(kind: Kind): acp.ToolKind { - const kindMap: Record = { - [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'; - } - - async #resolvePrompt( - message: acp.ContentBlock[], - abortSignal: AbortSignal, - ): Promise { - 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(); - - // 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 += ' '; - } - } - if (resolvedSpec) { - initialQueryText += `@${resolvedSpec}`; - } else { - // If not resolved for reading (e.g. lone @ or invalid path that was skipped), - // add the original @-string back, ensuring spacing if it's not the first element. - if ( - i > 0 && - initialQueryText.length > 0 && - !initialQueryText.endsWith(' ') && - !chunk.fileData?.fileUri.startsWith(' ') - ) { - initialQueryText += ' '; - } - if (chunk.fileData?.fileUri) { - initialQueryText += `@${chunk.fileData.fileUri}`; - } - } - } - } - initialQueryText = initialQueryText.trim(); - // Inform user about ignored paths - if (ignoredPaths.length > 0) { - const ignoreType = respectGitIgnore ? 'git-ignored' : 'custom-ignored'; - this.debug( - `Ignored ${ignoredPaths.length} ${ignoreType} files: ${ignoredPaths.join(', ')}`, - ); - } - - const processedQueryParts: Part[] = [{ text: initialQueryText }]; - - if (pathSpecsToRead.length === 0 && embeddedContext.length === 0) { - // Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText - console.warn('No valid file paths found in @ commands to read.'); - return [{ text: initialQueryText }]; - } - - if (pathSpecsToRead.length > 0) { - const toolArgs = { - paths: pathSpecsToRead, - respectGitIgnore, // Use configuration setting - }; - - const callId = `${readManyFilesTool.name}-${Date.now()}`; - - try { - const invocation = readManyFilesTool.build(toolArgs); - - await this.sendUpdate({ - sessionUpdate: 'tool_call', - toolCallId: callId, - status: 'in_progress', - title: invocation.getDescription(), - content: [], - locations: invocation.toolLocations(), - kind: readManyFilesTool.kind, - }); - - const result = await invocation.execute(abortSignal); - const content = toToolCallContent(result) || { - type: 'content', - content: { - type: 'text', - text: `Successfully read: ${contentLabelsForDisplay.join(', ')}`, - }, - }; - await this.sendUpdate({ - sessionUpdate: 'tool_call_update', - toolCallId: callId, - status: 'completed', - content: content ? [content] : [], - }); - if (Array.isArray(result.llmContent)) { - const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; - processedQueryParts.push({ - text: '\n--- Content from referenced files ---', - }); - for (const part of result.llmContent) { - if (typeof part === 'string') { - const match = fileContentRegex.exec(part); - if (match) { - const filePathSpecInContent = match[1]; // This is a resolved pathSpec - const fileActualContent = match[2].trim(); - processedQueryParts.push({ - text: `\nContent from @${filePathSpecInContent}:\n`, - }); - processedQueryParts.push({ text: fileActualContent }); - } else { - processedQueryParts.push({ text: part }); - } - } else { - // part is a Part object. - processedQueryParts.push(part); - } - } - } else { - console.warn( - 'read_many_files tool returned no content or empty content.', - ); - } - } catch (error: unknown) { - await this.sendUpdate({ - sessionUpdate: 'tool_call_update', - toolCallId: callId, - status: 'failed', - content: [ - { - type: 'content', - content: { - type: 'text', - text: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, - }, - }, - ], - }); - - throw error; - } - } - - if (embeddedContext.length > 0) { - processedQueryParts.push({ - text: '\n--- Content from referenced context ---', - }); - - for (const contextPart of embeddedContext) { - processedQueryParts.push({ - text: `\nContent from @${contextPart.uri}:\n`, - }); - if ('text' in contextPart) { - processedQueryParts.push({ - text: contextPart.text, - }); - } else { - processedQueryParts.push({ - inlineData: { - mimeType: contextPart.mimeType ?? 'application/octet-stream', - data: contextPart.blob, - }, - }); - } - } - } - - return processedQueryParts; - } - - debug(msg: string) { - if (this.config.getDebugMode()) { - console.warn(msg); - } - } -} - -/** - * Converts todo items to plan entries format for zed integration. - * Maps todo status to plan status and assigns a default priority. - * - * @param todos - Array of todo items with id, content, and status - * @returns Array of plan entries with content, priority, and status - */ -function convertTodosToPlanEntries( - todos: Array<{ - id: string; - content: string; - status: 'pending' | 'in_progress' | 'completed'; - }>, -): acp.PlanEntry[] { - return todos.map((todo) => ({ - content: todo.content, - priority: 'medium' as const, // Default priority since todos don't have priority - status: todo.status, - })); -} - -function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null { - if (toolResult.error?.message) { - throw new Error(toolResult.error.message); - } - - if (toolResult.returnDisplay) { - if (typeof toolResult.returnDisplay === 'string') { - return { - type: 'content', - content: { type: 'text', text: toolResult.returnDisplay }, - }; - } else if ( - 'type' in toolResult.returnDisplay && - toolResult.returnDisplay.type === 'plan_summary' - ) { - const planDisplay = toolResult.returnDisplay; - const planText = `${planDisplay.message}\n\n${planDisplay.plan}`; - return { - type: 'content', - content: { type: 'text', text: planText }, - }; - } else { - if ('fileName' in toolResult.returnDisplay) { - return { - type: 'diff', - path: toolResult.returnDisplay.fileName, - oldText: toolResult.returnDisplay.originalContent, - newText: toolResult.returnDisplay.newContent, - }; - } - return null; - } - } - return null; -} - -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: `Always Allow Plans`, - kind: 'allow_always', - }, - ...basicPermissionOptions, - ]; - default: { - const unreachable: never = confirmation; - throw new Error(`Unexpected: ${unreachable}`); - } - } -} diff --git a/packages/core/package.json b/packages/core/package.json index 42bca596..ffb29e06 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,7 @@ "@google/genai": "1.16.0", "@modelcontextprotocol/sdk": "^1.11.0", "@opentelemetry/api": "^1.9.0", + "async-mutex": "^0.5.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0", "@opentelemetry/exporter-logs-otlp-http": "^0.203.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.203.0", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index ea897db2..1c83432d 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -23,19 +23,6 @@ import { } from '../core/contentGenerator.js'; import { GeminiClient } from '../core/client.js'; import { GitService } from '../services/gitService.js'; - -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: vi.fn().mockReturnValue(true), - statSync: vi.fn().mockReturnValue({ - isDirectory: vi.fn().mockReturnValue(true), - }), - realpathSync: vi.fn((path) => path), - }; -}); - import { ShellTool } from '../tools/shell.js'; import { ReadFileTool } from '../tools/read-file.js'; import { GrepTool } from '../tools/grep.js'; @@ -54,9 +41,9 @@ function createToolMock(toolName: string) { return ToolMock; } -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + const mocked = { ...actual, existsSync: vi.fn().mockReturnValue(true), statSync: vi.fn().mockReturnValue({ @@ -64,6 +51,10 @@ vi.mock('fs', async (importOriginal) => { }), realpathSync: vi.fn((path) => path), }; + return { + ...mocked, + default: mocked, // Required for ESM default imports (import fs from 'node:fs') + }; }); // Mock dependencies that might be called during Config construction or createServerConfig @@ -197,7 +188,6 @@ describe('Server Config (config.ts)', () => { const USER_MEMORY = 'Test User Memory'; const TELEMETRY_SETTINGS = { enabled: false }; const EMBEDDING_MODEL = 'gemini-embedding'; - const SESSION_ID = 'test-session-id'; const baseParams: ConfigParameters = { cwd: '/tmp', embeddingModel: EMBEDDING_MODEL, @@ -208,7 +198,6 @@ describe('Server Config (config.ts)', () => { fullContext: FULL_CONTEXT, userMemory: USER_MEMORY, telemetry: TELEMETRY_SETTINGS, - sessionId: SESSION_ID, model: MODEL, usageStatisticsEnabled: false, }; @@ -217,7 +206,7 @@ describe('Server Config (config.ts)', () => { // Reset mocks if necessary vi.clearAllMocks(); vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation( - () => undefined, + async () => undefined, ); }); @@ -476,7 +465,7 @@ describe('Server Config (config.ts)', () => { ...baseParams, usageStatisticsEnabled: true, }); - await config.refreshAuth(AuthType.USE_GEMINI); + await config.initialize(); expect(QwenLogger.prototype.logStartSessionEvent).toHaveBeenCalledOnce(); }); @@ -956,7 +945,6 @@ describe('Server Config (config.ts)', () => { describe('setApprovalMode with folder trust', () => { const baseParams: ConfigParameters = { - sessionId: 'test', targetDir: '.', debugMode: false, model: 'test-model', @@ -987,7 +975,6 @@ describe('setApprovalMode with folder trust', () => { it('should NOT throw an error when setting PLAN mode in an untrusted folder', () => { const config = new Config({ - sessionId: 'test', targetDir: '.', debugMode: false, model: 'test-model', @@ -1168,7 +1155,6 @@ describe('BaseLlmClient Lifecycle', () => { const USER_MEMORY = 'Test User Memory'; const TELEMETRY_SETTINGS = { enabled: false }; const EMBEDDING_MODEL = 'gemini-embedding'; - const SESSION_ID = 'test-session-id'; const baseParams: ConfigParameters = { cwd: '/tmp', embeddingModel: EMBEDDING_MODEL, @@ -1179,7 +1165,6 @@ describe('BaseLlmClient Lifecycle', () => { fullContext: FULL_CONTEXT, userMemory: USER_MEMORY, telemetry: TELEMETRY_SETTINGS, - sessionId: SESSION_ID, model: MODEL, usageStatisticsEnabled: false, }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1213556b..59baba85 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -69,7 +69,7 @@ import { DEFAULT_OTLP_ENDPOINT, DEFAULT_TELEMETRY_TARGET, initializeTelemetry, - logCliConfiguration, + logStartSession, logRipgrepFallback, RipgrepFallbackEvent, StartSessionEvent, @@ -93,6 +93,12 @@ import { import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js'; import { Storage } from './storage.js'; import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js'; +import { ChatRecordingService } from '../services/chatRecordingService.js'; +import { + SessionService, + type ResumedSessionData, +} from '../services/sessionService.js'; +import { randomUUID } from 'node:crypto'; // Re-export types export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig }; @@ -110,6 +116,42 @@ export enum ApprovalMode { export const APPROVAL_MODES = Object.values(ApprovalMode); +/** + * Information about an approval mode including display name and description. + */ +export interface ApprovalModeInfo { + id: ApprovalMode; + name: string; + description: string; +} + +/** + * Detailed information about each approval mode. + * Used for UI display and protocol responses. + */ +export const APPROVAL_MODE_INFO: Record = { + [ApprovalMode.PLAN]: { + id: ApprovalMode.PLAN, + name: 'Plan', + description: 'Analyze only, do not modify files or execute commands', + }, + [ApprovalMode.DEFAULT]: { + id: ApprovalMode.DEFAULT, + name: 'Default', + description: 'Require approval for file edits or shell commands', + }, + [ApprovalMode.AUTO_EDIT]: { + id: ApprovalMode.AUTO_EDIT, + name: 'Auto Edit', + description: 'Automatically approve file edits', + }, + [ApprovalMode.YOLO]: { + id: ApprovalMode.YOLO, + name: 'YOLO', + description: 'Automatically approve all tools', + }, +}; + export interface AccessibilitySettings { disableLoadingPhrases?: boolean; screenReader?: boolean; @@ -211,7 +253,8 @@ export interface SandboxConfig { } export interface ConfigParameters { - sessionId: string; + sessionId?: string; + sessionData?: ResumedSessionData; embeddingModel?: string; sandbox?: SandboxConfig; targetDir: string; @@ -315,10 +358,11 @@ function normalizeConfigOutputFormat( } export class Config { + private sessionId: string; + private sessionData?: ResumedSessionData; private toolRegistry!: ToolRegistry; private promptRegistry!: PromptRegistry; private subagentManager!: SubagentManager; - private readonly sessionId: string; private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGenerator!: ContentGenerator; @@ -358,6 +402,8 @@ export class Config { }; private fileDiscoveryService: FileDiscoveryService | null = null; private gitService: GitService | undefined = undefined; + private sessionService: SessionService | undefined = undefined; + private chatRecordingService: ChatRecordingService | undefined = undefined; private readonly checkpointing: boolean; private readonly proxy: string | undefined; private readonly cwd: string; @@ -415,7 +461,8 @@ export class Config { private readonly useSmartEdit: boolean; constructor(params: ConfigParameters) { - this.sessionId = params.sessionId; + this.sessionId = params.sessionId ?? randomUUID(); + this.sessionData = params.sessionData; this.embeddingModel = params.embeddingModel ?? DEFAULT_QWEN_EMBEDDING_MODEL; this.fileSystemService = new StandardFileSystemService(); this.sandbox = params.sandbox; @@ -540,6 +587,7 @@ export class Config { setGlobalDispatcher(new ProxyAgent(this.getProxy() as string)); } this.geminiClient = new GeminiClient(this); + this.chatRecordingService = new ChatRecordingService(this); } /** @@ -561,6 +609,8 @@ export class Config { this.toolRegistry = await this.createToolRegistry(); await this.geminiClient.initialize(); + + logStartSession(this, new StartSessionEvent(this)); } getContentGenerator(): ContentGenerator { @@ -606,7 +656,6 @@ export class Config { this.contentGenerator = await createContentGenerator( newContentGeneratorConfig, this, - this.getSessionId(), isInitialAuth, ); // Only assign to instance properties after successful initialization @@ -617,9 +666,6 @@ export class Config { // Reset the session flag since we're explicitly changing auth and using default model this.inFallbackMode = false; - - // Logging the cli configuration here as the auth related configuration params would have been loaded by this point - logCliConfiguration(this, new StartSessionEvent(this, this.toolRegistry)); } /** @@ -646,6 +692,26 @@ export class Config { return this.sessionId; } + /** + * Starts a new session and resets session-scoped services. + */ + startNewSession(sessionId?: string): string { + this.sessionId = sessionId ?? randomUUID(); + this.sessionData = undefined; + this.chatRecordingService = new ChatRecordingService(this); + if (this.initialized) { + logStartSession(this, new StartSessionEvent(this)); + } + return this.sessionId; + } + + /** + * Returns the resumed session data if this session was resumed from a previous one. + */ + getResumedSessionData(): ResumedSessionData | undefined { + return this.sessionData; + } + shouldLoadMemoryFromIncludeDirectories(): boolean { return this.loadMemoryFromIncludeDirectories; } @@ -1128,6 +1194,26 @@ export class Config { return this.gitService; } + /** + * Returns the chat recording service. + */ + getChatRecordingService(): ChatRecordingService { + if (!this.chatRecordingService) { + this.chatRecordingService = new ChatRecordingService(this); + } + return this.chatRecordingService; + } + + /** + * Gets or creates a SessionService for managing chat sessions. + */ + getSessionService(): SessionService { + if (!this.sessionService) { + this.sessionService = new SessionService(this.targetDir); + } + return this.sessionService; + } + getFileExclusions(): FileExclusions { return this.fileExclusions; } diff --git a/packages/core/src/config/flashFallback.test.ts b/packages/core/src/config/flashFallback.test.ts index 4173786c..2de20b2b 100644 --- a/packages/core/src/config/flashFallback.test.ts +++ b/packages/core/src/config/flashFallback.test.ts @@ -20,7 +20,6 @@ describe('Flash Model Fallback Configuration', () => { isDirectory: () => true, } as fs.Stats); config = new Config({ - sessionId: 'test-session', targetDir: '/test', debugMode: false, cwd: '/test', @@ -44,7 +43,6 @@ describe('Flash Model Fallback Configuration', () => { it('should only mark as switched if contentGeneratorConfig exists', async () => { // Create config without initializing contentGeneratorConfig const newConfig = new Config({ - sessionId: 'test-session-2', targetDir: '/test', debugMode: false, cwd: '/test', @@ -67,7 +65,6 @@ describe('Flash Model Fallback Configuration', () => { it('should fall back to initial model if contentGeneratorConfig is not available', () => { // Test with fresh config where contentGeneratorConfig might not be set const newConfig = new Config({ - sessionId: 'test-session-2', targetDir: '/test', debugMode: false, cwd: '/test', diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 1015206a..36cc1b25 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -4,18 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; - -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - mkdirSync: vi.fn(), - }; -}); - import { Storage } from './storage.js'; describe('Storage – getGlobalSettingsPath', () => { diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index bc4538f8..8e598787 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -14,6 +14,7 @@ export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; export const OAUTH_FILE = 'oauth_creds.json'; const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; +const PROJECT_DIR_NAME = 'projects'; export class Storage { private readonly targetDir: string; @@ -66,6 +67,12 @@ export class Storage { return path.join(this.targetDir, QWEN_DIR); } + getProjectDir(): string { + const projectId = this.sanitizeCwd(this.getProjectRoot()); + const projectsDir = path.join(Storage.getGlobalQwenDir(), PROJECT_DIR_NAME); + return path.join(projectsDir, projectId); + } + getProjectTempDir(): string { const hash = this.getFilePathHash(this.getProjectRoot()); const tempDir = Storage.getGlobalTempDir(); @@ -117,4 +124,8 @@ export class Storage { getHistoryFilePath(): string { return path.join(this.getProjectTempDir(), 'shell_history'); } + + private sanitizeCwd(cwd: string): string { + return cwd.replace(/[^a-zA-Z0-9]/g, '-'); + } } diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index b0a03385..e475e5b3 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -61,6 +61,7 @@ vi.mock('node:fs', () => { }); }), existsSync: vi.fn((path: string) => mockFileSystem.has(path)), + appendFileSync: vi.fn(), }; return { @@ -364,6 +365,9 @@ describe('Gemini Client (client.ts)', () => { getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), storage: { getProjectTempDir: vi.fn().mockReturnValue('/test/temp'), + getProjectDir: vi + .fn() + .mockReturnValue('/test/project/root/.gemini/projects/test-project'), }, getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator), getBaseLlmClient: vi.fn().mockReturnValue({ @@ -374,6 +378,8 @@ describe('Gemini Client (client.ts)', () => { }), getSubagentManager: vi.fn().mockReturnValue(mockSubagentManager), getSkipLoopDetection: vi.fn().mockReturnValue(false), + getChatRecordingService: vi.fn().mockReturnValue(undefined), + getResumedSessionData: vi.fn().mockReturnValue(undefined), } as unknown as Config; client = new GeminiClient(mockConfig); @@ -1513,6 +1519,7 @@ ${JSON.stringify( [{ text: 'Start conversation' }], signal, 'prompt-id-3', + { isContinuation: false }, Number.MAX_SAFE_INTEGER, // Bypass the MAX_TURNS protection ); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 3aa34950..2fa65d2d 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -39,7 +39,6 @@ import { } from './turn.js'; // Services -import { type ChatRecordingService } from '../services/chatRecordingService.js'; import { ChatCompressionService, COMPRESSION_PRESERVE_THRESHOLD, @@ -55,12 +54,17 @@ import { NextSpeakerCheckEvent, logNextSpeakerCheck, } from '../telemetry/index.js'; +import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; // Utilities import { getDirectoryContextString, getInitialChatHistory, } from '../utils/environmentContext.js'; +import { + buildApiHistoryFromConversation, + replayUiTelemetryFromConversation, +} from '../services/sessionService.js'; import { reportError } from '../utils/errorReporting.js'; import { getErrorMessage } from '../utils/errors.js'; import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; @@ -96,7 +100,7 @@ export class GeminiClient { private sessionTurnCount = 0; private readonly loopDetector: LoopDetectionService; - private lastPromptId: string; + private lastPromptId: string | undefined = undefined; private lastSentIdeContext: IdeContext | undefined; private forceFullIdeContext = true; @@ -108,11 +112,24 @@ export class GeminiClient { constructor(private readonly config: Config) { this.loopDetector = new LoopDetectionService(config); - this.lastPromptId = this.config.getSessionId(); } async initialize() { - this.chat = await this.startChat(); + this.lastPromptId = this.config.getSessionId(); + + // Check if we're resuming from a previous session + const resumedSessionData = this.config.getResumedSessionData(); + if (resumedSessionData) { + replayUiTelemetryFromConversation(resumedSessionData.conversation); + // Convert resumed session to API history format + // Each ChatRecord's message field is already a Content object + const resumedHistory = buildApiHistoryFromConversation( + resumedSessionData.conversation, + ); + this.chat = await this.startChat(resumedHistory); + } else { + this.chat = await this.startChat(); + } } private getContentGeneratorOrFail(): ContentGenerator { @@ -161,10 +178,6 @@ export class GeminiClient { this.chat = await this.startChat(); } - getChatRecordingService(): ChatRecordingService | undefined { - return this.chat?.getChatRecordingService(); - } - getLoopDetectionService(): LoopDetectionService { return this.loopDetector; } @@ -212,6 +225,7 @@ export class GeminiClient { tools, }, history, + this.config.getChatRecordingService(), ); } catch (error) { await reportError( @@ -396,12 +410,15 @@ export class GeminiClient { request: PartListUnion, signal: AbortSignal, prompt_id: string, + options?: { isContinuation: boolean }, turns: number = MAX_TURNS, ): AsyncGenerator { - const isNewPrompt = this.lastPromptId !== prompt_id; - if (isNewPrompt) { + if (!options?.isContinuation) { this.loopDetector.reset(prompt_id); this.lastPromptId = prompt_id; + + // record user message for session management + this.config.getChatRecordingService()?.recordUserMessage(request); } this.sessionTurnCount++; if ( @@ -510,7 +527,7 @@ export class GeminiClient { // append system reminders to the request let requestToSent = await flatMapTextParts(request, async (text) => [text]); - if (isNewPrompt) { + if (!options?.isContinuation) { const systemReminders = []; // add subagent system reminder if there are subagents @@ -580,6 +597,7 @@ export class GeminiClient { nextRequest, signal, prompt_id, + options, boundedTurns - 1, ); } @@ -624,7 +642,7 @@ export class GeminiClient { config: requestConfig, contents, }, - this.lastPromptId, + this.lastPromptId!, ); }; const onPersistent429Callback = async ( @@ -678,7 +696,14 @@ export class GeminiClient { if (info.compressionStatus === CompressionStatus.COMPRESSED) { // Success: update chat with new compressed history if (newHistory) { + const chatRecordingService = this.config.getChatRecordingService(); + chatRecordingService?.recordChatCompression({ + info, + compressedHistory: newHistory, + }); + this.chat = await this.startChat(newHistory); + uiTelemetryService.setLastPromptTokenCount(info.newTokenCount); this.forceFullIdeContext = true; } } else if ( diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 2218832e..6b480d42 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -119,7 +119,6 @@ export function createContentGeneratorConfig( export async function createContentGenerator( config: ContentGeneratorConfig, gcConfig: Config, - sessionId?: string, isInitialAuth?: boolean, ): Promise { const version = process.env['CLI_VERSION'] || process.version; @@ -138,7 +137,6 @@ export async function createContentGenerator( httpOptions, config.authType, gcConfig, - sessionId, ), gcConfig, ); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index d0bf1aa8..26a1b29c 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -256,6 +256,7 @@ describe('CoreToolScheduler', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -333,6 +334,7 @@ describe('CoreToolScheduler', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -511,6 +513,7 @@ describe('CoreToolScheduler', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -597,6 +600,7 @@ describe('CoreToolScheduler', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -689,6 +693,7 @@ describe('CoreToolScheduler with payload', () => { isInteractive: () => true, // Required to prevent auto-denial of tool calls getIdeMode: () => false, getExperimentalZedIntegration: () => false, + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -1012,6 +1017,7 @@ describe('CoreToolScheduler edit cancellation', () => { isInteractive: () => true, // Required to prevent auto-denial of tool calls getIdeMode: () => false, getExperimentalZedIntegration: () => false, + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -1118,6 +1124,7 @@ describe('CoreToolScheduler YOLO mode', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -1258,6 +1265,7 @@ describe('CoreToolScheduler cancellation during executing with live output', () terminalWidth: 90, terminalHeight: 30, }), + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -1358,6 +1366,7 @@ describe('CoreToolScheduler request queueing', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -1490,6 +1499,7 @@ describe('CoreToolScheduler request queueing', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -1592,6 +1602,7 @@ describe('CoreToolScheduler request queueing', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -1667,6 +1678,7 @@ describe('CoreToolScheduler request queueing', () => { isInteractive: () => true, // Required to prevent auto-denial of tool calls getIdeMode: () => false, getExperimentalZedIntegration: () => false, + getChatRecordingService: () => undefined, } as unknown as Config; const testTool = new TestApprovalTool(mockConfig); @@ -1858,6 +1870,7 @@ describe('CoreToolScheduler Sequential Execution', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -1978,6 +1991,7 @@ describe('CoreToolScheduler Sequential Execution', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, + getChatRecordingService: () => undefined, } as unknown as Config; const scheduler = new CoreToolScheduler({ diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 8334ce5a..493758dc 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -16,6 +16,7 @@ import type { ToolConfirmationPayload, AnyDeclarativeTool, AnyToolInvocation, + ChatRecordingService, } from '../index.js'; import { ToolConfirmationOutcome, @@ -321,6 +322,10 @@ interface CoreToolSchedulerOptions { onToolCallsUpdate?: ToolCallsUpdateHandler; getPreferredEditor: () => EditorType | undefined; onEditorClose: () => void; + /** + * Optional recording service. If provided, tool results will be recorded. + */ + chatRecordingService?: ChatRecordingService; } export class CoreToolScheduler { @@ -332,6 +337,7 @@ export class CoreToolScheduler { private getPreferredEditor: () => EditorType | undefined; private config: Config; private onEditorClose: () => void; + private chatRecordingService?: ChatRecordingService; private isFinalizingToolCalls = false; private isScheduling = false; private requestQueue: Array<{ @@ -349,6 +355,7 @@ export class CoreToolScheduler { this.onToolCallsUpdate = options.onToolCallsUpdate; this.getPreferredEditor = options.getPreferredEditor; this.onEditorClose = options.onEditorClose; + this.chatRecordingService = options.chatRecordingService; } private setStatusInternal( @@ -1208,6 +1215,9 @@ export class CoreToolScheduler { logToolCall(this.config, new ToolCallEvent(call)); } + // Record tool results before notifying completion + this.recordToolResults(completedCalls); + if (this.onAllToolCallsComplete) { this.isFinalizingToolCalls = true; await this.onAllToolCallsComplete(completedCalls); @@ -1224,6 +1234,33 @@ export class CoreToolScheduler { } } + /** + * Records tool results to the chat recording service. + * This captures both the raw Content (for API reconstruction) and + * enriched metadata (for UI recovery). + */ + private recordToolResults(completedCalls: CompletedToolCall[]): void { + if (!this.chatRecordingService) return; + + // Collect all response parts from completed calls + const responseParts: Part[] = completedCalls.flatMap( + (call) => call.response.responseParts, + ); + + if (responseParts.length === 0) return; + + // Record each tool result individually + for (const call of completedCalls) { + this.chatRecordingService.recordToolResult(call.response.responseParts, { + callId: call.request.callId, + status: call.status, + resultDisplay: call.response.resultDisplay, + error: call.response.error, + errorType: call.response.errorType, + }); + } + } + private notifyToolCallsUpdate(): void { if (this.onToolCallsUpdate) { this.onToolCallsUpdate([...this.toolCalls]); diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 94ef927d..3e31a1c5 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -43,6 +43,7 @@ vi.mock('node:fs', () => { }); }), existsSync: vi.fn((path: string) => mockFileSystem.has(path)), + appendFileSync: vi.fn(), }; return { @@ -120,6 +121,7 @@ describe('GeminiChat', () => { setQuotaErrorOccurred: vi.fn(), flashFallbackHandler: undefined, getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), + getCliVersion: vi.fn().mockReturnValue('1.0.0'), storage: { getProjectTempDir: vi.fn().mockReturnValue('/test/temp'), }, diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 79249733..5bdba396 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -14,10 +14,9 @@ import type { SendMessageParameters, Part, Tool, + GenerateContentResponseUsageMetadata, } from '@google/genai'; -import { ApiError } from '@google/genai'; -import { toParts } from '../code_assist/converter.js'; -import { createUserContent } from '@google/genai'; +import { ApiError, createUserContent } from '@google/genai'; import { retryWithBackoff } from '../utils/retry.js'; import type { Config } from '../config/config.js'; import { @@ -30,14 +29,12 @@ import { logContentRetry, logContentRetryFailure, } from '../telemetry/loggers.js'; -import { ChatRecordingService } from '../services/chatRecordingService.js'; +import { type ChatRecordingService } from '../services/chatRecordingService.js'; import { ContentRetryEvent, ContentRetryFailureEvent, } from '../telemetry/types.js'; import { handleFallback } from '../fallback/handler.js'; -import { isFunctionResponse } from '../utils/messageInspectors.js'; -import { partListUnionToString } from './geminiRequest.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; export enum StreamEventType { @@ -200,16 +197,23 @@ export class GeminiChat { // A promise to represent the current state of the message being sent to the // model. private sendPromise: Promise = Promise.resolve(); - private readonly chatRecordingService: ChatRecordingService; + /** + * Creates a new GeminiChat instance. + * + * @param config - The configuration object. + * @param generationConfig - Optional generation configuration. + * @param history - Optional initial conversation history. + * @param chatRecordingService - Optional recording service. If provided, chat + * messages will be recorded. + */ constructor( private readonly config: Config, private readonly generationConfig: GenerateContentConfig = {}, private history: Content[] = [], + private readonly chatRecordingService?: ChatRecordingService, ) { validateHistory(history); - this.chatRecordingService = new ChatRecordingService(config); - this.chatRecordingService.initialize(); } setSystemInstruction(sysInstr: string) { @@ -253,20 +257,6 @@ export class GeminiChat { const userContent = createUserContent(params.message); - // Record user input - capture complete message with all parts (text, files, images, etc.) - // but skip recording function responses (tool call results) as they should be stored in tool call records - if (!isFunctionResponse(userContent)) { - const userMessage = Array.isArray(params.message) - ? params.message - : [params.message]; - const userMessageContent = partListUnionToString(toParts(userMessage)); - this.chatRecordingService.recordMessage({ - model, - type: 'user', - content: userMessageContent, - }); - } - // Add user content to history ONCE before any attempts. this.history.push(userContent); const requestContents = this.getHistory(true); @@ -505,7 +495,11 @@ export class GeminiChat { model: string, streamResponse: AsyncGenerator, ): AsyncGenerator { - const modelResponseParts: Part[] = []; + // Collect ALL parts from the model response (including thoughts for recording) + const allModelParts: Part[] = []; + // Non-thought parts for history (what we send back to the API) + const historyParts: Part[] = []; + let usageMetadata: GenerateContentResponseUsageMetadata | undefined; let hasToolCall = false; let hasFinishReason = false; @@ -516,23 +510,20 @@ export class GeminiChat { if (isValidResponse(chunk)) { const content = chunk.candidates?.[0]?.content; if (content?.parts) { - if (content.parts.some((part) => part.thought)) { - // Record thoughts - this.recordThoughtFromContent(content); - } if (content.parts.some((part) => part.functionCall)) { hasToolCall = true; } - modelResponseParts.push( - ...content.parts.filter((part) => !part.thought), - ); + // Collect all parts for recording + allModelParts.push(...content.parts); + // Collect non-thought parts for history + historyParts.push(...content.parts.filter((part) => !part.thought)); } } - // Record token usage if this chunk has usageMetadata + // Collect token usage for consolidated recording if (chunk.usageMetadata) { - this.chatRecordingService.recordMessageTokens(chunk.usageMetadata); + usageMetadata = chunk.usageMetadata; if (chunk.usageMetadata.promptTokenCount !== undefined) { uiTelemetryService.setLastPromptTokenCount( chunk.usageMetadata.promptTokenCount, @@ -543,10 +534,11 @@ export class GeminiChat { yield chunk; // Yield every chunk to the UI immediately. } - // String thoughts and consolidate text parts. - const consolidatedParts: Part[] = []; - for (const part of modelResponseParts) { - const lastPart = consolidatedParts[consolidatedParts.length - 1]; + // Consolidate text parts for history (merges adjacent text parts). + const consolidatedHistoryParts: Part[] = []; + for (const part of historyParts) { + const lastPart = + consolidatedHistoryParts[consolidatedHistoryParts.length - 1]; if ( lastPart?.text && isValidNonThoughtTextPart(lastPart) && @@ -554,22 +546,29 @@ export class GeminiChat { ) { lastPart.text += part.text; } else { - consolidatedParts.push(part); + consolidatedHistoryParts.push(part); } } - const responseText = consolidatedParts + const responseText = consolidatedHistoryParts .filter((part) => part.text) .map((part) => part.text) .join('') .trim(); - // Record model response text from the collected parts - if (responseText) { - this.chatRecordingService.recordMessage({ + // Record assistant turn with raw Content and metadata + if (responseText || hasToolCall || usageMetadata) { + this.chatRecordingService?.recordAssistantTurn({ model, - type: 'qwen', - content: responseText, + message: [ + ...(responseText ? [{ text: responseText }] : []), + ...(hasToolCall + ? historyParts + .filter((part) => part.functionCall) + .map((part) => ({ functionCall: part.functionCall })) + : []), + ], + tokens: usageMetadata, }); } @@ -594,39 +593,8 @@ export class GeminiChat { } } - this.history.push({ role: 'model', parts: consolidatedParts }); - } - - /** - * Gets the chat recording service instance. - */ - getChatRecordingService(): ChatRecordingService { - return this.chatRecordingService; - } - - /** - * Extracts and records thought from thought content. - */ - private recordThoughtFromContent(content: Content): void { - if (!content.parts || content.parts.length === 0) { - return; - } - - const thoughtPart = content.parts[0]; - if (thoughtPart.text) { - // Extract subject and description using the same logic as turn.ts - const rawText = thoughtPart.text; - const subjectStringMatches = rawText.match(/\*\*(.*?)\*\*/s); - const subject = subjectStringMatches - ? subjectStringMatches[1].trim() - : ''; - const description = rawText.replace(/\*\*(.*?)\*\*/s, '').trim(); - - this.chatRecordingService.recordThought({ - subject, - description, - }); - } + // Add to history (without thoughts, for API calls) + this.history.push({ role: 'model', parts: consolidatedHistoryParts }); } } diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index 03751545..e3b8d175 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -62,6 +62,7 @@ describe('executeToolCall', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests + getChatRecordingService: () => undefined, } as unknown as Config; abortController = new AbortController(); diff --git a/packages/core/src/core/nonInteractiveToolExecutor.ts b/packages/core/src/core/nonInteractiveToolExecutor.ts index 3575af96..e6897b15 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.ts @@ -34,6 +34,7 @@ export async function executeToolCall( return new Promise((resolve, reject) => { new CoreToolScheduler({ config, + chatRecordingService: config.getChatRecordingService(), outputUpdateHandler: options.outputUpdateHandler, onAllToolCallsComplete: async (completedToolCalls) => { if (options.onAllToolCallsComplete) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 883fb114..38ac7ada 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -63,6 +63,7 @@ export * from './utils/thoughtUtils.js'; export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; export * from './services/chatRecordingService.js'; +export * from './services/sessionService.js'; export * from './services/fileSystemService.js'; // Export IDE specific logic @@ -104,6 +105,7 @@ export * from './tools/mcp-client.js'; export * from './tools/mcp-tool.js'; export * from './tools/task.js'; export * from './tools/todoWrite.js'; +export * from './tools/exitPlanMode.js'; // MCP OAuth export { MCPOAuthProvider } from './mcp/oauth-provider.js'; @@ -121,7 +123,6 @@ export { OAuthUtils } from './mcp/oauth-utils.js'; // Export telemetry functions export * from './telemetry/index.js'; -export { sessionId } from './utils/session.js'; export * from './utils/browser.js'; // OpenAI Logging Utilities export { OpenAILogger, openaiLogger } from './utils/openaiLogger.js'; diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 01eb2b8c..050b9381 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -5,27 +5,20 @@ */ import { randomUUID } from 'node:crypto'; -import fs from 'node:fs'; import path from 'node:path'; -import { - afterEach, - beforeEach, - describe, - expect, - it, - type MockInstance, - vi, -} from 'vitest'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; -import { getProjectHash } from '../utils/paths.js'; import { ChatRecordingService, - type ConversationRecord, - type ToolCallRecord, + type ChatRecord, } from './chatRecordingService.js'; +import * as jsonl from '../utils/jsonl-utils.js'; +import type { Part } from '@google/genai'; -vi.mock('node:fs'); vi.mock('node:path'); +vi.mock('node:child_process'); vi.mock('node:crypto', () => ({ randomUUID: vi.fn(), createHash: vi.fn(() => ({ @@ -34,23 +27,28 @@ vi.mock('node:crypto', () => ({ })), })), })); -vi.mock('../utils/paths.js'); +vi.mock('../utils/jsonl-utils.js'); describe('ChatRecordingService', () => { let chatRecordingService: ChatRecordingService; let mockConfig: Config; - let mkdirSyncSpy: MockInstance; - let writeFileSyncSpy: MockInstance; + let uuidCounter = 0; beforeEach(() => { + uuidCounter = 0; + mockConfig = { getSessionId: vi.fn().mockReturnValue('test-session-id'), getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), + getCliVersion: vi.fn().mockReturnValue('1.0.0'), storage: { getProjectTempDir: vi .fn() - .mockReturnValue('/test/project/root/.gemini/tmp'), + .mockReturnValue('/test/project/root/.gemini/tmp/hash'), + getProjectDir: vi + .fn() + .mockReturnValue('/test/project/root/.gemini/projects/test-project'), }, getModel: vi.fn().mockReturnValue('gemini-pro'), getDebugMode: vi.fn().mockReturnValue(false), @@ -61,351 +59,270 @@ describe('ChatRecordingService', () => { isOutputMarkdown: false, }), }), + getResumedSessionData: vi.fn().mockReturnValue(undefined), } as unknown as Config; - vi.mocked(getProjectHash).mockReturnValue('test-project-hash'); - vi.mocked(randomUUID).mockReturnValue('this-is-a-test-uuid'); + vi.mocked(randomUUID).mockImplementation( + () => + `00000000-0000-0000-0000-00000000000${++uuidCounter}` as `${string}-${string}-${string}-${string}-${string}`, + ); vi.mocked(path.join).mockImplementation((...args) => args.join('/')); + vi.mocked(path.dirname).mockImplementation((p) => { + const parts = p.split('/'); + parts.pop(); + return parts.join('/'); + }); + vi.mocked(execSync).mockReturnValue('main\n'); + vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); chatRecordingService = new ChatRecordingService(mockConfig); - mkdirSyncSpy = vi - .spyOn(fs, 'mkdirSync') - .mockImplementation(() => undefined); - - writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); + // Mock jsonl-utils + vi.mocked(jsonl.writeLineSync).mockImplementation(() => undefined); }); afterEach(() => { vi.restoreAllMocks(); }); - describe('initialize', () => { - it('should create a new session if none is provided', () => { - chatRecordingService.initialize(); + describe('recordUserMessage', () => { + it('should record a user message immediately', () => { + const userParts: Part[] = [{ text: 'Hello, world!' }]; + chatRecordingService.recordUserMessage(userParts); - expect(mkdirSyncSpy).toHaveBeenCalledWith( - '/test/project/root/.gemini/tmp/chats', - { recursive: true }, - ); - expect(writeFileSyncSpy).not.toHaveBeenCalled(); + expect(jsonl.writeLineSync).toHaveBeenCalledTimes(1); + const record = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + + expect(record.uuid).toBe('00000000-0000-0000-0000-000000000001'); + expect(record.parentUuid).toBeNull(); + expect(record.type).toBe('user'); + // The service wraps parts in a Content object using createUserContent + expect(record.message).toEqual({ role: 'user', parts: userParts }); + expect(record.sessionId).toBe('test-session-id'); + expect(record.cwd).toBe('/test/project/root'); + expect(record.version).toBe('1.0.0'); + expect(record.gitBranch).toBe('main'); }); - it('should resume from an existing session if provided', () => { - const readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - sessionId: 'old-session-id', - projectHash: 'test-project-hash', - messages: [], - }), - ); - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - - chatRecordingService.initialize({ - filePath: '/test/project/root/.gemini/tmp/chats/session.json', - conversation: { - sessionId: 'old-session-id', - } as ConversationRecord, - }); - - expect(mkdirSyncSpy).not.toHaveBeenCalled(); - expect(readFileSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).not.toHaveBeenCalled(); - }); - }); - - describe('recordMessage', () => { - beforeEach(() => { - chatRecordingService.initialize(); - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify({ - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [], - }), - ); - }); - - it('should record a new message', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - chatRecordingService.recordMessage({ - type: 'user', - content: 'Hello', + it('should chain messages correctly with parentUuid', () => { + chatRecordingService.recordUserMessage([{ text: 'First message' }]); + chatRecordingService.recordAssistantTurn({ model: 'gemini-pro', + message: [{ text: 'Response' }], }); - expect(mkdirSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).toHaveBeenCalled(); - const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, - ) as ConversationRecord; - expect(conversation.messages).toHaveLength(1); - expect(conversation.messages[0].content).toBe('Hello'); - expect(conversation.messages[0].type).toBe('user'); + chatRecordingService.recordUserMessage([{ text: 'Second message' }]); + + const calls = vi.mocked(jsonl.writeLineSync).mock.calls; + const user1 = calls[0][1] as ChatRecord; + const assistant = calls[1][1] as ChatRecord; + const user2 = calls[2][1] as ChatRecord; + + expect(user1.uuid).toBe('00000000-0000-0000-0000-000000000001'); + expect(user1.parentUuid).toBeNull(); + + expect(assistant.uuid).toBe('00000000-0000-0000-0000-000000000002'); + expect(assistant.parentUuid).toBe('00000000-0000-0000-0000-000000000001'); + + expect(user2.uuid).toBe('00000000-0000-0000-0000-000000000003'); + expect(user2.parentUuid).toBe('00000000-0000-0000-0000-000000000002'); }); + }); - it('should create separate messages when recording multiple messages', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: '1', - type: 'user', - content: 'Hello', - timestamp: new Date().toISOString(), - }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); - - chatRecordingService.recordMessage({ - type: 'user', - content: 'World', + describe('recordAssistantTurn', () => { + it('should record assistant turn with content only', () => { + const parts: Part[] = [{ text: 'Hello!' }]; + chatRecordingService.recordAssistantTurn({ model: 'gemini-pro', + message: parts, }); - expect(mkdirSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).toHaveBeenCalled(); - const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, - ) as ConversationRecord; - expect(conversation.messages).toHaveLength(2); - expect(conversation.messages[0].content).toBe('Hello'); - expect(conversation.messages[1].content).toBe('World'); - }); - }); + expect(jsonl.writeLineSync).toHaveBeenCalledTimes(1); + const record = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; - describe('recordThought', () => { - it('should queue a thought', () => { - chatRecordingService.initialize(); - chatRecordingService.recordThought({ - subject: 'Thinking', - description: 'Thinking...', - }); - // @ts-expect-error private property - expect(chatRecordingService.queuedThoughts).toHaveLength(1); - // @ts-expect-error private property - expect(chatRecordingService.queuedThoughts[0].subject).toBe('Thinking'); - // @ts-expect-error private property - expect(chatRecordingService.queuedThoughts[0].description).toBe( - 'Thinking...', - ); - }); - }); - - describe('recordMessageTokens', () => { - beforeEach(() => { - chatRecordingService.initialize(); + expect(record.type).toBe('assistant'); + // The service wraps parts in a Content object using createModelContent + expect(record.message).toEqual({ role: 'model', parts }); + expect(record.model).toBe('gemini-pro'); + expect(record.usageMetadata).toBeUndefined(); + expect(record.toolCallResult).toBeUndefined(); }); - it('should update the last message with token info', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: '1', - type: 'qwen', - content: 'Response', - timestamp: new Date().toISOString(), - }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); - - chatRecordingService.recordMessageTokens({ - promptTokenCount: 1, - candidatesTokenCount: 2, - totalTokenCount: 3, - cachedContentTokenCount: 0, - }); - - expect(mkdirSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).toHaveBeenCalled(); - const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, - ) as ConversationRecord; - expect(conversation.messages[0]).toEqual({ - ...initialConversation.messages[0], + it('should record assistant turn with all data', () => { + const parts: Part[] = [ + { thought: true, text: 'Thinking...' }, + { text: 'Here is the result.' }, + { functionCall: { name: 'read_file', args: { path: '/test.txt' } } }, + ]; + chatRecordingService.recordAssistantTurn({ + model: 'gemini-pro', + message: parts, tokens: { - input: 1, - output: 2, - total: 3, - cached: 0, - thoughts: 0, - tool: 0, + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 10, + totalTokenCount: 160, }, }); + + const record = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + + // The service wraps parts in a Content object using createModelContent + expect(record.message).toEqual({ role: 'model', parts }); + expect(record.model).toBe('gemini-pro'); + expect(record.usageMetadata?.totalTokenCount).toBe(160); }); - it('should queue token info if the last message already has tokens', () => { - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: '1', - type: 'qwen', - content: 'Response', - timestamp: new Date().toISOString(), - tokens: { input: 1, output: 1, total: 2, cached: 0 }, - }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); - - chatRecordingService.recordMessageTokens({ - promptTokenCount: 2, - candidatesTokenCount: 2, - totalTokenCount: 4, - cachedContentTokenCount: 0, - }); - - // @ts-expect-error private property - expect(chatRecordingService.queuedTokens).toEqual({ - input: 2, - output: 2, - total: 4, - cached: 0, - thoughts: 0, - tool: 0, - }); - }); - }); - - describe('recordToolCalls', () => { - beforeEach(() => { - chatRecordingService.initialize(); - }); - - it('should add new tool calls to the last message', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: '1', - type: 'qwen', - content: '', - timestamp: new Date().toISOString(), - }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); - - const toolCall: ToolCallRecord = { - id: 'tool-1', - name: 'testTool', - args: {}, - status: 'awaiting_approval', - timestamp: new Date().toISOString(), - }; - chatRecordingService.recordToolCalls('gemini-pro', [toolCall]); - - expect(mkdirSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).toHaveBeenCalled(); - const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, - ) as ConversationRecord; - expect(conversation.messages[0]).toEqual({ - ...initialConversation.messages[0], - toolCalls: [ - { - ...toolCall, - displayName: 'Test Tool', - description: 'A test tool', - renderOutputAsMarkdown: false, - }, - ], - }); - }); - - it('should create a new message if the last message is not from gemini', () => { - const writeFileSyncSpy = vi - .spyOn(fs, 'writeFileSync') - .mockImplementation(() => undefined); - const initialConversation = { - sessionId: 'test-session-id', - projectHash: 'test-project-hash', - messages: [ - { - id: 'a-uuid', - type: 'user', - content: 'call a tool', - timestamp: new Date().toISOString(), - }, - ], - }; - vi.spyOn(fs, 'readFileSync').mockReturnValue( - JSON.stringify(initialConversation), - ); - - const toolCall: ToolCallRecord = { - id: 'tool-1', - name: 'testTool', - args: {}, - status: 'awaiting_approval', - timestamp: new Date().toISOString(), - }; - chatRecordingService.recordToolCalls('gemini-pro', [toolCall]); - - expect(mkdirSyncSpy).toHaveBeenCalled(); - expect(writeFileSyncSpy).toHaveBeenCalled(); - const conversation = JSON.parse( - writeFileSyncSpy.mock.calls[0][1] as string, - ) as ConversationRecord; - expect(conversation.messages).toHaveLength(2); - expect(conversation.messages[1]).toEqual({ - ...conversation.messages[1], - id: 'this-is-a-test-uuid', + it('should record assistant turn with only tokens', () => { + chatRecordingService.recordAssistantTurn({ model: 'gemini-pro', - type: 'qwen', - thoughts: [], - content: '', - toolCalls: [ - { - ...toolCall, - displayName: 'Test Tool', - description: 'A test tool', - renderOutputAsMarkdown: false, - }, - ], + tokens: { + promptTokenCount: 10, + candidatesTokenCount: 20, + cachedContentTokenCount: 0, + totalTokenCount: 30, + }, }); + + const record = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + + expect(record.message).toBeUndefined(); + expect(record.usageMetadata?.totalTokenCount).toBe(30); }); }); - describe('deleteSession', () => { - it('should delete the session file', () => { - const unlinkSyncSpy = vi - .spyOn(fs, 'unlinkSync') - .mockImplementation(() => undefined); - chatRecordingService.deleteSession('test-session-id'); - expect(unlinkSyncSpy).toHaveBeenCalledWith( - '/test/project/root/.gemini/tmp/chats/test-session-id.json', - ); + describe('recordToolResult', () => { + it('should record tool result with Parts', () => { + // First record a user and assistant message to set up the chain + chatRecordingService.recordUserMessage([{ text: 'Hello' }]); + chatRecordingService.recordAssistantTurn({ + model: 'gemini-pro', + message: [{ functionCall: { name: 'shell', args: { command: 'ls' } } }], + }); + + // Now record the tool result (Parts with functionResponse) + const toolResultParts: Part[] = [ + { + functionResponse: { + id: 'call-1', + name: 'shell', + response: { output: 'file1.txt\nfile2.txt' }, + }, + }, + ]; + chatRecordingService.recordToolResult(toolResultParts); + + expect(jsonl.writeLineSync).toHaveBeenCalledTimes(3); + const record = vi.mocked(jsonl.writeLineSync).mock + .calls[2][1] as ChatRecord; + + expect(record.type).toBe('tool_result'); + // The service wraps parts in a Content object using createUserContent + expect(record.message).toEqual({ role: 'user', parts: toolResultParts }); + }); + + it('should record tool result with toolCallResult metadata', () => { + const toolResultParts: Part[] = [ + { + functionResponse: { + id: 'call-1', + name: 'shell', + response: { output: 'result' }, + }, + }, + ]; + const metadata = { + callId: 'call-1', + status: 'success', + responseParts: toolResultParts, + resultDisplay: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + chatRecordingService.recordToolResult(toolResultParts, metadata); + + const record = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + + expect(record.type).toBe('tool_result'); + // The service wraps parts in a Content object using createUserContent + expect(record.message).toEqual({ role: 'user', parts: toolResultParts }); + expect(record.toolCallResult).toBeDefined(); + expect(record.toolCallResult?.callId).toBe('call-1'); + }); + + it('should chain tool result correctly with parentUuid', () => { + chatRecordingService.recordUserMessage([{ text: 'Hello' }]); + chatRecordingService.recordAssistantTurn({ + model: 'gemini-pro', + message: [{ text: 'Using tool' }], + }); + const toolResultParts: Part[] = [ + { + functionResponse: { + id: 'call-1', + name: 'shell', + response: { output: 'done' }, + }, + }, + ]; + chatRecordingService.recordToolResult(toolResultParts); + + const userRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + const assistantRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[1][1] as ChatRecord; + const toolResultRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[2][1] as ChatRecord; + + expect(userRecord.parentUuid).toBeNull(); + expect(assistantRecord.parentUuid).toBe(userRecord.uuid); + expect(toolResultRecord.parentUuid).toBe(assistantRecord.uuid); }); }); + + describe('recordSlashCommand', () => { + it('should record slash command with payload and subtype', () => { + chatRecordingService.recordSlashCommand({ + phase: 'invocation', + rawCommand: '/about', + }); + + expect(jsonl.writeLineSync).toHaveBeenCalledTimes(1); + const record = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + + expect(record.type).toBe('system'); + expect(record.subtype).toBe('slash_command'); + expect(record.systemPayload).toMatchObject({ + phase: 'invocation', + rawCommand: '/about', + }); + }); + + it('should chain slash command after prior records', () => { + chatRecordingService.recordUserMessage([{ text: 'Hello' }]); + chatRecordingService.recordSlashCommand({ + phase: 'result', + rawCommand: '/about', + }); + + const userRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + const slashRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[1][1] as ChatRecord; + + expect(userRecord.parentUuid).toBeNull(); + expect(slashRecord.parentUuid).toBe(userRecord.uuid); + }); + }); + + // Note: Session management tests (listSessions, loadSession, deleteSession, etc.) + // have been moved to sessionService.test.ts + // Session resume integration tests should test via SessionService mock }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index ed22f391..b5630212 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -5,96 +5,127 @@ */ import { type Config } from '../config/config.js'; -import { type Status } from '../core/coreToolScheduler.js'; -import { type ThoughtSummary } from '../utils/thoughtUtils.js'; -import { getProjectHash } from '../utils/paths.js'; import path from 'node:path'; import fs from 'node:fs'; import { randomUUID } from 'node:crypto'; -import type { - PartListUnion, - GenerateContentResponseUsageMetadata, +import { + type PartListUnion, + type Content, + type GenerateContentResponseUsageMetadata, + createUserContent, + createModelContent, } from '@google/genai'; +import * as jsonl from '../utils/jsonl-utils.js'; +import { getGitBranch } from '../utils/gitUtils.js'; +import type { + ChatCompressionInfo, + ToolCallResponseInfo, +} from '../core/turn.js'; +import type { Status } from '../core/coreToolScheduler.js'; +import type { TaskResultDisplay } from '../tools/tools.js'; +import type { UiEvent } from '../telemetry/uiTelemetry.js'; /** - * Token usage summary for a message or conversation. + * A single record stored in the JSONL file. + * Forms a tree structure via uuid/parentUuid for future checkpointing support. + * + * Each record is self-contained with full metadata, enabling: + * - Append-only writes (crash-safe) + * - Tree reconstruction by following parentUuid chain + * - Future checkpointing by branching from any historical record */ -export interface TokensSummary { - input: number; // promptTokenCount - output: number; // candidatesTokenCount - cached: number; // cachedContentTokenCount - thoughts?: number; // thoughtsTokenCount - tool?: number; // toolUsePromptTokenCount - total: number; // totalTokenCount -} - -/** - * Base fields common to all messages. - */ -export interface BaseMessageRecord { - id: string; - timestamp: string; - content: PartListUnion; -} - -/** - * Record of a tool call execution within a conversation. - */ -export interface ToolCallRecord { - id: string; - name: string; - args: Record; - result?: PartListUnion | null; - status: Status; - timestamp: string; - // UI-specific fields for display purposes - displayName?: string; - description?: string; - resultDisplay?: string; - renderOutputAsMarkdown?: boolean; -} - -/** - * Message type and message type-specific fields. - */ -export type ConversationRecordExtra = - | { - type: 'user'; - } - | { - type: 'qwen'; - toolCalls?: ToolCallRecord[]; - thoughts?: Array; - tokens?: TokensSummary | null; - model?: string; - }; - -/** - * A single message record in a conversation. - */ -export type MessageRecord = BaseMessageRecord & ConversationRecordExtra; - -/** - * Complete conversation record stored in session files. - */ -export interface ConversationRecord { +export interface ChatRecord { + /** Unique identifier for this logical message */ + uuid: string; + /** UUID of the parent message; null for root (first message in session) */ + parentUuid: string | null; + /** Session identifier - groups records into a logical conversation */ sessionId: string; - projectHash: string; - startTime: string; - lastUpdated: string; - messages: MessageRecord[]; + /** ISO 8601 timestamp of when the record was created */ + timestamp: string; + /** + * Message type: user input, assistant response, tool result, or system event. + * System records are append-only events that can alter how history is reconstructed + * (e.g., chat compression checkpoints) while keeping the original UI history intact. + */ + type: 'user' | 'assistant' | 'tool_result' | 'system'; + /** Optional system subtype for distinguishing system behaviors */ + subtype?: 'chat_compression' | 'slash_command' | 'ui_telemetry'; + /** Working directory at time of message */ + cwd: string; + /** CLI version for compatibility tracking */ + version: string; + /** Current git branch, if available */ + gitBranch?: string; + + // Content field - raw API format for history reconstruction + + /** + * The actual Content object (role + parts) sent to/from LLM. + * This is stored in the exact format needed for API calls, enabling + * direct aggregation into Content[] for session resumption. + * Contains: text, functionCall, functionResponse, thought parts, etc. + */ + message?: Content; + + // Metadata fields (not part of API Content) + + /** Token usage statistics */ + usageMetadata?: GenerateContentResponseUsageMetadata; + /** Model used for this response */ + model?: string; + /** + * Tool call metadata for UI recovery. + * Contains enriched info (displayName, status, result, etc.) not in API format. + */ + toolCallResult?: Partial; + + /** + * Payload for system records. For chat compression, this stores all data needed + * to reconstruct the compressed history without mutating the original UI list. + */ + systemPayload?: + | ChatCompressionRecordPayload + | SlashCommandRecordPayload + | UiTelemetryRecordPayload; } /** - * Data structure for resuming an existing session. + * Stored payload for chat compression checkpoints. This allows us to rebuild the + * effective chat history on resume while keeping the original UI-visible history. */ -export interface ResumedSessionData { - conversation: ConversationRecord; - filePath: string; +export interface ChatCompressionRecordPayload { + /** Compression metrics/status returned by the compression service */ + info: ChatCompressionInfo; + /** + * Snapshot of the new history contents that the model should see after + * compression (summary turns + retained tail). Stored as Content[] for + * resume reconstruction. + */ + compressedHistory: Content[]; +} + +export interface SlashCommandRecordPayload { + /** Whether this record represents the invocation or the resulting output. */ + phase: 'invocation' | 'result'; + /** Raw user-entered slash command (e.g., "/about"). */ + rawCommand: string; + /** + * History items the UI displayed for this command, in the same shape used by + * the CLI (without IDs). Stored as plain objects for replay on resume. + */ + outputHistoryItems?: Array>; } /** - * Service for automatically recording chat conversations to disk. + * Stored payload for UI telemetry replay. + */ +export interface UiTelemetryRecordPayload { + uiEvent: UiEvent; +} + +/** + * Service for recording the current chat session to disk. * * This service provides comprehensive conversation recording that captures: * - All user and assistant messages @@ -102,346 +133,276 @@ export interface ResumedSessionData { * - Token usage statistics * - Assistant thoughts and reasoning * - * Sessions are stored as JSON files in ~/.qwen/tmp//chats/ + * **API Design:** + * - `recordUserMessage()` - Records a user message (immediate write) + * - `recordAssistantTurn()` - Records an assistant turn with all data (immediate write) + * - `recordToolResult()` - Records tool results (immediate write) + * + * **Storage Format:** JSONL files with tree-structured records. + * Each record has uuid/parentUuid fields enabling: + * - Append-only writes (never rewrite the file) + * - Linear history reconstruction + * - Future checkpointing (branch from any historical point) + * + * File location: ~/.qwen/tmp//chats/ + * + * For session management (list, load, remove), use SessionService. */ export class ChatRecordingService { - private conversationFile: string | null = null; - private cachedLastConvData: string | null = null; - private sessionId: string; - private projectHash: string; - private queuedThoughts: Array = []; - private queuedTokens: TokensSummary | null = null; - private config: Config; + /** UUID of the last written record in the chain */ + private lastRecordUuid: string | null = null; + private readonly config: Config; constructor(config: Config) { this.config = config; - this.sessionId = config.getSessionId(); - this.projectHash = getProjectHash(config.getProjectRoot()); + this.lastRecordUuid = + config.getResumedSessionData()?.lastCompletedUuid ?? null; } /** - * Initializes the chat recording service: creates a new conversation file and associates it with - * this service instance, or resumes from an existing session if resumedSessionData is provided. + * Returns the session ID. + * @returns The session ID. */ - initialize(resumedSessionData?: ResumedSessionData): void { + private getSessionId(): string { + return this.config.getSessionId(); + } + + /** + * Ensures the chats directory exists, creating it if it doesn't exist. + * @returns The path to the chats directory. + * @throws Error if the directory cannot be created. + */ + private ensureChatsDir(): string { + const projectDir = this.config.storage.getProjectDir(); + const chatsDir = path.join(projectDir, 'chats'); + try { - if (resumedSessionData) { - // Resume from existing session - this.conversationFile = resumedSessionData.filePath; - this.sessionId = resumedSessionData.conversation.sessionId; - - // Update the session ID in the existing file - this.updateConversation((conversation) => { - conversation.sessionId = this.sessionId; - }); - - // Clear any cached data to force fresh reads - this.cachedLastConvData = null; - } else { - // Create new session - const chatsDir = path.join( - this.config.storage.getProjectTempDir(), - 'chats', - ); - fs.mkdirSync(chatsDir, { recursive: true }); - - const timestamp = new Date() - .toISOString() - .slice(0, 16) - .replace(/:/g, '-'); - const filename = `session-${timestamp}-${this.sessionId.slice( - 0, - 8, - )}.json`; - this.conversationFile = path.join(chatsDir, filename); - - this.writeConversation({ - sessionId: this.sessionId, - projectHash: this.projectHash, - startTime: new Date().toISOString(), - lastUpdated: new Date().toISOString(), - messages: [], - }); - } - - // Clear any queued data since this is a fresh start - this.queuedThoughts = []; - this.queuedTokens = null; - } catch (error) { - console.error('Error initializing chat recording service:', error); - throw error; + fs.mkdirSync(chatsDir, { recursive: true }); + } catch { + // Ignore errors - directory will be created if it doesn't exist } + + return chatsDir; } - private getLastMessage( - conversation: ConversationRecord, - ): MessageRecord | undefined { - return conversation.messages.at(-1); + /** + * Ensures the conversation file exists, creating it if it doesn't exist. + * Uses atomic file creation to avoid race conditions. + * @returns The path to the conversation file. + * @throws Error if the file cannot be created or accessed. + */ + private ensureConversationFile(): string { + const chatsDir = this.ensureChatsDir(); + const sessionId = this.getSessionId(); + const safeFilename = `${sessionId}.jsonl`; + const conversationFile = path.join(chatsDir, safeFilename); + + if (fs.existsSync(conversationFile)) { + return conversationFile; + } + + try { + // Use 'wx' flag for exclusive creation - atomic operation that fails if file exists + // This avoids the TOCTOU race condition of existsSync + writeFileSync + fs.writeFileSync(conversationFile, '', { flag: 'wx', encoding: 'utf8' }); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + // EEXIST means file already exists, which is expected and fine + if (nodeError.code !== 'EEXIST') { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to create conversation file at ${conversationFile}: ${message}`, + ); + } + } + + return conversationFile; } - private newMessage( - type: ConversationRecordExtra['type'], - content: PartListUnion, - ): MessageRecord { + /** + * Creates base fields for a ChatRecord. + */ + private createBaseRecord( + type: ChatRecord['type'], + ): Omit { return { - id: randomUUID(), + uuid: randomUUID(), + parentUuid: this.lastRecordUuid, + sessionId: this.getSessionId(), timestamp: new Date().toISOString(), type, - content, + cwd: this.config.getProjectRoot(), + version: this.config.getCliVersion() || 'unknown', + gitBranch: getGitBranch(this.config.getProjectRoot()), }; } /** - * Records a message in the conversation. + * Appends a record to the session file and updates lastRecordUuid. */ - recordMessage(message: { + private appendRecord(record: ChatRecord): void { + try { + const conversationFile = this.ensureConversationFile(); + + jsonl.writeLineSync(conversationFile, record); + this.lastRecordUuid = record.uuid; + } catch (error) { + console.error('Error appending record:', error); + throw error; + } + } + + /** + * Records a user message. + * Writes immediately to disk. + * + * @param message The raw PartListUnion object as used with the API + */ + recordUserMessage(message: PartListUnion): void { + try { + const record: ChatRecord = { + ...this.createBaseRecord('user'), + message: createUserContent(message), + }; + this.appendRecord(record); + } catch (error) { + console.error('Error saving user message:', error); + } + } + + /** + * Records an assistant turn with all available data. + * Writes immediately to disk. + * + * @param data.message The raw PartListUnion object from the model response + * @param data.model The model name + * @param data.tokens Token usage statistics + * @param data.toolCallsMetadata Enriched tool call info for UI recovery + */ + recordAssistantTurn(data: { model: string; - type: ConversationRecordExtra['type']; - content: PartListUnion; + message?: PartListUnion; + tokens?: GenerateContentResponseUsageMetadata; }): void { - if (!this.conversationFile) return; - try { - this.updateConversation((conversation) => { - const msg = this.newMessage(message.type, message.content); - if (msg.type === 'qwen') { - // If it's a new Gemini message then incorporate any queued thoughts. - conversation.messages.push({ - ...msg, - thoughts: this.queuedThoughts, - tokens: this.queuedTokens, - model: message.model, - }); - this.queuedThoughts = []; - this.queuedTokens = null; - } else { - // Or else just add it. - conversation.messages.push(msg); - } - }); + const record: ChatRecord = { + ...this.createBaseRecord('assistant'), + model: data.model, + }; + + if (data.message !== undefined) { + record.message = createModelContent(data.message); + } + + if (data.tokens) { + record.usageMetadata = data.tokens; + } + + this.appendRecord(record); } catch (error) { - console.error('Error saving message:', error); - throw error; + console.error('Error saving assistant turn:', error); } } /** - * Records a thought from the assistant's reasoning process. + * Records tool results (function responses) sent back to the model. + * Writes immediately to disk. + * + * @param message The raw PartListUnion object with functionResponse parts + * @param toolCallResult Optional tool call result info for UI recovery */ - recordThought(thought: ThoughtSummary): void { - if (!this.conversationFile) return; - - try { - this.queuedThoughts.push({ - ...thought, - timestamp: new Date().toISOString(), - }); - } catch (error) { - console.error('Error saving thought:', error); - throw error; - } - } - - /** - * Updates the tokens for the last message in the conversation (which should be by Gemini). - */ - recordMessageTokens( - respUsageMetadata: GenerateContentResponseUsageMetadata, + recordToolResult( + message: PartListUnion, + toolCallResult?: Partial & { status: Status }, ): void { - if (!this.conversationFile) return; - try { - const tokens = { - input: respUsageMetadata.promptTokenCount ?? 0, - output: respUsageMetadata.candidatesTokenCount ?? 0, - cached: respUsageMetadata.cachedContentTokenCount ?? 0, - thoughts: respUsageMetadata.thoughtsTokenCount ?? 0, - tool: respUsageMetadata.toolUsePromptTokenCount ?? 0, - total: respUsageMetadata.totalTokenCount ?? 0, + const record: ChatRecord = { + ...this.createBaseRecord('tool_result'), + message: createUserContent(message), }; - this.updateConversation((conversation) => { - const lastMsg = this.getLastMessage(conversation); - // If the last message already has token info, it's because this new token info is for a - // new message that hasn't been recorded yet. - if (lastMsg && lastMsg.type === 'qwen' && !lastMsg.tokens) { - lastMsg.tokens = tokens; - this.queuedTokens = null; - } else { - this.queuedTokens = tokens; - } - }); - } catch (error) { - console.error('Error updating message tokens:', error); - throw error; - } - } - /** - * Adds tool calls to the last message in the conversation (which should be by Gemini). - * This method enriches tool calls with metadata from the ToolRegistry. - */ - recordToolCalls(model: string, toolCalls: ToolCallRecord[]): void { - if (!this.conversationFile) return; - - // Enrich tool calls with metadata from the ToolRegistry - const toolRegistry = this.config.getToolRegistry(); - const enrichedToolCalls = toolCalls.map((toolCall) => { - const toolInstance = toolRegistry.getTool(toolCall.name); - return { - ...toolCall, - displayName: toolInstance?.displayName || toolCall.name, - description: toolInstance?.description || '', - renderOutputAsMarkdown: toolInstance?.isOutputMarkdown || false, - }; - }); - - try { - this.updateConversation((conversation) => { - const lastMsg = this.getLastMessage(conversation); - // If a tool call was made, but the last message isn't from Gemini, it's because Gemini is - // calling tools without starting the message with text. So the user submits a prompt, and - // Gemini immediately calls a tool (maybe with some thinking first). In that case, create - // a new empty Gemini message. - // Also if there are any queued thoughts, it means this tool call(s) is from a new Gemini - // message--because it's thought some more since we last, if ever, created a new Gemini - // message from tool calls, when we dequeued the thoughts. + if (toolCallResult) { + // special case for task executions - we don't want to record the tool calls if ( - !lastMsg || - lastMsg.type !== 'qwen' || - this.queuedThoughts.length > 0 + typeof toolCallResult.resultDisplay === 'object' && + toolCallResult.resultDisplay !== null && + 'type' in toolCallResult.resultDisplay && + toolCallResult.resultDisplay.type === 'task_execution' ) { - const newMsg: MessageRecord = { - ...this.newMessage('qwen' as const, ''), - // This isn't strictly necessary, but TypeScript apparently can't - // tell that the first parameter to newMessage() becomes the - // resulting message's type, and so it thinks that toolCalls may - // not be present. Confirming the type here satisfies it. - type: 'qwen' as const, - toolCalls: enrichedToolCalls, - thoughts: this.queuedThoughts, - model, + const taskResult = toolCallResult.resultDisplay as TaskResultDisplay; + record.toolCallResult = { + ...toolCallResult, + resultDisplay: { + ...taskResult, + toolCalls: [], + }, }; - // If there are any queued thoughts join them to this message. - if (this.queuedThoughts.length > 0) { - newMsg.thoughts = this.queuedThoughts; - this.queuedThoughts = []; - } - // If there's any queued tokens info join it to this message. - if (this.queuedTokens) { - newMsg.tokens = this.queuedTokens; - this.queuedTokens = null; - } - conversation.messages.push(newMsg); } else { - // The last message is an existing Gemini message that we need to update. - - // Update any existing tool call entries. - if (!lastMsg.toolCalls) { - lastMsg.toolCalls = []; - } - lastMsg.toolCalls = lastMsg.toolCalls.map((toolCall) => { - // If there are multiple tool calls with the same ID, this will take the first one. - const incomingToolCall = toolCalls.find( - (tc) => tc.id === toolCall.id, - ); - if (incomingToolCall) { - // Merge in the new data to keep preserve thoughts, etc., that were assigned to older - // versions of the tool call. - return { ...toolCall, ...incomingToolCall }; - } else { - return toolCall; - } - }); - - // Add any new tools calls that aren't in the message yet. - for (const toolCall of enrichedToolCalls) { - const existingToolCall = lastMsg.toolCalls.find( - (tc) => tc.id === toolCall.id, - ); - if (!existingToolCall) { - lastMsg.toolCalls.push(toolCall); - } - } + record.toolCallResult = toolCallResult; } - }); + } + + this.appendRecord(record); } catch (error) { - console.error('Error adding tool call to message:', error); - throw error; + console.error('Error saving tool result:', error); } } /** - * Loads up the conversation record from disk. + * Records a slash command invocation as a system record. This keeps the model + * history clean while allowing resume to replay UI output for commands like + * /about. */ - private readConversation(): ConversationRecord { + recordSlashCommand(payload: SlashCommandRecordPayload): void { try { - this.cachedLastConvData = fs.readFileSync(this.conversationFile!, 'utf8'); - return JSON.parse(this.cachedLastConvData); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('Error reading conversation file:', error); - throw error; - } - - // Placeholder empty conversation if file doesn't exist. - return { - sessionId: this.sessionId, - projectHash: this.projectHash, - startTime: new Date().toISOString(), - lastUpdated: new Date().toISOString(), - messages: [], + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'slash_command', + systemPayload: payload, }; + + this.appendRecord(record); + } catch (error) { + console.error('Error saving slash command record:', error); } } /** - * Saves the conversation record; overwrites the file. + * Records a chat compression checkpoint as a system record. This keeps the UI + * history immutable while allowing resume/continue flows to reconstruct the + * compressed model-facing history from the stored snapshot. */ - private writeConversation(conversation: ConversationRecord): void { + recordChatCompression(payload: ChatCompressionRecordPayload): void { try { - if (!this.conversationFile) return; - // Don't write the file yet until there's at least one message. - if (conversation.messages.length === 0) return; + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'chat_compression', + systemPayload: payload, + }; - // Only write the file if this change would change the file. - if (this.cachedLastConvData !== JSON.stringify(conversation, null, 2)) { - conversation.lastUpdated = new Date().toISOString(); - const newContent = JSON.stringify(conversation, null, 2); - this.cachedLastConvData = newContent; - fs.writeFileSync(this.conversationFile, newContent); - } + this.appendRecord(record); } catch (error) { - console.error('Error writing conversation file:', error); - throw error; + console.error('Error saving chat compression record:', error); } } /** - * Convenient helper for updating the conversation without file reading and writing and time - * updating boilerplate. + * Records a UI telemetry event for replaying metrics on resume. */ - private updateConversation( - updateFn: (conversation: ConversationRecord) => void, - ) { - const conversation = this.readConversation(); - updateFn(conversation); - this.writeConversation(conversation); - } - - /** - * Deletes a session file by session ID. - */ - deleteSession(sessionId: string): void { + recordUiTelemetryEvent(uiEvent: UiEvent): void { try { - const chatsDir = path.join( - this.config.storage.getProjectTempDir(), - 'chats', - ); - const sessionPath = path.join(chatsDir, `${sessionId}.json`); - fs.unlinkSync(sessionPath); + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'ui_telemetry', + systemPayload: { uiEvent }, + }; + + this.appendRecord(record); } catch (error) { - console.error('Error deleting session:', error); - throw error; + console.error('Error saving ui telemetry record:', error); } } } diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts new file mode 100644 index 00000000..58ff1f23 --- /dev/null +++ b/packages/core/src/services/sessionService.test.ts @@ -0,0 +1,721 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from 'vitest'; +import { getProjectHash } from '../utils/paths.js'; +import { + SessionService, + buildApiHistoryFromConversation, + type ConversationRecord, +} from './sessionService.js'; +import { CompressionStatus } from '../core/turn.js'; +import type { ChatRecord } from './chatRecordingService.js'; +import * as jsonl from '../utils/jsonl-utils.js'; + +vi.mock('node:path'); +vi.mock('../utils/paths.js'); +vi.mock('../utils/jsonl-utils.js'); + +describe('SessionService', () => { + let sessionService: SessionService; + + let readdirSyncSpy: MockInstance; + let statSyncSpy: MockInstance; + let unlinkSyncSpy: MockInstance; + + beforeEach(() => { + vi.mocked(getProjectHash).mockReturnValue('test-project-hash'); + vi.mocked(path.join).mockImplementation((...args) => args.join('/')); + vi.mocked(path.dirname).mockImplementation((p) => { + const parts = p.split('/'); + parts.pop(); + return parts.join('/'); + }); + + sessionService = new SessionService('/test/project/root'); + + readdirSyncSpy = vi.spyOn(fs, 'readdirSync').mockReturnValue([]); + statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation( + () => + ({ + mtimeMs: Date.now(), + isFile: () => true, + }) as fs.Stats, + ); + unlinkSyncSpy = vi + .spyOn(fs, 'unlinkSync') + .mockImplementation(() => undefined); + + // Mock jsonl-utils + vi.mocked(jsonl.read).mockResolvedValue([]); + vi.mocked(jsonl.readLines).mockResolvedValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Test session IDs (UUID-like format) + const sessionIdA = '550e8400-e29b-41d4-a716-446655440000'; + const sessionIdB = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + const sessionIdC = '6ba7b811-9dad-11d1-80b4-00c04fd430c8'; + + // Test records + const recordA1: ChatRecord = { + uuid: 'a1', + parentUuid: null, + sessionId: sessionIdA, + timestamp: '2024-01-01T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hello session a' }] }, + cwd: '/test/project/root', + version: '1.0.0', + gitBranch: 'main', + }; + + const recordB1: ChatRecord = { + uuid: 'b1', + parentUuid: null, + sessionId: sessionIdB, + timestamp: '2024-01-02T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hi session b' }] }, + cwd: '/test/project/root', + version: '1.0.0', + gitBranch: 'feature', + }; + + const recordB2: ChatRecord = { + uuid: 'b2', + parentUuid: 'b1', + sessionId: sessionIdB, + timestamp: '2024-01-02T02:00:00Z', + type: 'assistant', + message: { role: 'model', parts: [{ text: 'hey back' }] }, + cwd: '/test/project/root', + version: '1.0.0', + }; + + describe('listSessions', () => { + it('should return empty list when no sessions exist', async () => { + readdirSyncSpy.mockReturnValue([]); + + const result = await sessionService.listSessions(); + + expect(result.items).toHaveLength(0); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); + }); + + it('should return empty list when chats directory does not exist', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + readdirSyncSpy.mockImplementation(() => { + throw error; + }); + + const result = await sessionService.listSessions(); + + expect(result.items).toHaveLength(0); + expect(result.hasMore).toBe(false); + }); + + it('should list sessions sorted by mtime descending', async () => { + const now = Date.now(); + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + `${sessionIdB}.jsonl`, + ] as unknown as Array>); + + statSyncSpy.mockImplementation((filePath: fs.PathLike) => { + const path = filePath.toString(); + return { + mtimeMs: path.includes(sessionIdB) ? now : now - 10000, + isFile: () => true, + } as fs.Stats; + }); + + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes(sessionIdA)) { + return [recordA1]; + } + return [recordB1]; + }, + ); + + const result = await sessionService.listSessions(); + + expect(result.items).toHaveLength(2); + // sessionIdB should be first (more recent mtime) + expect(result.items[0].sessionId).toBe(sessionIdB); + expect(result.items[1].sessionId).toBe(sessionIdA); + }); + + it('should extract prompt text from first record', async () => { + const now = Date.now(); + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array>); + + statSyncSpy.mockReturnValue({ + mtimeMs: now, + isFile: () => true, + } as fs.Stats); + + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + const result = await sessionService.listSessions(); + + expect(result.items[0].prompt).toBe('hello session a'); + expect(result.items[0].gitBranch).toBe('main'); + }); + + it('should truncate long prompts', async () => { + const longPrompt = 'A'.repeat(300); + const recordWithLongPrompt: ChatRecord = { + ...recordA1, + message: { role: 'user', parts: [{ text: longPrompt }] }, + }; + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array>); + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.readLines).mockResolvedValue([recordWithLongPrompt]); + + const result = await sessionService.listSessions(); + + expect(result.items[0].prompt.length).toBe(203); // 200 + '...' + expect(result.items[0].prompt.endsWith('...')).toBe(true); + }); + + it('should paginate with size parameter', async () => { + const now = Date.now(); + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + `${sessionIdB}.jsonl`, + `${sessionIdC}.jsonl`, + ] as unknown as Array>); + + statSyncSpy.mockImplementation((filePath: fs.PathLike) => { + const path = filePath.toString(); + let mtime = now; + if (path.includes(sessionIdB)) mtime = now - 1000; + if (path.includes(sessionIdA)) mtime = now - 2000; + return { + mtimeMs: mtime, + isFile: () => true, + } as fs.Stats; + }); + + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes(sessionIdC)) { + return [{ ...recordA1, sessionId: sessionIdC }]; + } + if (filePath.includes(sessionIdB)) { + return [recordB1]; + } + return [recordA1]; + }, + ); + + const result = await sessionService.listSessions({ size: 2 }); + + expect(result.items).toHaveLength(2); + expect(result.items[0].sessionId).toBe(sessionIdC); // newest + expect(result.items[1].sessionId).toBe(sessionIdB); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBeDefined(); + }); + + it('should paginate with cursor parameter', async () => { + const now = Date.now(); + const oldMtime = now - 2000; + const cursorMtime = now - 1000; + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + `${sessionIdB}.jsonl`, + `${sessionIdC}.jsonl`, + ] as unknown as Array>); + + statSyncSpy.mockImplementation((filePath: fs.PathLike) => { + const path = filePath.toString(); + let mtime = now; + if (path.includes(sessionIdB)) mtime = cursorMtime; + if (path.includes(sessionIdA)) mtime = oldMtime; + return { + mtimeMs: mtime, + isFile: () => true, + } as fs.Stats; + }); + + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + // Get items older than cursor (cursorMtime) + const result = await sessionService.listSessions({ cursor: cursorMtime }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].sessionId).toBe(sessionIdA); + expect(result.hasMore).toBe(false); + }); + + it('should skip files from different projects', async () => { + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array>); + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + + // This record is from a different cwd (different project) + const differentProjectRecord: ChatRecord = { + ...recordA1, + cwd: '/different/project', + }; + vi.mocked(jsonl.readLines).mockResolvedValue([differentProjectRecord]); + vi.mocked(getProjectHash).mockImplementation((cwd: string) => + cwd === '/test/project/root' + ? 'test-project-hash' + : 'other-project-hash', + ); + + const result = await sessionService.listSessions(); + + expect(result.items).toHaveLength(0); + }); + + it('should skip files that do not match session file pattern', async () => { + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, // valid + 'not-a-uuid.jsonl', // invalid pattern + 'readme.txt', // not jsonl + '.hidden.jsonl', // hidden file + ] as unknown as Array>); + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + const result = await sessionService.listSessions(); + + // Only the valid UUID pattern file should be processed + expect(result.items).toHaveLength(1); + expect(result.items[0].sessionId).toBe(sessionIdA); + }); + }); + + describe('loadSession', () => { + it('should load a session by id and reconstruct history', async () => { + const now = Date.now(); + statSyncSpy.mockReturnValue({ + mtimeMs: now, + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.read).mockResolvedValue([recordB1, recordB2]); + + const loaded = await sessionService.loadSession(sessionIdB); + + expect(loaded?.conversation.sessionId).toBe(sessionIdB); + expect(loaded?.conversation.messages).toHaveLength(2); + expect(loaded?.conversation.messages[0].uuid).toBe('b1'); + expect(loaded?.conversation.messages[1].uuid).toBe('b2'); + expect(loaded?.lastCompletedUuid).toBe('b2'); + }); + + it('should return undefined when session file is empty', async () => { + vi.mocked(jsonl.read).mockResolvedValue([]); + + const loaded = await sessionService.loadSession('nonexistent'); + + expect(loaded).toBeUndefined(); + }); + + it('should return undefined when session belongs to different project', async () => { + const now = Date.now(); + statSyncSpy.mockReturnValue({ + mtimeMs: now, + isFile: () => true, + } as fs.Stats); + + const differentProjectRecord: ChatRecord = { + ...recordA1, + cwd: '/different/project', + }; + vi.mocked(jsonl.read).mockResolvedValue([differentProjectRecord]); + vi.mocked(getProjectHash).mockImplementation((cwd: string) => + cwd === '/test/project/root' + ? 'test-project-hash' + : 'other-project-hash', + ); + + const loaded = await sessionService.loadSession(sessionIdA); + + expect(loaded).toBeUndefined(); + }); + + it('should reconstruct tree-structured history correctly', async () => { + const records: ChatRecord[] = [ + { + uuid: 'r1', + parentUuid: null, + sessionId: 'test', + timestamp: '2024-01-01T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'First' }] }, + cwd: '/test/project/root', + version: '1.0.0', + }, + { + uuid: 'r2', + parentUuid: 'r1', + sessionId: 'test', + timestamp: '2024-01-01T00:01:00Z', + type: 'assistant', + message: { role: 'model', parts: [{ text: 'Second' }] }, + cwd: '/test/project/root', + version: '1.0.0', + }, + { + uuid: 'r3', + parentUuid: 'r2', + sessionId: 'test', + timestamp: '2024-01-01T00:02:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Third' }] }, + cwd: '/test/project/root', + version: '1.0.0', + }, + ]; + + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.read).mockResolvedValue(records); + + const loaded = await sessionService.loadSession('test'); + + expect(loaded?.conversation.messages).toHaveLength(3); + expect(loaded?.conversation.messages.map((m) => m.uuid)).toEqual([ + 'r1', + 'r2', + 'r3', + ]); + }); + + it('should aggregate multiple records with same uuid', async () => { + const records: ChatRecord[] = [ + { + uuid: 'u1', + parentUuid: null, + sessionId: 'test', + timestamp: '2024-01-01T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Hello' }] }, + cwd: '/test/project/root', + version: '1.0.0', + }, + // Multiple records for same assistant message + { + uuid: 'a1', + parentUuid: 'u1', + sessionId: 'test', + timestamp: '2024-01-01T00:01:00Z', + type: 'assistant', + message: { + role: 'model', + parts: [{ thought: true, text: 'Thinking...' }], + }, + cwd: '/test/project/root', + version: '1.0.0', + }, + { + uuid: 'a1', + parentUuid: 'u1', + sessionId: 'test', + timestamp: '2024-01-01T00:01:01Z', + type: 'assistant', + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 20, + cachedContentTokenCount: 0, + totalTokenCount: 30, + }, + cwd: '/test/project/root', + version: '1.0.0', + }, + { + uuid: 'a1', + parentUuid: 'u1', + sessionId: 'test', + timestamp: '2024-01-01T00:01:02Z', + type: 'assistant', + message: { role: 'model', parts: [{ text: 'Response' }] }, + model: 'gemini-pro', + cwd: '/test/project/root', + version: '1.0.0', + }, + ]; + + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.read).mockResolvedValue(records); + + const loaded = await sessionService.loadSession('test'); + + expect(loaded?.conversation.messages).toHaveLength(2); + + const assistantMsg = loaded?.conversation.messages[1]; + expect(assistantMsg?.uuid).toBe('a1'); + expect(assistantMsg?.message?.parts).toHaveLength(2); + expect(assistantMsg?.usageMetadata?.totalTokenCount).toBe(30); + expect(assistantMsg?.model).toBe('gemini-pro'); + }); + }); + + describe('removeSession', () => { + it('should remove session file', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + const result = await sessionService.removeSession(sessionIdA); + + expect(result).toBe(true); + expect(unlinkSyncSpy).toHaveBeenCalled(); + }); + + it('should return false when session does not exist', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([]); + + const result = await sessionService.removeSession( + '00000000-0000-0000-0000-000000000000', + ); + + expect(result).toBe(false); + expect(unlinkSyncSpy).not.toHaveBeenCalled(); + }); + + it('should return false for session from different project', async () => { + const differentProjectRecord: ChatRecord = { + ...recordA1, + cwd: '/different/project', + }; + vi.mocked(jsonl.readLines).mockResolvedValue([differentProjectRecord]); + vi.mocked(getProjectHash).mockImplementation((cwd: string) => + cwd === '/test/project/root' + ? 'test-project-hash' + : 'other-project-hash', + ); + + const result = await sessionService.removeSession(sessionIdA); + + expect(result).toBe(false); + expect(unlinkSyncSpy).not.toHaveBeenCalled(); + }); + + it('should handle file not found error', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(jsonl.readLines).mockRejectedValue(error); + + const result = await sessionService.removeSession( + '00000000-0000-0000-0000-000000000000', + ); + + expect(result).toBe(false); + }); + }); + + describe('loadLastSession', () => { + it('should return the most recent session (same as getLatestSession)', async () => { + const now = Date.now(); + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + `${sessionIdB}.jsonl`, + ] as unknown as Array>); + + statSyncSpy.mockImplementation((filePath: fs.PathLike) => { + const path = filePath.toString(); + return { + mtimeMs: path.includes(sessionIdB) ? now : now - 10000, + isFile: () => true, + } as fs.Stats; + }); + + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes(sessionIdB)) { + return [recordB1]; + } + return [recordA1]; + }, + ); + + vi.mocked(jsonl.read).mockResolvedValue([recordB1, recordB2]); + + const latest = await sessionService.loadLastSession(); + + expect(latest?.conversation.sessionId).toBe(sessionIdB); + }); + + it('should return undefined when no sessions exist', async () => { + readdirSyncSpy.mockReturnValue([]); + + const latest = await sessionService.loadLastSession(); + + expect(latest).toBeUndefined(); + }); + }); + + describe('sessionExists', () => { + it('should return true for existing session', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + const exists = await sessionService.sessionExists(sessionIdA); + + expect(exists).toBe(true); + }); + + it('should return false for non-existing session', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([]); + + const exists = await sessionService.sessionExists( + '00000000-0000-0000-0000-000000000000', + ); + + expect(exists).toBe(false); + }); + + it('should return false for session from different project', async () => { + const differentProjectRecord: ChatRecord = { + ...recordA1, + cwd: '/different/project', + }; + vi.mocked(jsonl.readLines).mockResolvedValue([differentProjectRecord]); + vi.mocked(getProjectHash).mockImplementation((cwd: string) => + cwd === '/test/project/root' + ? 'test-project-hash' + : 'other-project-hash', + ); + + const exists = await sessionService.sessionExists(sessionIdA); + + expect(exists).toBe(false); + }); + }); + + describe('buildApiHistoryFromConversation', () => { + it('should return linear messages when no compression checkpoint exists', () => { + const assistantA1: ChatRecord = { + ...recordB2, + sessionId: sessionIdA, + parentUuid: recordA1.uuid, + }; + + const conversation: ConversationRecord = { + sessionId: sessionIdA, + projectHash: 'test-project-hash', + startTime: '2024-01-01T00:00:00Z', + lastUpdated: '2024-01-01T00:00:00Z', + messages: [recordA1, assistantA1], + }; + + const history = buildApiHistoryFromConversation(conversation); + + expect(history).toEqual([recordA1.message, assistantA1.message]); + }); + + it('should use compressedHistory snapshot and append subsequent records after compression', () => { + const compressionRecord: ChatRecord = { + uuid: 'c1', + parentUuid: 'b2', + sessionId: sessionIdA, + timestamp: '2024-01-02T03:00:00Z', + type: 'system', + subtype: 'chat_compression', + cwd: '/test/project/root', + version: '1.0.0', + gitBranch: 'main', + systemPayload: { + info: { + originalTokenCount: 100, + newTokenCount: 50, + compressionStatus: CompressionStatus.COMPRESSED, + }, + compressedHistory: [ + { role: 'user', parts: [{ text: 'summary' }] }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the additional context!' }], + }, + recordB2.message!, + ], + }, + }; + + const postCompressionRecord: ChatRecord = { + uuid: 'c2', + parentUuid: 'c1', + sessionId: sessionIdA, + timestamp: '2024-01-02T04:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'new question' }] }, + cwd: '/test/project/root', + version: '1.0.0', + gitBranch: 'main', + }; + + const conversation: ConversationRecord = { + sessionId: sessionIdA, + projectHash: 'test-project-hash', + startTime: '2024-01-01T00:00:00Z', + lastUpdated: '2024-01-02T04:00:00Z', + messages: [ + recordA1, + recordB2, + compressionRecord, + postCompressionRecord, + ], + }; + + const history = buildApiHistoryFromConversation(conversation); + + expect(history).toEqual([ + { role: 'user', parts: [{ text: 'summary' }] }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the additional context!' }], + }, + recordB2.message, + postCompressionRecord.message, + ]); + }); + }); +}); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts new file mode 100644 index 00000000..efeaa634 --- /dev/null +++ b/packages/core/src/services/sessionService.ts @@ -0,0 +1,656 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Storage } from '../config/storage.js'; +import { getProjectHash } from '../utils/paths.js'; +import path from 'node:path'; +import fs from 'node:fs'; +import readline from 'node:readline'; +import type { Content, Part } from '@google/genai'; +import * as jsonl from '../utils/jsonl-utils.js'; +import type { + ChatCompressionRecordPayload, + ChatRecord, + UiTelemetryRecordPayload, +} from './chatRecordingService.js'; +import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; + +/** + * Session item for list display. + * Contains essential info extracted from the first record of a session file. + */ +export interface SessionListItem { + /** Unique session identifier */ + sessionId: string; + /** Working directory at session start */ + cwd: string; + /** ISO 8601 timestamp when session started */ + startTime: string; + /** File modification time (used for ordering and pagination) */ + mtime: number; + /** First user prompt text (truncated for display) */ + prompt: string; + /** Git branch at session start, if available */ + gitBranch?: string; + /** Full path to the session file */ + filePath: string; + /** Number of messages in the session (unique message UUIDs) */ + messageCount: number; +} + +/** + * Pagination options for listing sessions. + */ +export interface ListSessionsOptions { + /** + * Cursor for pagination (mtime of the last item from previous page). + * Items with mtime < cursor will be returned. + * If undefined, starts from the most recent. + */ + cursor?: number; + /** + * Maximum number of items to return. + * @default 20 + */ + size?: number; +} + +/** + * Result of listing sessions with pagination info. + */ +export interface ListSessionsResult { + /** Session items for this page */ + items: SessionListItem[]; + /** + * Cursor for next page (mtime of last item). + * Undefined if no more items. + */ + nextCursor?: number; + /** Whether there are more items after this page */ + hasMore: boolean; +} + +/** + * Complete conversation reconstructed from ChatRecords. + * Used for resuming sessions and API compatibility. + */ +export interface ConversationRecord { + sessionId: string; + projectHash: string; + startTime: string; + lastUpdated: string; + /** Messages in chronological order (reconstructed from tree) */ + messages: ChatRecord[]; +} + +/** + * Data structure for resuming an existing session. + */ +export interface ResumedSessionData { + conversation: ConversationRecord; + filePath: string; + /** UUID of the last completed message - new messages should use this as parentUuid */ + lastCompletedUuid: string | null; +} + +/** + * Maximum number of files to process when listing sessions. + * This is a safety limit to prevent performance issues with very large chat directories. + */ +const MAX_FILES_TO_PROCESS = 10000; + +/** + * Pattern for validating session file names. + * Session files are named as `${sessionId}.jsonl` where sessionId is a UUID-like identifier + * (32-36 hex characters, optionally with hyphens). + */ +const SESSION_FILE_PATTERN = /^[0-9a-fA-F-]{32,36}\.jsonl$/; +/** Maximum number of lines to scan when looking for the first prompt text. */ +const MAX_PROMPT_SCAN_LINES = 10; + +/** + * Service for managing chat sessions. + * + * This service handles: + * - Listing sessions with pagination (ordered by mtime) + * - Loading full session data for resumption + * - Removing sessions + * + * Sessions are stored as JSONL files, one per session. + * File location: ~/.qwen/tmp//chats/ + */ +export class SessionService { + private readonly storage: Storage; + private readonly projectHash: string; + + constructor(cwd: string) { + this.storage = new Storage(cwd); + this.projectHash = getProjectHash(cwd); + } + + private getChatsDir(): string { + return path.join(this.storage.getProjectDir(), 'chats'); + } + + /** + * Extracts the first user prompt text from a Content object. + */ + private extractPromptText(message: Content | undefined): string { + if (!message?.parts) return ''; + + for (const part of message.parts as Part[]) { + if ('text' in part) { + const textPart = part as { text: string }; + const text = textPart.text; + // Truncate long prompts for display + return text.length > 200 ? `${text.slice(0, 200)}...` : text; + } + } + return ''; + } + + /** + * Finds the first available prompt text by scanning the first N records, + * preferring user messages. Returns an empty string if none found. + */ + private extractFirstPromptFromRecords(records: ChatRecord[]): string { + for (const record of records) { + if (record.type !== 'user') continue; + const prompt = this.extractPromptText(record.message); + if (prompt) return prompt; + } + return ''; + } + + /** + * Counts unique message UUIDs in a session file. + * This gives the number of logical messages in the session. + */ + private async countSessionMessages(filePath: string): Promise { + const uniqueUuids = new Set(); + try { + const fileStream = fs.createReadStream(filePath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const record = JSON.parse(trimmed) as ChatRecord; + if (record.type === 'user' || record.type === 'assistant') { + uniqueUuids.add(record.uuid); + } + } catch { + // Ignore malformed lines + continue; + } + } + + return uniqueUuids.size; + } catch { + return 0; + } + } + + /** + * Lists sessions for the current project with pagination. + * + * Sessions are ordered by file modification time (most recent first). + * Uses cursor-based pagination with mtime as the cursor. + * + * Only reads the first line of each JSONL file for efficiency. + * Files are filtered by UUID pattern first, then by project hash. + * + * @param options Pagination options + * @returns Paginated list of sessions + */ + async listSessions( + options: ListSessionsOptions = {}, + ): Promise { + const { cursor, size = 20 } = options; + const chatsDir = this.getChatsDir(); + + // Get all valid session files (matching UUID pattern) with their stats + let files: Array<{ name: string; mtime: number }> = []; + try { + const fileNames = fs.readdirSync(chatsDir); + for (const name of fileNames) { + // Only process files matching session file pattern + if (!SESSION_FILE_PATTERN.test(name)) continue; + const filePath = path.join(chatsDir, name); + try { + const stats = fs.statSync(filePath); + files.push({ name, mtime: stats.mtimeMs }); + } catch { + // Skip files we can't stat + continue; + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { items: [], hasMore: false }; + } + throw error; + } + + // Sort by mtime descending (most recent first) + files.sort((a, b) => b.mtime - a.mtime); + + // Apply cursor filter (items with mtime < cursor) + if (cursor !== undefined) { + files = files.filter((f) => f.mtime < cursor); + } + + // Iterate through files until we have enough matching ones. + // Different projects may share the same chats directory due to path sanitization, + // so we need to filter by project hash and continue until we have enough items. + const items: SessionListItem[] = []; + let filesProcessed = 0; + let lastProcessedMtime: number | undefined; + let hasMoreFiles = false; + + for (const file of files) { + // Safety limit to prevent performance issues + if (filesProcessed >= MAX_FILES_TO_PROCESS) { + hasMoreFiles = true; + break; + } + + // Stop if we have enough items + if (items.length >= size) { + hasMoreFiles = true; + break; + } + + filesProcessed++; + lastProcessedMtime = file.mtime; + + const filePath = path.join(chatsDir, file.name); + const records = await jsonl.readLines( + filePath, + MAX_PROMPT_SCAN_LINES, + ); + + if (records.length === 0) continue; + const firstRecord = records[0]; + + // Skip if not matching current project + // We use cwd comparison since first record doesn't have projectHash + const recordProjectHash = getProjectHash(firstRecord.cwd); + if (recordProjectHash !== this.projectHash) continue; + + // Count messages for this session + const messageCount = await this.countSessionMessages(filePath); + + const prompt = this.extractFirstPromptFromRecords(records); + + items.push({ + sessionId: firstRecord.sessionId, + cwd: firstRecord.cwd, + startTime: firstRecord.timestamp, + mtime: file.mtime, + prompt, + gitBranch: firstRecord.gitBranch, + filePath, + messageCount, + }); + } + + // Determine next cursor (mtime of last processed file) + // Only set if there are more files to process + const nextCursor = + hasMoreFiles && lastProcessedMtime !== undefined + ? lastProcessedMtime + : undefined; + + return { + items, + nextCursor, + hasMore: hasMoreFiles, + }; + } + + /** + * Reads all records from a session file. + */ + private async readAllRecords(filePath: string): Promise { + try { + return await jsonl.read(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('Error reading session file:', error); + } + return []; + } + } + + /** + * Aggregates multiple records with the same uuid into a single ChatRecord. + * Merges content fields (message, tokens, model, toolCallResult). + */ + private aggregateRecords(records: ChatRecord[]): ChatRecord { + if (records.length === 0) { + throw new Error('Cannot aggregate empty records array'); + } + + const base = { ...records[0] }; + + for (let i = 1; i < records.length; i++) { + const record = records[i]; + + // Merge message (Content objects) + if (record.message !== undefined) { + if (base.message === undefined) { + base.message = record.message; + } else { + base.message = { + role: base.message.role, + parts: [ + ...(base.message.parts || []), + ...(record.message.parts || []), + ], + }; + } + } + + // Merge tokens (take the latest) + if (record.usageMetadata) { + base.usageMetadata = record.usageMetadata; + } + + // Merge toolCallResult + if (record.toolCallResult && !base.toolCallResult) { + base.toolCallResult = record.toolCallResult; + } + + // Merge model (take the first non-empty one) + if (record.model && !base.model) { + base.model = record.model; + } + + // Update timestamp to the latest + if (record.timestamp > base.timestamp) { + base.timestamp = record.timestamp; + } + } + + return base; + } + + /** + * Reconstructs a linear conversation from tree-structured records. + */ + private reconstructHistory( + records: ChatRecord[], + leafUuid?: string, + ): ChatRecord[] { + if (records.length === 0) return []; + + const recordsByUuid = new Map(); + for (const record of records) { + const existing = recordsByUuid.get(record.uuid) || []; + existing.push(record); + recordsByUuid.set(record.uuid, existing); + } + + let currentUuid: string | null = + leafUuid ?? records[records.length - 1].uuid; + const uuidChain: string[] = []; + const visited = new Set(); + + while (currentUuid && !visited.has(currentUuid)) { + visited.add(currentUuid); + uuidChain.push(currentUuid); + const recordsForUuid = recordsByUuid.get(currentUuid); + if (!recordsForUuid || recordsForUuid.length === 0) break; + currentUuid = recordsForUuid[0].parentUuid; + } + + uuidChain.reverse(); + const messages: ChatRecord[] = []; + for (const uuid of uuidChain) { + const recordsForUuid = recordsByUuid.get(uuid); + if (recordsForUuid && recordsForUuid.length > 0) { + messages.push(this.aggregateRecords(recordsForUuid)); + } + } + + return messages; + } + + /** + * Loads a session by its session ID. + * Reconstructs the full conversation from tree-structured records. + * + * @param sessionId The session ID to load + * @returns Session data for resumption, or null if not found + */ + async loadSession( + sessionId: string, + ): Promise { + const chatsDir = this.getChatsDir(); + const filePath = path.join(chatsDir, `${sessionId}.jsonl`); + + const records = await this.readAllRecords(filePath); + if (records.length === 0) { + return; + } + + // Verify this session belongs to the current project + const firstRecord = records[0]; + const recordProjectHash = getProjectHash(firstRecord.cwd); + if (recordProjectHash !== this.projectHash) { + return; + } + + // Reconstruct linear history + const messages = this.reconstructHistory(records); + if (messages.length === 0) { + return; + } + + const lastMessage = messages[messages.length - 1]; + const stats = fs.statSync(filePath); + + const conversation: ConversationRecord = { + sessionId: firstRecord.sessionId, + projectHash: this.projectHash, + startTime: firstRecord.timestamp, + lastUpdated: new Date(stats.mtimeMs).toISOString(), + messages, + }; + + return { + conversation, + filePath, + lastCompletedUuid: lastMessage.uuid, + }; + } + + /** + * Removes a session by its session ID. + * + * @param sessionId The session ID to remove + * @returns true if removed, false if not found + */ + async removeSession(sessionId: string): Promise { + const chatsDir = this.getChatsDir(); + const filePath = path.join(chatsDir, `${sessionId}.jsonl`); + + try { + // Verify the file exists and belongs to this project + const records = await jsonl.readLines(filePath, 1); + if (records.length === 0) { + return false; + } + + const recordProjectHash = getProjectHash(records[0].cwd); + if (recordProjectHash !== this.projectHash) { + return false; + } + + fs.unlinkSync(filePath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } + } + + /** + * Loads the most recent session for the current project. + * Combines listSessions and loadSession for convenience. + * + * @returns Session data for resumption, or undefined if no sessions exist + */ + async loadLastSession(): Promise { + const result = await this.listSessions({ size: 1 }); + if (result.items.length === 0) { + return; + } + return this.loadSession(result.items[0].sessionId); + } + + /** + * Checks if a session exists by its session ID. + * + * @param sessionId The session ID to check + * @returns true if session exists and belongs to current project + */ + async sessionExists(sessionId: string): Promise { + const chatsDir = this.getChatsDir(); + const filePath = path.join(chatsDir, `${sessionId}.jsonl`); + + try { + const records = await jsonl.readLines(filePath, 1); + if (records.length === 0) { + return false; + } + const recordProjectHash = getProjectHash(records[0].cwd); + return recordProjectHash === this.projectHash; + } catch { + return false; + } + } +} + +/** + * Builds the model-facing chat history (Content[]) from a reconstructed + * conversation. This keeps UI history intact while applying chat compression + * checkpoints for the API history used on resume. + * + * Strategy: + * - Find the latest system/chat_compression record (if any). + * - Use its compressedHistory snapshot as the base history. + * - Append all messages after that checkpoint (skipping system records). + * - If no checkpoint exists, return the linear message list (message field only). + */ +export function buildApiHistoryFromConversation( + conversation: ConversationRecord, +): Content[] { + const { messages } = conversation; + + let lastCompressionIndex = -1; + let compressedHistory: Content[] | undefined; + + messages.forEach((record, index) => { + if (record.type === 'system' && record.subtype === 'chat_compression') { + const payload = record.systemPayload as + | ChatCompressionRecordPayload + | undefined; + if (payload?.compressedHistory) { + lastCompressionIndex = index; + compressedHistory = payload.compressedHistory; + } + } + }); + + if (compressedHistory && lastCompressionIndex >= 0) { + const baseHistory: Content[] = structuredClone(compressedHistory); + + // Append everything after the compression record (newer turns) + for (let i = lastCompressionIndex + 1; i < messages.length; i++) { + const record = messages[i]; + if (record.type === 'system') continue; + if (record.message) { + baseHistory.push(structuredClone(record.message as Content)); + } + } + + return baseHistory; + } + + // Fallback: return linear messages as Content[] + return messages + .map((record) => record.message) + .filter((message): message is Content => message !== undefined) + .map((message) => structuredClone(message)); +} + +/** + * Replays stored UI telemetry events to rebuild metrics when resuming a session. + * Also restores the last prompt token count from the best available source. + */ +export function replayUiTelemetryFromConversation( + conversation: ConversationRecord, +): void { + uiTelemetryService.reset(); + + for (const record of conversation.messages) { + if (record.type !== 'system' || record.subtype !== 'ui_telemetry') { + continue; + } + const payload = record.systemPayload as + | UiTelemetryRecordPayload + | undefined; + const uiEvent = payload?.uiEvent; + if (uiEvent) { + uiTelemetryService.addEvent(uiEvent); + } + } + + const resumePromptTokens = getResumePromptTokenCount(conversation); + if (resumePromptTokens !== undefined) { + uiTelemetryService.setLastPromptTokenCount(resumePromptTokens); + } +} + +/** + * Returns the best available prompt token count for resuming telemetry: + * - If a chat compression checkpoint exists, use its new token count. + * - Otherwise, use the last assistant usageMetadata input (fallback to total). + */ +export function getResumePromptTokenCount( + conversation: ConversationRecord, +): number | undefined { + let fallback: number | undefined; + + for (let i = conversation.messages.length - 1; i >= 0; i--) { + const record = conversation.messages[i]; + if (record.type === 'system' && record.subtype === 'chat_compression') { + const payload = record.systemPayload as + | ChatCompressionRecordPayload + | undefined; + if (payload?.info) { + return payload.info.newTokenCount; + } + } + + if (fallback === undefined && record.type === 'assistant') { + const usage = record.usageMetadata; + if (usage) { + fallback = usage.totalTokenCount ?? usage.promptTokenCount; + } + } + } + + return fallback; +} diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index 6aa25234..26436c88 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -55,9 +55,7 @@ describe('SubagentManager', () => { } as unknown as ToolRegistry; // Create mock Config object using test utility - mockConfig = makeFakeConfig({ - sessionId: 'test-session-id', - }); + mockConfig = makeFakeConfig({}); // Mock the tool registry and project root methods vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(mockToolRegistry); diff --git a/packages/core/src/subagents/subagent.test.ts b/packages/core/src/subagents/subagent.test.ts index a08b104e..256fb44d 100644 --- a/packages/core/src/subagents/subagent.test.ts +++ b/packages/core/src/subagents/subagent.test.ts @@ -65,7 +65,6 @@ async function createMockConfig( toolRegistryMocks = {}, ): Promise<{ config: Config; toolRegistry: ToolRegistry }> { const configParams: ConfigParameters = { - sessionId: 'test-session', model: DEFAULT_GEMINI_MODEL, targetDir: '.', debugMode: false, diff --git a/packages/core/src/subagents/subagent.ts b/packages/core/src/subagents/subagent.ts index 7d161b10..885e8ca6 100644 --- a/packages/core/src/subagents/subagent.ts +++ b/packages/core/src/subagents/subagent.ts @@ -572,6 +572,7 @@ export class SubAgentScope { const responded = new Set(); let resolveBatch: (() => void) | null = null; const scheduler = new CoreToolScheduler({ + config: this.runtimeContext, outputUpdateHandler: undefined, onAllToolCallsComplete: async (completedCalls) => { for (const call of completedCalls) { @@ -710,7 +711,6 @@ export class SubAgentScope { } }, getPreferredEditor: () => undefined, - config: this.runtimeContext, onEditorClose: () => {}, }); diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts index f7404d04..3ece605f 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts @@ -142,6 +142,7 @@ describe('ClearcutLogger', () => { const loggerConfig = makeFakeConfig({ ...config, + sessionId: 'test-session-id', }); ClearcutLogger.clearInstance(); @@ -248,7 +249,7 @@ describe('ClearcutLogger', () => { it('logs default metadata', () => { // Define expected values - const session_id = 'my-session-id'; + const session_id = 'test-session-id'; const auth_type = AuthType.USE_GEMINI; const google_accounts = 123; const surface = 'ide-1234'; @@ -260,7 +261,7 @@ describe('ClearcutLogger', () => { // Setup logger with expected values const { logger, loggerConfig } = setup({ lifetimeGoogleAccounts: google_accounts, - config: { sessionId: session_id }, + config: {}, }); vi.spyOn(loggerConfig, 'getContentGeneratorConfig').mockReturnValue({ authType: auth_type, diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 16c230ba..9adb2d32 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -25,7 +25,7 @@ export { parseTelemetryTargetValue, } from './config.js'; export { - logCliConfiguration, + logStartSession, logUserPrompt, logToolCall, logApiRequest, diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 324a4f2d..1167cc6a 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -41,7 +41,7 @@ import { import { logApiRequest, logApiResponse, - logCliConfiguration, + logStartSession, logUserPrompt, logToolCall, logFlashFallback, @@ -116,7 +116,7 @@ describe('loggers', () => { }); it('logs the chat compression event to QwenLogger', () => { - const mockConfig = makeFakeConfig(); + const mockConfig = makeFakeConfig({ sessionId: 'test-session-id' }); const event = makeChatCompressionEvent({ tokens_before: 9001, @@ -131,7 +131,7 @@ describe('loggers', () => { }); it('records the chat compression event to OTEL', () => { - const mockConfig = makeFakeConfig(); + const mockConfig = makeFakeConfig({ sessionId: 'test-session-id' }); logChatCompression( mockConfig, @@ -177,10 +177,12 @@ describe('loggers', () => { getTargetDir: () => 'target-dir', getProxy: () => 'http://test.proxy.com:8080', getOutputFormat: () => OutputFormat.JSON, + getToolRegistry: () => undefined, + getChatRecordingService: () => undefined, } as unknown as Config; const startSessionEvent = new StartSessionEvent(mockConfig); - logCliConfiguration(mockConfig, startSessionEvent); + logStartSession(mockConfig, startSessionEvent); expect(mockLogger.emit).toHaveBeenCalledWith({ body: 'CLI configuration loaded.', @@ -281,7 +283,8 @@ describe('loggers', () => { getUsageStatisticsEnabled: () => true, getTelemetryEnabled: () => true, getTelemetryLogPromptsEnabled: () => true, - } as Config; + getChatRecordingService: () => undefined, + } as unknown as Config; const mockMetrics = { recordApiResponseMetrics: vi.fn(), @@ -368,7 +371,7 @@ describe('loggers', () => { getUsageStatisticsEnabled: () => true, getTelemetryEnabled: () => true, getTelemetryLogPromptsEnabled: () => true, - } as Config; + } as unknown as Config; it('should log an API request with request_text', () => { const event = new ApiRequestEvent( @@ -498,6 +501,7 @@ describe('loggers', () => { const cfg2 = { getSessionId: () => 'test-session-id', getTargetDir: () => 'target-dir', + getProjectRoot: () => '/test/project/root', getProxy: () => 'http://test.proxy.com:8080', getContentGeneratorConfig: () => ({ model: 'test-model' }) as ContentGeneratorConfig, @@ -530,7 +534,8 @@ describe('loggers', () => { getUsageStatisticsEnabled: () => true, getTelemetryEnabled: () => true, getTelemetryLogPromptsEnabled: () => true, - } as Config; + getChatRecordingService: () => undefined, + } as unknown as Config; const mockMetrics = { recordToolCallMetrics: vi.fn(), @@ -1029,7 +1034,7 @@ describe('loggers', () => { }); it('logs the event to Clearcut and OTEL', () => { - const mockConfig = makeFakeConfig(); + const mockConfig = makeFakeConfig({ sessionId: 'test-session-id' }); const event = new MalformedJsonResponseEvent('test-model'); logMalformedJsonResponse(mockConfig, event); diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index efd5af06..b7039a55 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -101,7 +101,7 @@ function getCommonAttributes(config: Config): LogAttributes { }; } -export function logCliConfiguration( +export function logStartSession( config: Config, event: StartSessionEvent, ): void { @@ -172,6 +172,7 @@ export function logToolCall(config: Config, event: ToolCallEvent): void { 'event.timestamp': new Date().toISOString(), } as UiEvent; uiTelemetryService.addEvent(uiEvent); + config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent); QwenLogger.getInstance(config)?.logToolCallEvent(event); if (!isTelemetrySdkInitialized()) return; @@ -339,6 +340,7 @@ export function logApiError(config: Config, event: ApiErrorEvent): void { 'event.timestamp': new Date().toISOString(), } as UiEvent; uiTelemetryService.addEvent(uiEvent); + config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent); QwenLogger.getInstance(config)?.logApiErrorEvent(event); if (!isTelemetrySdkInitialized()) return; @@ -405,6 +407,7 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void { 'event.timestamp': new Date().toISOString(), } as UiEvent; uiTelemetryService.addEvent(uiEvent); + config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent); QwenLogger.getInstance(config)?.logApiResponseEvent(event); if (!isTelemetrySdkInitialized()) return; const attributes: LogAttributes = { diff --git a/packages/core/src/telemetry/metrics.test.ts b/packages/core/src/telemetry/metrics.test.ts index df87d554..e90602af 100644 --- a/packages/core/src/telemetry/metrics.test.ts +++ b/packages/core/src/telemetry/metrics.test.ts @@ -134,7 +134,9 @@ describe('Telemetry Metrics', () => { }); it('records token compression with the correct attributes', () => { - const config = makeFakeConfig({}); + const config = makeFakeConfig({ + sessionId: 'test-session-id', + }); initializeMetricsModule(config); recordChatCompressionMetricsModule(config, { diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts index 2150ad95..41871c36 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts @@ -59,6 +59,7 @@ const makeFakeConfig = (overrides: Partial = {}): Config => { getTelemetryLogPromptsEnabled: () => false, getFileFilteringRespectGitIgnore: () => true, getOutputFormat: () => 'text', + getToolRegistry: () => undefined, ...overrides, }; return defaults as Config; diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index 3d286b02..b6a97a2e 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -39,8 +39,8 @@ import type { ExtensionDisableEvent, AuthEvent, RipgrepFallbackEvent, + EndSessionEvent, } from '../types.js'; -import { EndSessionEvent } from '../types.js'; import type { RumEvent, RumViewEvent, @@ -102,6 +102,7 @@ export class QwenLogger { private lastFlushTime: number = Date.now(); private userId: string; + private sessionId: string; /** @@ -115,17 +116,12 @@ export class QwenLogger { */ private pendingFlush: boolean = false; - private isShutdown: boolean = false; - - private constructor(config?: Config) { + private constructor(config: Config) { this.config = config; this.events = new FixedDeque(Array, MAX_EVENTS); this.installationManager = new InstallationManager(); this.userId = this.generateUserId(); - this.sessionId = - typeof this.config?.getSessionId === 'function' - ? this.config.getSessionId() - : ''; + this.sessionId = config.getSessionId(); } private generateUserId(): string { @@ -139,10 +135,6 @@ export class QwenLogger { return undefined; if (!QwenLogger.instance) { QwenLogger.instance = new QwenLogger(config); - process.on( - 'exit', - QwenLogger.instance.shutdown.bind(QwenLogger.instance), - ); } return QwenLogger.instance; @@ -241,10 +233,10 @@ export class QwenLogger { id: this.userId, }, session: { - id: this.sessionId, + id: this.sessionId || this.config?.getSessionId(), }, view: { - id: this.sessionId, + id: this.sessionId || this.config?.getSessionId(), name: 'qwen-code-cli', }, os: osMetadata, @@ -364,7 +356,24 @@ export class QwenLogger { } // session events - logStartSessionEvent(event: StartSessionEvent): void { + async logStartSessionEvent(event: StartSessionEvent): Promise { + // Flush all pending events with the old session ID first. + // If flush fails, discard the pending events to avoid mixing sessions. + await this.flushToRum().catch((error: unknown) => { + if (this.config?.getDebugMode()) { + console.debug( + 'Error flushing pending events before session start:', + error, + ); + } + }); + + // Clear any remaining events (discard if flush failed) + this.events.clear(); + + // Now set the new session ID + this.sessionId = event.session_id; + const applicationEvent = this.createViewEvent('session', 'session_start', { properties: { model: event.model, @@ -852,14 +861,6 @@ export class QwenLogger { } } - shutdown() { - if (this.isShutdown) return; - - this.isShutdown = true; - const event = new EndSessionEvent(this.config); - this.logEndSessionEvent(event); - } - private requeueFailedEvents(eventsToSend: RumEvent[]): void { // Add the events back to the front of the queue to be retried, but limit retry queue size const eventsToRetry = eventsToSend.slice(-MAX_RETRY_EVENTS); // Keep only the most recent events diff --git a/packages/core/src/telemetry/telemetry.test.ts b/packages/core/src/telemetry/telemetry.test.ts index 15bd2e95..6051d226 100644 --- a/packages/core/src/telemetry/telemetry.test.ts +++ b/packages/core/src/telemetry/telemetry.test.ts @@ -24,7 +24,6 @@ describe('telemetry', () => { vi.resetAllMocks(); mockConfig = new Config({ - sessionId: 'test-session-id', model: 'test-model', targetDir: '/test/dir', debugMode: false, diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 8d21f634..cfe4a2a0 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -17,7 +17,6 @@ import { } from './tool-call-decision.js'; import type { FileOperation } from './metrics.js'; export { ToolCallDecision }; -import type { ToolRegistry } from '../tools/tool-registry.js'; import type { OutputFormat } from '../output/types.js'; export interface BaseTelemetryEvent { @@ -31,6 +30,7 @@ type CommonFields = keyof BaseTelemetryEvent; export class StartSessionEvent implements BaseTelemetryEvent { 'event.name': 'cli_config'; 'event.timestamp': string; + session_id: string; model: string; embedding_model: string; sandbox_enabled: boolean; @@ -48,9 +48,10 @@ export class StartSessionEvent implements BaseTelemetryEvent { mcp_tools?: string; output_format: OutputFormat; - constructor(config: Config, toolRegistry?: ToolRegistry) { + constructor(config: Config) { const generatorConfig = config.getContentGeneratorConfig(); const mcpServers = config.getMcpServers(); + const toolRegistry = config.getToolRegistry(); let useGemini = false; let useVertex = false; @@ -60,6 +61,7 @@ export class StartSessionEvent implements BaseTelemetryEvent { } this['event.name'] = 'cli_config'; + this.session_id = config.getSessionId(); this.model = config.getModel(); this.embedding_model = config.getEmbeddingModel(); this.sandbox_enabled = diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index 5917d485..9a257e5a 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -152,6 +152,18 @@ export class UiTelemetryService extends EventEmitter { }); } + /** + * Resets metrics to the initial state (used when resuming a session). + */ + reset(): void { + this.#metrics = createInitialMetrics(); + this.#lastPromptTokenCount = 0; + this.emit('update', { + metrics: this.#metrics, + lastPromptTokenCount: this.#lastPromptTokenCount, + }); + } + private getOrCreateModelMetrics(modelName: string): ModelMetrics { if (!this.#metrics.models[modelName]) { this.#metrics.models[modelName] = createInitialModelMetrics(); diff --git a/packages/core/src/test-utils/config.ts b/packages/core/src/test-utils/config.ts index d1ed7e26..7a9e95b4 100644 --- a/packages/core/src/test-utils/config.ts +++ b/packages/core/src/test-utils/config.ts @@ -13,7 +13,6 @@ import { Config } from '../config/config.js'; export const DEFAULT_CONFIG_PARAMETERS: ConfigParameters = { usageStatisticsEnabled: true, debugMode: false, - sessionId: 'test-session-id', proxy: undefined, model: 'qwen-9001-super-duper', targetDir: '/', diff --git a/packages/core/src/tools/exitPlanMode.test.ts b/packages/core/src/tools/exitPlanMode.test.ts index cdae6bfd..8f5e4163 100644 --- a/packages/core/src/tools/exitPlanMode.test.ts +++ b/packages/core/src/tools/exitPlanMode.test.ts @@ -205,9 +205,7 @@ describe('ExitPlanModeTool', () => { }; const invocation = tool.build(params); - expect(invocation.getDescription()).toBe( - 'Present implementation plan for user approval', - ); + expect(invocation.getDescription()).toBe('Plan:'); }); it('should return empty tool locations', () => { diff --git a/packages/core/src/tools/exitPlanMode.ts b/packages/core/src/tools/exitPlanMode.ts index 6ff7d176..e3c92f92 100644 --- a/packages/core/src/tools/exitPlanMode.ts +++ b/packages/core/src/tools/exitPlanMode.ts @@ -60,7 +60,7 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation< } getDescription(): string { - return 'Present implementation plan for user approval'; + return 'Plan:'; } override async shouldConfirmExecute( diff --git a/packages/core/src/tools/memoryTool.test.ts b/packages/core/src/tools/memoryTool.test.ts index 471f87e0..b6483784 100644 --- a/packages/core/src/tools/memoryTool.test.ts +++ b/packages/core/src/tools/memoryTool.test.ts @@ -29,10 +29,6 @@ vi.mock(import('node:fs/promises'), async (importOriginal) => { }; }); -vi.mock('fs', () => ({ - mkdirSync: vi.fn(), -})); - vi.mock('os'); const MEMORY_SECTION_HEADER = '## Qwen Added Memories'; diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index a7aa6648..aaca9923 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -144,30 +144,6 @@ describe('ReadFileTool', () => { ).toBe(path.join('sub', 'dir', 'file.txt')); }); - it('should return shortened path when file path is deep', () => { - const deepPath = path.join( - tempRootDir, - 'very', - 'deep', - 'directory', - 'structure', - 'that', - 'exceeds', - 'the', - 'normal', - 'limit', - 'file.txt', - ); - const params: ReadFileToolParams = { absolute_path: deepPath }; - const invocation = tool.build(params); - expect(typeof invocation).not.toBe('string'); - const desc = ( - invocation as ToolInvocation - ).getDescription(); - expect(desc).toContain('...'); - expect(desc).toContain('file.txt'); - }); - it('should handle non-normalized file paths correctly', () => { const subDir = path.join(tempRootDir, 'sub', 'dir'); const params: ReadFileToolParams = { diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index a9e47ccf..e4b41c23 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -57,7 +57,18 @@ class ReadFileToolInvocation extends BaseToolInvocation< this.params.absolute_path, this.config.getTargetDir(), ); - return shortenPath(relativePath); + const shortPath = shortenPath(relativePath); + + const { offset, limit } = this.params; + if (offset !== undefined && limit !== undefined) { + return `${shortPath} (lines ${offset + 1}-${offset + limit})`; + } else if (offset !== undefined) { + return `${shortPath} (from line ${offset + 1})`; + } else if (limit !== undefined) { + return `${shortPath} (first ${limit} lines)`; + } + + return shortPath; } override toolLocations(): ToolLocation[] { diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index cb2a35aa..2bcc3e16 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -104,7 +104,6 @@ const baseConfigParams: ConfigParameters = { userMemory: '', geminiMdFileCount: 0, approvalMode: ApprovalMode.DEFAULT, - sessionId: 'test-session-id', }; describe('ToolRegistry', () => { diff --git a/packages/core/src/utils/flashFallback.test.ts b/packages/core/src/utils/flashFallback.test.ts index 8ef9665f..7f21fe01 100644 --- a/packages/core/src/utils/flashFallback.test.ts +++ b/packages/core/src/utils/flashFallback.test.ts @@ -32,7 +32,6 @@ describe('Retry Utility Fallback Integration', () => { isDirectory: () => true, } as fs.Stats); config = new Config({ - sessionId: 'test-session', targetDir: '/test', debugMode: false, cwd: '/test', diff --git a/packages/core/src/utils/gitUtils.ts b/packages/core/src/utils/gitUtils.ts index 9ac8f1b0..e63b6beb 100644 --- a/packages/core/src/utils/gitUtils.ts +++ b/packages/core/src/utils/gitUtils.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { execSync } from 'node:child_process'; /** * Checks if a directory is within a git repository @@ -71,3 +72,19 @@ export function findGitRoot(directory: string): string | null { return null; } } + +/** + * Gets the current git branch, if in a git repository. + */ +export const getGitBranch = (cwd: string): string | undefined => { + try { + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + return branch || undefined; + } catch { + return undefined; + } +}; diff --git a/packages/core/src/utils/jsonl-utils.ts b/packages/core/src/utils/jsonl-utils.ts new file mode 100644 index 00000000..d0771f1b --- /dev/null +++ b/packages/core/src/utils/jsonl-utils.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Efficient JSONL (JSON Lines) file utilities. + * + * Reading operations: + * - readLines() - Reads the first N lines efficiently using buffered I/O + * - read() - Reads entire file into memory as array + * + * Writing operations: + * - writeLine() - Async append with mutex-based concurrency control + * - writeLineSync() - Sync append (use in non-async contexts) + * - write() - Overwrites entire file with array of objects + * + * Utility operations: + * - countLines() - Counts non-empty lines + * - exists() - Checks if file exists and is non-empty + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline'; +import { Mutex } from 'async-mutex'; + +/** + * A map of file paths to mutexes for preventing concurrent writes. + */ +const fileLocks = new Map(); + +/** + * Gets or creates a mutex for a specific file path. + */ +function getFileLock(filePath: string): Mutex { + if (!fileLocks.has(filePath)) { + fileLocks.set(filePath, new Mutex()); + } + return fileLocks.get(filePath)!; +} + +/** + * Reads the first N lines from a JSONL file efficiently. + * Returns an array of parsed objects. + */ +export async function readLines( + filePath: string, + count: number, +): Promise { + try { + const fileStream = fs.createReadStream(filePath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + const results: T[] = []; + for await (const line of rl) { + if (results.length >= count) break; + const trimmed = line.trim(); + if (trimmed.length > 0) { + results.push(JSON.parse(trimmed) as T); + } + } + + return results; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error( + `Error reading first ${count} lines from ${filePath}:`, + error, + ); + } + return []; + } +} + +/** + * Reads all lines from a JSONL file. + * Returns an array of parsed objects. + */ +export async function read(filePath: string): Promise { + try { + const fileStream = fs.createReadStream(filePath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + const results: T[] = []; + for await (const line of rl) { + const trimmed = line.trim(); + if (trimmed.length > 0) { + results.push(JSON.parse(trimmed) as T); + } + } + + return results; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error(`Error reading ${filePath}:`, error); + } + return []; + } +} + +/** + * Appends a line to a JSONL file with concurrency control. + * This method uses a mutex to ensure only one write happens at a time per file. + */ +export async function writeLine( + filePath: string, + data: unknown, +): Promise { + const lock = getFileLock(filePath); + await lock.runExclusive(() => { + const line = `${JSON.stringify(data)}\n`; + // Ensure directory exists before writing + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.appendFileSync(filePath, line, 'utf8'); + }); +} + +/** + * Synchronous version of writeLine for use in non-async contexts. + * Uses a simple flag-based locking mechanism (less robust than async version). + */ +export function writeLineSync(filePath: string, data: unknown): void { + const line = `${JSON.stringify(data)}\n`; + // Ensure directory exists before writing + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.appendFileSync(filePath, line, 'utf8'); +} + +/** + * Overwrites a JSONL file with an array of objects. + * Each object will be written as a separate line. + */ +export function write(filePath: string, data: unknown[]): void { + const lines = data.map((item) => JSON.stringify(item)).join('\n'); + // Ensure directory exists before writing + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, `${lines}\n`, 'utf8'); +} + +/** + * Counts the number of non-empty lines in a JSONL file. + */ +export async function countLines(filePath: string): Promise { + try { + const fileStream = fs.createReadStream(filePath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + let count = 0; + for await (const line of rl) { + if (line.trim().length > 0) { + count++; + } + } + return count; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error(`Error counting lines in ${filePath}:`, error); + } + return 0; + } +} + +/** + * Checks if a JSONL file exists and is not empty. + */ +export function exists(filePath: string): boolean { + try { + const stats = fs.statSync(filePath); + return stats.isFile() && stats.size > 0; + } catch { + return false; + } +} diff --git a/packages/core/src/utils/nextSpeakerChecker.test.ts b/packages/core/src/utils/nextSpeakerChecker.test.ts index 3d52efa5..3cdb8628 100644 --- a/packages/core/src/utils/nextSpeakerChecker.test.ts +++ b/packages/core/src/utils/nextSpeakerChecker.test.ts @@ -32,6 +32,7 @@ vi.mock('node:fs', () => { }); }), existsSync: vi.fn((path: string) => mockFileSystem.has(path)), + appendFileSync: vi.fn(), }; return { diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index c5986c68..0bdfaf83 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -38,7 +38,7 @@ export function tildeifyPath(path: string): string { * Shortens a path string if it exceeds maxLen, prioritizing the start and end segments. * Example: /path/to/a/very/long/file.txt -> /path/.../long/file.txt */ -export function shortenPath(filePath: string, maxLen: number = 35): string { +export function shortenPath(filePath: string, maxLen: number = 80): string { if (filePath.length <= maxLen) { return filePath; } diff --git a/packages/core/src/utils/session.ts b/packages/core/src/utils/session.ts deleted file mode 100644 index 96cdbbf4..00000000 --- a/packages/core/src/utils/session.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { randomUUID } from 'node:crypto'; - -export const sessionId = randomUUID();