Compare commits

...

297 Commits

Author SHA1 Message Date
tanzhenxin
4cbb57a793 Merge pull request #1249 from QwenLM/feat/vscode-ide-companion-package-script
fix(vscode-ide-companion): improve cross-platform compatibility in prepackage script
2025-12-14 00:02:05 +08:00
yiliang114
9280739a85 fix(vscode-ide-companion): improve cross-platform compatibility in prepackage script
- Enable shell option conditionally for Windows platform
- Use 'node' command directly and template literals for path handling
2025-12-14 00:00:06 +08:00
tanzhenxin
b6128ef2d2 Merge pull request #1248 from QwenLM/feat/vscode-ide-companion-borading
refactor(vscode-ide-companion): optimize CLI detection and version management
2025-12-13 23:37:06 +08:00
tanzhenxin
f7ef720e3b test 2025-12-13 23:18:49 +08:00
tanzhenxin
0e6ebe85e4 test 2025-12-13 23:04:00 +08:00
yiliang114
3138aa1fe3 chore(vscode-ide-companion): wip 2025-12-13 22:30:10 +08:00
yiliang114
0f2f1faee5 chore(vscode-ide-companion): wip 2025-12-13 22:19:54 +08:00
yiliang114
641dd03689 chore(vscode-ide-companion): wip 2025-12-13 22:03:16 +08:00
yiliang114
44fef93399 chore(vscode-ide-companion): wip 2025-12-13 21:39:41 +08:00
yiliang114
b073b2db79 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 21:37:18 +08:00
yiliang114
ccc6192164 chore(vscode-ide-companion): wip 2025-12-13 21:37:10 +08:00
tanzhenxin
a5f8c66c35 Merge pull request #1246 from QwenLM/feat/bundle-cli-in-vscode
Bundle CLI into VSCode release package
2025-12-13 21:25:58 +08:00
yiliang114
c8d18591b0 Merge branch 'feat/bundle-cli-in-vscode' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 21:21:53 +08:00
tanzhenxin
e9036daa8d fix failed unit tests 2025-12-13 21:14:05 +08:00
tanzhenxin
8e29cc88f9 bundle cli in vscode 2025-12-13 20:56:18 +08:00
yiliang114
61ce586117 refactor(vscode-ide-companion/cli): consolidate CLI detection and version management
- Replace separate CliDetector, CliVersionChecker, and CliVersionManager classes with unified CliManager
- Remove redundant code and simplify CLI detection logic
- Maintain all existing functionality while improving code organization
- Update imports in dependent files to use CliManager

This change reduces complexity by consolidating CLI-related functionality into a single manager class.
2025-12-13 20:42:59 +08:00
yiliang114
90fc4c33f0 fix(vscode-ide-companion/session): improve timeout configuration for different methods
Extend timeout duration to 2 minutes for both session_prompt and initialize methods
to prevent timeouts during longer operations. Default timeout remains at 60 seconds
for other methods.

This change improves reliability of session management by providing adequate
time for initialization and prompt operations to complete.
2025-12-13 20:06:02 +08:00
yiliang114
389d8dd9c4 Remove CLI version checker and status bar display, revert to original notification approach
This change removes the CliVersionChecker class and all related status bar functionality, reverting to the original approach that uses vscode.window.showInformationMessage for version-related notifications, as was implemented in the main branch.

The changes include:
1. Removing CliVersionChecker import
2. Removing status bar item creation and update logic
3. Removing CLI version check on activation
4. Removing showCliVersionInfo command

