Compare commits

..

212 Commits

Author SHA1 Message Date
github-actions[bot]
07b5c2d077 chore(release): v0.4.1 2025-12-12 09:56:18 +00: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
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
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
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
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
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
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
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
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
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
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
198 changed files with 24439 additions and 1093 deletions

View File

@@ -132,6 +132,24 @@ jobs:
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]"
@@ -184,7 +202,7 @@ jobs:
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Publish @qwen-code/sdk-typescript'
- 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' || '' }}

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

@@ -75,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'],
@@ -111,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'
],
},
],

View File

@@ -25,6 +25,14 @@ type PendingRequest = {
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?: {
@@ -39,6 +47,9 @@ type SessionUpdateNotification = {
text?: string;
};
modeId?: string;
_meta?: {
usage?: UsageMetadata;
};
};
};
@@ -587,4 +598,52 @@ function setupAcpTest(
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

@@ -13,7 +13,7 @@ import {
isSDKAssistantMessage,
type TextBlock,
type ContentBlock,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();

View File

@@ -17,7 +17,7 @@ import {
isSDKAssistantMessage,
isSDKSystemMessage,
type SDKMessage,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
import {
SDKTestHelper,
extractText,
@@ -532,7 +532,6 @@ describe('Configuration Options (E2E)', () => {
cwd: testDir,
authType: 'openai',
debug: true,
logLevel: 'debug',
stderr: (msg: string) => {
stderrMessages.push(msg);
},

View File

@@ -19,7 +19,7 @@ import {
type SDKMessage,
type ToolUseBlock,
type SDKSystemMessage,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
import {
SDKTestHelper,
createMCPServer,

View File

@@ -21,7 +21,7 @@ import {
type SDKMessage,
type ControlMessage,
type ToolUseBlock,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();

View File

@@ -22,7 +22,7 @@ import {
type SDKUserMessage,
type ToolUseBlock,
type ContentBlock,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
import {
SDKTestHelper,
createSharedTestOptions,
@@ -555,6 +555,15 @@ describe('Permission Control (E2E)', () => {
...SHARED_TEST_OPTIONS,
cwd: testDir,
permissionMode: 'default',
timeout: {
/**
* We use a short control request timeout and
* wait till the time exceeded to test if
* an immediate close() will raise an query close
* error and no other uncaught timeout error
*/
controlRequest: 5000,
},
},
});
@@ -563,7 +572,9 @@ describe('Permission Control (E2E)', () => {
await expect(q.setPermissionMode('yolo')).rejects.toThrow(
'Query is closed',
);
});
await new Promise((resolve) => setTimeout(resolve, 8000));
}, 10_000);
});
describe('canUseTool and setPermissionMode integration', () => {
@@ -1184,7 +1195,7 @@ describe('Permission Control (E2E)', () => {
});
describe('mode comparison tests', () => {
it(
it.skip(
'should demonstrate different behaviors across all modes for write operations',
async () => {
const modes: Array<'default' | 'auto-edit' | 'yolo'> = [

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

@@ -13,7 +13,7 @@ import {
type SDKMessage,
type SDKSystemMessage,
type SDKAssistantMessage,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
import {
SDKTestHelper,
extractText,
@@ -44,7 +44,6 @@ describe('Single-Turn Query (E2E)', () => {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: true,
logLevel: 'debug',
},
});

View File

@@ -17,7 +17,7 @@ import {
type SubagentConfig,
type ContentBlock,
type ToolUseBlock,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
import {
SDKTestHelper,
extractText,

View File

@@ -9,7 +9,7 @@ import {
isSDKAssistantMessage,
isSDKSystemMessage,
type SDKUserMessage,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();

View File

@@ -21,12 +21,12 @@ import type {
ContentBlock,
TextBlock,
ToolUseBlock,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
import {
isSDKAssistantMessage,
isSDKSystemMessage,
isSDKResultMessage,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
// ============================================================================
// Core Test Helper Class

View File

@@ -12,11 +12,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKAssistantMessage,
type SDKMessage,
} from '@qwen-code/sdk-typescript';
import { query, isSDKAssistantMessage, type SDKMessage } from '@qwen-code/sdk';
import {
SDKTestHelper,
extractText,

View File

@@ -5,9 +5,7 @@
"allowJs": true,
"baseUrl": ".",
"paths": {
"@qwen-code/sdk-typescript": [
"../packages/sdk-typescript/dist/index.d.ts"
]
"@qwen-code/sdk": ["../packages/sdk-typescript/dist/index.d.ts"]
}
},
"include": ["**/*.ts"],

View File

@@ -31,7 +31,7 @@ export default defineConfig({
resolve: {
alias: {
// Use built SDK bundle for e2e tests
'@qwen-code/sdk-typescript': resolve(
'@qwen-code/sdk': resolve(
__dirname,
'../packages/sdk-typescript/dist/index.mjs',
),

1256
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.4.0",
"version": "0.4.1",
"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.4.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1"
},
"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.4.0",
"version": "0.4.1",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1"
},
"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

@@ -316,6 +316,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,
});
@@ -500,10 +517,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(),

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

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

@@ -411,4 +411,48 @@ describe('HistoryReplayer', () => {
]);
});
});
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

@@ -4,8 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ChatRecord } from '@qwen-code/qwen-code-core';
import type { Content } from '@google/genai';
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';
@@ -52,6 +55,9 @@ export class HistoryReplayer {
if (record.message) {
await this.replayContent(record.message, 'assistant');
}
if (record.usageMetadata) {
await this.replayUsageMetadata(record.usageMetadata);
}
break;
case 'tool_result':
@@ -88,11 +94,22 @@ export class HistoryReplayer {
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.
*/
@@ -118,6 +135,54 @@ export class HistoryReplayer {
// 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);
}
}
/**

View File

@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, FunctionCall, Part } from '@google/genai';
import type {
Content,
FunctionCall,
GenerateContentResponseUsageMetadata,
Part,
} from '@google/genai';
import type {
Config,
GeminiChat,
@@ -55,6 +60,7 @@ import type { SessionContext, ToolCallStartParams } from './types.js';
import { HistoryReplayer } from './HistoryReplayer.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
import { PlanEmitter } from './emitters/PlanEmitter.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import { SubAgentTracker } from './SubAgentTracker.js';
/**
@@ -79,6 +85,7 @@ export class Session implements SessionContext {
private readonly historyReplayer: HistoryReplayer;
private readonly toolCallEmitter: ToolCallEmitter;
private readonly planEmitter: PlanEmitter;
private readonly messageEmitter: MessageEmitter;
// Implement SessionContext interface
readonly sessionId: string;
@@ -96,6 +103,7 @@ export class Session implements SessionContext {
this.toolCallEmitter = new ToolCallEmitter(this);
this.planEmitter = new PlanEmitter(this);
this.historyReplayer = new HistoryReplayer(this);
this.messageEmitter = new MessageEmitter(this);
}
getId(): string {
@@ -192,6 +200,8 @@ export class Session implements SessionContext {
}
const functionCalls: FunctionCall[] = [];
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
const streamStartTime = Date.now();
try {
const responseStream = await chat.sendMessageStream(
@@ -222,20 +232,18 @@ export class Session implements SessionContext {
continue;
}
const content: acp.ContentBlock = {
type: 'text',
text: part.text,
};
this.sendUpdate({
sessionUpdate: part.thought
? 'agent_thought_chunk'
: 'agent_message_chunk',
content,
});
this.messageEmitter.emitMessage(
part.text,
'assistant',
part.thought,
);
}
}
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
usageMetadata = resp.value.usageMetadata;
}
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
functionCalls.push(...resp.value.functionCalls);
}
@@ -251,6 +259,15 @@ export class Session implements SessionContext {
throw error;
}
if (usageMetadata) {
const durationMs = Date.now() - streamStartTime;
await this.messageEmitter.emitUsageMetadata(
usageMetadata,
'',
durationMs,
);
}
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
@@ -444,7 +461,9 @@ export class Session implements SessionContext {
}
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
this.config.getApprovalMode() !== ApprovalMode.YOLO
? await invocation.shouldConfirmExecute(abortSignal)
: false;
if (confirmationDetails) {
const content: acp.ToolCallContent[] = [];
@@ -522,6 +541,7 @@ export class Session implements SessionContext {
callId,
toolName: fc.name,
args,
status: 'in_progress',
};
await this.toolCallEmitter.emitStart(startParams);
}

View File

@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-123',
status: 'in_progress',
status: 'pending',
title: 'read_file',
content: [],
locations: [],

View File

@@ -9,6 +9,7 @@ import type {
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
ToolCallConfirmationDetails,
AnyDeclarativeTool,
AnyToolInvocation,
@@ -20,6 +21,7 @@ import {
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';
/**
@@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
*/
export class SubAgentTracker {
private readonly toolCallEmitter: ToolCallEmitter;
private readonly messageEmitter: MessageEmitter;
private readonly toolStates = new Map<
string,
{
@@ -76,6 +79,7 @@ export class SubAgentTracker {
private readonly client: acp.Client,
) {
this.toolCallEmitter = new ToolCallEmitter(ctx);
this.messageEmitter = new MessageEmitter(ctx);
}
/**
@@ -92,16 +96,19 @@ export class SubAgentTracker {
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();
},
@@ -252,6 +259,20 @@ export class SubAgentTracker {
};
}
/**
* 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.
*/

View File

@@ -148,4 +148,59 @@ describe('MessageEmitter', () => {
});
});
});
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

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { Usage } from '../../schema.js';
import { BaseEmitter } from './BaseEmitter.js';
/**
@@ -24,6 +26,16 @@ export class MessageEmitter extends BaseEmitter {
});
}
/**
* 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.
*/
@@ -35,12 +47,28 @@ export class MessageEmitter extends BaseEmitter {
}
/**
* Emits an agent thought chunk.
* Emits usage metadata.
*/
async emitAgentThought(text: string): Promise<void> {
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_thought_chunk',
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
_meta: meta,
});
}

View File

@@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-123',
status: 'in_progress',
status: 'pending',
title: 'unknown_tool', // Falls back to tool name
content: [],
locations: [],
@@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-456',
status: 'in_progress',
status: 'pending',
title: 'edit_file: Test tool description',
content: [],
locations: [{ path: '/test/file.ts', line: 10 }],
@@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-fail',
status: 'in_progress',
status: 'pending',
title: 'failing_tool', // Fallback to tool name
content: [],
locations: [], // Fallback to empty
@@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => {
type: 'content',
content: {
type: 'text',
text: '{"output":"test output"}',
text: 'test output',
},
},
],
@@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => {
content: [
{
type: 'content',
content: { type: 'text', text: '{"output":"Function output"}' },
content: { type: 'text', text: 'Function output' },
},
],
rawOutput: 'raw result',

View File

@@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter {
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: params.callId,
status: 'in_progress',
status: params.status || 'pending',
title,
content: [],
locations,
@@ -275,7 +275,18 @@ export class ToolCallEmitter extends BaseEmitter {
// Handle functionResponse parts - stringify the response
if ('functionResponse' in part && part.functionResponse) {
try {
const responseText = JSON.stringify(part.functionResponse.response);
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 },

View File

@@ -35,6 +35,8 @@ export interface ToolCallStartParams {
callId: string;
/** Arguments passed to the tool */
args?: Record<string, unknown>;
/** Status of the tool call */
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
}
/**

View File

@@ -138,6 +138,7 @@ export interface CliArgs {
coreTools: string[] | undefined;
excludeTools: string[] | undefined;
authType: string | undefined;
channel: string | undefined;
}
function normalizeOutputFormat(
@@ -297,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,
@@ -559,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;
}
@@ -983,6 +995,7 @@ export async function loadCliConfig(
output: {
format: outputSettingsFormat,
},
channel: argv.channel,
});
}

View File

@@ -485,6 +485,7 @@ describe('gemini.tsx main function kitty protocol', () => {
excludeTools: undefined,
authType: undefined,
maxSessionTurns: undefined,
channel: undefined,
});
await main();

View File

@@ -276,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();
}

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
*/
@@ -27,7 +30,7 @@ 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 { SdkMcpController } from './controllers/sdkMcpController.js';
// import { HookController } from './controllers/hookController.js';
import type {
CLIControlRequest,
@@ -65,7 +68,7 @@ export class ControlDispatcher implements IPendingRequestRegistry {
// Make controllers publicly accessible
readonly systemController: SystemController;
readonly permissionController: PermissionController;
// readonly mcpController: MCPController;
readonly sdkMcpController: SdkMcpController;
// readonly hookController: HookController;
// Central pending request registries
@@ -88,7 +91,11 @@ export class ControlDispatcher implements IPendingRequestRegistry {
this,
'PermissionController',
);
// this.mcpController = new MCPController(context, this, 'MCPController');
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.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
*/
@@ -306,9 +354,8 @@ export class ControlDispatcher implements IPendingRequestRegistry {
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

@@ -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);
}

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

@@ -44,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:
@@ -70,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 ||
@@ -192,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',
@@ -373,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;
@@ -392,14 +418,18 @@ export class PermissionController extends BaseController {
toolCall.confirmationDetails,
);
const response = await this.sendControlRequest({
subtype: 'can_use_tool',
tool_name: toolCall.request.name,
tool_use_id: toolCall.request.callId,
input: toolCall.request.args,
permission_suggestions: permissionSuggestions,
blocked_path: null,
} as CLIControlPermissionRequest);
const response = await this.sendControlRequest(
{
subtype: 'can_use_tool',
tool_name: toolCall.request.name,
tool_use_id: toolCall.request.callId,
input: toolCall.request.args,
permission_suggestions: permissionSuggestions,
blocked_path: null,
} as CLIControlPermissionRequest,
undefined, // use default timeout
this.context.abortSignal,
);
if (response.subtype !== 'success') {
await toolCall.confirmationDetails.onConfirm(

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,9 +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 {
/**
@@ -28,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`);
@@ -51,46 +67,110 @@ 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>> {
if (signal.aborted) {
throw new Error('Request aborted');
}
this.context.config.setSdkMode(true);
if (payload.sdkMcpServers && typeof payload.sdkMcpServers === 'object') {
for (const serverName of Object.keys(payload.sdkMcpServers)) {
this.context.sdkMcpServers.add(serverName);
// 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
);
}
try {
this.context.config.addMcpServers(payload.sdkMcpServers);
if (this.context.debugMode) {
console.error(
`[SystemController] Added ${Object.keys(payload.sdkMcpServers).length} SDK MCP servers to config`,
);
}
} catch (error) {
if (this.context.debugMode) {
console.error(
'[SystemController] Failed to add SDK MCP servers:',
error,
);
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') {
try {
this.context.config.addMcpServers(payload.mcpServers);
if (this.context.debugMode) {
console.error(
`[SystemController] Added ${Object.keys(payload.mcpServers).length} MCP servers to config`,
);
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;
}
} catch (error) {
if (this.context.debugMode) {
console.error('[SystemController] Failed to add MCP servers:', error);
}
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,
);
}
}
}
}
@@ -143,13 +223,96 @@ export class SystemController extends BaseController {
can_set_permission_mode:
typeof this.context.config.setApprovalMode === 'function',
can_set_model: typeof this.context.config.setModel === 'function',
/* TODO: sdkMcpServers support */
can_handle_mcp_message: false,
// SDK MCP servers are supported - messages routed through control plane
can_handle_mcp_message: true,
};
return capabilities;
}
private normalizeMcpServerConfig(
serverName: string,
config?: CLIMcpServerConfig,
): MCPServerConfig | null {
if (!config || typeof config !== 'object') {
if (this.context.debugMode) {
console.error(
`[SystemController] Ignoring invalid MCP server config for '${serverName}'`,
);
}
return null;
}
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,
};
}
/**
* Handle interrupt request
*
@@ -183,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
@@ -223,8 +391,14 @@ export class SystemController extends BaseController {
*
* Returns list of supported slash commands loaded dynamically
*/
private async handleSupportedCommands(): Promise<Record<string, unknown>> {
const slashCommands = await this.loadSlashCommandNames();
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',
@@ -235,15 +409,24 @@ export class SystemController extends BaseController {
/**
* 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(): Promise<string[]> {
const controller = new AbortController();
private async loadSlashCommandNames(signal: AbortSignal): Promise<string[]> {
if (signal.aborted) {
return [];
}
try {
const service = await CommandService.create(
[new BuiltinCommandLoader(this.context.config)],
controller.signal,
signal,
);
if (signal.aborted) {
return [];
}
const names = new Set<string>();
const commands = service.getCommands();
for (const command of commands) {
@@ -251,6 +434,11 @@ export class SystemController extends BaseController {
}
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:',
@@ -258,8 +446,6 @@ export class SystemController extends BaseController {
);
}
return [];
} finally {
controller.abort();
}
}
}

View File

@@ -153,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>;
@@ -187,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>

View File

@@ -4,7 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import type {
Config,
ConfigInitializeOptions,
} from '@qwen-code/qwen-code-core';
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
import { ControlContext } from './control/ControlContext.js';
@@ -50,6 +53,12 @@ class Session {
private isShuttingDown: boolean = false;
private configInitialized: boolean = false;
// Single initialization promise that resolves when session is ready for user messages.
// Created lazily once initialization actually starts.
private initializationPromise: Promise<void> | null = null;
private initializationResolve: (() => void) | null = null;
private initializationReject: ((error: Error) => void) | null = null;
constructor(config: Config, initialPrompt?: CLIUserMessage) {
this.config = config;
this.sessionId = config.getSessionId();
@@ -66,12 +75,32 @@ class Session {
this.setupSignalHandlers();
}
private ensureInitializationPromise(): void {
if (this.initializationPromise) {
return;
}
this.initializationPromise = new Promise<void>((resolve, reject) => {
this.initializationResolve = () => {
resolve();
this.initializationResolve = null;
this.initializationReject = null;
};
this.initializationReject = (error: Error) => {
reject(error);
this.initializationResolve = null;
this.initializationReject = null;
};
});
}
private getNextPromptId(): string {
this.promptIdCounter++;
return `${this.sessionId}########${this.promptIdCounter}`;
}
private async ensureConfigInitialized(): Promise<void> {
private async ensureConfigInitialized(
options?: ConfigInitializeOptions,
): Promise<void> {
if (this.configInitialized) {
return;
}
@@ -81,7 +110,7 @@ class Session {
}
try {
await this.config.initialize();
await this.config.initialize(options);
this.configInitialized = true;
} catch (error) {
if (this.debugMode) {
@@ -91,6 +120,44 @@ class Session {
}
}
/**
* Mark initialization as complete
*/
private completeInitialization(): void {
if (this.initializationResolve) {
if (this.debugMode) {
console.error('[Session] Initialization complete');
}
this.initializationResolve();
this.initializationResolve = null;
this.initializationReject = null;
}
}
/**
* Mark initialization as failed
*/
private failInitialization(error: Error): void {
if (this.initializationReject) {
if (this.debugMode) {
console.error('[Session] Initialization failed:', error);
}
this.initializationReject(error);
this.initializationResolve = null;
this.initializationReject = null;
}
}
/**
* Wait for session to be ready for user messages
*/
private async waitForInitialization(): Promise<void> {
if (!this.initializationPromise) {
return;
}
await this.initializationPromise;
}
private ensureControlSystem(): void {
if (this.controlContext && this.dispatcher && this.controlService) {
return;
@@ -120,49 +187,114 @@ class Session {
return this.dispatcher;
}
private async handleFirstMessage(
/**
* Handle the first message to determine session mode (SDK vs direct).
* This is synchronous from the message loop's perspective - it starts
* async work but does not return a promise that the loop awaits.
*
* The initialization completes asynchronously and resolves initializationPromise
* when ready for user messages.
*/
private handleFirstMessage(
message:
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest,
): Promise<boolean> {
): void {
if (isControlRequest(message)) {
const request = message as CLIControlRequest;
this.controlSystemEnabled = true;
this.ensureControlSystem();
if (request.request.subtype === 'initialize') {
// Dispatch the initialize request first
await this.dispatcher?.dispatch(request);
// After handling initialize control request, initialize the config
// This is the SDK mode where config initialization is deferred
await this.ensureConfigInitialized();
return true;
if (request.request.subtype === 'initialize') {
// Start SDK mode initialization (fire-and-forget from loop perspective)
void this.initializeSdkMode(request);
return;
}
if (this.debugMode) {
console.error(
'[Session] Ignoring non-initialize control request during initialization',
);
}
return true;
return;
}
if (isCLIUserMessage(message)) {
this.controlSystemEnabled = false;
// For non-SDK mode (direct user message), initialize config if not already done
await this.ensureConfigInitialized();
this.enqueueUserMessage(message as CLIUserMessage);
return true;
// Start direct mode initialization (fire-and-forget from loop perspective)
void this.initializeDirectMode(message as CLIUserMessage);
return;
}
this.controlSystemEnabled = false;
return false;
}
private async handleControlRequest(
request: CLIControlRequest,
/**
* SDK mode initialization flow
* Dispatches initialize request and initializes config with MCP support
*/
private async initializeSdkMode(request: CLIControlRequest): Promise<void> {
this.ensureInitializationPromise();
try {
// Dispatch the initialize request first
// This registers SDK MCP servers in the control context
await this.dispatcher?.dispatch(request);
// Get sendSdkMcpMessage callback from SdkMcpController
// This callback is used by McpClientManager to send MCP messages
// from CLI MCP clients to SDK MCP servers via the control plane
const sendSdkMcpMessage =
this.dispatcher?.sdkMcpController.createSendSdkMcpMessage();
// Initialize config with SDK MCP message support
await this.ensureConfigInitialized({ sendSdkMcpMessage });
// Initialization complete!
this.completeInitialization();
} catch (error) {
if (this.debugMode) {
console.error('[Session] SDK mode initialization failed:', error);
}
this.failInitialization(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Direct mode initialization flow
* Initializes config and enqueues the first user message
*/
private async initializeDirectMode(
userMessage: CLIUserMessage,
): Promise<void> {
this.ensureInitializationPromise();
try {
// Initialize config
await this.ensureConfigInitialized();
// Initialization complete!
this.completeInitialization();
// Enqueue the first user message for processing
this.enqueueUserMessage(userMessage);
} catch (error) {
if (this.debugMode) {
console.error('[Session] Direct mode initialization failed:', error);
}
this.failInitialization(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Handle control request asynchronously (fire-and-forget from main loop).
* Errors are handled internally and responses sent by dispatcher.
*/
private handleControlRequestAsync(request: CLIControlRequest): void {
const dispatcher = this.getDispatcher();
if (!dispatcher) {
if (this.debugMode) {
@@ -171,9 +303,20 @@ class Session {
return;
}
await dispatcher.dispatch(request);
// Fire-and-forget: dispatch runs concurrently
// The dispatcher's pendingIncomingRequests tracks completion
void dispatcher.dispatch(request).catch((error) => {
if (this.debugMode) {
console.error('[Session] Control request dispatch error:', error);
}
// Error response is already sent by dispatcher.dispatch()
});
}
/**
* Handle control response - MUST be synchronous
* This resolves pending outgoing requests, breaking the deadlock cycle.
*/
private handleControlResponse(response: CLIControlResponse): void {
const dispatcher = this.getDispatcher();
if (!dispatcher) {
@@ -201,8 +344,8 @@ class Session {
return;
}
// Ensure config is initialized before processing user messages
await this.ensureConfigInitialized();
// Wait for initialization to complete before processing user messages
await this.waitForInitialization();
const promptId = this.getNextPromptId();
@@ -307,6 +450,45 @@ class Session {
process.on('SIGTERM', this.shutdownHandler);
}
/**
* Wait for all pending work to complete before shutdown
*/
private async waitForAllPendingWork(): Promise<void> {
// 1. Wait for initialization to complete (or fail)
try {
await this.waitForInitialization();
} catch (error) {
if (this.debugMode) {
console.error('[Session] Initialization error during shutdown:', error);
}
}
// 2. Wait for all control request handlers using dispatcher's tracking
if (this.dispatcher) {
const pendingCount = this.dispatcher.getPendingIncomingRequestCount();
if (pendingCount > 0 && this.debugMode) {
console.error(
`[Session] Waiting for ${pendingCount} pending control request handlers`,
);
}
await this.dispatcher.waitForPendingIncomingRequests();
}
// 3. Wait for user message processing queue
while (this.processingPromise) {
if (this.debugMode) {
console.error('[Session] Waiting for user message processing');
}
try {
await this.processingPromise;
} catch (error) {
if (this.debugMode) {
console.error('[Session] Error in user message processing:', error);
}
}
}
}
private async shutdown(): Promise<void> {
if (this.debugMode) {
console.error('[Session] Shutting down');
@@ -314,18 +496,8 @@ class Session {
this.isShuttingDown = true;
if (this.processingPromise) {
try {
await this.processingPromise;
} catch (error) {
if (this.debugMode) {
console.error(
'[Session] Error waiting for processing to complete:',
error,
);
}
}
}
// Wait for all pending work
await this.waitForAllPendingWork();
this.dispatcher?.shutdown();
this.cleanupSignalHandlers();
@@ -339,18 +511,30 @@ class Session {
}
}
/**
* Main message processing loop
*
* CRITICAL: This loop must NEVER await handlers that might need to
* send control requests and wait for responses. Such handlers must
* be started in fire-and-forget mode, allowing the loop to continue
* reading responses that resolve pending requests.
*
* Message handling order:
* 1. control_response - FIRST, synchronously resolves pending requests
* 2. First message - determines mode, starts async initialization
* 3. control_request - fire-and-forget, tracked by dispatcher
* 4. control_cancel - synchronous
* 5. user_message - enqueued for processing
*/
async run(): Promise<void> {
try {
if (this.debugMode) {
console.error('[Session] Starting session', this.sessionId);
}
// Handle initial prompt if provided (fire-and-forget)
if (this.initialPrompt !== null) {
const handled = await this.handleFirstMessage(this.initialPrompt);
if (handled && this.isShuttingDown) {
await this.shutdown();
return;
}
this.handleFirstMessage(this.initialPrompt);
}
try {
@@ -359,23 +543,33 @@ class Session {
break;
}
if (this.controlSystemEnabled === null) {
const handled = await this.handleFirstMessage(message);
if (handled) {
if (this.isShuttingDown) {
break;
}
continue;
}
// ============================================================
// CRITICAL: Handle control_response FIRST and SYNCHRONOUSLY
// This resolves pending outgoing requests, breaking deadlock.
// ============================================================
if (isControlResponse(message)) {
this.handleControlResponse(message as CLIControlResponse);
continue;
}
// Handle first message to determine session mode
if (this.controlSystemEnabled === null) {
this.handleFirstMessage(message);
continue;
}
// ============================================================
// CRITICAL: Handle control_request in FIRE-AND-FORGET mode
// DON'T await - let handler run concurrently while loop continues
// Dispatcher's pendingIncomingRequests tracks completion
// ============================================================
if (isControlRequest(message)) {
await this.handleControlRequest(message as CLIControlRequest);
} else if (isControlResponse(message)) {
this.handleControlResponse(message as CLIControlResponse);
this.handleControlRequestAsync(message as CLIControlRequest);
} else if (isControlCancel(message)) {
// Cancel is synchronous - OK to handle inline
this.handleControlCancel(message as ControlCancelRequest);
} else if (isCLIUserMessage(message)) {
// User messages are enqueued, processing runs separately
this.enqueueUserMessage(message as CLIUserMessage);
} else if (this.debugMode) {
if (
@@ -402,19 +596,8 @@ class Session {
throw streamError;
}
while (this.processingPromise) {
if (this.debugMode) {
console.error('[Session] Waiting for final processing to complete');
}
try {
await this.processingPromise;
} catch (error) {
if (this.debugMode) {
console.error('[Session] Error in final processing:', error);
}
}
}
// Stream ended - wait for all pending work before shutdown
await this.waitForAllPendingWork();
await this.shutdown();
} catch (error) {
if (this.debugMode) {

View File

@@ -1,8 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
MCPServerConfig,
SubagentConfig,
} from '@qwen-code/qwen-code-core';
import type { SubagentConfig } from '@qwen-code/qwen-code-core';
/**
* Annotation for attaching metadata to content blocks
@@ -298,11 +295,68 @@ 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?: Record<string, MCPServerConfig>;
mcpServers?: Record<string, MCPServerConfig>;
/**
* 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[];
}

View File

@@ -1307,7 +1307,7 @@ describe('InputPrompt', () => {
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji)
mockBuffer.visualCursor = [0, 7]; // cursor after '👍' (emoji is 1 code point, so total is 7)
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,

View File

@@ -707,15 +707,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
statusText = t('Accepting edits');
}
const borderColor =
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default;
return (
<>
<Box
borderStyle="round"
borderColor={
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default
}
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={borderColor}
paddingX={1}
>
<Text
@@ -829,9 +834,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
</Text>,
);
}

View File

@@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
──────────────────────────────────────────────────────────────────────────────────────────────────
"────────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
──────────────────────────────────────────────────────────────────────────────────────────────────
"────────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
"────────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
! Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
"────────────────────────────────────────────────────────────────────────────────────────────────────
! Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
* Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
"────────────────────────────────────────────────────────────────────────────────────────────────────
* Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
"────────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.4.0",
"version": "0.4.1",
"description": "Qwen Code Core",
"repository": {
"type": "git",
@@ -47,7 +47,7 @@
"fast-uri": "^3.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
"glob": "^10.4.5",
"glob": "^10.5.0",
"google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",

View File

@@ -63,6 +63,7 @@ vi.mock('../tools/tool-registry', () => {
ToolRegistryMock.prototype.registerTool = vi.fn();
ToolRegistryMock.prototype.discoverAllTools = vi.fn();
ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed
ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []);
ToolRegistryMock.prototype.getTool = vi.fn();
ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []);
return { ToolRegistry: ToolRegistryMock };

View File

@@ -46,6 +46,7 @@ import { ExitPlanModeTool } from '../tools/exitPlanMode.js';
import { GlobTool } from '../tools/glob.js';
import { GrepTool } from '../tools/grep.js';
import { LSTool } from '../tools/ls.js';
import type { SendSdkMcpMessage } from '../tools/mcp-client.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import { ReadFileTool } from '../tools/read-file.js';
import { ReadManyFilesTool } from '../tools/read-many-files.js';
@@ -239,9 +240,18 @@ export class MCPServerConfig {
readonly targetAudience?: string,
/* targetServiceAccount format: <service-account-name>@<project-num>.iam.gserviceaccount.com */
readonly targetServiceAccount?: string,
// SDK MCP server type - 'sdk' indicates server runs in SDK process
readonly type?: 'sdk',
) {}
}
/**
* Check if an MCP server config represents an SDK server
*/
export function isSdkMcpServerConfig(config: MCPServerConfig): boolean {
return config.type === 'sdk';
}
export enum AuthProviderType {
DYNAMIC_DISCOVERY = 'dynamic_discovery',
GOOGLE_CREDENTIALS = 'google_credentials',
@@ -339,6 +349,7 @@ export interface ConfigParameters {
skipStartupContext?: boolean;
sdkMode?: boolean;
sessionSubagents?: SubagentConfig[];
channel?: string;
}
function normalizeConfigOutputFormat(
@@ -360,6 +371,17 @@ function normalizeConfigOutputFormat(
}
}
/**
* Options for Config.initialize()
*/
export interface ConfigInitializeOptions {
/**
* Callback for sending MCP messages to SDK servers via control plane.
* Required for SDK MCP server support in SDK mode.
*/
sendSdkMcpMessage?: SendSdkMcpMessage;
}
export class Config {
private sessionId: string;
private sessionData?: ResumedSessionData;
@@ -464,6 +486,7 @@ export class Config {
private readonly enableToolOutputTruncation: boolean;
private readonly eventEmitter?: EventEmitter;
private readonly useSmartEdit: boolean;
private readonly channel: string | undefined;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId ?? randomUUID();
@@ -577,6 +600,7 @@ export class Config {
this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true;
this.useSmartEdit = params.useSmartEdit ?? false;
this.extensionManagement = params.extensionManagement ?? true;
this.channel = params.channel;
this.storage = new Storage(this.targetDir);
this.vlmSwitchMode = params.vlmSwitchMode;
this.inputFormat = params.inputFormat ?? InputFormat.TEXT;
@@ -599,8 +623,9 @@ export class Config {
/**
* Must only be called once, throws if called again.
* @param options Optional initialization options including sendSdkMcpMessage callback
*/
async initialize(): Promise<void> {
async initialize(options?: ConfigInitializeOptions): Promise<void> {
if (this.initialized) {
throw Error('Config was already initialized');
}
@@ -619,7 +644,9 @@ export class Config {
this.subagentManager.loadSessionSubagents(this.sessionSubagents);
}
this.toolRegistry = await this.createToolRegistry();
this.toolRegistry = await this.createToolRegistry(
options?.sendSdkMcpMessage,
);
await this.geminiClient.initialize();
@@ -1120,6 +1147,10 @@ export class Config {
return this.cliVersion;
}
getChannel(): string | undefined {
return this.channel;
}
/**
* Get the current FileSystemService
*/
@@ -1261,8 +1292,14 @@ export class Config {
return this.subagentManager;
}
async createToolRegistry(): Promise<ToolRegistry> {
const registry = new ToolRegistry(this, this.eventEmitter);
async createToolRegistry(
sendSdkMcpMessage?: SendSdkMcpMessage,
): Promise<ToolRegistry> {
const registry = new ToolRegistry(
this,
this.eventEmitter,
sendSdkMcpMessage,
);
const coreToolsConfig = this.getCoreTools();
const excludeToolsConfig = this.getExcludeTools();
@@ -1347,6 +1384,7 @@ export class Config {
}
await registry.discoverAllTools();
console.debug('ToolRegistry created', registry.getAllToolNames());
return registry;
}
}

View File

@@ -130,10 +130,13 @@ export class DashScopeOpenAICompatibleProvider
}
buildMetadata(userPromptId: string): DashScopeRequestMetadata {
const channel = this.cliConfig.getChannel?.();
return {
metadata: {
sessionId: this.cliConfig.getSessionId?.(),
promptId: userPromptId,
...(channel ? { channel } : {}),
},
};
}

View File

@@ -28,5 +28,6 @@ export type DashScopeRequestMetadata = {
metadata: {
sessionId?: string;
promptId: string;
channel?: string;
};
};

View File

@@ -102,7 +102,9 @@ export * from './tools/shell.js';
export * from './tools/web-search/index.js';
export * from './tools/read-many-files.js';
export * from './tools/mcp-client.js';
export * from './tools/mcp-client-manager.js';
export * from './tools/mcp-tool.js';
export * from './tools/sdk-control-client-transport.js';
export * from './tools/task.js';
export * from './tools/todoWrite.js';
export * from './tools/exitPlanMode.js';

View File

@@ -58,6 +58,7 @@ export type {
SubAgentStartEvent,
SubAgentRoundEvent,
SubAgentStreamTextEvent,
SubAgentUsageEvent,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentFinishEvent,

View File

@@ -10,7 +10,7 @@ import type {
ToolConfirmationOutcome,
ToolResultDisplay,
} from '../tools/tools.js';
import type { Part } from '@google/genai';
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
export type SubAgentEvent =
| 'start'
@@ -20,6 +20,7 @@ export type SubAgentEvent =
| 'tool_call'
| 'tool_result'
| 'tool_waiting_approval'
| 'usage_metadata'
| 'finish'
| 'error';
@@ -31,6 +32,7 @@ export enum SubAgentEventType {
TOOL_CALL = 'tool_call',
TOOL_RESULT = 'tool_result',
TOOL_WAITING_APPROVAL = 'tool_waiting_approval',
USAGE_METADATA = 'usage_metadata',
FINISH = 'finish',
ERROR = 'error',
}
@@ -57,6 +59,14 @@ export interface SubAgentStreamTextEvent {
timestamp: number;
}
export interface SubAgentUsageEvent {
subagentId: string;
round: number;
usage: GenerateContentResponseUsageMetadata;
durationMs?: number;
timestamp: number;
}
export interface SubAgentToolCallEvent {
subagentId: string;
round: number;

View File

@@ -50,6 +50,15 @@ describe('SubagentStatistics', () => {
expect(summary.outputTokens).toBe(600);
expect(summary.totalTokens).toBe(1800);
});
it('should track thought and cached tokens', () => {
stats.recordTokens(100, 50, 10, 5);
const summary = stats.getSummary();
expect(summary.thoughtTokens).toBe(10);
expect(summary.cachedTokens).toBe(5);
expect(summary.totalTokens).toBe(165); // 100 + 50 + 10 + 5
});
});
describe('tool usage statistics', () => {
@@ -93,14 +102,14 @@ describe('SubagentStatistics', () => {
stats.start(baseTime);
stats.setRounds(2);
stats.recordToolCall('file_read', true, 100);
stats.recordTokens(1000, 500);
stats.recordTokens(1000, 500, 20, 10);
const result = stats.formatCompact('Test task', baseTime + 5000);
expect(result).toContain('📋 Task Completed: Test task');
expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success');
expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2');
expect(result).toContain('🔢 Tokens: 1,500 (in 1000, out 500)');
expect(result).toContain('🔢 Tokens: 1,530 (in 1000, out 500)');
});
it('should handle zero tool calls', () => {

View File

@@ -23,6 +23,8 @@ export interface SubagentStatsSummary {
successRate: number;
inputTokens: number;
outputTokens: number;
thoughtTokens: number;
cachedTokens: number;
totalTokens: number;
estimatedCost: number;
toolUsage: ToolUsageStats[];
@@ -36,6 +38,8 @@ export class SubagentStatistics {
private failedToolCalls = 0;
private inputTokens = 0;
private outputTokens = 0;
private thoughtTokens = 0;
private cachedTokens = 0;
private toolUsage = new Map<string, ToolUsageStats>();
start(now = Date.now()) {
@@ -74,9 +78,16 @@ export class SubagentStatistics {
this.toolUsage.set(name, tu);
}
recordTokens(input: number, output: number) {
recordTokens(
input: number,
output: number,
thought: number = 0,
cached: number = 0,
) {
this.inputTokens += Math.max(0, input || 0);
this.outputTokens += Math.max(0, output || 0);
this.thoughtTokens += Math.max(0, thought || 0);
this.cachedTokens += Math.max(0, cached || 0);
}
getSummary(now = Date.now()): SubagentStatsSummary {
@@ -86,7 +97,11 @@ export class SubagentStatistics {
totalToolCalls > 0
? (this.successfulToolCalls / totalToolCalls) * 100
: 0;
const totalTokens = this.inputTokens + this.outputTokens;
const totalTokens =
this.inputTokens +
this.outputTokens +
this.thoughtTokens +
this.cachedTokens;
const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5;
return {
rounds: this.rounds,
@@ -97,6 +112,8 @@ export class SubagentStatistics {
successRate,
inputTokens: this.inputTokens,
outputTokens: this.outputTokens,
thoughtTokens: this.thoughtTokens,
cachedTokens: this.cachedTokens,
totalTokens,
estimatedCost,
toolUsage: Array.from(this.toolUsage.values()),
@@ -116,8 +133,12 @@ export class SubagentStatistics {
`⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
];
if (typeof stats.totalTokens === 'number') {
const parts = [
`in ${stats.inputTokens ?? 0}`,
`out ${stats.outputTokens ?? 0}`,
];
lines.push(
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`,
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${parts.length ? ` (${parts.join(', ')})` : ''}`,
);
}
return lines.join('\n');
@@ -152,8 +173,12 @@ export class SubagentStatistics {
`🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`,
);
if (typeof stats.totalTokens === 'number') {
const parts = [
`in ${stats.inputTokens ?? 0}`,
`out ${stats.outputTokens ?? 0}`,
];
lines.push(
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`,
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (${parts.join(', ')})`,
);
}
if (stats.toolUsage && stats.toolUsage.length) {

View File

@@ -41,6 +41,7 @@ import type {
SubAgentToolResultEvent,
SubAgentStreamTextEvent,
SubAgentErrorEvent,
SubAgentUsageEvent,
} from './subagent-events.js';
import {
type SubAgentEventEmitter,
@@ -369,6 +370,7 @@ export class SubAgentScope {
},
};
const roundStreamStart = Date.now();
const responseStream = await chat.sendMessageStream(
this.modelConfig.model ||
this.runtimeContext.getModel() ||
@@ -439,10 +441,19 @@ export class SubAgentScope {
if (lastUsage) {
const inTok = Number(lastUsage.promptTokenCount || 0);
const outTok = Number(lastUsage.candidatesTokenCount || 0);
if (isFinite(inTok) || isFinite(outTok)) {
const thoughtTok = Number(lastUsage.thoughtsTokenCount || 0);
const cachedTok = Number(lastUsage.cachedContentTokenCount || 0);
if (
isFinite(inTok) ||
isFinite(outTok) ||
isFinite(thoughtTok) ||
isFinite(cachedTok)
) {
this.stats.recordTokens(
isFinite(inTok) ? inTok : 0,
isFinite(outTok) ? outTok : 0,
isFinite(thoughtTok) ? thoughtTok : 0,
isFinite(cachedTok) ? cachedTok : 0,
);
// mirror legacy fields for compatibility
this.executionStats.inputTokens =
@@ -453,11 +464,20 @@ export class SubAgentScope {
(isFinite(outTok) ? outTok : 0);
this.executionStats.totalTokens =
(this.executionStats.inputTokens || 0) +
(this.executionStats.outputTokens || 0);
(this.executionStats.outputTokens || 0) +
(isFinite(thoughtTok) ? thoughtTok : 0) +
(isFinite(cachedTok) ? cachedTok : 0);
this.executionStats.estimatedCost =
(this.executionStats.inputTokens || 0) * 3e-5 +
(this.executionStats.outputTokens || 0) * 6e-5;
}
this.eventEmitter?.emit(SubAgentEventType.USAGE_METADATA, {
subagentId: this.subagentId,
round: turnCounter,
usage: lastUsage,
durationMs: Date.now() - roundStreamStart,
timestamp: Date.now(),
} as SubAgentUsageEvent);
}
if (functionCalls.length > 0) {

View File

@@ -249,6 +249,9 @@ export class QwenLogger {
authType === AuthType.USE_OPENAI
? this.config?.getContentGeneratorConfig().baseUrl || ''
: '',
...(this.config?.getChannel?.()
? { channel: this.config.getChannel() }
: {}),
},
_v: `qwen-code@${version}`,
} as RumPayload;

View File

@@ -23,6 +23,12 @@ export type UiEvent =
| (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR })
| (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL });
export {
EVENT_API_ERROR,
EVENT_API_RESPONSE,
EVENT_TOOL_CALL,
} from './constants.js';
export interface ToolCallStats {
count: number;
success: number;

View File

@@ -5,6 +5,7 @@
*/
import type { Config, MCPServerConfig } from '../config/config.js';
import { isSdkMcpServerConfig } from '../config/config.js';
import type { ToolRegistry } from './tool-registry.js';
import type { PromptRegistry } from '../prompts/prompt-registry.js';
import {
@@ -12,6 +13,7 @@ import {
MCPDiscoveryState,
populateMcpServerCommand,
} from './mcp-client.js';
import type { SendSdkMcpMessage } from './mcp-client.js';
import { getErrorMessage } from '../utils/errors.js';
import type { EventEmitter } from 'node:events';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
@@ -31,6 +33,7 @@ export class McpClientManager {
private readonly workspaceContext: WorkspaceContext;
private discoveryState: MCPDiscoveryState = MCPDiscoveryState.NOT_STARTED;
private readonly eventEmitter?: EventEmitter;
private readonly sendSdkMcpMessage?: SendSdkMcpMessage;
constructor(
mcpServers: Record<string, MCPServerConfig>,
@@ -40,6 +43,7 @@ export class McpClientManager {
debugMode: boolean,
workspaceContext: WorkspaceContext,
eventEmitter?: EventEmitter,
sendSdkMcpMessage?: SendSdkMcpMessage,
) {
this.mcpServers = mcpServers;
this.mcpServerCommand = mcpServerCommand;
@@ -48,6 +52,7 @@ export class McpClientManager {
this.debugMode = debugMode;
this.workspaceContext = workspaceContext;
this.eventEmitter = eventEmitter;
this.sendSdkMcpMessage = sendSdkMcpMessage;
}
/**
@@ -71,6 +76,11 @@ export class McpClientManager {
this.eventEmitter?.emit('mcp-client-update', this.clients);
const discoveryPromises = Object.entries(servers).map(
async ([name, config]) => {
// For SDK MCP servers, pass the sendSdkMcpMessage callback
const sdkCallback = isSdkMcpServerConfig(config)
? this.sendSdkMcpMessage
: undefined;
const client = new McpClient(
name,
config,
@@ -78,6 +88,7 @@ export class McpClientManager {
this.promptRegistry,
this.workspaceContext,
this.debugMode,
sdkCallback,
);
this.clients.set(name, client);

View File

@@ -13,6 +13,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type {
GetPromptResult,
JSONRPCMessage,
Prompt,
} from '@modelcontextprotocol/sdk/types.js';
import {
@@ -22,10 +23,11 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import { parse } from 'shell-quote';
import type { Config, MCPServerConfig } from '../config/config.js';
import { AuthProviderType } from '../config/config.js';
import { AuthProviderType, isSdkMcpServerConfig } from '../config/config.js';
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { SdkControlClientTransport } from './sdk-control-client-transport.js';
import type { FunctionDeclaration } from '@google/genai';
import { mcpToTool } from '@google/genai';
@@ -42,6 +44,14 @@ import type {
} from '../utils/workspaceContext.js';
import type { ToolRegistry } from './tool-registry.js';
/**
* Callback type for sending MCP messages to SDK servers via control plane
*/
export type SendSdkMcpMessage = (
serverName: string,
message: JSONRPCMessage,
) => Promise<JSONRPCMessage>;
export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes
export type DiscoveredMCPPrompt = Prompt & {
@@ -92,6 +102,7 @@ export class McpClient {
private readonly promptRegistry: PromptRegistry,
private readonly workspaceContext: WorkspaceContext,
private readonly debugMode: boolean,
private readonly sendSdkMcpMessage?: SendSdkMcpMessage,
) {
this.client = new Client({
name: `qwen-cli-mcp-client-${this.serverName}`,
@@ -189,7 +200,12 @@ export class McpClient {
}
private async createTransport(): Promise<Transport> {
return createTransport(this.serverName, this.serverConfig, this.debugMode);
return createTransport(
this.serverName,
this.serverConfig,
this.debugMode,
this.sendSdkMcpMessage,
);
}
private async discoverTools(cliConfig: Config): Promise<DiscoveredMCPTool[]> {
@@ -501,6 +517,7 @@ export function populateMcpServerCommand(
* @param mcpServerName The name identifier for this MCP server
* @param mcpServerConfig Configuration object containing connection details
* @param toolRegistry The registry to register discovered tools with
* @param sendSdkMcpMessage Optional callback for SDK MCP servers to route messages via control plane.
* @returns Promise that resolves when discovery is complete
*/
export async function connectAndDiscover(
@@ -511,6 +528,7 @@ export async function connectAndDiscover(
debugMode: boolean,
workspaceContext: WorkspaceContext,
cliConfig: Config,
sendSdkMcpMessage?: SendSdkMcpMessage,
): Promise<void> {
updateMCPServerStatus(mcpServerName, MCPServerStatus.CONNECTING);
@@ -521,6 +539,7 @@ export async function connectAndDiscover(
mcpServerConfig,
debugMode,
workspaceContext,
sendSdkMcpMessage,
);
mcpClient.onerror = (error) => {
@@ -744,6 +763,7 @@ export function hasNetworkTransport(config: MCPServerConfig): boolean {
*
* @param mcpServerName The name of the MCP server, used for logging and identification.
* @param mcpServerConfig The configuration specifying how to connect to the server.
* @param sendSdkMcpMessage Optional callback for SDK MCP servers to route messages via control plane.
* @returns A promise that resolves to a connected MCP `Client` instance.
* @throws An error if the connection fails or the configuration is invalid.
*/
@@ -752,6 +772,7 @@ export async function connectToMcpServer(
mcpServerConfig: MCPServerConfig,
debugMode: boolean,
workspaceContext: WorkspaceContext,
sendSdkMcpMessage?: SendSdkMcpMessage,
): Promise<Client> {
const mcpClient = new Client({
name: 'qwen-code-mcp-client',
@@ -808,6 +829,7 @@ export async function connectToMcpServer(
mcpServerName,
mcpServerConfig,
debugMode,
sendSdkMcpMessage,
);
try {
await mcpClient.connect(transport, {
@@ -1172,7 +1194,21 @@ export async function createTransport(
mcpServerName: string,
mcpServerConfig: MCPServerConfig,
debugMode: boolean,
sendSdkMcpMessage?: SendSdkMcpMessage,
): Promise<Transport> {
if (isSdkMcpServerConfig(mcpServerConfig)) {
if (!sendSdkMcpMessage) {
throw new Error(
`SDK MCP server '${mcpServerName}' requires sendSdkMcpMessage callback`,
);
}
return new SdkControlClientTransport({
serverName: mcpServerName,
sendMcpMessage: sendSdkMcpMessage,
debugMode,
});
}
if (
mcpServerConfig.authProviderType ===
AuthProviderType.SERVICE_ACCOUNT_IMPERSONATION

View File

@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* SdkControlClientTransport - MCP Client transport for SDK MCP servers
*
* This transport enables CLI's MCP client to connect to SDK MCP servers
* through the control plane. Messages are routed:
*
* CLI MCP Client → SdkControlClientTransport → sendMcpMessage() →
* control_request (mcp_message) → SDK → control_response → onmessage → CLI
*
* Unlike StdioClientTransport which spawns a subprocess, this transport
* communicates with SDK MCP servers running in the SDK process.
*/
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
/**
* Callback to send MCP messages to SDK via control plane
* Returns the MCP response from the SDK
*/
export type SendMcpMessageCallback = (
serverName: string,
message: JSONRPCMessage,
) => Promise<JSONRPCMessage>;
export interface SdkControlClientTransportOptions {
serverName: string;
sendMcpMessage: SendMcpMessageCallback;
debugMode?: boolean;
}
/**
* MCP Client Transport for SDK MCP servers
*
* Implements the @modelcontextprotocol/sdk Transport interface to enable
* CLI's MCP client to connect to SDK MCP servers via the control plane.
*/
export class SdkControlClientTransport {
private serverName: string;
private sendMcpMessage: SendMcpMessageCallback;
private debugMode: boolean;
private started = false;
// Transport interface callbacks
onmessage?: (message: JSONRPCMessage) => void;
onerror?: (error: Error) => void;
onclose?: () => void;
constructor(options: SdkControlClientTransportOptions) {
this.serverName = options.serverName;
this.sendMcpMessage = options.sendMcpMessage;
this.debugMode = options.debugMode ?? false;
}
/**
* Start the transport
* For SDK transport, this just marks it as ready - no subprocess to spawn
*/
async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
if (this.debugMode) {
console.error(
`[SdkControlClientTransport] Started for server '${this.serverName}'`,
);
}
}
/**
* Send a message to the SDK MCP server via control plane
*
* Routes the message through the control plane and delivers
* the response via onmessage callback.
*/
async send(message: JSONRPCMessage): Promise<void> {
if (!this.started) {
throw new Error(
`SdkControlClientTransport (${this.serverName}) not started. Call start() first.`,
);
}
if (this.debugMode) {
console.error(
`[SdkControlClientTransport] Sending message to '${this.serverName}':`,
JSON.stringify(message),
);
}
try {
// Send message to SDK and wait for response
const response = await this.sendMcpMessage(this.serverName, message);
if (this.debugMode) {
console.error(
`[SdkControlClientTransport] Received response from '${this.serverName}':`,
JSON.stringify(response),
);
}
// Deliver response via onmessage callback
if (this.onmessage) {
this.onmessage(response);
}
} catch (error) {
if (this.debugMode) {
console.error(
`[SdkControlClientTransport] Error sending to '${this.serverName}':`,
error,
);
}
if (this.onerror) {
this.onerror(error instanceof Error ? error : new Error(String(error)));
}
throw error;
}
}
/**
* Close the transport
*/
async close(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
if (this.debugMode) {
console.error(
`[SdkControlClientTransport] Closed for server '${this.serverName}'`,
);
}
if (this.onclose) {
this.onclose();
}
}
/**
* Check if transport is started
*/
isStarted(): boolean {
return this.started;
}
/**
* Get server name
*/
getServerName(): string {
return this.serverName;
}
}

View File

@@ -16,6 +16,7 @@ import type { Config } from '../config/config.js';
import { spawn } from 'node:child_process';
import { StringDecoder } from 'node:string_decoder';
import { connectAndDiscover } from './mcp-client.js';
import type { SendSdkMcpMessage } from './mcp-client.js';
import { McpClientManager } from './mcp-client-manager.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { parse } from 'shell-quote';
@@ -173,7 +174,11 @@ export class ToolRegistry {
private config: Config;
private mcpClientManager: McpClientManager;
constructor(config: Config, eventEmitter?: EventEmitter) {
constructor(
config: Config,
eventEmitter?: EventEmitter,
sendSdkMcpMessage?: SendSdkMcpMessage,
) {
this.config = config;
this.mcpClientManager = new McpClientManager(
this.config.getMcpServers() ?? {},
@@ -183,6 +188,7 @@ export class ToolRegistry {
this.config.getDebugMode(),
this.config.getWorkspaceContext(),
eventEmitter,
sendSdkMcpMessage,
);
}

View File

@@ -391,6 +391,19 @@ describe('Shell Command Processor - Encoding Functions', () => {
expect(result).toBe('windows-1252');
});
it('should prioritize UTF-8 detection over Windows system encoding', () => {
mockedOsPlatform.mockReturnValue('win32');
mockedExecSync.mockReturnValue('Active code page: 936'); // GBK
const buffer = Buffer.from('test');
// Mock chardet to return UTF-8
mockedChardetDetect.mockReturnValue('UTF-8');
const result = getCachedEncodingForBuffer(buffer);
expect(result).toBe('utf-8');
});
it('should cache null system encoding result', () => {
// Reset the cache specifically for this test
resetEncodingCache();

View File

@@ -34,6 +34,15 @@ export function getCachedEncodingForBuffer(buffer: Buffer): string {
// If we have a cached system encoding, use it
if (cachedSystemEncoding) {
// If the system encoding is not UTF-8 (e.g. Windows CP936), but the buffer
// is detected as UTF-8, prefer UTF-8. This handles tools like 'git' which
// often output UTF-8 regardless of the system code page.
if (cachedSystemEncoding !== 'utf-8') {
const detected = detectEncodingFromBuffer(buffer);
if (detected === 'utf-8') {
return 'utf-8';
}
}
return cachedSystemEncoding;
}

View File

@@ -1,4 +1,4 @@
# @qwen-code/sdk-typescript
# @qwen-code/sdk
A minimum experimental TypeScript SDK for programmatic access to Qwen Code.
@@ -7,20 +7,20 @@ Feel free to submit a feature request/issue/PR.
## Installation
```bash
npm install @qwen-code/sdk-typescript
npm install @qwen-code/sdk
```
## Requirements
- Node.js >= 20.0.0
- [Qwen Code](https://github.com/QwenLM/qwen-code) installed and accessible in PATH
- [Qwen Code](https://github.com/QwenLM/qwen-code) >= 0.4.0 (stable) installed and accessible in PATH
> **Note for nvm users**: If you use nvm to manage Node.js versions, the SDK may not be able to auto-detect the Qwen Code executable. You should explicitly set the `pathToQwenExecutable` option to the full path of the `qwen` binary.
## Quick Start
```typescript
import { query } from '@qwen-code/sdk-typescript';
import { query } from '@qwen-code/sdk';
// Single-turn query
const result = query({
@@ -59,9 +59,9 @@ Creates a new query session with the Qwen Code.
| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. |
| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. |
| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. |
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 30 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
| `env` | `Record<string, string>` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. |
| `mcpServers` | `Record<string, ExternalMcpServerConfig>` | - | External MCP (Model Context Protocol) servers to connect. Each server is identified by a unique name and configured with `command`, `args`, and `env`. |
| `mcpServers` | `Record<string, McpServerConfig>` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. |
| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. |
| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. |
| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. A turn consists of a user message and an assistant response. |
@@ -74,12 +74,27 @@ Creates a new query session with the Qwen Code.
### Timeouts
The SDK enforces the following timeouts:
The SDK enforces the following default timeouts:
| Timeout | Duration | Description |
| ------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Permission Callback | 30 seconds | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. |
| Control Request | 30 seconds | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. |
| Timeout | Default | Description |
| ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `canUseTool` | 1 minute | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. |
| `mcpRequest` | 1 minute | Maximum time for SDK MCP tool calls to complete. |
| `controlRequest` | 1 minute | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. |
| `streamClose` | 1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. |
You can customize these timeouts via the `timeout` option:
```typescript
const query = qwen.query('Your prompt', {
timeout: {
canUseTool: 60000, // 60 seconds for permission callback
mcpRequest: 600000, // 10 minutes for MCP tool calls
controlRequest: 60000, // 60 seconds for control requests
streamClose: 15000, // 15 seconds for stream close wait
},
});
```
### Message Types
@@ -92,7 +107,7 @@ import {
isSDKSystemMessage,
isSDKResultMessage,
isSDKPartialAssistantMessage,
} from '@qwen-code/sdk-typescript';
} from '@qwen-code/sdk';
for await (const message of result) {
if (isSDKAssistantMessage(message)) {
@@ -152,7 +167,7 @@ The SDK supports different permission modes for controlling tool execution:
### Multi-turn Conversation
```typescript
import { query, type SDKUserMessage } from '@qwen-code/sdk-typescript';
import { query, type SDKUserMessage } from '@qwen-code/sdk';
async function* generateMessages(): AsyncIterable<SDKUserMessage> {
yield {
@@ -186,7 +201,7 @@ for await (const message of result) {
### Custom Permission Handler
```typescript
import { query, type CanUseTool } from '@qwen-code/sdk-typescript';
import { query, type CanUseTool } from '@qwen-code/sdk';
const canUseTool: CanUseTool = async (toolName, input, { signal }) => {
// Allow all read operations
@@ -212,10 +227,10 @@ const result = query({
});
```
### With MCP Servers
### With External MCP Servers
```typescript
import { query } from '@qwen-code/sdk-typescript';
import { query } from '@qwen-code/sdk';
const result = query({
prompt: 'Use the custom tool from my MCP server',
@@ -231,10 +246,88 @@ const result = query({
});
```
### With SDK-Embedded MCP Servers
The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process.
#### `tool(name, description, inputSchema, handler)`
Creates a tool definition with Zod schema type inference.
| Parameter | Type | Description |
| ------------- | ---------------------------------- | ------------------------------------------------------------------------ |
| `name` | `string` | Tool name (1-64 chars, starts with letter, alphanumeric and underscores) |
| `description` | `string` | Human-readable description of what the tool does |
| `inputSchema` | `ZodRawShape` | Zod schema object defining the tool's input parameters |
| `handler` | `(args, extra) => Promise<Result>` | Async function that executes the tool and returns MCP content blocks |
The handler must return a `CallToolResult` object with the following structure:
```typescript
{
content: Array<
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
| { type: 'resource'; uri: string; mimeType?: string; text?: string }
>;
isError?: boolean;
}
```
#### `createSdkMcpServer(options)`
Creates an SDK-embedded MCP server instance.
| Option | Type | Default | Description |
| --------- | ------------------------ | --------- | ------------------------------------ |
| `name` | `string` | Required | Unique name for the MCP server |
| `version` | `string` | `'1.0.0'` | Server version |
| `tools` | `SdkMcpToolDefinition[]` | - | Array of tools created with `tool()` |
Returns a `McpSdkServerConfigWithInstance` object that can be passed directly to the `mcpServers` option.
#### Example
```typescript
import { z } from 'zod';
import { query, tool, createSdkMcpServer } from '@qwen-code/sdk';
// Define a tool with Zod schema
const calculatorTool = tool(
'calculate_sum',
'Add two numbers',
{ a: z.number(), b: z.number() },
async (args) => ({
content: [{ type: 'text', text: String(args.a + args.b) }],
}),
);
// Create the MCP server
const server = createSdkMcpServer({
name: 'calculator',
tools: [calculatorTool],
});
// Use the server in a query
const result = query({
prompt: 'What is 42 + 17?',
options: {
permissionMode: 'yolo',
mcpServers: {
calculator: server,
},
},
});
for await (const message of result) {
console.log(message);
}
```
### Abort a Query
```typescript
import { query, isAbortError } from '@qwen-code/sdk-typescript';
import { query, isAbortError } from '@qwen-code/sdk';
const abortController = new AbortController();
@@ -266,7 +359,7 @@ try {
The SDK provides an `AbortError` class for handling aborted queries:
```typescript
import { AbortError, isAbortError } from '@qwen-code/sdk-typescript';
import { AbortError, isAbortError } from '@qwen-code/sdk';
try {
// ... query operations

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/sdk-typescript",
"version": "0.1.0",
"name": "@qwen-code/sdk",
"version": "0.4.1",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",

View File

@@ -14,7 +14,7 @@ import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PACKAGE_NAME = '@qwen-code/sdk-typescript';
const PACKAGE_NAME = '@qwen-code/sdk';
const TAG_PREFIX = 'sdk-typescript-v';
function readJson(filePath) {

View File

@@ -3,6 +3,17 @@ export { AbortError, isAbortError } from './types/errors.js';
export { Query } from './query/Query.js';
export { SdkLogger } from './utils/logger.js';
// SDK MCP Server exports
export { tool } from './mcp/tool.js';
export { createSdkMcpServer } from './mcp/createSdkMcpServer.js';
export type { SdkMcpToolDefinition } from './mcp/tool.js';
export type {
CreateSdkMcpServerOptions,
McpSdkServerConfigWithInstance,
} from './mcp/createSdkMcpServer.js';
export type { QueryOptions } from './query/createQuery.js';
export type { LogLevel, LoggerConfig, ScopedLogger } from './utils/logger.js';
@@ -18,6 +29,7 @@ export type {
SDKResultMessage,
SDKPartialAssistantMessage,
SDKMessage,
SDKMcpServerConfig,
ControlMessage,
CLIControlRequest,
CLIControlResponse,
@@ -43,6 +55,10 @@ export type {
PermissionMode,
CanUseTool,
PermissionResult,
ExternalMcpServerConfig,
SdkMcpServerConfig,
CLIMcpServerConfig,
McpServerConfig,
McpOAuthConfig,
McpAuthProviderType,
} from './types/types.js';
export { isSdkMcpServerConfig } from './types/types.js';

View File

@@ -103,9 +103,3 @@ export class SdkControlServerTransport {
return this.serverName;
}
}
export function createSdkControlServerTransport(
options: SdkControlServerTransportOptions,
): SdkControlServerTransport {
return new SdkControlServerTransport(options);
}

View File

@@ -1,29 +1,63 @@
/**
* Factory function to create SDK-embedded MCP servers
*
* Creates MCP Server instances that run in the user's Node.js process
* and are proxied to the CLI via the control plane.
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
type CallToolResultSchema,
} from '@modelcontextprotocol/sdk/types.js';
import type { ToolDefinition } from '../types/types.js';
import { formatToolResult, formatToolError } from './formatters.js';
/**
* Factory function to create SDK-embedded MCP servers
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { SdkMcpToolDefinition } from './tool.js';
import { validateToolName } from './tool.js';
import type { z } from 'zod';
type CallToolResult = z.infer<typeof CallToolResultSchema>;
/**
* Options for creating an SDK MCP server
*/
export type CreateSdkMcpServerOptions = {
name: string;
version?: string;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
tools?: Array<SdkMcpToolDefinition<any>>;
};
/**
* SDK MCP Server configuration with instance
*/
export type McpSdkServerConfigWithInstance = {
type: 'sdk';
name: string;
instance: McpServer;
};
/**
* Creates an MCP server instance that can be used with the SDK transport.
*
* @example
* ```typescript
* import { z } from 'zod';
* import { tool, createSdkMcpServer } from '@qwen-code/sdk';
*
* const calculatorTool = tool(
* 'calculate_sum',
* 'Add two numbers',
* { a: z.number(), b: z.number() },
* async (args) => ({ content: [{ type: 'text', text: String(args.a + args.b) }] })
* );
*
* const server = createSdkMcpServer({
* name: 'calculator',
* version: '1.0.0',
* tools: [calculatorTool],
* });
* ```
*/
export function createSdkMcpServer(
name: string,
version: string,
tools: ToolDefinition[],
): Server {
// Validate server name
options: CreateSdkMcpServerOptions,
): McpSdkServerConfigWithInstance {
const { name, version = '1.0.0', tools } = options;
if (!name || typeof name !== 'string') {
throw new Error('MCP server name must be a non-empty string');
}
@@ -32,78 +66,42 @@ export function createSdkMcpServer(
throw new Error('MCP server version must be a non-empty string');
}
if (!Array.isArray(tools)) {
if (tools !== undefined && !Array.isArray(tools)) {
throw new Error('Tools must be an array');
}
// Validate tool names are unique
const toolNames = new Set<string>();
for (const tool of tools) {
validateToolName(tool.name);
if (toolNames.has(tool.name)) {
throw new Error(
`Duplicate tool name '${tool.name}' in MCP server '${name}'`,
);
if (tools) {
for (const t of tools) {
validateToolName(t.name);
if (toolNames.has(t.name)) {
throw new Error(
`Duplicate tool name '${t.name}' in MCP server '${name}'`,
);
}
toolNames.add(t.name);
}
toolNames.add(tool.name);
}
// Create MCP Server instance
const server = new Server(
{
name,
version,
},
const server = new McpServer(
{ name, version },
{
capabilities: {
tools: {},
tools: tools ? {} : undefined,
},
},
);
// Create tool map for fast lookup
const toolMap = new Map<string, ToolDefinition>();
for (const tool of tools) {
toolMap.set(tool.name, tool);
if (tools) {
tools.forEach((toolDef) => {
server.tool(
toolDef.name,
toolDef.description,
toolDef.inputSchema,
toolDef.handler,
);
});
}
// Register list_tools handler
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
}));
// Register call_tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name: toolName, arguments: toolArgs } = request.params;
// Find tool
const tool = toolMap.get(toolName);
if (!tool) {
return formatToolError(
new Error(`Tool '${toolName}' not found in server '${name}'`),
) as CallToolResult;
}
try {
// Invoke tool handler
const result = await tool.handler(toolArgs);
// Format result
return formatToolResult(result) as CallToolResult;
} catch (error) {
// Handle tool execution error
return formatToolError(
error instanceof Error
? error
: new Error(`Tool '${toolName}' failed: ${String(error)}`),
) as CallToolResult;
}
});
return server;
return { type: 'sdk', name, instance: server };
}

View File

@@ -1,39 +1,76 @@
/**
* Tool definition helper for SDK-embedded MCP servers
*
* Provides type-safe tool definitions with generic input/output types.
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolDefinition } from '../types/types.js';
/**
* Tool definition helper for SDK-embedded MCP servers
*/
export function tool<TInput = unknown, TOutput = unknown>(
def: ToolDefinition<TInput, TOutput>,
): ToolDefinition<TInput, TOutput> {
// Validate tool definition
if (!def.name || typeof def.name !== 'string') {
throw new Error('Tool definition must have a name (string)');
import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import type { z, ZodRawShape, ZodObject, ZodTypeAny } from 'zod';
type CallToolResult = z.infer<typeof CallToolResultSchema>;
/**
* SDK MCP Tool Definition with Zod schema type inference
*/
export type SdkMcpToolDefinition<Schema extends ZodRawShape = ZodRawShape> = {
name: string;
description: string;
inputSchema: Schema;
handler: (
args: z.infer<ZodObject<Schema, 'strip', ZodTypeAny>>,
extra: unknown,
) => Promise<CallToolResult>;
};
/**
* Create an SDK MCP tool definition with Zod schema inference
*
* @example
* ```typescript
* import { z } from 'zod';
* import { tool } from '@qwen-code/sdk';
*
* const calculatorTool = tool(
* 'calculate_sum',
* 'Calculate the sum of two numbers',
* { a: z.number(), b: z.number() },
* async (args) => {
* // args is inferred as { a: number, b: number }
* return { content: [{ type: 'text', text: String(args.a + args.b) }] };
* }
* );
* ```
*/
export function tool<Schema extends ZodRawShape>(
name: string,
description: string,
inputSchema: Schema,
handler: (
args: z.infer<ZodObject<Schema, 'strip', ZodTypeAny>>,
extra: unknown,
) => Promise<CallToolResult>,
): SdkMcpToolDefinition<Schema> {
if (!name || typeof name !== 'string') {
throw new Error('Tool name must be a non-empty string');
}
if (!def.description || typeof def.description !== 'string') {
throw new Error(
`Tool definition for '${def.name}' must have a description (string)`,
);
if (!description || typeof description !== 'string') {
throw new Error(`Tool '${name}' must have a description (string)`);
}
if (!def.inputSchema || typeof def.inputSchema !== 'object') {
throw new Error(
`Tool definition for '${def.name}' must have an inputSchema (object)`,
);
if (!inputSchema || typeof inputSchema !== 'object') {
throw new Error(`Tool '${name}' must have an inputSchema (object)`);
}
if (!def.handler || typeof def.handler !== 'function') {
throw new Error(
`Tool definition for '${def.name}' must have a handler (function)`,
);
if (!handler || typeof handler !== 'function') {
throw new Error(`Tool '${name}' must have a handler (function)`);
}
// Return definition (pass-through for type safety)
return def;
return { name, description, inputSchema, handler };
}
export function validateToolName(name: string): void {
@@ -53,39 +90,3 @@ export function validateToolName(name: string): void {
);
}
}
export function validateInputSchema(schema: unknown): void {
if (!schema || typeof schema !== 'object') {
throw new Error('Input schema must be an object');
}
const schemaObj = schema as Record<string, unknown>;
if (!schemaObj.type) {
throw new Error('Input schema must have a type field');
}
// For object schemas, validate properties
if (schemaObj.type === 'object') {
if (schemaObj.properties && typeof schemaObj.properties !== 'object') {
throw new Error('Input schema properties must be an object');
}
if (schemaObj.required && !Array.isArray(schemaObj.required)) {
throw new Error('Input schema required must be an array');
}
}
}
export function createTool<TInput = unknown, TOutput = unknown>(
def: ToolDefinition<TInput, TOutput>,
): ToolDefinition<TInput, TOutput> {
// Validate via tool() function
const validated = tool(def);
// Additional validation
validateToolName(validated.name);
validateInputSchema(validated.inputSchema);
return validated;
}

View File

@@ -5,10 +5,10 @@
* Implements AsyncIterator protocol for message consumption.
*/
const PERMISSION_CALLBACK_TIMEOUT = 30000;
const MCP_REQUEST_TIMEOUT = 30000;
const CONTROL_REQUEST_TIMEOUT = 30000;
const STREAM_CLOSE_TIMEOUT = 10000;
const DEFAULT_CAN_USE_TOOL_TIMEOUT = 60_000;
const DEFAULT_MCP_REQUEST_TIMEOUT = 60_000;
const DEFAULT_CONTROL_REQUEST_TIMEOUT = 60_000;
const DEFAULT_STREAM_CLOSE_TIMEOUT = 60_000;
import { randomUUID } from 'node:crypto';
import { SdkLogger } from '../utils/logger.js';
@@ -19,6 +19,7 @@ import type {
CLIControlResponse,
ControlCancelRequest,
PermissionSuggestion,
WireSDKMcpServerConfig,
} from '../types/protocol.js';
import {
isSDKUserMessage,
@@ -31,12 +32,17 @@ import {
isControlCancel,
} from '../types/protocol.js';
import type { Transport } from '../transport/Transport.js';
import type { QueryOptions } from '../types/types.js';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { QueryOptions, CLIMcpServerConfig } from '../types/types.js';
import { isSdkMcpServerConfig } from '../types/types.js';
import { Stream } from '../utils/Stream.js';
import { serializeJsonLine } from '../utils/jsonLines.js';
import { AbortError } from '../types/errors.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { SdkControlServerTransport } from '../mcp/SdkControlServerTransport.js';
import {
SdkControlServerTransport,
type SdkControlServerTransportOptions,
} from '../mcp/SdkControlServerTransport.js';
import { ControlRequestType } from '../types/protocol.js';
interface PendingControlRequest {
@@ -46,6 +52,11 @@ interface PendingControlRequest {
abortController: AbortController;
}
interface PendingMcpResponse {
resolve: (response: JSONRPCMessage) => void;
reject: (error: Error) => void;
}
interface TransportWithEndInput extends Transport {
endInput(): void;
}
@@ -61,7 +72,9 @@ export class Query implements AsyncIterable<SDKMessage> {
private abortController: AbortController;
private pendingControlRequests: Map<string, PendingControlRequest> =
new Map();
private pendingMcpResponses: Map<string, PendingMcpResponse> = new Map();
private sdkMcpTransports: Map<string, SdkControlServerTransport> = new Map();
private sdkMcpServers: Map<string, McpServer> = new Map();
readonly initialized: Promise<void>;
private closed = false;
private messageRouterStarted = false;
@@ -92,6 +105,11 @@ export class Query implements AsyncIterable<SDKMessage> {
*/
this.sdkMessages = this.readSdkMessages();
/**
* Promise that resolves when the first SDKResultMessage is received.
* Used to coordinate endInput() timing - ensures all initialization
* (SDK MCP servers, control responses) is complete before closing CLI stdin.
*/
this.firstResultReceivedPromise = new Promise((resolve) => {
this.firstResultReceivedResolve = resolve;
});
@@ -121,17 +139,152 @@ export class Query implements AsyncIterable<SDKMessage> {
this.startMessageRouter();
}
private async initializeSdkMcpServers(): Promise<void> {
if (!this.options.mcpServers) {
return;
}
const connectionPromises: Array<Promise<void>> = [];
// Extract SDK MCP servers from the unified mcpServers config
for (const [key, config] of Object.entries(this.options.mcpServers)) {
if (!isSdkMcpServerConfig(config)) {
continue; // Skip external MCP servers
}
// Use the name from SDKMcpServerConfig, fallback to key for backwards compatibility
const serverName = config.name || key;
const server = config.instance;
// Create transport options with callback to route MCP server responses
const transportOptions: SdkControlServerTransportOptions = {
sendToQuery: async (message: JSONRPCMessage) => {
this.handleMcpServerResponse(serverName, message);
},
serverName,
};
const sdkTransport = new SdkControlServerTransport(transportOptions);
// Connect server to transport and only register on success
const connectionPromise = server
.connect(sdkTransport)
.then(() => {
// Only add to maps after successful connection
this.sdkMcpServers.set(serverName, server);
this.sdkMcpTransports.set(serverName, sdkTransport);
logger.debug(`SDK MCP server '${serverName}' connected to transport`);
})
.catch((error) => {
logger.error(
`Failed to connect SDK MCP server '${serverName}' to transport:`,
error,
);
// Don't throw - one failed server shouldn't prevent others
});
connectionPromises.push(connectionPromise);
}
// Wait for all connection attempts to complete
await Promise.all(connectionPromises);
if (this.sdkMcpServers.size > 0) {
logger.info(
`Initialized ${this.sdkMcpServers.size} SDK MCP server(s): ${Array.from(this.sdkMcpServers.keys()).join(', ')}`,
);
}
}
/**
* Handle response messages from SDK MCP servers
*
* When an MCP server sends a response via transport.send(), this callback
* routes it back to the pending request that's waiting for it.
*/
private handleMcpServerResponse(
serverName: string,
message: JSONRPCMessage,
): void {
// Check if this is a response with an id
if ('id' in message && message.id !== null && message.id !== undefined) {
const key = `${serverName}:${message.id}`;
const pending = this.pendingMcpResponses.get(key);
if (pending) {
logger.debug(
`Routing MCP response for server '${serverName}', id: ${message.id}`,
);
pending.resolve(message);
this.pendingMcpResponses.delete(key);
return;
}
}
// If no pending request found, log a warning (this shouldn't happen normally)
logger.warn(
`Received MCP server response with no pending request: server='${serverName}'`,
message,
);
}
/**
* Get SDK MCP servers config for CLI initialization
*
* Only SDK servers are sent in the initialize request.
*/
private getSdkMcpServersForCli(): Record<string, WireSDKMcpServerConfig> {
const sdkServers: Record<string, WireSDKMcpServerConfig> = {};
for (const [name] of this.sdkMcpServers.entries()) {
sdkServers[name] = { type: 'sdk', name };
}
return sdkServers;
}
/**
* Get external MCP servers (non-SDK) that should be managed by the CLI
*/
private getMcpServersForCli(): Record<string, CLIMcpServerConfig> {
if (!this.options.mcpServers) {
return {};
}
const externalServers: Record<string, CLIMcpServerConfig> = {};
for (const [name, config] of Object.entries(this.options.mcpServers)) {
if (isSdkMcpServerConfig(config)) {
continue;
}
externalServers[name] = config as CLIMcpServerConfig;
}
return externalServers;
}
private async initialize(): Promise<void> {
try {
logger.debug('Initializing Query');
const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys());
// Initialize SDK MCP servers and wait for connections
await this.initializeSdkMcpServers();
// Get only successfully connected SDK servers for CLI
const sdkMcpServersForCli = this.getSdkMcpServersForCli();
const mcpServersForCli = this.getMcpServersForCli();
logger.debug('SDK MCP servers for CLI:', sdkMcpServersForCli);
logger.debug('External MCP servers for CLI:', mcpServersForCli);
await this.sendControlRequest(ControlRequestType.INITIALIZE, {
hooks: null,
sdkMcpServers:
sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined,
mcpServers: this.options.mcpServers,
Object.keys(sdkMcpServersForCli).length > 0
? sdkMcpServersForCli
: undefined,
mcpServers:
Object.keys(mcpServersForCli).length > 0
? mcpServersForCli
: undefined,
agents: this.options.agents,
});
logger.info('Query initialized successfully');
@@ -279,10 +432,13 @@ export class Query implements AsyncIterable<SDKMessage> {
}
try {
const canUseToolTimeout =
this.options.timeout?.canUseTool ?? DEFAULT_CAN_USE_TOOL_TIMEOUT;
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
timeoutId = setTimeout(
() => reject(new Error('Permission callback timeout')),
PERMISSION_CALLBACK_TIMEOUT,
canUseToolTimeout,
);
});
@@ -296,6 +452,10 @@ export class Query implements AsyncIterable<SDKMessage> {
timeoutPromise,
]);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (result.behavior === 'allow') {
return {
behavior: 'allow',
@@ -361,32 +521,45 @@ export class Query implements AsyncIterable<SDKMessage> {
}
private handleMcpRequest(
_serverName: string,
serverName: string,
message: JSONRPCMessage,
transport: SdkControlServerTransport,
): Promise<JSONRPCMessage> {
const messageId = 'id' in message ? message.id : null;
const key = `${serverName}:${messageId}`;
return new Promise((resolve, reject) => {
const mcpRequestTimeout =
this.options.timeout?.mcpRequest ?? DEFAULT_MCP_REQUEST_TIMEOUT;
const timeout = setTimeout(() => {
this.pendingMcpResponses.delete(key);
reject(new Error('MCP request timeout'));
}, MCP_REQUEST_TIMEOUT);
}, mcpRequestTimeout);
const messageId = 'id' in message ? message.id : null;
/**
* Hook into transport to capture response.
* Temporarily replace sendToQuery to intercept the response message
* matching this request's ID, then restore the original handler.
*/
const originalSend = transport.sendToQuery;
transport.sendToQuery = async (responseMessage: JSONRPCMessage) => {
if ('id' in responseMessage && responseMessage.id === messageId) {
clearTimeout(timeout);
transport.sendToQuery = originalSend;
resolve(responseMessage);
}
return originalSend(responseMessage);
const cleanup = () => {
clearTimeout(timeout);
this.pendingMcpResponses.delete(key);
};
const resolveAndCleanup = (response: JSONRPCMessage) => {
cleanup();
resolve(response);
};
const rejectAndCleanup = (error: Error) => {
cleanup();
reject(error);
};
// Register pending response handler
this.pendingMcpResponses.set(key, {
resolve: resolveAndCleanup,
reject: rejectAndCleanup,
});
// Deliver message to MCP server via transport.onmessage
// The server will process it and call transport.send() with the response,
// which triggers handleMcpServerResponse to resolve our pending promise
transport.handleMessage(message);
});
}
@@ -452,6 +625,10 @@ export class Query implements AsyncIterable<SDKMessage> {
subtype: string,
data: Record<string, unknown> = {},
): Promise<Record<string, unknown> | null> {
if (this.closed) {
return Promise.reject(new Error('Query is closed'));
}
const requestId = randomUUID();
const request: CLIControlRequest = {
@@ -466,10 +643,13 @@ export class Query implements AsyncIterable<SDKMessage> {
const responsePromise = new Promise<Record<string, unknown> | null>(
(resolve, reject) => {
const abortController = new AbortController();
const controlRequestTimeout =
this.options.timeout?.controlRequest ??
DEFAULT_CONTROL_REQUEST_TIMEOUT;
const timeout = setTimeout(() => {
this.pendingControlRequests.delete(requestId);
reject(new Error(`Control request timeout: ${subtype}`));
}, CONTROL_REQUEST_TIMEOUT);
}, controlRequestTimeout);
this.pendingControlRequests.set(requestId, {
resolve,
@@ -517,9 +697,16 @@ export class Query implements AsyncIterable<SDKMessage> {
for (const pending of this.pendingControlRequests.values()) {
pending.abortController.abort();
clearTimeout(pending.timeout);
pending.reject(new Error('Query is closed'));
}
this.pendingControlRequests.clear();
// Clean up pending MCP responses
for (const pending of this.pendingMcpResponses.values()) {
pending.reject(new Error('Query is closed'));
}
this.pendingMcpResponses.clear();
await this.transport.close();
/**
@@ -542,7 +729,7 @@ export class Query implements AsyncIterable<SDKMessage> {
}
}
this.sdkMcpTransports.clear();
logger.info('Query closed');
logger.info('Query is closed');
}
private async *readSdkMessages(): AsyncGenerator<SDKMessage> {
@@ -588,24 +775,39 @@ export class Query implements AsyncIterable<SDKMessage> {
}
/**
* In multi-turn mode with MCP servers, wait for first result
* to ensure MCP servers have time to process before next input.
* This prevents race conditions where the next input arrives before
* MCP servers have finished processing the current request.
* After all user messages are sent (for-await loop ended), determine when to
* close the CLI's stdin via endInput().
*
* - If a result message was already received: All initialization (SDK MCP servers,
* control responses, etc.) is complete, safe to close stdin immediately.
* - If no result yet: Wait for either the result to arrive, or the timeout to expire.
* This gives pending control_responses from SDK MCP servers or other modules
* time to complete their initialization before we close the input stream.
*
* The timeout ensures we don't hang indefinitely - either the turn proceeds
* normally, or it fails with a timeout, but Promise.race will always resolve.
*/
if (
!this.isSingleTurn &&
this.sdkMcpTransports.size > 0 &&
this.firstResultReceivedPromise
) {
await Promise.race([
this.firstResultReceivedPromise,
new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, STREAM_CLOSE_TIMEOUT);
}),
]);
const streamCloseTimeout =
this.options.timeout?.streamClose ?? DEFAULT_STREAM_CLOSE_TIMEOUT;
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<void>((resolve) => {
timeoutId = setTimeout(() => {
logger.info('streamCloseTimeout resolved');
resolve();
}, streamCloseTimeout);
});
await Promise.race([this.firstResultReceivedPromise, timeoutPromise]);
if (timeoutId) {
clearTimeout(timeoutId);
}
}
this.endInput();
@@ -635,28 +837,16 @@ export class Query implements AsyncIterable<SDKMessage> {
}
async interrupt(): Promise<void> {
if (this.closed) {
throw new Error('Query is closed');
}
await this.sendControlRequest(ControlRequestType.INTERRUPT);
}
async setPermissionMode(mode: string): Promise<void> {
if (this.closed) {
throw new Error('Query is closed');
}
await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, {
mode,
});
}
async setModel(model: string): Promise<void> {
if (this.closed) {
throw new Error('Query is closed');
}
await this.sendControlRequest(ControlRequestType.SET_MODEL, { model });
}
@@ -667,10 +857,6 @@ export class Query implements AsyncIterable<SDKMessage> {
* @throws Error if query is closed
*/
async supportedCommands(): Promise<Record<string, unknown> | null> {
if (this.closed) {
throw new Error('Query is closed');
}
return this.sendControlRequest(ControlRequestType.SUPPORTED_COMMANDS);
}
@@ -681,10 +867,6 @@ export class Query implements AsyncIterable<SDKMessage> {
* @throws Error if query is closed
*/
async mcpServerStatus(): Promise<Record<string, unknown> | null> {
if (this.closed) {
throw new Error('Query is closed');
}
return this.sendControlRequest(ControlRequestType.MCP_SERVER_STATUS);
}

View File

@@ -139,6 +139,7 @@ export class ProcessTransport implements Transport {
'stream-json',
'--output-format',
'stream-json',
'--channel=SDK',
];
if (this.options.model) {

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
export interface Annotation {
type: string;
value: string;
@@ -293,10 +294,44 @@ export interface MCPServerConfig {
targetServiceAccount?: string;
}
/**
* SDK MCP Server configuration
*
* SDK MCP servers run in the SDK process and are connected via in-memory transport.
* Tool calls are routed through the control plane between SDK and CLI.
*/
export interface SDKMcpServerConfig {
/**
* Type identifier for SDK MCP servers
*/
type: 'sdk';
/**
* Server name for identification and routing
*/
name: string;
/**
* The MCP Server instance created by createSdkMcpServer()
*/
instance: McpServer;
}
/**
* Wire format for SDK MCP servers sent to the CLI
*/
export type WireSDKMcpServerConfig = Omit<SDKMcpServerConfig, 'instance'>;
export interface CLIControlInitializeRequest {
subtype: 'initialize';
hooks?: HookRegistration[] | null;
sdkMcpServers?: Record<string, MCPServerConfig>;
/**
* 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, WireSDKMcpServerConfig>;
/**
* External MCP servers that should be managed by the CLI.
*/
mcpServers?: Record<string, MCPServerConfig>;
agents?: SubagentConfig[];
}

View File

@@ -2,19 +2,98 @@ import { z } from 'zod';
import type { CanUseTool } from './types.js';
import type { SubagentConfig } from './protocol.js';
export const ExternalMcpServerConfigSchema = z.object({
command: z.string().min(1, 'Command must be a non-empty string'),
/**
* OAuth configuration for MCP servers
*/
export const McpOAuthConfigSchema = z
.object({
enabled: z.boolean().optional(),
clientId: z
.string()
.min(1, 'clientId must be a non-empty string')
.optional(),
clientSecret: z.string().optional(),
scopes: z.array(z.string()).optional(),
redirectUri: z.string().optional(),
authorizationUrl: z.string().optional(),
tokenUrl: z.string().optional(),
audiences: z.array(z.string()).optional(),
tokenParamName: z.string().optional(),
registrationUrl: z.string().optional(),
})
.strict();
/**
* CLI MCP Server configuration schema
*
* Supports multiple transport types:
* - stdio: command, args, env, cwd
* - SSE: url
* - Streamable HTTP: httpUrl, headers
* - WebSocket: tcp
*/
export const CLIMcpServerConfigSchema = z.object({
// For stdio transport
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
cwd: z.string().optional(),
// For SSE transport
url: z.string().optional(),
// For streamable HTTP transport
httpUrl: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
// For WebSocket transport
tcp: z.string().optional(),
// Common
timeout: z.number().optional(),
trust: z.boolean().optional(),
// Metadata
description: z.string().optional(),
includeTools: z.array(z.string()).optional(),
excludeTools: z.array(z.string()).optional(),
extensionName: z.string().optional(),
// OAuth configuration
oauth: McpOAuthConfigSchema.optional(),
authProviderType: z
.enum([
'dynamic_discovery',
'google_credentials',
'service_account_impersonation',
])
.optional(),
// Service Account Configuration
targetAudience: z.string().optional(),
targetServiceAccount: z.string().optional(),
});
/**
* SDK MCP Server configuration schema
*/
export const SdkMcpServerConfigSchema = z.object({
connect: z.custom<(transport: unknown) => Promise<void>>(
(val) => typeof val === 'function',
{ message: 'connect must be a function' },
type: z.literal('sdk'),
name: z.string().min(1, 'name must be a non-empty string'),
instance: z.custom<{
connect(transport: unknown): Promise<void>;
close(): Promise<void>;
}>(
(val) =>
val &&
typeof val === 'object' &&
'connect' in val &&
typeof val.connect === 'function',
{ message: 'instance must be an MCP Server with connect method' },
),
});
/**
* Unified MCP Server configuration schema
*/
export const McpServerConfigSchema = z.union([
CLIMcpServerConfigSchema,
SdkMcpServerConfigSchema,
]);
export const ModelConfigSchema = z.object({
model: z.string().optional(),
temp: z.number().optional(),
@@ -37,6 +116,13 @@ export const SubagentConfigSchema = z.object({
isBuiltin: z.boolean().optional(),
});
export const TimeoutConfigSchema = z.object({
canUseTool: z.number().positive().optional(),
mcpRequest: z.number().positive().optional(),
controlRequest: z.number().positive().optional(),
streamClose: z.number().positive().optional(),
});
export const QueryOptionsSchema = z
.object({
cwd: z.string().optional(),
@@ -49,7 +135,7 @@ export const QueryOptionsSchema = z
message: 'canUseTool must be a function',
})
.optional(),
mcpServers: z.record(z.string(), ExternalMcpServerConfigSchema).optional(),
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
abortController: z.instanceof(AbortController).optional(),
debug: z.boolean().optional(),
stderr: z
@@ -78,5 +164,6 @@ export const QueryOptionsSchema = z
)
.optional(),
includePartialMessages: z.boolean().optional(),
timeout: TimeoutConfigSchema.optional(),
})
.strict();

View File

@@ -2,25 +2,11 @@ import type {
PermissionMode,
PermissionSuggestion,
SubagentConfig,
SDKMcpServerConfig,
} from './protocol.js';
export type { PermissionMode };
type JSONSchema = {
type: string;
properties?: Record<string, unknown>;
required?: string[];
description?: string;
[key: string]: unknown;
};
export type ToolDefinition<TInput = unknown, TOutput = unknown> = {
name: string;
description: string;
inputSchema: JSONSchema;
handler: (input: TInput) => Promise<TOutput>;
};
export type TransportOptions = {
pathToQwenExecutable: string;
cwd?: string;
@@ -61,14 +47,115 @@ export type PermissionResult =
interrupt?: boolean;
};
export interface ExternalMcpServerConfig {
command: string;
args?: string[];
env?: Record<string, string>;
/**
* OAuth configuration for MCP servers
*/
export interface McpOAuthConfig {
enabled?: boolean;
clientId?: string;
clientSecret?: string;
scopes?: string[];
redirectUri?: string;
authorizationUrl?: string;
tokenUrl?: string;
audiences?: string[];
tokenParamName?: string;
registrationUrl?: string;
}
export interface SdkMcpServerConfig {
connect: (transport: unknown) => Promise<void>;
/**
* Auth provider type for MCP servers
*/
export type McpAuthProviderType =
| 'dynamic_discovery'
| 'google_credentials'
| 'service_account_impersonation';
/**
* CLI MCP Server configuration
*
* Supports multiple transport types:
* - stdio: command, args, env, cwd
* - SSE: url
* - Streamable HTTP: httpUrl, headers
* - WebSocket: tcp
*
* This interface aligns with MCPServerConfig in @qwen-code/qwen-code-core.
*/
export interface CLIMcpServerConfig {
// For stdio transport
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
// For SSE transport
url?: string;
// For streamable HTTP transport
httpUrl?: string;
headers?: Record<string, string>;
// For WebSocket transport
tcp?: string;
// Common
timeout?: number;
trust?: boolean;
// Metadata
description?: string;
includeTools?: string[];
excludeTools?: string[];
extensionName?: string;
// OAuth configuration
oauth?: McpOAuthConfig;
authProviderType?: McpAuthProviderType;
// Service Account Configuration
/** targetAudience format: CLIENT_ID.apps.googleusercontent.com */
targetAudience?: string;
/** targetServiceAccount format: <service-account-name>@<project-num>.iam.gserviceaccount.com */
targetServiceAccount?: string;
}
/**
* Unified MCP Server configuration
*
* Supports both external MCP servers (stdio/SSE/HTTP/WebSocket) and SDK-embedded MCP servers.
*
* @example External MCP server (stdio)
* ```typescript
* mcpServers: {
* 'my-server': { command: 'node', args: ['server.js'] }
* }
* ```
*
* @example External MCP server (SSE)
* ```typescript
* mcpServers: {
* 'remote-server': { url: 'http://localhost:3000/sse' }
* }
* ```
*
* @example External MCP server (Streamable HTTP)
* ```typescript
* mcpServers: {
* 'http-server': { httpUrl: 'http://localhost:3000/mcp', headers: { 'Authorization': 'Bearer token' } }
* }
* ```
*
* @example SDK MCP server
* ```typescript
* const server = createSdkMcpServer('weather', '1.0.0', [weatherTool]);
* mcpServers: {
* 'weather': { type: 'sdk', name: 'weather', instance: server }
* }
* ```
*/
export type McpServerConfig = CLIMcpServerConfig | SDKMcpServerConfig;
/**
* Type guard to check if a config is an SDK MCP server
*/
export function isSdkMcpServerConfig(
config: McpServerConfig,
): config is SDKMcpServerConfig {
return 'type' in config && config.type === 'sdk';
}
/**
@@ -174,11 +261,36 @@ export interface QueryOptions {
canUseTool?: CanUseTool;
/**
* External MCP (Model Context Protocol) servers to connect to.
* Each server is identified by a unique name and configured with command, args, and environment.
* @example { 'my-server': { command: 'node', args: ['server.js'], env: { PORT: '3000' } } }
* MCP (Model Context Protocol) servers to connect to.
*
* Supports both external MCP servers and SDK-embedded MCP servers:
*
* **External MCP servers** - Run in separate processes, connected via stdio/SSE/HTTP:
* ```typescript
* mcpServers: {
* 'stdio-server': { command: 'node', args: ['server.js'], env: { PORT: '3000' } },
* 'sse-server': { url: 'http://localhost:3000/sse' },
* 'http-server': { httpUrl: 'http://localhost:3000/mcp' }
* }
* ```
*
* **SDK MCP servers** - Run in the SDK process, connected via in-memory transport:
* ```typescript
* const myTool = tool({
* name: 'my_tool',
* description: 'My custom tool',
* inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
* handler: async (input) => ({ result: input.input.toUpperCase() }),
* });
*
* const server = createSdkMcpServer('my-server', '1.0.0', [myTool]);
*
* mcpServers: {
* 'my-server': { type: 'sdk', name: 'my-server', instance: server }
* }
* ```
*/
mcpServers?: Record<string, ExternalMcpServerConfig>;
mcpServers?: Record<string, McpServerConfig>;
/**
* AbortController to cancel the query session.
@@ -204,7 +316,7 @@ export interface QueryOptions {
/**
* Logging level for the SDK.
* Controls the verbosity of log messages output by the SDK.
* @default 'info'
* @default 'error'
*/
logLevel?: 'debug' | 'info' | 'warn' | 'error';
@@ -294,4 +406,43 @@ export interface QueryOptions {
* @default false
*/
includePartialMessages?: boolean;
/**
* Timeout configuration for various SDK operations.
* All values are in milliseconds.
*/
timeout?: {
/**
* Timeout for the `canUseTool` callback.
* If the callback doesn't resolve within this time, the permission request
* will be denied with a timeout error (fail-safe behavior).
* @default 60000 (1 minute)
*/
canUseTool?: number;
/**
* Timeout for SDK MCP tool calls.
* This applies to tool calls made to SDK-embedded MCP servers.
* @default 60000 (1 minute)
*/
mcpRequest?: number;
/**
* Timeout for SDK→CLI control requests.
* This applies to internal control operations like initialize, interrupt,
* setPermissionMode, setModel, etc.
* @default 60000 (1 minute)
*/
controlRequest?: number;
/**
* Timeout for waiting before closing CLI's stdin after user messages are sent.
* In multi-turn mode with SDK MCP servers, after all user messages are processed,
* the SDK waits for the first result message to ensure all initialization
* (control responses, MCP server setup, etc.) is complete before closing stdin.
* This timeout is a fallback to avoid hanging indefinitely.
* @default 60000 (1 minute)
*/
streamClose?: number;
};
}

View File

@@ -22,7 +22,7 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
export class SdkLogger {
private static config: LoggerConfig = {};
private static effectiveLevel: LogLevel = 'info';
private static effectiveLevel: LogLevel = 'error';
static configure(config: LoggerConfig): void {
this.config = config;
@@ -47,7 +47,7 @@ export class SdkLogger {
return 'debug';
}
return 'info';
return 'error';
}
private static isValidLogLevel(level: string): boolean {

View File

@@ -542,13 +542,16 @@ describe('Query', () => {
const canUseTool = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ behavior: 'allow' }), 35000); // Exceeds 30s timeout
setTimeout(() => resolve({ behavior: 'allow' }), 15000);
}),
);
const query = new Query(transport, {
cwd: '/test',
canUseTool,
timeout: {
canUseTool: 10000,
},
});
const controlReq = createControlRequest('can_use_tool', 'perm-req-4');
@@ -567,7 +570,7 @@ describe('Query', () => {
});
}
},
{ timeout: 35000 },
{ timeout: 15000 },
);
await query.close();
@@ -1204,7 +1207,12 @@ describe('Query', () => {
});
it('should handle control request timeout', async () => {
const query = new Query(transport, { cwd: '/test' });
const query = new Query(transport, {
cwd: '/test',
timeout: {
controlRequest: 10000,
},
});
// Respond to initialize
await vi.waitFor(() => {
@@ -1224,7 +1232,7 @@ describe('Query', () => {
await expect(interruptPromise).rejects.toThrow(/timeout/i);
await query.close();
}, 35000);
}, 15000);
it('should handle malformed control responses', async () => {
const query = new Query(transport, { cwd: '/test' });

View File

@@ -1,3 +1,9 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Unit tests for createSdkMcpServer
*
@@ -5,93 +11,112 @@
*/
import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { createSdkMcpServer } from '../../src/mcp/createSdkMcpServer.js';
import { tool } from '../../src/mcp/tool.js';
import type { ToolDefinition } from '../../src/types/config.js';
import type { SdkMcpToolDefinition } from '../../src/mcp/tool.js';
describe('createSdkMcpServer', () => {
describe('Server Creation', () => {
it('should create server with name and version', () => {
const server = createSdkMcpServer('test-server', '1.0.0', []);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [],
});
expect(server).toBeDefined();
expect(server.type).toBe('sdk');
expect(server.name).toBe('test-server');
expect(server.instance).toBeDefined();
});
it('should create server with default version', () => {
const server = createSdkMcpServer({
name: 'test-server',
});
expect(server).toBeDefined();
expect(server.name).toBe('test-server');
});
it('should throw error with invalid name', () => {
expect(() => createSdkMcpServer('', '1.0.0', [])).toThrow(
'name must be a non-empty string',
expect(() => createSdkMcpServer({ name: '', version: '1.0.0' })).toThrow(
'MCP server name must be a non-empty string',
);
});
it('should throw error with invalid version', () => {
expect(() => createSdkMcpServer('test', '', [])).toThrow(
'version must be a non-empty string',
expect(() => createSdkMcpServer({ name: 'test', version: '' })).toThrow(
'MCP server version must be a non-empty string',
);
});
it('should throw error with non-array tools', () => {
expect(() =>
createSdkMcpServer('test', '1.0.0', {} as unknown as ToolDefinition[]),
createSdkMcpServer({
name: 'test',
version: '1.0.0',
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
tools: {} as unknown as SdkMcpToolDefinition<any>[],
}),
).toThrow('Tools must be an array');
});
});
describe('Tool Registration', () => {
it('should register single tool', () => {
const testTool = tool({
name: 'test_tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {
input: { type: 'string' },
},
},
handler: async () => 'result',
});
const testTool = tool(
'test_tool',
'A test tool',
{ input: z.string() },
async () => ({
content: [{ type: 'text', text: 'result' }],
}),
);
const server = createSdkMcpServer('test-server', '1.0.0', [testTool]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [testTool],
});
expect(server).toBeDefined();
});
it('should register multiple tools', () => {
const tool1 = tool({
name: 'tool1',
description: 'Tool 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool1 = tool('tool1', 'Tool 1', {}, async () => ({
content: [{ type: 'text', text: 'result1' }],
}));
const tool2 = tool({
name: 'tool2',
description: 'Tool 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
const tool2 = tool('tool2', 'Tool 2', {}, async () => ({
content: [{ type: 'text', text: 'result2' }],
}));
const server = createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [tool1, tool2],
});
expect(server).toBeDefined();
});
it('should throw error for duplicate tool names', () => {
const tool1 = tool({
name: 'duplicate',
description: 'Tool 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool1 = tool('duplicate', 'Tool 1', {}, async () => ({
content: [{ type: 'text', text: 'result1' }],
}));
const tool2 = tool({
name: 'duplicate',
description: 'Tool 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
const tool2 = tool('duplicate', 'Tool 2', {}, async () => ({
content: [{ type: 'text', text: 'result2' }],
}));
expect(() =>
createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]),
createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [tool1, tool2],
}),
).toThrow("Duplicate tool name 'duplicate'");
});
@@ -99,36 +124,41 @@ describe('createSdkMcpServer', () => {
const invalidTool = {
name: '123invalid', // Starts with number
description: 'Invalid tool',
inputSchema: { type: 'object' },
handler: async () => 'result',
inputSchema: {},
handler: async () => ({
content: [{ type: 'text' as const, text: 'result' }],
}),
};
expect(() =>
createSdkMcpServer('test-server', '1.0.0', [
invalidTool as unknown as ToolDefinition,
]),
createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
tools: [invalidTool as unknown as SdkMcpToolDefinition<any>],
}),
).toThrow('Tool name');
});
});
describe('Tool Handler Invocation', () => {
it('should invoke tool handler with correct input', async () => {
const handler = vi.fn().mockResolvedValue({ result: 'success' });
const testTool = tool({
name: 'test_tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {
value: { type: 'string' },
},
required: ['value'],
},
handler,
const handler = vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'success' }],
});
createSdkMcpServer('test-server', '1.0.0', [testTool]);
const testTool = tool(
'test_tool',
'A test tool',
{ value: z.string() },
handler,
);
createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [testTool],
});
// Note: Actual invocation testing requires MCP SDK integration
// This test verifies the handler was properly registered
@@ -140,17 +170,18 @@ describe('createSdkMcpServer', () => {
.fn()
.mockImplementation(async (input: { value: string }) => {
await new Promise((resolve) => setTimeout(resolve, 10));
return { processed: input.value };
return {
content: [{ type: 'text', text: `processed: ${input.value}` }],
};
});
const testTool = tool({
name: 'async_tool',
description: 'An async tool',
inputSchema: { type: 'object' },
handler,
});
const testTool = tool('async_tool', 'An async tool', {}, handler);
const server = createSdkMcpServer('test-server', '1.0.0', [testTool]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [testTool],
});
expect(server).toBeDefined();
});
@@ -158,40 +189,29 @@ describe('createSdkMcpServer', () => {
describe('Type Safety', () => {
it('should preserve input type in handler', async () => {
type ToolInput = {
name: string;
age: number;
};
type ToolOutput = {
greeting: string;
};
const handler = vi
.fn()
.mockImplementation(async (input: ToolInput): Promise<ToolOutput> => {
return {
greeting: `Hello ${input.name}, age ${input.age}`,
};
});
const typedTool = tool<ToolInput, ToolOutput>({
name: 'typed_tool',
description: 'A typed tool',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name', 'age'],
},
handler,
const handler = vi.fn().mockImplementation(async (input) => {
return {
content: [
{ type: 'text', text: `Hello ${input.name}, age ${input.age}` },
],
};
});
const server = createSdkMcpServer('test-server', '1.0.0', [
typedTool as ToolDefinition,
]);
const typedTool = tool(
'typed_tool',
'A typed tool',
{
name: z.string(),
age: z.number(),
},
handler,
);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [typedTool],
});
expect(server).toBeDefined();
});
@@ -201,14 +221,13 @@ describe('createSdkMcpServer', () => {
it('should handle tool handler errors gracefully', async () => {
const handler = vi.fn().mockRejectedValue(new Error('Tool failed'));
const errorTool = tool({
name: 'error_tool',
description: 'A tool that errors',
inputSchema: { type: 'object' },
handler,
});
const errorTool = tool('error_tool', 'A tool that errors', {}, handler);
const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [errorTool],
});
expect(server).toBeDefined();
// Error handling occurs during tool invocation
@@ -219,14 +238,18 @@ describe('createSdkMcpServer', () => {
throw new Error('Sync error');
});
const errorTool = tool({
name: 'sync_error_tool',
description: 'A tool that errors synchronously',
inputSchema: { type: 'object' },
const errorTool = tool(
'sync_error_tool',
'A tool that errors synchronously',
{},
handler,
});
);
const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [errorTool],
});
expect(server).toBeDefined();
});
@@ -234,69 +257,76 @@ describe('createSdkMcpServer', () => {
describe('Complex Tool Scenarios', () => {
it('should support tool with complex input schema', () => {
const complexTool = tool({
name: 'complex_tool',
description: 'A tool with complex schema',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
filters: {
type: 'object',
properties: {
category: { type: 'string' },
minPrice: { type: 'number' },
},
},
options: {
type: 'array',
items: { type: 'string' },
},
},
required: ['query'],
const complexTool = tool(
'complex_tool',
'A tool with complex schema',
{
query: z.string(),
filters: z
.object({
category: z.string().optional(),
minPrice: z.number().optional(),
})
.optional(),
options: z.array(z.string()).optional(),
},
handler: async (input: { filters?: unknown[] }) => {
async (input) => {
return {
results: [],
filters: input.filters,
content: [
{
type: 'text',
text: JSON.stringify({ results: [], filters: input.filters }),
},
],
};
},
});
);
const server = createSdkMcpServer('test-server', '1.0.0', [
complexTool as ToolDefinition,
]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [complexTool],
});
expect(server).toBeDefined();
});
it('should support tool returning complex output', () => {
const complexOutputTool = tool({
name: 'complex_output_tool',
description: 'Returns complex data',
inputSchema: { type: 'object' },
handler: async () => {
const complexOutputTool = tool(
'complex_output_tool',
'Returns complex data',
{},
async () => {
return {
data: [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
],
metadata: {
total: 2,
page: 1,
},
nested: {
deep: {
value: 'test',
content: [
{
type: 'text',
text: JSON.stringify({
data: [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
],
metadata: {
total: 2,
page: 1,
},
nested: {
deep: {
value: 'test',
},
},
}),
},
},
],
};
},
});
);
const server = createSdkMcpServer('test-server', '1.0.0', [
complexOutputTool,
]);
const server = createSdkMcpServer({
name: 'test-server',
version: '1.0.0',
tools: [complexOutputTool],
});
expect(server).toBeDefined();
});
@@ -304,44 +334,50 @@ describe('createSdkMcpServer', () => {
describe('Multiple Servers', () => {
it('should create multiple independent servers', () => {
const tool1 = tool({
name: 'tool1',
description: 'Tool in server 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool1 = tool('tool1', 'Tool in server 1', {}, async () => ({
content: [{ type: 'text', text: 'result1' }],
}));
const tool2 = tool({
name: 'tool2',
description: 'Tool in server 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
const tool2 = tool('tool2', 'Tool in server 2', {}, async () => ({
content: [{ type: 'text', text: 'result2' }],
}));
const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]);
const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]);
const server1 = createSdkMcpServer({
name: 'server1',
version: '1.0.0',
tools: [tool1],
});
const server2 = createSdkMcpServer({
name: 'server2',
version: '1.0.0',
tools: [tool2],
});
expect(server1).toBeDefined();
expect(server2).toBeDefined();
expect(server1.name).toBe('server1');
expect(server2.name).toBe('server2');
});
it('should allow same tool name in different servers', () => {
const tool1 = tool({
name: 'shared_name',
description: 'Tool in server 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool1 = tool('shared_name', 'Tool in server 1', {}, async () => ({
content: [{ type: 'text', text: 'result1' }],
}));
const tool2 = tool({
name: 'shared_name',
description: 'Tool in server 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
const tool2 = tool('shared_name', 'Tool in server 2', {}, async () => ({
content: [{ type: 'text', text: 'result2' }],
}));
const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]);
const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]);
const server1 = createSdkMcpServer({
name: 'server1',
version: '1.0.0',
tools: [tool1],
});
const server2 = createSdkMcpServer({
name: 'server2',
version: '1.0.0',
tools: [tool2],
});
expect(server1).toBeDefined();
expect(server2).toBeDefined();

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.4.0",
"version": "0.4.1",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -1,5 +1,26 @@
This file contains third-party software notices and license terms.
============================================================
semver@7.7.2
(git+https://github.com/npm/node-semver.git)
The ISC License
Copyright (c) Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
============================================================
@modelcontextprotocol/sdk@1.15.1
(git+https://github.com/modelcontextprotocol/typescript-sdk.git)
@@ -2317,3 +2338,520 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
============================================================
markdown-it@14.1.0
(No repository found)
Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
============================================================
argparse@2.0.1
(No repository found)
A. HISTORY OF THE SOFTWARE
==========================
Python was created in the early 1990s by Guido van Rossum at Stichting
Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands
as a successor of a language called ABC. Guido remains Python's
principal author, although it includes many contributions from others.
In 1995, Guido continued his work on Python at the Corporation for
National Research Initiatives (CNRI, see http://www.cnri.reston.va.us)
in Reston, Virginia where he released several versions of the
software.
In May 2000, Guido and the Python core development team moved to
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
year, the PythonLabs team moved to Digital Creations, which became
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
https://www.python.org/psf/) was formed, a non-profit organization
created specifically to own Python-related Intellectual Property.
Zope Corporation was a sponsoring member of the PSF.
All Python releases are Open Source (see http://www.opensource.org for
the Open Source Definition). Historically, most, but not all, Python
releases have also been GPL-compatible; the table below summarizes
the various releases.
Release Derived Year Owner GPL-
from compatible? (1)
0.9.0 thru 1.2 1991-1995 CWI yes
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
1.6 1.5.2 2000 CNRI no
2.0 1.6 2000 BeOpen.com no
1.6.1 1.6 2001 CNRI yes (2)
2.1 2.0+1.6.1 2001 PSF no
2.0.1 2.0+1.6.1 2001 PSF yes
2.1.1 2.1+2.0.1 2001 PSF yes
2.1.2 2.1.1 2002 PSF yes
2.1.3 2.1.2 2002 PSF yes
2.2 and above 2.1.1 2001-now PSF yes
Footnotes:
(1) GPL-compatible doesn't mean that we're distributing Python under
the GPL. All Python licenses, unlike the GPL, let you distribute
a modified version without making your changes open source. The
GPL-compatible licenses make it possible to combine Python with
other software that is released under the GPL; the others don't.
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
because its license has a choice of law clause. According to
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
is "not incompatible" with the GPL.
Thanks to the many outside volunteers who have worked under Guido's
direction to make these releases possible.
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
===============================================================
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
--------------------------------------------
1. This LICENSE AGREEMENT is between the Python Software Foundation
("PSF"), and the Individual or Organization ("Licensee") accessing and
otherwise using this software ("Python") in source or binary form and
its associated documentation.
2. Subject to the terms and conditions of this License Agreement, PSF hereby
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
analyze, test, perform and/or display publicly, prepare derivative works,
distribute, and otherwise use Python alone or in any derivative version,
provided, however, that PSF's License Agreement and PSF's notice of copyright,
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation;
All Rights Reserved" are retained in Python alone or in any derivative version
prepared by Licensee.
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python.
4. PSF is making Python available to Licensee on an "AS IS"
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. Nothing in this License Agreement shall be deemed to create any
relationship of agency, partnership, or joint venture between PSF and
Licensee. This License Agreement does not grant permission to use PSF
trademarks or trade name in a trademark sense to endorse or promote
products or services of Licensee, or any third party.
8. By copying, installing or otherwise using Python, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
-------------------------------------------
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
Individual or Organization ("Licensee") accessing and otherwise using
this software in source or binary form and its associated
documentation ("the Software").
2. Subject to the terms and conditions of this BeOpen Python License
Agreement, BeOpen hereby grants Licensee a non-exclusive,
royalty-free, world-wide license to reproduce, analyze, test, perform
and/or display publicly, prepare derivative works, distribute, and
otherwise use the Software alone or in any derivative version,
provided, however, that the BeOpen Python License is retained in the
Software, alone or in any derivative version prepared by Licensee.
3. BeOpen is making the Software available to Licensee on an "AS IS"
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
5. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
6. This License Agreement shall be governed by and interpreted in all
respects by the law of the State of California, excluding conflict of
law provisions. Nothing in this License Agreement shall be deemed to
create any relationship of agency, partnership, or joint venture
between BeOpen and Licensee. This License Agreement does not grant
permission to use BeOpen trademarks or trade names in a trademark
sense to endorse or promote products or services of Licensee, or any
third party. As an exception, the "BeOpen Python" logos available at
http://www.pythonlabs.com/logos.html may be used according to the
permissions granted on that web page.
7. By copying, installing or otherwise using the software, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
---------------------------------------
1. This LICENSE AGREEMENT is between the Corporation for National
Research Initiatives, having an office at 1895 Preston White Drive,
Reston, VA 20191 ("CNRI"), and the Individual or Organization
("Licensee") accessing and otherwise using Python 1.6.1 software in
source or binary form and its associated documentation.
2. Subject to the terms and conditions of this License Agreement, CNRI
hereby grants Licensee a nonexclusive, royalty-free, world-wide
license to reproduce, analyze, test, perform and/or display publicly,
prepare derivative works, distribute, and otherwise use Python 1.6.1
alone or in any derivative version, provided, however, that CNRI's
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
1995-2001 Corporation for National Research Initiatives; All Rights
Reserved" are retained in Python 1.6.1 alone or in any derivative
version prepared by Licensee. Alternately, in lieu of CNRI's License
Agreement, Licensee may substitute the following text (omitting the
quotes): "Python 1.6.1 is made available subject to the terms and
conditions in CNRI's License Agreement. This Agreement together with
Python 1.6.1 may be located on the Internet using the following
unique, persistent identifier (known as a handle): 1895.22/1013. This
Agreement may also be obtained from a proxy server on the Internet
using the following URL: http://hdl.handle.net/1895.22/1013".
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python 1.6.1 or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python 1.6.1.
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. This License Agreement shall be governed by the federal
intellectual property law of the United States, including without
limitation the federal copyright law, and, to the extent such
U.S. federal law does not apply, by the law of the Commonwealth of
Virginia, excluding Virginia's conflict of law provisions.
Notwithstanding the foregoing, with regard to derivative works based
on Python 1.6.1 that incorporate non-separable material that was
previously distributed under the GNU General Public License (GPL), the
law of the Commonwealth of Virginia shall govern this License
Agreement only as to issues arising under or with respect to
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
License Agreement shall be deemed to create any relationship of
agency, partnership, or joint venture between CNRI and Licensee. This
License Agreement does not grant permission to use CNRI trademarks or
trade name in a trademark sense to endorse or promote products or
services of Licensee, or any third party.
8. By clicking on the "ACCEPT" button where indicated, or by copying,
installing or otherwise using Python 1.6.1, Licensee agrees to be
bound by the terms and conditions of this License Agreement.
ACCEPT
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
--------------------------------------------------
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
The Netherlands. All rights reserved.
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee is hereby granted,
provided that the above copyright notice appear in all copies and that
both that copyright notice and this permission notice appear in
supporting documentation, and that the name of Stichting Mathematisch
Centrum or CWI not be used in advertising or publicity pertaining to
distribution of the software without specific, written prior
permission.
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
============================================================
entities@4.5.0
(git://github.com/fb55/entities.git)
Copyright (c) Felix Böhm
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
============================================================
linkify-it@5.0.0
(No repository found)
Copyright (c) 2015 Vitaly Puzrin.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
============================================================
uc.micro@2.1.0
(No repository found)
Copyright Mathias Bynens <https://mathiasbynens.be/>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
mdurl@2.0.0
(No repository found)
Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
.parse() is based on Joyent's node.js `url` code:
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
============================================================
punycode.js@2.3.1
(https://github.com/mathiasbynens/punycode.js.git)
Copyright Mathias Bynens <https://mathiasbynens.be/>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
react@19.1.0
(https://github.com/facebook/react.git)
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
react-dom@19.1.0
(https://github.com/facebook/react.git)
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
scheduler@0.26.0
(https://github.com/facebook/react.git)
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -19,6 +19,63 @@ To use this extension, you'll need:
- VS Code version 1.101.0 or newer
- Qwen Code (installed separately) running within the VS Code integrated terminal
# Development and Debugging
To debug and develop this extension locally:
1. **Clone the repository**
```bash
git clone https://github.com/QwenLM/qwen-code.git
cd qwen-code
```
2. **Install dependencies**
```bash
npm install
# or if using pnpm
pnpm install
```
3. **Start debugging**
```bash
code . # Open the project root in VS Code
```
- Open the `packages/vscode-ide-companion/src/extension.ts` file
- Open Debug panel (`Ctrl+Shift+D` or `Cmd+Shift+D`)
- Select **"Launch Companion VS Code Extension"** from the debug dropdown
- Press `F5` to launch Extension Development Host
4. **Make changes and reload**
- Edit the source code in the original VS Code window
- To see your changes, reload the Extension Development Host window by:
- Pressing `Ctrl+R` (Windows/Linux) or `Cmd+R` (macOS)
- Or clicking the "Reload" button in the debug toolbar
5. **View logs and debug output**
- Open the Debug Console in the original VS Code window to see extension logs
- In the Extension Development Host window, open Developer Tools with `Help > Toggle Developer Tools` to see webview logs
## Build for Production
To build the extension for distribution:
```bash
npm run compile
# or
pnpm run compile
```
To package the extension as a VSIX file:
```bash
npx vsce package
# or
pnpm vsce package
```
# Terms of Service and Privacy Notice
By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md).

View File

@@ -31,8 +31,69 @@ const esbuildProblemMatcherPlugin = {
},
};
/**
* @type {import('esbuild').Plugin}
*/
const cssInjectPlugin = {
name: 'css-inject',
setup(build) {
// Handle CSS files
build.onLoad({ filter: /\.css$/ }, async (args) => {
const fs = await import('fs');
const postcss = (await import('postcss')).default;
const tailwindcss = (await import('tailwindcss')).default;
const autoprefixer = (await import('autoprefixer')).default;
let css = await fs.promises.readFile(args.path, 'utf8');
// For styles.css, we need to resolve @import statements
if (args.path.endsWith('styles.css')) {
// Read all imported CSS files and inline them
const importRegex = /@import\s+'([^']+)';/g;
let match;
const basePath = args.path.substring(0, args.path.lastIndexOf('/'));
while ((match = importRegex.exec(css)) !== null) {
const importPath = match[1];
// Resolve relative paths correctly
let fullPath;
if (importPath.startsWith('./')) {
fullPath = basePath + importPath.substring(1);
} else if (importPath.startsWith('../')) {
fullPath = basePath + '/' + importPath;
} else {
fullPath = basePath + '/' + importPath;
}
try {
const importedCss = await fs.promises.readFile(fullPath, 'utf8');
css = css.replace(match[0], importedCss);
} catch (err) {
console.warn(`Could not import ${fullPath}: ${err.message}`);
}
}
}
// Process with PostCSS (Tailwind + Autoprefixer)
const result = await postcss([tailwindcss, autoprefixer]).process(css, {
from: args.path,
to: args.path,
});
return {
contents: `
const style = document.createElement('style');
style.textContent = ${JSON.stringify(result.css)};
document.head.appendChild(style);
`,
loader: 'js',
};
});
},
};
async function main() {
const ctx = await esbuild.context({
// Build extension
const extensionCtx = await esbuild.context({
entryPoints: ['src/extension.ts'],
bundle: true,
format: 'cjs',
@@ -55,11 +116,30 @@ async function main() {
],
loader: { '.node': 'file' },
});
// Build webview
const webviewCtx = await esbuild.context({
entryPoints: ['src/webview/index.tsx'],
bundle: true,
format: 'iife',
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: 'browser',
outfile: 'dist/webview.js',
logLevel: 'silent',
plugins: [cssInjectPlugin, esbuildProblemMatcherPlugin],
jsx: 'automatic', // Use new JSX transform (React 17+)
define: {
'process.env.NODE_ENV': production ? '"production"' : '"development"',
},
});
if (watch) {
await ctx.watch();
await Promise.all([extensionCtx.watch(), webviewCtx.watch()]);
} else {
await ctx.rebuild();
await ctx.dispose();
await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]);
await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]);
}
}

View File

@@ -6,20 +6,44 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
import importPlugin from 'eslint-plugin-import';
export default [
{
files: ['**/*.ts'],
files: ['**/*.ts', '**/*.tsx'],
},
{
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
module: 'readonly',
require: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
process: 'readonly',
console: 'readonly',
},
},
},
{
plugins: {
'@typescript-eslint': typescriptEslint,
'react-hooks': reactHooks,
import: importPlugin,
},
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
@@ -30,6 +54,17 @@ export default [
format: ['camelCase', 'PascalCase'],
},
],
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// Restrict deep imports but allow known-safe exceptions used by the webview
// - react-dom/client: required for React 18's createRoot API
// - ./styles/**: local CSS modules loaded by the webview
'import/no-internal-modules': [
'error',
{
allow: ['react-dom/client', './styles/**'],
},
],
curly: 'warn',
eqeqeq: 'warn',

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.4.0",
"version": "0.4.1",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {
@@ -54,6 +54,15 @@
{
"command": "qwen-code.showNotices",
"title": "Qwen Code: View Third-Party Notices"
},
{
"command": "qwen-code.openChat",
"title": "Qwen Code: Open",
"icon": "./assets/icon.png"
},
{
"command": "qwen-code.login",
"title": "Qwen Code: Login"
}
],
"menus": {
@@ -65,6 +74,10 @@
{
"command": "qwen.diff.cancel",
"when": "qwen.diff.isVisible"
},
{
"command": "qwen-code.login",
"when": "false"
}
],
"editor/title": [
@@ -77,6 +90,10 @@
"command": "qwen.diff.cancel",
"when": "qwen.diff.isVisible",
"group": "navigation"
},
{
"command": "qwen-code.openChat",
"group": "navigation"
}
]
},
@@ -115,21 +132,33 @@
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/markdown-it": "^14.1.2",
"@types/node": "20.x",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.1",
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0",
"autoprefixer": "^10.4.22",
"esbuild": "^0.25.3",
"eslint": "^9.25.1",
"eslint-plugin-react-hooks": "^5.2.0",
"npm-run-all2": "^8.0.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"dependencies": {
"semver": "^7.7.2",
"@modelcontextprotocol/sdk": "^1.15.1",
"cors": "^2.8.5",
"express": "^5.1.0",
"markdown-it": "^14.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zod": "^3.25.76"
}
}

View File

@@ -0,0 +1,13 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable no-undef */
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { CliFeatureFlags, CliVersionInfo } from './cliVersionManager.js';
export class CliContextManager {
private static instance: CliContextManager;
private currentVersionInfo: CliVersionInfo | null = null;
private constructor() {}
/**
* Get singleton instance
*/
static getInstance(): CliContextManager {
if (!CliContextManager.instance) {
CliContextManager.instance = new CliContextManager();
}
return CliContextManager.instance;
}
/**
* Set current CLI version information
*
* @param versionInfo - CLI version information
*/
setCurrentVersionInfo(versionInfo: CliVersionInfo): void {
this.currentVersionInfo = versionInfo;
}
/**
* Get current CLI feature flags
*
* @returns Current CLI feature flags or default flags if not set
*/
getCurrentFeatures(): CliFeatureFlags {
if (this.currentVersionInfo) {
return this.currentVersionInfo.features;
}
// Return default feature flags (all disabled)
return {
supportsSessionList: false,
supportsSessionLoad: false,
};
}
supportsSessionList(): boolean {
return this.getCurrentFeatures().supportsSessionList;
}
supportsSessionLoad(): boolean {
return this.getCurrentFeatures().supportsSessionLoad;
}
}

View File

@@ -0,0 +1,215 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export interface CliDetectionResult {
isInstalled: boolean;
cliPath?: string;
version?: string;
error?: string;
}
/**
* Detects if Qwen Code CLI is installed and accessible
*/
export class CliDetector {
private static cachedResult: CliDetectionResult | null = null;
private static lastCheckTime: number = 0;
private static readonly CACHE_DURATION_MS = 30000; // 30 seconds
/**
* Checks if the Qwen Code CLI is installed
* @param forceRefresh - Force a new check, ignoring cache
* @returns Detection result with installation status and details
*/
static async detectQwenCli(
forceRefresh = false,
): Promise<CliDetectionResult> {
const now = Date.now();
// Return cached result if available and not expired
if (
!forceRefresh &&
this.cachedResult &&
now - this.lastCheckTime < this.CACHE_DURATION_MS
) {
console.log('[CliDetector] Returning cached result');
return this.cachedResult;
}
console.log(
'[CliDetector] Starting CLI detection, current PATH:',
process.env.PATH,
);
try {
const isWindows = process.platform === 'win32';
const whichCommand = isWindows ? 'where' : 'which';
// Check if qwen command exists
try {
// Use NVM environment for consistent detection
// Fallback chain: default alias -> node alias -> current version
const detectionCommand =
process.platform === 'win32'
? `${whichCommand} qwen`
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); which qwen';
console.log(
'[CliDetector] Detecting CLI with command:',
detectionCommand,
);
const { stdout } = await execAsync(detectionCommand, {
timeout: 5000,
shell: '/bin/bash',
});
// The output may contain multiple lines, with NVM activation messages
// We want the last line which should be the actual path
const lines = stdout
.trim()
.split('\n')
.filter((line) => line.trim());
const cliPath = lines[lines.length - 1];
console.log('[CliDetector] Found CLI at:', cliPath);
// Try to get version
let version: string | undefined;
try {
// Use NVM environment for version check
// Fallback chain: default alias -> node alias -> current version
// Also ensure we use the correct Node.js version that matches the CLI installation
const versionCommand =
process.platform === 'win32'
? 'qwen --version'
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); qwen --version';
console.log(
'[CliDetector] Getting version with command:',
versionCommand,
);
const { stdout: versionOutput } = await execAsync(versionCommand, {
timeout: 5000,
shell: '/bin/bash',
});
// The output may contain multiple lines, with NVM activation messages
// We want the last line which should be the actual version
const versionLines = versionOutput
.trim()
.split('\n')
.filter((line) => line.trim());
version = versionLines[versionLines.length - 1];
console.log('[CliDetector] CLI version:', version);
} catch (versionError) {
console.log('[CliDetector] Failed to get CLI version:', versionError);
// Version check failed, but CLI is installed
}
this.cachedResult = {
isInstalled: true,
cliPath,
version,
};
this.lastCheckTime = now;
return this.cachedResult;
} catch (detectionError) {
console.log('[CliDetector] CLI not found, error:', detectionError);
// CLI not found
let error = `Qwen Code CLI not found in PATH. Please install it using: npm install -g @qwen-code/qwen-code@latest`;
// Provide specific guidance for permission errors
if (detectionError instanceof Error) {
const errorMessage = detectionError.message;
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
error += `\n\nThis may be due to permission issues. Possible solutions:
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Check your PATH environment variable includes npm's global bin directory`;
}
}
this.cachedResult = {
isInstalled: false,
error,
};
this.lastCheckTime = now;
return this.cachedResult;
}
} catch (error) {
console.log('[CliDetector] General detection error:', error);
const errorMessage =
error instanceof Error ? error.message : String(error);
let userFriendlyError = `Failed to detect Qwen Code CLI: ${errorMessage}`;
// Provide specific guidance for permission errors
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
userFriendlyError += `\n\nThis may be due to permission issues. Possible solutions:
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Check your PATH environment variable includes npm's global bin directory`;
}
this.cachedResult = {
isInstalled: false,
error: userFriendlyError,
};
this.lastCheckTime = now;
return this.cachedResult;
}
}
/**
* Clears the cached detection result
*/
static clearCache(): void {
this.cachedResult = null;
this.lastCheckTime = 0;
}
/**
* Gets installation instructions based on the platform
*/
static getInstallationInstructions(): {
title: string;
steps: string[];
documentationUrl: string;
} {
return {
title: 'Qwen Code CLI is not installed',
steps: [
'Install via npm:',
' npm install -g @qwen-code/qwen-code@latest',
'',
'If you are using nvm (automatically handled by the plugin):',
' The plugin will automatically use your default nvm version',
'',
'Or install from source:',
' git clone https://github.com/QwenLM/qwen-code.git',
' cd qwen-code',
' npm install',
' npm install -g .',
'',
'After installation, reload VS Code or restart the extension.',
],
documentationUrl: 'https://github.com/QwenLM/qwen-code#installation',
};
}
}

View File

@@ -0,0 +1,225 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { CliDetector } from './cliDetector.js';
/**
* CLI Detection and Installation Handler
* Responsible for detecting, installing, and prompting for Qwen CLI
*/
export class CliInstaller {
/**
* Check CLI installation status and send results to WebView
* @param sendToWebView Callback function to send messages to WebView
*/
static async checkInstallation(
sendToWebView: (message: unknown) => void,
): Promise<void> {
try {
const result = await CliDetector.detectQwenCli();
sendToWebView({
type: 'cliDetectionResult',
data: {
isInstalled: result.isInstalled,
cliPath: result.cliPath,
version: result.version,
error: result.error,
installInstructions: result.isInstalled
? undefined
: CliDetector.getInstallationInstructions(),
},
});
if (!result.isInstalled) {
console.log('[CliInstaller] Qwen CLI not detected:', result.error);
} else {
console.log(
'[CliInstaller] Qwen CLI detected:',
result.cliPath,
result.version,
);
}
} catch (error) {
console.error('[CliInstaller] CLI detection error:', error);
}
}
/**
* Prompt user to install CLI
* Display warning message with installation options
*/
static async promptInstallation(): Promise<void> {
const selection = await vscode.window.showWarningMessage(
'Qwen Code CLI is not installed. You can browse conversation history, but cannot send new messages.',
'Install Now',
'View Documentation',
'Remind Me Later',
);
if (selection === 'Install Now') {
await this.install();
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse('https://github.com/QwenLM/qwen-code#installation'),
);
}
}
/**
* Install Qwen CLI
* Install global CLI package via npm
*/
static async install(): Promise<void> {
try {
// Show progress notification
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Installing Qwen Code CLI',
cancellable: false,
},
async (progress) => {
progress.report({
message: 'Running: npm install -g @qwen-code/qwen-code@latest',
});
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
try {
// Use NVM environment to ensure we get the same Node.js version
// as when they run 'node -v' in terminal
// Fallback chain: default alias -> node alias -> current version
const installCommand =
process.platform === 'win32'
? 'npm install -g @qwen-code/qwen-code@latest'
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); npm install -g @qwen-code/qwen-code@latest';
console.log(
'[CliInstaller] Installing with command:',
installCommand,
);
console.log(
'[CliInstaller] Current process PATH:',
process.env['PATH'],
);
// Also log Node.js version being used by VS Code
console.log(
'[CliInstaller] VS Code Node.js version:',
process.version,
);
console.log(
'[CliInstaller] VS Code Node.js execPath:',
process.execPath,
);
const { stdout, stderr } = await execAsync(
installCommand,
{
timeout: 120000,
shell: '/bin/bash',
}, // 2 minutes timeout
);
console.log('[CliInstaller] Installation output:', stdout);
if (stderr) {
console.warn('[CliInstaller] Installation stderr:', stderr);
}
// Clear cache and recheck
CliDetector.clearCache();
const detection = await CliDetector.detectQwenCli();
if (detection.isInstalled) {
vscode.window
.showInformationMessage(
`✅ Qwen Code CLI installed successfully! Version: ${detection.version}`,
'Reload Window',
)
.then((selection) => {
if (selection === 'Reload Window') {
vscode.commands.executeCommand(
'workbench.action.reloadWindow',
);
}
});
} else {
throw new Error(
'Installation completed but CLI still not detected',
);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error('[CliInstaller] Installation failed:', errorMessage);
console.error('[CliInstaller] Error stack:', error);
// Provide specific guidance for permission errors
let userFriendlyMessage = `Failed to install Qwen Code CLI: ${errorMessage}`;
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
userFriendlyMessage += `\n\nThis is likely due to permission issues. Possible solutions:
\n1. Reinstall without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. Fix npm permissions: sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Configure npm to use a different directory: npm config set prefix ~/.npm-global`;
}
vscode.window
.showErrorMessage(
userFriendlyMessage,
'Try Manual Installation',
'View Documentation',
)
.then((selection) => {
if (selection === 'Try Manual Installation') {
const terminal = vscode.window.createTerminal(
'Qwen Code Installation',
);
terminal.show();
// Provide different installation commands based on error type
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
terminal.sendText('# Try installing without sudo:');
terminal.sendText(
'npm install -g @qwen-code/qwen-code@latest',
);
terminal.sendText('');
terminal.sendText('# Or fix npm permissions:');
terminal.sendText(
'sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}',
);
} else {
terminal.sendText(
'npm install -g @qwen-code/qwen-code@latest',
);
}
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse(
'https://github.com/QwenLM/qwen-code#installation',
),
);
}
});
}
},
);
} catch (error) {
console.error('[CliInstaller] Install CLI error:', error);
}
}
}

View File

@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { statSync } from 'fs';
export interface CliPathDetectionResult {
path: string | null;
error?: string;
}
/**
* Determine the correct Node.js executable path for a given CLI installation
* Handles various Node.js version managers (nvm, n, manual installations)
*
* @param cliPath - Path to the CLI executable
* @returns Path to the Node.js executable, or null if not found
*/
export function determineNodePathForCli(
cliPath: string,
): CliPathDetectionResult {
// Common patterns for Node.js installations
const nodePathPatterns = [
// NVM pattern: /Users/user/.nvm/versions/node/vXX.XX.X/bin/qwen -> /Users/user/.nvm/versions/node/vXX.XX.X/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
// N pattern: /Users/user/n/bin/qwen -> /Users/user/n/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
// Manual installation pattern: /usr/local/bin/qwen -> /usr/local/bin/node
cliPath.replace(/\/qwen$/, '/node'),
// Alternative pattern: /opt/nodejs/bin/qwen -> /opt/nodejs/bin/node
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
];
// Check each pattern
for (const nodePath of nodePathPatterns) {
try {
const stats = statSync(nodePath);
if (stats.isFile()) {
// Verify it's executable
if (stats.mode & 0o111) {
console.log(`[CLI] Found Node.js executable for CLI at: ${nodePath}`);
return { path: nodePath };
} else {
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
return {
path: null,
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
};
}
}
} catch (error) {
// Differentiate between error types
if (error instanceof Error) {
if ('code' in error && error.code === 'EACCES') {
console.log(`[CLI] Permission denied accessing ${nodePath}`);
return {
path: null,
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
};
} else if ('code' in error && error.code === 'ENOENT') {
// File not found, continue to next pattern
continue;
} else {
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
return {
path: null,
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
};
}
}
}
}
// Try to find node in the same directory as the CLI
const cliDir = cliPath.substring(0, cliPath.lastIndexOf('/'));
const potentialNodePaths = [`${cliDir}/node`, `${cliDir}/bin/node`];
for (const nodePath of potentialNodePaths) {
try {
const stats = statSync(nodePath);
if (stats.isFile()) {
if (stats.mode & 0o111) {
console.log(
`[CLI] Found Node.js executable in CLI directory at: ${nodePath}`,
);
return { path: nodePath };
} else {
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
return {
path: null,
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
};
}
}
} catch (error) {
// Differentiate between error types
if (error instanceof Error) {
if ('code' in error && error.code === 'EACCES') {
console.log(`[CLI] Permission denied accessing ${nodePath}`);
return {
path: null,
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
};
} else if ('code' in error && error.code === 'ENOENT') {
// File not found, continue
continue;
} else {
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
return {
path: null,
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
};
}
}
}
}
console.log(`[CLI] Could not determine Node.js path for CLI: ${cliPath}`);
return {
path: null,
error: `Could not find Node.js executable for CLI at ${cliPath}. Please verify the CLI installation.`,
};
}

View File

@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import semver from 'semver';
import { CliDetector, type CliDetectionResult } from './cliDetector.js';
export const MIN_CLI_VERSION_FOR_SESSION_METHODS = '0.5.0';
export interface CliFeatureFlags {
supportsSessionList: boolean;
supportsSessionLoad: boolean;
}
export interface CliVersionInfo {
version: string | undefined;
isSupported: boolean;
features: CliFeatureFlags;
detectionResult: CliDetectionResult;
}
/**
* CLI Version Manager
*
* Manages CLI version detection and feature availability based on version
*/
export class CliVersionManager {
private static instance: CliVersionManager;
private cachedVersionInfo: CliVersionInfo | null = null;
private lastCheckTime: number = 0;
private static readonly CACHE_DURATION_MS = 30000; // 30 seconds
private constructor() {}
/**
* Get singleton instance
*/
static getInstance(): CliVersionManager {
if (!CliVersionManager.instance) {
CliVersionManager.instance = new CliVersionManager();
}
return CliVersionManager.instance;
}
/**
* Check if CLI version meets minimum requirements
*
* @param version - Version string to check
* @param minVersion - Minimum required version
* @returns Whether version meets requirements
*/
private isVersionSupported(
version: string | undefined,
minVersion: string,
): boolean {
if (!version) {
return false;
}
// Use semver for robust comparison (handles v-prefix, pre-release, etc.)
const v = semver.valid(version) ?? semver.coerce(version)?.version ?? null;
const min =
semver.valid(minVersion) ?? semver.coerce(minVersion)?.version ?? null;
if (!v || !min) {
console.warn(
`[CliVersionManager] Invalid semver: version=${version}, min=${minVersion}`,
);
return false;
}
console.log(`[CliVersionManager] Version ${v} meets requirements: ${min}`);
return semver.gte(v, min);
}
/**
* Get feature flags based on CLI version
*
* @param version - CLI version string
* @returns Feature flags
*/
private getFeatureFlags(version: string | undefined): CliFeatureFlags {
const isSupportedVersion = this.isVersionSupported(
version,
MIN_CLI_VERSION_FOR_SESSION_METHODS,
);
return {
supportsSessionList: isSupportedVersion,
supportsSessionLoad: isSupportedVersion,
};
}
/**
* Detect CLI version and features
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns CLI version information
*/
async detectCliVersion(forceRefresh = false): Promise<CliVersionInfo> {
const now = Date.now();
// Return cached result if available and not expired
if (
!forceRefresh &&
this.cachedVersionInfo &&
now - this.lastCheckTime < CliVersionManager.CACHE_DURATION_MS
) {
console.log('[CliVersionManager] Returning cached version info');
return this.cachedVersionInfo;
}
console.log('[CliVersionManager] Detecting CLI version...');
try {
// Detect CLI installation
const detectionResult = await CliDetector.detectQwenCli(forceRefresh);
const versionInfo: CliVersionInfo = {
version: detectionResult.version,
isSupported: this.isVersionSupported(
detectionResult.version,
MIN_CLI_VERSION_FOR_SESSION_METHODS,
),
features: this.getFeatureFlags(detectionResult.version),
detectionResult,
};
// Cache the result
this.cachedVersionInfo = versionInfo;
this.lastCheckTime = now;
console.log(
'[CliVersionManager] CLI version detection result:',
versionInfo,
);
return versionInfo;
} catch (error) {
console.error('[CliVersionManager] Failed to detect CLI version:', error);
// Return fallback result
const fallbackResult: CliVersionInfo = {
version: undefined,
isSupported: false,
features: {
supportsSessionList: false,
supportsSessionLoad: false,
},
detectionResult: {
isInstalled: false,
error: error instanceof Error ? error.message : String(error),
},
};
return fallbackResult;
}
}
/**
* Clear cached version information
*/
clearCache(): void {
this.cachedVersionInfo = null;
this.lastCheckTime = 0;
CliDetector.clearCache();
}
/**
* Check if CLI supports session/list method
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns Whether session/list is supported
*/
async supportsSessionList(forceRefresh = false): Promise<boolean> {
const versionInfo = await this.detectCliVersion(forceRefresh);
return versionInfo.features.supportsSessionList;
}
/**
* Check if CLI supports session/load method
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns Whether session/load is supported
*/
async supportsSessionLoad(forceRefresh = false): Promise<boolean> {
const versionInfo = await this.detectCliVersion(forceRefresh);
return versionInfo.features.supportsSessionLoad;
}
}

View File

@@ -0,0 +1,80 @@
import * as vscode from 'vscode';
import type { DiffManager } from '../diff-manager.js';
import type { WebViewProvider } from '../webview/WebViewProvider.js';
type Logger = (message: string) => void;
export const runQwenCodeCommand = 'qwen-code.runQwenCode';
export const showDiffCommand = 'qwenCode.showDiff';
export const openChatCommand = 'qwen-code.openChat';
export const openNewChatTabCommand = 'qwenCode.openNewChatTab';
export const loginCommand = 'qwen-code.login';
export function registerNewCommands(
context: vscode.ExtensionContext,
log: Logger,
diffManager: DiffManager,
getWebViewProviders: () => WebViewProvider[],
createWebViewProvider: () => WebViewProvider,
): void {
const disposables: vscode.Disposable[] = [];
disposables.push(
vscode.commands.registerCommand(openChatCommand, async () => {
const providers = getWebViewProviders();
if (providers.length > 0) {
await providers[providers.length - 1].show();
} else {
const provider = createWebViewProvider();
await provider.show();
}
}),
);
disposables.push(
vscode.commands.registerCommand(
showDiffCommand,
async (args: { path: string; oldText: string; newText: string }) => {
try {
let absolutePath = args.path;
if (!args.path.startsWith('/') && !args.path.match(/^[a-zA-Z]:/)) {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (workspaceFolder) {
absolutePath = vscode.Uri.joinPath(
workspaceFolder.uri,
args.path,
).fsPath;
}
}
log(`[Command] Showing diff for ${absolutePath}`);
await diffManager.showDiff(absolutePath, args.oldText, args.newText);
} catch (error) {
log(`[Command] Error showing diff: ${error}`);
vscode.window.showErrorMessage(`Failed to show diff: ${error}`);
}
},
),
);
disposables.push(
vscode.commands.registerCommand(openNewChatTabCommand, async () => {
const provider = createWebViewProvider();
// Session restoration is now disabled by default, so no need to suppress it
await provider.show();
}),
);
disposables.push(
vscode.commands.registerCommand(loginCommand, async () => {
const providers = getWebViewProviders();
if (providers.length > 0) {
await providers[providers.length - 1].forceReLogin();
} else {
vscode.window.showInformationMessage(
'Please open Qwen Code chat first before logging in.',
);
}
}),
);
context.subscriptions.push(...disposables);
}

View File

@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export const AGENT_METHODS = {
authenticate: 'authenticate',
initialize: 'initialize',
session_cancel: 'session/cancel',
session_list: 'session/list',
session_load: 'session/load',
session_new: 'session/new',
session_prompt: 'session/prompt',
session_save: 'session/save',
session_set_mode: 'session/set_mode',
} as const;
export const CLIENT_METHODS = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
} as const;

View File

@@ -0,0 +1,146 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Loading messages from Qwen Code CLI
* Source: packages/cli/src/ui/hooks/usePhraseCycler.ts
*/
export const WITTY_LOADING_PHRASES = [
"I'm Feeling Lucky",
'Shipping awesomeness... ',
'Painting the serifs back on...',
'Navigating the slime mold...',
'Consulting the digital spirits...',
'Reticulating splines...',
'Warming up the AI hamsters...',
'Asking the magic conch shell...',
'Generating witty retort...',
'Polishing the algorithms...',
"Don't rush perfection (or my code)...",
'Brewing fresh bytes...',
'Counting electrons...',
'Engaging cognitive processors...',
'Checking for syntax errors in the universe...',
'One moment, optimizing humor...',
'Shuffling punchlines...',
'Untangling neural nets...',
'Compiling brilliance...',
'Loading wit.exe...',
'Summoning the cloud of wisdom...',
'Preparing a witty response...',
"Just a sec, I'm debugging reality...",
'Confuzzling the options...',
'Tuning the cosmic frequencies...',
'Crafting a response worthy of your patience...',
'Compiling the 1s and 0s...',
'Resolving dependencies... and existential crises...',
'Defragmenting memories... both RAM and personal...',
'Rebooting the humor module...',
'Caching the essentials (mostly cat memes)...',
'Optimizing for ludicrous speed',
"Swapping bits... don't tell the bytes...",
'Garbage collecting... be right back...',
'Assembling the interwebs...',
'Converting coffee into code...',
'Updating the syntax for reality...',
'Rewiring the synapses...',
'Looking for a misplaced semicolon...',
"Greasin' the cogs of the machine...",
'Pre-heating the servers...',
'Calibrating the flux capacitor...',
'Engaging the improbability drive...',
'Channeling the Force...',
'Aligning the stars for optimal response...',
'So say we all...',
'Loading the next great idea...',
"Just a moment, I'm in the zone...",
'Preparing to dazzle you with brilliance...',
"Just a tick, I'm polishing my wit...",
"Hold tight, I'm crafting a masterpiece...",
"Just a jiffy, I'm debugging the universe...",
"Just a moment, I'm aligning the pixels...",
"Just a sec, I'm optimizing the humor...",
"Just a moment, I'm tuning the algorithms...",
'Warp speed engaged...',
'Mining for more Dilithium crystals...',
"Don't panic...",
'Following the white rabbit...',
'The truth is in here... somewhere...',
'Blowing on the cartridge...',
'Loading... Do a barrel roll!',
'Waiting for the respawn...',
'Finishing the Kessel Run in less than 12 parsecs...',
"The cake is not a lie, it's just still loading...",
'Fiddling with the character creation screen...',
"Just a moment, I'm finding the right meme...",
"Pressing 'A' to continue...",
'Herding digital cats...',
'Polishing the pixels...',
'Finding a suitable loading screen pun...',
'Distracting you with this witty phrase...',
'Almost there... probably...',
'Our hamsters are working as fast as they can...',
'Giving Cloudy a pat on the head...',
'Petting the cat...',
'Rickrolling my boss...',
'Never gonna give you up, never gonna let you down...',
'Slapping the bass...',
'Tasting the snozberries...',
"I'm going the distance, I'm going for speed...",
'Is this the real life? Is this just fantasy?...',
"I've got a good feeling about this...",
'Poking the bear...',
'Doing research on the latest memes...',
'Figuring out how to make this more witty...',
'Hmmm... let me think...',
'What do you call a fish with no eyes? A fsh...',
'Why did the computer go to therapy? It had too many bytes...',
"Why don't programmers like nature? It has too many bugs...",
'Why do programmers prefer dark mode? Because light attracts bugs...',
'Why did the developer go broke? Because they used up all their cache...',
"What can you do with a broken pencil? Nothing, it's pointless...",
'Applying percussive maintenance...',
'Searching for the correct USB orientation...',
'Ensuring the magic smoke stays inside the wires...',
'Rewriting in Rust for no particular reason...',
'Trying to exit Vim...',
'Spinning up the hamster wheel...',
"That's not a bug, it's an undocumented feature...",
'Engage.',
"I'll be back... with an answer.",
'My other process is a TARDIS...',
'Communing with the machine spirit...',
'Letting the thoughts marinate...',
'Just remembered where I put my keys...',
'Pondering the orb...',
"I've seen things you people wouldn't believe... like a user who reads loading messages.",
'Initiating thoughtful gaze...',
"What's a computer's favorite snack? Microchips.",
"Why do Java developers wear glasses? Because they don't C#.",
'Charging the laser... pew pew!',
'Dividing by zero... just kidding!',
'Looking for an adult superviso... I mean, processing.',
'Making it go beep boop.',
'Buffering... because even AIs need a moment.',
'Entangling quantum particles for a faster response...',
'Polishing the chrome... on the algorithms.',
'Are you not entertained? (Working on it!)',
'Summoning the code gremlins... to help, of course.',
'Just waiting for the dial-up tone to finish...',
'Recalibrating the humor-o-meter.',
'My other loading screen is even funnier.',
"Pretty sure there's a cat walking on the keyboard somewhere...",
'Enhancing... Enhancing... Still loading.',
"It's not a bug, it's a feature... of this loading screen.",
'Have you tried turning it off and on again? (The loading screen, not me.)',
'Constructing additional pylons...',
"New line? That's Ctrl+J.",
];
export const getRandomLoadingMessage = (): string =>
WITTY_LOADING_PHRASES[
Math.floor(Math.random() * WITTY_LOADING_PHRASES.length)
];

View File

@@ -12,6 +12,10 @@ import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js';
import * as path from 'node:path';
import * as vscode from 'vscode';
import { DIFF_SCHEME } from './extension.js';
import {
findLeftGroupOfChatWebview,
ensureLeftGroupOfChatWebview,
} from './utils/editorGroupUtils.js';
export class DiffContentProvider implements vscode.TextDocumentContentProvider {
private content = new Map<string, string>();
@@ -42,7 +46,9 @@ export class DiffContentProvider implements vscode.TextDocumentContentProvider {
// Information about a diff view that is currently open.
interface DiffInfo {
originalFilePath: string;
oldContent: string;
newContent: string;
leftDocUri: vscode.Uri;
rightDocUri: vscode.Uri;
}
@@ -55,11 +61,26 @@ export class DiffManager {
readonly onDidChange = this.onDidChangeEmitter.event;
private diffDocuments = new Map<string, DiffInfo>();
private readonly subscriptions: vscode.Disposable[] = [];
// Dedupe: remember recent showDiff calls keyed by (file+content)
private recentlyShown = new Map<string, number>();
private pendingDelayTimers = new Map<string, NodeJS.Timeout>();
private static readonly DEDUPE_WINDOW_MS = 1500;
// Optional hooks from extension to influence diff behavior
// - shouldDelay: when true, we defer opening diffs briefly (e.g., while a permission drawer is open)
// - shouldSuppress: when true, we skip opening diffs entirely (e.g., in auto/yolo mode)
private shouldDelay?: () => boolean;
private shouldSuppress?: () => boolean;
// Timed suppression window (e.g. immediately after permission allow)
private suppressUntil: number | null = null;
constructor(
private readonly log: (message: string) => void,
private readonly diffContentProvider: DiffContentProvider,
shouldDelay?: () => boolean,
shouldSuppress?: () => boolean,
) {
this.shouldDelay = shouldDelay;
this.shouldSuppress = shouldSuppress;
this.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor((editor) => {
this.onActiveEditorChange(editor);
@@ -75,43 +96,142 @@ export class DiffManager {
}
/**
* Creates and shows a new diff view.
* Checks if a diff view already exists for the given file path and content
* @param filePath Path to the file being diffed
* @param oldContent The original content (left side)
* @param newContent The modified content (right side)
* @returns True if a diff view with the same content already exists, false otherwise
*/
async showDiff(filePath: string, newContent: string) {
const fileUri = vscode.Uri.file(filePath);
private hasExistingDiff(
filePath: string,
oldContent: string,
newContent: string,
): boolean {
for (const diffInfo of this.diffDocuments.values()) {
if (
diffInfo.originalFilePath === filePath &&
diffInfo.oldContent === oldContent &&
diffInfo.newContent === newContent
) {
return true;
}
}
return false;
}
/**
* Finds an existing diff view for the given file path and focuses it
* @param filePath Path to the file being diffed
* @returns True if an existing diff view was found and focused, false otherwise
*/
private async focusExistingDiff(filePath: string): Promise<boolean> {
const normalizedPath = path.normalize(filePath);
for (const [, diffInfo] of this.diffDocuments.entries()) {
if (diffInfo.originalFilePath === normalizedPath) {
const rightDocUri = diffInfo.rightDocUri;
const leftDocUri = diffInfo.leftDocUri;
const diffTitle = `${path.basename(filePath)} (Before ↔ After)`;
try {
await vscode.commands.executeCommand(
'vscode.diff',
leftDocUri,
rightDocUri,
diffTitle,
{
viewColumn: vscode.ViewColumn.Beside,
preview: false,
preserveFocus: true,
},
);
return true;
} catch (error) {
this.log(`Failed to focus existing diff: ${error}`);
return false;
}
}
}
return false;
}
/**
* Creates and shows a new diff view.
* - Overload 1: showDiff(filePath, newContent)
* - Overload 2: showDiff(filePath, oldContent, newContent)
* If only newContent is provided, the old content will be read from the
* filesystem (empty string when file does not exist).
*/
async showDiff(filePath: string, newContent: string): Promise<void>;
async showDiff(
filePath: string,
oldContent: string,
newContent: string,
): Promise<void>;
async showDiff(filePath: string, a: string, b?: string): Promise<void> {
const haveOld = typeof b === 'string';
const oldContent = haveOld ? a : await this.readOldContentFromFs(filePath);
const newContent = haveOld ? (b as string) : a;
const normalizedPath = path.normalize(filePath);
const key = this.makeKey(normalizedPath, oldContent, newContent);
// Check if a diff view with the same content already exists
if (this.hasExistingDiff(normalizedPath, oldContent, newContent)) {
const last = this.recentlyShown.get(key) || 0;
const now = Date.now();
if (now - last < DiffManager.DEDUPE_WINDOW_MS) {
// Within dedupe window: ignore the duplicate request entirely
this.log(
`Duplicate showDiff suppressed for ${filePath} (within ${DiffManager.DEDUPE_WINDOW_MS}ms)`,
);
return;
}
// Outside the dedupe window: softly focus the existing diff
await this.focusExistingDiff(normalizedPath);
this.recentlyShown.set(key, now);
return;
}
// Left side: old content using qwen-diff scheme
const leftDocUri = vscode.Uri.from({
scheme: DIFF_SCHEME,
path: normalizedPath,
query: `old&rand=${Math.random()}`,
});
this.diffContentProvider.setContent(leftDocUri, oldContent);
// Right side: new content using qwen-diff scheme
const rightDocUri = vscode.Uri.from({
scheme: DIFF_SCHEME,
path: filePath,
// cache busting
query: `rand=${Math.random()}`,
path: normalizedPath,
query: `new&rand=${Math.random()}`,
});
this.diffContentProvider.setContent(rightDocUri, newContent);
this.addDiffDocument(rightDocUri, {
originalFilePath: filePath,
originalFilePath: normalizedPath,
oldContent,
newContent,
leftDocUri,
rightDocUri,
});
const diffTitle = `${path.basename(filePath)} ↔ Modified`;
const diffTitle = `${path.basename(normalizedPath)} (Before ↔ After)`;
await vscode.commands.executeCommand(
'setContext',
'qwen.diff.isVisible',
true,
);
let leftDocUri;
try {
await vscode.workspace.fs.stat(fileUri);
leftDocUri = fileUri;
} catch {
// We need to provide an empty document to diff against.
// Using the 'untitled' scheme is one way to do this.
leftDocUri = vscode.Uri.from({
scheme: 'untitled',
path: filePath,
});
// Prefer opening the diff adjacent to the chat webview (so we don't
// replace content inside the locked webview group). We try the group to
// the left of the chat webview first; if none exists we fall back to
// ViewColumn.Beside. With the chat locked in the leftmost group, this
// fallback opens diffs to the right of the chat.
let targetViewColumn = findLeftGroupOfChatWebview();
if (targetViewColumn === undefined) {
// If there is no left neighbor, create one to satisfy the requirement of
// opening diffs to the left of the chat webview.
targetViewColumn = await ensureLeftGroupOfChatWebview();
}
await vscode.commands.executeCommand(
@@ -120,6 +240,10 @@ export class DiffManager {
rightDocUri,
diffTitle,
{
// If a left-of-webview group was found, target it explicitly so the
// diff opens there while keeping focus on the webview. Otherwise, use
// the default "open to side" behavior.
viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside,
preview: false,
preserveFocus: true,
},
@@ -127,16 +251,19 @@ export class DiffManager {
await vscode.commands.executeCommand(
'workbench.action.files.setActiveEditorWriteableInSession',
);
this.recentlyShown.set(key, Date.now());
}
/**
* Closes an open diff view for a specific file.
*/
async closeDiff(filePath: string, suppressNotification = false) {
const normalizedPath = path.normalize(filePath);
let uriToClose: vscode.Uri | undefined;
for (const [uriString, diffInfo] of this.diffDocuments.entries()) {
if (diffInfo.originalFilePath === filePath) {
uriToClose = vscode.Uri.parse(uriString);
for (const [, diffInfo] of this.diffDocuments.entries()) {
if (diffInfo.originalFilePath === normalizedPath) {
uriToClose = diffInfo.rightDocUri;
break;
}
}
@@ -267,4 +394,40 @@ export class DiffManager {
}
}
}
/** Close all open qwen-diff editors */
async closeAll(): Promise<void> {
// Collect keys first to avoid iterator invalidation while closing
const uris = Array.from(this.diffDocuments.keys()).map((k) =>
vscode.Uri.parse(k),
);
for (const uri of uris) {
try {
await this.closeDiffEditor(uri);
} catch (err) {
this.log(`Failed to close diff editor: ${err}`);
}
}
}
// Read the current content of file from the workspace; return empty string if not found
private async readOldContentFromFs(filePath: string): Promise<string> {
try {
const fileUri = vscode.Uri.file(filePath);
const document = await vscode.workspace.openTextDocument(fileUri);
return document.getText();
} catch {
return '';
}
}
private makeKey(filePath: string, oldContent: string, newContent: string) {
// Simple stable key; content could be large but kept transiently
return `${filePath}\u241F${oldContent}\u241F${newContent}`;
}
/** Temporarily suppress opening diffs for a short duration. */
suppressFor(durationMs: number): void {
this.suppressUntil = Date.now() + Math.max(0, durationMs);
}
}

View File

@@ -40,6 +40,9 @@ vi.mock('vscode', () => ({
},
showTextDocument: vi.fn(),
showWorkspaceFolderPick: vi.fn(),
registerWebviewPanelSerializer: vi.fn(() => ({
dispose: vi.fn(),
})),
},
workspace: {
workspaceFolders: [],

View File

@@ -14,6 +14,8 @@ import {
IDE_DEFINITIONS,
type IdeInfo,
} from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
import { WebViewProvider } from './webview/WebViewProvider.js';
import { registerNewCommands } from './commands/index.js';
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
@@ -31,6 +33,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet<IdeInfo['name']> = new Set([
let ideServer: IDEServer;
let logger: vscode.OutputChannel;
let webViewProviders: WebViewProvider[] = []; // Track multiple chat tabs
let log: (message: string) => void = () => {};
@@ -108,7 +111,75 @@ export async function activate(context: vscode.ExtensionContext) {
checkForUpdates(context, log);
const diffContentProvider = new DiffContentProvider();
const diffManager = new DiffManager(log, diffContentProvider);
const diffManager = new DiffManager(
log,
diffContentProvider,
// Delay when any chat tab has a pending permission drawer
() => webViewProviders.some((p) => p.hasPendingPermission()),
// Suppress diffs when active mode is auto or yolo in any chat tab
() => {
const providers = webViewProviders.filter(
(p) => typeof p.shouldSuppressDiff === 'function',
);
if (providers.length === 0) {
return false;
}
return providers.every((p) => p.shouldSuppressDiff());
},
);
// Helper function to create a new WebView provider instance
const createWebViewProvider = (): WebViewProvider => {
const provider = new WebViewProvider(context, context.extensionUri);
webViewProviders.push(provider);
return provider;
};
// Register WebView panel serializer for persistence across reloads
context.subscriptions.push(
vscode.window.registerWebviewPanelSerializer('qwenCode.chat', {
async deserializeWebviewPanel(
webviewPanel: vscode.WebviewPanel,
state: unknown,
) {
console.log(
'[Extension] Deserializing WebView panel with state:',
state,
);
// Create a new provider for the restored panel
const provider = createWebViewProvider();
console.log('[Extension] Provider created for deserialization');
// Restore state if available BEFORE restoring the panel
if (state && typeof state === 'object') {
console.log('[Extension] Restoring state:', state);
provider.restoreState(
state as {
conversationId: string | null;
agentInitialized: boolean;
},
);
} else {
console.log('[Extension] No state to restore or invalid state');
}
await provider.restorePanel(webviewPanel);
console.log('[Extension] Panel restore completed');
log('WebView panel restored from serialization');
},
}),
);
// Register newly added commands via commands module
registerNewCommands(
context,
log,
diffManager,
() => webViewProviders,
createWebViewProvider,
);
context.subscriptions.push(
vscode.workspace.onDidCloseTextDocument((doc) => {
@@ -120,17 +191,53 @@ export async function activate(context: vscode.ExtensionContext) {
DIFF_SCHEME,
diffContentProvider,
),
vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => {
(vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => {
const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
if (docUri && docUri.scheme === DIFF_SCHEME) {
diffManager.acceptDiff(docUri);
}
// If WebView is requesting permission, actively select an allow option (prefer once)
try {
for (const provider of webViewProviders) {
if (provider?.hasPendingPermission()) {
provider.respondToPendingPermission('allow');
}
}
} catch (err) {
console.warn('[Extension] Auto-allow on diff.accept failed:', err);
}
console.log('[Extension] Diff accepted');
}),
vscode.commands.registerCommand('qwen.diff.cancel', (uri?: vscode.Uri) => {
const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
if (docUri && docUri.scheme === DIFF_SCHEME) {
diffManager.cancelDiff(docUri);
}
// If WebView is requesting permission, actively select reject/cancel
try {
for (const provider of webViewProviders) {
if (provider?.hasPendingPermission()) {
provider.respondToPendingPermission('cancel');
}
}
} catch (err) {
console.warn('[Extension] Auto-reject on diff.cancel failed:', err);
}
console.log('[Extension] Diff cancelled');
})),
vscode.commands.registerCommand('qwen.diff.closeAll', async () => {
try {
await diffManager.closeAll();
} catch (err) {
console.warn('[Extension] qwen.diff.closeAll failed:', err);
}
}),
vscode.commands.registerCommand('qwen.diff.suppressBriefly', async () => {
try {
diffManager.suppressFor(1200);
} catch (err) {
console.warn('[Extension] qwen.diff.suppressBriefly failed:', err);
}
}),
);
@@ -160,34 +267,42 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.workspace.onDidGrantWorkspaceTrust(() => {
ideServer.syncEnvVars();
}),
vscode.commands.registerCommand('qwen-code.runQwenCode', async () => {
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
vscode.window.showInformationMessage(
'No folder open. Please open a folder to run Qwen Code.',
);
return;
}
vscode.commands.registerCommand(
'qwen-code.runQwenCode',
async (
location?:
| vscode.TerminalLocation
| vscode.TerminalEditorLocationOptions,
) => {
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
vscode.window.showInformationMessage(
'No folder open. Please open a folder to run Qwen Code.',
);
return;
}
let selectedFolder: vscode.WorkspaceFolder | undefined;
if (workspaceFolders.length === 1) {
selectedFolder = workspaceFolders[0];
} else {
selectedFolder = await vscode.window.showWorkspaceFolderPick({
placeHolder: 'Select a folder to run Qwen Code in',
});
}
let selectedFolder: vscode.WorkspaceFolder | undefined;
if (workspaceFolders.length === 1) {
selectedFolder = workspaceFolders[0];
} else {
selectedFolder = await vscode.window.showWorkspaceFolderPick({
placeHolder: 'Select a folder to run Qwen Code in',
});
}
if (selectedFolder) {
const qwenCmd = 'qwen';
const terminal = vscode.window.createTerminal({
name: `Qwen Code (${selectedFolder.name})`,
cwd: selectedFolder.uri.fsPath,
});
terminal.show();
terminal.sendText(qwenCmd);
}
}),
if (selectedFolder) {
const qwenCmd = 'qwen';
const terminal = vscode.window.createTerminal({
name: `Qwen Code (${selectedFolder.name})`,
cwd: selectedFolder.uri.fsPath,
location,
});
terminal.show();
terminal.sendText(qwenCmd);
}
},
),
vscode.commands.registerCommand('qwen-code.showNotices', async () => {
const noticePath = vscode.Uri.joinPath(
context.extensionUri,
@@ -204,6 +319,11 @@ export async function deactivate(): Promise<void> {
if (ideServer) {
await ideServer.stop();
}
// Dispose all WebView providers
webViewProviders.forEach((provider) => {
provider.dispose();
});
webViewProviders = [];
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log(`Failed to stop IDE server during deactivation: ${message}`);

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