Compare commits

...

14 Commits

Author SHA1 Message Date
github-actions[bot]
13eb86d50f chore(release): v0.4.0-preview.0 2025-12-04 03:42:35 +00:00
tanzhenxin
6729980b47 skip acp integration test in sandbox env (#1141) 2025-12-04 10:28:21 +08:00
tanzhenxin
2ca36d7508 skip one flaky integration test (#1137) 2025-12-03 19:40:14 +08:00
tanzhenxin
e426c15e9e pump version to 0.4.0 (#1132) 2025-12-03 18:10:11 +08:00
tanzhenxin
0a75d85ac9 Session-Level Conversation History Management (#1113) 2025-12-03 18:04:48 +08:00
Zijun Yang
a7abd8d09f fix(shell-utils): resolve command detection on Ubuntu by using shell for builtins (#1123) 2025-12-02 11:49:40 +08:00
Mingholy
c9af74816a fix: reset authType settings (#1091)
* fix: reset authType settings

* fix: failed json-output tests

* fix: sandbox exception log to stderr
2025-11-23 17:59:35 +08:00
pomelo
9cfea73207 Merge pull request #1097 from QwenLM/chore-action
fix(ci): remove non-existent label from release failure issue creation
2025-11-23 08:27:34 +08:00
pomelo-nwu
87b1ffe017 fix(ci): remove non-existent label from release failure issue creation 2025-11-22 14:23:49 +08:00
pomelo
83fc321e15 Merge pull request #1090 from QwenLM/feat/logger-enhancement
Improve Usage Statistics by Moving Key Snapshot Fields into Properties
2025-11-21 15:55:26 +08:00
pomelo
48b77541c3 feat(i18n): Add Internationalization Support for UI and LLM Output (#1058) 2025-11-21 15:44:37 +08:00
tanzhenxin
f2439f8d53 fix: skip one unstable test case 2025-11-21 15:43:05 +08:00
tanzhenxin
fb6d0b43fa feat: change shortcut for subagent execution display 2025-11-21 15:42:17 +08:00
tanzhenxin
627283d357 feat: enhance usage statistics in qwen logger 2025-11-21 15:17:34 +08:00
209 changed files with 14085 additions and 4753 deletions

View File

@@ -224,5 +224,4 @@ jobs:
run: |-
gh issue create \
--title "Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \
--body "The release workflow failed. See the full run for details: ${DETAILS_URL}" \
--label "kind/bug,release-failure"
--body "The release workflow failed. See the full run for details: ${DETAILS_URL}"

View File

@@ -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 `<tag>` for identifying the conversation state.
- **Usage:** `/chat save <tag>`
- **Details on Checkpoint Location:** The default locations for saved chat checkpoints are:
- Linux/macOS: `~/.qwen/tmp/<project_hash>/`
- Windows: `C:\Users\<YourUsername>\.qwen\tmp\<project_hash>\`
- 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 <tag>`
- **`list`**
- **Description:** Lists available tags for chat state resumption.
- **`delete`**
- **Description:** Deletes a saved conversation checkpoint.
- **Usage:** `/chat delete <tag>`
- **`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`**
@@ -195,6 +172,16 @@ Slash commands provide meta-level control over the CLI itself.
- **`/init`**
- **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions.
- [**`/language`**](./language.md)
- **Description:** View or change the language setting for both UI and LLM output.
- **Sub-commands:**
- **`ui`**: Set the UI language (zh-CN or en-US)
- **`output`**: Set the LLM output language
- **Usage:** `/language [ui|output] [language]`
- **Examples:**
- `/language ui zh-CN` (set UI language to Simplified Chinese)
- `/language output English` (set LLM output language to English)
### Custom Commands
For a quick start, see the [example](#example-a-pure-function-refactoring-command) below.

View File

@@ -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.

View File

@@ -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 <format>`** (**`-o <format>`**):
- **Description:** Specifies the format of the CLI output for non-interactive mode.
- **Values:**

71
docs/cli/language.md Normal file
View File

@@ -0,0 +1,71 @@
# Language Command
The `/language` command allows you to customize the language settings for both the Qwen Code user interface (UI) and the language model's output. This command supports two distinct functionalities:
1. Setting the UI language for the Qwen Code interface
2. Setting the output language for the language model (LLM)
## UI Language Settings
To change the UI language of Qwen Code, use the `ui` subcommand:
```
/language ui [zh-CN|en-US]
```
### Available UI Languages
- **zh-CN**: Simplified Chinese (简体中文)
- **en-US**: English
### Examples
```
/language ui zh-CN # Set UI language to Simplified Chinese
/language ui en-US # Set UI language to English
```
### UI Language Subcommands
You can also use direct subcommands for convenience:
- `/language ui zh-CN` or `/language ui zh` or `/language ui 中文`
- `/language ui en-US` or `/language ui en` or `/language ui english`
## LLM Output Language Settings
To set the language for the language model's responses, use the `output` subcommand:
```
/language output <language>
```
This command generates a language rule file that instructs the LLM to respond in the specified language. The rule file is saved to `~/.qwen/output-language.md`.
### Examples
```
/language output 中文 # Set LLM output language to Chinese
/language output English # Set LLM output language to English
/language output 日本語 # Set LLM output language to Japanese
```
## Viewing Current Settings
When used without arguments, the `/language` command displays the current language settings:
```
/language
```
This will show:
- Current UI language
- Current LLM output language (if set)
- Available subcommands
## Notes
- UI language changes take effect immediately and reload all command descriptions
- LLM output language settings are persisted in a rule file that is automatically included in the model's context
- To request additional UI language packs, please open an issue on GitHub

View File

@@ -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/<sanitized-cwd>/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).

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,590 @@
/**
* @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;
const IS_SANDBOX =
process.env['GEMINI_SANDBOX'] &&
process.env['GEMINI_SANDBOX']!.toLowerCase() !== 'false';
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<number, PendingRequest>();
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'],
});
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<unknown>((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<void>((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,
};
}
(IS_SANDBOX ? describe.skip : 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();
}
});
});

View File

@@ -6,124 +6,71 @@
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
// PR, which is potentially hiding other failures
it.skipIf(process.platform === 'win32')(
'should exit gracefully on second Ctrl+C',
async () => {
const rig = new TestRig();
await rig.setup('should exit gracefully on second Ctrl+C');
it.skip('should exit gracefully on second Ctrl+C', async () => {
const rig = new TestRig();
await rig.setup('should exit gracefully on second Ctrl+C');
const { ptyProcess, promise } = rig.runInteractive();
const { ptyProcess, promise } = rig.runInteractive();
let output = '';
ptyProcess.onData((data) => {
output += data;
});
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);
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));
// 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,
);
// Wait for the exit prompt
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));
// 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<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error(
`Process did not exit within ${EXIT_TIMEOUT}ms. Output: ${output}`,
),
),
EXIT_TIMEOUT,
),
),
]);
// Expect a graceful exit (code 0)
expect(
result.exitCode,
`Process exited with code ${result.exitCode}. Output: ${result.output}`,
).toBe(0);
// 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);
},
);
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);
},
);
// 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);
});
});

View File

@@ -53,81 +53,6 @@ describe('JSON output', () => {
}
});
it('should return a JSON error for enforced auth mismatch before running', async () => {
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 {
delete process.env['OPENAI_API_KEY'];
}
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?',
@@ -306,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',
);
});
});

23
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.4.0-preview.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.4.0-preview.0",
"workspaces": [
"packages/*"
],
@@ -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": {
@@ -16024,7 +16032,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.4.0-preview.0",
"dependencies": {
"@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5",
@@ -16139,7 +16147,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.3.0",
"version": "0.4.0-preview.0",
"hasInstallScript": true,
"dependencies": {
"@google/genai": "1.16.0",
@@ -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",
@@ -16278,7 +16287,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.3.0",
"version": "0.4.0-preview.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -16290,7 +16299,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.3.0",
"version": "0.4.0-preview.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.4.0-preview.0",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0-preview.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -46,6 +46,7 @@
"lint:all": "node scripts/lint.js",
"format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present",
"check-i18n": "npm run check-i18n --workspace=packages/cli",
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
"prepare": "husky && npm run bundle",
"prepare:package": "node scripts/prepare-package.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.4.0-preview.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -26,13 +26,14 @@
"format": "prettier --write .",
"test": "vitest run",
"test:ci": "vitest run",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"check-i18n": "tsx ../../scripts/check-i18n.ts"
},
"files": [
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0-preview.0"
},
"dependencies": {
"@google/genai": "1.16.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
FileFilteringOptions,
MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import {
ApprovalMode,
Config,
@@ -23,9 +18,15 @@ import {
WriteFileTool,
resolveTelemetrySettings,
FatalConfigError,
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';
@@ -128,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(
@@ -395,6 +400,17 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
'Include partial assistant messages when using stream-json output.',
default: false,
})
.option('continue', {
type: 'boolean',
description:
'Resume the most recent session for the current project.',
default: false,
})
.option('resume', {
type: 'string',
description:
'Resume a specific session by its ID. Use without an ID to show session picker.',
})
.deprecateOption(
'show-memory-usage',
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
@@ -450,6 +466,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
) {
return '--input-format stream-json requires --output-format stream-json';
}
if (argv['continue'] && argv['resume']) {
return 'Cannot use both --continue and --resume together. Use --continue to resume the latest session, or --resume <sessionId> to resume a specific session.';
}
return true;
}),
)
@@ -564,7 +583,6 @@ export async function loadCliConfig(
settings: Settings,
extensions: Extension[],
extensionEnablementManager: ExtensionEnablementManager,
sessionId: string,
argv: CliArgs,
cwd: string = process.cwd(),
): Promise<Config> {
@@ -602,6 +620,20 @@ export async function loadCliConfig(
(e) => e.contextFiles,
);
// Automatically load output-language.md if it exists
const outputLanguageFilePath = path.join(
Storage.getGlobalQwenDir(),
'output-language.md',
);
if (fs.existsSync(outputLanguageFilePath)) {
extensionContextFilePaths.push(outputLanguageFilePath);
if (debugMode) {
logger.debug(
`Found output-language.md, adding to context files: ${outputLanguageFilePath}`,
);
}
}
const fileService = new FileDiscoveryService(cwd);
const fileFiltering = {
@@ -782,8 +814,33 @@ export async function loadCliConfig(
const vlmSwitchMode =
argv.vlmSwitchMode || settings.experimental?.vlmSwitchMode;
let sessionId: string | undefined;
let sessionData: ResumedSessionData | undefined;
if (argv.continue || argv.resume) {
const sessionService = new SessionService(cwd);
if (argv.continue) {
sessionData = await sessionService.loadLastSession();
if (sessionData) {
sessionId = sessionData.conversation.sessionId;
}
}
if (argv.resume) {
sessionId = argv.resume;
sessionData = await sessionService.loadSession(argv.resume);
if (!sessionData) {
const message = `No saved session found with ID ${argv.resume}. Run \`qwen --resume\` without an ID to choose from existing sessions.`;
console.log(message);
process.exit(1);
}
}
}
return new Config({
sessionId,
sessionData,
embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: cwd,

View File

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

View File

@@ -176,6 +176,23 @@ const SETTINGS_SCHEMA = {
description: 'Enable debug logging of keystrokes to the console.',
showInDialog: true,
},
language: {
type: 'enum',
label: 'Language',
category: 'General',
requiresRestart: false,
default: 'auto',
description:
'The language for the user interface. Use "auto" to detect from system settings. ' +
'You can also use custom language codes (e.g., "es", "fr") by placing JS language files ' +
'in ~/.qwen/locales/ (e.g., ~/.qwen/locales/es.js).',
showInDialog: true,
options: [
{ value: 'auto', label: 'Auto (detect from system)' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
],
},
},
},
output: {

View File

@@ -14,6 +14,7 @@ import {
import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import { initializeI18n } from '../i18n/index.js';
export interface InitializationResult {
authError: string | null;
@@ -33,6 +34,13 @@ export async function initializeApp(
config: Config,
settings: LoadedSettings,
): Promise<InitializationResult> {
// Initialize i18n system
const languageSetting =
process.env['QWEN_CODE_LANG'] ||
settings.merged.general?.language ||
'auto';
await initializeI18n(languageSetting);
const authType = settings.merged.security?.auth?.selectedType;
const authError = await performInitialAuth(config, authType);
@@ -44,7 +52,6 @@ export async function initializeApp(
undefined,
);
}
const themeError = validateTheme(settings);
const shouldOpenAuthDialog =

View File

@@ -6,6 +6,7 @@
import { themeManager } from '../ui/themes/theme-manager.js';
import { type LoadedSettings } from '../config/settings.js';
import { t } from '../i18n/index.js';
/**
* Validates the configured theme.
@@ -15,7 +16,9 @@ import { type LoadedSettings } from '../config/settings.js';
export function validateTheme(settings: LoadedSettings): string | null {
const effectiveTheme = settings.merged.ui?.theme;
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
return `Theme "${effectiveTheme}" not found.`;
return t('Theme "{{themeName}}" not found.', {
themeName: effectiveTheme,
});
}
return null;
}

View File

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

View File

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

View File

@@ -0,0 +1,232 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os';
export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes
// State
let currentLanguage: SupportedLanguage = 'en';
let translations: Record<string, string> = {};
// Cache
type TranslationDict = Record<string, string>;
const translationCache: Record<string, TranslationDict> = {};
const loadingPromises: Record<string, Promise<TranslationDict>> = {};
// Path helpers
const getBuiltinLocalesDir = (): string => {
const __filename = fileURLToPath(import.meta.url);
return path.join(path.dirname(__filename), 'locales');
};
const getUserLocalesDir = (): string =>
path.join(homedir(), '.qwen', 'locales');
/**
* Get the path to the user's custom locales directory.
* Users can place custom language packs (e.g., es.js, fr.js) in this directory.
* @returns The path to ~/.qwen/locales
*/
export function getUserLocalesDirectory(): string {
return getUserLocalesDir();
}
const getLocalePath = (
lang: SupportedLanguage,
useUserDir: boolean = false,
): string => {
const baseDir = useUserDir ? getUserLocalesDir() : getBuiltinLocalesDir();
return path.join(baseDir, `${lang}.js`);
};
// Language detection
export function detectSystemLanguage(): SupportedLanguage {
const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG'];
if (envLang?.startsWith('zh')) return 'zh';
if (envLang?.startsWith('en')) return 'en';
try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
if (locale.startsWith('zh')) return 'zh';
} catch {
// Fallback to default
}
return 'en';
}
// Translation loading
async function loadTranslationsAsync(
lang: SupportedLanguage,
): Promise<TranslationDict> {
if (translationCache[lang]) {
return translationCache[lang];
}
const existingPromise = loadingPromises[lang];
if (existingPromise) {
return existingPromise;
}
const loadPromise = (async () => {
// Try user directory first (for custom language packs), then builtin directory
const searchDirs = [
{ dir: getUserLocalesDir(), isUser: true },
{ dir: getBuiltinLocalesDir(), isUser: false },
];
for (const { dir, isUser } of searchDirs) {
// Ensure directory exists
if (!fs.existsSync(dir)) {
continue;
}
const jsPath = getLocalePath(lang, isUser);
if (!fs.existsSync(jsPath)) {
continue;
}
try {
// Convert file path to file:// URL for cross-platform compatibility
const fileUrl = pathToFileURL(jsPath).href;
try {
const module = await import(fileUrl);
const result = module.default || module;
if (
result &&
typeof result === 'object' &&
Object.keys(result).length > 0
) {
translationCache[lang] = result;
return result;
} else {
throw new Error('Module loaded but result is empty or invalid');
}
} catch {
// For builtin locales, try alternative import method (relative path)
if (!isUser) {
try {
const module = await import(`./locales/${lang}.js`);
const result = module.default || module;
if (
result &&
typeof result === 'object' &&
Object.keys(result).length > 0
) {
translationCache[lang] = result;
return result;
}
} catch {
// Continue to next directory
}
}
// If import failed, continue to next directory
continue;
}
} catch (error) {
// Log warning but continue to next directory
if (isUser) {
console.warn(
`Failed to load translations from user directory for ${lang}:`,
error,
);
} else {
console.warn(`Failed to load JS translations for ${lang}:`, error);
if (error instanceof Error) {
console.warn(`Error details: ${error.message}`);
console.warn(`Stack: ${error.stack}`);
}
}
// Continue to next directory
continue;
}
}
// Return empty object if both directories fail
// Cache it to avoid repeated failed attempts
translationCache[lang] = {};
return {};
})();
loadingPromises[lang] = loadPromise;
// Clean up promise after completion to allow retry on next call if needed
loadPromise.finally(() => {
delete loadingPromises[lang];
});
return loadPromise;
}
function loadTranslations(lang: SupportedLanguage): TranslationDict {
// Only return from cache (JS files require async loading)
return translationCache[lang] || {};
}
// String interpolation
function interpolate(
template: string,
params?: Record<string, string>,
): string {
if (!params) return template;
return template.replace(
/\{\{(\w+)\}\}/g,
(match, key) => params[key] ?? match,
);
}
// Language setting helpers
function resolveLanguage(lang: SupportedLanguage | 'auto'): SupportedLanguage {
return lang === 'auto' ? detectSystemLanguage() : lang;
}
// Public API
export function setLanguage(lang: SupportedLanguage | 'auto'): void {
const resolvedLang = resolveLanguage(lang);
currentLanguage = resolvedLang;
// Try to load translations synchronously (from cache only)
const loaded = loadTranslations(resolvedLang);
translations = loaded;
// Warn if translations are empty and JS file exists (requires async loading)
if (Object.keys(loaded).length === 0) {
const userJsPath = getLocalePath(resolvedLang, true);
const builtinJsPath = getLocalePath(resolvedLang, false);
if (fs.existsSync(userJsPath) || fs.existsSync(builtinJsPath)) {
console.warn(
`Language file for ${resolvedLang} requires async loading. ` +
`Use setLanguageAsync() instead, or call initializeI18n() first.`,
);
}
}
}
export async function setLanguageAsync(
lang: SupportedLanguage | 'auto',
): Promise<void> {
currentLanguage = resolveLanguage(lang);
translations = await loadTranslationsAsync(currentLanguage);
}
export function getCurrentLanguage(): SupportedLanguage {
return currentLanguage;
}
export function t(key: string, params?: Record<string, string>): string {
const translation = translations[key] ?? key;
return interpolate(translation, params);
}
export async function initializeI18n(
lang?: SupportedLanguage | 'auto',
): Promise<void> {
await setLanguageAsync(lang ?? 'auto');
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -12,7 +12,6 @@ import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { chatCommand } from '../ui/commands/chatCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
@@ -24,6 +23,7 @@ import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
import { languageCommand } from '../ui/commands/languageCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
@@ -60,7 +60,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
approvalModeCommand,
authCommand,
bugCommand,
chatCommand,
clearCommand,
compressCommand,
copyCommand,
@@ -72,6 +71,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
helpCommand,
await ideCommand(),
initCommand,
languageCommand,
mcpCommand,
memoryCommand,
modelCommand,

View File

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

View File

@@ -40,6 +40,7 @@ import {
getAllGeminiMdFilenames,
ShellExecutionService,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { validateAuthMethod } from '../config/auth.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import process from 'node:process';
@@ -89,6 +90,7 @@ import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
@@ -195,7 +197,6 @@ export const AppContainer = (props: AppContainerProps) => {
const [isConfigInitialized, setConfigInitialized] = useState(false);
const logger = useLogger(config.storage);
const [userMessages, setUserMessages] = useState<string[]>([]);
// Terminal and layout hooks
@@ -205,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
@@ -215,17 +217,28 @@ export const AppContainer = (props: AppContainerProps) => {
const lastTitleRef = useRef<string | null>(null);
const staticExtraHeight = 3;
// Initialize config (runs once on mount)
useEffect(() => {
(async () => {
// Note: the program will not work if this fails so let errors be
// handled by the global catch.
await config.initialize();
setConfigInitialized(true);
const resumedSessionData = config.getResumedSessionData();
if (resumedSessionData) {
const historyItems = buildResumedHistoryItems(
resumedSessionData,
config,
);
historyManager.loadHistory(historyItems);
}
})();
registerCleanup(async () => {
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
useEffect(
@@ -384,7 +397,13 @@ export const AppContainer = (props: AppContainerProps) => {
settings.merged.security?.auth.selectedType
) {
onAuthError(
`Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`,
t(
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
{
enforcedType: settings.merged.security?.auth.enforcedType,
currentType: settings.merged.security?.auth.selectedType,
},
),
);
} else if (
settings.merged.security?.auth?.selectedType &&
@@ -515,6 +534,7 @@ export const AppContainer = (props: AppContainerProps) => {
slashCommandActions,
extensionsUpdateStateInternal,
isConfigInitialized,
logger,
);
// Vision switch handlers

View File

@@ -15,6 +15,7 @@ import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { t } from '../../i18n/index.js';
function parseDefaultAuthType(
defaultAuthType: string | undefined,
@@ -39,10 +40,14 @@ export function AuthDialog(): React.JSX.Element {
const items = [
{
key: AuthType.QWEN_OAUTH,
label: 'Qwen OAuth',
label: t('Qwen OAuth'),
value: AuthType.QWEN_OAUTH,
},
{ key: AuthType.USE_OPENAI, label: 'OpenAI', value: AuthType.USE_OPENAI },
{
key: AuthType.USE_OPENAI,
label: t('OpenAI'),
value: AuthType.USE_OPENAI,
},
];
const initialAuthIndex = Math.max(
@@ -98,7 +103,9 @@ export function AuthDialog(): React.JSX.Element {
if (settings.merged.security?.auth?.selectedType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
t(
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
),
);
return;
}
@@ -116,9 +123,9 @@ export function AuthDialog(): React.JSX.Element {
padding={1}
width="100%"
>
<Text bold>Get started</Text>
<Text bold>{t('Get started')}</Text>
<Box marginTop={1}>
<Text>How would you like to authenticate for this project?</Text>
<Text>{t('How would you like to authenticate for this project?')}</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
@@ -134,19 +141,19 @@ export function AuthDialog(): React.JSX.Element {
</Box>
)}
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
<Text color={Colors.AccentPurple}>{t('(Use Enter to Set Auth)')}</Text>
</Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text color={Colors.Gray}>
Note: Your existing API key in settings.json will not be cleared
when using Qwen OAuth. You can switch back to OpenAI authentication
later if needed.
{t(
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
)}
</Text>
</Box>
)}
<Box marginTop={1}>
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
<Text>{t('Terms of Services and Privacy Notice for Qwen Code')}</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.AccentBlue}>

View File

@@ -10,6 +10,7 @@ import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
interface AuthInProgressProps {
onTimeout: () => void;
@@ -48,13 +49,13 @@ export function AuthInProgress({
>
{timedOut ? (
<Text color={theme.status.error}>
Authentication timed out. Please try again.
{t('Authentication timed out. Please try again.')}
</Text>
) : (
<Box>
<Text>
<Spinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C to
cancel)
<Spinner type="dots" />{' '}
{t('Waiting for auth... (Press ESC or CTRL+C to cancel)')}
</Text>
</Box>
)}

View File

@@ -18,6 +18,7 @@ import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
import { t } from '../../i18n/index.js';
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
@@ -60,7 +61,9 @@ export const useAuthCommand = (
const handleAuthFailure = useCallback(
(error: unknown) => {
setIsAuthenticating(false);
const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`;
const errorMessage = t('Failed to authenticate. Message: {{message}}', {
message: getErrorMessage(error),
});
onAuthError(errorMessage);
// Log authentication failure
@@ -127,7 +130,9 @@ export const useAuthCommand = (
addItem(
{
type: MessageType.INFO,
text: `Authenticated successfully with ${authType} credentials.`,
text: t('Authenticated successfully with {{authType}} credentials.', {
authType,
}),
},
Date.now(),
);
@@ -225,7 +230,13 @@ export const useAuthCommand = (
)
) {
onAuthError(
`Invalid QWEN_DEFAULT_AUTH_TYPE value: "${defaultAuthType}". Valid values are: ${[AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', ')}`,
t(
'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}',
{
value: defaultAuthType,
validValues: [AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', '),
},
),
);
}
}, [onAuthError]);

View File

@@ -8,10 +8,13 @@ import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { MessageType, type HistoryItemAbout } from '../types.js';
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
import { t } from '../../i18n/index.js';
export const aboutCommand: SlashCommand = {
name: 'about',
description: 'show version info',
get description() {
return t('show version info');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const systemInfo = await getExtendedSystemInfo(context);

View File

@@ -9,15 +9,20 @@ import {
type SlashCommand,
type OpenDialogActionReturn,
} from './types.js';
import { t } from '../../i18n/index.js';
export const agentsCommand: SlashCommand = {
name: 'agents',
description: 'Manage subagents for specialized task delegation.',
get description() {
return t('Manage subagents for specialized task delegation.');
},
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'manage',
description: 'Manage existing subagents (view, edit, delete).',
get description() {
return t('Manage existing subagents (view, edit, delete).');
},
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
@@ -26,7 +31,9 @@ export const agentsCommand: SlashCommand = {
},
{
name: 'create',
description: 'Create a new subagent with guided setup.',
get description() {
return t('Create a new subagent with guided setup.');
},
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -10,10 +10,13 @@ import type {
OpenDialogActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const approvalModeCommand: SlashCommand = {
name: 'approval-mode',
description: 'View or change the approval mode for tool usage',
get description() {
return t('View or change the approval mode for tool usage');
},
kind: CommandKind.BUILT_IN,
action: async (
_context: CommandContext,

View File

@@ -6,10 +6,13 @@
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const authCommand: SlashCommand = {
name: 'auth',
description: 'change the auth method',
get description() {
return t('change the auth method');
},
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -16,10 +16,13 @@ import {
getSystemInfoFields,
getFieldValue,
} from '../../utils/systemInfoFields.js';
import { t } from '../../i18n/index.js';
export const bugCommand: SlashCommand = {
name: 'bug',
description: 'submit a bug report',
get description() {
return t('submit a bug report');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => {
const bugDescription = (args || '').trim();

View File

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

View File

@@ -1,389 +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 { theme } from '../semantic-colors.js';
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';
interface ChatDetail {
name: string;
mtime: Date;
}
const getSavedChatTags = async (
context: CommandContext,
mtSortDesc: boolean,
): Promise<ChatDetail[]> => {
const cfg = context.services.config;
const geminiDir = cfg?.storage?.getProjectTempDir();
if (!geminiDir) {
return [];
}
try {
const file_head = 'checkpoint-';
const file_tail = '.json';
const files = await fsPromises.readdir(geminiDir);
const chatDetails: Array<{ name: string; mtime: Date }> = [];
for (const file of files) {
if (file.startsWith(file_head) && file.endsWith(file_tail)) {
const filePath = path.join(geminiDir, file);
const stats = await fsPromises.stat(filePath);
const tagName = file.slice(file_head.length, -file_tail.length);
chatDetails.push({
name: decodeTagName(tagName),
mtime: stats.mtime,
});
}
}
chatDetails.sort((a, b) =>
mtSortDesc
? b.mtime.getTime() - a.mtime.getTime()
: a.mtime.getTime() - b.mtime.getTime(),
);
return chatDetails;
} catch (_err) {
return [];
}
};
const listCommand: SlashCommand = {
name: 'list',
description: 'List saved conversation checkpoints',
kind: CommandKind.BUILT_IN,
action: async (context): Promise<MessageActionReturn> => {
const chatDetails = await getSavedChatTags(context, false);
if (chatDetails.length === 0) {
return {
type: 'message',
messageType: 'info',
content: 'No saved conversation checkpoints found.',
};
}
const maxNameLength = Math.max(
...chatDetails.map((chat) => chat.name.length),
);
let message = '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 += `\nNote: Newest last, oldest first`;
return {
type: 'message',
messageType: 'info',
content: message,
};
},
};
const saveCommand: SlashCommand = {
name: 'save',
description:
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat save <tag>',
};
}
const { logger, config } = context.services;
await logger.initialize();
if (!context.overwriteConfirmed) {
const exists = await logger.checkpointExists(tag);
if (exists) {
return {
type: 'confirm_action',
prompt: React.createElement(
Text,
null,
'A checkpoint with the tag ',
React.createElement(Text, { color: theme.text.accent }, tag),
' already exists. Do you want to overwrite it?',
),
originalInvocation: {
raw: context.invocation?.raw || `/chat save ${tag}`,
},
};
}
}
const chat = await config?.getGeminiClient()?.getChat();
if (!chat) {
return {
type: 'message',
messageType: 'error',
content: '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: `Conversation checkpoint saved with tag: ${decodeTagName(tag)}.`,
};
} else {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to save.',
};
}
},
};
const resumeCommand: SlashCommand = {
name: 'resume',
altNames: ['load'],
description:
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args) => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat resume <tag>',
};
}
const { logger } = context.services;
await logger.initialize();
const conversation = await logger.loadCheckpoint(tag);
if (conversation.length === 0) {
return {
type: 'message',
messageType: 'info',
content: `No saved checkpoint found with 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',
description: 'Delete a conversation checkpoint. Usage: /chat delete <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
};
}
const { logger } = context.services;
await logger.initialize();
const deleted = await logger.deleteCheckpoint(tag);
if (deleted) {
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`,
};
} else {
return {
type: 'message',
messageType: 'error',
content: `Error: No checkpoint found with 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',
description:
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
let filePathArg = args.trim();
if (!filePathArg) {
filePathArg = `gemini-conversation-${Date.now()}.json`;
}
const filePath = path.resolve(filePathArg);
const extension = path.extname(filePath);
if (extension !== '.md' && extension !== '.json') {
return {
type: 'message',
messageType: 'error',
content: '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: '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: '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: `Conversation shared to ${filePath}`,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${errorMessage}`,
};
}
},
};
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Manage conversation history.',
kind: CommandKind.BUILT_IN,
subCommands: [
listCommand,
saveCommand,
resumeCommand,
deleteCommand,
shareCommand,
],
};

View File

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

View File

@@ -4,27 +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',
description: 'clear the screen and conversation history',
altNames: ['reset', 'new'],
get description() {
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('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('Clearing terminal.');
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}
uiTelemetryService.setLastPromptTokenCount(0);
context.ui.clear();
},
};

View File

@@ -8,11 +8,14 @@ import type { HistoryItemCompression } from '../types.js';
import { MessageType } from '../types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const compressCommand: SlashCommand = {
name: 'compress',
altNames: ['summarize'],
description: 'Compresses the context by replacing it with a summary.',
get description() {
return t('Compresses the context by replacing it with a summary.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const { ui } = context;
@@ -20,7 +23,7 @@ export const compressCommand: SlashCommand = {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Already compressing, wait for previous request to complete',
text: t('Already compressing, wait for previous request to complete'),
},
Date.now(),
);
@@ -60,7 +63,7 @@ export const compressCommand: SlashCommand = {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Failed to compress chat history.',
text: t('Failed to compress chat history.'),
},
Date.now(),
);
@@ -69,9 +72,9 @@ export const compressCommand: SlashCommand = {
ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to compress chat history: ${
e instanceof Error ? e.message : String(e)
}`,
text: t('Failed to compress chat history: {{error}}', {
error: e instanceof Error ? e.message : String(e),
}),
},
Date.now(),
);

View File

@@ -7,10 +7,13 @@
import { copyToClipboard } from '../utils/commandUtils.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const copyCommand: SlashCommand = {
name: 'copy',
description: 'Copy the last result or code snippet to clipboard',
get description() {
return t('Copy the last result or code snippet to clipboard');
},
kind: CommandKind.BUILT_IN,
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
const chat = await context.services.config?.getGeminiClient()?.getChat();

View File

@@ -10,6 +10,7 @@ import { MessageType } from '../types.js';
import * as os from 'node:os';
import * as path from 'node:path';
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
export function expandHomeDir(p: string): string {
if (!p) {
@@ -27,13 +28,18 @@ export function expandHomeDir(p: string): string {
export const directoryCommand: SlashCommand = {
name: 'directory',
altNames: ['dir'],
description: 'Manage workspace directories',
get description() {
return t('Manage workspace directories');
},
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'add',
description:
'Add directories to the workspace. Use comma to separate multiple paths',
get description() {
return t(
'Add directories to the workspace. Use comma to separate multiple paths',
);
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args: string) => {
const {
@@ -46,7 +52,7 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
text: t('Configuration is not available.'),
},
Date.now(),
);
@@ -63,7 +69,7 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.ERROR,
text: 'Please provide at least one path to add.',
text: t('Please provide at least one path to add.'),
},
Date.now(),
);
@@ -74,8 +80,9 @@ export const directoryCommand: SlashCommand = {
return {
type: 'message' as const,
messageType: 'error' as const,
content:
content: t(
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
),
};
}
@@ -88,7 +95,12 @@ export const directoryCommand: SlashCommand = {
added.push(pathToAdd.trim());
} catch (e) {
const error = e as Error;
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
errors.push(
t("Error adding '{{path}}': {{error}}", {
path: pathToAdd.trim(),
error: error.message,
}),
);
}
}
@@ -117,12 +129,21 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.INFO,
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
text: t(
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}',
{
directories: added.join('\n- '),
},
),
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
errors.push(
t('Error refreshing memory: {{error}}', {
error: (error as Error).message,
}),
);
}
if (added.length > 0) {
@@ -133,7 +154,9 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.INFO,
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
text: t('Successfully added directories:\n- {{directories}}', {
directories: added.join('\n- '),
}),
},
Date.now(),
);
@@ -150,7 +173,9 @@ export const directoryCommand: SlashCommand = {
},
{
name: 'show',
description: 'Show all directories in the workspace',
get description() {
return t('Show all directories in the workspace');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
const {
@@ -161,7 +186,7 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
text: t('Configuration is not available.'),
},
Date.now(),
);
@@ -173,7 +198,9 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.INFO,
text: `Current workspace directories:\n${directoryList}`,
text: t('Current workspace directories:\n{{directories}}', {
directories: directoryList,
}),
},
Date.now(),
);

View File

@@ -12,19 +12,28 @@ import {
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
import { t, getCurrentLanguage } from '../../i18n/index.js';
export const docsCommand: SlashCommand = {
name: 'docs',
description: 'open full Qwen Code documentation in your browser',
get description() {
return t('open full Qwen Code documentation in your browser');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext): Promise<void> => {
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en';
const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`;
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`,
text: t(
'Please open the following URL in your browser to view the documentation:\n{{url}}',
{
url: docsUrl,
},
),
},
Date.now(),
);
@@ -32,7 +41,9 @@ export const docsCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Opening documentation in your browser: ${docsUrl}`,
text: t('Opening documentation in your browser: {{url}}', {
url: docsUrl,
}),
},
Date.now(),
);

View File

@@ -9,10 +9,13 @@ import {
type OpenDialogActionReturn,
type SlashCommand,
} from './types.js';
import { t } from '../../i18n/index.js';
export const editorCommand: SlashCommand = {
name: 'editor',
description: 'set external editor preference',
get description() {
return t('set external editor preference');
},
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -19,6 +19,7 @@ import {
type SlashCommand,
CommandKind,
} from './types.js';
import { t } from '../../i18n/index.js';
async function listAction(context: CommandContext) {
context.ui.addItem(
@@ -131,14 +132,18 @@ async function updateAction(context: CommandContext, args: string) {
const listExtensionsCommand: SlashCommand = {
name: 'list',
description: 'List active extensions',
get description() {
return t('List active extensions');
},
kind: CommandKind.BUILT_IN,
action: listAction,
};
const updateExtensionsCommand: SlashCommand = {
name: 'update',
description: 'Update extensions. Usage: update <extension-names>|--all',
get description() {
return t('Update extensions. Usage: update <extension-names>|--all');
},
kind: CommandKind.BUILT_IN,
action: updateAction,
completion: async (context, partialArg) => {
@@ -158,7 +163,9 @@ const updateExtensionsCommand: SlashCommand = {
export const extensionsCommand: SlashCommand = {
name: 'extensions',
description: 'Manage extensions',
get description() {
return t('Manage extensions');
},
kind: CommandKind.BUILT_IN,
subCommands: [listExtensionsCommand, updateExtensionsCommand],
action: (context, args) =>

View File

@@ -7,12 +7,15 @@
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { MessageType, type HistoryItemHelp } from '../types.js';
import { t } from '../../i18n/index.js';
export const helpCommand: SlashCommand = {
name: 'help',
altNames: ['?'],
kind: CommandKind.BUILT_IN,
description: 'for help on Qwen Code',
get description() {
return t('for help on Qwen Code');
},
action: async (context) => {
const helpItem: Omit<HistoryItemHelp, 'id'> = {
type: MessageType.HELP,

View File

@@ -26,6 +26,7 @@ import type {
} from './types.js';
import { CommandKind } from './types.js';
import { SettingScope } from '../../config/settings.js';
import { t } from '../../i18n/index.js';
function getIdeStatusMessage(ideClient: IdeClient): {
messageType: 'info' | 'error';
@@ -138,27 +139,35 @@ export const ideCommand = async (): Promise<SlashCommand> => {
if (!currentIDE) {
return {
name: 'ide',
description: 'manage IDE integration',
get description() {
return t('manage IDE integration');
},
kind: CommandKind.BUILT_IN,
action: (): SlashCommandActionReturn =>
({
type: 'message',
messageType: 'error',
content: `IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.`,
content: t(
'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.',
),
}) as const,
};
}
const ideSlashCommand: SlashCommand = {
name: 'ide',
description: 'manage IDE integration',
get description() {
return t('manage IDE integration');
},
kind: CommandKind.BUILT_IN,
subCommands: [],
};
const statusCommand: SlashCommand = {
name: 'status',
description: 'check status of IDE integration',
get description() {
return t('check status of IDE integration');
},
kind: CommandKind.BUILT_IN,
action: async (): Promise<SlashCommandActionReturn> => {
const { messageType, content } =
@@ -173,7 +182,12 @@ export const ideCommand = async (): Promise<SlashCommand> => {
const installCommand: SlashCommand = {
name: 'install',
description: `install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`,
get description() {
const ideName = ideClient.getDetectedIdeDisplayName() ?? 'IDE';
return t('install required IDE companion for {{ideName}}', {
ideName,
});
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const installer = getIdeInstaller(currentIDE);
@@ -246,7 +260,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
const enableCommand: SlashCommand = {
name: 'enable',
description: 'enable IDE integration',
get description() {
return t('enable IDE integration');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(
@@ -268,7 +284,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
const disableCommand: SlashCommand = {
name: 'disable',
description: 'disable IDE integration',
get description() {
return t('disable IDE integration');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(

View File

@@ -15,10 +15,13 @@ import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
import { CommandKind } from './types.js';
import { Text } from 'ink';
import React from 'react';
import { t } from '../../i18n/index.js';
export const initCommand: SlashCommand = {
name: 'init',
description: 'Analyzes the project and creates a tailored QWEN.md file.',
get description() {
return t('Analyzes the project and creates a tailored QWEN.md file.');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -28,7 +31,7 @@ export const initCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
content: t('Configuration not available.'),
};
}
const targetDir = context.services.config.getTargetDir();

View File

@@ -0,0 +1,458 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
CommandContext,
SlashCommandActionReturn,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { SettingScope } from '../../config/settings.js';
import {
setLanguageAsync,
getCurrentLanguage,
type SupportedLanguage,
t,
} from '../../i18n/index.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { Storage } from '@qwen-code/qwen-code-core';
const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md';
/**
* Generates the LLM output language rule template based on the language name.
*/
function generateLlmOutputLanguageRule(language: string): string {
return `# ⚠️ CRITICAL: ${language} Output Language Rule - HIGHEST PRIORITY ⚠️
## 🚨 MANDATORY RULE - NO EXCEPTIONS 🚨
**YOU MUST RESPOND IN ${language.toUpperCase()} FOR EVERY SINGLE OUTPUT, REGARDLESS OF THE USER'S INPUT LANGUAGE.**
This is a **NON-NEGOTIABLE** requirement. Even if the user writes in English, says "hi", asks a simple question, or explicitly requests another language, **YOU MUST ALWAYS RESPOND IN ${language.toUpperCase()}.**
## What Must Be in ${language}
**EVERYTHING** you output: conversation replies, tool call descriptions, success/error messages, generated file content (comments, documentation), and all explanatory text.
**Tool outputs**: All descriptive text from \`read_file\`, \`write_file\`, \`codebase_search\`, \`run_terminal_cmd\`, \`todo_write\`, \`web_search\`, etc. MUST be in ${language}.
## Examples
### ✅ CORRECT:
- User says "hi" → Respond in ${language} (e.g., "Bonjour" if ${language} is French)
- Tool result → "已成功读取文件 config.json" (if ${language} is Chinese)
- Error → "无法找到指定的文件" (if ${language} is Chinese)
### ❌ WRONG:
- User says "hi" → "Hello" in English
- Tool result → "Successfully read file" in English
- Error → "File not found" in English
## Notes
- Code elements (variable/function names, syntax) can remain in English
- Comments, documentation, and all other text MUST be in ${language}
**THIS RULE IS ACTIVE NOW. ALL OUTPUTS MUST BE IN ${language.toUpperCase()}. NO EXCEPTIONS.**
`;
}
/**
* Gets the path to the LLM output language rule file.
*/
function getLlmOutputLanguageRulePath(): string {
return path.join(
Storage.getGlobalQwenDir(),
LLM_OUTPUT_LANGUAGE_RULE_FILENAME,
);
}
/**
* Gets the current LLM output language from the rule file if it exists.
*/
function getCurrentLlmOutputLanguage(): string | null {
const filePath = getLlmOutputLanguageRulePath();
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
// Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese")
const match = content.match(/^#\s+(.+?)\s+Response Rules/i);
if (match) {
return match[1];
}
} catch {
// Ignore errors
}
}
return null;
}
/**
* Sets the UI language and persists it to settings.
*/
async function setUiLanguage(
context: CommandContext,
lang: SupportedLanguage,
): Promise<MessageActionReturn> {
const { services } = context;
const { settings } = services;
if (!services.config) {
return {
type: 'message',
messageType: 'error',
content: t('Configuration not available.'),
};
}
// Set language in i18n system (async to support JS translation files)
await setLanguageAsync(lang);
// Persist to settings (user scope)
if (settings && typeof settings.setValue === 'function') {
try {
settings.setValue(SettingScope.User, 'general.language', lang);
} catch (error) {
console.warn('Failed to save language setting:', error);
}
}
// Reload commands to update their descriptions with the new language
context.ui.reloadCommands();
// Map language codes to friendly display names
const langDisplayNames: Record<SupportedLanguage, string> = {
zh: '中文zh-CN',
en: 'Englishen-US',
};
return {
type: 'message',
messageType: 'info',
content: t('UI language changed to {{lang}}', {
lang: langDisplayNames[lang],
}),
};
}
/**
* Generates the LLM output language rule file.
*/
function generateLlmOutputLanguageRuleFile(
language: string,
): Promise<MessageActionReturn> {
try {
const filePath = getLlmOutputLanguageRulePath();
const content = generateLlmOutputLanguageRule(language);
// Ensure directory exists
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
// Write file (overwrite if exists)
fs.writeFileSync(filePath, content, 'utf-8');
return Promise.resolve({
type: 'message',
messageType: 'info',
content: [
t('LLM output language rule file generated at {{path}}', {
path: filePath,
}),
'',
t('Please restart the application for the changes to take effect.'),
].join('\n'),
});
} catch (error) {
return Promise.resolve({
type: 'message',
messageType: 'error',
content: t(
'Failed to generate LLM output language rule file: {{error}}',
{
error: error instanceof Error ? error.message : String(error),
},
),
});
}
}
export const languageCommand: SlashCommand = {
name: 'language',
get description() {
return t('View or change the language setting');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
const { services } = context;
if (!services.config) {
return {
type: 'message',
messageType: 'error',
content: t('Configuration not available.'),
};
}
const trimmedArgs = args.trim();
// If no arguments, show current language settings and usage
if (!trimmedArgs) {
const currentUiLang = getCurrentLanguage();
const currentLlmLang = getCurrentLlmOutputLanguage();
const message = [
t('Current UI language: {{lang}}', { lang: currentUiLang }),
currentLlmLang
? t('Current LLM output language: {{lang}}', { lang: currentLlmLang })
: t('LLM output language not set'),
'',
t('Available subcommands:'),
` /language ui [zh-CN|en-US] - ${t('Set UI language')}`,
` /language output <language> - ${t('Set LLM output language')}`,
].join('\n');
return {
type: 'message',
messageType: 'info',
content: message,
};
}
// Parse subcommand
const parts = trimmedArgs.split(/\s+/);
const subcommand = parts[0].toLowerCase();
if (subcommand === 'ui') {
// Handle /language ui [zh-CN|en-US]
if (parts.length === 1) {
// Show UI language subcommand help
return {
type: 'message',
messageType: 'info',
content: [
t('Set UI language'),
'',
t('Usage: /language ui [zh-CN|en-US]'),
'',
t('Available options:'),
t(' - zh-CN: Simplified Chinese'),
t(' - en-US: English'),
'',
t(
'To request additional UI language packs, please open an issue on GitHub.',
),
].join('\n'),
};
}
const langArg = parts[1].toLowerCase();
let targetLang: SupportedLanguage | null = null;
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
targetLang = 'en';
} else if (
langArg === 'zh' ||
langArg === 'chinese' ||
langArg === '中文' ||
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else {
return {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN'),
};
}
return setUiLanguage(context, targetLang);
} else if (subcommand === 'output') {
// Handle /language output <language>
if (parts.length === 1) {
return {
type: 'message',
messageType: 'info',
content: [
t('Set LLM output language'),
'',
t('Usage: /language output <language>'),
` ${t('Example: /language output 中文')}`,
].join('\n'),
};
}
// Join all parts after "output" as the language name
const language = parts.slice(1).join(' ');
return generateLlmOutputLanguageRuleFile(language);
} else {
// Backward compatibility: treat as UI language
const langArg = trimmedArgs.toLowerCase();
let targetLang: SupportedLanguage | null = null;
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
targetLang = 'en';
} else if (
langArg === 'zh' ||
langArg === 'chinese' ||
langArg === '中文' ||
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else {
return {
type: 'message',
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
' - /language ui [zh-CN|en-US] - ' + t('Set UI language'),
' - /language output <language> - ' + t('Set LLM output language'),
].join('\n'),
};
}
return setUiLanguage(context, targetLang);
}
},
subCommands: [
{
name: 'ui',
get description() {
return t('Set UI language');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const trimmedArgs = args.trim();
if (!trimmedArgs) {
return {
type: 'message',
messageType: 'info',
content: [
t('Set UI language'),
'',
t('Usage: /language ui [zh-CN|en-US]'),
'',
t('Available options:'),
t(' - zh-CN: Simplified Chinese'),
t(' - en-US: English'),
'',
t(
'To request additional UI language packs, please open an issue on GitHub.',
),
].join('\n'),
};
}
const langArg = trimmedArgs.toLowerCase();
let targetLang: SupportedLanguage | null = null;
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
targetLang = 'en';
} else if (
langArg === 'zh' ||
langArg === 'chinese' ||
langArg === '中文' ||
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else {
return {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN'),
};
}
return setUiLanguage(context, targetLang);
},
subCommands: [
{
name: 'zh-CN',
altNames: ['zh', 'chinese', '中文'],
get description() {
return t('Set UI language to Simplified Chinese (zh-CN)');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'zh');
},
},
{
name: 'en-US',
altNames: ['en', 'english'],
get description() {
return t('Set UI language to English (en-US)');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'en');
},
},
],
},
{
name: 'output',
get description() {
return t('Set LLM output language');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const trimmedArgs = args.trim();
if (!trimmedArgs) {
return {
type: 'message',
messageType: 'info',
content: [
t('Set LLM output language'),
'',
t('Usage: /language output <language>'),
` ${t('Example: /language output 中文')}`,
` ${t('Example: /language output English')}`,
` ${t('Example: /language output 日本語')}`,
].join('\n'),
};
}
return generateLlmOutputLanguageRuleFile(trimmedArgs);
},
},
],
};

View File

@@ -24,10 +24,13 @@ import {
} from '@qwen-code/qwen-code-core';
import { appEvents, AppEvent } from '../../utils/events.js';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
import { t } from '../../i18n/index.js';
const authCommand: SlashCommand = {
name: 'auth',
description: 'Authenticate with an OAuth-enabled MCP server',
get description() {
return t('Authenticate with an OAuth-enabled MCP server');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -40,7 +43,7 @@ const authCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
content: t('Config not loaded.'),
};
}
@@ -56,14 +59,14 @@ const authCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: 'No MCP servers configured with OAuth authentication.',
content: t('No MCP servers configured with OAuth authentication.'),
};
}
return {
type: 'message',
messageType: 'info',
content: `MCP servers with OAuth authentication:\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\nUse /mcp auth <server-name> to authenticate.`,
content: `${t('MCP servers with OAuth authentication:')}\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\n${t('Use /mcp auth <server-name> to authenticate.')}`,
};
}
@@ -72,7 +75,7 @@ const authCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: `MCP server '${serverName}' not found.`,
content: t("MCP server '{{name}}' not found.", { name: serverName }),
};
}
@@ -89,7 +92,12 @@ const authCommand: SlashCommand = {
context.ui.addItem(
{
type: 'info',
text: `Starting OAuth authentication for MCP server '${serverName}'...`,
text: t(
"Starting OAuth authentication for MCP server '{{name}}'...",
{
name: serverName,
},
),
},
Date.now(),
);
@@ -111,7 +119,12 @@ const authCommand: SlashCommand = {
context.ui.addItem(
{
type: 'info',
text: `✅ Successfully authenticated with MCP server '${serverName}'!`,
text: t(
"Successfully authenticated and refreshed tools for '{{name}}'.",
{
name: serverName,
},
),
},
Date.now(),
);
@@ -122,7 +135,9 @@ const authCommand: SlashCommand = {
context.ui.addItem(
{
type: 'info',
text: `Re-discovering tools from '${serverName}'...`,
text: t("Re-discovering tools from '{{name}}'...", {
name: serverName,
}),
},
Date.now(),
);
@@ -140,13 +155,24 @@ const authCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: `Successfully authenticated and refreshed tools for '${serverName}'.`,
content: t(
"Successfully authenticated and refreshed tools for '{{name}}'.",
{
name: serverName,
},
),
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
content: t(
"Failed to authenticate with MCP server '{{name}}': {{error}}",
{
name: serverName,
error: getErrorMessage(error),
},
),
};
} finally {
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
@@ -165,7 +191,9 @@ const authCommand: SlashCommand = {
const listCommand: SlashCommand = {
name: 'list',
description: 'List configured MCP servers and tools',
get description() {
return t('List configured MCP servers and tools');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -176,7 +204,7 @@ const listCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
content: t('Config not loaded.'),
};
}
@@ -185,7 +213,7 @@ const listCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
content: t('Could not retrieve tool registry.'),
};
}
@@ -276,7 +304,9 @@ const listCommand: SlashCommand = {
const refreshCommand: SlashCommand = {
name: 'refresh',
description: 'Restarts MCP servers.',
get description() {
return t('Restarts MCP servers.');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -286,7 +316,7 @@ const refreshCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
content: t('Config not loaded.'),
};
}
@@ -295,14 +325,14 @@ const refreshCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
content: t('Could not retrieve tool registry.'),
};
}
context.ui.addItem(
{
type: 'info',
text: 'Restarting MCP servers...',
text: t('Restarting MCP servers...'),
},
Date.now(),
);
@@ -324,8 +354,11 @@ const refreshCommand: SlashCommand = {
export const mcpCommand: SlashCommand = {
name: 'mcp',
description:
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
get description() {
return t(
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
);
},
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, authCommand, refreshCommand],
// Default action when no subcommand is provided

View File

@@ -15,15 +15,20 @@ import fs from 'fs/promises';
import { MessageType } from '../types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
description: 'Commands for interacting with memory.',
get description() {
return t('Commands for interacting with memory.');
},
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'show',
description: 'Show the current memory contents.',
get description() {
return t('Show the current memory contents.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const memoryContent = context.services.config?.getUserMemory() || '';
@@ -31,8 +36,8 @@ export const memoryCommand: SlashCommand = {
const messageContent =
memoryContent.length > 0
? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`
: 'Memory is currently empty.';
? `${t('Current memory content from {{count}} file(s):', { count: String(fileCount) })}\n\n---\n${memoryContent}\n---`
: t('Memory is currently empty.');
context.ui.addItem(
{
@@ -45,7 +50,9 @@ export const memoryCommand: SlashCommand = {
subCommands: [
{
name: '--project',
description: 'Show project-level memory contents.',
get description() {
return t('Show project-level memory contents.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
try {
@@ -57,8 +64,14 @@ export const memoryCommand: SlashCommand = {
const messageContent =
memoryContent.trim().length > 0
? `Project memory content from ${projectMemoryPath}:\n\n---\n${memoryContent}\n---`
: 'Project memory is currently empty.';
? t(
'Project memory content from {{path}}:\n\n---\n{{content}}\n---',
{
path: projectMemoryPath,
content: memoryContent,
},
)
: t('Project memory is currently empty.');
context.ui.addItem(
{
@@ -71,7 +84,9 @@ export const memoryCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Project memory file not found or is currently empty.',
text: t(
'Project memory file not found or is currently empty.',
),
},
Date.now(),
);
@@ -80,7 +95,9 @@ export const memoryCommand: SlashCommand = {
},
{
name: '--global',
description: 'Show global memory contents.',
get description() {
return t('Show global memory contents.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
try {
@@ -96,8 +113,10 @@ export const memoryCommand: SlashCommand = {
const messageContent =
globalMemoryContent.trim().length > 0
? `Global memory content:\n\n---\n${globalMemoryContent}\n---`
: 'Global memory is currently empty.';
? t('Global memory content:\n\n---\n{{content}}\n---', {
content: globalMemoryContent,
})
: t('Global memory is currently empty.');
context.ui.addItem(
{
@@ -110,7 +129,9 @@ export const memoryCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Global memory file not found or is currently empty.',
text: t(
'Global memory file not found or is currently empty.',
),
},
Date.now(),
);
@@ -121,16 +142,20 @@ export const memoryCommand: SlashCommand = {
},
{
name: 'add',
description:
'Add content to the memory. Use --global for global memory or --project for project memory.',
get description() {
return t(
'Add content to the memory. Use --global for global memory or --project for project memory.',
);
},
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content:
content: t(
'Usage: /memory add [--global|--project] <text to remember>',
),
};
}
@@ -150,8 +175,9 @@ export const memoryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content:
content: t(
'Usage: /memory add [--global|--project] <text to remember>',
),
};
} else {
// No scope specified, will be handled by the tool
@@ -162,8 +188,9 @@ export const memoryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content:
content: t(
'Usage: /memory add [--global|--project] <text to remember>',
),
};
}
@@ -171,7 +198,10 @@ export const memoryCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to memory ${scopeText}: "${fact}"`,
text: t('Attempting to save to memory {{scope}}: "{{fact}}"', {
scope: scopeText,
fact,
}),
},
Date.now(),
);
@@ -185,21 +215,25 @@ export const memoryCommand: SlashCommand = {
subCommands: [
{
name: '--project',
description: 'Add content to project-level memory.',
get description() {
return t('Add content to project-level memory.');
},
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add --project <text to remember>',
content: t('Usage: /memory add --project <text to remember>'),
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to project memory: "${args.trim()}"`,
text: t('Attempting to save to project memory: "{{text}}"', {
text: args.trim(),
}),
},
Date.now(),
);
@@ -213,21 +247,25 @@ export const memoryCommand: SlashCommand = {
},
{
name: '--global',
description: 'Add content to global memory.',
get description() {
return t('Add content to global memory.');
},
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add --global <text to remember>',
content: t('Usage: /memory add --global <text to remember>'),
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to global memory: "${args.trim()}"`,
text: t('Attempting to save to global memory: "{{text}}"', {
text: args.trim(),
}),
},
Date.now(),
);
@@ -243,13 +281,15 @@ export const memoryCommand: SlashCommand = {
},
{
name: 'refresh',
description: 'Refresh the memory from the source.',
get description() {
return t('Refresh the memory from the source.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Refreshing memory from source files...',
text: t('Refreshing memory from source files...'),
},
Date.now(),
);

View File

@@ -12,10 +12,13 @@ import type {
} from './types.js';
import { CommandKind } from './types.js';
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
import { t } from '../../i18n/index.js';
export const modelCommand: SlashCommand = {
name: 'model',
description: 'Switch the model for this session',
get description() {
return t('Switch the model for this session');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -36,7 +39,7 @@ export const modelCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Content generator configuration not available.',
content: t('Content generator configuration not available.'),
};
}
@@ -45,7 +48,7 @@ export const modelCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Authentication type not available.',
content: t('Authentication type not available.'),
};
}
@@ -55,7 +58,12 @@ export const modelCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: `No models available for the current authentication type (${authType}).`,
content: t(
'No models available for the current authentication type ({{authType}}).',
{
authType,
},
),
};
}

View File

@@ -6,10 +6,13 @@
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const permissionsCommand: SlashCommand = {
name: 'permissions',
description: 'Manage folder trust settings',
get description() {
return t('Manage folder trust settings');
},
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -6,10 +6,13 @@
import { formatDuration } from '../utils/formatters.js';
import { CommandKind, type SlashCommand } from './types.js';
import { t } from '../../i18n/index.js';
export const quitConfirmCommand: SlashCommand = {
name: 'quit-confirm',
description: 'Show quit confirmation dialog',
get description() {
return t('Show quit confirmation dialog');
},
kind: CommandKind.BUILT_IN,
action: (context) => {
const now = Date.now();
@@ -37,7 +40,9 @@ export const quitConfirmCommand: SlashCommand = {
export const quitCommand: SlashCommand = {
name: 'quit',
altNames: ['exit'],
description: 'exit the cli',
get description() {
return t('exit the cli');
},
kind: CommandKind.BUILT_IN,
action: (context) => {
const now = Date.now();

View File

@@ -6,10 +6,13 @@
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const settingsCommand: SlashCommand = {
name: 'settings',
description: 'View and edit Qwen Code settings',
get description() {
return t('View and edit Qwen Code settings');
},
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -20,6 +20,7 @@ import {
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
import { t } from '../../i18n/index.js';
export const GITHUB_WORKFLOW_PATHS = [
'gemini-dispatch/gemini-dispatch.yml',
@@ -91,7 +92,9 @@ export async function updateGitignore(gitRepoRoot: string): Promise<void> {
export const setupGithubCommand: SlashCommand = {
name: 'setup-github',
description: 'Set up GitHub Actions',
get description() {
return t('Set up GitHub Actions');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,

View File

@@ -12,11 +12,14 @@ import {
type SlashCommand,
CommandKind,
} from './types.js';
import { t } from '../../i18n/index.js';
export const statsCommand: SlashCommand = {
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
get description() {
return t('check session stats. Usage: /stats [model|tools]');
},
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => {
const now = new Date();
@@ -25,7 +28,7 @@ export const statsCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Session start time is unavailable, cannot calculate stats.',
text: t('Session start time is unavailable, cannot calculate stats.'),
},
Date.now(),
);
@@ -43,7 +46,9 @@ export const statsCommand: SlashCommand = {
subCommands: [
{
name: 'model',
description: 'Show model-specific usage statistics.',
get description() {
return t('Show model-specific usage statistics.');
},
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => {
context.ui.addItem(
@@ -56,7 +61,9 @@ export const statsCommand: SlashCommand = {
},
{
name: 'tools',
description: 'Show tool-specific usage statistics.',
get description() {
return t('Show tool-specific usage statistics.');
},
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => {
context.ui.addItem(

View File

@@ -13,11 +13,15 @@ import {
} from './types.js';
import { getProjectSummaryPrompt } from '@qwen-code/qwen-code-core';
import type { HistoryItemSummary } from '../types.js';
import { t } from '../../i18n/index.js';
export const summaryCommand: SlashCommand = {
name: 'summary',
description:
'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md',
get description() {
return t(
'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md',
);
},
kind: CommandKind.BUILT_IN,
action: async (context): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
@@ -26,7 +30,7 @@ export const summaryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
content: t('Config not loaded.'),
};
}
@@ -35,7 +39,7 @@ export const summaryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'No chat client available to generate summary.',
content: t('No chat client available to generate summary.'),
};
}
@@ -44,15 +48,18 @@ export const summaryCommand: SlashCommand = {
ui.addItem(
{
type: 'error' as const,
text: 'Already generating summary, wait for previous request to complete',
text: t(
'Already generating summary, wait for previous request to complete',
),
},
Date.now(),
);
return {
type: 'message',
messageType: 'error',
content:
content: t(
'Already generating summary, wait for previous request to complete',
),
};
}
@@ -65,7 +72,7 @@ export const summaryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to summarize.',
content: t('No conversation found to summarize.'),
};
}
@@ -171,9 +178,12 @@ export const summaryCommand: SlashCommand = {
ui.addItem(
{
type: 'error' as const,
text: `Failed to generate project context summary: ${
error instanceof Error ? error.message : String(error)
}`,
text: `${t(
'Failed to generate project context summary: {{error}}',
{
error: error instanceof Error ? error.message : String(error),
},
)}`,
},
Date.now(),
);
@@ -181,9 +191,9 @@ export const summaryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: `Failed to generate project context summary: ${
error instanceof Error ? error.message : String(error)
}`,
content: t('Failed to generate project context summary: {{error}}', {
error: error instanceof Error ? error.message : String(error),
}),
};
}
},

View File

@@ -7,6 +7,7 @@
import type { MessageActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { terminalSetup } from '../utils/terminalSetup.js';
import { t } from '../../i18n/index.js';
/**
* Command to configure terminal keybindings for multiline input support.
@@ -16,8 +17,11 @@ import { terminalSetup } from '../utils/terminalSetup.js';
*/
export const terminalSetupCommand: SlashCommand = {
name: 'terminal-setup',
description:
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)',
get description() {
return t(
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)',
);
},
kind: CommandKind.BUILT_IN,
action: async (): Promise<MessageActionReturn> => {
@@ -27,7 +31,8 @@ export const terminalSetupCommand: SlashCommand = {
let content = result.message;
if (result.requiresRestart) {
content +=
'\n\nPlease restart your terminal for the changes to take effect.';
'\n\n' +
t('Please restart your terminal for the changes to take effect.');
}
return {
@@ -38,7 +43,9 @@ export const terminalSetupCommand: SlashCommand = {
} catch (error) {
return {
type: 'message',
content: `Failed to configure terminal: ${error}`,
content: t('Failed to configure terminal: {{error}}', {
error: String(error),
}),
messageType: 'error',
};
}

View File

@@ -6,10 +6,13 @@
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const themeCommand: SlashCommand = {
name: 'theme',
description: 'change the theme',
get description() {
return t('change the theme');
},
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -10,10 +10,13 @@ import {
CommandKind,
} from './types.js';
import { MessageType, type HistoryItemToolsList } from '../types.js';
import { t } from '../../i18n/index.js';
export const toolsCommand: SlashCommand = {
name: 'tools',
description: 'list available Qwen Code tools. Usage: /tools [desc]',
get description() {
return t('list available Qwen Code tools. Usage: /tools [desc]');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => {
const subCommand = args?.trim();
@@ -29,7 +32,7 @@ export const toolsCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Could not retrieve tool registry.',
text: t('Could not retrieve tool registry.'),
},
Date.now(),
);

View File

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

View File

@@ -6,10 +6,13 @@
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const vimCommand: SlashCommand = {
name: 'vim',
description: 'toggle vim mode on/off',
get description() {
return t('toggle vim mode on/off');
},
kind: CommandKind.BUILT_IN,
action: async (context, _args) => {
const newVimState = await context.ui.toggleVimEnabled();

View File

@@ -13,6 +13,7 @@ import {
getFieldValue,
type SystemInfoField,
} from '../../utils/systemInfoFields.js';
import { t } from '../../i18n/index.js';
type AboutBoxProps = ExtendedSystemInfo;
@@ -30,7 +31,7 @@ export const AboutBox: React.FC<AboutBoxProps> = (props) => {
>
<Box marginBottom={1}>
<Text bold color={theme.text.accent}>
About Qwen Code
{t('About Qwen Code')}
</Text>
</Box>
{fields.map((field: SystemInfoField) => (

View File

@@ -15,6 +15,7 @@ import { SettingScope } from '../../config/settings.js';
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { ScopeSelector } from './shared/ScopeSelector.js';
import { t } from '../../i18n/index.js';
interface ApprovalModeDialogProps {
/** Callback function when an approval mode is selected */
@@ -33,15 +34,15 @@ interface ApprovalModeDialogProps {
const formatModeDescription = (mode: ApprovalMode): string => {
switch (mode) {
case ApprovalMode.PLAN:
return 'Analyze only, do not modify files or execute commands';
return t('Analyze only, do not modify files or execute commands');
case ApprovalMode.DEFAULT:
return 'Require approval for file edits or shell commands';
return t('Require approval for file edits or shell commands');
case ApprovalMode.AUTO_EDIT:
return 'Automatically approve file edits';
return t('Automatically approve file edits');
case ApprovalMode.YOLO:
return 'Automatically approve all tools';
return t('Automatically approve all tools');
default:
return `${mode} mode`;
return t('{{mode}} mode', { mode });
}
};
@@ -134,7 +135,8 @@ export function ApprovalModeDialog({
<Box flexDirection="column" flexGrow={1}>
{/* Approval Mode Selection */}
<Text bold={focusSection === 'mode'} wrap="truncate">
{focusSection === 'mode' ? '> ' : ' '}Approval Mode{' '}
{focusSection === 'mode' ? '> ' : ' '}
{t('Approval Mode')}{' '}
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
</Text>
<Box height={1} />
@@ -167,15 +169,17 @@ export function ApprovalModeDialog({
{showWorkspacePriorityWarning && (
<>
<Text color={theme.status.warning} wrap="wrap">
Workspace approval mode exists and takes priority. User-level
change will have no effect.
{' '}
{t(
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
)}
</Text>
<Box height={1} />
</>
)}
<Text color={theme.text.secondary}>
(Use Enter to select, Tab to change focus)
{t('(Use Enter to select, Tab to change focus)')}
</Text>
</Box>
</Box>

View File

@@ -8,6 +8,7 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
interface AutoAcceptIndicatorProps {
approvalMode: ApprovalMode;
@@ -23,18 +24,18 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
switch (approvalMode) {
case ApprovalMode.PLAN:
textColor = theme.status.success;
textContent = 'plan mode';
subText = ' (shift + tab to cycle)';
textContent = t('plan mode');
subText = ` ${t('(shift + tab to cycle)')}`;
break;
case ApprovalMode.AUTO_EDIT:
textColor = theme.status.warning;
textContent = 'auto-accept edits';
subText = ' (shift + tab to cycle)';
textContent = t('auto-accept edits');
subText = ` ${t('(shift + tab to cycle)')}`;
break;
case ApprovalMode.YOLO:
textColor = theme.status.error;
textContent = 'YOLO mode';
subText = ' (shift + tab to cycle)';
textContent = t('YOLO mode');
subText = ` ${t('(shift + tab to cycle)')}`;
break;
case ApprovalMode.DEFAULT:
default:

View File

@@ -26,6 +26,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { StreamingState } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { t } from '../../i18n/index.js';
export const Composer = () => {
const config = useConfig();
@@ -86,14 +87,16 @@ export const Composer = () => {
)}
{uiState.ctrlCPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+C again to exit.
{t('Press Ctrl+C again to exit.')}
</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+D again to exit.
{t('Press Ctrl+D again to exit.')}
</Text>
) : uiState.showEscapePrompt ? (
<Text color={theme.text.secondary}>Press Esc again to clear.</Text>
<Text color={theme.text.secondary}>
{t('Press Esc again to clear.')}
</Text>
) : (
!settings.merged.ui?.hideContextSummary && (
<ContextSummaryDisplay
@@ -151,8 +154,8 @@ export const Composer = () => {
isEmbeddedShellFocused={uiState.embeddedShellFocused}
placeholder={
vimEnabled
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
: ' Type your message or @path/to/file'
? ' ' + t("Press 'i' for INSERT mode and 'Esc' for NORMAL mode.")
: ' ' + t('Type your message or @path/to/file')
}
/>
)}

View File

@@ -11,15 +11,16 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { type McpClient, MCPServerStatus } from '@qwen-code/qwen-code-core';
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
export const ConfigInitDisplay = () => {
const config = useConfig();
const [message, setMessage] = useState('Initializing...');
const [message, setMessage] = useState(t('Initializing...'));
useEffect(() => {
const onChange = (clients?: Map<string, McpClient>) => {
if (!clients || clients.size === 0) {
setMessage(`Initializing...`);
setMessage(t('Initializing...'));
return;
}
let connected = 0;
@@ -28,7 +29,12 @@ export const ConfigInitDisplay = () => {
connected++;
}
}
setMessage(`Connecting to MCP servers... (${connected}/${clients.size})`);
setMessage(
t('Connecting to MCP servers... ({{connected}}/{{total}})', {
connected: String(connected),
total: String(clients.size),
}),
);
};
appEvents.on('mcp-client-update', onChange);

View File

@@ -13,6 +13,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { t } from '../../i18n/index.js';
interface ContextSummaryDisplayProps {
geminiMdFileCount: number;
@@ -50,9 +51,11 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
if (openFileCount === 0) {
return '';
}
return `${openFileCount} open file${
openFileCount > 1 ? 's' : ''
} (ctrl+g to view)`;
const fileText =
openFileCount === 1
? t('{{count}} open file', { count: String(openFileCount) })
: t('{{count}} open files', { count: String(openFileCount) });
return `${fileText} ${t('(ctrl+g to view)')}`;
})();
const geminiMdText = (() => {
@@ -61,9 +64,15 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
}
const allNamesTheSame = new Set(contextFileNames).size < 2;
const name = allNamesTheSame ? contextFileNames[0] : 'context';
return `${geminiMdFileCount} ${name} file${
geminiMdFileCount > 1 ? 's' : ''
}`;
return geminiMdFileCount === 1
? t('{{count}} {{name}} file', {
count: String(geminiMdFileCount),
name,
})
: t('{{count}} {{name}} files', {
count: String(geminiMdFileCount),
name,
});
})();
const mcpText = (() => {
@@ -73,15 +82,27 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
const parts = [];
if (mcpServerCount > 0) {
parts.push(
`${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`,
);
const serverText =
mcpServerCount === 1
? t('{{count}} MCP server', { count: String(mcpServerCount) })
: t('{{count}} MCP servers', { count: String(mcpServerCount) });
parts.push(serverText);
}
if (blockedMcpServerCount > 0) {
let blockedText = `${blockedMcpServerCount} Blocked`;
let blockedText = t('{{count}} Blocked', {
count: String(blockedMcpServerCount),
});
if (mcpServerCount === 0) {
blockedText += ` MCP server${blockedMcpServerCount > 1 ? 's' : ''}`;
const serverText =
blockedMcpServerCount === 1
? t('{{count}} MCP server', {
count: String(blockedMcpServerCount),
})
: t('{{count}} MCP servers', {
count: String(blockedMcpServerCount),
});
blockedText += ` ${serverText}`;
}
parts.push(blockedText);
}
@@ -89,9 +110,9 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
// Add ctrl+t hint when MCP servers are available
if (mcpServers && Object.keys(mcpServers).length > 0) {
if (showToolDescriptions) {
text += ' (ctrl+t to toggle)';
text += ` ${t('(ctrl+t to toggle)')}`;
} else {
text += ' (ctrl+t to view)';
text += ` ${t('(ctrl+t to view)')}`;
}
}
return text;
@@ -102,7 +123,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
if (isNarrow) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>Using:</Text>
<Text color={theme.text.secondary}>{t('Using:')}</Text>
{summaryParts.map((part, index) => (
<Text key={index} color={theme.text.secondary}>
{' '}- {part}
@@ -115,7 +136,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
return (
<Box>
<Text color={theme.text.secondary}>
Using: {summaryParts.join(' | ')}
{t('Using:')} {summaryParts.join(' | ')}
</Text>
</Box>
);

View File

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

View File

@@ -19,6 +19,7 @@ import { SettingScope } from '../../config/settings.js';
import type { EditorType } from '@qwen-code/qwen-code-core';
import { isEditorAvailable } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
interface EditorDialogProps {
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
@@ -66,12 +67,16 @@ export function EditorSettingsDialog({
const scopeItems = [
{
label: 'User Settings',
get label() {
return t('User Settings');
},
value: SettingScope.User,
key: SettingScope.User,
},
{
label: 'Workspace Settings',
get label() {
return t('Workspace Settings');
},
value: SettingScope.Workspace,
key: SettingScope.Workspace,
},
@@ -145,7 +150,8 @@ export function EditorSettingsDialog({
<Box marginTop={1} flexDirection="column">
<Text bold={focusedSection === 'scope'}>
{focusedSection === 'scope' ? '> ' : ' '}Apply To
{focusedSection === 'scope' ? '> ' : ' '}
{t('Apply To')}
</Text>
<RadioButtonSelect
items={scopeItems}

View File

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

View File

@@ -8,6 +8,7 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { type SlashCommand, CommandKind } from '../commands/types.js';
import { t } from '../../i18n/index.js';
interface Help {
commands: readonly SlashCommand[];
@@ -23,46 +24,41 @@ export const Help: React.FC<Help> = ({ commands }) => (
>
{/* Basics */}
<Text bold color={theme.text.primary}>
Basics:
{t('Basics:')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Add context
{t('Add context')}
</Text>
: Use{' '}
<Text bold color={theme.text.accent}>
@
</Text>{' '}
to specify files for context (e.g.,{' '}
<Text bold color={theme.text.accent}>
@src/myFile.ts
</Text>
) to target specific files or folders.
:{' '}
{t(
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.',
{
symbol: t('@'),
example: t('@src/myFile.ts'),
},
)}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Shell mode
{t('Shell mode')}
</Text>
: Execute shell commands via{' '}
<Text bold color={theme.text.accent}>
!
</Text>{' '}
(e.g.,{' '}
<Text bold color={theme.text.accent}>
!npm run start
</Text>
) or use natural language (e.g.{' '}
<Text bold color={theme.text.accent}>
start server
</Text>
).
:{' '}
{t(
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).',
{
symbol: t('!'),
example1: t('!npm run start'),
example2: t('start server'),
},
)}
</Text>
<Box height={1} />
{/* Commands */}
<Text bold color={theme.text.primary}>
Commands:
{t('Commands:')}
</Text>
{commands
.filter((command) => command.description && !command.hidden)
@@ -71,7 +67,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{' '}
/{command.name}
{formatCommandLabel(command, '/')}
</Text>
{command.kind === CommandKind.MCP_PROMPT && (
<Text color={theme.text.secondary}> [MCP]</Text>
@@ -85,7 +81,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Text key={subCommand.name} color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{' '}
{subCommand.name}
{formatCommandLabel(subCommand)}
</Text>
{subCommand.description && ' - ' + subCommand.description}
</Text>
@@ -97,81 +93,95 @@ export const Help: React.FC<Help> = ({ commands }) => (
{' '}
!{' '}
</Text>
- shell command
- {t('shell command')}
</Text>
<Text color={theme.text.primary}>
<Text color={theme.text.secondary}>[MCP]</Text> - Model Context Protocol
command (from external servers)
<Text color={theme.text.secondary}>[MCP]</Text> -{' '}
{t('Model Context Protocol command (from external servers)')}
</Text>
<Box height={1} />
{/* Shortcuts */}
<Text bold color={theme.text.primary}>
Keyboard Shortcuts:
{t('Keyboard Shortcuts:')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Alt+Left/Right
</Text>{' '}
- Jump through words in the input
- {t('Jump through words in the input')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+C
</Text>{' '}
- Close dialogs, cancel requests, or quit application
- {t('Close dialogs, cancel requests, or quit application')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'}
</Text>{' '}
-{' '}
{process.platform === 'linux'
? '- New line (Alt+Enter works for certain linux distros)'
: '- New line'}
? t('New line (Alt+Enter works for certain linux distros)')
: t('New line')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+L
</Text>{' '}
- Clear the screen
- {t('Clear the screen')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
</Text>{' '}
- Open input in external editor
- {t('Open input in external editor')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Enter
</Text>{' '}
- Send message
- {t('Send message')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Esc
</Text>{' '}
- Cancel operation / Clear input (double press)
- {t('Cancel operation / Clear input (double press)')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Shift+Tab
</Text>{' '}
- Cycle approval modes
- {t('Cycle approval modes')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Up/Down
</Text>{' '}
- Cycle through your prompt history
- {t('Cycle through your prompt history')}
</Text>
<Box height={1} />
<Text color={theme.text.primary}>
For a full list of shortcuts, see{' '}
<Text bold color={theme.text.accent}>
docs/keyboard-shortcuts.md
</Text>
{t('For a full list of shortcuts, see {{docPath}}', {
docPath: t('docs/keyboard-shortcuts.md'),
})}
</Text>
</Box>
);
/**
* Builds a display label for a slash command, including any alternate names.
*/
function formatCommandLabel(command: SlashCommand, prefix = ''): string {
const altNames = command.altNames?.filter(Boolean);
const baseLabel = `${prefix}${command.name}`;
if (!altNames || altNames.length === 0) {
return baseLabel;
}
return `${baseLabel} (${altNames.join(', ')})`;
}

View File

@@ -66,20 +66,6 @@ const mockSlashCommands: SlashCommand[] = [
},
],
},
{
name: 'chat',
description: 'Manage chats',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'resume',
description: 'Resume a chat',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
completion: async () => ['fix-foo', 'fix-bar'],
},
],
},
];
describe('InputPrompt', () => {
@@ -210,6 +196,7 @@ describe('InputPrompt', () => {
inputWidth: 80,
suggestionsWidth: 80,
focus: true,
placeholder: ' Type your message or @path/to/file',
};
});
@@ -570,14 +557,14 @@ describe('InputPrompt', () => {
});
it('should complete a partial argument for a command', async () => {
// SCENARIO: /chat resume fi- -> Tab
// SCENARIO: /memory add fi- -> Tab
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
activeSuggestionIndex: 0,
});
props.buffer.setText('/chat resume fi-');
props.buffer.setText('/memory add fi-');
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
@@ -1950,7 +1937,7 @@ describe('InputPrompt', () => {
unmount();
});
it('expands and collapses long suggestion via Right/Left arrows', async () => {
it.skip('expands and collapses long suggestion via Right/Left arrows', async () => {
props.shellModeActive = false;
const longValue = 'l'.repeat(200);

View File

@@ -27,6 +27,7 @@ import {
parseInputForHighlighting,
buildSegmentsForVisualSlice,
} from '../utils/highlight.js';
import { t } from '../../i18n/index.js';
import {
clipboardHasImage,
saveClipboardImage,
@@ -88,7 +89,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
config,
slashCommands,
commandContext,
placeholder = ' Type your message or @path/to/file',
placeholder,
focus = true,
suggestionsWidth,
shellModeActive,
@@ -697,13 +698,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
let statusText = '';
if (shellModeActive) {
statusColor = theme.ui.symbol;
statusText = 'Shell mode';
statusText = t('Shell mode');
} else if (showYoloStyling) {
statusColor = theme.status.error;
statusText = 'YOLO mode';
statusText = t('YOLO mode');
} else if (showAutoAcceptStyling) {
statusColor = theme.status.warning;
statusText = 'Accepting edits';
statusText = t('Accepting edits');
}
return (

View File

@@ -14,6 +14,7 @@ import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { t } from '../../i18n/index.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
@@ -40,7 +41,12 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
? t('(esc to cancel, {{time}})', {
time:
elapsedTime < 60
? `${elapsedTime}s`
: formatDuration(elapsedTime * 1000),
})
: null;
return (

View File

@@ -20,6 +20,7 @@ import {
getAvailableModelsForAuthType,
MAINLINE_CODER,
} from '../models/availableModels.js';
import { t } from '../../i18n/index.js';
interface ModelDialogProps {
onClose: () => void;
@@ -87,7 +88,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
padding={1}
width="100%"
>
<Text bold>Select Model</Text>
<Text bold>{t('Select Model')}</Text>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={MODEL_OPTIONS}
@@ -97,7 +98,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
/>
</Box>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>(Press Esc to close)</Text>
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
</Box>
</Box>
);

View File

@@ -15,6 +15,7 @@ import {
} from '../utils/computeStats.js';
import type { ModelMetrics } from '../contexts/SessionContext.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { t } from '../../i18n/index.js';
const METRIC_COL_WIDTH = 28;
const MODEL_COL_WIDTH = 22;
@@ -65,7 +66,7 @@ export const ModelStatsDisplay: React.FC = () => {
paddingX={2}
>
<Text color={theme.text.primary}>
No API calls have been made in this session.
{t('No API calls have been made in this session.')}
</Text>
</Box>
);
@@ -94,7 +95,7 @@ export const ModelStatsDisplay: React.FC = () => {
paddingX={2}
>
<Text bold color={theme.text.accent}>
Model Stats For Nerds
{t('Model Stats For Nerds')}
</Text>
<Box height={1} />
@@ -102,7 +103,7 @@ export const ModelStatsDisplay: React.FC = () => {
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text bold color={theme.text.primary}>
Metric
{t('Metric')}
</Text>
</Box>
{modelNames.map((name) => (
@@ -125,13 +126,13 @@ export const ModelStatsDisplay: React.FC = () => {
/>
{/* API Section */}
<StatRow title="API" values={[]} isSection />
<StatRow title={t('API')} values={[]} isSection />
<StatRow
title="Requests"
title={t('Requests')}
values={getModelValues((m) => m.api.totalRequests.toLocaleString())}
/>
<StatRow
title="Errors"
title={t('Errors')}
values={getModelValues((m) => {
const errorRate = calculateErrorRate(m);
return (
@@ -146,7 +147,7 @@ export const ModelStatsDisplay: React.FC = () => {
})}
/>
<StatRow
title="Avg Latency"
title={t('Avg Latency')}
values={getModelValues((m) => {
const avgLatency = calculateAverageLatency(m);
return formatDuration(avgLatency);
@@ -156,9 +157,9 @@ export const ModelStatsDisplay: React.FC = () => {
<Box height={1} />
{/* Tokens Section */}
<StatRow title="Tokens" values={[]} isSection />
<StatRow title={t('Tokens')} values={[]} isSection />
<StatRow
title="Total"
title={t('Total')}
values={getModelValues((m) => (
<Text color={theme.status.warning}>
{m.tokens.total.toLocaleString()}
@@ -166,13 +167,13 @@ export const ModelStatsDisplay: React.FC = () => {
))}
/>
<StatRow
title="Prompt"
title={t('Prompt')}
isSubtle
values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
/>
{hasCached && (
<StatRow
title="Cached"
title={t('Cached')}
isSubtle
values={getModelValues((m) => {
const cacheHitRate = calculateCacheHitRate(m);
@@ -186,20 +187,20 @@ export const ModelStatsDisplay: React.FC = () => {
)}
{hasThoughts && (
<StatRow
title="Thoughts"
title={t('Thoughts')}
isSubtle
values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
/>
)}
{hasTool && (
<StatRow
title="Tool"
title={t('Tool')}
isSubtle
values={getModelValues((m) => m.tokens.tool.toLocaleString())}
/>
)}
<StatRow
title="Output"
title={t('Output')}
isSubtle
values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
/>

Some files were not shown because too many files have changed in this diff Show More