This addresses the issue where version detection notifications should use vscode.window.showInformationMessage instead of status bar display.
2025-12-13 19:49:04 +08:00
yiliang114
4590138a1e fix(vscode-ide-companion/cli): improve Windows compatibility for shell commands
fix(vscode-ide-companion/session): improve timeout configuration for different methods
2025-12-13 18:30:14 +08:00
yiliang114
0ac191e2db chore(vscode-ide-companion): wip 2025-12-13 17:50:15 +08:00
yiliang114
9e392b3035 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 17:40:16 +08:00
易良
65796e2799 Fix/vscode ide companion completion menu content (#1243)
* refactor(vscode-ide-companion/types): move ApprovalModeValue type to dedicated file

feat(vscode-ide-companion/file-context): improve file context handling and search

Enhance file context hook to better handle search queries and reduce redundant requests.
Track last query to optimize when to refetch full file list.
Improve logging for debugging purposes.

* feat(vscode-ide-companion/completion): enhance completion menu performance and refresh logic

Implement item comparison to prevent unnecessary re-renders when completion items
haven't actually changed. Optimize refresh logic to only trigger when workspace
files content changes. Improve completion menu stability and responsiveness.

refactor(vscode-ide-companion/handlers): remove SettingsMessageHandler and consolidate functionality

Move setApprovalMode functionality from SettingsMessageHandler to SessionMessageHandler
to reduce code duplication and simplify message handling architecture. Remove unused
settings-related imports and clean up message router configuration.

chore(vscode-ide-companion/ui): minor UI improvements and code cleanup

Consolidate imports in SessionSelector component.
Remove debug console log statement from FileMessageHandler.
Move getTimeAgo utility function to sessionGrouping file and remove obsolete timeUtils file.
Clean up completion menu CSS classes.

* fix(vscode-ide-companion): resolve all ESLint errors

Fixed unused variable errors in SessionMessageHandler.ts:
- Commented out unused conversation and messages variables

Also includes previous commits:
1. feat(vscode-ide-companion): add upgrade button to CLI version warning
2. fix(vscode-ide-companion): resolve ESLint errors in InputForm component

When the Qwen Code CLI version is below the minimum required version,
the warning message now includes an "Upgrade Now" button that opens
a terminal and runs the npm install command to upgrade the CLI.

Added tests to verify the functionality works correctly.
2025-12-13 17:37:45 +08:00
yiliang114
8fdcd53f53 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 17:05:40 +08:00
pomelo
693a58d517 Merge pull request #1194 from afarber/add-audio-notification-bell
feat: add terminal bell setting to enable/disable audio notifications
2025-12-13 17:03:35 +08:00
pomelo
c7f4c462f4 Merge pull request #1219 from afarber/1181-resume-session-id
feat: show session resume command on exit
2025-12-13 17:02:16 +08:00
yiliang114
c94708e448 Implement authenticate/update message handling for Qwen OAuth authentication
- Added authenticate_update method to ACP schema constants
- Added AuthenticateUpdateNotification type definitions
- Updated ACP message handler to process authenticate/update notifications
- Added VS Code notification handler with 'Open in Browser' and 'Copy Link' options
- Integrated authenticate update handling in QwenAgentManager

This implementation allows users to easily authenticate with Qwen OAuth when automatic browser launch fails by providing a notification with direct link and copy functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 16:59:30 +08:00
tanzhenxin
0b854e494f Merge pull request #1245 from QwenLM/feat/remove-corgi-mode
Remove obsolete “corgi mode”
2025-12-13 16:57:10 +08:00
yiliang114
f6f4b24356 Optimize CLI version warning to avoid repetitive notifications
- Added tracking mechanism to prevent showing the same version warning multiple times
- This resolves the issue where users were getting frequent warnings when opening new sessions
- Kept the existing cache mechanism for version detection performance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 16:49:43 +08:00
yiliang114
bc5dd87eb4 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 16:44:29 +08:00
tanzhenxin
f8aeb06823 remove obsolete corgi mode 2025-12-13 16:41:03 +08:00
yiliang114
bca288e742 wip(vscode-ide-companion): OnboardingPage 2025-12-13 16:28:58 +08:00
yiliang114
5841370b1a wip(vscode-ide-companion): OnboardingPage 2025-12-13 15:51:34 +08:00
tanzhenxin
0d90d5c118 Merge pull request #1240 from QwenLM/feat/acp-authenticate-update
Add ACP authenticate update message
2025-12-13 14:41:59 +08:00
pomelo
cc0d688c8b Merge pull request #1235 from afarber/501-remove-duplicated-if-check
fix: remove redundant if-check and add tests for OpenAI converter
2025-12-13 12:07:24 +08:00
tanzhenxin
4eb7aa5448 add acp authenticate update message 2025-12-13 12:01:46 +08:00
pomelo
9978fe107b Merge pull request #1236 from afarber/1102-add-slash-language
test(cli): add tests for /language command and fix LLM output language parsing
2025-12-13 12:00:18 +08:00
pomelo
b95bb2cd95 Merge pull request #1238 from fazilus/feat/russian
feat(i18n): add Russian language support
2025-12-13 11:44:02 +08:00
yiliang114
e895c49f5c fix(vscode-ide-companion): resolve all ESLint errors
Fixed unused variable errors in SessionMessageHandler.ts:
- Commented out unused conversation and messages variables

Also includes previous commits:
1. feat(vscode-ide-companion): add upgrade button to CLI version warning
2. fix(vscode-ide-companion): resolve ESLint errors in InputForm component

When the Qwen Code CLI version is below the minimum required version,
the warning message now includes an "Upgrade Now" button that opens
a terminal and runs the npm install command to upgrade the CLI.

Added tests to verify the functionality works correctly.
2025-12-13 09:56:18 +08:00
yiliang114
3191cf73b3 feat(vscode-ide-companion/completion): enhance completion menu performance and refresh logic
Implement item comparison to prevent unnecessary re-renders when completion items
haven't actually changed. Optimize refresh logic to only trigger when workspace
files content changes. Improve completion menu stability and responsiveness.

refactor(vscode-ide-companion/handlers): remove SettingsMessageHandler and consolidate functionality

Move setApprovalMode functionality from SettingsMessageHandler to SessionMessageHandler
to reduce code duplication and simplify message handling architecture. Remove unused
settings-related imports and clean up message router configuration.

chore(vscode-ide-companion/ui): minor UI improvements and code cleanup

Consolidate imports in SessionSelector component.
Remove debug console log statement from FileMessageHandler.
Move getTimeAgo utility function to sessionGrouping file and remove obsolete timeUtils file.
Clean up completion menu CSS classes.
2025-12-13 09:19:18 +08:00
yiliang114
f5306339f6 refactor(vscode-ide-companion/types): move ApprovalModeValue type to dedicated file
feat(vscode-ide-companion/file-context): improve file context handling and search

Enhance file context hook to better handle search queries and reduce redundant requests.
Track last query to optimize when to refetch full file list.
Improve logging for debugging purposes.
2025-12-13 09:19:09 +08:00
Fazil
026fd468b1 feat(i18n): add Russian language support 2025-12-12 21:12:37 +03:00
Alexander Farber
d25af87eaf Fix the copyright 2025-12-12 15:46:37 +01:00
Alexander Farber
a5039d15bf Revert to SupportedLanguage 2025-12-12 15:36:50 +01:00
Alexander Farber
3ff916a5f1 Add tests for /language command and fix regex parsing 2025-12-12 15:28:43 +01:00
Alexander Farber
7bb9bc1e5e Remove redundant if-check and add tests for tool conversion in converter.ts 2025-12-12 14:29:37 +01:00
tanzhenxin
8b29dd130e Merge pull request #1233 from QwenLM/chore/v0.5.0
pump version to 0.5.0
2025-12-12 16:32:13 +08:00
tanzhenxin
d0be8b43d7 pump version to 0.5.0 2025-12-12 16:29:50 +08:00
tanzhenxin
3095442eb3 Merge pull request #1223 from QwenLM/fix/vscode-ide-companion-login-twice
fix(vscode-ide-companion/auth): deduplicate concurrent authentication calls
2025-12-12 16:19:25 +08:00
tanzhenxin
2ceecab503 Merge pull request #1226 from QwenLM/feat/support-channel-field
feat: Add channel field support for client identification
2025-12-12 16:16:36 +08:00
pomelo
e5ed0334ab Merge pull request #1230 from BlockHand/docker-ide 2025-12-12 16:16:23 +08:00
刘伟光
2b62b1e8bc feat: 将注释修改成英文 2025-12-12 14:40:30 +08:00
yiliang114
89be6edb5e chore(vscode-ide-companion): add comment 2025-12-12 13:59:05 +08:00
yiliang114
d812c9dcf2 chore(vscode-ide-companion): add fixme comment for auth delay 2025-12-12 13:51:14 +08:00
yiliang114
d754767e73 chore(vscode-ide-companion): rm authState manager in vscode-ide-companion to simplify the login architecture 2025-12-12 13:40:18 +08:00
刘伟光
bb8447edd7 fix: 修复在docker环境中无法连接ide的问题 2025-12-12 11:36:15 +08:00
yiliang114
02234f5434 chore(vscode-ide-companion): change comments and delays 2025-12-12 01:17:38 +08:00
yiliang114
25261ab88d fix(vscode-ide-companion): slight delay to ensure auth state settlement 2025-12-12 01:14:28 +08:00
DragonnZhang
60a58ad8e5 feat: add support for the channel field to CLI parameters and configurations 2025-12-12 01:06:00 +08:00
yiliang114
c20df192a8 chore(vscode-ide-companion): revert some log util, will continue next time 2025-12-11 23:57:21 +08:00
yiliang114
b34894c8ea feat(vscode-ide-companion/auth): deduplicate concurrent authentication calls
Prevent multiple simultaneous authentication flows by:
- Adding static authInFlight promise tracking in AcpConnection
- Implementing runExclusiveAuth method in AuthStateManager
- Adding sessionCreateInFlight tracking in QwenAgentManager
- Ensuring only one auth flow runs at a time across different components

This prevents race conditions and duplicate login prompts when multiple components request authentication simultaneously.
2025-12-11 22:56:58 +08:00
Alexander Farber
ba3b576906 Hide resume message when session has no messages 2025-12-11 13:10:01 +01:00
Alexander Farber
b67ee32481 Update snapshots 2025-12-11 12:53:30 +01:00
Alexander Farber
5b8ce440ea Show session resume command on exit 2025-12-11 12:22:48 +01:00
tanzhenxin
58d3a9c253 Merge pull request #1176 from QwenLM/feat/acp-usage-metadata
Feat/acp usage metadata
2025-12-11 14:09:43 +08:00
TianHao Zhang
d06a6d7ef9 feat: update references from Gemini to Qwen in setup commands and gitignore handling (#1156) 2025-12-11 14:09:22 +08:00
pomelo
ae9753a326 Merge pull request #1189 from afarber/security-fixes
fix: update vulnerable dependencies (glob, jws, tar, js-yaml)
2025-12-11 09:42:30 +08:00
pomelo
a02c4b2765 Merge pull request #1059 from yiliang114/feat/jinjing/write-and-read-file-in-vscode
feat: VSCode Extension Implementation
2025-12-10 16:15:28 +08:00
pomelo
0055399cba Merge pull request #1191 from afarber/remove-vertical-borders
feat(ui): remove vertical borders from input prompt for easier copy/paste
2025-12-10 16:08:04 +08:00
Alexander Farber
5f78909040 Add terminal bell setting to enable/disable audio notifications 2025-12-09 20:27:20 +01:00
yiliang114
5ef3d32f16 refactor(vscode-ide-companion/qwenAgentManager): remove saveCheckpointViaCommand method
refactor(vscode-ide-companion/webview): improve message handling during checkpoint saves
feat(vscode-ide-companion/markdown): enhance file path link handling with line numbers support
feat(vscode-ide-companion/message): add enableFileLinks prop to MessageContent component
feat(vscode-ide-companion/user-message): disable file links in user messages
2025-12-10 01:31:39 +08:00
xuewenjie
49c032492a fix: prefer UTF-8 encoding for shell output on Windows when detected
pump versionm to 0.4.1 (#1177)
2025-12-10 01:31:32 +08:00
yiliang114
4345b9370e feat(vscode-ide-companion): enhance panel manager group tracking and ui improvements
chore(vscode-ide-companion): remove unused todo comment in auth state manager
2025-12-10 01:31:27 +08:00
yiliang114
d2e2a07327 chore(vscode-ide-companion): bump version to 0.4.1 and add semver dependency and improve cli version checking with semver package 2025-12-10 01:31:09 +08:00
Alexander Farber
5b74422be6 Do not skip the tests 2025-12-09 16:46:18 +01:00
Alexander Farber
06c398a015 Remove vertical borders from input prompt for easier copy/paste 2025-12-09 16:37:32 +01:00
Alexander Farber
aec5d6463a Update vulnerable dependencies (glob, jws, tar, js-yaml) 2025-12-09 15:14:20 +01:00
yiliang114
29032d2c6a chore(vscode-ide-companion): bump version to 0.4.1 2025-12-09 21:48:40 +08:00
yiliang114
e91ea3ac1a refactor(vscode-ide-companion): simplify openDiff tool implementation
- Remove redundant file reading logic from ide-server
- Leverage diffManager's new capability to read old content internally
- Simplify openDiff tool call site to pass only newContent
- Update comments to reflect the simplified implementation
2025-12-09 15:53:41 +08:00
yiliang114
f2a74c74b6 feat(vscode-ide-companion): enhance panel manager group tracking and cleanup
- Add panelGroupViewColumn tracking for precise group identification
- Implement improved tab capture mechanism with delayed execution
- Add robust group cleanup logic to close empty locked columns
- Implement focusGroupByColumn helper for reliable group focusing
- Add proper error handling and fallback mechanisms for group operations
2025-12-09 15:53:19 +08:00
yiliang114
21651410c8 feat(vscode-ide-companion): update approval mode cycling behavior
- Add NEXT_APPROVAL_MODE constant for cyclic mode mapping
- Remove 'plan' mode from public toggle sequence
- Simplify handleToggleEditMode implementation using NEXT_APPROVAL_MODE mapping
- Update import statements to include NEXT_APPROVAL_MODE
- Remove unused comment about plan mode in toggle sequence
2025-12-09 15:53:06 +08:00
yiliang114
09cefbcf67 feat(vscode-ide-companion): add showDiff overload and file reading capability
- Add overloaded showDiff method to support calling with only newContent
- Implement readOldContentFromFs helper to read existing file content
- Simplify IDE server openDiff tool to use minimal call site
- Improve diff manager documentation and code clarity
2025-12-09 15:52:52 +08:00
tanzhenxin
5fddcd509c pump versionm to 0.4.1 (#1177) 2025-12-09 10:25:07 +08:00
tanzhenxin
d7b9466516 #1129, add usage update in ACP mode 2025-12-09 09:58:19 +08:00
yiliang114
fcd4bb9c03 style(vscode-ide-companion): adjust EmptyState component layout
- Remove max-width constraint from EmptyState component wrapper
- Improve responsive layout for empty state view
2025-12-09 00:15:56 +08:00
yiliang114
828b760820 refactor(vscode-ide-companion): clean up code and improve type safety
- Remove commented out error handling code in acpMessageHandler
- Simplify session update handler by removing redundant comments
- Clean up chat types interface definitions
- Simplify null check in MessageRouter
- Improve type safety in SettingsMessageHandler with ApprovalModeValue type
2025-12-09 00:15:44 +08:00
yiliang114
ef3d7b92d0 feat(vscode-ide-companion): improve message handling and diff auto-opening
- Ignore messages during checkpoint saves in WebViewProvider
- Move diff auto-opening logic from useEffect to useWebViewMessages hook
- Remove unused imports and variables in EditToolCall component
- Enhance tool call handling for edit operations with diff content
2025-12-09 00:15:30 +08:00
yiliang114
58b9e477bc feat(vscode-ide-companion): implement session rehydration for loading past conversations
- Add rehydratingSessionId flag to track session loading state
- Route message chunks as discrete messages during rehydration instead of streaming
- Update session handlers to properly manage conversation switching
- Improve session manager documentation
2025-12-09 00:14:35 +08:00
yiliang114
f4edcc5cd2 fix(vscode-ide-companion): global auth state 2025-12-08 23:38:16 +08:00
yiliang114
7adb9ed7ff refactor(vscode-ide-companion): introduce ApprovalMode enum and improve mode handling
- Create new approvalModeTypes.ts with ApprovalMode enum and helper functions
- Replace hardcoded string literals with ApprovalModeValue type
- Consolidate mode mapping logic using new helper functions
2025-12-08 23:12:04 +08:00
yiliang114
f146f062cb chore(vscode-ide-companion): rm markdown code copy button 2025-12-08 21:00:29 +08:00
yiliang114
111234eb24 refactor(vscode-ide-companion): simplify ACP connection and cleanup configurations
- Remove .claude from .gitignore
- Update CSS file path in eslint config
- Simplify VS Code extension title
- Remove unused keybinding for openChat command
- Delete unused auth constants file
- Simplify ACP connection by removing backend parameter
- Move authMethod to acpTypes
- Restrict ACP backend to Qwen only
- Remove backend property from connection state
- Minor formatting update in webview index.tsx
2025-12-08 20:44:48 +08:00
pomelo
a6a572336c Merge pull request #1157 from QwenLM/fix/windows-shell-output-garbled 2025-12-08 16:45:05 +08:00
yiliang114
96cd685b1b chore(vscode-ide-companion): rm comment about claude 2025-12-08 13:04:06 +08:00
yiliang114
e8b4ee111c fix(vscode-ide-companion): rm useless component 2025-12-08 12:58:05 +08:00
yiliang114
ac0d5206ba chore(vscode-ide-companion): rm useTerminal & some useless code about openDiff 2025-12-08 12:46:42 +08:00
xuewenjie
e5e1e6a3da Merge branch 'main' of github.com:QwenLM/qwen-code into fix/windows-shell-output-garbled 2025-12-08 11:35:47 +08:00
yiliang114
6269415e7b chore(vscode-ide-companion): reorder dlv dependency in package-lock.json 2025-12-08 11:31:58 +08:00
yiliang114
efccd44cb4 fix(vscode-ide-companion/permission-drawer): improve ref typing for input element 2025-12-08 11:31:50 +08:00
tanzhenxin
efbf50554d Merge branch 'main' into feat/acp-usage-metadata 2025-12-08 10:27:38 +08:00
yiliang114
63e4794633 chore(vscode-ide-companion): temporarily annotate some logic to suppress showDiff 2025-12-08 10:18:55 +08:00
yiliang114
be71976a1f chore(vscode-ide-companion): refactor directory structure 2025-12-08 00:54:26 +08:00
yiliang114
e47263f7c9 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/write-and-read-file-in-vscode 2025-12-07 23:05:36 +08:00
yiliang114
51b4de0c23 fix(vscode-ide-companion): resolve ESLint errors and improve code quality
- Fix unused variable issues by removing unused variables and renaming caught errors to match ESLint rules
- Fix TypeScript type mismatches in mode handling
- Add missing curly braces to if statements to comply with ESLint rules
- Resolve missing dependency warnings in React hooks
- Clean up empty catch blocks by adding appropriate comments
- Remove unused _lastEditorState variables that were declared but never read

These changes ensure the codebase passes ESLint checks and follows best practices for code quality.
2025-12-07 16:57:40 +08:00
yiliang114
67eee14ca9 style(vscode-ide-companion): adjust chat session initialization logic and optimize tool invocation component style 2025-12-06 22:44:31 +08:00
yiliang114
ed44520e51 fix(vscode-ide-companion): clean up deprecated permission drawer file
Remove obsolete PermissionDrawer.tsx file that has been replaced by the new directory structure
2025-12-06 21:46:31 +08:00
yiliang114
7cd26f728d feat(vscode-ide-companion): implement session message handling and UI improvements
Complete session message handling with JSONL support and UI enhancements

- Add JSONL session file reading capability

- Improve error handling and authentication flows

- Update UI components for better user experience

- Fix command identifier references

- Enhance MarkdownRenderer with copy functionality

- Update Tailwind configuration for better component coverage
2025-12-06 21:46:14 +08:00
yiliang114
ad79b9bcab refactor(vscode-ide-companion): restructure tool call components
Restructure tool call components with dedicated container implementations

- Move tool call components to done subdirectory

- Implement specialized ToolCallContainer for each tool type

- Update component routing in ToolCallRouter

- Add isFirst/isLast props for better layout control

- Improve shared types and layout components
2025-12-06 21:45:51 +08:00
yiliang114
ad301963a6 feat(vscode-ide-companion): enhance session management with pagination support
Implement cursor-based pagination for session listing and improve session handling

- Add pagination state management in useSessionManagement hook

- Implement handleLoadMoreSessions for infinite scrolling

- Update SessionMessageHandler to support paged session requests

- Add ChatHeader component for improved UI layout

- Fix session title duplication issue

- Improve error handling in session operations
2025-12-06 21:45:36 +08:00
yiliang114
e538a3d1bf fix(vscode-ide-companion): improve ACP connection and session management
Enhance session/load and session/list ACP methods with proper cwd handling and pagination support

- Add workingDir tracking in AcpConnection

- Improve parameter handling in loadSession and listSessions

- Add pagination support for session listing

- Fix null/undefined checks in message handling
2025-12-06 21:45:18 +08:00
yiliang114
413c143004 feat(vscode-ide-companion): update command identifiers to use kebab-case
Standardize command naming convention by changing qwenCode.* to qwen-code.* in package.json configuration
2025-12-06 21:45:04 +08:00
Mingholy
b4be2c6c7f Merge pull request #1166 from QwenLM/mingholy/fix/unstable-e2e-test
test: skip unstable e2e test
2025-12-06 18:42:29 +08:00
mingholy.lmh
8b5b8d2b90 test: skip unstable e2e test 2025-12-06 18:41:19 +08:00
Mingholy
6e826b815e Merge pull request #1165 from QwenLM/mingholy/fix/sdk-timeout
fix: update timeout settings and default logging level in SDK
2025-12-06 18:18:23 +08:00
mingholy.lmh
86b166bb1d fix: adjust e2e tests via timeout option 2025-12-06 17:53:31 +08:00
yiliang114
57a684ad97 WIP: All changes including session and toolcall improvements 2025-12-06 16:53:40 +08:00
mingholy.lmh
bf6abf7752 fix: update timeout settings and default logging level in SDK 2025-12-06 12:27:16 +08:00
yiliang114
541d0b22e5 chore(vscode-ide-companion): code style & command register bugfix 2025-12-06 01:32:52 +08:00
yiliang114
96b275a756 fix(vscode-ide-companion): fix bugs & support terminal mode operation 2025-12-06 00:30:22 +08:00
Mingholy
ab228c682f Merge pull request #1161 from QwenLM/mingholy/fix/integration-test-scripts
test: separating integration tests for the CLI and SDK
2025-12-05 22:34:30 +08:00
mingholy.lmh
22943b888d test: clean up integration test by removing unnecessary console logs 2025-12-05 22:11:27 +08:00
mingholy.lmh
96d458fa8c chore: rename @qwen-code/sdk-typescript to @qwen-code/sdk 2025-12-05 21:47:26 +08:00
mingholy.lmh
0e9255b122 fix: integration test scripts 2025-12-05 21:27:12 +08:00
Mingholy
3ed0a34b5e Merge pull request #1147 from QwenLM/mingholy/feat/cli-sdk-stage-2
Custom tools support via SDK controlled MCP servers
2025-12-05 21:19:58 +08:00
mingholy.lmh
2949b33a4e chore: enhance integration testing for SDK and CLI 2025-12-05 21:05:36 +08:00
mingholy.lmh
c218048551 fix: prevent sending control request when query is closed 2025-12-05 18:46:51 +08:00
yiliang114
be44e7af56 refactor(vscode-ide-companion): update message handling and configuration 2025-12-05 18:03:46 +08:00
yiliang114
ac9cb3a6d3 style(vscode-ide-companion/ui): improve component styling and layout 2025-12-05 18:03:37 +08:00
yiliang114
13aa4b03c7 feat(vscode-ide-companion/ui): improve permission drawer UI and logic 2025-12-05 18:03:29 +08:00
yiliang114
75fd2a5dcc feat(vscode-ide-companion/markdown): add copy button to code blocks in markdown renderer 2025-12-05 18:03:23 +08:00
yiliang114
811b332bc3 feat(vscode-ide-companion/ui): add context toggle control to input form 2025-12-05 18:03:18 +08:00
yiliang114
bf4673b00b feat(vscode-ide-companion/webview): enhance text selection tracking and restore path 2025-12-05 18:03:08 +08:00
yiliang114
645a5b181a fix(vscode-ide-companion/panel): update chat webview placement strategy to dedicated left group 2025-12-05 18:03:03 +08:00
yiliang114
2957058521 feat(vscode-ide-companion/webview): persist info banner dismissal state and add context control 2025-12-05 18:02:54 +08:00
yiliang114
e7b92622ce feat(vscode-ide-companion/diff): implement diff auto-open functionality in preferred editor group 2025-12-05 18:02:46 +08:00
yiliang114
82f97fe56d fix(vscode-ide-companion/acp): correct optionId mapping and outcome handling in acpMessageHandler 2025-12-05 18:02:40 +08:00
xuewenjie
2c1a836f18 fix: prefer UTF-8 encoding for shell output on Windows when detected 2025-12-05 16:49:26 +08:00
tanzhenxin
3a7b1159ae feat: add usage metadata in acp session/update event 2025-12-05 15:40:49 +08:00
tanzhenxin
3e2a2255ee DeepSeek V3.2 Thinking Mode Integration (#1134) 2025-12-05 15:08:35 +08:00
mingholy.lmh
46478e5dd3 fix: try fix sandbox integration test failure 2025-12-05 13:14:55 +08:00
mingholy.lmh
64de3520b3 docs: update README to include SDK-embedded MCP server details and usage examples 2025-12-05 13:14:55 +08:00
mingholy.lmh
322ce80e2c feat: implement SDK MCP server support and enhance control request handling
- Added new `SdkMcpController` to manage communication between CLI MCP clients and SDK MCP servers.
- Introduced `createSdkMcpServer` function for creating SDK-embedded MCP servers.
- Updated configuration options to support both external and SDK MCP servers.
- Enhanced timeout settings for various SDK operations, including MCP requests.
- Refactored existing control request handling to accommodate new SDK MCP server functionality.
- Updated tests to cover new SDK MCP server features and ensure proper integration.
2025-12-05 13:14:54 +08:00
yiliang114
c6f5a4585e style(vscode-ide-companion/ui): improve InfoBanner layout and centering
Add maxWidth constraint and auto margins to center the InfoBanner component
for better visual presentation.
2025-12-05 12:44:56 +08:00
yiliang114
b1a439e38f feat(vscode-ide-companion/read): implement diff auto-open functionality
Add diff viewing capabilities to Read tool calls with automatic opening of
VS Code diff tabs when diff content is detected. Includes:
- Import necessary React hooks and VS Code context
- Memoize content grouping for performance
- Auto-open diff in VS Code with useEffect
- Handle diff messages with useCallback
- Display compact UI when diff content is present
2025-12-05 12:42:58 +08:00
yiliang114
a6467e7f9b fix(vscode-ide-companion/ui): remove active background from focused permission options
Remove bg-[var(--app-list-active-background)] from focused permission drawer
options to improve visual consistency.
2025-12-05 12:42:50 +08:00
yiliang114
5ed60348d6 refactor(vscode-ide-companion/extension): disable unused path import and diff relay
Temporarily disable unused path import and diff event relaying functionality
that was causing issues.

TODO: 没有生效 - temporarily disabled due to commented out usage
2025-12-05 12:42:44 +08:00
yiliang114
0851ab572d fix(vscode-ide-companion/acp): correct optionId mapping in acpMessageHandler
Simplify the optionId mapping logic to directly use the provided optionId
rather than transforming 'reject_once' to 'cancel'. This ensures the
original optionId value is preserved in the outcome.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 11:41:33 +08:00
pomelo
a58d3f7aaf Merge pull request #1148 from QwenLM/feat/no-quit-confirm
Remove `/quit-confirm` flow
2025-12-05 10:56:08 +08:00
yiliang114
8203f6582f feat(vscode-ide-companion/extension): relay diff accept/cancel events to chat webview
Added functionality to relay diff accepted/cancelled events from the IDE to the chat webview
so users get immediate feedback when they accept or cancel diffs in the Claude Code style.
2025-12-05 02:45:44 +08:00
yiliang114
2d844d11df fix(vscode-ide-companion): improve message logging and permission handling
- Increase message logging truncation limit from 500 to 1500 characters
- Fix permission option mapping logic for reject_once/cancel options
- Add TODO comments for diff accept/cancel responses during permission requests

Resolves issues with permission handling and improves debugging capabilities.
2025-12-05 02:15:48 +08:00
Mingholy
aacc4b43ff Merge pull request #1150 from QwenLM/mingholy/ci/skip-case
test: skip qwen-oauth test in containerized environments
2025-12-04 20:53:12 +08:00
mingholy.lmh
57b519db9a test: skip qwen-oauth e2 case in sandbox 2025-12-04 20:39:51 +08:00
Mingholy
43f23f8ce5 Merge pull request #1103 from QwenLM/mingholy/feat/cli-sdk-stage-1
feat: basic TypeScript SDK
2025-12-04 18:06:22 +08:00
mingholy.lmh
427c69ba07 chore: fix sdk release workflow and verified with yamllint and act 2025-12-04 17:52:07 +08:00
tanzhenxin
1c45ef563d remove unused files 2025-12-04 17:41:52 +08:00
mingholy.lmh
0630908e0c fix: lint error 2025-12-04 17:36:22 +08:00
mingholy.lmh
c18fed574f chore: fix RELEASE_TAG fallback in workflow 2025-12-04 17:22:20 +08:00
mingholy.lmh
51b9281774 chore: remove scheduled triggers from SDK release workflow 2025-12-04 17:10:24 +08:00
mingholy.lmh
839a1d9d8c fix: mock path for cross platform compability in test cases 2025-12-04 17:10:24 +08:00
mingholy.lmh
56f61bc0b8 fix: path literals in windows 2025-12-04 17:10:24 +08:00
mingholy.lmh
b1d848f935 chore: update lockfile 2025-12-04 17:10:24 +08:00
mingholy.lmh
81c8b3eaec feat: add GitHub Actions workflow for SDK release automation 2025-12-04 17:10:23 +08:00
mingholy.lmh
50e3a6ee0a fix: enhance 429 error handling and fix failed cases 2025-12-04 17:10:23 +08:00
mingholy.lmh
3056f8a63d feat(tests): move SDK integration tests to integration-tests to share globalSetup 2025-12-04 17:10:23 +08:00
mingholy.lmh
ae7d6af717 fix(test): remove unused test cases 2025-12-04 17:10:22 +08:00
mingholy.lmh
8035be6f8d fix: plan mode adjustments 2025-12-04 17:10:22 +08:00
mingholy.lmh
249b141f19 feat: add allowedTools for SDK use and re-organize test setup 2025-12-04 17:10:22 +08:00
mingholy.lmh
56957a687b refactor: rename ambiguous exported types 2025-12-04 17:10:21 +08:00
mingholy.lmh
638b7bb466 feat: add allowedTools support 2025-12-04 17:10:21 +08:00
mingholy.lmh
d76341b8d8 chore: keep comments for queryOptions 2025-12-04 17:10:21 +08:00
mingholy.lmh
769a438fa4 feat: enhance logging capabilities and update query options in sdk-typescript
- Introduced a new logging system with adjustable log levels (debug, info, warn, error).
- Updated query options to include a logLevel parameter for controlling verbosity.
- Refactored existing code to utilize the new logging system for better error handling and debugging.
- Cleaned up unused code and improved the structure of the SDK.
2025-12-04 17:10:20 +08:00
mingholy.lmh
49dc84ac0e feat: add support for includePartialMessages option in query and transport layers 2025-12-04 17:10:20 +08:00
mingholy.lmh
ac6aecb622 refactor: update test structure and clean up unused code in cli and sdk 2025-12-04 17:10:20 +08:00
mingholy.lmh
ad9ba914e1 refactor: clean up exports in sdk-typescript index file 2025-12-04 17:10:20 +08:00
mingholy.lmh
d76cdf1076 feat: sdk subagent support 2025-12-04 17:10:20 +08:00
mingholy.lmh
e1ffaec499 feat: create draft framework for cli & sdk 2025-12-04 17:10:16 +08:00
tanzhenxin
5b2f3e285c remove /quit-confirm slash command 2025-12-04 16:21:32 +08:00
yiliang114
4145f45c7c fix(vscode-ide-companion): fix send/stop button state timing issue
- Show stop button immediately when request is submitted (isWaitingForResponse=true)
- Prevent confusing state where send button appears disabled instead of stop button
- Improve UX by providing immediate visual feedback on request submission

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 12:47:18 +08:00
tanzhenxin
6729980b47 skip acp integration test in sandbox env (#1141) 2025-12-04 10:28:21 +08:00
yiliang114
d56923b657 refactor(vscode-ide-companion): remove deprecated file locations
- Removed old InfoBanner.tsx file location
- Removed old ReadToolCall.tsx file location

These files have been moved to new directory structures.
2025-12-04 08:29:34 +08:00
yiliang114
32258f2f04 fix(vscode-ide-companion): ensure restored panel title resets to default
- Added code to reset restored panel title to 'Qwen Code' on initialization
- Added error handling for title reset operation

This ensures consistent panel labeling when restoring previous sessions.
2025-12-04 08:29:22 +08:00
yiliang114
5dec3e653c feat(vscode-ide-companion): add specialized tool call components
- Added ExecuteNodeToolCall for specialized node/npm command execution
- Added UpdatedPlanToolCall for enhanced plan visualization with checkboxes
- Created ExecuteNode CSS styling
- Refactored ReadToolCall to new directory structure
- Updated tool call router to handle new component types
- Enhanced LayoutComponents with className prop support
- Added specialized handling for todo_write and updated_plan tool kinds

These additions improve the visualization and handling of various tool call types in the UI.
2025-12-04 08:29:07 +08:00
yiliang114
3053e6c41f style(vscode-ide-companion): update UI components and styling
- Updated chat message container spacing and styling
- Adjusted AssistantMessage text color handling
- Enhanced QwenMessageTimeline CSS rules for better visual flow
- Moved InfoBanner to ui directory for better organization
- Updated InputForm styling
- Added new CSS classes and updated tailwind configuration
- Improved timeline connection lines and message item spacing

These changes enhance the visual appearance and user experience of the chat interface.
2025-12-04 08:28:54 +08:00
yiliang114
86cd06ef43 feat(vscode-ide-companion): add MarkdownRenderer component for rich message formatting
- Added MarkdownRenderer component with markdown-it integration
- Updated MessageContent to use MarkdownRenderer instead of custom parsing
- Added CSS styling for markdown-rendered content

This improves message display with proper markdown rendering support.
2025-12-04 08:28:42 +08:00
yiliang114
7270983821 feat(vscode-ide-companion): add markdown-it dependency for markdown rendering
- Added markdown-it as a dependency for enhanced markdown processing
- Updated @types/markdown-it as dev dependency
- Updated NOTICES.txt with new dependency licenses

This enables rich markdown rendering capabilities in the VS Code extension.
2025-12-04 08:28:30 +08:00
yiliang114
b1901f103f refactor(vscode-ide-companion): remove merged timeline CSS file
- Remove MergedSimpleTimeline.css in favor of SimpleTimeline.css
2025-12-04 01:54:16 +08:00
yiliang114
5701a3c897 refactor(vscode-ide-companion): update timeline CSS files
- Replace MergedSimpleTimeline.css with SimpleTimeline.css
- Update timeline styling for tool calls and messages
2025-12-04 01:54:07 +08:00
yiliang114
2145b28f8b style(vscode-ide-companion): update message components and layout styling
- Add qwen-message class to AssistantMessage container
- Update LayoutComponents styling
- Add custom timeline styles for message items
2025-12-04 01:53:42 +08:00
yiliang114
e3c456a430 feat(vscode-ide-companion): add cancel streaming functionality
- Add handleCancel callback to App component
- Implement cancelStreaming message posting to VS Code
- Add onCancel prop to InputForm component
- Replace send button with stop button during streaming
2025-12-04 01:53:19 +08:00
yiliang114
35f98723ca style(vscode-ide-companion): bash toolcall 2025-12-04 00:23:19 +08:00
yiliang114
b9b3b6d62e style(vscode-ide-companion): header & empty state 2025-12-04 00:18:04 +08:00
yiliang114
cec6b8691a Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/write-and-read-file-in-vscode 2025-12-03 23:14:44 +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
yiliang114
05f5189bb4 refactor(vscode-ide-companion/panel): reimplement editor group locking with proper error handling 2025-12-03 01:03:10 +08:00
yiliang114
c6299bf135 feat(vscode-ide-companion): improve CLI path detection and error handling
- Move determineNodePathForCli function to dedicated cliPathDetector.ts file
- Enhance error handling with specific guidance for permission issues
- Add detailed error messages for different failure scenarios
- Improve logging for debugging CLI path detection issues

This change improves the reliability of CLI path detection by providing better error messages and handling edge cases more gracefully.
2025-12-03 00:27:20 +08:00
yiliang114
2e449f4d45 wip(vscode-ide-companion): timelint 2025-12-02 14:41:00 +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
yiliang114
90fc53a9df refactor(webview): Refactoring Input Form and Timeline Components 2025-12-02 01:29:33 +08:00
yiliang114
ed0d5f67db style(vscode-ide-companion): form component style opt 2025-12-01 00:15:18 +08:00
yiliang114
1b37d729cb style(vscode-ide-companion): use tailwind to refactor some ui components 2025-11-30 23:06:37 +08:00
yiliang114
1acc24bc17 fix(vscode-ide-companion): Interactive unification of first login and login 2025-11-30 22:26:04 +08:00
yiliang114
b1e74e5732 style(vscode-ide-companion): optimize header & message style 2025-11-30 21:27:55 +08:00
yiliang114
82205034cc chore(vscode-ide-companion): remove the ui component of the active save session temporarily 2025-11-29 18:29:43 +08:00
yiliang114
c038745897 fix(vscode-ide-companion): 修复 Tailwind 可重用组件类和 ESLint 配置, 调整 ChatHeader 按钮样式
- 在 tailwind.css 中正确定义可重用的 Tailwind 组件类
- 修复 ChatHeader 组件中的按钮样式,确保 hover 效果正常工作
- 修复 ESLint 配置中的 importPlugin 导入问题
- 清理 App.css 中重复的 CSS 变量定义
- 为 btn-ghost 类设置 4px border radius
- 为按钮内的 span 添加左右 4px padding (使用 px-1)
- 确保按钮 hover 时有背景色效果
2025-11-29 18:13:50 +08:00
yiliang114
6885138cf0 refactor(vscode-ide-companion): Refactoring the project structure and updating dependencies 2025-11-29 13:16:58 +08:00
yiliang114
9ae45c01a6 refactor(vscode): 重构消息排序和展示逻辑
- 移除旧的消息排序改进总结文档
- 重新组织消息渲染逻辑,合并所有类型的消息按时间戳排序
- 优化工具调用处理流程,添加时间戳支持
- 改进会话保存机制,直接使用SessionManager保存检查点
- 重构部分组件以提高可维护性
2025-11-28 22:35:31 +08:00
yiliang114
5ce40085d5 fix(vscode-ide-companion): 优化 CLI 检测和连接逻辑 2025-11-28 17:44:18 +08:00
yiliang114
627f5fb43a refactor(vscode-ide-companion): 优化代码结构和性能
- 移除未使用的依赖项 qwen-code
- 优化 completion 刷新逻辑,避免渲染循环
- 更新 CompletionMenu 组件,增加空状态提示
2025-11-28 10:04:29 +08:00
yiliang114
9cc48f12da feat(vscode-ide-companion): 改进消息排序和显示逻辑
- 添加时间戳支持,确保消息按时间顺序排列
- 更新工具调用处理逻辑,自动添加和保留时间戳
- 修改消息渲染逻辑,将所有类型的消息合并排序后统一渲染
- 优化完成的工具调用显示,修复显示顺序问题
- 调整进行中的工具调用显示,统一到消息流中展示
- 移除重复的计划展示逻辑,避免最新块重复出现
- 重构消息处理和渲染代码,提高可维护性
2025-11-28 09:55:06 +08:00
yiliang114
dc340daf8b feat(vscode-ide-companion): 0.2.4 版本ACP 协议检测和实现
- 新增 session/list 方法支持
- 改进 session/load 方法兼容性
- 优化代理环境变量设置
- 调整 CLI 安装流程
- 移除未使用的随机加载消息功能
2025-11-28 01:17:55 +08:00
yiliang114
f78b1eff93 build(vscode-ide-companion): 更新包准备命令
- 将 "prepare1" 命令重命名为 "prepare",以提高可读性和一致性
- 优化了包生成流程,确保在构建前生成必要的 notices
2025-11-28 01:14:36 +08:00
yiliang114
8bc9bea5a1 feat(cli): 添加 CLI 版本检测和会话验证功能
- 新增 CLI 版本检测功能,支持检测 CLI 版本并缓存结果
- 实现会话验证方法,用于检查当前会话是否有效
- 在连接处理中集成 CLI 版本检测和会话验证逻辑
- 优化 WebViewProvider 中的初始化流程,支持背景初始化
- 更新消息处理逻辑,增加与 CLI 相关的错误处理
2025-11-28 01:13:57 +08:00
yiliang114
b986692f94 feat(auth): 优化认证流程并添加认证状态管理
- 新增 AuthStateManager 类用于管理认证状态
- 修改 createNewSession 方法以使用缓存的认证信息
- 添加清除认证缓存的功能
- 优化登录命令处理,增加加载状态显示
- 新增登录成功和失败的消息处理
2025-11-28 01:06:36 +08:00
yiliang114
4f63d92bb1 Add unit tests for CLI modules and fix ESLint issues
- Add comprehensive unit tests for all CLI-related modules:
  - CliContextManager
  - CliVersionManager
  - cliDetector
  - CliInstaller
- Fix ESLint issues by replacing @ts-ignore with @ts-expect-error
- Fix any type issues in test files
- Add tests for diff-manager functionality
- Improve loading messages random selection stability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 01:06:21 +08:00
yiliang114
3c09ad46ca refactor(vscode-ide-companion): translate Chinese comments to English
- Translate all Chinese comments in TypeScript files to English for better code readability
- Update documentation comments to be in English
- Maintain code functionality while improving internationalization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 01:01:47 +08:00
yiliang114
d5ede56e62 Revert "fix(vscode-ide-companion): 解决 mac 环境多个 node 版本的安装问题"
This reverts commit 530039c517.
2025-11-28 00:57:33 +08:00
yiliang114
530039c517 fix(vscode-ide-companion): 解决 mac 环境多个 node 版本的安装问题 2025-11-25 20:24:26 +08:00
yiliang114
0cbf95d6b3 chore(vscode-ide-companion): update dependencies in package-lock.json
Added new dependencies including:
- @cfworker/json-schema
- @parcel/watcher and related platform-specific packages
- autoprefixer
- browserslist
- chokidar
- Various other utility packages

These updates likely support enhanced functionality and improved compatibility.
2025-11-25 15:30:36 +08:00
yiliang114
579772197a chore(vscode-ide-companion): 更新 .gitignore 文件
- 移除了 pnpm-lock.yaml 文件的忽略规则
- 保留了 .claude 目录的忽略规则
2025-11-25 13:46:38 +08:00
yiliang114
934365c41f style(vscode-ide-companion): improve UI styling for chat components 2025-11-25 13:39:49 +08:00
yiliang114
f623bfbb34 chore(vscode-ide-companion): add qwen-code dependency to package files 2025-11-25 13:39:07 +08:00
yiliang114
f503eb2520 feat(vscode-ide-companion): split module & notes in english 2025-11-25 00:32:51 +08:00
yiliang114
3cf22c065f Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/write-and-read-file-in-vscode 2025-11-24 20:40:19 +08:00
yiliang114
a1ec1227cc refactor(vscode-ide-companion): reorganize component structure
重构组件结构,优化目录组织:
- 将 ChatHeader 从 ui/ 移动到 layouts/ 目录
- 删除未使用的 Button 和 Card 组件
- 更新 tailwind.config.js 扫描路径为 layouts/
- 简化组件目录结构,提高可维护性
2025-11-24 20:38:36 +08:00
yiliang114
36af718616 chore(vscode-ide-companion): add testing infrastructure and improve config files
添加测试基础设施和改进配置文件:
- 添加 vitest.config.js 用于单元测试配置
- 配置测试覆盖率报告(v8 provider)
- 修复 postcss.config.js 的 eslint 警告
- 设置测试环境为 Node.js
2025-11-24 20:38:35 +08:00
yiliang114
795e7fa2c5 fix(vscode-ide-companion): improve type safety in webview components
修复 webview 组件的类型安全问题:
- App.tsx: 规范化工具调用状态为联合类型
- InProgressToolCall.tsx: 安全处理非字符串类型的 title 属性
- InputForm.tsx: 修正 RefObject 泛型类型声明
- ReadToolCall.tsx: 添加空 children 避免 ToolCallContainer 警告
2025-11-24 20:38:35 +08:00
yiliang114
b6914c6b33 feat(vscode-ide-companion): enhance WebViewProvider with force re-login functionality
增强 WebViewProvider 的重新登录功能:
- 修复 _lastEditorState 变量声明错误(const -> let)
- 在强制重新登录后添加清理等待时间
- 添加登录成功/失败的 WebView 消息通知
- 改进错误处理和日志记录
- 为用户提供更好的登录状态反馈
2025-11-24 20:38:35 +08:00
yiliang114
f11d054a47 feat(vscode-ide-companion): improve authentication flow with cached auth state
优化认证流程,支持缓存认证状态避免重复认证:
- 在 qwenAgentManager 中创建会话前先进行认证
- 在 qwenConnectionHandler 中检查缓存的认证状态
- 只在没有有效缓存时触发新的认证流程
- 认证失败时清除无效缓存
- 添加详细的日志记录用于调试
2025-11-24 20:32:08 +08:00
yiliang114
4ad377b0d8 chore(vscode-ide-companion): update lock 2025-11-24 10:04:03 +08:00
yiliang114
b7f9acf0ff refactor(vscode-ide-companion): migrate session save to CLI /chat save command
- Replace manual checkpoint file writing with CLI's native /chat save command
- Add saveCheckpointViaCommand method to use CLI's built-in save functionality
- Deprecate saveSessionViaAcp as CLI doesn't support session/save ACP method
- Update saveCheckpoint to delegate to CLI command for complete context preservation
- Enhanced error logging in acpSessionManager session load
- Mark saveSessionViaAcp as deprecated with fallback to command-based save
- Fix ESLint errors: remove unused imports and catch variables, wrap case block declarations

This ensures checkpoints are saved with complete session context including tool calls,
leveraging CLI's native save functionality instead of manual file operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 01:00:31 +08:00
yiliang114
4dfbdcddca feat(vscode-ide-companion): 增强工具调用与输入表单组件功能
- 新增 InProgressToolCall 组件用于展示进行中的工具调用状态
- 重构 InputForm 为独立组件,提升代码可维护性
- 改进 tool_call_update 处理逻辑,支持创建缺失的初始工具调用
- 添加思考块(thought chunk)日志以便调试 AI 思维过程
- 更新样式以支持新的进行中工具调用卡片显示
- 在权限请求时自动创建对应的工具调用记录
```
2025-11-23 22:28:11 +08:00
yiliang114
826516581b feat(vscode-ide-companion): send initial active editor state to WebView on initialization
- Send activeEditorChanged message with initial editor state when WebView is created
- Include fileName, filePath, and selection info if available
- Applied in both side panel and editor panel initialization

This ensures the WebView displays the current file context immediately on load.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:45:44 +08:00
yiliang114
4f964b5281 refactor(vscode-ide-companion): extract AssistantMessage as standalone component with Claude Code styles
- Extract AssistantMessage component from inline implementation
- Add status prop support (default, success, error, warning, loading)
- Implement bullet point indicator using CSS pseudo-elements (::before)
- Use inline styles for layout to prevent Tailwind override
- Add AssistantMessage.css with pseudo-element styles for different states
- Import AssistantMessage.css in ClaudeCodeStyles.css

Restores Claude Code DOM structure and styling:
- Outer container with padding-left: 30px for bullet spacing
- Bullet point colors based on status (green, red, yellow, gray)
- Loading state with pulse animation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:44:40 +08:00
yiliang114
de8ea0678d feat(vscode-ide-companion): refactor message components with modular architecture
Refactor UI message rendering by extracting message types into dedicated components. Add ChatHeader component for better session management interface.

- Extract message components: UserMessage, AssistantMessage, ThinkingMessage, StreamingMessage, WaitingMessage
- Add ChatHeader component with session selector and action buttons
- Delete MessageContent.css and consolidate styles into App.scss
- Update Tailwind config for component styling
- Improve message rendering with proper TypeScript typing
2025-11-23 20:56:15 +08:00
yiliang114
c4bcd178a4 feat(vscode-ide-companion): implement manual login via /login command
BREAKING CHANGE: Login is no longer automatic when opening webview

Changes:
- Remove auto-login on webview open and restore
- Add /login slash command for manual authentication
- Add VSCode progress notification during login process
- Add warning notification when user tries to chat without login
- Implement pending message auto-retry after successful login
- Add NotLoggedInMessage component (for future use)
- Improve InfoBanner close button styling consistency

User flow:
1. Open webview - no automatic login
2. Type /login or select from completion menu to login
3. Show "Logging in to Qwen Code..." progress notification
4. After login, show success message and auto-retry pending messages
5. If user tries to chat without login, show warning with "Login Now" button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 19:20:01 +08:00
yiliang114
e5729b0420 Merge branch 'feat/jinjing/implement-ui-from-cc-vscode-extension' of https://github.com/yiliang114/qwen-code into feat/jinjing/implement-ui-from-cc-vscode-extension 2025-11-23 18:37:00 +08:00
yiliang114
aceb857436 refactor(vscode-ide-companion): extract InfoBanner as standalone component
Move info banner UI from App.tsx to dedicated component with open settings functionality.
2025-11-23 18:11:47 +08:00
yiliang114
e15dd2f5c9 refactor(vscode-ide-companion): extract InfoBanner as standalone component
Move info banner UI from App.tsx to dedicated component with open settings functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 18:09:43 +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
yiliang114
8ac38aad92 chore(vscode-ide-companion): tailwind base 2025-11-23 17:33:10 +08:00
yiliang114
38fd303b07 docs(vscode-ide-companion): add Tailwind CSS integration guides
添加 Tailwind CSS 渐进式集成文档,包括:
- 渐进式采用策略文档
- 集成计划文档
- 渐进式集成指南
2025-11-23 16:41:22 +08:00
yiliang114
9899d872a2 feat(vscode-ide-companion): add Tailwind CSS demo components
添加 Tailwind CSS 示例组件和重构的 PermissionDrawer:
- TailwindDemo: 展示 Tailwind CSS 基础用法
- PermissionDrawer.tailwind: 使用 Tailwind CSS 重构的权限抽屉组件
2025-11-23 16:41:09 +08:00
yiliang114
36a96a7b5c feat(vscode-ide-companion): add shadcn/ui components and utilities
添加 shadcn/ui 基础组件库和 cn 工具函数,包括:
- Button 按钮组件
- Dialog 对话框组件
- cn 类名合并工具函数
2025-11-23 16:40:55 +08:00
yiliang114
951f6b2829 feat(vscode-ide-companion): add global Tailwind CSS styles
添加全局 Tailwind CSS 样式文件,包含基础层、组件层和工具层
2025-11-23 16:40:42 +08:00
yiliang114
eff01819a8 build(vscode-ide-companion): add Tailwind CSS configuration
添加 Tailwind CSS、PostCSS 和 Autoprefixer 配置,为渐进式采用 Tailwind CSS 做准备
2025-11-23 16:40:30 +08:00
yiliang114
31f8ca07b6 chore: remove root package-lock.json
移除根目录的 package-lock.json 文件,采用各子包独立管理依赖的策略
2025-11-23 16:40:17 +08:00
yiliang114
39adaaff11 refactor(vscode-ide-companion): minor adjustments to SaveSessionDialog and SessionManager components 2025-11-23 15:17:40 +08:00
yiliang114
fd2e5b0933 feat(vscode-ide-companion): improve PermissionDrawer UI with enhanced styling and responsiveness 2025-11-23 15:17:17 +08:00
yiliang114
49a2be195d chore: add .claude/ to gitignore 2025-11-23 15:17:05 +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
yiliang114
ce07fb2b3f feat(session): 实现会话保存和加载功能
- 在 AcpConnection 和 AcpSessionManager 中添加会话保存方法
- 在 QwenAgentManager 中实现通过 ACP 和直接保存会话的功能
- 在前端添加保存会话对话框和相关交互逻辑
- 新增 QwenSessionManager 用于直接操作文件系统保存和加载会话
2025-11-21 23:51:48 +08:00
yiliang114
e2beecb9c4 feat(vscode-ide-companion): 更新核心服务和扩展功能
- 增强 extension.ts,集成新增功能
- 优化 ide-server.ts,改进服务端逻辑
- 更新 diff-manager.ts,提升差异管理能力
- 改进 ACP 连接和消息处理
- 更新会话处理器,支持新的交互模式

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:54:24 +08:00
yiliang114
ecc6e22002 feat(vscode-ide-companion): 更新 UI 样式
- 重构 PlanDisplay 组件和样式
- 更新 PermissionRequest 组件逻辑
- 增强 PermissionDrawer 样式,提升视觉体验

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:54:03 +08:00
yiliang114
99f93b457c feat(vscode-ide-companion): 更新主应用界面和消息处理
- 重构 App.tsx,集成新增的 UI 组件
- 增强 MessageHandler,支持更多消息类型处理
- 优化 FileOperations,改进文件操作逻辑

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:53:46 +08:00
yiliang114
748ad8f4dd refactor(vscode-ide-companion): 重构工具调用组件
- 重构 ExecuteToolCall、GenericToolCall、ReadToolCall 等组件
- 统一工具调用组件的展示样式和交互逻辑
- 优化代码结构,提高可维护性

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:53:25 +08:00
yiliang114
a33187ed7a feat(vscode-ide-companion): 新增时间线组件
- 新增 Timeline 组件用于显示会话历史
- 支持展示消息、工具调用等事件
- 提供清晰的时间轴视图

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:53:05 +08:00
yiliang114
088c766c22 feat(vscode-ide-companion): 新增自动完成功能
- 新增 CompletionMenu 组件支持 @ 和 / 触发补全
- 新增 useCompletionTrigger hook 处理补全触发逻辑
- 支持实时查询和过滤补全项

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:52:47 +08:00
yiliang114
b82ef5b73f feat(vscode-ide-companion): 新增上下文附件管理功能
- 新增 ContextAttachmentManager 管理上下文附件
- 新增 ContextPills 组件用于显示上下文标签
- 支持文件、符号、选区等多种上下文类型

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:52:29 +08:00
yiliang114
328924f578 feat(vscode-ide-companion): 新增 DiffDisplay 组件和 diff 统计工具
- 增强 DiffDisplay 组件,支持更丰富的差异展示
- 新增 diffStats.ts 工具,提供差异统计功能
- 新增样式文件 DiffDisplay.css

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:52:10 +08:00
yiliang114
1eedd36542 feat(vscode-ide-companion): 新增共享 UI 组件 FileLink
- 新增 FileLink 组件用于显示文件链接
- 更新 LayoutComponents 增加通用布局组件
- 新增 utils.ts 提供工具函数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:51:50 +08:00
yiliang114
9ba99177b9 refactor(vscode-ide-companion): 重构主动完成和会话管理
- 移除了 QwenAgentManager 中的冗余类型导出
- 优化了 App 组件中的会话管理和标题更新逻辑
- 改进了消息输入框的中文输入法支持
- 调整了活动文件指示器的样式
2025-11-20 23:50:41 +08:00
yiliang114
7d2411e72f feat(vscode-ide-companion): 优化 Qwen Code 聊天窗口创建逻辑
- 修改 createPanel 方法返回值类型,使其支持异步操作
- 实现聚焦当前激活编辑器的功能
- 优化多窗口创建逻辑,允许在已有 Qwen Code 窗口旁边创建新窗口
- 移除自动锁定编辑器组的功能,以支持多个 Qwen Code 标签页
- 在 UI 中添加聚焦当前文件的按钮
2025-11-20 23:41:41 +08:00
yiliang114
5a9f5e3432 fix(vscode-ide-companion): 修复新建会话按钮,在同一 view column 创建新 tab
问题:
- 之前的实现会复用现有 panel 并清空当前会话
- 期望行为是在同一 view column(不创建分屏)中创建新的 VS Code tab

解决方案:
1. 修改 qwenCode.openNewChatTab 命令
   - 总是创建新的 WebviewProvider 和 WebviewPanel
   - PanelManager 的 findExistingQwenCodeViewColumn() 确保在同一 column 打开
2. 修改 MessageHandler 中的 openNewChatTab 处理
   - 调用 VS Code 命令创建新 panel/tab
3. 移除不再需要的 createNewSession 方法

效果:
- 点击新建会话按钮会在同一 view column 中创建新的 VS Code tab
- 类似 Claude Code 的交互方式
- 不会创建新的分屏

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 23:14:40 +08:00
yiliang114
95b67bbebd fix(vscode-ide-companion): 修复新建会话按钮创建新分屏的问题
问题:
- 点击新建会话按钮时,会在 VS Code 中创建一个新的 webview panel(分屏)
- 期望行为是在同一个 panel 内创建新会话,类似 Claude Code 的交互方式

解决方案:
1. 修改 extension.ts 中的 openNewChatTab 命令
   - 检查是否已有 webview panel 打开
   - 如果有,则在现有 panel 中创建新会话
   - 如果没有,才创建新 panel
2. 在 WebViewProvider 中添加 createNewSession 方法
   - 通过 agentManager 创建新会话
   - 清空当前对话 UI
3. 修改 MessageHandler 中的 openNewChatTab 处理
   - 直接调用 handleNewQwenSession 创建新会话
   - 不再执行 VS Code 命令创建新 panel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 23:07:56 +08:00
yiliang114
492c56a780 test(vscode-ide-companion): registerWebviewPanelSerializer 添加 mock 实现 2025-11-20 14:31:20 +08:00
yiliang114
06a8580361 refactor(vscode-ide-companion): 重构 WebViewProvider 组件 2025-11-20 11:37:28 +08:00
yiliang114
dcc10eb0a9 fix(vscode-ide-companion): 重构 useVSCode hook 实现, 解决 webview 中 vscode api 重复声明 2025-11-20 11:07:09 +08:00
yiliang114
805e5f92c1 docs(readme): 添加 VS Code 扩展相关信息
- 在主 README 中添加 VS Code 扩展部分,介绍扩展的功能和用途
- 更新 VS Code 扩展的 README,详细说明调试和开发指南
- 优化扩展开发流程说明,提供两种调试选项
2025-11-20 10:41:23 +08:00
yiliang114
8cb7ea0d3d Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/implement-ui-from-cc-vscode-extension 2025-11-20 10:06:14 +08:00
yiliang114
b534bd2b18 chore: rm wip doc 2025-11-20 10:05:47 +08:00
yiliang114
6286b8b6e8 feat(vscode-ide-companion): 增加代码编辑功能和文件操作支持
- 实现了与 Claude Code 类似的代码编辑功能
- 添加了文件打开、保存等操作的支持
- 优化了消息显示,增加了代码高亮和文件路径点击功能
- 改进了用户界面,增加了编辑模式切换和思考模式功能
2025-11-20 01:04:11 +08:00
yiliang114
e81255e589 feat(vscode-ide-companion): 优化权限请求组件并添加错误处理功能
- 移动权限请求组件到抽屉中,优化用户体验
- 为权限选项添加编号,提高可识别性
- 实现错误对象的特殊处理,提取更有意义的错误信息
- 优化工具调用错误内容的展示,提高错误信息的可读性
2025-11-20 00:01:18 +08:00
yiliang114
018990b7f6 build(vscode-ide-companion): 添加 SCSS 支持
- 在 esbuild.js 中添加 SCSS 文件处理逻辑
- 在 package.json 中添加 sass 依赖
- 新增代码使用 sass 编译 SCSS 文件,并将其注入到页面中
2025-11-19 23:34:05 +08:00
yiliang114
bc2b503e8d test(vscode-ide-companion): 尝试通过 session/load 旧会话
- 修改了 WebViewProvider 中的逻辑,先尝试通过 ACP 加载旧会话
- 如果加载失败,则创建新会话作为回退方案
- 在 AcpConnection 中添加了初始化响应的日志输出
- 在 QwenAgentManager 中添加了新的 loadSessionViaAcp 方法,用于测试 ACP 的 session/load 功能
2025-11-19 17:08:25 +08:00
yiliang114
454cbfdde4 refactor(webview): 重构工具调用显示逻辑
- 新增多个工具调用组件,分别处理不同类型的工具调用
- 优化工具调用卡片的样式和布局
- 添加加载状态和随机加载消息
- 重构 App 组件,支持新的工具调用显示逻辑
2025-11-19 15:42:35 +08:00
yiliang114
04dfad7ab5 docs(implementation): 更新实现总结文档 2025-11-19 13:50:52 +08:00
yiliang114
e02866d06f refactor(vscode-ide-companion): 重构代码并更新文件命名
- 更新文件命名规则,使用小写字母和下划线
- 修复部分代码导入路径
- 删除未使用的 WEBVIEW_PIN_FEATURE.md 文件
2025-11-19 10:40:16 +08:00
yiliang114
9fcdd3fa77 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/implement-ui-from-cc-vscode-extension 2025-11-19 10:08:21 +08:00
yiliang114
754ae30939 refactor(vscode-ide-companion): 重构 WebViewProvider 初始化逻辑
- 抽离初始化代理连接逻辑到单独的方法中
- 优化面板恢复时的代理连接流程
- 移除 EmptyState 组件中的信息横幅
- 在 App 组件中添加可关闭的信息横幅
- 调整输入表单样式,移除冗余样式
2025-11-19 00:40:48 +08:00
yiliang114
0577fe6f36 refactor(vscode-ide-companion): 重构 WebViewProvider 初始化逻辑
- 抽离初始化代理连接逻辑到单独的方法中
- 优化面板恢复时的代理连接流程
- 移除 EmptyState 组件中的信息横幅
- 在 App 组件中添加可关闭的信息横幅
- 调整输入表单样式,移除冗余样式
2025-11-19 00:34:45 +08:00
yiliang114
732220e651 wip(vscode-ide-companion): 实现 quick win 功能
- 将 WebView 调整到编辑器右侧
- 添加 ChatHeader 组件,实现会话下拉菜单
- 替换模态框为紧凑型下拉菜单
- 更新会话切换逻辑,显示当前标题
- 清理旧的会话选择器样式
基于 Claude Code v2.0.43 UI 分析实现。
2025-11-19 00:16:45 +08:00
yiliang114
729a3d0ab3 Merge branch 'feat/jinjing/write-and-read-file-in-vscode' into feat/jinjing/implement-ui-from-cc-vscode-extension 2025-11-18 19:15:18 +08:00
yiliang114
0e3759fbd2 chore(vscode-ide-companion): 新建文件版权头更新 2025-11-18 19:14:38 +08:00
yiliang114
f8db157a5d Merge branch 'feat/jinjing/qwen-code-vscode-extension-init' into feat/jinjing/write-and-read-file-in-vscode 2025-11-18 19:13:11 +08:00
yiliang114
f827aadd76 Revert "refactor(vscode-ide-companion): 文件版权头 Copyright 2025 Google LLC"
This reverts commit 91af599823.
2025-11-18 19:10:07 +08:00
yiliang114
39426be9a1 wip 2025-11-18 14:25:05 +08:00
yiliang114
f95f6e63bb Merge branch 'feat/jinjing/qwen-code-vscode-extension-init' into feat/jinjing/write-and-read-file-in-vscode 2025-11-18 10:40:59 +08:00
yiliang114
91af599823 refactor(vscode-ide-companion): 文件版权头 Copyright 2025 Google LLC 2025-11-18 10:34:27 +08:00
yiliang114
ad8d7aae8a refactor(vscode-ide-companion): 文件版权头 Copyright 2025 Google LLC 2025-11-18 10:33:40 +08:00
yiliang114
d22d07a840 feat(vscode-ide-companion): 添加 Qwen Code CLI 安装检测和提示功能
- 新增 CliDetector 类用于检测 Qwen Code CLI 安装状态
- 在 WebViewProvider 中集成 CLI 检测逻辑
- 添加 CLI 未安装时的提示和安装引导功能
- 优化 agent 连接流程,增加 CLI 安装检测步骤
2025-11-18 01:52:46 +08:00
yiliang114
28892996b3 feat(vscode): 重构 Qwen 交互模型并优化权限请求 UI
- 重构 QwenAgentManager 类,支持处理多种类型的消息更新
- 改进权限请求界面,增加详细信息展示和选项选择功能
- 新增工具调用卡片组件,用于展示工具调用相关信息
- 优化消息流处理逻辑,支持不同类型的内容块
- 调整会话切换和新会话创建的处理方式
2025-11-18 01:00:25 +08:00
yiliang114
eeeb1d490a feat(vscode-ide-companion): 实现自定义权限请求 UI 并添加文件读写功能
- 新增 fs/read_text_file 和 fs/write_text_file 方法处理
- 实现精美的 Claude 风格权限请求 UI
- 优化权限请求处理逻辑,支持取消操作
- 添加日志输出以便调试
2025-11-17 21:44:39 +08:00
yiliang114
247c237647 fix(vscode-ide-companion): 优化缓存 Qwen Chat UI 的登录态机制 2025-11-17 20:00:52 +08:00
yiliang114
c423e12aa7 feat(vscode-ide-companion): update qwen logo 2025-11-17 19:10:17 +08:00
yiliang114
dc40995e70 feat(vscode-ide-companion): import chat chat customEditor to vscode extension folder 2025-11-17 18:53:00 +08:00
368 changed files with 54580 additions and 6118 deletions

237
.github/workflows/release-sdk.yml vendored Normal file
View File

@@ -0,0 +1,237 @@
name: 'Release SDK'
on:
workflow_dispatch:
inputs:
version:
description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.'
required: false
type: 'string'
ref:
description: 'The branch or ref (full git sha) to release from.'
required: true
type: 'string'
default: 'main'
dry_run:
description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.'
required: true
type: 'boolean'
default: true
create_nightly_release:
description: 'Auto apply the nightly release tag, input version is ignored.'
required: false
type: 'boolean'
default: false
create_preview_release:
description: 'Auto apply the preview release tag, input version is ignored.'
required: false
type: 'boolean'
default: false
force_skip_tests:
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
required: false
type: 'boolean'
default: false
jobs:
release-sdk:
runs-on: 'ubuntu-latest'
environment:
name: 'production-release'
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}'
if: |-
${{ github.repository == 'QwenLM/qwen-code' }}
permissions:
contents: 'write'
packages: 'write'
id-token: 'write'
issues: 'write'
outputs:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
- name: 'Set booleans for simplified logic'
env:
CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}'
CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}'
DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}'
id: 'vars'
run: |-
is_nightly="false"
if [[ "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then
is_nightly="true"
fi
echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}"
is_preview="false"
if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then
is_preview="true"
fi
echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}"
is_dry_run="false"
if [[ "${DRY_RUN_INPUT}" == "true" ]]; then
is_dry_run="true"
fi
echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Install Dependencies'
run: |-
npm ci
- name: 'Get the version'
id: 'version'
run: |
VERSION_ARGS=()
if [[ "${IS_NIGHTLY}" == "true" ]]; then
VERSION_ARGS+=(--type=nightly)
elif [[ "${IS_PREVIEW}" == "true" ]]; then
VERSION_ARGS+=(--type=preview)
if [[ -n "${MANUAL_VERSION}" ]]; then
VERSION_ARGS+=("--preview_version_override=${MANUAL_VERSION}")
fi
else
VERSION_ARGS+=(--type=stable)
if [[ -n "${MANUAL_VERSION}" ]]; then
VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}")
fi
fi
VERSION_JSON=$(node packages/sdk-typescript/scripts/get-release-version.js "${VERSION_ARGS[@]}")
echo "RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)" >> "$GITHUB_OUTPUT"
echo "RELEASE_VERSION=$(echo "$VERSION_JSON" | jq -r .releaseVersion)" >> "$GITHUB_OUTPUT"
echo "NPM_TAG=$(echo "$VERSION_JSON" | jq -r .npmTag)" >> "$GITHUB_OUTPUT"
echo "PREVIOUS_RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .previousReleaseTag)" >> "$GITHUB_OUTPUT"
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}'
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Run Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
working-directory: 'packages/sdk-typescript'
run: |
npm run test:ci
env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
- name: 'Build CLI for Integration Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
run: |
npm run build
npm run bundle
- name: 'Run SDK Integration Tests'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
run: |
npm run test:integration:sdk:sandbox:none
npm run test:integration:sdk:sandbox:docker
env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
- name: 'Configure Git User'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: 'Create and switch to a release branch'
id: 'release_branch'
env:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}"
git switch -c "${BRANCH_NAME}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Update package version'
working-directory: 'packages/sdk-typescript'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Commit and Conditionally Push package version'
env:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
git add packages/sdk-typescript/package.json
if git diff --staged --quiet; then
echo "No version changes to commit"
else
git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}"
fi
if [[ "${IS_DRY_RUN}" == "false" ]]; then
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
else
echo "Dry run enabled. Skipping push."
fi
- name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Configure npm for publishing'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create GitHub Release and Tag'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
run: |-
gh release create "sdk-typescript-${RELEASE_TAG}" \
--target "$RELEASE_BRANCH" \
--title "SDK TypeScript Release ${RELEASE_TAG}" \
--notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \
--generate-notes
- name: 'Create Issue on Failure'
if: |-
${{ failure() }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}"
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
run: |-
gh issue create \
--title "SDK Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \
--body "The SDK release workflow failed. See the full run for details: ${DETAILS_URL}"

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}"

3
.vscode/launch.json vendored
View File

@@ -27,7 +27,7 @@
"outFiles": [
"${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js"
],
"preLaunchTask": "npm: build: vscode-ide-companion"
"preLaunchTask": "launch: vscode-ide-companion (copy+build)"
},
{
"name": "Attach",
@@ -79,7 +79,6 @@
"--",
"-p",
"${input:prompt}",
"-y",
"--output-format",
"stream-json"
],

16
.vscode/tasks.json vendored
View File

@@ -20,6 +20,22 @@
"problemMatcher": [],
"label": "npm: build: vscode-ide-companion",
"detail": "npm run build -w packages/vscode-ide-companion"
},
{
"label": "copy: bundled-cli (dev)",
"type": "shell",
"command": "node",
"args": ["packages/vscode-ide-companion/scripts/copy-bundled-cli.js"],
"problemMatcher": []
},
{
"label": "launch: vscode-ide-companion (copy+build)",
"dependsOrder": "sequence",
"dependsOn": [
"copy: bundled-cli (dev)",
"npm: build: vscode-ide-companion"
],
"problemMatcher": []
}
]
}

View File

@@ -88,6 +88,12 @@ npm install -g .
brew install qwen-code
```
## VS Code Extension
In addition to the CLI tool, Qwen Code also provides a **VS Code extension** that brings AI-powered coding assistance directly into your editor with features like file system operations, native diffing, interactive chat, and more.
> 📦 The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md).
## Quick Start
```bash

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`**
@@ -168,16 +145,6 @@ Slash commands provide meta-level control over the CLI itself.
- **`nodesc`** or **`nodescriptions`**:
- **Description:** Hide tool descriptions, showing only the tool names.
- **`/quit-confirm`**
- **Description:** Show a confirmation dialog before exiting Qwen Code, allowing you to choose how to handle your current session.
- **Usage:** `/quit-confirm`
- **Features:**
- **Quit immediately:** Exit without saving anything (equivalent to `/quit`)
- **Generate summary and quit:** Create a project summary using `/summary` before exiting
- **Save conversation and quit:** Save the current conversation with an auto-generated tag before exiting
- **Keyboard shortcut:** Press **Ctrl+C** twice to trigger the quit confirmation dialog
- **Note:** This command is automatically triggered when you press Ctrl+C once, providing a safety mechanism to prevent accidental exits.
- **`/quit`** (or **`/exit`**)
- **Description:** Exit Qwen Code immediately without any confirmation dialog.

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. 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:**

View File

@@ -10,19 +10,21 @@ The `/language` command allows you to customize the language settings for both t
To change the UI language of Qwen Code, use the `ui` subcommand:
```
/language ui [zh-CN|en-US]
/language ui [zh-CN|en-US|ru-RU]
```
### Available UI Languages
- **zh-CN**: Simplified Chinese (简体中文)
- **en-US**: English
- **ru-RU**: Russian (Русский)
### Examples
```
/language ui zh-CN # Set UI language to Simplified Chinese
/language ui en-US # Set UI language to English
/language ui ru-RU # Set UI language to Russian
```
### UI Language Subcommands
@@ -31,6 +33,7 @@ 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`
- `/language ui ru-RU` or `/language ui ru` or `/language ui русский`
## LLM Output Language Settings

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,20 +75,12 @@ 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
### Quit Confirmation
When exiting with `/quit-confirm` and choosing "Generate summary and quit":
1. A project summary is automatically created
2. Next session will trigger the Welcome Back dialog
3. You can seamlessly continue your work
## File Structure
The Welcome Back feature creates and uses:

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

@@ -22,6 +22,7 @@ export default tseslint.config(
'bundle/**',
'package/bundle/**',
'.integration-tests/**',
'packages/**/.integration-test/**',
'dist/**',
],
},
@@ -74,6 +75,8 @@ export default tseslint.config(
},
},
rules: {
// We use TypeScript for React components; prop-types are unnecessary
'react/prop-types': 'off',
// General Best Practice Rules (subset adapted for flat config)
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'arrow-body-style': ['error', 'as-needed'],
@@ -110,10 +113,14 @@ export default tseslint.config(
{
allow: [
'react-dom/test-utils',
'react-dom/client',
'memfs/lib/volume.js',
'yargs/**',
'msw/node',
'**/generated/**'
'**/generated/**',
'./styles/tailwind.css',
'./styles/App.css',
'./styles/style.css'
],
},
],
@@ -150,7 +157,7 @@ export default tseslint.config(
},
},
{
files: ['packages/*/src/**/*.test.{ts,tsx}'],
files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'],
plugins: {
vitest,
},
@@ -158,11 +165,19 @@ export default tseslint.config(
...vitest.configs.recommended.rules,
'vitest/expect-expect': 'off',
'vitest/no-commented-out-tests': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
// extra settings for scripts that we run directly with node
{
files: ['./scripts/**/*.js', 'esbuild.config.js'],
files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'],
languageOptions: {
globals: {
...globals.node,
@@ -229,7 +244,7 @@ export default tseslint.config(
prettierConfig,
// extra settings for scripts that we run directly with node
{
files: ['./integration-tests/**/*.js'],
files: ['./integration-tests/**/*.{js,ts,tsx}'],
languageOptions: {
globals: {
...globals.node,

View File

@@ -0,0 +1,649 @@
/**
* @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 UsageMetadata = {
promptTokens?: number | null;
completionTokens?: number | null;
thoughtsTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
};
type SessionUpdateNotification = {
sessionId?: string;
update?: {
sessionUpdate?: string;
availableCommands?: Array<{
name: string;
description: string;
input?: { hint: string } | null;
}>;
content?: {
type: string;
text?: string;
};
modeId?: string;
_meta?: {
usage?: UsageMetadata;
};
};
};
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();
}
});
it('receives usage metadata in agent_message_chunk updates', async () => {
const rig = new TestRig();
rig.setup('acp usage metadata');
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
try {
await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [{ type: 'text', text: 'Say "hello".' }],
});
await delay(500);
// Find updates with usage metadata
const updatesWithUsage = sessionUpdates.filter(
(u) =>
u.update?.sessionUpdate === 'agent_message_chunk' &&
u.update?._meta?.usage,
);
expect(updatesWithUsage.length).toBeGreaterThan(0);
const usage = updatesWithUsage[0].update?._meta?.usage;
expect(usage).toBeDefined();
expect(
typeof usage?.promptTokens === 'number' ||
typeof usage?.totalTokens === 'number',
).toBe(true);
} 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

@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
@@ -30,6 +30,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
const integrationTestsDir = join(rootDir, '.integration-tests');
let runDir = ''; // Make runDir accessible in teardown
let sdkE2eRunDir = ''; // SDK E2E test run directory
const memoryFilePath = join(
os.homedir(),
@@ -48,14 +49,36 @@ export async function setup() {
// File doesn't exist, which is fine.
}
// Setup for CLI integration tests
runDir = join(integrationTestsDir, `${Date.now()}`);
await mkdir(runDir, { recursive: true });
// Setup for SDK E2E tests (separate directory with prefix)
sdkE2eRunDir = join(integrationTestsDir, `sdk-e2e-${Date.now()}`);
await mkdir(sdkE2eRunDir, { recursive: true });
// Clean up old test runs, but keep the latest few for debugging
try {
const testRuns = await readdir(integrationTestsDir);
if (testRuns.length > 5) {
const oldRuns = testRuns.sort().slice(0, testRuns.length - 5);
// Clean up old CLI integration test runs (without sdk-e2e- prefix)
const cliTestRuns = testRuns.filter((run) => !run.startsWith('sdk-e2e-'));
if (cliTestRuns.length > 5) {
const oldRuns = cliTestRuns.sort().slice(0, cliTestRuns.length - 5);
await Promise.all(
oldRuns.map((oldRun) =>
rm(join(integrationTestsDir, oldRun), {
recursive: true,
force: true,
}),
),
);
}
// Clean up old SDK E2E test runs (with sdk-e2e- prefix)
const sdkTestRuns = testRuns.filter((run) => run.startsWith('sdk-e2e-'));
if (sdkTestRuns.length > 5) {
const oldRuns = sdkTestRuns.sort().slice(0, sdkTestRuns.length - 5);
await Promise.all(
oldRuns.map((oldRun) =>
rm(join(integrationTestsDir, oldRun), {
@@ -69,24 +92,37 @@ export async function setup() {
console.error('Error cleaning up old test runs:', e);
}
// Environment variables for CLI integration tests
process.env['INTEGRATION_TEST_FILE_DIR'] = runDir;
process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true';
process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log');
// Environment variables for SDK E2E tests
process.env['E2E_TEST_FILE_DIR'] = sdkE2eRunDir;
process.env['TEST_CLI_PATH'] = join(rootDir, 'dist/cli.js');
if (process.env['KEEP_OUTPUT']) {
console.log(`Keeping output for test run in: ${runDir}`);
console.log(`Keeping output for SDK E2E test run in: ${sdkE2eRunDir}`);
}
process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false';
console.log(`\nIntegration test output directory: ${runDir}`);
console.log(`SDK E2E test output directory: ${sdkE2eRunDir}`);
console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`);
}
export async function teardown() {
// Cleanup the test run directory unless KEEP_OUTPUT is set
// Cleanup the CLI test run directory unless KEEP_OUTPUT is set
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
await rm(runDir, { recursive: true, force: true });
}
// Cleanup the SDK E2E test run directory unless KEEP_OUTPUT is set
if (process.env['KEEP_OUTPUT'] !== 'true' && sdkE2eRunDir) {
await rm(sdkE2eRunDir, { recursive: true, force: true });
}
if (originalMemoryContent !== null) {
await mkdir(dirname(memoryFilePath), { recursive: true });
await writeFile(memoryFilePath, originalMemoryContent, 'utf-8');

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',
);
});
});

View File

@@ -0,0 +1,486 @@
/**
* E2E tests based on abort-and-lifecycle.ts example
* Tests AbortController integration and process lifecycle management
*/
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
AbortError,
isAbortError,
isSDKAssistantMessage,
type TextBlock,
type ContentBlock,
} from '@qwen-code/sdk';
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
describe('AbortController and Process Lifecycle (E2E)', () => {
let helper: SDKTestHelper;
let testDir: string;
beforeEach(async () => {
helper = new SDKTestHelper();
testDir = await helper.setup('abort-and-lifecycle');
});
afterEach(async () => {
await helper.cleanup();
});
describe('Basic AbortController Usage', () => {
it('should support AbortController cancellation', async () => {
const controller = new AbortController();
// Abort after 5 seconds
setTimeout(() => {
controller.abort();
}, 5000);
const q = query({
prompt: 'Write a very long story about TypeScript programming',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
abortController: controller,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 100);
// Should receive some content before abort
expect(text.length).toBeGreaterThan(0);
}
}
// Should not reach here - query should be aborted
expect(false).toBe(true);
} catch (error) {
expect(isAbortError(error)).toBe(true);
} finally {
await q.close();
}
});
it('should handle abort during query execution', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
abortController: controller,
debug: false,
},
});
let receivedFirstMessage = false;
try {
for await (const message of q) {
if (isSDKAssistantMessage(message)) {
if (!receivedFirstMessage) {
// Abort immediately after receiving first assistant message
receivedFirstMessage = true;
controller.abort();
}
}
}
} catch (error) {
expect(isAbortError(error)).toBe(true);
expect(error instanceof AbortError).toBe(true);
// Should have received at least one message before abort
expect(receivedFirstMessage).toBe(true);
} finally {
await q.close();
}
});
it('should handle abort immediately after query starts', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Write a very long essay',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
abortController: controller,
debug: false,
},
});
// Abort immediately after query initialization
setTimeout(() => {
controller.abort();
}, 200);
try {
for await (const _message of q) {
// May or may not receive messages before abort
}
} catch (error) {
expect(isAbortError(error)).toBe(true);
expect(error instanceof AbortError).toBe(true);
} finally {
await q.close();
}
});
});
describe('Process Lifecycle Monitoring', () => {
it('should handle normal process completion', async () => {
const q = query({
prompt: 'Why do we choose to go to the moon?',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
let completedSuccessfully = false;
try {
for await (const message of q) {
if (isSDKAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 100);
expect(text.length).toBeGreaterThan(0);
}
}
completedSuccessfully = true;
} catch (error) {
// Should not throw for normal completion
expect(false).toBe(true);
} finally {
await q.close();
expect(completedSuccessfully).toBe(true);
}
});
it('should handle process cleanup after error', async () => {
const q = query({
prompt: 'Hello world',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 50);
expect(text.length).toBeGreaterThan(0);
}
}
} catch (error) {
// Expected to potentially have errors
} finally {
// Should cleanup successfully even after error
await q.close();
expect(true).toBe(true); // Cleanup completed
}
});
});
describe('Input Stream Control', () => {
it('should support endInput() method', async () => {
const q = query({
prompt: 'What is 2 + 2?',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
let receivedResponse = false;
let endInputCalled = false;
try {
for await (const message of q) {
if (isSDKAssistantMessage(message) && !endInputCalled) {
const textBlocks = message.message.content.filter(
(block: ContentBlock): block is TextBlock =>
block.type === 'text',
);
const text = textBlocks.map((b: TextBlock) => b.text).join('');
expect(text.length).toBeGreaterThan(0);
receivedResponse = true;
// End input after receiving first response
q.endInput();
endInputCalled = true;
}
}
expect(receivedResponse).toBe(true);
expect(endInputCalled).toBe(true);
} finally {
await q.close();
}
});
});
describe('Error Handling and Recovery', () => {
it('should handle invalid executable path', async () => {
try {
const q = query({
prompt: 'Hello world',
options: {
pathToQwenExecutable: '/nonexistent/path/to/cli',
debug: false,
},
});
// Should not reach here - query() should throw immediately
for await (const _message of q) {
// Should not reach here
}
// Should not reach here
expect(false).toBe(true);
} catch (error) {
expect(error instanceof Error).toBe(true);
expect((error as Error).message).toBeDefined();
expect((error as Error).message).toContain(
'Invalid pathToQwenExecutable',
);
}
});
it('should throw AbortError with correct properties', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Explain the concept of async programming',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
abortController: controller,
debug: false,
},
});
// Abort after allowing query to start
setTimeout(() => controller.abort(), 1000);
try {
for await (const _message of q) {
// May receive some messages before abort
}
} catch (error) {
// Verify error type and helper functions
expect(isAbortError(error)).toBe(true);
expect(error instanceof AbortError).toBe(true);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBeDefined();
} finally {
await q.close();
}
});
});
describe('Debugging with stderr callback', () => {
it('should capture stderr messages when debug is enabled', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Why do we choose to go to the moon?',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
try {
for await (const message of q) {
if (isSDKAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 50);
expect(text.length).toBeGreaterThan(0);
}
}
} finally {
await q.close();
expect(stderrMessages.length).toBeGreaterThan(0);
}
});
it('should not capture stderr when debug is disabled', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
try {
for await (const _message of q) {
// Consume all messages
}
} finally {
await q.close();
// Should have minimal or no stderr output when debug is false
expect(stderrMessages.length).toBeLessThan(10);
}
});
});
describe('Abort with Cleanup', () => {
it('should cleanup properly after abort', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Write a very long essay about programming',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
abortController: controller,
debug: false,
},
});
// Abort immediately
setTimeout(() => controller.abort(), 100);
try {
for await (const _message of q) {
// May receive some messages before abort
}
} catch (error) {
if (error instanceof AbortError) {
expect(true).toBe(true); // Expected abort error
} else {
throw error; // Unexpected error
}
} finally {
await q.close();
expect(true).toBe(true); // Cleanup completed after abort
}
});
it('should handle multiple abort calls gracefully', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Count to 100',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
abortController: controller,
debug: false,
},
});
// Multiple abort calls
setTimeout(() => controller.abort(), 100);
setTimeout(() => controller.abort(), 200);
setTimeout(() => controller.abort(), 300);
try {
for await (const _message of q) {
// Should be interrupted
}
} catch (error) {
expect(isAbortError(error)).toBe(true);
} finally {
await q.close();
}
});
});
describe('Resource Management Edge Cases', () => {
it('should handle close() called multiple times', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
// Start the query
const iterator = q[Symbol.asyncIterator]();
await iterator.next();
// Close multiple times
await q.close();
await q.close();
await q.close();
// Should not throw
expect(true).toBe(true);
});
it('should handle abort after close', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
abortController: controller,
debug: false,
},
});
// Start and close immediately
const iterator = q[Symbol.asyncIterator]();
await iterator.next();
await q.close();
// Abort after close
controller.abort();
// Should not throw
expect(true).toBe(true);
});
});
});

View File

@@ -0,0 +1,640 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* E2E tests for SDK configuration options:
* - logLevel: Controls SDK internal logging verbosity
* - env: Environment variables passed to CLI process
* - authType: Authentication type for AI service
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKAssistantMessage,
isSDKSystemMessage,
type SDKMessage,
} from '@qwen-code/sdk';
import {
SDKTestHelper,
extractText,
createSharedTestOptions,
assertSuccessfulCompletion,
} from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
describe('Configuration Options (E2E)', () => {
let helper: SDKTestHelper;
let testDir: string;
beforeEach(async () => {
helper = new SDKTestHelper();
testDir = await helper.setup('configuration-options');
});
afterEach(async () => {
await helper.cleanup();
});
describe('logLevel Option', () => {
it('should respect logLevel: debug and capture detailed logs', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'What is 1 + 1? Just answer the number.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
logLevel: 'debug',
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Debug level should produce verbose logging
expect(stderrMessages.length).toBeGreaterThan(0);
// Debug logs should contain detailed information like [DEBUG]
const hasDebugLogs = stderrMessages.some(
(msg) => msg.includes('[DEBUG]') || msg.includes('debug'),
);
expect(hasDebugLogs).toBe(true);
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
it('should respect logLevel: info and filter out debug messages', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'What is 2 + 2? Just answer the number.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
logLevel: 'info',
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Info level should filter out debug messages
// Check that we don't have [DEBUG] level messages from the SDK logger
const sdkDebugLogs = stderrMessages.filter(
(msg) =>
msg.includes('[DEBUG]') && msg.includes('[ProcessTransport]'),
);
expect(sdkDebugLogs.length).toBe(0);
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
it('should respect logLevel: warn and only show warnings and errors', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
logLevel: 'warn',
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Warn level should filter out info and debug messages from SDK
const sdkInfoOrDebugLogs = stderrMessages.filter(
(msg) =>
(msg.includes('[DEBUG]') || msg.includes('[INFO]')) &&
(msg.includes('[ProcessTransport]') ||
msg.includes('[createQuery]') ||
msg.includes('[Query]')),
);
expect(sdkInfoOrDebugLogs.length).toBe(0);
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
it('should respect logLevel: error and only show error messages', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello world',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
logLevel: 'error',
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Error level should filter out all non-error messages from SDK
const sdkNonErrorLogs = stderrMessages.filter(
(msg) =>
(msg.includes('[DEBUG]') ||
msg.includes('[INFO]') ||
msg.includes('[WARN]')) &&
(msg.includes('[ProcessTransport]') ||
msg.includes('[createQuery]') ||
msg.includes('[Query]')),
);
expect(sdkNonErrorLogs.length).toBe(0);
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
it('should use logLevel over debug flag when both are provided', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'What is 3 + 3?',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: true, // Would normally enable debug logging
logLevel: 'error', // But logLevel should take precedence
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
try {
for await (const _message of q) {
// Consume all messages
}
// logLevel: error should suppress debug/info/warn even with debug: true
const sdkNonErrorLogs = stderrMessages.filter(
(msg) =>
(msg.includes('[DEBUG]') ||
msg.includes('[INFO]') ||
msg.includes('[WARN]')) &&
(msg.includes('[ProcessTransport]') ||
msg.includes('[createQuery]') ||
msg.includes('[Query]')),
);
expect(sdkNonErrorLogs.length).toBe(0);
} finally {
await q.close();
}
});
});
describe('env Option', () => {
it('should pass custom environment variables to CLI process', async () => {
const q = query({
prompt: 'What is 1 + 1? Just the number please.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
env: {
CUSTOM_TEST_VAR: 'test_value_12345',
ANOTHER_VAR: 'another_value',
},
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// The query should complete successfully with custom env vars
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
it('should allow overriding existing environment variables', async () => {
// Store original value for comparison
const originalPath = process.env['PATH'];
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
env: {
// Override an existing env var (not PATH as it might break things)
MY_TEST_OVERRIDE: 'overridden_value',
},
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Query should complete successfully
assertSuccessfulCompletion(messages);
// Verify original process env is not modified
expect(process.env['PATH']).toBe(originalPath);
} finally {
await q.close();
}
});
it('should work with empty env object', async () => {
const q = query({
prompt: 'What is 2 + 2?',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
env: {},
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
it('should support setting model-related environment variables', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
env: {
// Common model-related env vars that CLI might respect
OPENAI_API_KEY: process.env['OPENAI_API_KEY'] || 'test-key',
},
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Should complete (may succeed or fail based on API key validity)
expect(messages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
});
it('should not leak env vars between query instances', async () => {
// First query with specific env var
const q1 = query({
prompt: 'Say one',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
env: {
ISOLATED_VAR_1: 'value_1',
},
debug: false,
},
});
try {
for await (const _message of q1) {
// Consume messages
}
} finally {
await q1.close();
}
// Second query with different env var
const q2 = query({
prompt: 'Say two',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
env: {
ISOLATED_VAR_2: 'value_2',
},
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q2) {
messages.push(message);
}
// Second query should complete successfully
assertSuccessfulCompletion(messages);
// Verify process.env is not polluted by either query
expect(process.env['ISOLATED_VAR_1']).toBeUndefined();
expect(process.env['ISOLATED_VAR_2']).toBeUndefined();
} finally {
await q2.close();
}
});
});
describe('authType Option', () => {
it('should accept authType: openai', async () => {
const q = query({
prompt: 'What is 1 + 1? Just the number.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
authType: 'openai',
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Query should complete with openai auth type
assertSuccessfulCompletion(messages);
// Verify we got an assistant response
const assistantMessages = messages.filter(isSDKAssistantMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
});
// Skip in containerized sandbox environments - qwen-oauth requires user interaction
// which is not possible in Docker/Podman CI environments
it.skipIf(
process.env['SANDBOX'] === 'sandbox:docker' ||
process.env['SANDBOX'] === 'sandbox:podman',
)('should accept authType: qwen-oauth', async () => {
// Note: qwen-oauth requires credentials in ~/.qwen and user interaction
// Without credentials, the auth process will timeout waiting for user
// This test verifies the option is accepted and passed correctly to CLI
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
authType: 'qwen-oauth',
debug: true,
logLevel: 'debug',
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
const messages: SDKMessage[] = [];
try {
// Use a timeout to avoid hanging when credentials are not configured
const timeoutPromise = new Promise<'timeout'>((resolve) =>
setTimeout(() => resolve('timeout'), 20000),
);
const collectMessages = async () => {
for await (const message of q) {
messages.push(message);
}
return 'completed';
};
const result = await Promise.race([collectMessages(), timeoutPromise]);
if (result === 'timeout') {
// Timeout is expected when OAuth credentials are not configured
// Verify that CLI was spawned with correct --auth-type argument
const hasAuthTypeArg = stderrMessages.some((msg) =>
msg.includes('--auth-type'),
);
expect(hasAuthTypeArg).toBe(true);
} else {
// If credentials exist and auth completed, verify we got messages
expect(messages.length).toBeGreaterThan(0);
}
} finally {
await q.close();
}
});
it('should use default auth when authType is not specified', async () => {
const q = query({
prompt: 'What is 2 + 2? Just the number.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
// authType not specified - should use default
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Query should complete with default auth
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
it('should properly pass authType to CLI process', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Say hi',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
authType: 'openai',
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// There should be spawn log containing auth-type
const hasAuthTypeArg = stderrMessages.some((msg) =>
msg.includes('--auth-type'),
);
expect(hasAuthTypeArg).toBe(true);
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
});
describe('Combined Options', () => {
it('should work with logLevel, env, and authType together', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'What is 3 + 3? Just the number.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
logLevel: 'debug',
env: {
COMBINED_TEST_VAR: 'combined_value',
},
authType: 'openai',
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
assistantText += extractText(message.message.content);
}
}
// All three options should work together
expect(stderrMessages.length).toBeGreaterThan(0); // logLevel: debug produces logs
expect(assistantText).toMatch(/6/); // Query should work
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
it('should maintain system message consistency with all options', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
logLevel: 'info',
env: {
SYSTEM_MSG_TEST: 'test',
},
authType: 'openai',
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Should have system init message
const systemMessages = messages.filter(isSDKSystemMessage);
const initMessage = systemMessages.find((m) => m.subtype === 'init');
expect(initMessage).toBeDefined();
expect(initMessage!.session_id).toBeDefined();
expect(initMessage!.tools).toBeDefined();
expect(initMessage!.permission_mode).toBeDefined();
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
});
});

View File

@@ -0,0 +1,405 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* E2E tests for MCP (Model Context Protocol) server integration via SDK
* Tests that the SDK can properly interact with MCP servers configured in qwen-code
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKAssistantMessage,
isSDKResultMessage,
isSDKSystemMessage,
isSDKUserMessage,
type SDKMessage,
type ToolUseBlock,
type SDKSystemMessage,
} from '@qwen-code/sdk';
import {
SDKTestHelper,
createMCPServer,
extractText,
findToolUseBlocks,
createSharedTestOptions,
} from './test-helper.js';
const SHARED_TEST_OPTIONS = {
...createSharedTestOptions(),
permissionMode: 'yolo' as const,
};
describe('MCP Server Integration (E2E)', () => {
let helper: SDKTestHelper;
let serverScriptPath: string;
let testDir: string;
beforeEach(async () => {
// Create isolated test environment using SDKTestHelper
helper = new SDKTestHelper();
testDir = await helper.setup('mcp-server-integration');
// Create MCP server using the helper utility
const mcpServer = await createMCPServer(helper, 'math', 'test-math-server');
serverScriptPath = mcpServer.scriptPath;
});
afterEach(async () => {
// Cleanup test directory
await helper.cleanup();
});
describe('Basic MCP Tool Usage', () => {
it('should use MCP add tool to add two numbers', async () => {
const q = query({
prompt:
'Use the add tool to calculate 5 + 10. Just give me the result.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
let foundToolUse = false;
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(message, 'add');
if (toolUseBlocks.length > 0) {
foundToolUse = true;
}
assistantText += extractText(message.message.content);
}
}
// Validate tool was called
expect(foundToolUse).toBe(true);
// Validate result contains expected answer
expect(assistantText).toMatch(/15/);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isSDKResultMessage(lastMessage)).toBe(true);
if (isSDKResultMessage(lastMessage)) {
expect(lastMessage.subtype).toBe('success');
}
} finally {
await q.close();
}
});
it('should use MCP multiply tool to multiply two numbers', async () => {
const q = query({
prompt:
'Use the multiply tool to calculate 6 * 7. Just give me the result.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
let foundToolUse = false;
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(message, 'multiply');
if (toolUseBlocks.length > 0) {
foundToolUse = true;
}
assistantText += extractText(message.message.content);
}
}
// Validate tool was called
expect(foundToolUse).toBe(true);
// Validate result contains expected answer
expect(assistantText).toMatch(/42/);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isSDKResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
});
});
describe('MCP Server Discovery', () => {
it('should list MCP servers in system init message', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
let systemMessage: SDKSystemMessage | null = null;
try {
for await (const message of q) {
if (isSDKSystemMessage(message) && message.subtype === 'init') {
systemMessage = message;
break;
}
}
// Validate MCP server is listed
expect(systemMessage).not.toBeNull();
expect(systemMessage!.mcp_servers).toBeDefined();
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
// Find our test server
const testServer = systemMessage!.mcp_servers?.find(
(server) => server.name === 'test-math-server',
);
expect(testServer).toBeDefined();
// Note: tools are not exposed in the mcp_servers array in system message
// They are available through the MCP protocol but not in the init message
} finally {
await q.close();
}
});
});
describe('Complex MCP Operations', () => {
it('should chain multiple MCP tool calls', async () => {
const q = query({
prompt:
'First use add to calculate 10 + 5, then multiply the result by 2. Give me the final answer.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
const toolCalls: string[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(message);
toolUseBlocks.forEach((block) => {
toolCalls.push(block.name);
});
assistantText += extractText(message.message.content);
}
}
// Validate both tools were called
expect(toolCalls).toContain('add');
expect(toolCalls).toContain('multiply');
// Validate result: (10 + 5) * 2 = 30
expect(assistantText).toMatch(/30/);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isSDKResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
});
it('should handle multiple calls to the same MCP tool', async () => {
const q = query({
prompt:
'Use the add tool twice: first add 1 + 2, then add 3 + 4. Tell me both results.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
const addToolCalls: ToolUseBlock[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(message, 'add');
addToolCalls.push(...toolUseBlocks);
assistantText += extractText(message.message.content);
}
}
// Validate add tool was called at least twice
expect(addToolCalls.length).toBeGreaterThanOrEqual(2);
// Validate results contain expected answers: 3 and 7
expect(assistantText).toMatch(/3/);
expect(assistantText).toMatch(/7/);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isSDKResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
});
});
describe('MCP Tool Message Flow', () => {
it('should receive proper message sequence for MCP tool usage', async () => {
const q = query({
prompt: 'Use add to calculate 2 + 3',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messageTypes: string[] = [];
let foundToolUse = false;
let foundToolResult = false;
try {
for await (const message of q) {
messageTypes.push(message.type);
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(message);
if (toolUseBlocks.length > 0) {
foundToolUse = true;
expect(toolUseBlocks[0].name).toBe('add');
expect(toolUseBlocks[0].input).toBeDefined();
}
}
if (isSDKUserMessage(message)) {
const content = message.message.content;
const contentArray = Array.isArray(content)
? content
: [{ type: 'text', text: content }];
const toolResultBlock = contentArray.find(
(block) => block.type === 'tool_result',
);
if (toolResultBlock) {
foundToolResult = true;
}
}
}
// Validate message flow
expect(foundToolUse).toBe(true);
expect(foundToolResult).toBe(true);
expect(messageTypes).toContain('system');
expect(messageTypes).toContain('assistant');
expect(messageTypes).toContain('user');
expect(messageTypes).toContain('result');
// Result should be last message
expect(messageTypes[messageTypes.length - 1]).toBe('result');
} finally {
await q.close();
}
});
});
describe('Error Handling', () => {
it('should handle gracefully when MCP tool is not available', async () => {
const q = query({
prompt: 'Use the subtract tool to calculate 10 - 5',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
assistantText += extractText(message.message.content);
}
}
// Should complete without crashing
const lastMessage = messages[messages.length - 1];
expect(isSDKResultMessage(lastMessage)).toBe(true);
// Assistant should indicate tool is not available or provide alternative
expect(assistantText.length).toBeGreaterThan(0);
} finally {
await q.close();
}
});
});
});

View File

@@ -0,0 +1,559 @@
/**
* E2E tests based on multi-turn.ts example
* Tests multi-turn conversation functionality with real CLI
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKUserMessage,
isSDKAssistantMessage,
isSDKSystemMessage,
isSDKResultMessage,
isSDKPartialAssistantMessage,
isControlRequest,
isControlResponse,
isControlCancel,
type SDKUserMessage,
type SDKAssistantMessage,
type TextBlock,
type ContentBlock,
type SDKMessage,
type ControlMessage,
type ToolUseBlock,
} from '@qwen-code/sdk';
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
/**
* Determine the message type using protocol type guards
*/
function getMessageType(message: SDKMessage | ControlMessage): string {
if (isSDKUserMessage(message)) {
return '🧑 USER';
} else if (isSDKAssistantMessage(message)) {
return '🤖 ASSISTANT';
} else if (isSDKSystemMessage(message)) {
return `🖥️ SYSTEM(${message.subtype})`;
} else if (isSDKResultMessage(message)) {
return `✅ RESULT(${message.subtype})`;
} else if (isSDKPartialAssistantMessage(message)) {
return '⏳ STREAM_EVENT';
} else if (isControlRequest(message)) {
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
} else if (isControlResponse(message)) {
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
} else if (isControlCancel(message)) {
return '🛑 CONTROL_CANCEL';
} else {
return '❓ UNKNOWN';
}
}
/**
* Helper to extract text from ContentBlock array
*/
function extractText(content: ContentBlock[]): string {
return content
.filter((block): block is TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
}
describe('Multi-Turn Conversations (E2E)', () => {
let helper: SDKTestHelper;
let testDir: string;
beforeEach(async () => {
helper = new SDKTestHelper();
testDir = await helper.setup('multi-turn');
});
afterEach(async () => {
await helper.cleanup();
});
describe('AsyncIterable Prompt Support', () => {
it('should handle multi-turn conversation using AsyncIterable prompt', async () => {
// Create multi-turn conversation generator
async function* createMultiTurnConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 1 + 1?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 2 + 2?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 3 + 3?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
// Create multi-turn query using AsyncIterable prompt
const q = query({
prompt: createMultiTurnConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const messages: SDKMessage[] = [];
const assistantMessages: SDKAssistantMessage[] = [];
const assistantTexts: string[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
const text = extractText(message.message.content);
assistantTexts.push(text);
}
}
expect(messages.length).toBeGreaterThan(0);
expect(assistantMessages.length).toBeGreaterThanOrEqual(3);
// Validate content of responses
expect(assistantTexts[0]).toMatch(/2/);
expect(assistantTexts[1]).toMatch(/4/);
expect(assistantTexts[2]).toMatch(/6/);
} finally {
await q.close();
}
});
it('should maintain session context across turns', async () => {
async function* createContextualConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content:
'Suppose we have 3 rabbits and 4 carrots. How many animals are there?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'How many animals are there? Only output the number',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createContextualConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const assistantMessages: SDKAssistantMessage[] = [];
try {
for await (const message of q) {
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
}
}
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
// The second response should reference the color blue
const secondResponse = extractText(
assistantMessages[1].message.content,
);
expect(secondResponse.toLowerCase()).toContain('3');
} finally {
await q.close();
}
});
});
describe('Tool Usage in Multi-Turn', () => {
it('should handle tool usage across multiple turns', async () => {
async function* createToolConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Create a file named test.txt with content "hello"',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Now read the test.txt file',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createToolConversation(),
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'yolo',
cwd: testDir,
debug: false,
},
});
const messages: SDKMessage[] = [];
let toolUseCount = 0;
const assistantMessages: SDKAssistantMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
const hasToolUseBlock = message.message.content.some(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
if (hasToolUseBlock) {
toolUseCount++;
}
}
}
expect(messages.length).toBeGreaterThan(0);
expect(toolUseCount).toBeGreaterThan(0);
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
// Validate second response mentions the file content
const secondResponse = extractText(
assistantMessages[assistantMessages.length - 1].message.content,
);
expect(secondResponse.toLowerCase()).toMatch(/hello|test\.txt/);
} finally {
await q.close();
}
});
});
describe('Message Flow and Sequencing', () => {
it('should process messages in correct sequence', async () => {
async function* createSequentialConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'First question: What is 1 + 1?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Second question: What is 2 + 2?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createSequentialConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const messageSequence: string[] = [];
const assistantResponses: string[] = [];
try {
for await (const message of q) {
const messageType = getMessageType(message);
messageSequence.push(messageType);
if (isSDKAssistantMessage(message)) {
const text = extractText(message.message.content);
assistantResponses.push(text);
}
}
expect(messageSequence.length).toBeGreaterThan(0);
expect(assistantResponses.length).toBeGreaterThanOrEqual(2);
// Should end with result
expect(messageSequence[messageSequence.length - 1]).toContain('RESULT');
// Should have assistant responses
expect(messageSequence.some((type) => type.includes('ASSISTANT'))).toBe(
true,
);
} finally {
await q.close();
}
});
it('should handle conversation completion correctly', async () => {
async function* createSimpleConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Hello',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Goodbye',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createSimpleConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
let completedNaturally = false;
let messageCount = 0;
try {
for await (const message of q) {
messageCount++;
if (isSDKResultMessage(message)) {
completedNaturally = true;
expect(message.subtype).toBe('success');
}
}
expect(messageCount).toBeGreaterThan(0);
expect(completedNaturally).toBe(true);
} finally {
await q.close();
}
});
});
describe('Error Handling in Multi-Turn', () => {
it('should handle empty conversation gracefully', async () => {
async function* createEmptyConversation(): AsyncIterable<SDKUserMessage> {
// Generator that yields nothing
/* eslint-disable no-constant-condition */
if (false) {
yield {} as SDKUserMessage; // Unreachable, but satisfies TypeScript
}
}
const q = query({
prompt: createEmptyConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Should handle empty conversation without crashing
expect(true).toBe(true);
} finally {
await q.close();
}
});
it('should handle conversation with delays', async () => {
async function* createDelayedConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'First message',
},
parent_tool_use_id: null,
} as SDKUserMessage;
// Longer delay to test patience
await new Promise((resolve) => setTimeout(resolve, 500));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Second message after delay',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createDelayedConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const assistantMessages: SDKAssistantMessage[] = [];
try {
for await (const message of q) {
if (isSDKAssistantMessage(message)) {
assistantMessages.push(message);
}
}
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
} finally {
await q.close();
}
});
});
describe('Partial Messages in Multi-Turn', () => {
it('should receive partial messages when includePartialMessages is enabled', async () => {
async function* createMultiTurnConversation(): AsyncIterable<SDKUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 1 + 1?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 2 + 2?',
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
const q = query({
prompt: createMultiTurnConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
includePartialMessages: true,
debug: false,
},
});
const messages: SDKMessage[] = [];
let partialMessageCount = 0;
let assistantMessageCount = 0;
try {
for await (const message of q) {
messages.push(message);
if (isSDKPartialAssistantMessage(message)) {
partialMessageCount++;
}
if (isSDKAssistantMessage(message)) {
assistantMessageCount++;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(partialMessageCount).toBeGreaterThan(0);
expect(assistantMessageCount).toBeGreaterThanOrEqual(2);
} finally {
await q.close();
}
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,456 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* E2E tests for SDK-embedded MCP servers
*
* Tests that the SDK can create and manage MCP servers running in the SDK process
* using the tool() and createSdkMcpServer() APIs.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { z } from 'zod';
import {
query,
tool,
createSdkMcpServer,
isSDKAssistantMessage,
isSDKResultMessage,
isSDKSystemMessage,
type SDKMessage,
type SDKSystemMessage,
} from '@qwen-code/sdk';
import {
SDKTestHelper,
extractText,
findToolUseBlocks,
createSharedTestOptions,
} from './test-helper.js';
const SHARED_TEST_OPTIONS = {
...createSharedTestOptions(),
permissionMode: 'yolo' as const,
};
describe('SDK MCP Server Integration (E2E)', () => {
let helper: SDKTestHelper;
let testDir: string;
beforeEach(async () => {
helper = new SDKTestHelper();
testDir = await helper.setup('sdk-mcp-server-integration');
});
afterEach(async () => {
await helper.cleanup();
});
describe('Basic SDK MCP Tool Usage', () => {
it('should use SDK MCP tool to perform a simple calculation', async () => {
// Define a simple calculator tool using the tool() API with Zod schema
const calculatorTool = tool(
'calculate_sum',
'Calculate the sum of two numbers',
z.object({
a: z.number().describe('First number'),
b: z.number().describe('Second number'),
}).shape,
async (args) => ({
content: [{ type: 'text', text: String(args.a + args.b) }],
}),
);
// Create SDK MCP server with the tool
const serverConfig = createSdkMcpServer({
name: 'sdk-calculator',
version: '1.0.0',
tools: [calculatorTool],
});
const q = query({
prompt:
'Use the calculate_sum tool to add 25 and 17. Output the result of tool only.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
mcpServers: {
'sdk-calculator': serverConfig,
},
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
let foundToolUse = false;
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(message, 'calculate_sum');
if (toolUseBlocks.length > 0) {
foundToolUse = true;
}
assistantText += extractText(message.message.content);
}
}
// Validate tool was called
expect(foundToolUse).toBe(true);
// Validate result contains expected answer: 25 + 17 = 42
expect(assistantText).toMatch(/42/);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isSDKResultMessage(lastMessage)).toBe(true);
if (isSDKResultMessage(lastMessage)) {
expect(lastMessage.subtype).toBe('success');
}
} finally {
await q.close();
}
});
it('should use SDK MCP tool with string operations', async () => {
// Define a string manipulation tool with Zod schema
const stringTool = tool(
'reverse_string',
'Reverse a string',
{
text: z.string().describe('The text to reverse'),
},
async (args) => ({
content: [
{ type: 'text', text: args.text.split('').reverse().join('') },
],
}),
);
const serverConfig = createSdkMcpServer({
name: 'sdk-string-utils',
version: '1.0.0',
tools: [stringTool],
});
const q = query({
prompt: `Use the 'reverse_string' tool to process the word "hello world". Output the tool result only.`,
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
mcpServers: {
'sdk-string-utils': serverConfig,
},
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
let foundToolUse = false;
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(message, 'reverse_string');
if (toolUseBlocks.length > 0) {
foundToolUse = true;
}
assistantText += extractText(message.message.content);
}
}
// Validate tool was called
expect(foundToolUse).toBe(true);
// Validate result contains reversed string: "olleh"
expect(assistantText.toLowerCase()).toMatch(/olleh/);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isSDKResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
});
});
describe('Multiple SDK MCP Tools', () => {
it('should use multiple tools from the same SDK MCP server', async () => {
// Define the Zod schema shape for two numbers
const twoNumbersSchema = {
a: z.number().describe('First number'),
b: z.number().describe('Second number'),
};
// Define multiple tools
const addTool = tool(
'sdk_add',
'Add two numbers',
twoNumbersSchema,
async (args) => ({
content: [{ type: 'text', text: String(args.a + args.b) }],
}),
);
const multiplyTool = tool(
'sdk_multiply',
'Multiply two numbers',
twoNumbersSchema,
async (args) => ({
content: [{ type: 'text', text: String(args.a * args.b) }],
}),
);
const serverConfig = createSdkMcpServer({
name: 'sdk-math',
version: '1.0.0',
tools: [addTool, multiplyTool],
});
const q = query({
prompt:
'First use sdk_add to calculate 10 + 5, then use sdk_multiply to multiply the result by 3. Give me the final answer.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'sdk-math': serverConfig,
},
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
const toolCalls: string[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(message);
toolUseBlocks.forEach((block) => {
toolCalls.push(block.name);
});
assistantText += extractText(message.message.content);
}
}
// Validate both tools were called
expect(toolCalls).toContain('sdk_add');
expect(toolCalls).toContain('sdk_multiply');
// Validate result: (10 + 5) * 3 = 45
expect(assistantText).toMatch(/45/);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isSDKResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
});
});
describe('SDK MCP Server Discovery', () => {
it('should list SDK MCP servers in system init message', async () => {
// Define echo tool with Zod schema
const echoTool = tool(
'echo',
'Echo a message',
{
message: z.string().describe('Message to echo'),
},
async (args) => ({
content: [{ type: 'text', text: args.message }],
}),
);
const serverConfig = createSdkMcpServer({
name: 'sdk-echo',
version: '1.0.0',
tools: [echoTool],
});
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'sdk-echo': serverConfig,
},
},
});
let systemMessage: SDKSystemMessage | null = null;
try {
for await (const message of q) {
if (isSDKSystemMessage(message) && message.subtype === 'init') {
systemMessage = message;
break;
}
}
// Validate MCP server is listed
expect(systemMessage).not.toBeNull();
expect(systemMessage!.mcp_servers).toBeDefined();
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
// Find our SDK MCP server
const sdkServer = systemMessage!.mcp_servers?.find(
(server) => server.name === 'sdk-echo',
);
expect(sdkServer).toBeDefined();
} finally {
await q.close();
}
});
});
describe('SDK MCP Tool Error Handling', () => {
it('should handle tool errors gracefully', async () => {
// Define a tool that throws an error with Zod schema
const errorTool = tool(
'maybe_fail',
'A tool that may fail based on input',
{
shouldFail: z.boolean().describe('If true, the tool will fail'),
},
async (args) => {
if (args.shouldFail) {
throw new Error('Tool intentionally failed');
}
return { content: [{ type: 'text', text: 'Success!' }] };
},
);
const serverConfig = createSdkMcpServer({
name: 'sdk-error-test',
version: '1.0.0',
tools: [errorTool],
});
const q = query({
prompt:
'Use the maybe_fail tool with shouldFail set to true. Tell me what happens.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'sdk-error-test': serverConfig,
},
},
});
const messages: SDKMessage[] = [];
let foundToolUse = false;
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(message, 'maybe_fail');
if (toolUseBlocks.length > 0) {
foundToolUse = true;
}
}
}
// Tool should be called
expect(foundToolUse).toBe(true);
// Query should complete (even with tool error)
const lastMessage = messages[messages.length - 1];
expect(isSDKResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
});
});
describe('Async Tool Handlers', () => {
it('should handle async tool handlers with delays', async () => {
// Define a tool with async delay using Zod schema
const delayedTool = tool(
'delayed_response',
'Returns a value after a delay',
{
delay: z.number().describe('Delay in milliseconds (max 100)'),
value: z.string().describe('Value to return'),
},
async (args) => {
// Cap delay at 100ms for test performance
const actualDelay = Math.min(args.delay, 100);
await new Promise((resolve) => setTimeout(resolve, actualDelay));
return {
content: [{ type: 'text', text: `Delayed result: ${args.value}` }],
};
},
);
const serverConfig = createSdkMcpServer({
name: 'sdk-async',
version: '1.0.0',
tools: [delayedTool],
});
const q = query({
prompt:
'Use the delayed_response tool with delay=50 and value="test_async". Tell me the result.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'sdk-async': serverConfig,
},
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
let foundToolUse = false;
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(
message,
'delayed_response',
);
if (toolUseBlocks.length > 0) {
foundToolUse = true;
}
assistantText += extractText(message.message.content);
}
}
// Validate tool was called
expect(foundToolUse).toBe(true);
// Validate result contains the delayed response
expect(assistantText.toLowerCase()).toMatch(/test_async/i);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isSDKResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
});
});
});

View File

@@ -0,0 +1,528 @@
/**
* E2E tests for single-turn query execution
* Tests basic query patterns with simple prompts and clear output expectations
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKAssistantMessage,
isSDKSystemMessage,
isSDKResultMessage,
isSDKPartialAssistantMessage,
type SDKMessage,
type SDKSystemMessage,
type SDKAssistantMessage,
} from '@qwen-code/sdk';
import {
SDKTestHelper,
extractText,
createSharedTestOptions,
assertSuccessfulCompletion,
collectMessagesByType,
} from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
describe('Single-Turn Query (E2E)', () => {
let helper: SDKTestHelper;
let testDir: string;
beforeEach(async () => {
helper = new SDKTestHelper();
testDir = await helper.setup('single-turn');
});
afterEach(async () => {
await helper.cleanup();
});
describe('Simple Text Queries', () => {
it('should answer basic arithmetic question', async () => {
const q = query({
prompt: 'What is 2 + 2? Just give me the number.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: true,
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
assistantText += extractText(message.message.content);
}
}
// Validate we got messages
expect(messages.length).toBeGreaterThan(0);
// Validate assistant response content
expect(assistantText.length).toBeGreaterThan(0);
expect(assistantText).toMatch(/4/);
// Validate message flow ends with success
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
it('should answer simple factual question', async () => {
const q = query({
prompt: 'What is the capital of France? One word answer.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
assistantText += extractText(message.message.content);
}
}
// Validate content
expect(assistantText.length).toBeGreaterThan(0);
expect(assistantText.toLowerCase()).toContain('paris');
// Validate completion
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
it('should handle greeting and self-description', async () => {
const q = query({
prompt: 'Say hello and tell me your name in one sentence.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
assistantText += extractText(message.message.content);
}
}
// Validate content contains greeting
expect(assistantText.length).toBeGreaterThan(0);
expect(assistantText.toLowerCase()).toMatch(/hello|hi|greetings/);
// Validate message types
const assistantMessages = collectMessagesByType(
messages,
isSDKAssistantMessage,
);
expect(assistantMessages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
});
});
describe('System Initialization', () => {
it('should receive system message with initialization info', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const messages: SDKMessage[] = [];
let systemMessage: SDKSystemMessage | null = null;
try {
for await (const message of q) {
messages.push(message);
if (isSDKSystemMessage(message) && message.subtype === 'init') {
systemMessage = message;
}
}
// Validate system message exists and has required fields
expect(systemMessage).not.toBeNull();
expect(systemMessage!.type).toBe('system');
expect(systemMessage!.subtype).toBe('init');
expect(systemMessage!.uuid).toBeDefined();
expect(systemMessage!.session_id).toBeDefined();
expect(systemMessage!.cwd).toBeDefined();
expect(systemMessage!.tools).toBeDefined();
expect(Array.isArray(systemMessage!.tools)).toBe(true);
expect(systemMessage!.mcp_servers).toBeDefined();
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
expect(systemMessage!.model).toBeDefined();
expect(systemMessage!.permission_mode).toBeDefined();
expect(systemMessage!.qwen_code_version).toBeDefined();
// Validate system message appears early in sequence
const systemMessageIndex = messages.findIndex(
(msg) => isSDKSystemMessage(msg) && msg.subtype === 'init',
);
expect(systemMessageIndex).toBeGreaterThanOrEqual(0);
expect(systemMessageIndex).toBeLessThan(3);
} finally {
await q.close();
}
});
it('should maintain session ID consistency', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
let systemMessage: SDKSystemMessage | null = null;
const sessionId = q.getSessionId();
try {
for await (const message of q) {
if (isSDKSystemMessage(message) && message.subtype === 'init') {
systemMessage = message;
}
}
// Validate session IDs are consistent
expect(sessionId).toBeDefined();
expect(systemMessage).not.toBeNull();
expect(systemMessage!.session_id).toBeDefined();
expect(systemMessage!.uuid).toBeDefined();
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
} finally {
await q.close();
}
});
});
describe('Message Flow', () => {
it('should follow expected message sequence', async () => {
const q = query({
prompt: 'Say hi',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const messageTypes: string[] = [];
try {
for await (const message of q) {
messageTypes.push(message.type);
}
// Validate message sequence
expect(messageTypes.length).toBeGreaterThan(0);
expect(messageTypes).toContain('assistant');
expect(messageTypes[messageTypes.length - 1]).toBe('result');
} finally {
await q.close();
}
});
it('should complete iteration naturally', async () => {
const q = query({
prompt: 'Say goodbye',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
let completedNaturally = false;
let messageCount = 0;
try {
for await (const message of q) {
messageCount++;
if (isSDKResultMessage(message)) {
completedNaturally = true;
expect(message.subtype).toBe('success');
}
}
expect(messageCount).toBeGreaterThan(0);
expect(completedNaturally).toBe(true);
} finally {
await q.close();
}
});
});
describe('Configuration Options', () => {
it('should respect debug option and capture stderr', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
try {
for await (const _message of q) {
// Consume all messages
}
// Debug mode should produce stderr output
expect(stderrMessages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
});
it('should respect cwd option', async () => {
const q = query({
prompt: 'What is 1 + 1?',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
let hasResponse = false;
try {
for await (const message of q) {
if (isSDKAssistantMessage(message)) {
hasResponse = true;
}
}
expect(hasResponse).toBe(true);
} finally {
await q.close();
}
});
it('should receive partial messages when includePartialMessages is enabled', async () => {
const q = query({
prompt: 'Count from 1 to 5',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
includePartialMessages: true,
debug: false,
},
});
const messages: SDKMessage[] = [];
let partialMessageCount = 0;
let assistantMessageCount = 0;
try {
for await (const message of q) {
messages.push(message);
if (isSDKPartialAssistantMessage(message)) {
partialMessageCount++;
}
if (isSDKAssistantMessage(message)) {
assistantMessageCount++;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(partialMessageCount).toBeGreaterThan(0);
expect(assistantMessageCount).toBeGreaterThan(0);
} finally {
await q.close();
}
});
});
describe('Message Type Recognition', () => {
it('should correctly identify all message types', async () => {
const q = query({
prompt: 'What is 5 + 5?',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Validate type guards work correctly
const assistantMessages = collectMessagesByType(
messages,
isSDKAssistantMessage,
);
const resultMessages = collectMessagesByType(
messages,
isSDKResultMessage,
);
const systemMessages = collectMessagesByType(
messages,
isSDKSystemMessage,
);
expect(assistantMessages.length).toBeGreaterThan(0);
expect(resultMessages.length).toBeGreaterThan(0);
expect(systemMessages.length).toBeGreaterThan(0);
// Validate assistant message structure
const firstAssistant = assistantMessages[0];
expect(firstAssistant.message.content).toBeDefined();
expect(Array.isArray(firstAssistant.message.content)).toBe(true);
// Validate result message structure
const resultMessage = resultMessages[0];
expect(resultMessage.subtype).toBe('success');
} finally {
await q.close();
}
});
it('should extract text content from assistant messages', async () => {
const q = query({
prompt: 'Count from 1 to 3',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
let assistantMessage: SDKAssistantMessage | null = null;
try {
for await (const message of q) {
if (isSDKAssistantMessage(message)) {
assistantMessage = message;
}
}
expect(assistantMessage).not.toBeNull();
expect(assistantMessage!.message.content).toBeDefined();
// Validate content contains expected numbers
const text = extractText(assistantMessage!.message.content);
expect(text.length).toBeGreaterThan(0);
expect(text).toMatch(/1/);
expect(text).toMatch(/2/);
expect(text).toMatch(/3/);
} finally {
await q.close();
}
});
});
describe('Error Handling', () => {
it('should throw if CLI not found', async () => {
try {
const q = query({
prompt: 'Hello',
options: {
pathToQwenExecutable: '/nonexistent/path/to/cli',
debug: false,
},
});
for await (const _message of q) {
// Should not reach here
}
expect(false).toBe(true); // Should have thrown
} catch (error) {
expect(error).toBeDefined();
expect(error instanceof Error).toBe(true);
expect((error as Error).message).toContain(
'Invalid pathToQwenExecutable',
);
}
});
});
describe('Resource Management', () => {
it('should cleanup subprocess on close()', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
// Start and immediately close
const iterator = q[Symbol.asyncIterator]();
await iterator.next();
// Should close without error
await q.close();
expect(true).toBe(true); // Cleanup completed
});
it('should handle close() called multiple times', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
// Start the query
const iterator = q[Symbol.asyncIterator]();
await iterator.next();
// Close multiple times
await q.close();
await q.close();
await q.close();
// Should not throw
expect(true).toBe(true);
});
});
});

View File

@@ -0,0 +1,614 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* E2E tests for subagent configuration and execution
* Tests subagent delegation and task completion
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKAssistantMessage,
type SDKMessage,
type SubagentConfig,
type ContentBlock,
type ToolUseBlock,
} from '@qwen-code/sdk';
import {
SDKTestHelper,
extractText,
createSharedTestOptions,
findToolUseBlocks,
assertSuccessfulCompletion,
findSystemMessage,
} from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
describe('Subagents (E2E)', () => {
let helper: SDKTestHelper;
let testWorkDir: string;
beforeEach(async () => {
// Create isolated test environment using SDKTestHelper
helper = new SDKTestHelper();
testWorkDir = await helper.setup('subagent-tests');
// Create a simple test file for subagent to work with
await helper.createFile('test.txt', 'Hello from test file\n');
});
afterEach(async () => {
// Cleanup test directory
await helper.cleanup();
});
describe('Subagent Configuration', () => {
it('should accept session-level subagent configuration', async () => {
const simpleSubagent: SubagentConfig = {
name: 'simple-greeter',
description: 'A simple subagent that responds to greetings',
systemPrompt:
'You are a friendly greeter. When given a task, respond with a cheerful greeting.',
level: 'session',
};
const q = query({
prompt: 'Hello, let simple-greeter to say hi back to me.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [simpleSubagent],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Validate system message includes the subagent
const systemMessage = findSystemMessage(messages, 'init');
expect(systemMessage).not.toBeNull();
expect(systemMessage!.agents).toBeDefined();
expect(systemMessage!.agents).toContain('simple-greeter');
// Validate successful completion
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
});
it('should accept multiple subagent configurations', async () => {
const greeterAgent: SubagentConfig = {
name: 'greeter',
description: 'Responds to greetings',
systemPrompt: 'You are a friendly greeter.',
level: 'session',
};
const mathAgent: SubagentConfig = {
name: 'math-helper',
description: 'Helps with math problems',
systemPrompt: 'You are a math expert. Solve math problems clearly.',
level: 'session',
};
const q = query({
prompt: 'What is 5 + 5?',
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [greeterAgent, mathAgent],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Validate both subagents are registered
const systemMessage = findSystemMessage(messages, 'init');
expect(systemMessage).not.toBeNull();
expect(systemMessage!.agents).toBeDefined();
expect(systemMessage!.agents).toContain('greeter');
expect(systemMessage!.agents).toContain('math-helper');
expect(systemMessage!.agents!.length).toBeGreaterThanOrEqual(2);
} finally {
await q.close();
}
});
it('should handle subagent with custom model config', async () => {
const customModelAgent: SubagentConfig = {
name: 'custom-model-agent',
description: 'Agent with custom model configuration',
systemPrompt: 'You are a helpful assistant.',
level: 'session',
modelConfig: {
temp: 0.7,
top_p: 0.9,
},
};
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [customModelAgent],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Validate subagent is registered
const systemMessage = findSystemMessage(messages, 'init');
expect(systemMessage).not.toBeNull();
expect(systemMessage!.agents).toBeDefined();
expect(systemMessage!.agents).toContain('custom-model-agent');
} finally {
await q.close();
}
});
it('should handle subagent with run config', async () => {
const limitedAgent: SubagentConfig = {
name: 'limited-agent',
description: 'Agent with execution limits',
systemPrompt: 'You are a helpful assistant.',
level: 'session',
runConfig: {
max_turns: 5,
max_time_minutes: 1,
},
};
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [limitedAgent],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Validate subagent is registered
const systemMessage = findSystemMessage(messages, 'init');
expect(systemMessage).not.toBeNull();
expect(systemMessage!.agents).toBeDefined();
expect(systemMessage!.agents).toContain('limited-agent');
} finally {
await q.close();
}
});
it('should handle subagent with specific tools', async () => {
const toolRestrictedAgent: SubagentConfig = {
name: 'read-only-agent',
description: 'Agent that can only read files',
systemPrompt:
'You are a file reading assistant. Read files when asked.',
level: 'session',
tools: ['read_file', 'list_directory'],
};
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [toolRestrictedAgent],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Validate subagent is registered
const systemMessage = findSystemMessage(messages, 'init');
expect(systemMessage).not.toBeNull();
expect(systemMessage!.agents).toBeDefined();
expect(systemMessage!.agents).toContain('read-only-agent');
} finally {
await q.close();
}
});
});
describe('Subagent Execution', () => {
it('should delegate task to subagent when appropriate', async () => {
const fileReaderAgent: SubagentConfig = {
name: 'file-reader',
description: 'Reads and reports file contents',
systemPrompt: `You are a file reading assistant. When given a task to read a file, use the read_file tool to read it and report its contents back. Be concise in your response.`,
level: 'session',
tools: ['read_file', 'list_directory'],
};
const testFile = helper.getPath('test.txt');
const q = query({
prompt: `Use the file-reader subagent to read the file at ${testFile} and tell me what it contains.`,
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [fileReaderAgent],
debug: false,
permissionMode: 'yolo',
},
});
const messages: SDKMessage[] = [];
let foundTaskTool = false;
let taskToolUseId: string | null = null;
let foundSubagentToolCall = false;
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
// Check for task tool use in content blocks (main agent calling subagent)
const taskToolBlocks = findToolUseBlocks(message, 'task');
if (taskToolBlocks.length > 0) {
foundTaskTool = true;
taskToolUseId = taskToolBlocks[0].id;
}
// Check if this message is from a subagent (has parent_tool_use_id)
if (message.parent_tool_use_id !== null) {
// This is a subagent message
const subagentToolBlocks = findToolUseBlocks(message);
if (subagentToolBlocks.length > 0) {
foundSubagentToolCall = true;
// Verify parent_tool_use_id matches the task tool use id
expect(message.parent_tool_use_id).toBe(taskToolUseId);
}
}
assistantText += extractText(message.message.content);
}
}
// Validate task tool was used (subagent delegation)
expect(foundTaskTool).toBe(true);
expect(taskToolUseId).not.toBeNull();
// Validate subagent actually made tool calls with proper parent_tool_use_id
expect(foundSubagentToolCall).toBe(true);
// Validate we got a response
expect(assistantText.length).toBeGreaterThan(0);
// Validate successful completion
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
}, 60000); // Increase timeout for subagent execution
it('should complete simple task with subagent', async () => {
const simpleTaskAgent: SubagentConfig = {
name: 'simple-calculator',
description: 'Performs simple arithmetic calculations',
systemPrompt:
'You are a calculator. When given a math problem, solve it and provide just the answer.',
level: 'session',
};
const q = query({
prompt: 'Use the simple-calculator subagent to calculate 15 + 27.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [simpleTaskAgent],
debug: false,
permissionMode: 'yolo',
},
});
const messages: SDKMessage[] = [];
let foundTaskTool = false;
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
// Check for task tool use (main agent delegating to subagent)
const toolUseBlock = message.message.content.find(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use' && block.name === 'task',
);
if (toolUseBlock) {
foundTaskTool = true;
}
assistantText += extractText(message.message.content);
}
}
// Validate task tool was used (subagent was called)
expect(foundTaskTool).toBe(true);
// Validate we got a response
expect(assistantText.length).toBeGreaterThan(0);
// Validate successful completion
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
}, 60000);
it('should verify subagent execution with comprehensive parent_tool_use_id checks', async () => {
const comprehensiveAgent: SubagentConfig = {
name: 'comprehensive-agent',
description: 'Agent for comprehensive testing',
systemPrompt:
'You are a helpful assistant. When asked to list files, use the list_directory tool.',
level: 'session',
tools: ['list_directory', 'read_file'],
};
const q = query({
prompt: `Use the comprehensive-agent subagent to list the files in ${testWorkDir}.`,
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [comprehensiveAgent],
debug: false,
permissionMode: 'yolo',
},
});
const messages: SDKMessage[] = [];
let taskToolUseId: string | null = null;
const subagentToolCalls: ToolUseBlock[] = [];
const mainAgentToolCalls: ToolUseBlock[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
// Collect all tool use blocks
const toolUseBlocks = message.message.content.filter(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
for (const toolUse of toolUseBlocks) {
if (toolUse.name === 'task') {
// This is the main agent calling the subagent
taskToolUseId = toolUse.id;
mainAgentToolCalls.push(toolUse);
}
// If this message has parent_tool_use_id, it's from a subagent
if (message.parent_tool_use_id !== null) {
subagentToolCalls.push(toolUse);
}
}
}
}
// Criterion 1: When a subagent is called, there must be a 'task' tool being called
expect(taskToolUseId).not.toBeNull();
expect(mainAgentToolCalls.length).toBeGreaterThan(0);
expect(mainAgentToolCalls.some((tc) => tc.name === 'task')).toBe(true);
// Criterion 2: A tool call from a subagent is identified by a non-null parent_tool_use_id
// All subagent tool calls should have parent_tool_use_id set to the task tool's id
expect(subagentToolCalls.length).toBeGreaterThan(0);
// Verify all subagent messages have the correct parent_tool_use_id
const subagentMessages = messages.filter(
(msg): msg is SDKMessage & { parent_tool_use_id: string } =>
isSDKAssistantMessage(msg) && msg.parent_tool_use_id !== null,
);
expect(subagentMessages.length).toBeGreaterThan(0);
for (const subagentMsg of subagentMessages) {
expect(subagentMsg.parent_tool_use_id).toBe(taskToolUseId);
}
// Verify no main agent tool calls (except task) have parent_tool_use_id
const mainAgentMessages = messages.filter(
(msg): msg is SDKMessage =>
isSDKAssistantMessage(msg) && msg.parent_tool_use_id === null,
);
for (const mainMsg of mainAgentMessages) {
if (isSDKAssistantMessage(mainMsg)) {
// Main agent messages should not have parent_tool_use_id
expect(mainMsg.parent_tool_use_id).toBeNull();
}
}
// Validate successful completion
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
}, 60000);
});
describe('Subagent Error Handling', () => {
it('should handle empty subagent array', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Should still work with empty agents array
const systemMessage = findSystemMessage(messages, 'init');
expect(systemMessage).not.toBeNull();
expect(systemMessage!.agents).toBeDefined();
} finally {
await q.close();
}
});
it('should handle subagent with minimal configuration', async () => {
const minimalAgent: SubagentConfig = {
name: 'minimal-agent',
description: 'Minimal configuration agent',
systemPrompt: 'You are a helpful assistant.',
level: 'session',
};
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [minimalAgent],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Validate minimal agent is registered
const systemMessage = findSystemMessage(messages, 'init');
expect(systemMessage).not.toBeNull();
expect(systemMessage!.agents).toBeDefined();
expect(systemMessage!.agents).toContain('minimal-agent');
} finally {
await q.close();
}
});
});
describe('Subagent Integration', () => {
it('should work with other SDK options', async () => {
const testAgent: SubagentConfig = {
name: 'test-agent',
description: 'Test agent for integration',
systemPrompt: 'You are a test assistant.',
level: 'session',
};
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [testAgent],
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
permissionMode: 'default',
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Validate subagent works with debug mode
const systemMessage = findSystemMessage(messages, 'init');
expect(systemMessage).not.toBeNull();
expect(systemMessage!.agents).toBeDefined();
expect(systemMessage!.agents).toContain('test-agent');
expect(stderrMessages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
});
it('should maintain session consistency with subagents', async () => {
const sessionAgent: SubagentConfig = {
name: 'session-agent',
description: 'Agent for session testing',
systemPrompt: 'You are a session test assistant.',
level: 'session',
};
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testWorkDir,
agents: [sessionAgent],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Validate session consistency
const systemMessage = findSystemMessage(messages, 'init');
expect(systemMessage).not.toBeNull();
expect(systemMessage!.session_id).toBeDefined();
expect(systemMessage!.uuid).toBeDefined();
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
expect(systemMessage!.agents).toContain('session-agent');
} finally {
await q.close();
}
});
});
});

View File

@@ -0,0 +1,317 @@
/**
* E2E tests for system controller features:
* - setModel API for dynamic model switching
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKAssistantMessage,
isSDKSystemMessage,
type SDKUserMessage,
} from '@qwen-code/sdk';
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
/**
* Factory function that creates a streaming input with a control point.
* After the first message is yielded, the generator waits for a resume signal,
* allowing the test code to call query instance methods like setModel.
*
* @param firstMessage - The first user message to send
* @param secondMessage - The second user message to send after control operations
* @returns Object containing the async generator and a resume function
*/
function createStreamingInputWithControlPoint(
firstMessage: string,
secondMessage: string,
): {
generator: AsyncIterable<SDKUserMessage>;
resume: () => void;
} {
let resumeResolve: (() => void) | null = null;
const resumePromise = new Promise<void>((resolve) => {
resumeResolve = resolve;
});
const generator = (async function* () {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: firstMessage,
},
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
await resumePromise;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: secondMessage,
},
parent_tool_use_id: null,
} as SDKUserMessage;
})();
const resume = () => {
if (resumeResolve) {
resumeResolve();
}
};
return { generator, resume };
}
describe('System Control (E2E)', () => {
let helper: SDKTestHelper;
let testDir: string;
beforeEach(async () => {
helper = new SDKTestHelper();
testDir = await helper.setup('system-control');
});
afterEach(async () => {
await helper.cleanup();
});
describe('setModel API', () => {
it('should change model dynamically during streaming input', async () => {
const { generator, resume } = createStreamingInputWithControlPoint(
'Tell me the model name.',
'Tell me the model name now again.',
);
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
debug: false,
},
});
try {
const resolvers: {
first?: () => void;
second?: () => void;
} = {};
const firstResponsePromise = new Promise<void>((resolve) => {
resolvers.first = resolve;
});
const secondResponsePromise = new Promise<void>((resolve) => {
resolvers.second = resolve;
});
let firstResponseReceived = false;
let secondResponseReceived = false;
const systemMessages: Array<{ model?: string }> = [];
// Consume messages in a single loop
(async () => {
for await (const message of q) {
if (isSDKSystemMessage(message)) {
systemMessages.push({ model: message.model });
}
if (isSDKAssistantMessage(message)) {
if (!firstResponseReceived) {
firstResponseReceived = true;
resolvers.first?.();
} else if (!secondResponseReceived) {
secondResponseReceived = true;
resolvers.second?.();
}
}
}
})();
// Wait for first response
await Promise.race([
firstResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for first response')),
15000,
),
),
]);
expect(firstResponseReceived).toBe(true);
// Perform control operation: set model
await q.setModel('qwen3-vl-plus');
// Resume the input stream
resume();
// Wait for second response
await Promise.race([
secondResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for second response')),
10000,
),
),
]);
expect(secondResponseReceived).toBe(true);
// Verify system messages - model should change from qwen3-max to qwen3-vl-plus
expect(systemMessages.length).toBeGreaterThanOrEqual(2);
expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']);
expect(systemMessages[1].model).toBe('qwen3-vl-plus');
} finally {
await q.close();
}
});
it('should handle multiple model changes in sequence', async () => {
const sessionId = crypto.randomUUID();
let resumeResolve1: (() => void) | null = null;
let resumeResolve2: (() => void) | null = null;
const resumePromise1 = new Promise<void>((resolve) => {
resumeResolve1 = resolve;
});
const resumePromise2 = new Promise<void>((resolve) => {
resumeResolve2 = resolve;
});
const generator = (async function* () {
yield {
type: 'user',
session_id: sessionId,
message: { role: 'user', content: 'First message' },
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
await resumePromise1;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: { role: 'user', content: 'Second message' },
parent_tool_use_id: null,
} as SDKUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
await resumePromise2;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: { role: 'user', content: 'Third message' },
parent_tool_use_id: null,
} as SDKUserMessage;
})();
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
debug: false,
},
});
try {
const systemMessages: Array<{ model?: string }> = [];
let responseCount = 0;
const resolvers: Array<() => void> = [];
const responsePromises = [
new Promise<void>((resolve) => resolvers.push(resolve)),
new Promise<void>((resolve) => resolvers.push(resolve)),
new Promise<void>((resolve) => resolvers.push(resolve)),
];
(async () => {
for await (const message of q) {
if (isSDKSystemMessage(message)) {
systemMessages.push({ model: message.model });
}
if (isSDKAssistantMessage(message)) {
if (responseCount < resolvers.length) {
resolvers[responseCount]?.();
responseCount++;
}
}
}
})();
// Wait for first response
await Promise.race([
responsePromises[0],
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout 1')), 10000),
),
]);
// First model change
await q.setModel('qwen3-turbo');
resumeResolve1!();
// Wait for second response
await Promise.race([
responsePromises[1],
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout 2')), 10000),
),
]);
// Second model change
await q.setModel('qwen3-vl-plus');
resumeResolve2!();
// Wait for third response
await Promise.race([
responsePromises[2],
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout 3')), 10000),
),
]);
// Verify we received system messages for each model
expect(systemMessages.length).toBeGreaterThanOrEqual(3);
expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']);
expect(systemMessages[1].model).toBe('qwen3-turbo');
expect(systemMessages[2].model).toBe('qwen3-vl-plus');
} finally {
await q.close();
}
});
it('should throw error when setModel is called on closed query', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
},
});
await q.close();
await expect(q.setModel('qwen3-turbo')).rejects.toThrow(
'Query is closed',
);
});
});
});

View File

@@ -0,0 +1,970 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* SDK E2E Test Helper
* Provides utilities for SDK e2e tests including test isolation,
* file management, MCP server setup, and common test utilities.
*/
import { mkdir, writeFile, readFile, rm, chmod } from 'node:fs/promises';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import type {
SDKMessage,
SDKAssistantMessage,
SDKSystemMessage,
SDKUserMessage,
ContentBlock,
TextBlock,
ToolUseBlock,
} from '@qwen-code/sdk';
import {
isSDKAssistantMessage,
isSDKSystemMessage,
isSDKResultMessage,
} from '@qwen-code/sdk';
// ============================================================================
// Core Test Helper Class
// ============================================================================
export interface SDKTestHelperOptions {
/**
* Optional settings for .qwen/settings.json
*/
settings?: Record<string, unknown>;
/**
* Whether to create .qwen/settings.json
*/
createQwenConfig?: boolean;
}
/**
* Helper class for SDK E2E tests
* Provides isolated test environments for each test case
*/
export class SDKTestHelper {
testDir: string | null = null;
testName?: string;
private baseDir: string;
constructor() {
this.baseDir = process.env['E2E_TEST_FILE_DIR']!;
if (!this.baseDir) {
throw new Error('E2E_TEST_FILE_DIR environment variable not set');
}
}
/**
* Setup an isolated test directory for a specific test
*/
async setup(
testName: string,
options: SDKTestHelperOptions = {},
): Promise<string> {
this.testName = testName;
const sanitizedName = this.sanitizeTestName(testName);
this.testDir = join(this.baseDir, sanitizedName);
await mkdir(this.testDir, { recursive: true });
// Optionally create .qwen/settings.json for CLI configuration
if (options.createQwenConfig) {
const qwenDir = join(this.testDir, '.qwen');
await mkdir(qwenDir, { recursive: true });
const settings = {
telemetry: {
enabled: false, // SDK tests don't need telemetry
},
...options.settings,
};
await writeFile(
join(qwenDir, 'settings.json'),
JSON.stringify(settings, null, 2),
'utf-8',
);
}
return this.testDir;
}
/**
* Create a file in the test directory
*/
async createFile(fileName: string, content: string): Promise<string> {
if (!this.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
const filePath = join(this.testDir, fileName);
await writeFile(filePath, content, 'utf-8');
return filePath;
}
/**
* Read a file from the test directory
*/
async readFile(fileName: string): Promise<string> {
if (!this.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
const filePath = join(this.testDir, fileName);
return await readFile(filePath, 'utf-8');
}
/**
* Create a subdirectory in the test directory
*/
async mkdir(dirName: string): Promise<string> {
if (!this.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
const dirPath = join(this.testDir, dirName);
await mkdir(dirPath, { recursive: true });
return dirPath;
}
/**
* Check if a file exists in the test directory
*/
fileExists(fileName: string): boolean {
if (!this.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
const filePath = join(this.testDir, fileName);
return existsSync(filePath);
}
/**
* Get the full path to a file in the test directory
*/
getPath(fileName: string): string {
if (!this.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
return join(this.testDir, fileName);
}
/**
* Cleanup test directory
*/
async cleanup(): Promise<void> {
if (this.testDir && process.env['KEEP_OUTPUT'] !== 'true') {
try {
await rm(this.testDir, { recursive: true, force: true });
} catch (error) {
if (process.env['VERBOSE'] === 'true') {
console.warn('Cleanup warning:', (error as Error).message);
}
}
}
}
/**
* Sanitize test name to create valid directory name
*/
private sanitizeTestName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.substring(0, 100); // Limit length
}
}
// ============================================================================
// MCP Server Utilities
// ============================================================================
export interface MCPServerConfig {
command: string;
args: string[];
}
export interface MCPServerResult {
scriptPath: string;
config: MCPServerConfig;
}
/**
* Built-in MCP server template: Math server with add and multiply tools
*/
const MCP_MATH_SERVER_SCRIPT = `#!/usr/bin/env node
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
const readline = require('readline');
const fs = require('fs');
// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set)
const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true';
function debug(msg) {
if (debugEnabled) {
fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`);
}
}
debug('MCP server starting...');
// Simple JSON-RPC implementation for MCP
class SimpleJSONRPC {
constructor() {
this.handlers = new Map();
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
this.rl.on('line', (line) => {
debug(\`Received line: \${line}\`);
try {
const message = JSON.parse(line);
debug(\`Parsed message: \${JSON.stringify(message)}\`);
this.handleMessage(message);
} catch (e) {
debug(\`Parse error: \${e.message}\`);
}
});
}
send(message) {
const msgStr = JSON.stringify(message);
debug(\`Sending message: \${msgStr}\`);
process.stdout.write(msgStr + '\\n');
}
async handleMessage(message) {
if (message.method && this.handlers.has(message.method)) {
try {
const result = await this.handlers.get(message.method)(message.params || {});
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
result
});
}
} catch (error) {
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: error.message
}
});
}
}
} else if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32601,
message: 'Method not found'
}
});
}
}
on(method, handler) {
this.handlers.set(method, handler);
}
}
// Create MCP server
const rpc = new SimpleJSONRPC();
// Handle initialize
rpc.on('initialize', async (params) => {
debug('Handling initialize request');
return {
protocolVersion: '2024-11-05',
capabilities: {
tools: {}
},
serverInfo: {
name: 'test-math-server',
version: '1.0.0'
}
};
});
// Handle tools/list
rpc.on('tools/list', async () => {
debug('Handling tools/list request');
return {
tools: [
{
name: 'add',
description: 'Add two numbers together',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' }
},
required: ['a', 'b']
}
},
{
name: 'multiply',
description: 'Multiply two numbers together',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' }
},
required: ['a', 'b']
}
}
]
};
});
// Handle tools/call
rpc.on('tools/call', async (params) => {
debug(\`Handling tools/call request for tool: \${params.name}\`);
if (params.name === 'add') {
const { a, b } = params.arguments;
return {
content: [{
type: 'text',
text: String(a + b)
}]
};
}
if (params.name === 'multiply') {
const { a, b } = params.arguments;
return {
content: [{
type: 'text',
text: String(a * b)
}]
};
}
throw new Error('Unknown tool: ' + params.name);
});
// Send initialization notification
rpc.send({
jsonrpc: '2.0',
method: 'initialized'
});
`;
/**
* Create an MCP server script in the test directory
* @param helper - SDKTestHelper instance
* @param type - Type of MCP server ('math' or provide custom script)
* @param serverName - Name of the MCP server (default: 'test-math-server')
* @param customScript - Custom MCP server script (if type is not 'math')
* @returns Object with scriptPath and config
*/
export async function createMCPServer(
helper: SDKTestHelper,
type: 'math' | 'custom' = 'math',
serverName: string = 'test-math-server',
customScript?: string,
): Promise<MCPServerResult> {
if (!helper.testDir) {
throw new Error('Test directory not initialized. Call setup() first.');
}
const script = type === 'math' ? MCP_MATH_SERVER_SCRIPT : customScript;
if (!script) {
throw new Error('Custom script required when type is "custom"');
}
const scriptPath = join(helper.testDir, `${serverName}.cjs`);
await writeFile(scriptPath, script, 'utf-8');
// Make script executable on Unix-like systems
if (process.platform !== 'win32') {
await chmod(scriptPath, 0o755);
}
return {
scriptPath,
config: {
command: 'node',
args: [scriptPath],
},
};
}
// ============================================================================
// Message & Content Utilities
// ============================================================================
/**
* Extract text from ContentBlock array
*/
export function extractText(content: ContentBlock[]): string {
return content
.filter((block): block is TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
}
/**
* Collect messages by type
*/
export function collectMessagesByType<T extends SDKMessage>(
messages: SDKMessage[],
predicate: (msg: SDKMessage) => msg is T,
): T[] {
return messages.filter(predicate);
}
/**
* Find tool use blocks in a message
*/
export function findToolUseBlocks(
message: SDKAssistantMessage,
toolName?: string,
): ToolUseBlock[] {
const toolUseBlocks = message.message.content.filter(
(block): block is ToolUseBlock => block.type === 'tool_use',
);
if (toolName) {
return toolUseBlocks.filter((block) => block.name === toolName);
}
return toolUseBlocks;
}
/**
* Extract all assistant text from messages
*/
export function getAssistantText(messages: SDKMessage[]): string {
return messages
.filter(isSDKAssistantMessage)
.map((msg) => extractText(msg.message.content))
.join('');
}
/**
* Find system message with optional subtype filter
*/
export function findSystemMessage(
messages: SDKMessage[],
subtype?: string,
): SDKSystemMessage | null {
const systemMessages = messages.filter(isSDKSystemMessage);
if (subtype) {
return systemMessages.find((msg) => msg.subtype === subtype) || null;
}
return systemMessages[0] || null;
}
/**
* Find all tool calls in messages
*/
export function findToolCalls(
messages: SDKMessage[],
toolName?: string,
): Array<{ message: SDKAssistantMessage; toolUse: ToolUseBlock }> {
const results: Array<{
message: SDKAssistantMessage;
toolUse: ToolUseBlock;
}> = [];
for (const message of messages) {
if (isSDKAssistantMessage(message)) {
const toolUseBlocks = findToolUseBlocks(message, toolName);
for (const toolUse of toolUseBlocks) {
results.push({ message, toolUse });
}
}
}
return results;
}
/**
* Find tool result for a specific tool use ID
*/
export function findToolResult(
messages: SDKMessage[],
toolUseId: string,
): { content: string; isError: boolean } | null {
for (const message of messages) {
if (message.type === 'user' && 'message' in message) {
const userMsg = message as SDKUserMessage;
const content = userMsg.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (
block.type === 'tool_result' &&
(block as { tool_use_id?: string }).tool_use_id === toolUseId
) {
const resultBlock = block as {
content?: string | ContentBlock[];
is_error?: boolean;
};
let resultContent = '';
if (typeof resultBlock.content === 'string') {
resultContent = resultBlock.content;
} else if (Array.isArray(resultBlock.content)) {
resultContent = resultBlock.content
.filter((b): b is TextBlock => b.type === 'text')
.map((b) => b.text)
.join('');
}
return {
content: resultContent,
isError: resultBlock.is_error ?? false,
};
}
}
}
}
}
return null;
}
/**
* Find all tool results for a specific tool name
*/
export function findToolResults(
messages: SDKMessage[],
toolName: string,
): Array<{ toolUseId: string; content: string; isError: boolean }> {
const results: Array<{
toolUseId: string;
content: string;
isError: boolean;
}> = [];
// First find all tool calls for this tool
const toolCalls = findToolCalls(messages, toolName);
// Then find the result for each tool call
for (const { toolUse } of toolCalls) {
const result = findToolResult(messages, toolUse.id);
if (result) {
results.push({
toolUseId: toolUse.id,
content: result.content,
isError: result.isError,
});
}
}
return results;
}
/**
* Find all tool result blocks from messages (without requiring tool name)
*/
export function findAllToolResultBlocks(
messages: SDKMessage[],
): Array<{ toolUseId: string; content: string; isError: boolean }> {
const results: Array<{
toolUseId: string;
content: string;
isError: boolean;
}> = [];
for (const message of messages) {
if (message.type === 'user' && 'message' in message) {
const userMsg = message as SDKUserMessage;
const content = userMsg.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result' && 'tool_use_id' in block) {
const resultBlock = block as {
tool_use_id: string;
content?: string | ContentBlock[];
is_error?: boolean;
};
let resultContent = '';
if (typeof resultBlock.content === 'string') {
resultContent = resultBlock.content;
} else if (Array.isArray(resultBlock.content)) {
resultContent = (resultBlock.content as ContentBlock[])
.filter((b): b is TextBlock => b.type === 'text')
.map((b) => b.text)
.join('');
}
results.push({
toolUseId: resultBlock.tool_use_id,
content: resultContent,
isError: resultBlock.is_error ?? false,
});
}
}
}
}
}
return results;
}
/**
* Check if any tool results exist in messages
*/
export function hasAnyToolResults(messages: SDKMessage[]): boolean {
return findAllToolResultBlocks(messages).length > 0;
}
/**
* Check if any successful (non-error) tool results exist
*/
export function hasSuccessfulToolResults(messages: SDKMessage[]): boolean {
return findAllToolResultBlocks(messages).some((r) => !r.isError);
}
/**
* Check if any error tool results exist
*/
export function hasErrorToolResults(messages: SDKMessage[]): boolean {
return findAllToolResultBlocks(messages).some((r) => r.isError);
}
// ============================================================================
// Streaming Input Utilities
// ============================================================================
/**
* Create a simple streaming input from an array of message contents
*/
export async function* createStreamingInput(
messageContents: string[],
sessionId?: string,
): AsyncIterable<SDKUserMessage> {
const sid = sessionId || crypto.randomUUID();
for (const content of messageContents) {
yield {
type: 'user',
session_id: sid,
message: {
role: 'user',
content: content,
},
parent_tool_use_id: null,
} as SDKUserMessage;
// Small delay between messages
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/**
* Create a controlled streaming input with pause/resume capability
*/
export function createControlledStreamingInput(
messageContents: string[],
sessionId?: string,
): {
generator: AsyncIterable<SDKUserMessage>;
resume: () => void;
resumeAll: () => void;
} {
const sid = sessionId || crypto.randomUUID();
const resumeResolvers: Array<() => void> = [];
const resumePromises: Array<Promise<void>> = [];
// Create a resume promise for each message after the first
for (let i = 1; i < messageContents.length; i++) {
const promise = new Promise<void>((resolve) => {
resumeResolvers.push(resolve);
});
resumePromises.push(promise);
}
const generator = (async function* () {
// Yield first message immediately
yield {
type: 'user',
session_id: sid,
message: {
role: 'user',
content: messageContents[0],
},
parent_tool_use_id: null,
} as SDKUserMessage;
// For subsequent messages, wait for resume
for (let i = 1; i < messageContents.length; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
await resumePromises[i - 1];
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sid,
message: {
role: 'user',
content: messageContents[i],
},
parent_tool_use_id: null,
} as SDKUserMessage;
}
})();
let currentResumeIndex = 0;
return {
generator,
resume: () => {
if (currentResumeIndex < resumeResolvers.length) {
resumeResolvers[currentResumeIndex]();
currentResumeIndex++;
}
},
resumeAll: () => {
resumeResolvers.forEach((resolve) => resolve());
currentResumeIndex = resumeResolvers.length;
},
};
}
// ============================================================================
// Assertion Utilities
// ============================================================================
/**
* Assert that messages follow expected type sequence
*/
export function assertMessageSequence(
messages: SDKMessage[],
expectedTypes: string[],
): void {
const actualTypes = messages.map((msg) => msg.type);
if (actualTypes.length < expectedTypes.length) {
throw new Error(
`Expected at least ${expectedTypes.length} messages, got ${actualTypes.length}`,
);
}
for (let i = 0; i < expectedTypes.length; i++) {
if (actualTypes[i] !== expectedTypes[i]) {
throw new Error(
`Expected message ${i} to be type '${expectedTypes[i]}', got '${actualTypes[i]}'`,
);
}
}
}
/**
* Assert that a specific tool was called
*/
export function assertToolCalled(
messages: SDKMessage[],
toolName: string,
): void {
const toolCalls = findToolCalls(messages, toolName);
if (toolCalls.length === 0) {
const allToolCalls = findToolCalls(messages);
const allToolNames = allToolCalls.map((tc) => tc.toolUse.name);
throw new Error(
`Expected tool '${toolName}' to be called. Found tools: ${allToolNames.length > 0 ? allToolNames.join(', ') : 'none'}`,
);
}
}
/**
* Assert that the conversation completed successfully
*/
export function assertSuccessfulCompletion(messages: SDKMessage[]): void {
const lastMessage = messages[messages.length - 1];
if (!isSDKResultMessage(lastMessage)) {
throw new Error(
`Expected last message to be a result message, got '${lastMessage.type}'`,
);
}
if (lastMessage.subtype !== 'success') {
throw new Error(
`Expected successful completion, got result subtype '${lastMessage.subtype}'`,
);
}
}
/**
* Wait for a condition to be true with timeout
*/
export async function waitFor(
predicate: () => boolean | Promise<boolean>,
options: {
timeout?: number;
interval?: number;
errorMessage?: string;
} = {},
): Promise<void> {
const {
timeout = 5000,
interval = 100,
errorMessage = 'Condition not met within timeout',
} = options;
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const result = await predicate();
if (result) {
return;
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
throw new Error(errorMessage);
}
// ============================================================================
// Debug and Validation Utilities
// ============================================================================
/**
* Validate model output and warn about unexpected content
* Inspired by integration-tests test-helper
*/
export function validateModelOutput(
result: string,
expectedContent: string | (string | RegExp)[] | null = null,
testName = '',
): boolean {
// First, check if there's any output at all
if (!result || result.trim().length === 0) {
throw new Error('Expected model to return some output');
}
// If expectedContent is provided, check for it and warn if missing
if (expectedContent) {
const contents = Array.isArray(expectedContent)
? expectedContent
: [expectedContent];
const missingContent = contents.filter((content) => {
if (typeof content === 'string') {
return !result.toLowerCase().includes(content.toLowerCase());
} else if (content instanceof RegExp) {
return !content.test(result);
}
return false;
});
if (missingContent.length > 0) {
console.warn(
`Warning: Model did not include expected content in response: ${missingContent.join(', ')}.`,
'This is not ideal but not a test failure.',
);
console.warn(
'The tool was called successfully, which is the main requirement.',
);
return false;
} else if (process.env['VERBOSE'] === 'true') {
console.log(`${testName}: Model output validated successfully.`);
}
return true;
}
return true;
}
/**
* Print debug information when tests fail
*/
export function printDebugInfo(
messages: SDKMessage[],
context: Record<string, unknown> = {},
): void {
console.error('Test failed - Debug info:');
console.error('Message count:', messages.length);
// Print message types
const messageTypes = messages.map((m) => m.type);
console.error('Message types:', messageTypes.join(', '));
// Print assistant text
const assistantText = getAssistantText(messages);
console.error(
'Assistant text (first 500 chars):',
assistantText.substring(0, 500),
);
if (assistantText.length > 500) {
console.error(
'Assistant text (last 500 chars):',
assistantText.substring(assistantText.length - 500),
);
}
// Print tool calls
const toolCalls = findToolCalls(messages);
console.error(
'Tool calls found:',
toolCalls.map((tc) => tc.toolUse.name),
);
// Print any additional context provided
Object.entries(context).forEach(([key, value]) => {
console.error(`${key}:`, value);
});
}
/**
* Create detailed error message for tool call expectations
*/
export function createToolCallErrorMessage(
expectedTools: string | string[],
foundTools: string[],
messages: SDKMessage[],
): string {
const expectedStr = Array.isArray(expectedTools)
? expectedTools.join(' or ')
: expectedTools;
const assistantText = getAssistantText(messages);
const preview = assistantText
? assistantText.substring(0, 200) + '...'
: 'no output';
return (
`Expected to find ${expectedStr} tool call(s). ` +
`Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` +
`Output preview: ${preview}`
);
}
// ============================================================================
// Shared Test Options Helper
// ============================================================================
/**
* Create shared test options with CLI path
*/
export function createSharedTestOptions(
overrides: Record<string, unknown> = {},
) {
const TEST_CLI_PATH = process.env['TEST_CLI_PATH'];
if (!TEST_CLI_PATH) {
throw new Error('TEST_CLI_PATH environment variable not set');
}
return {
pathToQwenExecutable: TEST_CLI_PATH,
...overrides,
};
}

View File

@@ -0,0 +1,744 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* E2E tests for tool control parameters:
* - coreTools: Limit available tools to a specific set
* - excludeTools: Block specific tools from execution
* - allowedTools: Auto-approve specific tools without confirmation
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { query, isSDKAssistantMessage, type SDKMessage } from '@qwen-code/sdk';
import {
SDKTestHelper,
extractText,
findToolCalls,
findToolResults,
assertSuccessfulCompletion,
createSharedTestOptions,
} from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
const TEST_TIMEOUT = 60000;
describe('Tool Control Parameters (E2E)', () => {
let helper: SDKTestHelper;
let testDir: string;
beforeEach(async () => {
helper = new SDKTestHelper();
testDir = await helper.setup('tool-control', {
createQwenConfig: false,
});
});
afterEach(async () => {
await helper.cleanup();
});
describe('coreTools parameter', () => {
it(
'should only allow specified tools when coreTools is set',
async () => {
// Create a test file
await helper.createFile('test.txt', 'original content');
const q = query({
prompt:
'Read the file test.txt and then write "modified" to test.txt. Finally, list the directory.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'yolo',
// Only allow read_file and write_file, exclude list_directory
coreTools: ['read_file', 'write_file'],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Should have read_file and write_file calls
const toolCalls = findToolCalls(messages);
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
expect(toolNames).toContain('read_file');
expect(toolNames).toContain('write_file');
// Should NOT have list_directory since it's not in coreTools
expect(toolNames).not.toContain('list_directory');
// Verify file was modified
const content = await helper.readFile('test.txt');
expect(content).toContain('modified');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should work with minimal tool set',
async () => {
const q = query({
prompt: 'What is 2 + 2? Just answer with the number.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
// Only allow thinking, no file operations
coreTools: [],
debug: false,
},
});
const messages: SDKMessage[] = [];
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isSDKAssistantMessage(message)) {
assistantText += extractText(message.message.content);
}
}
// Should answer without any tool calls
expect(assistantText).toMatch(/4/);
// Should have no tool calls
const toolCalls = findToolCalls(messages);
expect(toolCalls.length).toBe(0);
assertSuccessfulCompletion(messages);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('excludeTools parameter', () => {
it(
'should block excluded tools from execution',
async () => {
await helper.createFile('test.txt', 'test content');
const q = query({
prompt:
'Read test.txt and then write empty content to it to clear it.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'yolo',
coreTools: ['read_file', 'write_file'],
// Block all write_file tool
excludeTools: ['write_file'],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
const toolCalls = findToolCalls(messages);
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
// Should be able to read the file
expect(toolNames).toContain('read_file');
// The excluded tools should have been called but returned permission declined
// Check if write_file was attempted and got permission denied
const writeFileResults = findToolResults(messages, 'write_file');
if (writeFileResults.length > 0) {
// Tool was called but should have permission declined message
for (const result of writeFileResults) {
expect(result.content).toMatch(/permission.*declined/i);
}
}
// File content should remain unchanged (because write was denied)
const content = await helper.readFile('test.txt');
expect(content).toBe('test content');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should block multiple excluded tools',
async () => {
await helper.createFile('test.txt', 'test content');
const q = query({
prompt: 'Read test.txt, list the directory, and run "echo hello".',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'yolo',
// Block multiple tools
excludeTools: ['list_directory', 'run_shell_command'],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
const toolCalls = findToolCalls(messages);
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
// Should be able to read
expect(toolNames).toContain('read_file');
// Excluded tools should have been attempted but returned permission declined
const listDirResults = findToolResults(messages, 'list_directory');
if (listDirResults.length > 0) {
for (const result of listDirResults) {
expect(result.content).toMatch(/permission.*declined/i);
}
}
const shellResults = findToolResults(messages, 'run_shell_command');
if (shellResults.length > 0) {
for (const result of shellResults) {
expect(result.content).toMatch(/permission.*declined/i);
}
}
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should block all shell commands when run_shell_command is excluded',
async () => {
const q = query({
prompt: 'Run "echo hello" and "ls -la" commands.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'yolo',
// Block all shell commands - excludeTools blocks entire tools
excludeTools: ['run_shell_command'],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// All shell commands should have permission declined
const shellResults = findToolResults(messages, 'run_shell_command');
for (const result of shellResults) {
expect(result.content).toMatch(/permission.*declined/i);
}
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'excludeTools should take priority over allowedTools',
async () => {
await helper.createFile('test.txt', 'test content');
const q = query({
prompt:
'Clear the content of test.txt by writing empty string to it.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'default',
// Conflicting settings: exclude takes priority
excludeTools: ['write_file'],
allowedTools: ['write_file'],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// write_file should have been attempted but returned permission declined
const writeFileResults = findToolResults(messages, 'write_file');
if (writeFileResults.length > 0) {
// Tool was called but should have permission declined message (exclude takes priority)
for (const result of writeFileResults) {
expect(result.content).toMatch(/permission.*declined/i);
}
}
// File content should remain unchanged (because write was denied)
const content = await helper.readFile('test.txt');
expect(content).toBe('test content');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('allowedTools parameter', () => {
it(
'should auto-approve allowed tools without canUseTool callback',
async () => {
await helper.createFile('test.txt', 'original');
let canUseToolCalled = false;
const q = query({
prompt: 'Read test.txt and write "modified" to it.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'default',
coreTools: ['read_file', 'write_file'],
// Allow write_file without confirmation
allowedTools: ['read_file', 'write_file'],
canUseTool: async (_toolName) => {
canUseToolCalled = true;
return { behavior: 'deny', message: 'Should not be called' };
},
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
const toolCalls = findToolCalls(messages);
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
// Should have executed the tools
expect(toolNames).toContain('read_file');
expect(toolNames).toContain('write_file');
// canUseTool should NOT have been called (tools are in allowedTools)
expect(canUseToolCalled).toBe(false);
// Verify file was modified
const content = await helper.readFile('test.txt');
expect(content).toContain('modified');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should allow specific shell commands with pattern matching',
async () => {
const q = query({
prompt: 'Run "echo hello" and "ls -la" commands.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'default',
// Allow specific shell commands
allowedTools: ['ShellTool(echo )', 'ShellTool(ls )'],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
const toolCalls = findToolCalls(messages);
const shellCalls = toolCalls.filter(
(tc) => tc.toolUse.name === 'run_shell_command',
);
// Should have executed shell commands
expect(shellCalls.length).toBeGreaterThan(0);
// All shell commands should be echo or ls
for (const call of shellCalls) {
const input = call.toolUse.input as { command?: string };
if (input.command) {
expect(input.command).toMatch(/^(echo |ls )/);
}
}
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should fall back to canUseTool for non-allowed tools',
async () => {
await helper.createFile('test.txt', 'test');
const canUseToolCalls: string[] = [];
const q = query({
prompt: 'Read test.txt and append an empty line to it.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'default',
// Only allow read_file, list_directory should trigger canUseTool
coreTools: ['read_file', 'write_file'],
allowedTools: ['read_file'],
canUseTool: async (toolName) => {
canUseToolCalls.push(toolName);
return {
behavior: 'allow',
updatedInput: {},
};
},
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
const toolCalls = findToolCalls(messages);
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
// Both tools should have been executed
expect(toolNames).toContain('read_file');
expect(toolNames).toContain('write_file');
// canUseTool should have been called for write_file (not in allowedTools)
// but NOT for read_file (in allowedTools)
expect(canUseToolCalls).toContain('write_file');
expect(canUseToolCalls).not.toContain('read_file');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should work with permissionMode: auto-edit',
async () => {
await helper.createFile('test.txt', 'test');
const canUseToolCalls: string[] = [];
const q = query({
prompt: 'Read test.txt, write "new" to it, and list the directory.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'auto-edit',
// Allow list_directory in addition to auto-approved edit tools
allowedTools: ['list_directory'],
canUseTool: async (toolName) => {
canUseToolCalls.push(toolName);
return {
behavior: 'deny',
message: 'Should not be called',
};
},
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
const toolCalls = findToolCalls(messages);
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
// All tools should have been executed
expect(toolNames).toContain('read_file');
expect(toolNames).toContain('write_file');
expect(toolNames).toContain('list_directory');
// canUseTool should NOT have been called
// (edit tools auto-approved, list_directory in allowedTools)
expect(canUseToolCalls.length).toBe(0);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Combined tool control scenarios', () => {
it(
'should work with coreTools + allowedTools',
async () => {
await helper.createFile('test.txt', 'test');
const q = query({
prompt: 'Read test.txt and write "modified" to it.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'default',
// Limit to specific tools
coreTools: ['read_file', 'write_file', 'list_directory'],
// Auto-approve write operations
allowedTools: ['write_file'],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
const toolCalls = findToolCalls(messages);
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
// Should use allowed tools from coreTools
expect(toolNames).toContain('read_file');
expect(toolNames).toContain('write_file');
// Should NOT use tools outside coreTools
expect(toolNames).not.toContain('run_shell_command');
// Verify file was modified
const content = await helper.readFile('test.txt');
expect(content).toContain('modified');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should work with coreTools + excludeTools',
async () => {
await helper.createFile('test.txt', 'test');
const q = query({
prompt:
'Read test.txt, write "new content" to it, and list directory.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'yolo',
// Allow file operations
coreTools: ['read_file', 'write_file', 'edit', 'list_directory'],
// But exclude edit
excludeTools: ['edit'],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
const toolCalls = findToolCalls(messages);
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
// Should use non-excluded tools from coreTools
expect(toolNames).toContain('read_file');
// Should NOT use excluded tool
expect(toolNames).not.toContain('edit');
// File should still exist
expect(helper.fileExists('test.txt')).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should work with all three parameters together',
async () => {
await helper.createFile('test.txt', 'test');
const canUseToolCalls: string[] = [];
const q = query({
prompt:
'Read test.txt, write "modified" to it, and list the directory.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'default',
// Limit available tools
coreTools: ['read_file', 'write_file', 'list_directory', 'edit'],
// Block edit
excludeTools: ['edit'],
// Auto-approve write
allowedTools: ['write_file'],
canUseTool: async (toolName) => {
canUseToolCalls.push(toolName);
return {
behavior: 'allow',
updatedInput: {},
};
},
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
const toolCalls = findToolCalls(messages);
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
// Should use allowed tools
expect(toolNames).toContain('read_file');
expect(toolNames).toContain('write_file');
// Should NOT use excluded tool
expect(toolNames).not.toContain('edit');
// canUseTool should be called for tools not in allowedTools
// but should NOT be called for write_file (in allowedTools)
expect(canUseToolCalls).not.toContain('write_file');
// Verify file was modified
const content = await helper.readFile('test.txt');
expect(content).toContain('modified');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Edge cases and error handling', () => {
it(
'should handle non-existent tool names in excludeTools',
async () => {
await helper.createFile('test.txt', 'test');
const q = query({
prompt: 'Read test.txt.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'yolo',
// Non-existent tool names should be ignored
excludeTools: ['non_existent_tool', 'another_fake_tool'],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
const toolCalls = findToolCalls(messages);
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
// Should work normally
expect(toolNames).toContain('read_file');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should handle non-existent tool names in allowedTools',
async () => {
await helper.createFile('test.txt', 'test');
const q = query({
prompt: 'Read test.txt.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'yolo',
// Non-existent tool names should be ignored
allowedTools: ['non_existent_tool', 'read_file'],
debug: false,
},
});
const messages: SDKMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
const toolCalls = findToolCalls(messages);
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
// Should work normally
expect(toolNames).toContain('read_file');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
});

View File

@@ -213,7 +213,7 @@ describe('simple-mcp-server', () => {
it('should add two numbers', async () => {
// Test directory is already set up in before hook
// Just run the command - MCP server config is in settings.json
const output = await rig.run('add 5 and 10');
const output = await rig.run('add 5 and 10, use tool if you can.');
const foundToolCall = await rig.waitForToolCall('add');

View File

@@ -2,7 +2,11 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowJs": true
"allowJs": true,
"baseUrl": ".",
"paths": {
"@qwen-code/sdk": ["../packages/sdk-typescript/dist/index.d.ts"]
}
},
"include": ["**/*.ts"],
"references": [{ "path": "../packages/core" }]

View File

@@ -1,12 +1,15 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { defineConfig } from 'vitest/config';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const timeoutMinutes = Number(process.env.TB_TIMEOUT_MINUTES || '5');
const __dirname = dirname(fileURLToPath(import.meta.url));
const timeoutMinutes = Number(process.env['TB_TIMEOUT_MINUTES'] || '5');
const testTimeoutMs = timeoutMinutes * 60 * 1000;
export default defineConfig({
@@ -25,4 +28,13 @@ export default defineConfig({
},
},
},
resolve: {
alias: {
// Use built SDK bundle for e2e tests
'@qwen-code/sdk': resolve(
__dirname,
'../packages/sdk-typescript/dist/index.mjs',
),
},
},
});

4078
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.5.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.5.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -37,6 +37,10 @@
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
"test:integration:sdk:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests sdk-typescript",
"test:integration:sdk:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests sdk-typescript",
"test:integration:cli:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
"test:integration:cli:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
"test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests",
"test:terminal-bench:oracle": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'oracle'",
"test:terminal-bench:qwen": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'qwen'",
@@ -89,7 +93,7 @@
"eslint-plugin-license-header": "^0.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^10.4.5",
"glob": "^10.5.0",
"globals": "^16.0.0",
"google-artifactregistry-auth": "^3.4.0",
"husky": "^9.1.7",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.5.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0"
},
"dependencies": {
"@google/genai": "1.16.0",
@@ -47,7 +47,7 @@
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"fzf": "^0.5.2",
"glob": "^10.4.5",
"glob": "^10.5.0",
"highlight.js": "^11.11.1",
"ink": "^6.2.3",
"ink-gradient": "^3.0.0",
@@ -63,7 +63,7 @@
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.1",
"tar": "^7.5.2",
"undici": "^7.10.0",
"extract-zip": "^2.0.1",
"update-notifier": "^7.3.1",

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);
}
@@ -73,6 +88,16 @@ export class AgentSideConnection implements Client {
);
}
/**
* Streams authentication updates (e.g. Qwen OAuth authUri) to the client.
*/
async authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void> {
return await this.#connection.sendNotification(
schema.CLIENT_METHODS.authenticate_update,
params,
);
}
/**
* Request permission before running a tool
*
@@ -226,9 +251,11 @@ class Connection {
).toResult();
}
let errorName;
let details;
if (error instanceof Error) {
errorName = error.name;
details = error.message;
} else if (
typeof error === 'object' &&
@@ -239,6 +266,10 @@ class Connection {
details = error.message;
}
if (errorName === 'TokenManagerError') {
return RequestError.authRequired(details).toResult();
}
return RequestError.internalError(details).toResult();
}
}
@@ -342,6 +373,7 @@ export interface Client {
params: schema.RequestPermissionRequest,
): Promise<schema.RequestPermissionResponse>;
sessionUpdate(params: schema.SessionNotification): Promise<void>;
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
writeTextFile(
params: schema.WriteTextFileRequest,
): Promise<schema.WriteTextFileResponse>;
@@ -360,7 +392,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,356 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { ReadableStream, WritableStream } from 'node:stream/web';
import {
APPROVAL_MODE_INFO,
APPROVAL_MODES,
AuthType,
clearCachedCredentialFile,
QwenOAuth2Event,
qwenOAuth2Events,
MCPServerConfig,
SessionService,
buildApiHistoryFromConversation,
type Config,
type ConversationRecord,
type DeviceAuthorizationData,
} from '@qwen-code/qwen-code-core';
import type { ApprovalModeValue } from './schema.js';
import * as acp from './acp.js';
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);
let authUri: string | undefined;
const authUriHandler = (deviceAuth: DeviceAuthorizationData) => {
authUri = deviceAuth.verification_uri_complete;
// Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking).
void this.client.authenticateUpdate({ _meta: { authUri } });
};
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.once(QwenOAuth2Event.AuthUri, authUriHandler);
}
await clearCachedCredentialFile();
try {
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
} finally {
// Ensure we don't leak listeners if auth fails early.
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler);
}
}
return;
}
async newSession({
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('No Selected Type');
}
try {
// Use true for the second argument to ensure only cached credentials are used
await config.refreshAuth(selectedType, true);
} catch (e) {
console.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired(
'Authentication failed: ' + (e as Error).message,
);
}
}
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,11 +13,14 @@ 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 = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
authenticate_update: 'authenticate/update',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
};
@@ -47,13 +50,14 @@ 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>;
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
export type AuthenticateResponse = z.infer<typeof authenticateResponseSchema>;
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
@@ -84,6 +88,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 +138,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 +195,7 @@ export const toolKindSchema = z.union([
z.literal('execute'),
z.literal('think'),
z.literal('fetch'),
z.literal('switch_mode'),
z.literal('other'),
]);
@@ -209,11 +226,33 @@ 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(),
});
export const authenticateResponseSchema = z.null();
export const authenticateUpdateSchema = z.object({
_meta: z.object({
authUri: z.string(),
}),
});
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
export const newSessionResponseSchema = z.object({
sessionId: z.string(),
@@ -221,6 +260,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'),
@@ -259,6 +321,23 @@ export const annotationsSchema = z.object({
priority: z.number().optional().nullable(),
});
export const usageSchema = z.object({
promptTokens: z.number().optional().nullable(),
completionTokens: z.number().optional().nullable(),
thoughtsTokens: z.number().optional().nullable(),
totalTokens: z.number().optional().nullable(),
cachedTokens: z.number().optional().nullable(),
});
export type Usage = z.infer<typeof usageSchema>;
export const sessionUpdateMetaSchema = z.object({
usage: usageSchema.optional().nullable(),
durationMs: z.number().optional().nullable(),
});
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
export const requestPermissionResponseSchema = z.object({
outcome: requestPermissionOutcomeSchema,
});
@@ -321,9 +400,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 +507,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,
@@ -417,10 +522,12 @@ export const sessionUpdateSchema = z.union([
z.object({
content: contentBlockSchema,
sessionUpdate: z.literal('agent_message_chunk'),
_meta: sessionUpdateMetaSchema.optional().nullable(),
}),
z.object({
content: contentBlockSchema,
sessionUpdate: z.literal('agent_thought_chunk'),
_meta: sessionUpdateMetaSchema.optional().nullable(),
}),
z.object({
content: z.array(toolCallContentSchema).optional(),
@@ -437,6 +544,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,15 +554,17 @@ export const sessionUpdateSchema = z.union([
entries: z.array(planEntrySchema),
sessionUpdate: z.literal('plan'),
}),
currentModeUpdateSchema,
availableCommandsUpdateSchema,
]);
export const agentResponseSchema = z.union([
initializeResponseSchema,
authenticateResponseSchema,
newSessionResponseSchema,
loadSessionResponseSchema,
promptResponseSchema,
listSessionsResponseSchema,
setModeResponseSchema,
]);
export const requestPermissionRequestSchema = z.object({
@@ -485,6 +595,8 @@ export const agentRequestSchema = z.union([
newSessionRequestSchema,
loadSessionRequestSchema,
promptRequestSchema,
listSessionsRequestSchema,
setModeRequestSchema,
]);
export const agentNotificationSchema = sessionNotificationSchema;

View File

@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import type { FileSystemService } from '@qwen-code/qwen-code-core';
import { AcpFileSystemService } from './filesystem.js';
const createFallback = (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
findFiles: vi.fn().mockReturnValue([]),
});
describe('AcpFileSystemService', () => {
describe('readTextFile ENOENT handling', () => {
it('parses path from ACP ENOENT message (quoted)', async () => {
const client = {
readTextFile: vi
.fn()
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-1',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
code: 'ENOENT',
path: '/remote/file.txt',
});
});
it('falls back to requested path when none provided', async () => {
const client = {
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-2',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
await expect(
svc.readTextFile('/fallback/path.txt'),
).rejects.toMatchObject({
code: 'ENOENT',
path: '/fallback/path.txt',
});
});
});
});

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
@@ -30,6 +30,20 @@ export class AcpFileSystemService implements FileSystemService {
limit: null,
});
if (response.content.startsWith('ERROR: ENOENT:')) {
// Treat ACP error strings as structured ENOENT errors without
// assuming a specific platform format.
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
const err = new Error(response.content) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
const rawPath = match?.groups?.['path']?.trim();
err['path'] = rawPath
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
: filePath;
throw err;
}
return response.content;
}

View File

@@ -0,0 +1,458 @@
/**
* @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',
]);
});
});
describe('usage metadata replay', () => {
it('should emit usage metadata after assistant message content', async () => {
const record: ChatRecord = {
uuid: 'assistant-uuid',
parentUuid: 'user-uuid',
sessionId: 'test-session',
timestamp: new Date().toISOString(),
type: 'assistant',
cwd: '/test',
version: '1.0.0',
message: {
role: 'model',
parts: [{ text: 'Hello!' }],
},
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
},
};
await replayer.replay([record]);
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Hello!' },
});
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
usage: {
promptTokens: 100,
completionTokens: 50,
thoughtsTokens: undefined,
totalTokens: 150,
cachedTokens: undefined,
},
},
});
});
});
});

View File

@@ -0,0 +1,202 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
import type {
Content,
GenerateContentResponseUsageMetadata,
} from '@google/genai';
import type { SessionContext } from './types.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
/**
* 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');
}
if (record.usageMetadata) {
await this.replayUsageMetadata(record.usageMetadata);
}
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>,
status: 'in_progress',
});
}
}
}
/**
* Replays usage metadata.
* @param usageMetadata - The usage metadata to replay
*/
private async replayUsageMetadata(
usageMetadata: GenerateContentResponseUsageMetadata,
): Promise<void> {
await this.messageEmitter.emitUsageMetadata(usageMetadata);
}
/**
* Replays a tool result record.
*/
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,
});
// Special handling: Task tool execution summary contains token usage
const { resultDisplay } = result ?? {};
if (
!!resultDisplay &&
typeof resultDisplay === 'object' &&
'type' in resultDisplay &&
(resultDisplay as { type?: unknown }).type === 'task_execution'
) {
await this.emitTaskUsageFromResultDisplay(
resultDisplay as TaskResultDisplay,
);
}
}
/**
* Emits token usage from a TaskResultDisplay execution summary, if present.
*/
private async emitTaskUsageFromResultDisplay(
resultDisplay: TaskResultDisplay,
): Promise<void> {
const summary = resultDisplay.executionSummary;
if (!summary) {
return;
}
const usageMetadata: GenerateContentResponseUsageMetadata = {};
if (Number.isFinite(summary.inputTokens)) {
usageMetadata.promptTokenCount = summary.inputTokens;
}
if (Number.isFinite(summary.outputTokens)) {
usageMetadata.candidatesTokenCount = summary.outputTokens;
}
if (Number.isFinite(summary.thoughtTokens)) {
usageMetadata.thoughtsTokenCount = summary.thoughtTokens;
}
if (Number.isFinite(summary.cachedTokens)) {
usageMetadata.cachedContentTokenCount = summary.cachedTokens;
}
if (Number.isFinite(summary.totalTokens)) {
usageMetadata.totalTokenCount = summary.totalTokens;
}
// Only emit if we captured at least one token metric
if (Object.keys(usageMetadata).length > 0) {
await this.messageEmitter.emitUsageMetadata(usageMetadata);
}
}
/**
* 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 '';
}
}

File diff suppressed because it is too large Load Diff

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: 'pending',
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,339 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
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 { MessageEmitter } from './emitters/MessageEmitter.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 messageEmitter: MessageEmitter;
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);
this.messageEmitter = new MessageEmitter(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);
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
return [
() => {
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
// Clean up any remaining states
this.toolStates.clear();
},
];
}
/**
* 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);
}
};
}
/**
* Creates a handler for usage metadata events.
*/
private createUsageMetadataHandler(
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentUsageEvent;
if (abortSignal.aborted) return;
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
};
}
/**
* Converts confirmation details to permission options for the client.
*/
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,206 @@
/**
* @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' },
});
});
});
describe('emitUsageMetadata', () => {
it('should emit agent_message_chunk with _meta.usage containing token counts', async () => {
const usageMetadata = {
promptTokenCount: 100,
candidatesTokenCount: 50,
thoughtsTokenCount: 25,
totalTokenCount: 175,
cachedContentTokenCount: 10,
};
await emitter.emitUsageMetadata(usageMetadata);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
usage: {
promptTokens: 100,
completionTokens: 50,
thoughtsTokens: 25,
totalTokens: 175,
cachedTokens: 10,
},
},
});
});
it('should include durationMs in _meta when provided', async () => {
const usageMetadata = {
promptTokenCount: 10,
candidatesTokenCount: 5,
thoughtsTokenCount: 2,
totalTokenCount: 17,
cachedContentTokenCount: 1,
};
await emitter.emitUsageMetadata(usageMetadata, 'done', 1234);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'done' },
_meta: {
usage: {
promptTokens: 10,
completionTokens: 5,
thoughtsTokens: 2,
totalTokens: 17,
cachedTokens: 1,
},
durationMs: 1234,
},
});
});
});
});

View File

@@ -0,0 +1,95 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { Usage } from '../../schema.js';
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 thought chunk.
*/
async emitAgentThought(text: string): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'agent_thought_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 usage metadata.
*/
async emitUsageMetadata(
usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '',
durationMs?: number,
): Promise<void> {
const usage: Usage = {
promptTokens: usageMetadata.promptTokenCount,
completionTokens: usageMetadata.candidatesTokenCount,
thoughtsTokens: usageMetadata.thoughtsTokenCount,
totalTokens: usageMetadata.totalTokenCount,
cachedTokens: usageMetadata.cachedContentTokenCount,
};
const meta =
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
_meta: meta,
});
}
/**
* 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: 'pending',
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: 'pending',
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: 'pending',
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: '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: 'Function output' },
},
],
rawOutput: 'raw result',
}),
);
});
});
});
});

View File

@@ -0,0 +1,302 @@
/**
* @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: params.status || 'pending',
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 resp = part.functionResponse.response as Record<
string,
unknown
>;
const outputField = resp['output'];
const errorField = resp['error'];
const responseText =
typeof outputField === 'string'
? outputField
: typeof errorField === 'string'
? errorField
: JSON.stringify(resp);
result.push({
type: 'content',
content: { type: 'text', text: responseText },
});
} 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,78 @@
/**
* @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>;
/** Status of the tool call */
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
}
/**
* 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,13 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
FileFilteringOptions,
MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import {
ApprovalMode,
AuthType,
Config,
DEFAULT_QWEN_EMBEDDING_MODEL,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
@@ -26,7 +22,12 @@ import {
Storage,
InputFormat,
OutputFormat,
SessionService,
type ResumedSessionData,
type FileFilteringOptions,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import type { Settings } from './settings.js';
import yargs, { type Argv } from 'yargs';
import { hideBin } from 'yargs/helpers';
@@ -129,6 +130,15 @@ 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;
maxSessionTurns: number | undefined;
coreTools: string[] | undefined;
excludeTools: string[] | undefined;
authType: string | undefined;
channel: string | undefined;
}
function normalizeOutputFormat(
@@ -288,6 +298,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('channel', {
type: 'string',
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
description: 'Channel identifier (VSCode, ACP, SDK, CI)',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -396,6 +411,47 @@ 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.',
})
.option('max-session-turns', {
type: 'number',
description: 'Maximum number of session turns',
})
.option('core-tools', {
type: 'array',
string: true,
description: 'Core tool paths',
coerce: (tools: string[]) =>
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
})
.option('exclude-tools', {
type: 'array',
string: true,
description: 'Tools to exclude',
coerce: (tools: string[]) =>
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
})
.option('allowed-tools', {
type: 'array',
string: true,
description: 'Tools to allow, will bypass confirmation',
coerce: (tools: string[]) =>
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
})
.option('auth-type', {
type: 'string',
choices: [AuthType.USE_OPENAI, AuthType.QWEN_OAUTH],
description: 'Authentication type',
})
.deprecateOption(
'show-memory-usage',
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
@@ -451,6 +507,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;
}),
)
@@ -506,6 +565,12 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
if (result['experimentalAcp'] && !result['channel']) {
(result as Record<string, unknown>)['channel'] = 'ACP';
}
return result as unknown as CliArgs;
}
@@ -565,7 +630,6 @@ export async function loadCliConfig(
settings: Settings,
extensions: Extension[],
extensionEnablementManager: ExtensionEnablementManager,
sessionId: string,
argv: CliArgs,
cwd: string = process.cwd(),
): Promise<Config> {
@@ -728,8 +792,14 @@ export async function loadCliConfig(
interactive = false;
}
// In non-interactive mode, exclude tools that require a prompt.
// However, if stream-json input is used, control can be requested via JSON messages,
// so tools should not be excluded in that case.
const extraExcludes: string[] = [];
if (!interactive && !argv.experimentalAcp) {
if (
!interactive &&
!argv.experimentalAcp &&
inputFormat !== InputFormat.STREAM_JSON
) {
switch (approvalMode) {
case ApprovalMode.PLAN:
case ApprovalMode.DEFAULT:
@@ -753,6 +823,7 @@ export async function loadCliConfig(
settings,
activeExtensions,
extraExcludes.length > 0 ? extraExcludes : undefined,
argv.excludeTools,
);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
@@ -797,8 +868,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,
@@ -808,7 +904,7 @@ export async function loadCliConfig(
debugMode,
question,
fullContext: argv.allFiles || false,
coreTools: settings.tools?.core || undefined,
coreTools: argv.coreTools || settings.tools?.core || undefined,
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
excludeTools,
toolDiscoveryCommand: settings.tools?.discoveryCommand,
@@ -841,13 +937,16 @@ export async function loadCliConfig(
model: resolvedModel,
extensionContextFilePaths,
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
maxSessionTurns:
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
extensions: allExtensions,
blockedMcpServers,
noBrowser: !!process.env['NO_BROWSER'],
authType: settings.security?.auth?.selectedType,
authType:
(argv.authType as AuthType | undefined) ||
settings.security?.auth?.selectedType,
inputFormat,
outputFormat,
includePartialMessages,
@@ -896,6 +995,7 @@ export async function loadCliConfig(
output: {
format: outputSettingsFormat,
},
channel: argv.channel,
});
}
@@ -955,8 +1055,10 @@ function mergeExcludeTools(
settings: Settings,
extensions: Extension[],
extraExcludes?: string[] | undefined,
cliExcludeTools?: string[] | undefined,
): string[] {
const allExcludeTools = new Set([
...(cliExcludeTools || []),
...(settings.tools?.exclude || []),
...(extraExcludes || []),
]);

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

@@ -191,8 +191,19 @@ const SETTINGS_SCHEMA = {
{ value: 'auto', label: 'Auto (detect from system)' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' },
],
},
terminalBell: {
type: 'boolean',
label: 'Terminal Bell',
category: 'General',
requiresRestart: false,
default: true,
description:
'Play terminal bell sound when response completes or needs approval.',
showInDialog: true,
},
},
},
output: {

View File

@@ -479,6 +479,13 @@ describe('gemini.tsx main function kitty protocol', () => {
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
continue: undefined,
resume: undefined,
coreTools: undefined,
excludeTools: undefined,
authType: undefined,
maxSessionTurns: undefined,
channel: 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,
);
@@ -278,8 +276,11 @@ export async function main() {
process.exit(1);
}
}
// For stream-json mode, don't read stdin here - it should be forwarded to the sandbox
// and consumed by StreamJsonInputReader inside the container
const inputFormat = argv.inputFormat as string | undefined;
let stdinData = '';
if (!process.stdin.isTTY) {
if (!process.stdin.isTTY && inputFormat !== 'stream-json') {
stdinData = await readStdin();
}
@@ -319,6 +320,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 +345,6 @@ export async function main() {
settings.merged,
extensions,
extensionEnablementManager,
sessionId,
argv,
);
@@ -374,7 +386,18 @@ export async function main() {
setMaxSizedBoxDebugging(isDebugMode);
const initializationResult = await initializeApp(config, settings);
// Check input format early to determine initialization flow
const inputFormat =
typeof config.getInputFormat === 'function'
? config.getInputFormat()
: InputFormat.TEXT;
// For stream-json mode, defer config.initialize() until after the initialize control request
// For other modes, initialize normally
let initializationResult: InitializationResult | undefined;
if (inputFormat !== InputFormat.STREAM_JSON) {
initializationResult = await initializeApp(config, settings);
}
if (
settings.merged.security?.auth?.selectedType ===
@@ -386,7 +409,7 @@ export async function main() {
}
if (config.getExperimentalZedIntegration()) {
return runZedIntegration(config, settings, extensions, argv);
return runAcpAgent(config, settings, extensions, argv);
}
let input = config.getQuestion();
@@ -408,19 +431,15 @@ export async function main() {
settings,
startupWarnings,
process.cwd(),
initializationResult,
initializationResult!,
);
return;
}
await config.initialize();
// Check input format BEFORE reading stdin
// In STREAM_JSON mode, stdin should be left for StreamJsonInputReader
const inputFormat =
typeof config.getInputFormat === 'function'
? config.getInputFormat()
: InputFormat.TEXT;
// For non-stream-json mode, initialize config here
if (inputFormat !== InputFormat.STREAM_JSON) {
await config.initialize();
}
// Only read stdin if NOT in stream-json mode
// In stream-json mode, stdin is used for protocol messages (control requests, etc.)
@@ -433,7 +452,8 @@ export async function main() {
}
const nonInteractiveConfig = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
(argv.authType as AuthType) ||
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
config,
settings,
@@ -469,7 +489,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

@@ -9,7 +9,7 @@ import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os';
export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes
export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes
// State
let currentLanguage: SupportedLanguage = 'en';
@@ -51,10 +51,12 @@ export function detectSystemLanguage(): SupportedLanguage {
const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG'];
if (envLang?.startsWith('zh')) return 'zh';
if (envLang?.startsWith('en')) return 'en';
if (envLang?.startsWith('ru')) return 'ru';
try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
if (locale.startsWith('zh')) return 'zh';
if (locale.startsWith('ru')) return 'ru';
} catch {
// Fallback to default
}

View File

@@ -110,7 +110,6 @@ export default {
'open full Qwen Code documentation in your browser',
'Configuration not available.': 'Configuration not available.',
'change the auth method': 'change the auth method',
'Show quit confirmation dialog': 'Show quit confirmation dialog',
'Copy the last result or code snippet to clipboard':
'Copy the last result or code snippet to clipboard',
@@ -690,18 +689,6 @@ export default {
'A custom command wants to run the following shell commands:':
'A custom command wants to run the following shell commands:',
// ============================================================================
// Dialogs - Quit Confirmation
// ============================================================================
'What would you like to do before exiting?':
'What would you like to do before exiting?',
'Quit immediately (/quit)': 'Quit immediately (/quit)',
'Generate summary and quit (/summary)':
'Generate summary and quit (/summary)',
'Save conversation and quit (/chat save)':
'Save conversation and quit (/chat save)',
'Cancel (stay in application)': 'Cancel (stay in application)',
// ============================================================================
// Dialogs - Pro Quota
// ============================================================================
@@ -880,6 +867,7 @@ export default {
// Exit Screen / Stats
// ============================================================================
'Agent powering down. Goodbye!': 'Agent powering down. Goodbye!',
'To continue this session, run': 'To continue this session, run',
'Interaction Summary': 'Interaction Summary',
'Session ID:': 'Session ID:',
'Tool Calls:': 'Tool Calls:',

File diff suppressed because it is too large Load Diff

View File

@@ -108,7 +108,6 @@ export default {
'在浏览器中打开完整的 Qwen Code 文档',
'Configuration not available.': '配置不可用',
'change the auth method': '更改认证方法',
'Show quit confirmation dialog': '显示退出确认对话框',
'Copy the last result or code snippet to clipboard':
'将最后的结果或代码片段复制到剪贴板',
@@ -655,15 +654,6 @@ export default {
'A custom command wants to run the following shell commands:':
'自定义命令想要运行以下 shell 命令:',
// ============================================================================
// Dialogs - Quit Confirmation
// ============================================================================
'What would you like to do before exiting?': '退出前您想要做什么?',
'Quit immediately (/quit)': '立即退出 (/quit)',
'Generate summary and quit (/summary)': '生成摘要并退出 (/summary)',
'Save conversation and quit (/chat save)': '保存对话并退出 (/chat save)',
'Cancel (stay in application)': '取消(留在应用程序中)',
// ============================================================================
// Dialogs - Pro Quota
// ============================================================================
@@ -830,6 +820,7 @@ export default {
// Exit Screen / Stats
// ============================================================================
'Agent powering down. Goodbye!': 'Qwen Code 正在关闭,再见!',
'To continue this session, run': '要继续此会话,请运行',
'Interaction Summary': '交互摘要',
'Session ID:': '会话 ID',
'Tool Calls:': '工具调用:',

View File

@@ -16,9 +16,12 @@
* Controllers:
* - SystemController: initialize, interrupt, set_model, supported_commands
* - PermissionController: can_use_tool, set_permission_mode
* - MCPController: mcp_message, mcp_server_status
* - SdkMcpController: mcp_server_status (mcp_message handled via callback)
* - HookController: hook_callback
*
* Note: mcp_message requests are NOT routed through the dispatcher. CLI MCP
* clients send messages via SdkMcpController.createSendSdkMcpMessage() callback.
*
* Note: Control request types are centrally defined in the ControlRequestType
* enum in packages/sdk/typescript/src/types/controlRequests.ts
*/
@@ -26,8 +29,8 @@
import type { IControlContext } from './ControlContext.js';
import type { IPendingRequestRegistry } from './controllers/baseController.js';
import { SystemController } from './controllers/systemController.js';
// import { PermissionController } from './controllers/permissionController.js';
// import { MCPController } from './controllers/mcpController.js';
import { PermissionController } from './controllers/permissionController.js';
import { SdkMcpController } from './controllers/sdkMcpController.js';
// import { HookController } from './controllers/hookController.js';
import type {
CLIControlRequest,
@@ -64,8 +67,8 @@ export class ControlDispatcher implements IPendingRequestRegistry {
// Make controllers publicly accessible
readonly systemController: SystemController;
// readonly permissionController: PermissionController;
// readonly mcpController: MCPController;
readonly permissionController: PermissionController;
readonly sdkMcpController: SdkMcpController;
// readonly hookController: HookController;
// Central pending request registries
@@ -83,12 +86,16 @@ export class ControlDispatcher implements IPendingRequestRegistry {
this,
'SystemController',
);
// this.permissionController = new PermissionController(
// context,
// this,
// 'PermissionController',
// );
// this.mcpController = new MCPController(context, this, 'MCPController');
this.permissionController = new PermissionController(
context,
this,
'PermissionController',
);
this.sdkMcpController = new SdkMcpController(
context,
this,
'SdkMcpController',
);
// this.hookController = new HookController(context, this, 'HookController');
// Listen for main abort signal
@@ -228,10 +235,10 @@ export class ControlDispatcher implements IPendingRequestRegistry {
}
this.pendingOutgoingRequests.clear();
// Cleanup controllers (MCP controller will close all clients)
// Cleanup controllers
this.systemController.cleanup();
// this.permissionController.cleanup();
// this.mcpController.cleanup();
this.permissionController.cleanup();
this.sdkMcpController.cleanup();
// this.hookController.cleanup();
}
@@ -291,6 +298,47 @@ export class ControlDispatcher implements IPendingRequestRegistry {
}
}
/**
* Get count of pending incoming requests (for debugging)
*/
getPendingIncomingRequestCount(): number {
return this.pendingIncomingRequests.size;
}
/**
* Wait for all incoming request handlers to complete.
*
* Uses polling since we don't have direct Promise references to handlers.
* The pendingIncomingRequests map is managed by BaseController:
* - Registered when handler starts (in handleRequest)
* - Deregistered when handler completes (success or error)
*
* @param pollIntervalMs - How often to check (default 50ms)
* @param timeoutMs - Maximum wait time (default 30s)
*/
async waitForPendingIncomingRequests(
pollIntervalMs: number = 50,
timeoutMs: number = 30000,
): Promise<void> {
const startTime = Date.now();
while (this.pendingIncomingRequests.size > 0) {
if (Date.now() - startTime > timeoutMs) {
if (this.context.debugMode) {
console.error(
`[ControlDispatcher] Timeout waiting for ${this.pendingIncomingRequests.size} pending incoming requests`,
);
}
break;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
if (this.context.debugMode && this.pendingIncomingRequests.size === 0) {
console.error('[ControlDispatcher] All incoming requests completed');
}
}
/**
* Returns the controller that handles the given request subtype
*/
@@ -302,13 +350,12 @@ export class ControlDispatcher implements IPendingRequestRegistry {
case 'supported_commands':
return this.systemController;
// case 'can_use_tool':
// case 'set_permission_mode':
// return this.permissionController;
case 'can_use_tool':
case 'set_permission_mode':
return this.permissionController;
// case 'mcp_message':
// case 'mcp_server_status':
// return this.mcpController;
case 'mcp_server_status':
return this.sdkMcpController;
// case 'hook_callback':
// return this.hookController;

View File

@@ -29,7 +29,7 @@
import type { IControlContext } from './ControlContext.js';
import type { ControlDispatcher } from './ControlDispatcher.js';
import type {
// PermissionServiceAPI,
PermissionServiceAPI,
SystemServiceAPI,
// McpServiceAPI,
// HookServiceAPI,
@@ -61,43 +61,31 @@ export class ControlService {
* Handles tool execution permissions, approval checks, and callbacks.
* Delegates to the shared PermissionController instance.
*/
// get permission(): PermissionServiceAPI {
// const controller = this.dispatcher.permissionController;
// return {
// /**
// * Check if a tool should be allowed based on current permission settings
// *
// * Evaluates permission mode and tool registry to determine if execution
// * should proceed. Can optionally modify tool arguments based on confirmation details.
// *
// * @param toolRequest - Tool call request information
// * @param confirmationDetails - Optional confirmation details for UI
// * @returns Permission decision with optional updated arguments
// */
// shouldAllowTool: controller.shouldAllowTool.bind(controller),
//
// /**
// * Build UI suggestions for tool confirmation dialogs
// *
// * Creates actionable permission suggestions based on tool confirmation details.
// *
// * @param confirmationDetails - Tool confirmation details
// * @returns Array of permission suggestions or null
// */
// buildPermissionSuggestions:
// controller.buildPermissionSuggestions.bind(controller),
//
// /**
// * Get callback for monitoring tool call status updates
// *
// * Returns callback function for integration with CoreToolScheduler.
// *
// * @returns Callback function for tool call updates
// */
// getToolCallUpdateCallback:
// controller.getToolCallUpdateCallback.bind(controller),
// };
// }
get permission(): PermissionServiceAPI {
const controller = this.dispatcher.permissionController;
return {
/**
* Build UI suggestions for tool confirmation dialogs
*
* Creates actionable permission suggestions based on tool confirmation details.
*
* @param confirmationDetails - Tool confirmation details
* @returns Array of permission suggestions or null
*/
buildPermissionSuggestions:
controller.buildPermissionSuggestions.bind(controller),
/**
* Get callback for monitoring tool call status updates
*
* Returns callback function for integration with CoreToolScheduler.
*
* @returns Callback function for tool call updates
*/
getToolCallUpdateCallback:
controller.getToolCallUpdateCallback.bind(controller),
};
}
/**
* System Domain API

View File

@@ -117,16 +117,41 @@ export abstract class BaseController {
* Send an outgoing control request to SDK
*
* Manages lifecycle: register -> send -> wait for response -> deregister
* Respects the provided AbortSignal for cancellation.
*/
async sendControlRequest(
payload: ControlRequestPayload,
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
signal?: AbortSignal,
): Promise<ControlResponse> {
// Check if already aborted
if (signal?.aborted) {
throw new Error('Request aborted');
}
const requestId = randomUUID();
return new Promise<ControlResponse>((resolve, reject) => {
// Setup abort handler
const abortHandler = () => {
this.registry.deregisterOutgoingRequest(requestId);
reject(new Error('Request aborted'));
if (this.context.debugMode) {
console.error(
`[${this.controllerName}] Outgoing request aborted: ${requestId}`,
);
}
};
if (signal) {
signal.addEventListener('abort', abortHandler, { once: true });
}
// Setup timeout
const timeoutId = setTimeout(() => {
if (signal) {
signal.removeEventListener('abort', abortHandler);
}
this.registry.deregisterOutgoingRequest(requestId);
reject(new Error('Control request timeout'));
if (this.context.debugMode) {
@@ -136,12 +161,27 @@ export abstract class BaseController {
}
}, timeoutMs);
// Wrap resolve/reject to clean up abort listener
const wrappedResolve = (response: ControlResponse) => {
if (signal) {
signal.removeEventListener('abort', abortHandler);
}
resolve(response);
};
const wrappedReject = (error: Error) => {
if (signal) {
signal.removeEventListener('abort', abortHandler);
}
reject(error);
};
// Register with central registry
this.registry.registerOutgoingRequest(
requestId,
this.controllerName,
resolve,
reject,
wrappedResolve,
wrappedReject,
timeoutId,
);
@@ -155,6 +195,9 @@ export abstract class BaseController {
try {
this.context.streamJson.send(request);
} catch (error) {
if (signal) {
signal.removeEventListener('abort', abortHandler);
}
this.registry.deregisterOutgoingRequest(requestId);
reject(error);
}
@@ -174,7 +217,5 @@ export abstract class BaseController {
/**
* Cleanup resources
*/
cleanup(): void {
// Subclasses can override to add cleanup logic
}
cleanup(): void {}
}

View File

@@ -1,287 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* MCP Controller
*
* Handles MCP-related control requests:
* - mcp_message: Route MCP messages
* - mcp_server_status: Return MCP server status
*/
import { BaseController } from './baseController.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ResultSchema } from '@modelcontextprotocol/sdk/types.js';
import type {
ControlRequestPayload,
CLIControlMcpMessageRequest,
} from '../../types.js';
import type {
MCPServerConfig,
WorkspaceContext,
} from '@qwen-code/qwen-code-core';
import {
connectToMcpServer,
MCP_DEFAULT_TIMEOUT_MSEC,
} from '@qwen-code/qwen-code-core';
export class MCPController extends BaseController {
/**
* Handle MCP control requests
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
): Promise<Record<string, unknown>> {
switch (payload.subtype) {
case 'mcp_message':
return this.handleMcpMessage(payload as CLIControlMcpMessageRequest);
case 'mcp_server_status':
return this.handleMcpStatus();
default:
throw new Error(`Unsupported request subtype in MCPController`);
}
}
/**
* Handle mcp_message request
*
* Routes JSON-RPC messages to MCP servers
*/
private async handleMcpMessage(
payload: CLIControlMcpMessageRequest,
): Promise<Record<string, unknown>> {
const serverNameRaw = payload.server_name;
if (
typeof serverNameRaw !== 'string' ||
serverNameRaw.trim().length === 0
) {
throw new Error('Missing server_name in mcp_message request');
}
const message = payload.message;
if (!message || typeof message !== 'object') {
throw new Error(
'Missing or invalid message payload for mcp_message request',
);
}
// Get or create MCP client
let clientEntry: { client: Client; config: MCPServerConfig };
try {
clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim());
} catch (error) {
throw new Error(
error instanceof Error
? error.message
: 'Failed to connect to MCP server',
);
}
const method = message.method;
if (typeof method !== 'string' || method.trim().length === 0) {
throw new Error('Invalid MCP message: missing method');
}
const jsonrpcVersion =
typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0';
const messageId = message.id;
const params = message.params;
const timeout =
typeof clientEntry.config.timeout === 'number'
? clientEntry.config.timeout
: MCP_DEFAULT_TIMEOUT_MSEC;
try {
// Handle notification (no id)
if (messageId === undefined) {
await clientEntry.client.notification({
method,
params,
});
return {
subtype: 'mcp_message',
mcp_response: {
jsonrpc: jsonrpcVersion,
id: null,
result: { success: true, acknowledged: true },
},
};
}
// Handle request (with id)
const result = await clientEntry.client.request(
{
method,
params,
},
ResultSchema,
{ timeout },
);
return {
subtype: 'mcp_message',
mcp_response: {
jsonrpc: jsonrpcVersion,
id: messageId,
result,
},
};
} catch (error) {
// If connection closed, remove from cache
if (error instanceof Error && /closed/i.test(error.message)) {
this.context.mcpClients.delete(serverNameRaw.trim());
}
const errorCode =
typeof (error as { code?: unknown })?.code === 'number'
? ((error as { code: number }).code as number)
: -32603;
const errorMessage =
error instanceof Error
? error.message
: 'Failed to execute MCP request';
const errorData = (error as { data?: unknown })?.data;
const errorBody: Record<string, unknown> = {
code: errorCode,
message: errorMessage,
};
if (errorData !== undefined) {
errorBody['data'] = errorData;
}
return {
subtype: 'mcp_message',
mcp_response: {
jsonrpc: jsonrpcVersion,
id: messageId ?? null,
error: errorBody,
},
};
}
}
/**
* Handle mcp_server_status request
*
* Returns status of registered MCP servers
*/
private async handleMcpStatus(): Promise<Record<string, unknown>> {
const status: Record<string, string> = {};
// Include SDK MCP servers
for (const serverName of this.context.sdkMcpServers) {
status[serverName] = 'connected';
}
// Include CLI-managed MCP clients
for (const serverName of this.context.mcpClients.keys()) {
status[serverName] = 'connected';
}
if (this.context.debugMode) {
console.error(
`[MCPController] MCP status: ${Object.keys(status).length} servers`,
);
}
return status;
}
/**
* Get or create MCP client for a server
*
* Implements lazy connection and caching
*/
private async getOrCreateMcpClient(
serverName: string,
): Promise<{ client: Client; config: MCPServerConfig }> {
// Check cache first
const cached = this.context.mcpClients.get(serverName);
if (cached) {
return cached;
}
// Get server configuration
const provider = this.context.config as unknown as {
getMcpServers?: () => Record<string, MCPServerConfig> | undefined;
getDebugMode?: () => boolean;
getWorkspaceContext?: () => unknown;
};
if (typeof provider.getMcpServers !== 'function') {
throw new Error(`MCP server "${serverName}" is not configured`);
}
const servers = provider.getMcpServers() ?? {};
const serverConfig = servers[serverName];
if (!serverConfig) {
throw new Error(`MCP server "${serverName}" is not configured`);
}
const debugMode =
typeof provider.getDebugMode === 'function'
? provider.getDebugMode()
: false;
const workspaceContext =
typeof provider.getWorkspaceContext === 'function'
? provider.getWorkspaceContext()
: undefined;
if (!workspaceContext) {
throw new Error('Workspace context is not available for MCP connection');
}
// Connect to MCP server
const client = await connectToMcpServer(
serverName,
serverConfig,
debugMode,
workspaceContext as WorkspaceContext,
);
// Cache the client
const entry = { client, config: serverConfig };
this.context.mcpClients.set(serverName, entry);
if (this.context.debugMode) {
console.error(`[MCPController] Connected to MCP server: ${serverName}`);
}
return entry;
}
/**
* Cleanup MCP clients
*/
override cleanup(): void {
if (this.context.debugMode) {
console.error(
`[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`,
);
}
// Close all MCP clients
for (const [serverName, { client }] of this.context.mcpClients.entries()) {
try {
client.close();
} catch (error) {
if (this.context.debugMode) {
console.error(
`[MCPController] Failed to close MCP client ${serverName}:`,
error,
);
}
}
}
this.context.mcpClients.clear();
}
}

View File

@@ -15,8 +15,10 @@
*/
import type {
ToolCallRequestInfo,
WaitingToolCall,
ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails,
ApprovalMode,
} from '@qwen-code/qwen-code-core';
import {
InputFormat,
@@ -42,15 +44,23 @@ export class PermissionController extends BaseController {
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
signal: AbortSignal,
): Promise<Record<string, unknown>> {
if (signal.aborted) {
throw new Error('Request aborted');
}
switch (payload.subtype) {
case 'can_use_tool':
return this.handleCanUseTool(payload as CLIControlPermissionRequest);
return this.handleCanUseTool(
payload as CLIControlPermissionRequest,
signal,
);
case 'set_permission_mode':
return this.handleSetPermissionMode(
payload as CLIControlSetPermissionModeRequest,
signal,
);
default:
@@ -68,7 +78,12 @@ export class PermissionController extends BaseController {
*/
private async handleCanUseTool(
payload: CLIControlPermissionRequest,
signal: AbortSignal,
): Promise<Record<string, unknown>> {
if (signal.aborted) {
throw new Error('Request aborted');
}
const toolName = payload.tool_name;
if (
!toolName ||
@@ -190,7 +205,12 @@ export class PermissionController extends BaseController {
*/
private async handleSetPermissionMode(
payload: CLIControlSetPermissionModeRequest,
signal: AbortSignal,
): Promise<Record<string, unknown>> {
if (signal.aborted) {
throw new Error('Request aborted');
}
const mode = payload.mode;
const validModes: PermissionMode[] = [
'default',
@@ -206,6 +226,7 @@ export class PermissionController extends BaseController {
}
this.context.permissionMode = mode;
this.context.config.setApprovalMode(mode as ApprovalMode);
if (this.context.debugMode) {
console.error(
@@ -334,47 +355,6 @@ export class PermissionController extends BaseController {
}
}
/**
* Check if a tool should be executed based on current permission settings
*
* This is a convenience method for direct tool execution checks without
* going through the control request flow.
*/
async shouldAllowTool(
toolRequest: ToolCallRequestInfo,
confirmationDetails?: unknown,
): Promise<{
allowed: boolean;
message?: string;
updatedArgs?: Record<string, unknown>;
}> {
// Check permission mode
const modeResult = this.checkPermissionMode();
if (!modeResult.allowed) {
return {
allowed: false,
message: modeResult.message,
};
}
// Check tool registry
const registryResult = this.checkToolRegistry(toolRequest.name);
if (!registryResult.allowed) {
return {
allowed: false,
message: registryResult.message,
};
}
// If we have confirmation details, we could potentially modify args
// This is a hook for future enhancement
if (confirmationDetails) {
// Future: handle argument modifications based on confirmation details
}
return { allowed: true };
}
/**
* Get callback for monitoring tool calls and handling outgoing permission requests
* This is passed to executeToolCall to hook into CoreToolScheduler updates
@@ -411,6 +391,14 @@ export class PermissionController extends BaseController {
toolCall: WaitingToolCall,
): Promise<void> {
try {
// Check if already aborted
if (this.context.abortSignal?.aborted) {
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
);
return;
}
const inputFormat = this.context.config.getInputFormat?.();
const isStreamJsonMode = inputFormat === InputFormat.STREAM_JSON;
@@ -439,7 +427,8 @@ export class PermissionController extends BaseController {
permission_suggestions: permissionSuggestions,
blocked_path: null,
} as CLIControlPermissionRequest,
30000,
undefined, // use default timeout
this.context.abortSignal,
);
if (response.subtype !== 'success') {
@@ -462,8 +451,15 @@ export class PermissionController extends BaseController {
ToolConfirmationOutcome.ProceedOnce,
);
} else {
// Extract cancel message from response if available
const cancelMessage =
typeof payload['message'] === 'string'
? payload['message']
: undefined;
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
cancelMessage ? { cancelMessage } : undefined,
);
}
} catch (error) {
@@ -473,9 +469,23 @@ export class PermissionController extends BaseController {
error,
);
}
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
);
// On error, use default cancel message
// Only pass payload for exec and mcp types that support it
const confirmationType = toolCall.confirmationDetails.type;
if (['edit', 'exec', 'mcp'].includes(confirmationType)) {
const execOrMcpDetails = toolCall.confirmationDetails as
| ToolExecuteConfirmationDetails
| ToolMcpConfirmationDetails;
await execOrMcpDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
undefined,
);
} else {
// For other types, don't pass payload (backward compatible)
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
);
}
} finally {
this.pendingOutgoingRequests.delete(toolCall.request.callId);
}

View File

@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* SDK MCP Controller
*
* Handles MCP communication between CLI MCP clients and SDK MCP servers:
* - Provides sendSdkMcpMessage callback for CLI → SDK MCP message routing
* - mcp_server_status: Returns status of SDK MCP servers
*
* Message Flow (CLI MCP Client → SDK MCP Server):
* CLI MCP Client → SdkControlClientTransport.send() →
* sendSdkMcpMessage callback → control_request (mcp_message) → SDK →
* SDK MCP Server processes → control_response → CLI MCP Client
*/
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import { BaseController } from './baseController.js';
import type {
ControlRequestPayload,
CLIControlMcpMessageRequest,
} from '../../types.js';
const MCP_REQUEST_TIMEOUT = 30_000; // 30 seconds
export class SdkMcpController extends BaseController {
/**
* Handle SDK MCP control requests from ControlDispatcher
*
* Note: mcp_message requests are NOT handled here. CLI MCP clients
* send messages via the sendSdkMcpMessage callback directly, not
* through the control dispatcher.
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
signal: AbortSignal,
): Promise<Record<string, unknown>> {
if (signal.aborted) {
throw new Error('Request aborted');
}
switch (payload.subtype) {
case 'mcp_server_status':
return this.handleMcpStatus();
default:
throw new Error(`Unsupported request subtype in SdkMcpController`);
}
}
/**
* Handle mcp_server_status request
*
* Returns status of all registered SDK MCP servers.
* SDK servers are considered "connected" if they are registered.
*/
private async handleMcpStatus(): Promise<Record<string, unknown>> {
const status: Record<string, string> = {};
for (const serverName of this.context.sdkMcpServers) {
// SDK MCP servers are "connected" once registered since they run in SDK process
status[serverName] = 'connected';
}
return {
subtype: 'mcp_server_status',
status,
};
}
/**
* Send MCP message to SDK server via control plane
*
* @param serverName - Name of the SDK MCP server
* @param message - MCP JSON-RPC message to send
* @returns MCP JSON-RPC response from SDK server
*/
private async sendMcpMessageToSdk(
serverName: string,
message: JSONRPCMessage,
): Promise<JSONRPCMessage> {
if (this.context.debugMode) {
console.error(
`[SdkMcpController] Sending MCP message to SDK server '${serverName}':`,
JSON.stringify(message),
);
}
// Send control request to SDK with the MCP message
const response = await this.sendControlRequest(
{
subtype: 'mcp_message',
server_name: serverName,
message: message as CLIControlMcpMessageRequest['message'],
},
MCP_REQUEST_TIMEOUT,
this.context.abortSignal,
);
// Extract MCP response from control response
const responsePayload = response.response as Record<string, unknown>;
const mcpResponse = responsePayload?.['mcp_response'] as JSONRPCMessage;
if (!mcpResponse) {
throw new Error(
`Invalid MCP response from SDK for server '${serverName}'`,
);
}
if (this.context.debugMode) {
console.error(
`[SdkMcpController] Received MCP response from SDK server '${serverName}':`,
JSON.stringify(mcpResponse),
);
}
return mcpResponse;
}
/**
* Create a callback function for sending MCP messages to SDK servers.
*
* This callback is used by McpClientManager/SdkControlClientTransport to send
* MCP messages from CLI MCP clients to SDK MCP servers via the control plane.
*
* @returns A function that sends MCP messages to SDK and returns the response
*/
createSendSdkMcpMessage(): (
serverName: string,
message: JSONRPCMessage,
) => Promise<JSONRPCMessage> {
return (serverName: string, message: JSONRPCMessage) =>
this.sendMcpMessageToSdk(serverName, message);
}
}

View File

@@ -18,7 +18,15 @@ import type {
ControlRequestPayload,
CLIControlInitializeRequest,
CLIControlSetModelRequest,
CLIMcpServerConfig,
} from '../../types.js';
import { CommandService } from '../../../services/CommandService.js';
import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js';
import {
MCPServerConfig,
AuthProviderType,
type MCPOAuthConfig,
} from '@qwen-code/qwen-code-core';
export class SystemController extends BaseController {
/**
@@ -26,20 +34,30 @@ export class SystemController extends BaseController {
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
signal: AbortSignal,
): Promise<Record<string, unknown>> {
if (signal.aborted) {
throw new Error('Request aborted');
}
switch (payload.subtype) {
case 'initialize':
return this.handleInitialize(payload as CLIControlInitializeRequest);
return this.handleInitialize(
payload as CLIControlInitializeRequest,
signal,
);
case 'interrupt':
return this.handleInterrupt();
case 'set_model':
return this.handleSetModel(payload as CLIControlSetModelRequest);
return this.handleSetModel(
payload as CLIControlSetModelRequest,
signal,
);
case 'supported_commands':
return this.handleSupportedCommands();
return this.handleSupportedCommands(signal);
default:
throw new Error(`Unsupported request subtype in SystemController`);
@@ -49,15 +67,130 @@ export class SystemController extends BaseController {
/**
* Handle initialize request
*
* Registers SDK MCP servers and returns capabilities
* Processes SDK MCP servers config.
* SDK servers are registered in context.sdkMcpServers
* and added to config.mcpServers with the sdk type flag.
* External MCP servers are configured separately in settings.
*/
private async handleInitialize(
payload: CLIControlInitializeRequest,
signal: AbortSignal,
): Promise<Record<string, unknown>> {
// Register SDK MCP servers if provided
if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) {
for (const serverName of payload.sdkMcpServers) {
this.context.sdkMcpServers.add(serverName);
if (signal.aborted) {
throw new Error('Request aborted');
}
this.context.config.setSdkMode(true);
// Process SDK MCP servers
if (
payload.sdkMcpServers &&
typeof payload.sdkMcpServers === 'object' &&
payload.sdkMcpServers !== null
) {
const sdkServers: Record<string, MCPServerConfig> = {};
for (const [key, wireConfig] of Object.entries(payload.sdkMcpServers)) {
const name =
typeof wireConfig?.name === 'string' && wireConfig.name.trim().length
? wireConfig.name
: key;
this.context.sdkMcpServers.add(name);
sdkServers[name] = new MCPServerConfig(
undefined, // command
undefined, // args
undefined, // env
undefined, // cwd
undefined, // url
undefined, // httpUrl
undefined, // headers
undefined, // tcp
undefined, // timeout
true, // trust - SDK servers are trusted
undefined, // description
undefined, // includeTools
undefined, // excludeTools
undefined, // extensionName
undefined, // oauth
undefined, // authProviderType
undefined, // targetAudience
undefined, // targetServiceAccount
'sdk', // type
);
}
const sdkServerCount = Object.keys(sdkServers).length;
if (sdkServerCount > 0) {
try {
this.context.config.addMcpServers(sdkServers);
if (this.context.debugMode) {
console.error(
`[SystemController] Added ${sdkServerCount} SDK MCP servers to config`,
);
}
} catch (error) {
if (this.context.debugMode) {
console.error(
'[SystemController] Failed to add SDK MCP servers:',
error,
);
}
}
}
}
if (
payload.mcpServers &&
typeof payload.mcpServers === 'object' &&
payload.mcpServers !== null
) {
const externalServers: Record<string, MCPServerConfig> = {};
for (const [name, serverConfig] of Object.entries(payload.mcpServers)) {
const normalized = this.normalizeMcpServerConfig(
name,
serverConfig as CLIMcpServerConfig | undefined,
);
if (normalized) {
externalServers[name] = normalized;
}
}
const externalCount = Object.keys(externalServers).length;
if (externalCount > 0) {
try {
this.context.config.addMcpServers(externalServers);
if (this.context.debugMode) {
console.error(
`[SystemController] Added ${externalCount} external MCP servers to config`,
);
}
} catch (error) {
if (this.context.debugMode) {
console.error(
'[SystemController] Failed to add external MCP servers:',
error,
);
}
}
}
}
if (payload.agents && Array.isArray(payload.agents)) {
try {
this.context.config.setSessionSubagents(payload.agents);
if (this.context.debugMode) {
console.error(
`[SystemController] Added ${payload.agents.length} session subagents to config`,
);
}
} catch (error) {
if (this.context.debugMode) {
console.error(
'[SystemController] Failed to add session subagents:',
error,
);
}
}
}
@@ -86,36 +219,98 @@ export class SystemController extends BaseController {
buildControlCapabilities(): Record<string, unknown> {
const capabilities: Record<string, unknown> = {
can_handle_can_use_tool: true,
can_handle_hook_callback: true,
can_handle_hook_callback: false,
can_set_permission_mode:
typeof this.context.config.setApprovalMode === 'function',
can_set_model: typeof this.context.config.setModel === 'function',
// SDK MCP servers are supported - messages routed through control plane
can_handle_mcp_message: true,
};
// Check if MCP message handling is available
try {
const mcpProvider = this.context.config as unknown as {
getMcpServers?: () => Record<string, unknown> | undefined;
};
if (typeof mcpProvider.getMcpServers === 'function') {
const servers = mcpProvider.getMcpServers();
capabilities['can_handle_mcp_message'] = Boolean(
servers && Object.keys(servers).length > 0,
);
} else {
capabilities['can_handle_mcp_message'] = false;
}
} catch (error) {
return capabilities;
}
private normalizeMcpServerConfig(
serverName: string,
config?: CLIMcpServerConfig,
): MCPServerConfig | null {
if (!config || typeof config !== 'object') {
if (this.context.debugMode) {
console.error(
'[SystemController] Failed to determine MCP capability:',
error,
`[SystemController] Ignoring invalid MCP server config for '${serverName}'`,
);
}
capabilities['can_handle_mcp_message'] = false;
return null;
}
return capabilities;
const authProvider = this.normalizeAuthProviderType(
config.authProviderType,
);
const oauthConfig = this.normalizeOAuthConfig(config.oauth);
return new MCPServerConfig(
config.command,
config.args,
config.env,
config.cwd,
config.url,
config.httpUrl,
config.headers,
config.tcp,
config.timeout,
config.trust,
config.description,
config.includeTools,
config.excludeTools,
config.extensionName,
oauthConfig,
authProvider,
config.targetAudience,
config.targetServiceAccount,
);
}
private normalizeAuthProviderType(
value?: string,
): AuthProviderType | undefined {
if (!value) {
return undefined;
}
switch (value) {
case AuthProviderType.DYNAMIC_DISCOVERY:
case AuthProviderType.GOOGLE_CREDENTIALS:
case AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION:
return value;
default:
if (this.context.debugMode) {
console.error(
`[SystemController] Unsupported authProviderType '${value}', skipping`,
);
}
return undefined;
}
}
private normalizeOAuthConfig(
oauth?: CLIMcpServerConfig['oauth'],
): MCPOAuthConfig | undefined {
if (!oauth) {
return undefined;
}
return {
enabled: oauth.enabled,
clientId: oauth.clientId,
clientSecret: oauth.clientSecret,
authorizationUrl: oauth.authorizationUrl,
tokenUrl: oauth.tokenUrl,
scopes: oauth.scopes,
audiences: oauth.audiences,
redirectUri: oauth.redirectUri,
tokenParamName: oauth.tokenParamName,
registrationUrl: oauth.registrationUrl,
};
}
/**
@@ -151,7 +346,12 @@ export class SystemController extends BaseController {
*/
private async handleSetModel(
payload: CLIControlSetModelRequest,
signal: AbortSignal,
): Promise<Record<string, unknown>> {
if (signal.aborted) {
throw new Error('Request aborted');
}
const model = payload.model;
// Validate model parameter
@@ -189,27 +389,63 @@ export class SystemController extends BaseController {
/**
* Handle supported_commands request
*
* Returns list of supported control commands
*
* Note: This list should match the ControlRequestType enum in
* packages/sdk/typescript/src/types/controlRequests.ts
* Returns list of supported slash commands loaded dynamically
*/
private async handleSupportedCommands(): Promise<Record<string, unknown>> {
const commands = [
'initialize',
'interrupt',
'set_model',
'supported_commands',
'can_use_tool',
'set_permission_mode',
'mcp_message',
'mcp_server_status',
'hook_callback',
];
private async handleSupportedCommands(
signal: AbortSignal,
): Promise<Record<string, unknown>> {
if (signal.aborted) {
throw new Error('Request aborted');
}
const slashCommands = await this.loadSlashCommandNames(signal);
return {
subtype: 'supported_commands',
commands,
commands: slashCommands,
};
}
/**
* Load slash command names using CommandService
*
* @param signal - AbortSignal to respect for cancellation
* @returns Promise resolving to array of slash command names
*/
private async loadSlashCommandNames(signal: AbortSignal): Promise<string[]> {
if (signal.aborted) {
return [];
}
try {
const service = await CommandService.create(
[new BuiltinCommandLoader(this.context.config)],
signal,
);
if (signal.aborted) {
return [];
}
const names = new Set<string>();
const commands = service.getCommands();
for (const command of commands) {
names.add(command.name);
}
return Array.from(names).sort();
} catch (error) {
// Check if the error is due to abort
if (signal.aborted) {
return [];
}
if (this.context.debugMode) {
console.error(
'[SystemController] Failed to load slash commands:',
error,
);
}
return [];
}
}
}

View File

@@ -13,10 +13,7 @@
*/
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type {
ToolCallRequestInfo,
MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
import type { PermissionSuggestion } from '../../types.js';
/**
@@ -26,25 +23,6 @@ import type { PermissionSuggestion } from '../../types.js';
* permission suggestions, and tool call monitoring callbacks.
*/
export interface PermissionServiceAPI {
/**
* Check if a tool should be allowed based on current permission settings
*
* Evaluates permission mode and tool registry to determine if execution
* should proceed. Can optionally modify tool arguments based on confirmation details.
*
* @param toolRequest - Tool call request information containing name, args, and call ID
* @param confirmationDetails - Optional confirmation details for UI-driven approvals
* @returns Promise resolving to permission decision with optional updated arguments
*/
shouldAllowTool(
toolRequest: ToolCallRequestInfo,
confirmationDetails?: unknown,
): Promise<{
allowed: boolean;
message?: string;
updatedArgs?: Record<string, unknown>;
}>;
/**
* Build UI suggestions for tool confirmation dialogs
*

View File

@@ -13,7 +13,11 @@ import type {
ServerGeminiStreamEvent,
TaskResultDisplay,
} from '@qwen-code/qwen-code-core';
import { GeminiEventType, ToolErrorType } from '@qwen-code/qwen-code-core';
import {
GeminiEventType,
ToolErrorType,
parseAndFormatApiError,
} from '@qwen-code/qwen-code-core';
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
import type {
CLIAssistantMessage,
@@ -600,6 +604,18 @@ export abstract class BaseJsonOutputAdapter {
}
this.finalizePendingBlocks(state, null);
break;
case GeminiEventType.Error: {
// Format the error message using parseAndFormatApiError for consistency
// with interactive mode error display
const errorText = parseAndFormatApiError(
event.value.error,
this.config.getContentGeneratorConfig()?.authType,
undefined,
this.config.getModel(),
);
this.appendText(state, errorText, null);
break;
}
default:
break;
}
@@ -939,9 +955,25 @@ export abstract class BaseJsonOutputAdapter {
this.emitMessageImpl(message);
}
/**
* Checks if responseParts contain any functionResponse with an error.
* This handles cancelled responses and other error cases where the error
* is embedded in responseParts rather than the top-level error field.
* @param responseParts - Array of Part objects
* @returns Error message if found, undefined otherwise
*/
private checkResponsePartsForError(
responseParts: Part[] | undefined,
): string | undefined {
// Use the shared helper function defined at file level
return checkResponsePartsForError(responseParts);
}
/**
* Emits a tool result message.
* Collects execution denied tool calls for inclusion in result messages.
* Handles both explicit errors (response.error) and errors embedded in
* responseParts (e.g., cancelled responses).
* @param request - Tool call request info
* @param response - Tool call response info
* @param parentToolUseId - Parent tool use ID (null for main agent)
@@ -951,6 +983,14 @@ export abstract class BaseJsonOutputAdapter {
response: ToolCallResponseInfo,
parentToolUseId: string | null = null,
): void {
// Check for errors in responseParts (e.g., cancelled responses)
const responsePartsError = this.checkResponsePartsForError(
response.responseParts,
);
// Determine if this is an error response
const hasError = Boolean(response.error) || Boolean(responsePartsError);
// Track permission denials (execution denied errors)
if (
response.error &&
@@ -967,7 +1007,7 @@ export abstract class BaseJsonOutputAdapter {
const block: ToolResultBlock = {
type: 'tool_result',
tool_use_id: request.callId,
is_error: Boolean(response.error),
is_error: hasError,
};
const content = toolResultContent(response);
if (content !== undefined) {
@@ -1173,11 +1213,41 @@ export function partsToString(parts: Part[]): string {
.join('');
}
/**
* Checks if responseParts contain any functionResponse with an error.
* Helper function for extracting error messages from responseParts.
* @param responseParts - Array of Part objects
* @returns Error message if found, undefined otherwise
*/
function checkResponsePartsForError(
responseParts: Part[] | undefined,
): string | undefined {
if (!responseParts || responseParts.length === 0) {
return undefined;
}
for (const part of responseParts) {
if (
'functionResponse' in part &&
part.functionResponse?.response &&
typeof part.functionResponse.response === 'object' &&
'error' in part.functionResponse.response &&
part.functionResponse.response['error']
) {
const error = part.functionResponse.response['error'];
return typeof error === 'string' ? error : String(error);
}
}
return undefined;
}
/**
* Extracts content from tool response.
* Uses functionResponsePartsToString to properly handle functionResponse parts,
* which correctly extracts output content from functionResponse objects rather
* than simply concatenating text or JSON.stringify.
* Also handles errors embedded in responseParts (e.g., cancelled responses).
*
* @param response - Tool call response
* @returns String content or undefined
@@ -1188,6 +1258,11 @@ export function toolResultContent(
if (response.error) {
return response.error.message;
}
// Check for errors in responseParts (e.g., cancelled responses)
const responsePartsError = checkResponsePartsForError(response.responseParts);
if (responsePartsError) {
return responsePartsError;
}
if (
typeof response.resultDisplay === 'string' &&
response.resultDisplay.trim().length > 0

View File

@@ -69,6 +69,7 @@ function createConfig(overrides: ConfigOverrides = {}): Config {
getDebugMode: () => false,
getApprovalMode: () => 'auto',
getOutputFormat: () => 'stream-json',
initialize: vi.fn(),
};
return { ...base, ...overrides } as unknown as Config;
}
@@ -152,6 +153,11 @@ describe('runNonInteractiveStreamJson', () => {
handleControlResponse: ReturnType<typeof vi.fn>;
handleCancel: ReturnType<typeof vi.fn>;
shutdown: ReturnType<typeof vi.fn>;
getPendingIncomingRequestCount: ReturnType<typeof vi.fn>;
waitForPendingIncomingRequests: ReturnType<typeof vi.fn>;
sdkMcpController: {
createSendSdkMcpMessage: ReturnType<typeof vi.fn>;
};
};
let mockConsolePatcher: {
patch: ReturnType<typeof vi.fn>;
@@ -186,6 +192,11 @@ describe('runNonInteractiveStreamJson', () => {
handleControlResponse: vi.fn(),
handleCancel: vi.fn(),
shutdown: vi.fn(),
getPendingIncomingRequestCount: vi.fn().mockReturnValue(0),
waitForPendingIncomingRequests: vi.fn().mockResolvedValue(undefined),
sdkMcpController: {
createSendSdkMcpMessage: vi.fn().mockReturnValue(vi.fn()),
},
};
(
ControlDispatcher as unknown as ReturnType<typeof vi.fn>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { SubagentConfig } from '@qwen-code/qwen-code-core';
/**
* Annotation for attaching metadata to content blocks
@@ -137,9 +138,8 @@ export interface CLISystemMessage {
status: string;
}>;
model?: string;
permissionMode?: string;
permission_mode?: string;
slash_commands?: string[];
apiKeySource?: string;
qwen_code_version?: string;
output_style?: string;
agents?: string[];
@@ -295,10 +295,69 @@ export interface CLIControlPermissionRequest {
blocked_path: string | null;
}
/**
* Wire format for SDK MCP server config in initialization request.
* The actual Server instance stays in the SDK process.
*/
export interface SDKMcpServerConfig {
type: 'sdk';
name: string;
}
/**
* Wire format for external MCP server config in initialization request.
* Represents stdio/SSE/HTTP/TCP transports that must run in the CLI process.
*/
export interface CLIMcpServerConfig {
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
httpUrl?: string;
headers?: Record<string, string>;
tcp?: string;
timeout?: number;
trust?: boolean;
description?: string;
includeTools?: string[];
excludeTools?: string[];
extensionName?: string;
oauth?: {
enabled?: boolean;
clientId?: string;
clientSecret?: string;
authorizationUrl?: string;
tokenUrl?: string;
scopes?: string[];
audiences?: string[];
redirectUri?: string;
tokenParamName?: string;
registrationUrl?: string;
};
authProviderType?:
| 'dynamic_discovery'
| 'google_credentials'
| 'service_account_impersonation';
targetAudience?: string;
targetServiceAccount?: string;
}
export interface CLIControlInitializeRequest {
subtype: 'initialize';
hooks?: HookRegistration[] | null;
sdkMcpServers?: string[];
/**
* SDK MCP servers config
* These are MCP servers running in the SDK process, connected via control plane.
* External MCP servers are configured separately in settings, not via initialization.
*/
sdkMcpServers?: Record<string, Omit<SDKMcpServerConfig, 'instance'>>;
/**
* External MCP servers that the SDK wants the CLI to manage.
* These run outside the SDK process and require CLI-side transport setup.
*/
mcpServers?: Record<string, CLIMcpServerConfig>;
agents?: SubagentConfig[];
}
export interface CLIControlSetPermissionModeRequest {

View File

@@ -245,6 +245,7 @@ describe('runNonInteractive', () => {
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
{ isContinuation: false },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Hello');
expect(processStdoutSpy).toHaveBeenCalledWith(' World');
@@ -293,11 +294,21 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
undefined,
);
// Verify first call has isContinuation: false
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
1,
[{ text: 'Use a tool' }],
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: false },
);
// Verify second call (after tool execution) has isContinuation: true
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
2,
[{ text: 'Tool response' }],
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: true },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
@@ -372,6 +383,7 @@ describe('runNonInteractive', () => {
],
expect.any(AbortSignal),
'prompt-id-3',
{ isContinuation: true },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
});
@@ -497,6 +509,7 @@ describe('runNonInteractive', () => {
processedParts,
expect.any(AbortSignal),
'prompt-id-7',
{ isContinuation: false },
);
// 6. Assert the final output is correct
@@ -528,6 +541,7 @@ describe('runNonInteractive', () => {
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
{ isContinuation: false },
);
// JSON adapter emits array of messages, last one is result with stats
@@ -680,6 +694,7 @@ describe('runNonInteractive', () => {
[{ text: 'Empty response test' }],
expect.any(AbortSignal),
'prompt-id-empty',
{ isContinuation: false },
);
// JSON adapter emits array of messages, last one is result with stats
@@ -831,6 +846,7 @@ describe('runNonInteractive', () => {
[{ text: 'Prompt from command' }],
expect.any(AbortSignal),
'prompt-id-slash',
{ isContinuation: false },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
@@ -887,6 +903,7 @@ describe('runNonInteractive', () => {
[{ text: '/unknowncommand' }],
expect.any(AbortSignal),
'prompt-id-unknown',
{ isContinuation: false },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
@@ -1217,6 +1234,7 @@ describe('runNonInteractive', () => {
[{ text: 'Message from stream-json input' }],
expect.any(AbortSignal),
'prompt-envelope',
{ isContinuation: false },
);
});
@@ -1692,6 +1710,7 @@ describe('runNonInteractive', () => {
[{ text: 'Simple string content' }],
expect.any(AbortSignal),
'prompt-string-content',
{ isContinuation: false },
);
// UserMessage with array of text blocks
@@ -1724,6 +1743,7 @@ describe('runNonInteractive', () => {
[{ text: 'First part' }, { text: 'Second part' }],
expect.any(AbortSignal),
'prompt-blocks-content',
{ isContinuation: false },
);
});
});

View File

@@ -15,7 +15,9 @@ import {
FatalInputError,
promptIdContext,
OutputFormat,
InputFormat,
uiTelemetryService,
parseAndFormatApiError,
} from '@qwen-code/qwen-code-core';
import type { Content, Part, PartListUnion } from '@google/genai';
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
@@ -170,6 +172,7 @@ export async function runNonInteractive(
adapter.emitMessage(systemMessage);
}
let isFirstTurn = true;
while (true) {
turnCount++;
if (
@@ -185,7 +188,9 @@ export async function runNonInteractive(
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
{ isContinuation: !isFirstTurn },
);
isFirstTurn = false;
// Start assistant message for this turn
if (adapter) {
@@ -205,10 +210,21 @@ export async function runNonInteractive(
}
} else {
// Text output mode - direct stdout
if (event.type === GeminiEventType.Content) {
if (event.type === GeminiEventType.Thought) {
process.stdout.write(event.value.description);
} else if (event.type === GeminiEventType.Content) {
process.stdout.write(event.value);
} else if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
} else if (event.type === GeminiEventType.Error) {
// Format and output the error message for text mode
const errorText = parseAndFormatApiError(
event.value.error,
config.getContentGeneratorConfig()?.authType,
undefined,
config.getModel(),
);
process.stderr.write(`${errorText}\n`);
}
}
}
@@ -225,40 +241,14 @@ export async function runNonInteractive(
for (const requestInfo of toolCallRequests) {
const finalRequestInfo = requestInfo;
/*
if (options.controlService) {
const permissionResult =
await options.controlService.permission.shouldAllowTool(
requestInfo,
);
if (!permissionResult.allowed) {
if (config.getDebugMode()) {
console.error(
`[runNonInteractive] Tool execution denied: ${requestInfo.name}`,
permissionResult.message ?? '',
);
}
if (adapter && permissionResult.message) {
adapter.emitSystemMessage('tool_denied', {
tool: requestInfo.name,
message: permissionResult.message,
});
}
continue;
}
if (permissionResult.updatedArgs) {
finalRequestInfo = {
...requestInfo,
args: permissionResult.updatedArgs,
};
}
}
const toolCallUpdateCallback = options.controlService
? options.controlService.permission.getToolCallUpdateCallback()
: undefined;
*/
const inputFormat =
typeof config.getInputFormat === 'function'
? config.getInputFormat()
: InputFormat.TEXT;
const toolCallUpdateCallback =
inputFormat === InputFormat.STREAM_JSON && options.controlService
? options.controlService.permission.getToolCallUpdateCallback()
: undefined;
// Only pass outputUpdateHandler for Task tool
const isTaskTool = finalRequestInfo.name === 'task';
@@ -277,13 +267,13 @@ export async function runNonInteractive(
isTaskTool && taskToolProgressHandler
? {
outputUpdateHandler: taskToolProgressHandler,
/*
toolCallUpdateCallback
? { onToolCallsUpdate: toolCallUpdateCallback }
: undefined,
*/
onToolCallsUpdate: toolCallUpdateCallback,
}
: undefined,
: toolCallUpdateCallback
? {
onToolCallsUpdate: toolCallUpdateCallback,
}
: undefined,
);
// Note: In JSON mode, subagent messages are automatically added to the main
@@ -303,9 +293,6 @@ export async function runNonInteractive(
? toolResponse.resultDisplay
: undefined,
);
// Note: We no longer emit a separate system message for tool errors
// in JSON/STREAM_JSON mode, as the error is already captured in the
// tool_result block with is_error=true.
}
if (adapter) {

View File

@@ -56,10 +56,8 @@ 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: {} }));
vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} }));
vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} }));
vi.mock('../ui/commands/extensionsCommand.js', () => ({
@@ -72,7 +70,6 @@ vi.mock('../ui/commands/modelCommand.js', () => ({
}));
vi.mock('../ui/commands/quitCommand.js', () => ({
quitCommand: {},
quitConfirmCommand: {},
}));
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));

View File

@@ -12,11 +12,9 @@ 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';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
@@ -29,7 +27,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { quitCommand, quitConfirmCommand } from '../ui/commands/quitCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
@@ -61,11 +59,9 @@ export class BuiltinCommandLoader implements ICommandLoader {
approvalModeCommand,
authCommand,
bugCommand,
chatCommand,
clearCommand,
compressCommand,
copyCommand,
corgiCommand,
docsCommand,
directoryCommand,
editorCommand,
@@ -79,7 +75,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
modelCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
quitCommand,
quitConfirmCommand,
restoreCommand(this.config),
statsCommand,
summaryCommand,

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
@@ -55,15 +56,17 @@ export const createMockCommandContext = (
pendingItem: null,
setPendingItem: vi.fn(),
loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
toggleVimEnabled: vi.fn(),
extensionsUpdateState: new Map(),
setExtensionsUpdateState: vi.fn(),
reloadCommands: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
session: {
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';
@@ -88,7 +89,6 @@ import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
@@ -136,7 +136,6 @@ export const AppContainer = (props: AppContainerProps) => {
const { settings, config, initializationResult } = props;
const historyManager = useHistory();
useMemoryMonitor(historyManager);
const [corgiMode, setCorgiMode] = useState(false);
const [debugMessage, setDebugMessage] = useState<string>('');
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
@@ -196,7 +195,6 @@ export const AppContainer = (props: AppContainerProps) => {
const [isConfigInitialized, setConfigInitialized] = useState(false);
const logger = useLogger(config.storage);
const [userMessages, setUserMessages] = useState<string[]>([]);
// Terminal and layout hooks
@@ -206,6 +204,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Additional hooks moved from App.tsx
const { stats: sessionStats } = useSessionStats();
const logger = useLogger(config.storage, sessionStats.sessionId);
const branchName = useGitBranchName(config.getTargetDir());
// Layout measurements
@@ -216,17 +215,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(
@@ -434,8 +444,6 @@ export const AppContainer = (props: AppContainerProps) => {
const { toggleVimEnabled } = useVimMode();
const { showQuitConfirmation } = useQuitConfirmation();
const {
isSubagentCreateDialogOpen,
openSubagentCreateDialog,
@@ -476,12 +484,10 @@ export const AppContainer = (props: AppContainerProps) => {
}, 100);
},
setDebugMessage,
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
openAgentsManagerDialog,
_showQuitConfirmation: showQuitConfirmation,
}),
[
openAuthDialog,
@@ -490,12 +496,10 @@ export const AppContainer = (props: AppContainerProps) => {
openSettingsDialog,
openModelDialog,
setDebugMessage,
setCorgiMode,
dispatchExtensionStateUpdate,
openPermissionsDialog,
openApprovalModeDialog,
addConfirmUpdateExtensionRequest,
showQuitConfirmation,
openSubagentCreateDialog,
openAgentsManagerDialog,
],
@@ -508,7 +512,6 @@ export const AppContainer = (props: AppContainerProps) => {
commandContext,
shellConfirmationRequest,
confirmationRequest,
quitConfirmationRequest,
} = useSlashCommandProcessor(
config,
settings,
@@ -522,6 +525,7 @@ export const AppContainer = (props: AppContainerProps) => {
slashCommandActions,
extensionsUpdateStateInternal,
isConfigInitialized,
logger,
);
// Vision switch handlers
@@ -938,6 +942,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFocused,
streamingState,
elapsedTime,
settings,
});
// Dialog close functionality
@@ -956,7 +961,6 @@ export const AppContainer = (props: AppContainerProps) => {
isFolderTrustDialogOpen,
showWelcomeBackDialog,
handleWelcomeBackClose,
quitConfirmationRequest,
});
const handleExit = useCallback(
@@ -970,25 +974,18 @@ export const AppContainer = (props: AppContainerProps) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// Exit directly without showing confirmation dialog
// Exit directly
handleSlashCommand('/quit');
return;
}
// First press: Prioritize cleanup tasks
// Special case: If quit-confirm dialog is open, Ctrl+C means "quit immediately"
if (quitConfirmationRequest) {
handleSlashCommand('/quit');
return;
}
// 1. Close other dialogs (highest priority)
/**
* For AuthDialog it is required to complete the authentication process,
* otherwise user cannot proceed to the next step.
* So a quit on AuthDialog should go with normal two press quit
* and without quit-confirm dialog.
* So a quit on AuthDialog should go with normal two press quit.
*/
if (isAuthDialogOpen) {
setPressedOnce(true);
@@ -1009,14 +1006,17 @@ export const AppContainer = (props: AppContainerProps) => {
return; // Request cancelled, end processing
}
// 3. Clear input buffer (if has content)
// 4. Clear input buffer (if has content)
if (buffer.text.length > 0) {
buffer.setText('');
return; // Input cleared, end processing
}
// All cleanup tasks completed, show quit confirmation dialog
handleSlashCommand('/quit-confirm');
// All cleanup tasks completed, set flag for double-press to quit
setPressedOnce(true);
timerRef.current = setTimeout(() => {
setPressedOnce(false);
}, CTRL_EXIT_PROMPT_DURATION_MS);
},
[
isAuthDialogOpen,
@@ -1024,7 +1024,6 @@ export const AppContainer = (props: AppContainerProps) => {
closeAnyOpenDialog,
streamingState,
cancelOngoingRequest,
quitConfirmationRequest,
buffer,
],
);
@@ -1041,8 +1040,8 @@ export const AppContainer = (props: AppContainerProps) => {
return;
}
// On first press: set flag, start timer, and call handleExit for cleanup/quit-confirm
// On second press (within 500ms): handleExit sees flag and does fast quit
// On first press: set flag, start timer, and call handleExit for cleanup
// On second press (within timeout): handleExit sees flag and does fast quit
if (!ctrlCPressedOnce) {
setCtrlCPressedOnce(true);
ctrlCTimerRef.current = setTimeout(() => {
@@ -1183,7 +1182,6 @@ export const AppContainer = (props: AppContainerProps) => {
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
!!quitConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
@@ -1218,7 +1216,6 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,
@@ -1232,7 +1229,6 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
quitConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,
@@ -1310,7 +1306,6 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,
@@ -1324,7 +1319,6 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
quitConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { corgiCommand } from './corgiCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('corgiCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
vi.spyOn(mockContext.ui, 'toggleCorgiMode');
});
it('should call the toggleCorgiMode function on the UI context', async () => {
if (!corgiCommand.action) {
throw new Error('The corgi command must have an action.');
}
await corgiCommand.action(mockContext, '');
expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1);
});
it('should have the correct name and description', () => {
expect(corgiCommand.name).toBe('corgi');
expect(corgiCommand.description).toBe('Toggles corgi mode.');
});
});

View File

@@ -1,17 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
export const corgiCommand: SlashCommand = {
name: 'corgi',
description: 'Toggles corgi mode.',
hidden: true,
kind: CommandKind.BUILT_IN,
action: (context, _args) => {
context.ui.toggleCorgiMode();
},
};

View File

@@ -0,0 +1,587 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
// Mock i18n module
vi.mock('../../i18n/index.js', () => ({
setLanguageAsync: vi.fn().mockResolvedValue(undefined),
getCurrentLanguage: vi.fn().mockReturnValue('en'),
t: vi.fn((key: string) => key),
}));
// Mock settings module to avoid Storage side effect
vi.mock('../../config/settings.js', () => ({
SettingScope: {
User: 'user',
Workspace: 'workspace',
Default: 'default',
},
}));
// Mock fs module
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
default: {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
},
};
});
// Mock Storage from core
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
Storage: {
getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'),
getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/.qwen/settings.json'),
},
};
});
// Import modules after mocking
import * as i18n from '../../i18n/index.js';
import { languageCommand } from './languageCommand.js';
describe('languageCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn().mockReturnValue('test-model'),
},
settings: {
merged: {},
setValue: vi.fn(),
},
},
});
// Reset i18n mocks
vi.mocked(i18n.getCurrentLanguage).mockReturnValue('en');
vi.mocked(i18n.t).mockImplementation((key: string) => key);
// Reset fs mocks
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('command metadata', () => {
it('should have the correct name', () => {
expect(languageCommand.name).toBe('language');
});
it('should have a description', () => {
expect(languageCommand.description).toBeDefined();
expect(typeof languageCommand.description).toBe('string');
});
it('should be a built-in command', () => {
expect(languageCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should have subcommands', () => {
expect(languageCommand.subCommands).toBeDefined();
expect(languageCommand.subCommands?.length).toBe(2);
});
it('should have ui and output subcommands', () => {
const subCommandNames = languageCommand.subCommands?.map((c) => c.name);
expect(subCommandNames).toContain('ui');
expect(subCommandNames).toContain('output');
});
});
describe('main command action - no arguments', () => {
it('should show current language settings when no arguments provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
});
it('should show available subcommands in help', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language ui'),
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language output'),
});
});
it('should show LLM output language when set', async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY',
);
// Make t() function handle interpolation for this test
vi.mocked(i18n.t).mockImplementation(
(key: string, params?: Record<string, string>) => {
if (params && key.includes('{{lang}}')) {
return key.replace('{{lang}}', params['lang'] || '');
}
return key;
},
);
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
// Verify it correctly parses "Chinese" from the template format
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Chinese'),
});
});
});
describe('main command action - config not available', () => {
it('should return error when config is null', async () => {
mockContext.services.config = null;
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Configuration not available'),
});
});
});
describe('/language ui subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language ui'),
});
});
it('should set English with "en"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(mockContext.services.settings.setValue).toHaveBeenCalled();
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "en-US"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en-US');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "english"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui english');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh-CN"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh-CN');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "chinese"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui chinese');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for invalid language', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui invalid');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid language'),
});
});
it('should persist setting to user scope', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
await languageCommand.action(mockContext, 'ui en');
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.anything(), // SettingScope.User
'general.language',
'en',
);
});
});
describe('/language output subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language output'),
});
});
it('should create LLM output language rule file', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Chinese');
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Chinese'),
'utf-8',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
});
});
it('should include restart notice in success message', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Japanese');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('restart'),
});
});
it('should handle file write errors gracefully', async () => {
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error('Permission denied');
});
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output German');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Failed to generate'),
});
});
});
describe('backward compatibility - direct language arguments', () => {
it('should set Chinese with direct "zh" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with direct "en" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for unknown direct argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'unknown');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid command'),
});
});
});
describe('ui subcommand object', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
it('should have correct metadata', () => {
expect(uiSubcommand).toBeDefined();
expect(uiSubcommand?.name).toBe('ui');
expect(uiSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have nested language subcommands', () => {
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
expect(nestedNames).toContain('zh-CN');
expect(nestedNames).toContain('en-US');
});
it('should have action that sets language', async () => {
if (!uiSubcommand?.action) {
throw new Error('UI subcommand must have an action.');
}
const result = await uiSubcommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
});
describe('output subcommand object', () => {
const outputSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'output',
);
it('should have correct metadata', () => {
expect(outputSubcommand).toBeDefined();
expect(outputSubcommand?.name).toBe('output');
expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have action that generates rule file', async () => {
if (!outputSubcommand?.action) {
throw new Error('Output subcommand must have an action.');
}
// Ensure mocks are properly set for this test
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
const result = await outputSubcommand.action(mockContext, 'French');
expect(fs.writeFileSync).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
});
});
});
describe('nested ui language subcommands', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
const zhCNSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'zh-CN',
);
const enUSSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'en-US',
);
it('zh-CN should have aliases', () => {
expect(zhCNSubcommand?.altNames).toContain('zh');
expect(zhCNSubcommand?.altNames).toContain('chinese');
});
it('en-US should have aliases', () => {
expect(enUSSubcommand?.altNames).toContain('en');
expect(enUSSubcommand?.altNames).toContain('english');
});
it('zh-CN action should set Chinese', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('en-US action should set English', async () => {
if (!enUSSubcommand?.action) {
throw new Error('en-US subcommand must have an action.');
}
const result = await enUSSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should reject extra arguments', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, 'extra args');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('do not accept additional arguments'),
});
});
});
});

View File

@@ -81,8 +81,9 @@ function getCurrentLlmOutputLanguage(): string | null {
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
// Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese")
const match = content.match(/^#\s+(.+?)\s+Response Rules/i);
// Extract language name from the first line
// Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i);
if (match) {
return match[1];
}
@@ -127,16 +128,17 @@ async function setUiLanguage(
context.ui.reloadCommands();
// Map language codes to friendly display names
const langDisplayNames: Record<SupportedLanguage, string> = {
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
zh: '中文zh-CN',
en: 'Englishen-US',
ru: 'Русский (ru-RU)',
};
return {
type: 'message',
messageType: 'info',
content: t('UI language changed to {{lang}}', {
lang: langDisplayNames[lang],
lang: langDisplayNames[lang] || lang,
}),
};
}
@@ -216,7 +218,7 @@ export const languageCommand: SlashCommand = {
: t('LLM output language not set'),
'',
t('Available subcommands:'),
` /language ui [zh-CN|en-US] - ${t('Set UI language')}`,
` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`,
` /language output <language> - ${t('Set LLM output language')}`,
].join('\n');
@@ -232,7 +234,7 @@ export const languageCommand: SlashCommand = {
const subcommand = parts[0].toLowerCase();
if (subcommand === 'ui') {
// Handle /language ui [zh-CN|en-US]
// Handle /language ui [zh-CN|en-US|ru-RU]
if (parts.length === 1) {
// Show UI language subcommand help
return {
@@ -241,11 +243,12 @@ export const languageCommand: SlashCommand = {
content: [
t('Set UI language'),
'',
t('Usage: /language ui [zh-CN|en-US]'),
t('Usage: /language ui [zh-CN|en-US|ru-RU]'),
'',
t('Available options:'),
t(' - zh-CN: Simplified Chinese'),
t(' - en-US: English'),
t(' - ru-RU: Russian'),
'',
t(
'To request additional UI language packs, please open an issue on GitHub.',
@@ -266,11 +269,18 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN'),
content: t('Invalid language. Available: en-US, zh-CN, ru-RU'),
};
}
@@ -307,13 +317,20 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
' - /language ui [zh-CN|en-US] - ' + t('Set UI language'),
' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'),
' - /language output <language> - ' + t('Set LLM output language'),
].join('\n'),
};
@@ -423,6 +440,29 @@ export const languageCommand: SlashCommand = {
return setUiLanguage(context, 'en');
},
},
{
name: 'ru-RU',
altNames: ['ru', 'russian', 'русский'],
get description() {
return t('Set UI language to Russian (ru-RU)');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'ru');
},
},
],
},
{

View File

@@ -8,35 +8,6 @@ import { formatDuration } from '../utils/formatters.js';
import { CommandKind, type SlashCommand } from './types.js';
import { t } from '../../i18n/index.js';
export const quitConfirmCommand: SlashCommand = {
name: 'quit-confirm',
get description() {
return t('Show quit confirmation dialog');
},
kind: CommandKind.BUILT_IN,
action: (context) => {
const now = Date.now();
const { sessionStartTime } = context.session.stats;
const wallDuration = now - sessionStartTime.getTime();
return {
type: 'quit_confirmation',
messages: [
{
type: 'user',
text: `/quit-confirm`,
id: now - 1,
},
{
type: 'quit_confirmation',
duration: formatDuration(wallDuration),
id: now,
},
],
};
},
};
export const quitCommand: SlashCommand = {
name: 'quit',
altNames: ['exit'],

View File

@@ -84,7 +84,7 @@ describe('setupGithubCommand', async () => {
const expectedSubstrings = [
`set -eEuo pipefail`,
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
`fakeOpenCommand "https://github.com/QwenLM/qwen-code-action`,
];
for (const substring of expectedSubstrings) {
@@ -112,7 +112,7 @@ describe('setupGithubCommand', async () => {
if (gitignoreExists) {
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
expect(gitignoreContent).toContain('.gemini/');
expect(gitignoreContent).toContain('.qwen/');
expect(gitignoreContent).toContain('gha-creds-*.json');
}
});
@@ -135,7 +135,7 @@ describe('updateGitignore', () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toBe('.gemini/\ngha-creds-*.json\n');
expect(content).toBe('.qwen/\ngha-creds-*.json\n');
});
it('appends entries to existing .gitignore file', async () => {
@@ -148,13 +148,13 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toBe(
'# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n',
'# Existing content\nnode_modules/\n\n.qwen/\ngha-creds-*.json\n',
);
});
it('does not add duplicate entries', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n';
const existingContent = '.qwen/\nsome-other-file\ngha-creds-*.json\n';
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
@@ -166,7 +166,7 @@ describe('updateGitignore', () => {
it('adds only missing entries when some already exist', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = '.gemini/\nsome-other-file\n';
const existingContent = '.qwen/\nsome-other-file\n';
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
@@ -174,17 +174,17 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
// Should add only the missing gha-creds-*.json entry
expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n');
expect(content).toBe('.qwen/\nsome-other-file\n\ngha-creds-*.json\n');
expect(content).toContain('gha-creds-*.json');
// Should not duplicate .gemini/ entry
expect((content.match(/\.gemini\//g) || []).length).toBe(1);
// Should not duplicate .qwen/ entry
expect((content.match(/\.qwen\//g) || []).length).toBe(1);
});
it('does not get confused by entries in comments or as substrings', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = [
'# This is a comment mentioning .gemini/ folder',
'my-app.gemini/config',
'# This is a comment mentioning .qwen/ folder',
'my-app.qwen/config',
'# Another comment with gha-creds-*.json pattern',
'some-other-gha-creds-file.json',
'',
@@ -196,7 +196,7 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
// Should add both entries since they don't actually exist as gitignore rules
expect(content).toContain('.gemini/');
expect(content).toContain('.qwen/');
expect(content).toContain('gha-creds-*.json');
// Verify the entries were added (not just mentioned in comments)
@@ -204,9 +204,9 @@ describe('updateGitignore', () => {
.split('\n')
.map((line) => line.split('#')[0].trim())
.filter((line) => line);
expect(lines).toContain('.gemini/');
expect(lines).toContain('.qwen/');
expect(lines).toContain('gha-creds-*.json');
expect(lines).toContain('my-app.gemini/config');
expect(lines).toContain('my-app.qwen/config');
expect(lines).toContain('some-other-gha-creds-file.json');
});

View File

@@ -23,11 +23,11 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
import { t } from '../../i18n/index.js';
export const GITHUB_WORKFLOW_PATHS = [
'gemini-dispatch/gemini-dispatch.yml',
'gemini-assistant/gemini-invoke.yml',
'issue-triage/gemini-triage.yml',
'issue-triage/gemini-scheduled-triage.yml',
'pr-review/gemini-review.yml',
'qwen-dispatch/qwen-dispatch.yml',
'qwen-assistant/qwen-invoke.yml',
'issue-triage/qwen-triage.yml',
'issue-triage/qwen-scheduled-triage.yml',
'pr-review/qwen-review.yml',
];
// Generate OS-specific commands to open the GitHub pages needed for setup.
@@ -50,9 +50,9 @@ function getOpenUrlsCommands(readmeUrl: string): string[] {
return commands;
}
// Add Gemini CLI specific entries to .gitignore file
// Add Qwen Code specific entries to .gitignore file
export async function updateGitignore(gitRepoRoot: string): Promise<void> {
const gitignoreEntries = ['.gemini/', 'gha-creds-*.json'];
const gitignoreEntries = ['.qwen/', 'gha-creds-*.json'];
const gitignorePath = path.join(gitRepoRoot, '.gitignore');
try {
@@ -121,7 +121,7 @@ export const setupGithubCommand: SlashCommand = {
// Get the latest release tag from GitHub
const proxy = context?.services?.config?.getProxy();
const releaseTag = await getLatestGitHubRelease(proxy);
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
const readmeUrl = `https://github.com/QwenLM/qwen-code-action/blob/${releaseTag}/README.md#quick-start`;
// Create the .github/workflows directory to download the files into
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
@@ -143,7 +143,7 @@ export const setupGithubCommand: SlashCommand = {
for (const workflow of GITHUB_WORKFLOW_PATHS) {
downloads.push(
(async () => {
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
const endpoint = `https://raw.githubusercontent.com/QwenLM/qwen-code-action/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
const response = await fetch(endpoint, {
method: 'GET',
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
@@ -204,8 +204,9 @@ export const setupGithubCommand: SlashCommand = {
toolName: 'run_shell_command',
toolArgs: {
description:
'Setting up GitHub Actions to triage issues and review PRs with Gemini.',
'Setting up GitHub Actions to triage issues and review PRs with Qwen.',
command,
is_background: false,
},
};
},

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: {
@@ -64,8 +64,6 @@ export interface CommandContext {
* @param history The array of history items to load.
*/
loadHistory: UseHistoryManagerReturn['loadHistory'];
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;
@@ -78,6 +76,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;
@@ -98,12 +98,6 @@ export interface QuitActionReturn {
messages: HistoryItem[];
}
/** The return type for a command action that requests quit confirmation. */
export interface QuitConfirmationActionReturn {
type: 'quit_confirmation';
messages: HistoryItem[];
}
/**
* The return type for a command action that results in a simple message
* being displayed to the user.
@@ -180,7 +174,6 @@ export type SlashCommandActionReturn =
| ToolActionReturn
| MessageActionReturn
| QuitActionReturn
| QuitConfirmationActionReturn
| OpenDialogActionReturn
| LoadHistoryActionReturn
| SubmitPromptActionReturn
@@ -214,7 +207,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

@@ -120,7 +120,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
},
branchName: 'main',
debugMessage: '',
corgiMode: false,
errorCount: 0,
nightly: false,
isTrustedFolder: true,
@@ -183,6 +182,7 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState, settings);
// Smoke check that the Footer renders when enabled.
expect(lastFrame()).toContain('Footer');
});
@@ -200,7 +200,6 @@ describe('Composer', () => {
it('passes correct props to Footer including vim mode when enabled', async () => {
const uiState = createMockUIState({
branchName: 'feature-branch',
corgiMode: true,
errorCount: 2,
sessionStats: {
sessionId: 'test-session',

View File

@@ -36,10 +36,6 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import {
QuitConfirmationDialog,
QuitChoice,
} from './QuitConfirmationDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -127,26 +123,6 @@ export const DialogManager = ({
/>
);
}
if (uiState.quitConfirmationRequest) {
return (
<QuitConfirmationDialog
onSelect={(choice: QuitChoice) => {
if (choice === QuitChoice.CANCEL) {
uiState.quitConfirmationRequest?.onConfirm(false, 'cancel');
} else if (choice === QuitChoice.QUIT) {
uiState.quitConfirmationRequest?.onConfirm(true, 'quit');
} else if (choice === QuitChoice.SAVE_AND_QUIT) {
uiState.quitConfirmationRequest?.onConfirm(true, 'save_and_quit');
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
uiState.quitConfirmationRequest?.onConfirm(
true,
'summary_and_quit',
);
}
}}
/>
);
}
if (uiState.confirmationRequest) {
return (
<ConsentPrompt

View File

@@ -33,7 +33,6 @@ export const Footer: React.FC = () => {
debugMode,
branchName,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
promptTokenCount,
@@ -45,7 +44,6 @@ export const Footer: React.FC = () => {
debugMode: config.getDebugMode(),
branchName: uiState.branchName,
debugMessage: uiState.debugMessage,
corgiMode: uiState.corgiMode,
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
@@ -153,16 +151,6 @@ export const Footer: React.FC = () => {
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
<Box alignItems="center" paddingLeft={2}>
{corgiMode && (
<Text>
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={theme.ui.symbol}>| </Text>

View File

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

View File

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

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