From 6729980b47db068c87bbeb98fe94fbc9798a3cd6 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 4 Dec 2025 10:28:21 +0800 Subject: [PATCH 01/38] skip acp integration test in sandbox env (#1141) --- integration-tests/acp-integration.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index d09ec54d..b098e025 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -15,6 +15,9 @@ const REQUEST_TIMEOUT_MS = 60_000; const INITIAL_PROMPT = 'Create a quick note (smoke test).'; const RESUME_PROMPT = 'Continue the note after reload.'; const LIST_SIZE = 5; +const IS_SANDBOX = + process.env['GEMINI_SANDBOX'] && + process.env['GEMINI_SANDBOX']!.toLowerCase() !== 'false'; type PendingRequest = { resolve: (value: unknown) => void; @@ -86,7 +89,6 @@ function setupAcpTest( const agent = spawn('node', [rig.bundlePath, '--experimental-acp'], { cwd: rig.testDir!, stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env }, }); agent.stderr?.on('data', (chunk) => { @@ -250,7 +252,7 @@ function setupAcpTest( }; } -describe('acp integration', () => { +(IS_SANDBOX ? describe.skip : describe)('acp integration', () => { it('creates, lists, loads, and resumes a session', async () => { const rig = new TestRig(); rig.setup('acp load session'); From 5b2f3e285c6e4d6412715efd4e9a4cd3b99a9900 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 4 Dec 2025 16:21:32 +0800 Subject: [PATCH 02/38] remove /quit-confirm slash command --- docs/cli/commands.md | 10 --- docs/cli/configuration-v1.md | 2 +- docs/features/welcome-back.md | 8 -- packages/cli/src/i18n/locales/en.js | 13 ---- packages/cli/src/i18n/locales/zh.js | 10 --- .../src/services/BuiltinCommandLoader.test.ts | 1 - .../cli/src/services/BuiltinCommandLoader.ts | 3 +- packages/cli/src/ui/AppContainer.tsx | 35 +++------ packages/cli/src/ui/commands/quitCommand.ts | 29 -------- packages/cli/src/ui/commands/types.ts | 7 -- .../cli/src/ui/components/DialogManager.tsx | 22 ------ .../src/ui/components/HistoryItemDisplay.tsx | 3 - .../cli/src/ui/contexts/UIStateContext.tsx | 2 - .../ui/hooks/slashCommandProcessor.test.ts | 1 - .../cli/src/ui/hooks/slashCommandProcessor.ts | 74 ------------------- packages/cli/src/ui/hooks/useDialogClose.ts | 8 -- packages/cli/src/ui/types.ts | 17 ----- 17 files changed, 12 insertions(+), 233 deletions(-) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index d258bc2d..aa056a43 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -145,16 +145,6 @@ Slash commands provide meta-level control over the CLI itself. - **`nodesc`** or **`nodescriptions`**: - **Description:** Hide tool descriptions, showing only the tool names. -- **`/quit-confirm`** - - **Description:** Show a confirmation dialog before exiting Qwen Code, allowing you to choose how to handle your current session. - - **Usage:** `/quit-confirm` - - **Features:** - - **Quit immediately:** Exit without saving anything (equivalent to `/quit`) - - **Generate summary and quit:** Create a project summary using `/summary` before exiting - - **Save conversation and quit:** Save the current conversation with an auto-generated tag before exiting - - **Keyboard shortcut:** Press **Ctrl+C** twice to trigger the quit confirmation dialog - - **Note:** This command is automatically triggered when you press Ctrl+C once, providing a safety mechanism to prevent accidental exits. - - **`/quit`** (or **`/exit`**) - **Description:** Exit Qwen Code immediately without any confirmation dialog. diff --git a/docs/cli/configuration-v1.md b/docs/cli/configuration-v1.md index 5127bbe2..2037db8d 100644 --- a/docs/cli/configuration-v1.md +++ b/docs/cli/configuration-v1.md @@ -671,4 +671,4 @@ Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM - **Category:** UI - **Requires Restart:** No - **Example:** `"enableWelcomeBack": false` - - **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. See the [Welcome Back documentation](./welcome-back.md) for more details. + - **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command. See the [Welcome Back documentation](./welcome-back.md) for more details. diff --git a/docs/features/welcome-back.md b/docs/features/welcome-back.md index 7175406b..1ce552ee 100644 --- a/docs/features/welcome-back.md +++ b/docs/features/welcome-back.md @@ -81,14 +81,6 @@ The Welcome Back feature works seamlessly with the `/summary` command: 2. **Automatic Detection:** Next time you start Qwen Code in this project, Welcome Back will detect the summary 3. **Resume Work:** Choose to continue and the summary will be loaded as context -### Quit Confirmation - -When exiting with `/quit-confirm` and choosing "Generate summary and quit": - -1. A project summary is automatically created -2. Next session will trigger the Welcome Back dialog -3. You can seamlessly continue your work - ## File Structure The Welcome Back feature creates and uses: diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 3ab57edb..c2217757 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -110,7 +110,6 @@ export default { 'open full Qwen Code documentation in your browser', 'Configuration not available.': 'Configuration not available.', 'change the auth method': 'change the auth method', - 'Show quit confirmation dialog': 'Show quit confirmation dialog', 'Copy the last result or code snippet to clipboard': 'Copy the last result or code snippet to clipboard', @@ -690,18 +689,6 @@ export default { 'A custom command wants to run the following shell commands:': 'A custom command wants to run the following shell commands:', - // ============================================================================ - // Dialogs - Quit Confirmation - // ============================================================================ - 'What would you like to do before exiting?': - 'What would you like to do before exiting?', - 'Quit immediately (/quit)': 'Quit immediately (/quit)', - 'Generate summary and quit (/summary)': - 'Generate summary and quit (/summary)', - 'Save conversation and quit (/chat save)': - 'Save conversation and quit (/chat save)', - 'Cancel (stay in application)': 'Cancel (stay in application)', - // ============================================================================ // Dialogs - Pro Quota // ============================================================================ diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 474753ae..adeb85f1 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -108,7 +108,6 @@ export default { '在浏览器中打开完整的 Qwen Code 文档', 'Configuration not available.': '配置不可用', 'change the auth method': '更改认证方法', - 'Show quit confirmation dialog': '显示退出确认对话框', 'Copy the last result or code snippet to clipboard': '将最后的结果或代码片段复制到剪贴板', @@ -655,15 +654,6 @@ export default { 'A custom command wants to run the following shell commands:': '自定义命令想要运行以下 shell 命令:', - // ============================================================================ - // Dialogs - Quit Confirmation - // ============================================================================ - 'What would you like to do before exiting?': '退出前您想要做什么?', - 'Quit immediately (/quit)': '立即退出 (/quit)', - 'Generate summary and quit (/summary)': '生成摘要并退出 (/summary)', - 'Save conversation and quit (/chat save)': '保存对话并退出 (/chat save)', - 'Cancel (stay in application)': '取消(留在应用程序中)', - // ============================================================================ // Dialogs - Pro Quota // ============================================================================ diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 67f8ee72..9d649b2f 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -71,7 +71,6 @@ vi.mock('../ui/commands/modelCommand.js', () => ({ })); vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {}, - quitConfirmCommand: {}, })); vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} })); vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} })); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 8be63a8e..100fbef9 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -28,7 +28,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { modelCommand } from '../ui/commands/modelCommand.js'; import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; -import { quitCommand, quitConfirmCommand } from '../ui/commands/quitCommand.js'; +import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; @@ -77,7 +77,6 @@ export class BuiltinCommandLoader implements ICommandLoader { modelCommand, ...(this.config?.getFolderTrust() ? [permissionsCommand] : []), quitCommand, - quitConfirmCommand, restoreCommand(this.config), statsCommand, summaryCommand, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ebcc14f6..edda3d4d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -89,7 +89,6 @@ import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useExtensionUpdates } from './hooks/useExtensionUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; -import { useQuitConfirmation } from './hooks/useQuitConfirmation.js'; import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; @@ -446,8 +445,6 @@ export const AppContainer = (props: AppContainerProps) => { const { toggleVimEnabled } = useVimMode(); - const { showQuitConfirmation } = useQuitConfirmation(); - const { isSubagentCreateDialogOpen, openSubagentCreateDialog, @@ -493,7 +490,6 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, - _showQuitConfirmation: showQuitConfirmation, }), [ openAuthDialog, @@ -507,7 +503,6 @@ export const AppContainer = (props: AppContainerProps) => { openPermissionsDialog, openApprovalModeDialog, addConfirmUpdateExtensionRequest, - showQuitConfirmation, openSubagentCreateDialog, openAgentsManagerDialog, ], @@ -520,7 +515,6 @@ export const AppContainer = (props: AppContainerProps) => { commandContext, shellConfirmationRequest, confirmationRequest, - quitConfirmationRequest, } = useSlashCommandProcessor( config, settings, @@ -969,7 +963,6 @@ export const AppContainer = (props: AppContainerProps) => { isFolderTrustDialogOpen, showWelcomeBackDialog, handleWelcomeBackClose, - quitConfirmationRequest, }); const handleExit = useCallback( @@ -983,25 +976,18 @@ export const AppContainer = (props: AppContainerProps) => { if (timerRef.current) { clearTimeout(timerRef.current); } - // Exit directly without showing confirmation dialog + // Exit directly handleSlashCommand('/quit'); return; } // First press: Prioritize cleanup tasks - // Special case: If quit-confirm dialog is open, Ctrl+C means "quit immediately" - if (quitConfirmationRequest) { - handleSlashCommand('/quit'); - return; - } - // 1. Close other dialogs (highest priority) /** * For AuthDialog it is required to complete the authentication process, * otherwise user cannot proceed to the next step. - * So a quit on AuthDialog should go with normal two press quit - * and without quit-confirm dialog. + * So a quit on AuthDialog should go with normal two press quit. */ if (isAuthDialogOpen) { setPressedOnce(true); @@ -1022,14 +1008,17 @@ export const AppContainer = (props: AppContainerProps) => { return; // Request cancelled, end processing } - // 3. Clear input buffer (if has content) + // 4. Clear input buffer (if has content) if (buffer.text.length > 0) { buffer.setText(''); return; // Input cleared, end processing } - // All cleanup tasks completed, show quit confirmation dialog - handleSlashCommand('/quit-confirm'); + // All cleanup tasks completed, set flag for double-press to quit + setPressedOnce(true); + timerRef.current = setTimeout(() => { + setPressedOnce(false); + }, CTRL_EXIT_PROMPT_DURATION_MS); }, [ isAuthDialogOpen, @@ -1037,7 +1026,6 @@ export const AppContainer = (props: AppContainerProps) => { closeAnyOpenDialog, streamingState, cancelOngoingRequest, - quitConfirmationRequest, buffer, ], ); @@ -1054,8 +1042,8 @@ export const AppContainer = (props: AppContainerProps) => { return; } - // On first press: set flag, start timer, and call handleExit for cleanup/quit-confirm - // On second press (within 500ms): handleExit sees flag and does fast quit + // On first press: set flag, start timer, and call handleExit for cleanup + // On second press (within timeout): handleExit sees flag and does fast quit if (!ctrlCPressedOnce) { setCtrlCPressedOnce(true); ctrlCTimerRef.current = setTimeout(() => { @@ -1196,7 +1184,6 @@ export const AppContainer = (props: AppContainerProps) => { !!confirmationRequest || confirmUpdateExtensionRequests.length > 0 || !!loopDetectionConfirmationRequest || - !!quitConfirmationRequest || isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || @@ -1245,7 +1232,6 @@ export const AppContainer = (props: AppContainerProps) => { confirmationRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, - quitConfirmationRequest, geminiMdFileCount, streamingState, initError, @@ -1337,7 +1323,6 @@ export const AppContainer = (props: AppContainerProps) => { confirmationRequest, confirmUpdateExtensionRequests, loopDetectionConfirmationRequest, - quitConfirmationRequest, geminiMdFileCount, streamingState, initError, diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index fc9683c9..4e9da3a0 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -8,35 +8,6 @@ import { formatDuration } from '../utils/formatters.js'; import { CommandKind, type SlashCommand } from './types.js'; import { t } from '../../i18n/index.js'; -export const quitConfirmCommand: SlashCommand = { - name: 'quit-confirm', - get description() { - return t('Show quit confirmation dialog'); - }, - kind: CommandKind.BUILT_IN, - action: (context) => { - const now = Date.now(); - const { sessionStartTime } = context.session.stats; - const wallDuration = now - sessionStartTime.getTime(); - - return { - type: 'quit_confirmation', - messages: [ - { - type: 'user', - text: `/quit-confirm`, - id: now - 1, - }, - { - type: 'quit_confirmation', - duration: formatDuration(wallDuration), - id: now, - }, - ], - }; - }, -}; - export const quitCommand: SlashCommand = { name: 'quit', altNames: ['exit'], diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 69d6e9d4..a2a352cb 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -100,12 +100,6 @@ export interface QuitActionReturn { messages: HistoryItem[]; } -/** The return type for a command action that requests quit confirmation. */ -export interface QuitConfirmationActionReturn { - type: 'quit_confirmation'; - messages: HistoryItem[]; -} - /** * The return type for a command action that results in a simple message * being displayed to the user. @@ -182,7 +176,6 @@ export type SlashCommandActionReturn = | ToolActionReturn | MessageActionReturn | QuitActionReturn - | QuitConfirmationActionReturn | OpenDialogActionReturn | LoadHistoryActionReturn | SubmitPromptActionReturn diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 2f6f8636..d696c87a 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -36,10 +36,6 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; -import { - QuitConfirmationDialog, - QuitChoice, -} from './QuitConfirmationDialog.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -127,24 +123,6 @@ export const DialogManager = ({ /> ); } - if (uiState.quitConfirmationRequest) { - return ( - { - if (choice === QuitChoice.CANCEL) { - uiState.quitConfirmationRequest?.onConfirm(false, 'cancel'); - } else if (choice === QuitChoice.QUIT) { - uiState.quitConfirmationRequest?.onConfirm(true, 'quit'); - } else if (choice === QuitChoice.SUMMARY_AND_QUIT) { - uiState.quitConfirmationRequest?.onConfirm( - true, - 'summary_and_quit', - ); - } - }} - /> - ); - } if (uiState.confirmationRequest) { return ( = ({ {itemForDisplay.type === 'quit' && ( )} - {itemForDisplay.type === 'quit_confirmation' && ( - - )} {itemForDisplay.type === 'tool_group' && ( { vi.fn(), // toggleVimEnabled vi.fn(), // setIsProcessing vi.fn(), // setGeminiMdFileCount - vi.fn(), // _showQuitConfirmation ), ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 758eb972..553accb7 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -18,7 +18,6 @@ import { IdeClient, } from '@qwen-code/qwen-code-core'; import { useSessionStats } from '../contexts/SessionContext.js'; -import { formatDuration } from '../utils/formatters.js'; import type { Message, HistoryItemWithoutId, @@ -53,7 +52,6 @@ function serializeHistoryItemForRecording( const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'quit', - 'quit-confirm', 'exit', 'clear', 'reset', @@ -75,7 +73,6 @@ interface SlashCommandProcessorActions { addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; openSubagentCreateDialog: () => void; openAgentsManagerDialog: () => void; - _showQuitConfirmation: () => void; } /** @@ -115,10 +112,6 @@ export const useSlashCommandProcessor = ( prompt: React.ReactNode; onConfirm: (confirmed: boolean) => void; }>(null); - const [quitConfirmationRequest, setQuitConfirmationRequest] = - useState void; - }>(null); const [sessionShellAllowlist, setSessionShellAllowlist] = useState( new Set(), @@ -174,11 +167,6 @@ export const useSlashCommandProcessor = ( type: 'quit', duration: message.duration, }; - } else if (message.type === MessageType.QUIT_CONFIRMATION) { - historyItemContent = { - type: 'quit_confirmation', - duration: message.duration, - }; } else if (message.type === MessageType.COMPRESSION) { historyItemContent = { type: 'compression', @@ -449,66 +437,6 @@ export const useSlashCommandProcessor = ( }); return { type: 'handled' }; } - case 'quit_confirmation': - // Show quit confirmation dialog instead of immediately quitting - setQuitConfirmationRequest({ - onConfirm: (shouldQuit: boolean, action?: string) => { - setQuitConfirmationRequest(null); - if (!shouldQuit) { - // User cancelled the quit operation - do nothing - return; - } - if (shouldQuit) { - if (action === 'summary_and_quit') { - // Generate summary and then quit - handleSlashCommand('/summary') - .then(() => { - // Wait for user to see the summary result - setTimeout(() => { - handleSlashCommand('/quit'); - }, 1200); - }) - .catch((error) => { - // If summary fails, still quit but show error - addItemWithRecording( - { - type: 'error', - text: `Failed to generate summary before quit: ${ - error instanceof Error - ? error.message - : String(error) - }`, - }, - Date.now(), - ); - // Give user time to see the error message - setTimeout(() => { - handleSlashCommand('/quit'); - }, 1000); - }); - } else { - // Just quit immediately - trigger the actual quit action - const now = Date.now(); - const { sessionStartTime } = sessionStats; - const wallDuration = now - sessionStartTime.getTime(); - - actions.quit([ - { - type: 'user', - text: `/quit`, - id: now - 1, - }, - { - type: 'quit', - duration: formatDuration(wallDuration), - id: now, - }, - ]); - } - } - }, - }); - return { type: 'handled' }; case 'quit': actions.quit(result.messages); @@ -692,7 +620,6 @@ export const useSlashCommandProcessor = ( setSessionShellAllowlist, setIsProcessing, setConfirmationRequest, - sessionStats, ], ); @@ -703,6 +630,5 @@ export const useSlashCommandProcessor = ( commandContext, shellConfirmationRequest, confirmationRequest, - quitConfirmationRequest, }; }; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 70a06abc..298f4496 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -44,11 +44,6 @@ export interface DialogCloseOptions { // Welcome back dialog showWelcomeBackDialog: boolean; handleWelcomeBackClose: () => void; - - // Quit confirmation dialog - quitConfirmationRequest: { - onConfirm: (shouldQuit: boolean, action?: string) => void; - } | null; } /** @@ -96,9 +91,6 @@ export function useDialogClose(options: DialogCloseOptions) { return true; } - // Note: quitConfirmationRequest is NOT handled here anymore - // It's handled specially in handleExit - ctrl+c in quit-confirm should exit immediately - // No dialog was open return false; }, [options]); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index bc9a6317..79e5621a 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -161,11 +161,6 @@ export type HistoryItemQuit = HistoryItemBase & { duration: string; }; -export type HistoryItemQuitConfirmation = HistoryItemBase & { - type: 'quit_confirmation'; - duration: string; -}; - export type HistoryItemToolGroup = HistoryItemBase & { type: 'tool_group'; tools: IndividualToolCallDisplay[]; @@ -256,7 +251,6 @@ export type HistoryItemWithoutId = | HistoryItemModelStats | HistoryItemToolStats | HistoryItemQuit - | HistoryItemQuitConfirmation | HistoryItemCompression | HistoryItemSummary | HistoryItemCompression @@ -278,7 +272,6 @@ export enum MessageType { MODEL_STATS = 'model_stats', TOOL_STATS = 'tool_stats', QUIT = 'quit', - QUIT_CONFIRMATION = 'quit_confirmation', GEMINI = 'gemini', COMPRESSION = 'compression', SUMMARY = 'summary', @@ -342,12 +335,6 @@ export type Message = duration: string; content?: string; } - | { - type: MessageType.QUIT_CONFIRMATION; - timestamp: Date; - duration: string; - content?: string; - } | { type: MessageType.COMPRESSION; compression: CompressionProps; @@ -404,7 +391,3 @@ export interface ConfirmationRequest { export interface LoopDetectionConfirmationRequest { onComplete: (result: { userSelection: 'disable' | 'keep' }) => void; } - -export interface QuitConfirmationRequest { - onConfirm: (shouldQuit: boolean, action?: string) => void; -} From e1ffaec49998a281d3e77aea68531ef04357ca72 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sun, 23 Nov 2025 19:36:31 +0800 Subject: [PATCH 03/38] feat: create draft framework for cli & sdk --- .vscode/launch.json | 1 - eslint.config.js | 10 +- packages/cli/src/config/config.ts | 50 +- packages/cli/src/gemini.test.tsx | 8 +- packages/cli/src/gemini.tsx | 27 +- .../control/ControlDispatcher.ts | 22 +- .../nonInteractive/control/ControlService.ts | 64 +- .../control/controllers/baseController.ts | 4 +- .../controllers/permissionController.ts | 92 +-- .../control/controllers/systemController.ts | 62 +- .../control/types/serviceAPIs.ts | 24 +- .../io/BaseJsonOutputAdapter.ts | 61 +- .../cli/src/nonInteractive/session.test.ts | 2 +- packages/cli/src/nonInteractive/session.ts | 656 +++++----------- packages/cli/src/nonInteractive/types.ts | 14 +- packages/cli/src/nonInteractiveCli.ts | 58 +- .../subagents/manage/AgentSelectionStep.tsx | 2 +- packages/core/src/config/config.ts | 51 +- packages/core/src/core/coreToolScheduler.ts | 21 +- .../core/src/subagents/subagent-events.ts | 3 +- .../core/src/subagents/subagent-manager.ts | 88 ++- packages/core/src/subagents/types.ts | 3 +- packages/core/src/tools/mcp-tool.ts | 6 +- packages/core/src/tools/shell.ts | 6 +- packages/core/src/tools/tools.ts | 19 +- packages/sdk-typescript/package.json | 68 ++ packages/sdk-typescript/src/index.ts | 66 ++ .../src/mcp/SdkControlServerTransport.ts | 111 +++ .../src/mcp/createSdkMcpServer.ts | 109 +++ packages/sdk-typescript/src/mcp/formatters.ts | 194 +++++ packages/sdk-typescript/src/mcp/tool.ts | 91 +++ packages/sdk-typescript/src/query/Query.ts | 738 ++++++++++++++++++ .../sdk-typescript/src/query/createQuery.ts | 139 ++++ .../src/transport/ProcessTransport.ts | 392 ++++++++++ .../sdk-typescript/src/transport/Transport.ts | 22 + packages/sdk-typescript/src/types/errors.ts | 17 + packages/sdk-typescript/src/types/protocol.ts | 560 +++++++++++++ .../src/types/queryOptionsSchema.ts | 86 ++ packages/sdk-typescript/src/types/types.ts | 57 ++ packages/sdk-typescript/src/utils/Stream.ts | 91 +++ packages/sdk-typescript/src/utils/cliPath.ts | 365 +++++++++ .../sdk-typescript/src/utils/jsonLines.ts | 65 ++ .../test/e2e/abort-and-lifecycle.test.ts | 466 +++++++++++ .../sdk-typescript/test/e2e/control.test.ts | 254 ++++++ .../sdk-typescript/test/e2e/globalSetup.ts | 56 ++ .../test/e2e/mcp-server.test.ts | 610 +++++++++++++++ .../test/e2e/multi-turn.test.ts | 479 ++++++++++++ .../test/e2e/permission-control.test.ts | 676 ++++++++++++++++ .../test/e2e/single-turn.test.ts | 479 ++++++++++++ .../test/unit/ProcessTransport.test.ts | 207 +++++ .../sdk-typescript/test/unit/Query.test.ts | 284 +++++++ .../unit/SdkControlServerTransport.test.ts | 259 ++++++ .../sdk-typescript/test/unit/Stream.test.ts | 255 ++++++ .../sdk-typescript/test/unit/cliPath.test.ts | 668 ++++++++++++++++ .../test/unit/createSdkMcpServer.test.ts | 350 +++++++++ packages/sdk-typescript/tsconfig.json | 41 + packages/sdk-typescript/vitest.config.ts | 40 + vitest.config.ts | 1 + 58 files changed, 8982 insertions(+), 668 deletions(-) create mode 100644 packages/sdk-typescript/package.json create mode 100644 packages/sdk-typescript/src/index.ts create mode 100644 packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts create mode 100644 packages/sdk-typescript/src/mcp/createSdkMcpServer.ts create mode 100644 packages/sdk-typescript/src/mcp/formatters.ts create mode 100644 packages/sdk-typescript/src/mcp/tool.ts create mode 100644 packages/sdk-typescript/src/query/Query.ts create mode 100644 packages/sdk-typescript/src/query/createQuery.ts create mode 100644 packages/sdk-typescript/src/transport/ProcessTransport.ts create mode 100644 packages/sdk-typescript/src/transport/Transport.ts create mode 100644 packages/sdk-typescript/src/types/errors.ts create mode 100644 packages/sdk-typescript/src/types/protocol.ts create mode 100644 packages/sdk-typescript/src/types/queryOptionsSchema.ts create mode 100644 packages/sdk-typescript/src/types/types.ts create mode 100644 packages/sdk-typescript/src/utils/Stream.ts create mode 100644 packages/sdk-typescript/src/utils/cliPath.ts create mode 100644 packages/sdk-typescript/src/utils/jsonLines.ts create mode 100644 packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts create mode 100644 packages/sdk-typescript/test/e2e/control.test.ts create mode 100644 packages/sdk-typescript/test/e2e/globalSetup.ts create mode 100644 packages/sdk-typescript/test/e2e/mcp-server.test.ts create mode 100644 packages/sdk-typescript/test/e2e/multi-turn.test.ts create mode 100644 packages/sdk-typescript/test/e2e/permission-control.test.ts create mode 100644 packages/sdk-typescript/test/e2e/single-turn.test.ts create mode 100644 packages/sdk-typescript/test/unit/ProcessTransport.test.ts create mode 100644 packages/sdk-typescript/test/unit/Query.test.ts create mode 100644 packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts create mode 100644 packages/sdk-typescript/test/unit/Stream.test.ts create mode 100644 packages/sdk-typescript/test/unit/cliPath.test.ts create mode 100644 packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts create mode 100644 packages/sdk-typescript/tsconfig.json create mode 100644 packages/sdk-typescript/vitest.config.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index d98757fb..0ae4f1b1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -79,7 +79,6 @@ "--", "-p", "${input:prompt}", - "-y", "--output-format", "stream-json" ], diff --git a/eslint.config.js b/eslint.config.js index 7b4f502f..8a35ef6f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -150,7 +150,7 @@ export default tseslint.config( }, }, { - files: ['packages/*/src/**/*.test.{ts,tsx}'], + files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'], plugins: { vitest, }, @@ -158,6 +158,14 @@ export default tseslint.config( ...vitest.configs.recommended.rules, 'vitest/expect-expect': 'off', 'vitest/no-commented-out-tests': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, // extra settings for scripts that we run directly with node diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3162638f..a35ef293 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -6,6 +6,7 @@ import { ApprovalMode, + AuthType, Config, DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, @@ -133,6 +134,10 @@ export interface CliArgs { continue: boolean | undefined; /** Resume a specific session by its ID */ resume: string | undefined; + maxSessionTurns: number | undefined; + coreTools: string[] | undefined; + excludeTools: string[] | undefined; + authType: string | undefined; } function normalizeOutputFormat( @@ -411,6 +416,31 @@ export async function parseArguments(settings: Settings): Promise { description: 'Resume a specific session by its ID. Use without an ID to show session picker.', }) + .option('max-session-turns', { + type: 'number', + description: 'Maximum number of session turns', + }) + .option('core-tools', { + type: 'array', + string: true, + description: 'Core tool paths', + coerce: (tools: string[]) => + // Handle comma-separated values + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('exclude-tools', { + type: 'array', + string: true, + description: 'Tools to exclude', + coerce: (tools: string[]) => + // Handle comma-separated values + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('auth-type', { + type: 'string', + choices: [AuthType.USE_OPENAI, AuthType.QWEN_OAUTH], + description: 'Authentication type', + }) .deprecateOption( 'show-memory-usage', 'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.', @@ -745,8 +775,14 @@ export async function loadCliConfig( interactive = false; } // In non-interactive mode, exclude tools that require a prompt. + // However, if stream-json input is used, control can be requested via JSON messages, + // so tools should not be excluded in that case. const extraExcludes: string[] = []; - if (!interactive && !argv.experimentalAcp) { + if ( + !interactive && + !argv.experimentalAcp && + inputFormat !== InputFormat.STREAM_JSON + ) { switch (approvalMode) { case ApprovalMode.PLAN: case ApprovalMode.DEFAULT: @@ -770,6 +806,7 @@ export async function loadCliConfig( settings, activeExtensions, extraExcludes.length > 0 ? extraExcludes : undefined, + argv.excludeTools, ); const blockedMcpServers: Array<{ name: string; extensionName: string }> = []; @@ -850,7 +887,7 @@ export async function loadCliConfig( debugMode, question, fullContext: argv.allFiles || false, - coreTools: settings.tools?.core || undefined, + coreTools: argv.coreTools || settings.tools?.core || undefined, allowedTools: argv.allowedTools || settings.tools?.allowed || undefined, excludeTools, toolDiscoveryCommand: settings.tools?.discoveryCommand, @@ -883,13 +920,16 @@ export async function loadCliConfig( model: resolvedModel, extensionContextFilePaths, sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1, - maxSessionTurns: settings.model?.maxSessionTurns ?? -1, + maxSessionTurns: + argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, experimentalZedIntegration: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, extensions: allExtensions, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], - authType: settings.security?.auth?.selectedType, + authType: + (argv.authType as AuthType | undefined) || + settings.security?.auth?.selectedType, inputFormat, outputFormat, includePartialMessages, @@ -997,8 +1037,10 @@ function mergeExcludeTools( settings: Settings, extensions: Extension[], extraExcludes?: string[] | undefined, + cliExcludeTools?: string[] | undefined, ): string[] { const allExcludeTools = new Set([ + ...(cliExcludeTools || []), ...(settings.tools?.exclude || []), ...(extraExcludes || []), ]); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index a9bc3d9e..81d34fe1 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -272,7 +272,7 @@ describe('gemini.tsx main function', () => { ); vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined); - vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {}); + vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => { }); const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup); runExitCleanupMock.mockResolvedValue(undefined); vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]); @@ -481,6 +481,10 @@ describe('gemini.tsx main function kitty protocol', () => { includePartialMessages: undefined, continue: undefined, resume: undefined, + coreTools: undefined, + excludeTools: undefined, + authType: undefined, + maxSessionTurns: undefined, }); await main(); @@ -494,7 +498,7 @@ describe('validateDnsResolutionOrder', () => { let consoleWarnSpy: ReturnType; beforeEach(() => { - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); }); afterEach(() => { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 310ef6b7..8210d5d5 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -383,7 +383,18 @@ export async function main() { setMaxSizedBoxDebugging(isDebugMode); - const initializationResult = await initializeApp(config, settings); + // Check input format early to determine initialization flow + const inputFormat = + typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + + // For stream-json mode, defer config.initialize() until after the initialize control request + // For other modes, initialize normally + let initializationResult: InitializationResult | undefined; + if (inputFormat !== InputFormat.STREAM_JSON) { + initializationResult = await initializeApp(config, settings); + } if ( settings.merged.security?.auth?.selectedType === @@ -417,19 +428,15 @@ export async function main() { settings, startupWarnings, process.cwd(), - initializationResult, + initializationResult!, ); return; } - await config.initialize(); - - // Check input format BEFORE reading stdin - // In STREAM_JSON mode, stdin should be left for StreamJsonInputReader - const inputFormat = - typeof config.getInputFormat === 'function' - ? config.getInputFormat() - : InputFormat.TEXT; + // For non-stream-json mode, initialize config here + if (inputFormat !== InputFormat.STREAM_JSON) { + await config.initialize(); + } // Only read stdin if NOT in stream-json mode // In stream-json mode, stdin is used for protocol messages (control requests, etc.) diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts index fa1b0e0f..b2165ee9 100644 --- a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts @@ -26,7 +26,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 { PermissionController } from './controllers/permissionController.js'; // import { MCPController } from './controllers/mcpController.js'; // import { HookController } from './controllers/hookController.js'; import type { @@ -64,7 +64,7 @@ export class ControlDispatcher implements IPendingRequestRegistry { // Make controllers publicly accessible readonly systemController: SystemController; - // readonly permissionController: PermissionController; + readonly permissionController: PermissionController; // readonly mcpController: MCPController; // readonly hookController: HookController; @@ -83,11 +83,11 @@ export class ControlDispatcher implements IPendingRequestRegistry { this, 'SystemController', ); - // this.permissionController = new PermissionController( - // context, - // this, - // 'PermissionController', - // ); + this.permissionController = new PermissionController( + context, + this, + 'PermissionController', + ); // this.mcpController = new MCPController(context, this, 'MCPController'); // this.hookController = new HookController(context, this, 'HookController'); @@ -230,7 +230,7 @@ export class ControlDispatcher implements IPendingRequestRegistry { // Cleanup controllers (MCP controller will close all clients) this.systemController.cleanup(); - // this.permissionController.cleanup(); + this.permissionController.cleanup(); // this.mcpController.cleanup(); // this.hookController.cleanup(); } @@ -302,9 +302,9 @@ export class ControlDispatcher implements IPendingRequestRegistry { case 'supported_commands': return this.systemController; - // case 'can_use_tool': - // case 'set_permission_mode': - // return this.permissionController; + case 'can_use_tool': + case 'set_permission_mode': + return this.permissionController; // case 'mcp_message': // case 'mcp_server_status': diff --git a/packages/cli/src/nonInteractive/control/ControlService.ts b/packages/cli/src/nonInteractive/control/ControlService.ts index 7193fb63..671a1853 100644 --- a/packages/cli/src/nonInteractive/control/ControlService.ts +++ b/packages/cli/src/nonInteractive/control/ControlService.ts @@ -29,7 +29,7 @@ import type { IControlContext } from './ControlContext.js'; import type { ControlDispatcher } from './ControlDispatcher.js'; import type { - // PermissionServiceAPI, + PermissionServiceAPI, SystemServiceAPI, // McpServiceAPI, // HookServiceAPI, @@ -61,43 +61,31 @@ export class ControlService { * Handles tool execution permissions, approval checks, and callbacks. * Delegates to the shared PermissionController instance. */ - // get permission(): PermissionServiceAPI { - // const controller = this.dispatcher.permissionController; - // return { - // /** - // * Check if a tool should be allowed based on current permission settings - // * - // * Evaluates permission mode and tool registry to determine if execution - // * should proceed. Can optionally modify tool arguments based on confirmation details. - // * - // * @param toolRequest - Tool call request information - // * @param confirmationDetails - Optional confirmation details for UI - // * @returns Permission decision with optional updated arguments - // */ - // shouldAllowTool: controller.shouldAllowTool.bind(controller), - // - // /** - // * Build UI suggestions for tool confirmation dialogs - // * - // * Creates actionable permission suggestions based on tool confirmation details. - // * - // * @param confirmationDetails - Tool confirmation details - // * @returns Array of permission suggestions or null - // */ - // buildPermissionSuggestions: - // controller.buildPermissionSuggestions.bind(controller), - // - // /** - // * Get callback for monitoring tool call status updates - // * - // * Returns callback function for integration with CoreToolScheduler. - // * - // * @returns Callback function for tool call updates - // */ - // getToolCallUpdateCallback: - // controller.getToolCallUpdateCallback.bind(controller), - // }; - // } + get permission(): PermissionServiceAPI { + const controller = this.dispatcher.permissionController; + return { + /** + * Build UI suggestions for tool confirmation dialogs + * + * Creates actionable permission suggestions based on tool confirmation details. + * + * @param confirmationDetails - Tool confirmation details + * @returns Array of permission suggestions or null + */ + buildPermissionSuggestions: + controller.buildPermissionSuggestions.bind(controller), + + /** + * Get callback for monitoring tool call status updates + * + * Returns callback function for integration with CoreToolScheduler. + * + * @returns Callback function for tool call updates + */ + getToolCallUpdateCallback: + controller.getToolCallUpdateCallback.bind(controller), + }; + } /** * System Domain API diff --git a/packages/cli/src/nonInteractive/control/controllers/baseController.ts b/packages/cli/src/nonInteractive/control/controllers/baseController.ts index d2e20545..90b7f56a 100644 --- a/packages/cli/src/nonInteractive/control/controllers/baseController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/baseController.ts @@ -174,7 +174,5 @@ export abstract class BaseController { /** * Cleanup resources */ - cleanup(): void { - // Subclasses can override to add cleanup logic - } + cleanup(): void {} } diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index f93b4489..08c6d41f 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -15,8 +15,10 @@ */ import type { - ToolCallRequestInfo, WaitingToolCall, + ToolExecuteConfirmationDetails, + ToolMcpConfirmationDetails, + ApprovalMode, } from '@qwen-code/qwen-code-core'; import { InputFormat, @@ -206,6 +208,7 @@ export class PermissionController extends BaseController { } this.context.permissionMode = mode; + this.context.config.setApprovalMode(mode as ApprovalMode); if (this.context.debugMode) { console.error( @@ -334,47 +337,6 @@ export class PermissionController extends BaseController { } } - /** - * Check if a tool should be executed based on current permission settings - * - * This is a convenience method for direct tool execution checks without - * going through the control request flow. - */ - async shouldAllowTool( - toolRequest: ToolCallRequestInfo, - confirmationDetails?: unknown, - ): Promise<{ - allowed: boolean; - message?: string; - updatedArgs?: Record; - }> { - // Check permission mode - const modeResult = this.checkPermissionMode(); - if (!modeResult.allowed) { - return { - allowed: false, - message: modeResult.message, - }; - } - - // Check tool registry - const registryResult = this.checkToolRegistry(toolRequest.name); - if (!registryResult.allowed) { - return { - allowed: false, - message: registryResult.message, - }; - } - - // If we have confirmation details, we could potentially modify args - // This is a hook for future enhancement - if (confirmationDetails) { - // Future: handle argument modifications based on confirmation details - } - - return { allowed: true }; - } - /** * Get callback for monitoring tool calls and handling outgoing permission requests * This is passed to executeToolCall to hook into CoreToolScheduler updates @@ -430,17 +392,14 @@ 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, - 30000, - ); + 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); if (response.subtype !== 'success') { await toolCall.confirmationDetails.onConfirm( @@ -462,8 +421,15 @@ export class PermissionController extends BaseController { ToolConfirmationOutcome.ProceedOnce, ); } else { + // Extract cancel message from response if available + const cancelMessage = + typeof payload['message'] === 'string' + ? payload['message'] + : undefined; + await toolCall.confirmationDetails.onConfirm( ToolConfirmationOutcome.Cancel, + cancelMessage ? { cancelMessage } : undefined, ); } } catch (error) { @@ -473,9 +439,23 @@ export class PermissionController extends BaseController { error, ); } - await toolCall.confirmationDetails.onConfirm( - ToolConfirmationOutcome.Cancel, - ); + // On error, use default cancel message + // Only pass payload for exec and mcp types that support it + const confirmationType = toolCall.confirmationDetails.type; + if (confirmationType === 'exec' || confirmationType === 'mcp') { + const execOrMcpDetails = toolCall.confirmationDetails as + | ToolExecuteConfirmationDetails + | ToolMcpConfirmationDetails; + await execOrMcpDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + undefined, + ); + } else { + // For other types, don't pass payload (backward compatible) + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + } } finally { this.pendingOutgoingRequests.delete(toolCall.request.callId); } diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index c3fc651b..a33ea161 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -55,12 +55,68 @@ export class SystemController extends BaseController { payload: CLIControlInitializeRequest, ): Promise> { // Register SDK MCP servers if provided - if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) { - for (const serverName of payload.sdkMcpServers) { + if (payload.sdkMcpServers && typeof payload.sdkMcpServers === 'object') { + for (const serverName of Object.keys(payload.sdkMcpServers)) { this.context.sdkMcpServers.add(serverName); } + + // Add SDK MCP servers to config + 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, + ); + } + } } + // Add MCP servers to config if provided + 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`, + ); + } + } catch (error) { + if (this.context.debugMode) { + console.error('[SystemController] Failed to add MCP servers:', error); + } + } + } + + // Add session subagents to config if provided + if (payload.agents && Array.isArray(payload.agents)) { + try { + this.context.config.addSessionSubagents(payload.agents); + + if (this.context.debugMode) { + console.error( + `[SystemController] Added ${payload.agents.length} session subagents to config`, + ); + } + } catch (error) { + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to add session subagents:', + error, + ); + } + } + } + + // Set SDK mode to true after handling initialize + this.context.config.setSdkMode(true); + // Build capabilities for response const capabilities = this.buildControlCapabilities(); @@ -86,7 +142,7 @@ export class SystemController extends BaseController { buildControlCapabilities(): Record { const capabilities: Record = { can_handle_can_use_tool: true, - can_handle_hook_callback: true, + can_handle_hook_callback: false, can_set_permission_mode: typeof this.context.config.setApprovalMode === 'function', can_set_model: typeof this.context.config.setModel === 'function', diff --git a/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts b/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts index c83637b7..9137d95a 100644 --- a/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts +++ b/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts @@ -13,10 +13,7 @@ */ import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import type { - ToolCallRequestInfo, - MCPServerConfig, -} from '@qwen-code/qwen-code-core'; +import type { MCPServerConfig } from '@qwen-code/qwen-code-core'; import type { PermissionSuggestion } from '../../types.js'; /** @@ -26,25 +23,6 @@ import type { PermissionSuggestion } from '../../types.js'; * permission suggestions, and tool call monitoring callbacks. */ export interface PermissionServiceAPI { - /** - * Check if a tool should be allowed based on current permission settings - * - * Evaluates permission mode and tool registry to determine if execution - * should proceed. Can optionally modify tool arguments based on confirmation details. - * - * @param toolRequest - Tool call request information containing name, args, and call ID - * @param confirmationDetails - Optional confirmation details for UI-driven approvals - * @returns Promise resolving to permission decision with optional updated arguments - */ - shouldAllowTool( - toolRequest: ToolCallRequestInfo, - confirmationDetails?: unknown, - ): Promise<{ - allowed: boolean; - message?: string; - updatedArgs?: Record; - }>; - /** * Build UI suggestions for tool confirmation dialogs * diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index 3968c5cc..551ea9ff 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -939,9 +939,25 @@ export abstract class BaseJsonOutputAdapter { this.emitMessageImpl(message); } + /** + * Checks if responseParts contain any functionResponse with an error. + * This handles cancelled responses and other error cases where the error + * is embedded in responseParts rather than the top-level error field. + * @param responseParts - Array of Part objects + * @returns Error message if found, undefined otherwise + */ + private checkResponsePartsForError( + responseParts: Part[] | undefined, + ): string | undefined { + // Use the shared helper function defined at file level + return checkResponsePartsForError(responseParts); + } + /** * Emits a tool result message. * Collects execution denied tool calls for inclusion in result messages. + * Handles both explicit errors (response.error) and errors embedded in + * responseParts (e.g., cancelled responses). * @param request - Tool call request info * @param response - Tool call response info * @param parentToolUseId - Parent tool use ID (null for main agent) @@ -951,6 +967,14 @@ export abstract class BaseJsonOutputAdapter { response: ToolCallResponseInfo, parentToolUseId: string | null = null, ): void { + // Check for errors in responseParts (e.g., cancelled responses) + const responsePartsError = this.checkResponsePartsForError( + response.responseParts, + ); + + // Determine if this is an error response + const hasError = Boolean(response.error) || Boolean(responsePartsError); + // Track permission denials (execution denied errors) if ( response.error && @@ -967,7 +991,7 @@ export abstract class BaseJsonOutputAdapter { const block: ToolResultBlock = { type: 'tool_result', tool_use_id: request.callId, - is_error: Boolean(response.error), + is_error: hasError, }; const content = toolResultContent(response); if (content !== undefined) { @@ -1173,11 +1197,41 @@ export function partsToString(parts: Part[]): string { .join(''); } +/** + * Checks if responseParts contain any functionResponse with an error. + * Helper function for extracting error messages from responseParts. + * @param responseParts - Array of Part objects + * @returns Error message if found, undefined otherwise + */ +function checkResponsePartsForError( + responseParts: Part[] | undefined, +): string | undefined { + if (!responseParts || responseParts.length === 0) { + return undefined; + } + + for (const part of responseParts) { + if ( + 'functionResponse' in part && + part.functionResponse?.response && + typeof part.functionResponse.response === 'object' && + 'error' in part.functionResponse.response && + part.functionResponse.response['error'] + ) { + const error = part.functionResponse.response['error']; + return typeof error === 'string' ? error : String(error); + } + } + + return undefined; +} + /** * Extracts content from tool response. * Uses functionResponsePartsToString to properly handle functionResponse parts, * which correctly extracts output content from functionResponse objects rather * than simply concatenating text or JSON.stringify. + * Also handles errors embedded in responseParts (e.g., cancelled responses). * * @param response - Tool call response * @returns String content or undefined @@ -1188,6 +1242,11 @@ export function toolResultContent( if (response.error) { return response.error.message; } + // Check for errors in responseParts (e.g., cancelled responses) + const responsePartsError = checkResponsePartsForError(response.responseParts); + if (responsePartsError) { + return responsePartsError; + } if ( typeof response.resultDisplay === 'string' && response.resultDisplay.trim().length > 0 diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 61643fb3..15f15954 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -134,7 +134,7 @@ function createControlCancel(requestId: string): ControlCancelRequest { }; } -describe('runNonInteractiveStreamJson', () => { +describe('runNonInteractiveStreamJson (refactored)', () => { let config: Config; let mockInputReader: { read: () => AsyncGenerator< diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index 614208b7..7cfa92c0 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -4,17 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** - * Stream JSON Runner with Session State Machine - * - * Handles stream-json input/output format with: - * - Initialize handshake - * - Message routing (control vs user messages) - * - FIFO user message queue - * - Sequential message processing - * - Graceful shutdown - */ - import type { Config } from '@qwen-code/qwen-code-core'; import { StreamJsonInputReader } from './io/StreamJsonInputReader.js'; import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js'; @@ -42,48 +31,7 @@ import { createMinimalSettings } from '../config/settings.js'; import { runNonInteractive } from '../nonInteractiveCli.js'; import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; -const SESSION_STATE = { - INITIALIZING: 'initializing', - IDLE: 'idle', - PROCESSING_QUERY: 'processing_query', - SHUTTING_DOWN: 'shutting_down', -} as const; - -type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE]; - -/** - * Message type classification for routing - */ -type MessageType = - | 'control_request' - | 'control_response' - | 'control_cancel' - | 'user' - | 'assistant' - | 'system' - | 'result' - | 'stream_event' - | 'unknown'; - -/** - * Routed message with classification - */ -interface RoutedMessage { - type: MessageType; - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest; -} - -/** - * Session Manager - * - * Manages the session lifecycle and message processing state machine. - */ -class SessionManager { - private state: SessionState = SESSION_STATE.INITIALIZING; +class Session { private userMessageQueue: CLIUserMessage[] = []; private abortController: AbortController; private config: Config; @@ -98,6 +46,9 @@ class SessionManager { private debugMode: boolean; private shutdownHandler: (() => void) | null = null; private initialPrompt: CLIUserMessage | null = null; + private processingPromise: Promise | null = null; + private isShuttingDown: boolean = false; + private configInitialized: boolean = false; constructor(config: Config, initialPrompt?: CLIUserMessage) { this.config = config; @@ -112,146 +63,31 @@ class SessionManager { config.getIncludePartialMessages(), ); - // Setup signal handlers for graceful shutdown this.setupSignalHandlers(); } - /** - * Get next prompt ID - */ private getNextPromptId(): string { this.promptIdCounter++; return `${this.sessionId}########${this.promptIdCounter}`; } - /** - * Route a message to the appropriate handler based on its type - * - * Classifies incoming messages and routes them to appropriate handlers. - */ - private route( - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest, - ): RoutedMessage { - // Check control messages first - if (isControlRequest(message)) { - return { type: 'control_request', message }; - } - if (isControlResponse(message)) { - return { type: 'control_response', message }; - } - if (isControlCancel(message)) { - return { type: 'control_cancel', message }; + private async ensureConfigInitialized(): Promise { + if (this.configInitialized) { + return; } - // Check data messages - if (isCLIUserMessage(message)) { - return { type: 'user', message }; - } - if (isCLIAssistantMessage(message)) { - return { type: 'assistant', message }; - } - if (isCLISystemMessage(message)) { - return { type: 'system', message }; - } - if (isCLIResultMessage(message)) { - return { type: 'result', message }; - } - if (isCLIPartialAssistantMessage(message)) { - return { type: 'stream_event', message }; - } - - // Unknown message type if (this.debugMode) { - console.error( - '[SessionManager] Unknown message type:', - JSON.stringify(message, null, 2), - ); - } - return { type: 'unknown', message }; - } - - /** - * Process a single message with unified logic for both initial prompt and stream messages. - * - * Handles: - * - Abort check - * - First message detection and handling - * - Normal message processing - * - Shutdown state checks - * - * @param message - Message to process - * @returns true if the calling code should exit (break/return), false to continue - */ - private async processSingleMessage( - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest, - ): Promise { - // Check for abort - if (this.abortController.signal.aborted) { - return true; + console.error('[Session] Initializing config'); } - // Handle first message if control system not yet initialized - if (this.controlSystemEnabled === null) { - const handled = await this.handleFirstMessage(message); - if (handled) { - // If handled, check if we should shutdown - return this.state === SESSION_STATE.SHUTTING_DOWN; - } - // If not handled, fall through to normal processing - } - - // Process message normally - await this.processMessage(message); - - // Check for shutdown after processing - return this.state === SESSION_STATE.SHUTTING_DOWN; - } - - /** - * Main entry point - run the session - */ - async run(): Promise { try { - if (this.debugMode) { - console.error('[SessionManager] Starting session', this.sessionId); - } - - // Process initial prompt if provided - if (this.initialPrompt !== null) { - const shouldExit = await this.processSingleMessage(this.initialPrompt); - if (shouldExit) { - await this.shutdown(); - return; - } - } - - // Process messages from stream - for await (const message of this.inputReader.read()) { - const shouldExit = await this.processSingleMessage(message); - if (shouldExit) { - break; - } - } - - // Stream closed, shutdown - await this.shutdown(); + await this.config.initialize(); + this.configInitialized = true; } catch (error) { if (this.debugMode) { - console.error('[SessionManager] Error:', error); + console.error('[Session] Failed to initialize config:', error); } - await this.shutdown(); throw error; - } finally { - // Ensure signal handlers are always cleaned up even if shutdown wasn't called - this.cleanupSignalHandlers(); } } @@ -259,14 +95,6 @@ class SessionManager { if (this.controlContext && this.dispatcher && this.controlService) { return; } - // The control system follows a strict three-layer architecture: - // 1. ControlContext (shared session state) - // 2. ControlDispatcher (protocol routing SDK ↔ CLI) - // 3. ControlService (programmatic API for CLI runtime) - // - // Application code MUST interact with the control plane exclusively through - // ControlService. ControlDispatcher is reserved for protocol-level message - // routing and should never be used directly outside of this file. this.controlContext = new ControlContext({ config: this.config, streamJson: this.outputAdapter, @@ -299,25 +127,32 @@ class SessionManager { | CLIControlResponse | ControlCancelRequest, ): Promise { - const routed = this.route(message); - - if (routed.type === 'control_request') { - const request = routed.message as CLIControlRequest; + 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); - this.state = SESSION_STATE.IDLE; + + // After handling initialize control request, initialize the config + // This is the SDK mode where config initialization is deferred + await this.ensureConfigInitialized(); return true; } - return false; + if (this.debugMode) { + console.error( + '[Session] Ignoring non-initialize control request during initialization', + ); + } + return true; } - if (routed.type === 'user') { + if (isCLIUserMessage(message)) { this.controlSystemEnabled = false; - this.state = SESSION_STATE.PROCESSING_QUERY; - this.userMessageQueue.push(routed.message as CLIUserMessage); - await this.processUserMessageQueue(); + // For non-SDK mode (direct user message), initialize config if not already done + await this.ensureConfigInitialized(); + this.enqueueUserMessage(message as CLIUserMessage); return true; } @@ -325,241 +160,50 @@ class SessionManager { return false; } - /** - * Process a single message from the stream - */ - private async processMessage( - message: - | CLIMessage - | CLIControlRequest - | CLIControlResponse - | ControlCancelRequest, + private async handleControlRequest( + request: CLIControlRequest, ): Promise { - const routed = this.route(message); - - if (this.debugMode) { - console.error( - `[SessionManager] State: ${this.state}, Message type: ${routed.type}`, - ); - } - - switch (this.state) { - case SESSION_STATE.INITIALIZING: - await this.handleInitializingState(routed); - break; - - case SESSION_STATE.IDLE: - await this.handleIdleState(routed); - break; - - case SESSION_STATE.PROCESSING_QUERY: - await this.handleProcessingState(routed); - break; - - case SESSION_STATE.SHUTTING_DOWN: - // Ignore all messages during shutdown - break; - - default: { - // Exhaustive check - const _exhaustiveCheck: never = this.state; - if (this.debugMode) { - console.error('[SessionManager] Unknown state:', _exhaustiveCheck); - } - break; - } - } - } - - /** - * Handle messages in initializing state - */ - private async handleInitializingState(routed: RoutedMessage): Promise { - if (routed.type === 'control_request') { - const request = routed.message as CLIControlRequest; - const dispatcher = this.getDispatcher(); - if (!dispatcher) { - if (this.debugMode) { - console.error( - '[SessionManager] Control request received before control system initialization', - ); - } - return; - } - if (request.request.subtype === 'initialize') { - await dispatcher.dispatch(request); - this.state = SESSION_STATE.IDLE; - if (this.debugMode) { - console.error('[SessionManager] Initialized, transitioning to idle'); - } - } else { - if (this.debugMode) { - console.error( - '[SessionManager] Ignoring non-initialize control request during initialization', - ); - } - } - } else { - if (this.debugMode) { - console.error( - '[SessionManager] Ignoring non-control message during initialization', - ); - } - } - } - - /** - * Handle messages in idle state - */ - private async handleIdleState(routed: RoutedMessage): Promise { const dispatcher = this.getDispatcher(); - if (routed.type === 'control_request') { - if (!dispatcher) { - if (this.debugMode) { - console.error('[SessionManager] Ignoring control request (disabled)'); - } - return; - } - const request = routed.message as CLIControlRequest; - await dispatcher.dispatch(request); - // Stay in idle state - } else if (routed.type === 'control_response') { - if (!dispatcher) { - return; - } - const response = routed.message as CLIControlResponse; - dispatcher.handleControlResponse(response); - // Stay in idle state - } else if (routed.type === 'control_cancel') { - if (!dispatcher) { - return; - } - const cancelRequest = routed.message as ControlCancelRequest; - dispatcher.handleCancel(cancelRequest.request_id); - } else if (routed.type === 'user') { - const userMessage = routed.message as CLIUserMessage; - this.userMessageQueue.push(userMessage); - // Start processing queue - await this.processUserMessageQueue(); - } else { + if (!dispatcher) { if (this.debugMode) { - console.error( - '[SessionManager] Ignoring message type in idle state:', - routed.type, - ); + console.error('[Session] Control system not enabled'); } - } - } - - /** - * Handle messages in processing state - */ - private async handleProcessingState(routed: RoutedMessage): Promise { - const dispatcher = this.getDispatcher(); - if (routed.type === 'control_request') { - if (!dispatcher) { - if (this.debugMode) { - console.error( - '[SessionManager] Control request ignored during processing (disabled)', - ); - } - return; - } - const request = routed.message as CLIControlRequest; - await dispatcher.dispatch(request); - // Continue processing - } else if (routed.type === 'control_response') { - if (!dispatcher) { - return; - } - const response = routed.message as CLIControlResponse; - dispatcher.handleControlResponse(response); - // Continue processing - } else if (routed.type === 'user') { - // Enqueue for later - const userMessage = routed.message as CLIUserMessage; - this.userMessageQueue.push(userMessage); - if (this.debugMode) { - console.error( - '[SessionManager] Enqueued user message during processing', - ); - } - } else { - if (this.debugMode) { - console.error( - '[SessionManager] Ignoring message type during processing:', - routed.type, - ); - } - } - } - - /** - * Process user message queue (FIFO) - */ - private async processUserMessageQueue(): Promise { - while ( - this.userMessageQueue.length > 0 && - !this.abortController.signal.aborted - ) { - this.state = SESSION_STATE.PROCESSING_QUERY; - const userMessage = this.userMessageQueue.shift()!; - - try { - await this.processUserMessage(userMessage); - } catch (error) { - if (this.debugMode) { - console.error( - '[SessionManager] Error processing user message:', - error, - ); - } - // Send error result - this.emitErrorResult(error); - } - } - - // If control system is disabled (single-query mode) and queue is empty, - // automatically shutdown instead of returning to idle - if ( - !this.abortController.signal.aborted && - this.state === SESSION_STATE.PROCESSING_QUERY && - this.controlSystemEnabled === false && - this.userMessageQueue.length === 0 - ) { - if (this.debugMode) { - console.error( - '[SessionManager] Single-query mode: queue processed, shutting down', - ); - } - this.state = SESSION_STATE.SHUTTING_DOWN; return; } - // Return to idle after processing queue (for multi-query mode with control system) - if ( - !this.abortController.signal.aborted && - this.state === SESSION_STATE.PROCESSING_QUERY - ) { - this.state = SESSION_STATE.IDLE; - if (this.debugMode) { - console.error('[SessionManager] Queue processed, returning to idle'); - } - } + await dispatcher.dispatch(request); + } + + private handleControlResponse(response: CLIControlResponse): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + return; + } + + dispatcher.handleControlResponse(response); + } + + private handleControlCancel(cancelRequest: ControlCancelRequest): void { + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + return; + } + + dispatcher.handleCancel(cancelRequest.request_id); } - /** - * Process a single user message - */ private async processUserMessage(userMessage: CLIUserMessage): Promise { const input = extractUserMessageText(userMessage); if (!input) { if (this.debugMode) { - console.error('[SessionManager] No text content in user message'); + console.error('[Session] No text content in user message'); } return; } + // Ensure config is initialized before processing user messages + await this.ensureConfigInitialized(); + const promptId = this.getNextPromptId(); try { @@ -575,16 +219,56 @@ class SessionManager { }, ); } catch (error) { - // Error already handled by runNonInteractive via adapter.emitResult if (this.debugMode) { - console.error('[SessionManager] Query execution error:', error); + console.error('[Session] Query execution error:', error); } } } - /** - * Send tool results as user message - */ + private async processUserMessageQueue(): Promise { + if (this.isShuttingDown || this.abortController.signal.aborted) { + return; + } + + while ( + this.userMessageQueue.length > 0 && + !this.isShuttingDown && + !this.abortController.signal.aborted + ) { + const userMessage = this.userMessageQueue.shift()!; + try { + await this.processUserMessage(userMessage); + } catch (error) { + if (this.debugMode) { + console.error('[Session] Error processing user message:', error); + } + this.emitErrorResult(error); + } + } + } + + private enqueueUserMessage(userMessage: CLIUserMessage): void { + this.userMessageQueue.push(userMessage); + this.ensureProcessingStarted(); + } + + private ensureProcessingStarted(): void { + if (this.processingPromise) { + return; + } + + this.processingPromise = this.processUserMessageQueue().finally(() => { + this.processingPromise = null; + if ( + this.userMessageQueue.length > 0 && + !this.isShuttingDown && + !this.abortController.signal.aborted + ) { + this.ensureProcessingStarted(); + } + }); + } + private emitErrorResult( error: unknown, numTurns: number = 0, @@ -602,52 +286,51 @@ class SessionManager { }); } - /** - * Handle interrupt control request - */ private handleInterrupt(): void { if (this.debugMode) { - console.error('[SessionManager] Interrupt requested'); - } - // Abort current query if processing - if (this.state === SESSION_STATE.PROCESSING_QUERY) { - this.abortController.abort(); - this.abortController = new AbortController(); // Create new controller for next query + console.error('[Session] Interrupt requested'); } + this.abortController.abort(); + this.abortController = new AbortController(); } - /** - * Setup signal handlers for graceful shutdown - */ private setupSignalHandlers(): void { this.shutdownHandler = () => { if (this.debugMode) { - console.error('[SessionManager] Shutdown signal received'); + console.error('[Session] Shutdown signal received'); } + this.isShuttingDown = true; this.abortController.abort(); - this.state = SESSION_STATE.SHUTTING_DOWN; }; process.on('SIGINT', this.shutdownHandler); process.on('SIGTERM', this.shutdownHandler); } - /** - * Shutdown session and cleanup resources - */ private async shutdown(): Promise { if (this.debugMode) { - console.error('[SessionManager] Shutting down'); + console.error('[Session] Shutting down'); + } + + 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, + ); + } + } } - this.state = SESSION_STATE.SHUTTING_DOWN; this.dispatcher?.shutdown(); this.cleanupSignalHandlers(); } - /** - * Remove signal handlers to prevent memory leaks - */ private cleanupSignalHandlers(): void { if (this.shutdownHandler) { process.removeListener('SIGINT', this.shutdownHandler); @@ -655,6 +338,94 @@ class SessionManager { this.shutdownHandler = null; } } + + async run(): Promise { + try { + if (this.debugMode) { + console.error('[Session] Starting session', this.sessionId); + } + + if (this.initialPrompt !== null) { + const handled = await this.handleFirstMessage(this.initialPrompt); + if (handled && this.isShuttingDown) { + await this.shutdown(); + return; + } + } + + try { + for await (const message of this.inputReader.read()) { + if (this.abortController.signal.aborted) { + break; + } + + if (this.controlSystemEnabled === null) { + const handled = await this.handleFirstMessage(message); + if (handled) { + if (this.isShuttingDown) { + break; + } + continue; + } + } + + if (isControlRequest(message)) { + await this.handleControlRequest(message as CLIControlRequest); + } else if (isControlResponse(message)) { + this.handleControlResponse(message as CLIControlResponse); + } else if (isControlCancel(message)) { + this.handleControlCancel(message as ControlCancelRequest); + } else if (isCLIUserMessage(message)) { + this.enqueueUserMessage(message as CLIUserMessage); + } else if (this.debugMode) { + if ( + !isCLIAssistantMessage(message) && + !isCLISystemMessage(message) && + !isCLIResultMessage(message) && + !isCLIPartialAssistantMessage(message) + ) { + console.error( + '[Session] Unknown message type:', + JSON.stringify(message, null, 2), + ); + } + } + + if (this.isShuttingDown) { + break; + } + } + } catch (streamError) { + if (this.debugMode) { + console.error('[Session] Stream reading error:', streamError); + } + 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); + } + } + } + + await this.shutdown(); + } catch (error) { + if (this.debugMode) { + console.error('[Session] Error:', error); + } + await this.shutdown(); + throw error; + } finally { + this.cleanupSignalHandlers(); + } + } } function extractUserMessageText(message: CLIUserMessage): string | null { @@ -682,12 +453,6 @@ function extractUserMessageText(message: CLIUserMessage): string | null { return null; } -/** - * Entry point for stream-json mode - * - * @param config - Configuration object - * @param input - Optional initial prompt input to process before reading from stream - */ export async function runNonInteractiveStreamJson( config: Config, input: string, @@ -698,7 +463,6 @@ export async function runNonInteractiveStreamJson( consolePatcher.patch(); try { - // Create initial user message from prompt input if provided let initialPrompt: CLIUserMessage | undefined = undefined; if (input && input.trim().length > 0) { const sessionId = config.getSessionId(); @@ -713,7 +477,7 @@ export async function runNonInteractiveStreamJson( }; } - const manager = new SessionManager(config, initialPrompt); + const manager = new Session(config, initialPrompt); await manager.run(); } finally { consolePatcher.cleanup(); diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 784ea916..2eec24c1 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -1,4 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + MCPServerConfig, + SubagentConfig, +} from '@qwen-code/qwen-code-core'; /** * Annotation for attaching metadata to content blocks @@ -295,10 +299,18 @@ export interface CLIControlPermissionRequest { blocked_path: string | null; } +export enum AuthProviderType { + DYNAMIC_DISCOVERY = 'dynamic_discovery', + GOOGLE_CREDENTIALS = 'google_credentials', + SERVICE_ACCOUNT_IMPERSONATION = 'service_account_impersonation', +} + export interface CLIControlInitializeRequest { subtype: 'initialize'; hooks?: HookRegistration[] | null; - sdkMcpServers?: string[]; + sdkMcpServers?: Record; + mcpServers?: Record; + agents?: SubagentConfig[]; } export interface CLIControlSetPermissionModeRequest { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 8e5a9c90..77e4f980 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -15,6 +15,7 @@ import { FatalInputError, promptIdContext, OutputFormat, + InputFormat, uiTelemetryService, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; @@ -225,40 +226,14 @@ export async function runNonInteractive( for (const requestInfo of toolCallRequests) { const finalRequestInfo = requestInfo; - /* - if (options.controlService) { - const permissionResult = - await options.controlService.permission.shouldAllowTool( - requestInfo, - ); - if (!permissionResult.allowed) { - if (config.getDebugMode()) { - console.error( - `[runNonInteractive] Tool execution denied: ${requestInfo.name}`, - permissionResult.message ?? '', - ); - } - if (adapter && permissionResult.message) { - adapter.emitSystemMessage('tool_denied', { - tool: requestInfo.name, - message: permissionResult.message, - }); - } - continue; - } - - if (permissionResult.updatedArgs) { - finalRequestInfo = { - ...requestInfo, - args: permissionResult.updatedArgs, - }; - } - } - - const toolCallUpdateCallback = options.controlService - ? options.controlService.permission.getToolCallUpdateCallback() - : undefined; - */ + const inputFormat = + typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + const toolCallUpdateCallback = + inputFormat === InputFormat.STREAM_JSON && options.controlService + ? options.controlService.permission.getToolCallUpdateCallback() + : undefined; // Only pass outputUpdateHandler for Task tool const isTaskTool = finalRequestInfo.name === 'task'; @@ -277,13 +252,13 @@ export async function runNonInteractive( isTaskTool && taskToolProgressHandler ? { outputUpdateHandler: taskToolProgressHandler, - /* - toolCallUpdateCallback - ? { onToolCallsUpdate: toolCallUpdateCallback } - : undefined, - */ + onToolCallsUpdate: toolCallUpdateCallback, } - : undefined, + : toolCallUpdateCallback + ? { + onToolCallsUpdate: toolCallUpdateCallback, + } + : undefined, ); // Note: In JSON mode, subagent messages are automatically added to the main @@ -303,9 +278,6 @@ export async function runNonInteractive( ? toolResponse.resultDisplay : undefined, ); - // Note: We no longer emit a separate system message for tool errors - // in JSON/STREAM_JSON mode, as the error is already captured in the - // tool_result block with is_error=true. } if (adapter) { diff --git a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx index 613ac87e..a186374d 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx @@ -218,7 +218,7 @@ export const AgentSelectionStep = ({ const renderAgentItem = ( agent: { name: string; - level: 'project' | 'user' | 'builtin'; + level: 'project' | 'user' | 'builtin' | 'session'; isBuiltin?: boolean; }, index: number, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 59baba85..29757ff6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -65,6 +65,7 @@ import { ideContextStore } from '../ide/ideContext.js'; import { InputFormat, OutputFormat } from '../output/types.js'; import { PromptRegistry } from '../prompts/prompt-registry.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; +import type { SubagentConfig } from '../subagents/types.js'; import { DEFAULT_OTLP_ENDPOINT, DEFAULT_TELEMETRY_TARGET, @@ -333,9 +334,11 @@ export interface ConfigParameters { eventEmitter?: EventEmitter; useSmartEdit?: boolean; output?: OutputSettings; - skipStartupContext?: boolean; inputFormat?: InputFormat; outputFormat?: OutputFormat; + skipStartupContext?: boolean; + sdkMode?: boolean; + sessionSubagents?: SubagentConfig[]; } function normalizeConfigOutputFormat( @@ -383,8 +386,10 @@ export class Config { private readonly toolDiscoveryCommand: string | undefined; private readonly toolCallCommand: string | undefined; private readonly mcpServerCommand: string | undefined; - private readonly mcpServers: Record | undefined; + private mcpServers: Record | undefined; + private sessionSubagents: SubagentConfig[]; private userMemory: string; + private sdkMode: boolean; private geminiMdFileCount: number; private approvalMode: ApprovalMode; private readonly showMemoryUsage: boolean; @@ -487,6 +492,8 @@ export class Config { this.toolCallCommand = params.toolCallCommand; this.mcpServerCommand = params.mcpServerCommand; this.mcpServers = params.mcpServers; + this.sessionSubagents = params.sessionSubagents ?? []; + this.sdkMode = params.sdkMode ?? false; this.userMemory = params.userMemory ?? ''; this.geminiMdFileCount = params.geminiMdFileCount ?? 0; this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT; @@ -842,6 +849,46 @@ export class Config { return this.mcpServers; } + setMcpServers(servers: Record): void { + if (this.initialized) { + throw new Error('Cannot modify mcpServers after initialization'); + } + this.mcpServers = servers; + } + + addMcpServers(servers: Record): void { + if (this.initialized) { + throw new Error('Cannot modify mcpServers after initialization'); + } + this.mcpServers = { ...this.mcpServers, ...servers }; + } + + getSessionSubagents(): SubagentConfig[] { + return this.sessionSubagents; + } + + setSessionSubagents(subagents: SubagentConfig[]): void { + if (this.initialized) { + throw new Error('Cannot modify sessionSubagents after initialization'); + } + this.sessionSubagents = subagents; + } + + addSessionSubagents(subagents: SubagentConfig[]): void { + if (this.initialized) { + throw new Error('Cannot modify sessionSubagents after initialization'); + } + this.sessionSubagents = [...this.sessionSubagents, ...subagents]; + } + + getSdkMode(): boolean { + return this.sdkMode; + } + + setSdkMode(value: boolean): void { + this.sdkMode = value; + } + getUserMemory(): string { return this.userMemory; } diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 493758dc..93f3b6e1 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -916,7 +916,10 @@ export class CoreToolScheduler { async handleConfirmationResponse( callId: string, - originalOnConfirm: (outcome: ToolConfirmationOutcome) => Promise, + originalOnConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise, outcome: ToolConfirmationOutcome, signal: AbortSignal, payload?: ToolConfirmationPayload, @@ -925,9 +928,7 @@ export class CoreToolScheduler { (c) => c.request.callId === callId && c.status === 'awaiting_approval', ); - if (toolCall && toolCall.status === 'awaiting_approval') { - await originalOnConfirm(outcome); - } + await originalOnConfirm(outcome, payload); if (outcome === ToolConfirmationOutcome.ProceedAlways) { await this.autoApproveCompatiblePendingTools(signal, callId); @@ -936,11 +937,10 @@ export class CoreToolScheduler { this.setToolCallOutcome(callId, outcome); if (outcome === ToolConfirmationOutcome.Cancel || signal.aborted) { - this.setStatusInternal( - callId, - 'cancelled', - 'User did not allow tool call', - ); + // Use custom cancel message from payload if provided, otherwise use default + const cancelMessage = + payload?.cancelMessage || 'User did not allow tool call'; + this.setStatusInternal(callId, 'cancelled', cancelMessage); } else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { const waitingToolCall = toolCall as WaitingToolCall; if (isModifiableDeclarativeTool(waitingToolCall.tool)) { @@ -998,7 +998,8 @@ export class CoreToolScheduler { ): Promise { if ( toolCall.confirmationDetails.type !== 'edit' || - !isModifiableDeclarativeTool(toolCall.tool) + !isModifiableDeclarativeTool(toolCall.tool) || + !payload.newContent ) { return; } diff --git a/packages/core/src/subagents/subagent-events.ts b/packages/core/src/subagents/subagent-events.ts index eb318f54..3c93112d 100644 --- a/packages/core/src/subagents/subagent-events.ts +++ b/packages/core/src/subagents/subagent-events.ts @@ -8,6 +8,7 @@ import { EventEmitter } from 'events'; import type { ToolCallConfirmationDetails, ToolConfirmationOutcome, + ToolResultDisplay, } from '../tools/tools.js'; import type { Part } from '@google/genai'; @@ -74,7 +75,7 @@ export interface SubAgentToolResultEvent { success: boolean; error?: string; responseParts?: Part[]; - resultDisplay?: string; + resultDisplay?: ToolResultDisplay; durationMs?: number; timestamp: number; } diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 8dcab0de..d83e3e7a 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -77,6 +77,15 @@ export class SubagentManager { ): Promise { this.validator.validateOrThrow(config); + // Prevent creating session-level agents + if (options.level === 'session') { + throw new SubagentError( + `Cannot create session-level subagent "${config.name}". Session agents are read-only and provided at runtime.`, + SubagentErrorCode.INVALID_CONFIG, + config.name, + ); + } + // Determine file path const filePath = options.customPath || this.getSubagentPath(config.name, options.level); @@ -142,6 +151,11 @@ export class SubagentManager { return BuiltinAgentRegistry.getBuiltinAgent(name); } + if (level === 'session') { + const sessionSubagents = this.subagentsCache?.get('session') || []; + return sessionSubagents.find((agent) => agent.name === name) || null; + } + return this.findSubagentByNameAtLevel(name, level); } @@ -191,6 +205,15 @@ export class SubagentManager { ); } + // Prevent updating session-level agents + if (existing.level === 'session') { + throw new SubagentError( + `Cannot update session-level subagent "${name}"`, + SubagentErrorCode.INVALID_CONFIG, + name, + ); + } + // Merge updates with existing configuration const updatedConfig = this.mergeConfigurations(existing, updates); @@ -236,8 +259,8 @@ export class SubagentManager { let deleted = false; for (const currentLevel of levelsToCheck) { - // Skip builtin level for deletion - if (currentLevel === 'builtin') { + // Skip builtin and session levels for deletion + if (currentLevel === 'builtin' || currentLevel === 'session') { continue; } @@ -277,6 +300,38 @@ export class SubagentManager { const subagents: SubagentConfig[] = []; const seenNames = new Set(); + // In SDK mode, only load session-level subagents + if (this.config.getSdkMode()) { + const sessionSubagents = this.config.getSessionSubagents(); + if (sessionSubagents && sessionSubagents.length > 0) { + this.loadSessionSubagents(sessionSubagents); + } + + const levelsToCheck: SubagentLevel[] = options.level + ? [options.level] + : ['session']; + + for (const level of levelsToCheck) { + const levelSubagents = this.subagentsCache?.get(level) || []; + + for (const subagent of levelSubagents) { + // Apply tool filter if specified + if ( + options.hasTool && + (!subagent.tools || !subagent.tools.includes(options.hasTool)) + ) { + continue; + } + + subagents.push(subagent); + seenNames.add(subagent.name); + } + } + + return subagents; + } + + // Normal mode: load from project, user, and builtin levels const levelsToCheck: SubagentLevel[] = options.level ? [options.level] : ['project', 'user', 'builtin']; @@ -322,8 +377,8 @@ export class SubagentManager { comparison = a.name.localeCompare(b.name); break; case 'level': { - // Project comes before user, user comes before builtin - const levelOrder = { project: 0, user: 1, builtin: 2 }; + // Project comes before user, user comes before builtin, session comes last + const levelOrder = { project: 0, user: 1, builtin: 2, session: 3 }; comparison = levelOrder[a.level] - levelOrder[b.level]; break; } @@ -339,6 +394,27 @@ export class SubagentManager { return subagents; } + /** + * Loads session-level subagents into the cache. + * Session subagents are provided directly via config and are read-only. + * + * @param subagents - Array of session subagent configurations + */ + loadSessionSubagents(subagents: SubagentConfig[]): void { + if (!this.subagentsCache) { + this.subagentsCache = new Map(); + } + + const sessionSubagents = subagents.map((config) => ({ + ...config, + level: 'session' as SubagentLevel, + filePath: ``, + })); + + this.subagentsCache.set('session', sessionSubagents); + this.notifyChangeListeners(); + } + /** * Refreshes the subagents cache by loading all subagents from disk. * This method is called automatically when cache is null or when force=true. @@ -693,6 +769,10 @@ export class SubagentManager { return ``; } + if (level === 'session') { + return ``; + } + const baseDir = level === 'project' ? path.join( diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index 67b78a50..0f83e3f1 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -11,8 +11,9 @@ import type { Content, FunctionDeclaration } from '@google/genai'; * - 'project': Stored in `.qwen/agents/` within the project directory * - 'user': Stored in `~/.qwen/agents/` in the user's home directory * - 'builtin': Built-in agents embedded in the codebase, always available + * - 'session': Session-level agents provided at runtime, read-only */ -export type SubagentLevel = 'project' | 'user' | 'builtin'; +export type SubagentLevel = 'project' | 'user' | 'builtin' | 'session'; /** * Core configuration for a subagent as stored in Markdown files. diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index afffa103..15f461e9 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -10,6 +10,7 @@ import type { ToolInvocation, ToolMcpConfirmationDetails, ToolResult, + ToolConfirmationPayload, } from './tools.js'; import { BaseDeclarativeTool, @@ -98,7 +99,10 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< serverName: this.serverName, toolName: this.serverToolName, // Display original tool name in confirmation toolDisplayName: this.displayName, // Display global registry name exposed to model and user - onConfirm: async (outcome: ToolConfirmationOutcome) => { + onConfirm: async ( + outcome: ToolConfirmationOutcome, + _payload?: ToolConfirmationPayload, + ) => { if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) { DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey); } else if (outcome === ToolConfirmationOutcome.ProceedAlwaysTool) { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 17e40dbe..8ff3047e 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -17,6 +17,7 @@ import type { ToolResultDisplay, ToolCallConfirmationDetails, ToolExecuteConfirmationDetails, + ToolConfirmationPayload, } from './tools.js'; import { BaseDeclarativeTool, @@ -102,7 +103,10 @@ export class ShellToolInvocation extends BaseToolInvocation< title: 'Confirm Shell Command', command: this.params.command, rootCommand: commandsToConfirm.join(', '), - onConfirm: async (outcome: ToolConfirmationOutcome) => { + onConfirm: async ( + outcome: ToolConfirmationOutcome, + _payload?: ToolConfirmationPayload, + ) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { commandsToConfirm.forEach((command) => this.allowlist.add(command)); } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 848b14c6..7b3c893e 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -531,13 +531,18 @@ export interface ToolEditConfirmationDetails { export interface ToolConfirmationPayload { // used to override `modifiedProposedContent` for modifiable tools in the // inline modify flow - newContent: string; + newContent?: string; + // used to provide custom cancellation message when outcome is Cancel + cancelMessage?: string; } export interface ToolExecuteConfirmationDetails { type: 'exec'; title: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; command: string; rootCommand: string; } @@ -548,7 +553,10 @@ export interface ToolMcpConfirmationDetails { serverName: string; toolName: string; toolDisplayName: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; } export interface ToolInfoConfirmationDetails { @@ -573,6 +581,11 @@ export interface ToolPlanConfirmationDetails { onConfirm: (outcome: ToolConfirmationOutcome) => Promise; } +/** + * TODO: + * 1. support explicit denied outcome + * 2. support proceed with modified input + */ export enum ToolConfirmationOutcome { ProceedOnce = 'proceed_once', ProceedAlways = 'proceed_always', diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json new file mode 100644 index 00000000..067d1d22 --- /dev/null +++ b/packages/sdk-typescript/package.json @@ -0,0 +1,68 @@ +{ + "name": "@qwen-code/sdk-typescript", + "version": "0.1.0", + "description": "TypeScript SDK for programmatic access to qwen-code CLI", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src test", + "lint:fix": "eslint src test --fix", + "clean": "rm -rf dist", + "prepublishOnly": "npm run clean && npm run build" + }, + "keywords": [ + "qwen", + "qwen-code", + "ai", + "code-assistant", + "sdk", + "typescript" + ], + "author": "Qwen Team", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitest/coverage-v8": "^1.6.0", + "eslint": "^8.57.0", + "typescript": "^5.4.5", + "vitest": "^1.6.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/qwen-ai/qwen-code.git", + "directory": "packages/sdk/typescript" + }, + "bugs": { + "url": "https://github.com/qwen-ai/qwen-code/issues" + }, + "homepage": "https://github.com/qwen-ai/qwen-code#readme" +} diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts new file mode 100644 index 00000000..c732c6ff --- /dev/null +++ b/packages/sdk-typescript/src/index.ts @@ -0,0 +1,66 @@ +export { query } from './query/createQuery.js'; + +export { Query } from './query/Query.js'; + +export type { ExternalMcpServerConfig } from './types/queryOptionsSchema.js'; + +export type { QueryOptions } from './query/createQuery.js'; + +export type { + ContentBlock, + TextBlock, + ThinkingBlock, + ToolUseBlock, + ToolResultBlock, + CLIUserMessage, + CLIAssistantMessage, + CLISystemMessage, + CLIResultMessage, + CLIPartialAssistantMessage, + CLIMessage, +} from './types/protocol.js'; + +export { + isCLIUserMessage, + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + isCLIPartialAssistantMessage, +} from './types/protocol.js'; + +export { AbortError, isAbortError } from './types/errors.js'; + +export { ControlRequestType } from './types/protocol.js'; + +export { ProcessTransport } from './transport/ProcessTransport.js'; +export type { Transport } from './transport/Transport.js'; + +export { Stream } from './utils/Stream.js'; +export { + serializeJsonLine, + parseJsonLineSafe, + isValidMessage, + parseJsonLinesStream, +} from './utils/jsonLines.js'; +export { + findCliPath, + resolveCliPath, + prepareSpawnInfo, +} from './utils/cliPath.js'; +export type { SpawnInfo } from './utils/cliPath.js'; + +export { createSdkMcpServer } from './mcp/createSdkMcpServer.js'; +export { + tool, + createTool, + validateToolName, + validateInputSchema, +} from './mcp/tool.js'; + +export type { + JSONSchema, + ToolDefinition, + PermissionMode, + CanUseTool, + PermissionResult, +} from './types/types.js'; diff --git a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts new file mode 100644 index 00000000..c160a9af --- /dev/null +++ b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts @@ -0,0 +1,111 @@ +/** + * SdkControlServerTransport - bridges MCP Server with Query's control plane + * + * Implements @modelcontextprotocol/sdk Transport interface to enable + * SDK-embedded MCP servers. Messages flow bidirectionally: + * + * MCP Server → send() → Query → control_request (mcp_message) → CLI + * CLI → control_request (mcp_message) → Query → handleMessage() → MCP Server + */ + +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; + +export type SendToQueryCallback = (message: JSONRPCMessage) => Promise; + +export interface SdkControlServerTransportOptions { + sendToQuery: SendToQueryCallback; + serverName: string; +} + +export class SdkControlServerTransport { + sendToQuery: SendToQueryCallback; + private serverName: string; + private started = false; + + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + + constructor(options: SdkControlServerTransportOptions) { + this.sendToQuery = options.sendToQuery; + this.serverName = options.serverName; + } + + async start(): Promise { + this.started = true; + } + + async send(message: JSONRPCMessage): Promise { + if (!this.started) { + throw new Error( + `SdkControlServerTransport (${this.serverName}) not started. Call start() first.`, + ); + } + + try { + // Send via Query's control plane + await this.sendToQuery(message); + } catch (error) { + // Invoke error callback if set + if (this.onerror) { + this.onerror(error instanceof Error ? error : new Error(String(error))); + } + throw error; + } + } + + async close(): Promise { + if (!this.started) { + return; // Already closed + } + + this.started = false; + + // Notify MCP Server + if (this.onclose) { + this.onclose(); + } + } + + handleMessage(message: JSONRPCMessage): void { + if (!this.started) { + console.warn( + `[SdkControlServerTransport] Received message for closed transport (${this.serverName})`, + ); + return; + } + + if (this.onmessage) { + this.onmessage(message); + } else { + console.warn( + `[SdkControlServerTransport] No onmessage handler set for ${this.serverName}`, + ); + } + } + + handleError(error: Error): void { + if (this.onerror) { + this.onerror(error); + } else { + console.error( + `[SdkControlServerTransport] Error for ${this.serverName}:`, + error, + ); + } + } + + isStarted(): boolean { + return this.started; + } + + getServerName(): string { + return this.serverName; + } +} + +export function createSdkControlServerTransport( + options: SdkControlServerTransportOptions, +): SdkControlServerTransport { + return new SdkControlServerTransport(options); +} diff --git a/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts new file mode 100644 index 00000000..841440e1 --- /dev/null +++ b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts @@ -0,0 +1,109 @@ +/** + * 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. + */ + +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'; +import { validateToolName } from './tool.js'; +import type { z } from 'zod'; + +type CallToolResult = z.infer; + +export function createSdkMcpServer( + name: string, + version: string, + tools: ToolDefinition[], +): Server { + // Validate server name + if (!name || typeof name !== 'string') { + throw new Error('MCP server name must be a non-empty string'); + } + + if (!version || typeof version !== 'string') { + throw new Error('MCP server version must be a non-empty string'); + } + + if (!Array.isArray(tools)) { + throw new Error('Tools must be an array'); + } + + // Validate tool names are unique + const toolNames = new Set(); + 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}'`, + ); + } + toolNames.add(tool.name); + } + + // Create MCP Server instance + const server = new Server( + { + name, + version, + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + // Create tool map for fast lookup + const toolMap = new Map(); + for (const tool of tools) { + toolMap.set(tool.name, tool); + } + + // 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; +} diff --git a/packages/sdk-typescript/src/mcp/formatters.ts b/packages/sdk-typescript/src/mcp/formatters.ts new file mode 100644 index 00000000..a71e12ff --- /dev/null +++ b/packages/sdk-typescript/src/mcp/formatters.ts @@ -0,0 +1,194 @@ +/** + * Tool result formatting utilities for MCP responses + * + * Converts various output types to MCP content blocks. + */ + +export type McpContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'resource'; uri: string; mimeType?: string; text?: string }; + +export interface ToolResult { + content: McpContentBlock[]; + isError?: boolean; +} + +export function formatToolResult(result: unknown): ToolResult { + // Handle Error objects + if (result instanceof Error) { + return { + content: [ + { + type: 'text', + text: result.message || 'Unknown error', + }, + ], + isError: true, + }; + } + + // Handle null/undefined + if (result === null || result === undefined) { + return { + content: [ + { + type: 'text', + text: '', + }, + ], + }; + } + + // Handle string + if (typeof result === 'string') { + return { + content: [ + { + type: 'text', + text: result, + }, + ], + }; + } + + // Handle number + if (typeof result === 'number') { + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + + // Handle boolean + if (typeof result === 'boolean') { + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + + // Handle object (including arrays) + if (typeof result === 'object') { + try { + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch { + // JSON.stringify failed + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; + } + } + + // Fallback: convert to string + return { + content: [ + { + type: 'text', + text: String(result), + }, + ], + }; +} + +export function formatToolError(error: Error | string): ToolResult { + const message = error instanceof Error ? error.message : error; + + return { + content: [ + { + type: 'text', + text: message, + }, + ], + isError: true, + }; +} + +export function formatTextResult(text: string): ToolResult { + return { + content: [ + { + type: 'text', + text, + }, + ], + }; +} + +export function formatJsonResult(data: unknown): ToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +export function mergeToolResults(results: ToolResult[]): ToolResult { + const mergedContent: McpContentBlock[] = []; + let hasError = false; + + for (const result of results) { + mergedContent.push(...result.content); + if (result.isError) { + hasError = true; + } + } + + return { + content: mergedContent, + isError: hasError, + }; +} + +export function isValidContentBlock(block: unknown): block is McpContentBlock { + if (!block || typeof block !== 'object') { + return false; + } + + const blockObj = block as Record; + + if (!blockObj.type || typeof blockObj.type !== 'string') { + return false; + } + + switch (blockObj.type) { + case 'text': + return typeof blockObj.text === 'string'; + + case 'image': + return ( + typeof blockObj.data === 'string' && + typeof blockObj.mimeType === 'string' + ); + + case 'resource': + return typeof blockObj.uri === 'string'; + + default: + return false; + } +} diff --git a/packages/sdk-typescript/src/mcp/tool.ts b/packages/sdk-typescript/src/mcp/tool.ts new file mode 100644 index 00000000..667bf5e5 --- /dev/null +++ b/packages/sdk-typescript/src/mcp/tool.ts @@ -0,0 +1,91 @@ +/** + * Tool definition helper for SDK-embedded MCP servers + * + * Provides type-safe tool definitions with generic input/output types. + */ + +import type { ToolDefinition } from '../types/types.js'; + +export function tool( + def: ToolDefinition, +): ToolDefinition { + // Validate tool definition + if (!def.name || typeof def.name !== 'string') { + throw new Error('Tool definition must have a name (string)'); + } + + if (!def.description || typeof def.description !== 'string') { + throw new Error( + `Tool definition for '${def.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 (!def.handler || typeof def.handler !== 'function') { + throw new Error( + `Tool definition for '${def.name}' must have a handler (function)`, + ); + } + + // Return definition (pass-through for type safety) + return def; +} + +export function validateToolName(name: string): void { + if (!name) { + throw new Error('Tool name cannot be empty'); + } + + if (name.length > 64) { + throw new Error( + `Tool name '${name}' is too long (max 64 characters): ${name.length}`, + ); + } + + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) { + throw new Error( + `Tool name '${name}' is invalid. Must start with a letter and contain only letters, numbers, and underscores.`, + ); + } +} + +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; + + 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( + def: ToolDefinition, +): ToolDefinition { + // Validate via tool() function + const validated = tool(def); + + // Additional validation + validateToolName(validated.name); + validateInputSchema(validated.inputSchema); + + return validated; +} diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts new file mode 100644 index 00000000..55d767c5 --- /dev/null +++ b/packages/sdk-typescript/src/query/Query.ts @@ -0,0 +1,738 @@ +/** + * Query class - Main orchestrator for SDK + * + * Manages SDK workflow, routes messages, and handles lifecycle. + * 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; + +import { randomUUID } from 'node:crypto'; +import type { + CLIMessage, + CLIUserMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, + PermissionSuggestion, +} from '../types/protocol.js'; +import { + isCLIUserMessage, + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + isCLIPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, +} from '../types/protocol.js'; +import type { Transport } from '../transport/Transport.js'; +import { type QueryOptions } from '../types/queryOptionsSchema.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 { ControlRequestType } from '../types/protocol.js'; + +interface PendingControlRequest { + resolve: (response: Record | null) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + abortController: AbortController; +} + +interface TransportWithEndInput extends Transport { + endInput(): void; +} + +export class Query implements AsyncIterable { + private transport: Transport; + private options: QueryOptions; + private sessionId: string; + private inputStream: Stream; + private sdkMessages: AsyncGenerator; + private abortController: AbortController; + private pendingControlRequests: Map = + new Map(); + private sdkMcpTransports: Map = new Map(); + readonly initialized: Promise; + private closed = false; + private messageRouterStarted = false; + + private firstResultReceivedPromise?: Promise; + private firstResultReceivedResolve?: () => void; + + private readonly isSingleTurn: boolean; + + constructor( + transport: Transport, + options: QueryOptions, + singleTurn: boolean = false, + ) { + this.transport = transport; + this.options = options; + this.sessionId = randomUUID(); + this.inputStream = new Stream(); + this.abortController = options.abortController ?? new AbortController(); + this.isSingleTurn = singleTurn; + + /** + * Create async generator proxy to ensure stream.next() is called at least once. + * The generator will start iterating when the user begins iteration. + * This ensures readResolve/readReject are set up as soon as iteration starts. + * If errors occur before iteration starts, they'll be stored in hasError and + * properly rejected when the user starts iterating. + */ + this.sdkMessages = this.readSdkMessages(); + + this.firstResultReceivedPromise = new Promise((resolve) => { + this.firstResultReceivedResolve = resolve; + }); + + /** + * Handle abort signal if controller is provided and already aborted or will be aborted. + * If already aborted, set error immediately. Otherwise, listen for abort events + * and set abort error on the stream before closing. + */ + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted by user')); + this.close().catch((err) => { + console.error('[Query] Error during abort cleanup:', err); + }); + } else { + this.abortController.signal.addEventListener('abort', () => { + this.inputStream.error(new AbortError('Query aborted by user')); + this.close().catch((err) => { + console.error('[Query] Error during abort cleanup:', err); + }); + }); + } + + this.initialized = this.initialize(); + this.initialized.catch(() => {}); + + this.startMessageRouter(); + } + + private async initialize(): Promise { + try { + await this.setupSdkMcpServers(); + + const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys()); + + await this.sendControlRequest(ControlRequestType.INITIALIZE, { + hooks: null, + sdkMcpServers: + sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined, + mcpServers: this.options.mcpServers, + }); + } catch (error) { + console.error('[Query] Initialization error:', error); + throw error; + } + } + + private async setupSdkMcpServers(): Promise { + if (!this.options.sdkMcpServers) { + return; + } + + const externalNames = Object.keys(this.options.mcpServers ?? {}); + const sdkNames = Object.keys(this.options.sdkMcpServers); + + const conflicts = sdkNames.filter((name) => externalNames.includes(name)); + if (conflicts.length > 0) { + throw new Error( + `MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`, + ); + } + + /** + * Import SdkControlServerTransport dynamically to avoid circular dependencies. + * Create transport for each server that sends MCP messages via control plane. + */ + const { SdkControlServerTransport } = await import( + '../mcp/SdkControlServerTransport.js' + ); + + for (const [name, server] of Object.entries(this.options.sdkMcpServers)) { + const transport = new SdkControlServerTransport({ + serverName: name, + sendToQuery: async (message: JSONRPCMessage) => { + await this.sendControlRequest(ControlRequestType.MCP_MESSAGE, { + server_name: name, + message, + }); + }, + }); + + await transport.start(); + await server.connect(transport); + this.sdkMcpTransports.set(name, transport); + } + } + + private startMessageRouter(): void { + if (this.messageRouterStarted) { + return; + } + + this.messageRouterStarted = true; + + (async () => { + try { + for await (const message of this.transport.readMessages()) { + await this.routeMessage(message); + + if (this.closed) { + break; + } + } + + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted')); + } else { + this.inputStream.done(); + } + } catch (error) { + this.inputStream.error( + error instanceof Error ? error : new Error(String(error)), + ); + } + })(); + } + + private async routeMessage(message: unknown): Promise { + if (isControlRequest(message)) { + await this.handleControlRequest(message); + return; + } + + if (isControlResponse(message)) { + this.handleControlResponse(message); + return; + } + + if (isControlCancel(message)) { + this.handleControlCancelRequest(message); + return; + } + + if (isCLISystemMessage(message)) { + /** + * SystemMessage contains session info (cwd, tools, model, etc.) + * that should be passed to user. + */ + this.inputStream.enqueue(message); + return; + } + + if (isCLIResultMessage(message)) { + if (this.firstResultReceivedResolve) { + this.firstResultReceivedResolve(); + } + /** + * In single-turn mode, automatically close input after receiving result + * to signal completion to the CLI. + */ + if (this.isSingleTurn && 'endInput' in this.transport) { + (this.transport as TransportWithEndInput).endInput(); + } + this.inputStream.enqueue(message); + return; + } + + if ( + isCLIAssistantMessage(message) || + isCLIUserMessage(message) || + isCLIPartialAssistantMessage(message) + ) { + this.inputStream.enqueue(message); + return; + } + + if (process.env['DEBUG']) { + console.warn('[Query] Unknown message type:', message); + } + this.inputStream.enqueue(message as CLIMessage); + } + + private async handleControlRequest( + request: CLIControlRequest, + ): Promise { + const { request_id, request: payload } = request; + + const requestAbortController = new AbortController(); + + try { + let response: Record | null = null; + + switch (payload.subtype) { + case 'can_use_tool': + response = await this.handlePermissionRequest( + payload.tool_name, + payload.input as Record, + payload.permission_suggestions, + requestAbortController.signal, + ); + break; + + case 'mcp_message': + response = await this.handleMcpMessage( + payload.server_name, + payload.message as unknown as JSONRPCMessage, + ); + break; + + default: + throw new Error( + `Unknown control request subtype: ${payload.subtype}`, + ); + } + + await this.sendControlResponse(request_id, true, response); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + await this.sendControlResponse(request_id, false, errorMessage); + } + } + + private async handlePermissionRequest( + toolName: string, + toolInput: Record, + permissionSuggestions: PermissionSuggestion[] | null, + signal: AbortSignal, + ): Promise> { + /* Default deny all wildcard tool requests */ + if (!this.options.canUseTool) { + return { behavior: 'deny', message: 'Denied' }; + } + + try { + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error('Permission callback timeout')), + PERMISSION_CALLBACK_TIMEOUT, + ); + }); + + const result = await Promise.race([ + Promise.resolve( + this.options.canUseTool(toolName, toolInput, { + signal, + suggestions: permissionSuggestions, + }), + ), + timeoutPromise, + ]); + + // Handle boolean return (backward compatibility) + if (typeof result === 'boolean') { + return result + ? { behavior: 'allow', updatedInput: toolInput } + : { behavior: 'deny', message: 'Denied' }; + } + + // Handle PermissionResult format + const permissionResult = result as { + behavior: 'allow' | 'deny'; + updatedInput?: Record; + message?: string; + interrupt?: boolean; + }; + + if (permissionResult.behavior === 'allow') { + return { + behavior: 'allow', + updatedInput: permissionResult.updatedInput ?? toolInput, + }; + } else { + return { + behavior: 'deny', + message: permissionResult.message ?? 'Denied', + ...(permissionResult.interrupt !== undefined + ? { interrupt: permissionResult.interrupt } + : {}), + }; + } + } catch (error) { + /** + * Timeout or error → deny (fail-safe). + * This ensures that any issues with the permission callback + * result in a safe default of denying access. + */ + const errorMessage = + error instanceof Error ? error.message : String(error); + console.warn( + '[Query] Permission callback error (denying by default):', + errorMessage, + ); + return { + behavior: 'deny', + message: `Permission check failed: ${errorMessage}`, + }; + } + } + + private async handleMcpMessage( + serverName: string, + message: JSONRPCMessage, + ): Promise> { + const transport = this.sdkMcpTransports.get(serverName); + if (!transport) { + throw new Error( + `MCP server '${serverName}' not found in SDK-embedded servers`, + ); + } + + /** + * Check if this is a request (has method and id) or notification. + * Requests need to wait for a response, while notifications are just routed. + */ + const isRequest = + 'method' in message && 'id' in message && message.id !== null; + + if (isRequest) { + const response = await this.handleMcpRequest( + serverName, + message, + transport, + ); + return { mcp_response: response }; + } else { + transport.handleMessage(message); + return { mcp_response: { jsonrpc: '2.0', result: {}, id: 0 } }; + } + } + + private handleMcpRequest( + _serverName: string, + message: JSONRPCMessage, + transport: SdkControlServerTransport, + ): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('MCP request timeout')); + }, MCP_REQUEST_TIMEOUT); + + 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); + }; + + transport.handleMessage(message); + }); + } + + private handleControlResponse(response: CLIControlResponse): void { + const { response: payload } = response; + const request_id = payload.request_id; + + const pending = this.pendingControlRequests.get(request_id); + if (!pending) { + console.warn( + '[Query] Received response for unknown request:', + request_id, + ); + return; + } + + clearTimeout(pending.timeout); + this.pendingControlRequests.delete(request_id); + + if (payload.subtype === 'success') { + pending.resolve(payload.response as Record | null); + } else { + /** + * Extract error message from error field. + * Error can be either a string or an object with a message property. + */ + const errorMessage = + typeof payload.error === 'string' + ? payload.error + : (payload.error?.message ?? 'Unknown error'); + pending.reject(new Error(errorMessage)); + } + } + + private handleControlCancelRequest(request: ControlCancelRequest): void { + const { request_id } = request; + + if (!request_id) { + console.warn('[Query] Received cancel request without request_id'); + return; + } + + const pending = this.pendingControlRequests.get(request_id); + if (pending) { + pending.abortController.abort(); + clearTimeout(pending.timeout); + this.pendingControlRequests.delete(request_id); + pending.reject(new AbortError('Request cancelled')); + } + } + + private async sendControlRequest( + subtype: string, + data: Record = {}, + ): Promise | null> { + const requestId = randomUUID(); + + const request: CLIControlRequest = { + type: 'control_request', + request_id: requestId, + request: { + subtype: subtype as never, + ...data, + } as CLIControlRequest['request'], + }; + + const responsePromise = new Promise | null>( + (resolve, reject) => { + const abortController = new AbortController(); + const timeout = setTimeout(() => { + this.pendingControlRequests.delete(requestId); + reject(new Error(`Control request timeout: ${subtype}`)); + }, CONTROL_REQUEST_TIMEOUT); + + this.pendingControlRequests.set(requestId, { + resolve, + reject, + timeout, + abortController, + }); + }, + ); + + this.transport.write(serializeJsonLine(request)); + return responsePromise; + } + + private async sendControlResponse( + requestId: string, + success: boolean, + responseOrError: Record | null | string, + ): Promise { + const response: CLIControlResponse = { + type: 'control_response', + response: success + ? { + subtype: 'success', + request_id: requestId, + response: responseOrError as Record | null, + } + : { + subtype: 'error', + request_id: requestId, + error: responseOrError as string, + }, + }; + + this.transport.write(serializeJsonLine(response)); + } + + async close(): Promise { + if (this.closed) { + return; + } + + this.closed = true; + + for (const pending of this.pendingControlRequests.values()) { + pending.abortController.abort(); + clearTimeout(pending.timeout); + } + this.pendingControlRequests.clear(); + + await this.transport.close(); + + /** + * Complete input stream - check if aborted first. + * Only set error/done if stream doesn't already have an error state. + */ + if (this.inputStream.hasError === undefined) { + if (this.abortController.signal.aborted) { + this.inputStream.error(new AbortError('Query aborted')); + } else { + this.inputStream.done(); + } + } + + for (const transport of this.sdkMcpTransports.values()) { + try { + await transport.close(); + } catch (error) { + console.error('[Query] Error closing MCP transport:', error); + } + } + this.sdkMcpTransports.clear(); + } + + private async *readSdkMessages(): AsyncGenerator { + for await (const message of this.inputStream) { + yield message; + } + } + + async next(...args: [] | [unknown]): Promise> { + return this.sdkMessages.next(...args); + } + + async return(value?: unknown): Promise> { + return this.sdkMessages.return(value); + } + + async throw(e?: unknown): Promise> { + return this.sdkMessages.throw(e); + } + + [Symbol.asyncIterator](): AsyncIterator { + return this.sdkMessages; + } + + async streamInput(messages: AsyncIterable): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + try { + /** + * Wait for initialization to complete before sending messages. + * This prevents "write after end" errors when streamInput is called + * with an empty iterable before initialization finishes. + */ + await this.initialized; + + for await (const message of messages) { + if (this.abortController.signal.aborted) { + break; + } + this.transport.write(serializeJsonLine(message)); + } + + /** + * 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. + */ + if ( + !this.isSingleTurn && + this.sdkMcpTransports.size > 0 && + this.firstResultReceivedPromise + ) { + await Promise.race([ + this.firstResultReceivedPromise, + new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, STREAM_CLOSE_TIMEOUT); + }), + ]); + } + + this.endInput(); + } catch (error) { + if (this.abortController.signal.aborted) { + console.log('[Query] Aborted during input streaming'); + this.inputStream.error( + new AbortError('Query aborted during input streaming'), + ); + return; + } + throw error; + } + } + + endInput(): void { + if (this.closed) { + throw new Error('Query is closed'); + } + + if ( + 'endInput' in this.transport && + typeof this.transport.endInput === 'function' + ) { + (this.transport as TransportWithEndInput).endInput(); + } + } + + async interrupt(): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + await this.sendControlRequest(ControlRequestType.INTERRUPT); + } + + async setPermissionMode(mode: string): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, { + mode, + }); + } + + async setModel(model: string): Promise { + if (this.closed) { + throw new Error('Query is closed'); + } + + await this.sendControlRequest(ControlRequestType.SET_MODEL, { model }); + } + + /** + * Get list of control commands supported by the CLI + * + * @returns Promise resolving to list of supported command names + * @throws Error if query is closed + */ + async supportedCommands(): Promise | null> { + if (this.closed) { + throw new Error('Query is closed'); + } + + return this.sendControlRequest(ControlRequestType.SUPPORTED_COMMANDS); + } + + /** + * Get the status of MCP servers + * + * @returns Promise resolving to MCP server status information + * @throws Error if query is closed + */ + async mcpServerStatus(): Promise | null> { + if (this.closed) { + throw new Error('Query is closed'); + } + + return this.sendControlRequest(ControlRequestType.MCP_SERVER_STATUS); + } + + getSessionId(): string { + return this.sessionId; + } + + isClosed(): boolean { + return this.closed; + } +} diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts new file mode 100644 index 00000000..4b87478e --- /dev/null +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -0,0 +1,139 @@ +/** + * Factory function for creating Query instances. + */ + +import type { CLIUserMessage } from '../types/protocol.js'; +import { serializeJsonLine } from '../utils/jsonLines.js'; +import { ProcessTransport } from '../transport/ProcessTransport.js'; +import { parseExecutableSpec } from '../utils/cliPath.js'; +import { Query } from './Query.js'; +import { + QueryOptionsSchema, + type QueryOptions, +} from '../types/queryOptionsSchema.js'; + +export type { QueryOptions }; + +export function query({ + prompt, + options = {}, +}: { + prompt: string | AsyncIterable; + options?: QueryOptions; +}): Query { + // Validate options and obtain normalized executable metadata + const parsedExecutable = validateOptions(options); + + // Determine if this is a single-turn or multi-turn query + // Single-turn: string prompt (simple Q&A) + // Multi-turn: AsyncIterable prompt (streaming conversation) + const isSingleTurn = typeof prompt === 'string'; + + // Resolve CLI specification while preserving explicit runtime directives + const pathToQwenExecutable = + options.pathToQwenExecutable ?? parsedExecutable.executablePath; + + // Use provided abortController or create a new one + const abortController = options.abortController ?? new AbortController(); + + // Create transport with abortController + const transport = new ProcessTransport({ + pathToQwenExecutable, + cwd: options.cwd, + model: options.model, + permissionMode: options.permissionMode, + mcpServers: options.mcpServers, + env: options.env, + abortController, + debug: options.debug, + stderr: options.stderr, + maxSessionTurns: options.maxSessionTurns, + coreTools: options.coreTools, + excludeTools: options.excludeTools, + authType: options.authType, + }); + + // Build query options with abortController + const queryOptions: QueryOptions = { + ...options, + abortController, + }; + + // Create Query + const queryInstance = new Query(transport, queryOptions, isSingleTurn); + + // Handle prompt based on type + if (isSingleTurn) { + // For single-turn queries, send the prompt directly via transport + const stringPrompt = prompt as string; + const message: CLIUserMessage = { + type: 'user', + session_id: queryInstance.getSessionId(), + message: { + role: 'user', + content: stringPrompt, + }, + parent_tool_use_id: null, + }; + + (async () => { + try { + await queryInstance.initialized; + transport.write(serializeJsonLine(message)); + } catch (err) { + console.error('[query] Error sending single-turn prompt:', err); + } + })(); + } else { + queryInstance + .streamInput(prompt as AsyncIterable) + .catch((err) => { + console.error('[query] Error streaming input:', err); + }); + } + + return queryInstance; +} + +/** + * Backward compatibility alias + * @deprecated Use query() instead + */ +export const createQuery = query; + +function validateOptions( + options: QueryOptions, +): ReturnType { + // Validate options using Zod schema + const validationResult = QueryOptionsSchema.safeParse(options); + if (!validationResult.success) { + const errors = validationResult.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join('; '); + throw new Error(`Invalid QueryOptions: ${errors}`); + } + + // Validate executable path early to provide clear error messages + let parsedExecutable: ReturnType; + try { + parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`); + } + + // Validate no MCP server name conflicts (cross-field validation not easily expressible in Zod) + if (options.mcpServers && options.sdkMcpServers) { + const externalNames = Object.keys(options.mcpServers); + const sdkNames = Object.keys(options.sdkMcpServers); + + const conflicts = externalNames.filter((name) => sdkNames.includes(name)); + if (conflicts.length > 0) { + throw new Error( + `MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`, + ); + } + } + + return parsedExecutable; +} diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts new file mode 100644 index 00000000..1c717f8c --- /dev/null +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -0,0 +1,392 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import * as readline from 'node:readline'; +import type { Writable, Readable } from 'node:stream'; +import type { TransportOptions } from '../types/types.js'; +import type { Transport } from './Transport.js'; +import { parseJsonLinesStream } from '../utils/jsonLines.js'; +import { prepareSpawnInfo } from '../utils/cliPath.js'; +import { AbortError } from '../types/errors.js'; + +type ExitListener = { + callback: (error?: Error) => void; + handler: (code: number | null, signal: NodeJS.Signals | null) => void; +}; + +export class ProcessTransport implements Transport { + private childProcess: ChildProcess | null = null; + private childStdin: Writable | null = null; + private childStdout: Readable | null = null; + private options: TransportOptions; + private ready = false; + private _exitError: Error | null = null; + private closed = false; + private abortController: AbortController; + private exitListeners: ExitListener[] = []; + private processExitHandler: (() => void) | null = null; + private abortHandler: (() => void) | null = null; + + constructor(options: TransportOptions) { + this.options = options; + this.abortController = + this.options.abortController ?? new AbortController(); + this.initialize(); + } + + private initialize(): void { + try { + if (this.abortController.signal.aborted) { + throw new AbortError('Transport start aborted'); + } + + const cliArgs = this.buildCliArguments(); + const cwd = this.options.cwd ?? process.cwd(); + const env = { ...process.env, ...this.options.env }; + + const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable); + + const stderrMode = + this.options.debug || this.options.stderr ? 'pipe' : 'ignore'; + + this.logForDebugging( + `Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`, + ); + + this.childProcess = spawn( + spawnInfo.command, + [...spawnInfo.args, ...cliArgs], + { + cwd, + env, + stdio: ['pipe', 'pipe', stderrMode], + signal: this.abortController.signal, + }, + ); + + this.childStdin = this.childProcess.stdin; + this.childStdout = this.childProcess.stdout; + + if (this.options.debug || this.options.stderr) { + this.childProcess.stderr?.on('data', (data) => { + this.logForDebugging(data.toString()); + }); + } + + const cleanup = (): void => { + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGTERM'); + } + }; + + this.processExitHandler = cleanup; + this.abortHandler = cleanup; + process.on('exit', this.processExitHandler); + this.abortController.signal.addEventListener('abort', this.abortHandler); + + this.setupEventHandlers(); + + this.ready = true; + } catch (error) { + this.ready = false; + throw error; + } + } + + private setupEventHandlers(): void { + if (!this.childProcess) return; + + this.childProcess.on('error', (error) => { + this.ready = false; + if (this.abortController.signal.aborted) { + this._exitError = new AbortError('CLI process aborted by user'); + } else { + this._exitError = new Error(`CLI process error: ${error.message}`); + this.logForDebugging(this._exitError.message); + } + }); + + this.childProcess.on('close', (code, signal) => { + this.ready = false; + if (this.abortController.signal.aborted) { + this._exitError = new AbortError('CLI process aborted by user'); + } else { + const error = this.getProcessExitError(code, signal); + if (error) { + this._exitError = error; + this.logForDebugging(error.message); + } + } + + const error = this._exitError; + for (const listener of this.exitListeners) { + try { + listener.callback(error || undefined); + } catch (err) { + this.logForDebugging(`Exit listener error: ${err}`); + } + } + }); + } + + private getProcessExitError( + code: number | null, + signal: NodeJS.Signals | null, + ): Error | undefined { + if (code !== 0 && code !== null) { + return new Error(`CLI process exited with code ${code}`); + } else if (signal) { + return new Error(`CLI process terminated by signal ${signal}`); + } + return undefined; + } + private buildCliArguments(): string[] { + const args: string[] = [ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + ]; + + if (this.options.model) { + args.push('--model', this.options.model); + } + + if (this.options.permissionMode) { + args.push('--approval-mode', this.options.permissionMode); + } + + if (this.options.maxSessionTurns !== undefined) { + args.push('--max-session-turns', String(this.options.maxSessionTurns)); + } + + if (this.options.coreTools && this.options.coreTools.length > 0) { + args.push('--core-tools', this.options.coreTools.join(',')); + } + + if (this.options.excludeTools && this.options.excludeTools.length > 0) { + args.push('--exclude-tools', this.options.excludeTools.join(',')); + } + + if (this.options.authType) { + args.push('--auth-type', this.options.authType); + } + + return args; + } + + async close(): Promise { + if (this.childStdin) { + this.childStdin.end(); + this.childStdin = null; + } + + if (this.processExitHandler) { + process.off('exit', this.processExitHandler); + this.processExitHandler = null; + } + + if (this.abortHandler) { + this.abortController.signal.removeEventListener( + 'abort', + this.abortHandler, + ); + this.abortHandler = null; + } + + for (const { handler } of this.exitListeners) { + this.childProcess?.off('close', handler); + } + this.exitListeners = []; + + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGTERM'); + setTimeout(() => { + if (this.childProcess && !this.childProcess.killed) { + this.childProcess.kill('SIGKILL'); + } + }, 5000); + } + + this.ready = false; + this.closed = true; + } + + async waitForExit(): Promise { + if (!this.childProcess) { + if (this._exitError) { + throw this._exitError; + } + return; + } + + if (this.childProcess.exitCode !== null || this.childProcess.killed) { + if (this._exitError) { + throw this._exitError; + } + return; + } + + return new Promise((resolve, reject) => { + const exitHandler = ( + code: number | null, + signal: NodeJS.Signals | null, + ) => { + if (this.abortController.signal.aborted) { + reject(new AbortError('Operation aborted')); + return; + } + + const error = this.getProcessExitError(code, signal); + if (error) { + reject(error); + } else { + resolve(); + } + }; + + this.childProcess!.once('close', exitHandler); + + const errorHandler = (error: Error) => { + this.childProcess!.off('close', exitHandler); + reject(error); + }; + + this.childProcess!.once('error', errorHandler); + + this.childProcess!.once('close', () => { + this.childProcess!.off('error', errorHandler); + }); + }); + } + + write(message: string): void { + if (this.abortController.signal.aborted) { + throw new AbortError('Cannot write: operation aborted'); + } + + if (!this.ready || !this.childStdin) { + throw new Error('Transport not ready for writing'); + } + + if (this.closed) { + throw new Error('Cannot write to closed transport'); + } + + if (this.childStdin.writableEnded) { + throw new Error('Cannot write to ended stream'); + } + + if (this.childProcess?.killed || this.childProcess?.exitCode !== null) { + throw new Error('Cannot write to terminated process'); + } + + if (this._exitError) { + throw new Error( + `Cannot write to process that exited with error: ${this._exitError.message}`, + ); + } + + if (process.env['DEBUG']) { + this.logForDebugging( + `[ProcessTransport] Writing to stdin (${message.length} bytes): ${message.substring(0, 100)}`, + ); + } + + try { + const written = this.childStdin.write(message); + if (!written) { + this.logForDebugging( + `[ProcessTransport] Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`, + ); + } else if (process.env['DEBUG']) { + this.logForDebugging( + `[ProcessTransport] Write successful (${message.length} bytes)`, + ); + } + } catch (error) { + this.ready = false; + throw new Error( + `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + async *readMessages(): AsyncGenerator { + if (!this.childStdout) { + throw new Error('Cannot read messages: process not started'); + } + + const rl = readline.createInterface({ + input: this.childStdout, + crlfDelay: Infinity, + terminal: false, + }); + + try { + for await (const message of parseJsonLinesStream( + rl, + 'ProcessTransport', + )) { + yield message; + } + + await this.waitForExit(); + } finally { + rl.close(); + } + } + + get isReady(): boolean { + return this.ready; + } + + get exitError(): Error | null { + return this._exitError; + } + + onExit(callback: (error?: Error) => void): () => void { + if (!this.childProcess) { + return () => {}; + } + + const handler = (code: number | null, signal: NodeJS.Signals | null) => { + const error = this.getProcessExitError(code, signal); + callback(error); + }; + + this.childProcess.on('close', handler); + this.exitListeners.push({ callback, handler }); + + return () => { + if (this.childProcess) { + this.childProcess.off('close', handler); + } + const index = this.exitListeners.findIndex((l) => l.handler === handler); + if (index !== -1) { + this.exitListeners.splice(index, 1); + } + }; + } + + endInput(): void { + if (this.childStdin) { + this.childStdin.end(); + } + } + + getInputStream(): Writable | undefined { + return this.childStdin || undefined; + } + + getOutputStream(): Readable | undefined { + return this.childStdout || undefined; + } + + private logForDebugging(message: string): void { + if (this.options.debug || process.env['DEBUG']) { + process.stderr.write(`[ProcessTransport] ${message}\n`); + } + if (this.options.stderr) { + this.options.stderr(message); + } + } +} diff --git a/packages/sdk-typescript/src/transport/Transport.ts b/packages/sdk-typescript/src/transport/Transport.ts new file mode 100644 index 00000000..cbfb1b7a --- /dev/null +++ b/packages/sdk-typescript/src/transport/Transport.ts @@ -0,0 +1,22 @@ +/** + * Transport interface for SDK-CLI communication + * + * The Transport abstraction enables communication between SDK and CLI via different mechanisms: + * - ProcessTransport: Local subprocess via stdin/stdout (initial implementation) + * - HttpTransport: Remote CLI via HTTP (future) + * - WebSocketTransport: Remote CLI via WebSocket (future) + */ + +export interface Transport { + close(): Promise; + + waitForExit(): Promise; + + write(message: string): void; + + readMessages(): AsyncGenerator; + + readonly isReady: boolean; + + readonly exitError: Error | null; +} diff --git a/packages/sdk-typescript/src/types/errors.ts b/packages/sdk-typescript/src/types/errors.ts new file mode 100644 index 00000000..21f503a6 --- /dev/null +++ b/packages/sdk-typescript/src/types/errors.ts @@ -0,0 +1,17 @@ +export class AbortError extends Error { + constructor(message = 'Operation aborted') { + super(message); + this.name = 'AbortError'; + Object.setPrototypeOf(this, AbortError.prototype); + } +} + +export function isAbortError(error: unknown): error is AbortError { + return ( + error instanceof AbortError || + (typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError') + ); +} diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts new file mode 100644 index 00000000..399221e0 --- /dev/null +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -0,0 +1,560 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export interface Annotation { + type: string; + value: string; +} + +export interface Usage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + total_tokens?: number; +} + +export interface ExtendedUsage extends Usage { + server_tool_use?: { + web_search_requests: number; + }; + service_tier?: string; + cache_creation?: { + ephemeral_1h_input_tokens: number; + ephemeral_5m_input_tokens: number; + }; +} + +export interface ModelUsage { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + webSearchRequests: number; + contextWindow: number; +} + +export interface CLIPermissionDenial { + tool_name: string; + tool_use_id: string; + tool_input: unknown; +} + +export interface TextBlock { + type: 'text'; + text: string; + annotations?: Annotation[]; +} + +export interface ThinkingBlock { + type: 'thinking'; + thinking: string; + signature?: string; + annotations?: Annotation[]; +} + +export interface ToolUseBlock { + type: 'tool_use'; + id: string; + name: string; + input: unknown; + annotations?: Annotation[]; +} + +export interface ToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + content?: string | ContentBlock[]; + is_error?: boolean; + annotations?: Annotation[]; +} + +export type ContentBlock = + | TextBlock + | ThinkingBlock + | ToolUseBlock + | ToolResultBlock; + +export interface APIUserMessage { + role: 'user'; + content: string | ContentBlock[]; +} + +export interface APIAssistantMessage { + id: string; + type: 'message'; + role: 'assistant'; + model: string; + content: ContentBlock[]; + stop_reason?: string | null; + usage: Usage; +} + +export interface CLIUserMessage { + type: 'user'; + uuid?: string; + session_id: string; + message: APIUserMessage; + parent_tool_use_id: string | null; + options?: Record; +} + +export interface CLIAssistantMessage { + type: 'assistant'; + uuid: string; + session_id: string; + message: APIAssistantMessage; + parent_tool_use_id: string | null; +} + +export interface CLISystemMessage { + type: 'system'; + subtype: string; + uuid: string; + session_id: string; + data?: unknown; + cwd?: string; + tools?: string[]; + mcp_servers?: Array<{ + name: string; + status: string; + }>; + model?: string; + permissionMode?: string; + slash_commands?: string[]; + apiKeySource?: string; + qwen_code_version?: string; + output_style?: string; + agents?: string[]; + skills?: string[]; + capabilities?: Record; + compact_metadata?: { + trigger: 'manual' | 'auto'; + pre_tokens: number; + }; +} + +export interface CLIResultMessageSuccess { + type: 'result'; + subtype: 'success'; + uuid: string; + session_id: string; + is_error: false; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + result: string; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + [key: string]: unknown; +} + +export interface CLIResultMessageError { + type: 'result'; + subtype: 'error_max_turns' | 'error_during_execution'; + uuid: string; + session_id: string; + is_error: true; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + error?: { + type?: string; + message: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError; + +export interface MessageStartStreamEvent { + type: 'message_start'; + message: { + id: string; + role: 'assistant'; + model: string; + }; +} + +export interface ContentBlockStartEvent { + type: 'content_block_start'; + index: number; + content_block: ContentBlock; +} + +export type ContentBlockDelta = + | { + type: 'text_delta'; + text: string; + } + | { + type: 'thinking_delta'; + thinking: string; + } + | { + type: 'input_json_delta'; + partial_json: string; + }; + +export interface ContentBlockDeltaEvent { + type: 'content_block_delta'; + index: number; + delta: ContentBlockDelta; +} + +export interface ContentBlockStopEvent { + type: 'content_block_stop'; + index: number; +} + +export interface MessageStopStreamEvent { + type: 'message_stop'; +} + +export type StreamEvent = + | MessageStartStreamEvent + | ContentBlockStartEvent + | ContentBlockDeltaEvent + | ContentBlockStopEvent + | MessageStopStreamEvent; + +export interface CLIPartialAssistantMessage { + type: 'stream_event'; + uuid: string; + session_id: string; + event: StreamEvent; + parent_tool_use_id: string | null; +} + +export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo'; + +/** + * TODO: Align with `ToolCallConfirmationDetails` + */ +export interface PermissionSuggestion { + type: 'allow' | 'deny' | 'modify'; + label: string; + description?: string; + modifiedInput?: unknown; +} + +export interface HookRegistration { + event: string; + callback_id: string; +} + +export interface HookCallbackResult { + shouldSkip?: boolean; + shouldInterrupt?: boolean; + suppressOutput?: boolean; + message?: string; +} + +export interface CLIControlInterruptRequest { + subtype: 'interrupt'; +} + +export interface CLIControlPermissionRequest { + subtype: 'can_use_tool'; + tool_name: string; + tool_use_id: string; + input: unknown; + permission_suggestions: PermissionSuggestion[] | null; + blocked_path: string | null; +} + +export enum AuthProviderType { + DYNAMIC_DISCOVERY = 'dynamic_discovery', + GOOGLE_CREDENTIALS = 'google_credentials', + SERVICE_ACCOUNT_IMPERSONATION = 'service_account_impersonation', +} + +export interface MCPServerConfig { + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + url?: string; + httpUrl?: string; + headers?: Record; + tcp?: string; + timeout?: number; + trust?: boolean; + description?: string; + includeTools?: string[]; + excludeTools?: string[]; + extensionName?: string; + oauth?: Record; + authProviderType?: AuthProviderType; + targetAudience?: string; + targetServiceAccount?: string; +} + +export interface CLIControlInitializeRequest { + subtype: 'initialize'; + hooks?: HookRegistration[] | null; + sdkMcpServers?: Record; + mcpServers?: Record; + agents?: SubagentConfig[]; +} + +export interface CLIControlSetPermissionModeRequest { + subtype: 'set_permission_mode'; + mode: PermissionMode; +} + +export interface CLIHookCallbackRequest { + subtype: 'hook_callback'; + callback_id: string; + input: unknown; + tool_use_id: string | null; +} + +export interface CLIControlMcpMessageRequest { + subtype: 'mcp_message'; + server_name: string; + message: { + jsonrpc?: string; + method: string; + params?: Record; + id?: string | number | null; + }; +} + +export interface CLIControlSetModelRequest { + subtype: 'set_model'; + model: string; +} + +export interface CLIControlMcpStatusRequest { + subtype: 'mcp_server_status'; +} + +export interface CLIControlSupportedCommandsRequest { + subtype: 'supported_commands'; +} + +export type ControlRequestPayload = + | CLIControlInterruptRequest + | CLIControlPermissionRequest + | CLIControlInitializeRequest + | CLIControlSetPermissionModeRequest + | CLIHookCallbackRequest + | CLIControlMcpMessageRequest + | CLIControlSetModelRequest + | CLIControlMcpStatusRequest + | CLIControlSupportedCommandsRequest; + +export interface CLIControlRequest { + type: 'control_request'; + request_id: string; + request: ControlRequestPayload; +} + +export interface PermissionApproval { + allowed: boolean; + reason?: string; + modifiedInput?: unknown; +} + +export interface ControlResponse { + subtype: 'success'; + request_id: string; + response: unknown; +} + +export interface ControlErrorResponse { + subtype: 'error'; + request_id: string; + error: string | { message: string; [key: string]: unknown }; +} + +export interface CLIControlResponse { + type: 'control_response'; + response: ControlResponse | ControlErrorResponse; +} + +export interface ControlCancelRequest { + type: 'control_cancel_request'; + request_id?: string; +} + +export type ControlMessage = + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest; + +/** + * Union of all CLI message types + */ +export type CLIMessage = + | CLIUserMessage + | CLIAssistantMessage + | CLISystemMessage + | CLIResultMessage + | CLIPartialAssistantMessage; + +export function isCLIUserMessage(msg: any): msg is CLIUserMessage { + return ( + msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg + ); +} + +export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'assistant' && + 'uuid' in msg && + 'message' in msg && + 'session_id' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isCLISystemMessage(msg: any): msg is CLISystemMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'system' && + 'subtype' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isCLIResultMessage(msg: any): msg is CLIResultMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'result' && + 'subtype' in msg && + 'duration_ms' in msg && + 'is_error' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isCLIPartialAssistantMessage( + msg: any, +): msg is CLIPartialAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'stream_event' && + 'uuid' in msg && + 'session_id' in msg && + 'event' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isControlRequest(msg: any): msg is CLIControlRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_request' && + 'request_id' in msg && + 'request' in msg + ); +} + +export function isControlResponse(msg: any): msg is CLIControlResponse { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_response' && + 'response' in msg + ); +} + +export function isControlCancel(msg: any): msg is ControlCancelRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_cancel_request' && + 'request_id' in msg + ); +} + +export function isTextBlock(block: any): block is TextBlock { + return block && typeof block === 'object' && block.type === 'text'; +} + +export function isThinkingBlock(block: any): block is ThinkingBlock { + return block && typeof block === 'object' && block.type === 'thinking'; +} + +export function isToolUseBlock(block: any): block is ToolUseBlock { + return block && typeof block === 'object' && block.type === 'tool_use'; +} + +export function isToolResultBlock(block: any): block is ToolResultBlock { + return block && typeof block === 'object' && block.type === 'tool_result'; +} + +export type SubagentLevel = 'session'; + +export interface ModelConfig { + model?: string; + temp?: number; + top_p?: number; +} + +export interface RunConfig { + max_time_minutes?: number; + max_turns?: number; +} + +export interface SubagentConfig { + name: string; + description: string; + tools?: string[]; + systemPrompt: string; + level: SubagentLevel; + filePath: string; + modelConfig?: Partial; + runConfig?: Partial; + color?: string; + readonly isBuiltin?: boolean; +} + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Request Types + * + * Centralized enum for all control request subtypes supported by the CLI. + * This enum should be kept in sync with the controllers in: + * - packages/cli/src/services/control/controllers/systemController.ts + * - packages/cli/src/services/control/controllers/permissionController.ts + * - packages/cli/src/services/control/controllers/mcpController.ts + * - packages/cli/src/services/control/controllers/hookController.ts + */ +export enum ControlRequestType { + // SystemController requests + INITIALIZE = 'initialize', + INTERRUPT = 'interrupt', + SET_MODEL = 'set_model', + SUPPORTED_COMMANDS = 'supported_commands', + + // PermissionController requests + CAN_USE_TOOL = 'can_use_tool', + SET_PERMISSION_MODE = 'set_permission_mode', + + // MCPController requests + MCP_MESSAGE = 'mcp_message', + MCP_SERVER_STATUS = 'mcp_server_status', + + // HookController requests + HOOK_CALLBACK = 'hook_callback', +} diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts new file mode 100644 index 00000000..d3a548af --- /dev/null +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -0,0 +1,86 @@ +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'), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), +}); + +export const SdkMcpServerConfigSchema = z.object({ + connect: z.custom<(transport: unknown) => Promise>( + (val) => typeof val === 'function', + { message: 'connect must be a function' }, + ), +}); + +export const ModelConfigSchema = z.object({ + model: z.string().optional(), + temp: z.number().optional(), + top_p: z.number().optional(), +}); + +export const RunConfigSchema = z.object({ + max_time_minutes: z.number().optional(), + max_turns: z.number().optional(), +}); + +export const SubagentConfigSchema = z.object({ + name: z.string().min(1, 'Name must be a non-empty string'), + description: z.string().min(1, 'Description must be a non-empty string'), + tools: z.array(z.string()).optional(), + systemPrompt: z.string().min(1, 'System prompt must be a non-empty string'), + filePath: z.string().min(1, 'File path must be a non-empty string'), + modelConfig: ModelConfigSchema.partial().optional(), + runConfig: RunConfigSchema.partial().optional(), + color: z.string().optional(), + isBuiltin: z.boolean().optional(), +}); + +export const QueryOptionsSchema = z + .object({ + cwd: z.string().optional(), + model: z.string().optional(), + pathToQwenExecutable: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + permissionMode: z.enum(['default', 'plan', 'auto-edit', 'yolo']).optional(), + canUseTool: z + .custom((val) => typeof val === 'function', { + message: 'canUseTool must be a function', + }) + .optional(), + mcpServers: z.record(z.string(), ExternalMcpServerConfigSchema).optional(), + sdkMcpServers: z.record(z.string(), SdkMcpServerConfigSchema).optional(), + abortController: z.instanceof(AbortController).optional(), + debug: z.boolean().optional(), + stderr: z + .custom< + (message: string) => void + >((val) => typeof val === 'function', { message: 'stderr must be a function' }) + .optional(), + maxSessionTurns: z.number().optional(), + coreTools: z.array(z.string()).optional(), + excludeTools: z.array(z.string()).optional(), + authType: z.enum(['openai', 'qwen-oauth']).optional(), + agents: z + .array( + z.custom( + (val) => + val && + typeof val === 'object' && + 'name' in val && + 'description' in val && + 'systemPrompt' in val && + 'filePath' in val, + { message: 'agents must be an array of SubagentConfig objects' }, + ), + ) + .optional(), + }) + .strict(); + +export type ExternalMcpServerConfig = z.infer< + typeof ExternalMcpServerConfigSchema +>; +export type QueryOptions = z.infer; diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts new file mode 100644 index 00000000..d2b9a400 --- /dev/null +++ b/packages/sdk-typescript/src/types/types.ts @@ -0,0 +1,57 @@ +import type { PermissionMode, PermissionSuggestion } from './protocol.js'; +import type { ExternalMcpServerConfig } from './queryOptionsSchema.js'; + +export type { PermissionMode }; + +export type JSONSchema = { + type: string; + properties?: Record; + required?: string[]; + description?: string; + [key: string]: unknown; +}; + +export type ToolDefinition = { + name: string; + description: string; + inputSchema: JSONSchema; + handler: (input: TInput) => Promise; +}; + +export type TransportOptions = { + pathToQwenExecutable: string; + cwd?: string; + model?: string; + permissionMode?: PermissionMode; + mcpServers?: Record; + env?: Record; + abortController?: AbortController; + debug?: boolean; + stderr?: (message: string) => void; + maxSessionTurns?: number; + coreTools?: string[]; + excludeTools?: string[]; + authType?: string; +}; + +type ToolInput = Record; + +export type CanUseTool = ( + toolName: string, + input: ToolInput, + options: { + signal: AbortSignal; + suggestions?: PermissionSuggestion[] | null; + }, +) => Promise; + +export type PermissionResult = + | { + behavior: 'allow'; + updatedInput: ToolInput; + } + | { + behavior: 'deny'; + message: string; + interrupt?: boolean; + }; diff --git a/packages/sdk-typescript/src/utils/Stream.ts b/packages/sdk-typescript/src/utils/Stream.ts new file mode 100644 index 00000000..8a58c0be --- /dev/null +++ b/packages/sdk-typescript/src/utils/Stream.ts @@ -0,0 +1,91 @@ +/** + * Async iterable queue for streaming messages between producer and consumer. + */ + +export class Stream implements AsyncIterable { + private returned: (() => void) | undefined; + private queue: T[] = []; + private readResolve: ((result: IteratorResult) => void) | undefined; + private readReject: ((error: Error) => void) | undefined; + private isDone = false; + hasError: Error | undefined; + private started = false; + + constructor(returned?: () => void) { + this.returned = returned; + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.started) { + throw new Error('Stream can only be iterated once'); + } + this.started = true; + return this; + } + + async next(): Promise> { + // Check queue first - if there are queued items, return immediately + if (this.queue.length > 0) { + return Promise.resolve({ + done: false, + value: this.queue.shift()!, + }); + } + // Check if stream is done + if (this.isDone) { + return Promise.resolve({ done: true, value: undefined }); + } + // Check for errors that occurred before next() was called + // This ensures errors set via error() before iteration starts are properly rejected + if (this.hasError) { + return Promise.reject(this.hasError); + } + // No queued items, not done, no error - set up promise for next value/error + return new Promise>((resolve, reject) => { + this.readResolve = resolve; + this.readReject = reject; + }); + } + + enqueue(value: T): void { + if (this.readResolve) { + const resolve = this.readResolve; + this.readResolve = undefined; + this.readReject = undefined; + resolve({ done: false, value }); + } else { + this.queue.push(value); + } + } + + done(): void { + this.isDone = true; + if (this.readResolve) { + const resolve = this.readResolve; + this.readResolve = undefined; + this.readReject = undefined; + resolve({ done: true, value: undefined }); + } + } + + error(error: Error): void { + this.hasError = error; + // If readReject exists (next() has been called), reject immediately + if (this.readReject) { + const reject = this.readReject; + this.readResolve = undefined; + this.readReject = undefined; + reject(error); + } + // Otherwise, error is stored in hasError and will be rejected when next() is called + // This handles the case where error() is called before the first next() call + } + + return(): Promise> { + this.isDone = true; + if (this.returned) { + this.returned(); + } + return Promise.resolve({ done: true, value: undefined }); + } +} diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts new file mode 100644 index 00000000..b6101ab3 --- /dev/null +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -0,0 +1,365 @@ +/** + * CLI path auto-detection and subprocess spawning utilities + * + * Supports multiple execution modes: + * 1. Native binary: 'qwen' (production) + * 2. Node.js bundle: 'node /path/to/cli.js' (production validation) + * 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime) + * 4. TypeScript source: 'tsx /path/to/index.ts' (development) + * + * Auto-detection locations for native binary: + * 1. QWEN_CODE_CLI_PATH environment variable + * 2. ~/.volta/bin/qwen + * 3. ~/.npm-global/bin/qwen + * 4. /usr/local/bin/qwen + * 5. ~/.local/bin/qwen + * 6. ~/node_modules/.bin/qwen + * 7. ~/.yarn/bin/qwen + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; + +/** + * Executable types supported by the SDK + */ +export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno'; + +/** + * Spawn information for CLI process + */ +export type SpawnInfo = { + /** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */ + command: string; + /** Arguments to pass to command */ + args: string[]; + /** Type of executable detected */ + type: ExecutableType; + /** Original input that was resolved */ + originalInput: string; +}; + +export function findNativeCliPath(): string { + const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || ''; + + const candidates: Array = [ + // 1. Environment variable (highest priority) + process.env['QWEN_CODE_CLI_PATH'], + + // 2. Volta bin + path.join(homeDir, '.volta', 'bin', 'qwen'), + + // 3. Global npm installations + path.join(homeDir, '.npm-global', 'bin', 'qwen'), + + // 4. Common Unix binary locations + '/usr/local/bin/qwen', + + // 5. User local bin + path.join(homeDir, '.local', 'bin', 'qwen'), + + // 6. Node modules bin in home directory + path.join(homeDir, 'node_modules', '.bin', 'qwen'), + + // 7. Yarn global bin + path.join(homeDir, '.yarn', 'bin', 'qwen'), + ]; + + // Find first existing candidate + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return path.resolve(candidate); + } + } + + // Not found - throw helpful error + throw new Error( + 'qwen CLI not found. Please:\n' + + ' 1. Install qwen globally: npm install -g qwen\n' + + ' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' + + ' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' + + '\n' + + 'For development/testing, you can also use:\n' + + ' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' + + ' • Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' + + ' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })', + ); +} + +function isCommandAvailable(command: string): boolean { + try { + // Use 'which' on Unix-like systems, 'where' on Windows + const whichCommand = process.platform === 'win32' ? 'where' : 'which'; + execSync(`${whichCommand} ${command}`, { + stdio: 'ignore', + timeout: 5000, // 5 second timeout + }); + return true; + } catch { + return false; + } +} + +function validateRuntimeAvailability(runtime: string): boolean { + // Node.js is always available since we're running in Node.js + if (runtime === 'node') { + return true; + } + + // Check if the runtime command is available in PATH + return isCommandAvailable(runtime); +} + +function validateFileExtensionForRuntime( + filePath: string, + runtime: string, +): boolean { + const ext = path.extname(filePath).toLowerCase(); + + switch (runtime) { + case 'node': + case 'bun': + return ['.js', '.mjs', '.cjs'].includes(ext); + case 'tsx': + return ['.ts', '.tsx'].includes(ext); + case 'deno': + return ['.ts', '.tsx', '.js', '.mjs'].includes(ext); + default: + return true; // Unknown runtime, let it pass + } +} + +/** + * Parse executable specification into components with comprehensive validation + * + * Supports multiple formats: + * - 'qwen' -> native binary (auto-detected) + * - '/path/to/qwen' -> native binary (explicit path) + * - '/path/to/cli.js' -> Node.js bundle (default for .js files) + * - '/path/to/index.ts' -> TypeScript source (requires tsx) + * + * Advanced runtime specification (for overriding defaults): + * - 'bun:/path/to/cli.js' -> Force Bun runtime + * - 'node:/path/to/cli.js' -> Force Node.js runtime + * - 'tsx:/path/to/index.ts' -> Force tsx runtime + * - 'deno:/path/to/cli.ts' -> Force Deno runtime + * + * @param executableSpec - Executable specification + * @returns Parsed executable information + * @throws Error if specification is invalid or files don't exist + */ +export function parseExecutableSpec(executableSpec?: string): { + runtime?: string; + executablePath: string; + isExplicitRuntime: boolean; +} { + // Handle empty string case first (before checking for undefined/null) + if ( + executableSpec === '' || + (executableSpec && executableSpec.trim() === '') + ) { + throw new Error('Command name cannot be empty'); + } + + if (!executableSpec) { + // Auto-detect native CLI + return { + executablePath: findNativeCliPath(), + isExplicitRuntime: false, + }; + } + + // Check for runtime prefix (e.g., 'bun:/path/to/cli.js') + const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/); + if (runtimeMatch) { + const [, runtime, filePath] = runtimeMatch; + if (!runtime || !filePath) { + throw new Error(`Invalid runtime specification: '${executableSpec}'`); + } + + // Validate runtime is supported + const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; + if (!supportedRuntimes.includes(runtime)) { + throw new Error( + `Unsupported runtime '${runtime}'. Supported runtimes: ${supportedRuntimes.join(', ')}`, + ); + } + + // Validate runtime availability + if (!validateRuntimeAvailability(runtime)) { + throw new Error( + `Runtime '${runtime}' is not available on this system. Please install it first.`, + ); + } + + const resolvedPath = path.resolve(filePath); + + // Validate file exists + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + + 'Please check the file path and ensure the file exists.', + ); + } + + // Validate file extension matches runtime + if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { + const ext = path.extname(resolvedPath); + throw new Error( + `File extension '${ext}' is not compatible with runtime '${runtime}'. ` + + `Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`, + ); + } + + return { + runtime, + executablePath: resolvedPath, + isExplicitRuntime: true, + }; + } + + // Check if it's a command name (no path separators) or a file path + const isCommandName = + !executableSpec.includes('/') && !executableSpec.includes('\\'); + + if (isCommandName) { + // It's a command name like 'qwen' - validate it's a reasonable command name + if (!executableSpec || executableSpec.trim() === '') { + throw new Error('Command name cannot be empty'); + } + + // Basic validation for command names + if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) { + throw new Error( + `Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`, + ); + } + + return { + executablePath: executableSpec, + isExplicitRuntime: false, + }; + } + + // It's a file path - validate and resolve + const resolvedPath = path.resolve(executableSpec); + + if (!fs.existsSync(resolvedPath)) { + throw new Error( + `Executable file not found at '${resolvedPath}'. ` + + 'Please check the file path and ensure the file exists. ' + + 'You can also:\n' + + ' • Set QWEN_CODE_CLI_PATH environment variable\n' + + ' • Install qwen globally: npm install -g qwen\n' + + ' • For TypeScript files, ensure tsx is installed: npm install -g tsx\n' + + ' • Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts', + ); + } + + // Additional validation for file paths + const stats = fs.statSync(resolvedPath); + if (!stats.isFile()) { + throw new Error( + `Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`, + ); + } + + return { + executablePath: resolvedPath, + isExplicitRuntime: false, + }; +} + +function getExpectedExtensions(runtime: string): string[] { + switch (runtime) { + case 'node': + case 'bun': + return ['.js', '.mjs', '.cjs']; + case 'tsx': + return ['.ts', '.tsx']; + case 'deno': + return ['.ts', '.tsx', '.js', '.mjs']; + default: + return []; + } +} + +/** + * @deprecated Use parseExecutableSpec and prepareSpawnInfo instead + */ +export function resolveCliPath(explicitPath?: string): string { + const parsed = parseExecutableSpec(explicitPath); + return parsed.executablePath; +} + +function detectRuntimeFromExtension(filePath: string): string | undefined { + const ext = path.extname(filePath).toLowerCase(); + + if (['.js', '.mjs', '.cjs'].includes(ext)) { + // Default to Node.js for JavaScript files + return 'node'; + } + + if (['.ts', '.tsx'].includes(ext)) { + // Check if tsx is available for TypeScript files + if (isCommandAvailable('tsx')) { + return 'tsx'; + } + // If tsx is not available, suggest it in error message + throw new Error( + `TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` + + 'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts', + ); + } + + // Native executable or unknown extension + return undefined; +} + +export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { + const parsed = parseExecutableSpec(executableSpec); + const { runtime, executablePath, isExplicitRuntime } = parsed; + + // If runtime is explicitly specified, use it + if (isExplicitRuntime && runtime) { + const runtimeCommand = runtime === 'node' ? process.execPath : runtime; + + return { + command: runtimeCommand, + args: [executablePath], + type: runtime as ExecutableType, + originalInput: executableSpec || '', + }; + } + + // If no explicit runtime, try to detect from file extension + const detectedRuntime = detectRuntimeFromExtension(executablePath); + + if (detectedRuntime) { + const runtimeCommand = + detectedRuntime === 'node' ? process.execPath : detectedRuntime; + + return { + command: runtimeCommand, + args: [executablePath], + type: detectedRuntime as ExecutableType, + originalInput: executableSpec || '', + }; + } + + // Native executable or command name - use it directly + return { + command: executablePath, + args: [], + type: 'native', + originalInput: executableSpec || '', + }; +} + +/** + * @deprecated Use prepareSpawnInfo() instead + */ +export function findCliPath(): string { + return findNativeCliPath(); +} diff --git a/packages/sdk-typescript/src/utils/jsonLines.ts b/packages/sdk-typescript/src/utils/jsonLines.ts new file mode 100644 index 00000000..e534bf70 --- /dev/null +++ b/packages/sdk-typescript/src/utils/jsonLines.ts @@ -0,0 +1,65 @@ +export function serializeJsonLine(message: unknown): string { + try { + return JSON.stringify(message) + '\n'; + } catch (error) { + throw new Error( + `Failed to serialize message to JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export function parseJsonLineSafe( + line: string, + context = 'JsonLines', +): unknown | null { + try { + return JSON.parse(line); + } catch (error) { + console.warn( + `[${context}] Failed to parse JSON line, skipping:`, + line.substring(0, 100), + error instanceof Error ? error.message : String(error), + ); + return null; + } +} + +export function isValidMessage(message: unknown): boolean { + return ( + message !== null && + typeof message === 'object' && + 'type' in message && + typeof (message as { type: unknown }).type === 'string' + ); +} + +export async function* parseJsonLinesStream( + lines: AsyncIterable, + context = 'JsonLines', +): AsyncGenerator { + for await (const line of lines) { + // Skip empty lines + if (line.trim().length === 0) { + continue; + } + + // Parse with error handling + const message = parseJsonLineSafe(line, context); + + // Skip malformed messages + if (message === null) { + continue; + } + + // Validate message structure + if (!isValidMessage(message)) { + console.warn( + `[${context}] Invalid message structure (missing 'type' field), skipping:`, + line.substring(0, 100), + ); + continue; + } + + yield message; + } +} diff --git a/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts b/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts new file mode 100644 index 00000000..a97d3db6 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts @@ -0,0 +1,466 @@ +/** + * E2E tests based on abort-and-lifecycle.ts example + * Tests AbortController integration and process lifecycle management + */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { describe, it, expect } from 'vitest'; +import { + query, + AbortError, + isAbortError, + isCLIAssistantMessage, + type TextBlock, + type ContentBlock, +} from '../../src/index.js'; + +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, +}; + +describe('AbortController and Process Lifecycle (E2E)', () => { + describe('Basic AbortController Usage', () => { + /* TODO: Currently query does not throw AbortError when aborted */ + it('should support AbortController cancellation', async () => { + const controller = new AbortController(); + + // Abort after 5 seconds + setTimeout(() => { + controller.abort(); + }, 5000); + + const q = query({ + prompt: 'Write a very long story about TypeScript programming', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 100); + + // Should receive some content before abort + expect(text.length).toBeGreaterThan(0); + } + } + + // Should not reach here - query should be aborted + expect(false).toBe(true); + } catch (error) { + expect(isAbortError(error)).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle abort during query execution', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + let receivedFirstMessage = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + if (!receivedFirstMessage) { + // Abort immediately after receiving first assistant message + receivedFirstMessage = true; + controller.abort(); + } + } + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + // Should have received at least one message before abort + expect(receivedFirstMessage).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle abort immediately after query starts', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Write a very long essay', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + // Abort immediately after query initialization + setTimeout(() => { + controller.abort(); + }, 200); + + try { + for await (const _message of q) { + // May or may not receive messages before abort + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Process Lifecycle Monitoring', () => { + it('should handle normal process completion', async () => { + const q = query({ + prompt: 'Why do we choose to go to the moon?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let completedSuccessfully = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 100); + expect(text.length).toBeGreaterThan(0); + } + } + + completedSuccessfully = true; + } catch (error) { + // Should not throw for normal completion + expect(false).toBe(true); + } finally { + await q.close(); + expect(completedSuccessfully).toBe(true); + } + }); + + it('should handle process cleanup after error', async () => { + const q = query({ + prompt: 'Hello world', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 50); + expect(text.length).toBeGreaterThan(0); + } + } + } catch (error) { + // Expected to potentially have errors + } finally { + // Should cleanup successfully even after error + await q.close(); + expect(true).toBe(true); // Cleanup completed + } + }); + }); + + describe('Input Stream Control', () => { + it('should support endInput() method', async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let receivedResponse = false; + let endInputCalled = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message) && !endInputCalled) { + const textBlocks = message.message.content.filter( + (block: ContentBlock): block is TextBlock => + block.type === 'text', + ); + const text = textBlocks.map((b: TextBlock) => b.text).join(''); + + expect(text.length).toBeGreaterThan(0); + receivedResponse = true; + + // End input after receiving first response + q.endInput(); + endInputCalled = true; + } + } + + expect(receivedResponse).toBe(true); + expect(endInputCalled).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling and Recovery', () => { + it('should handle invalid executable path', async () => { + try { + const q = query({ + prompt: 'Hello world', + options: { + pathToQwenExecutable: '/nonexistent/path/to/cli', + debug: false, + }, + }); + + // Should not reach here - query() should throw immediately + for await (const _message of q) { + // Should not reach here + } + + // Should not reach here + expect(false).toBe(true); + } catch (error) { + expect(error instanceof Error).toBe(true); + expect((error as Error).message).toBeDefined(); + expect((error as Error).message).toContain( + 'Invalid pathToQwenExecutable', + ); + } + }); + + it('should throw AbortError with correct properties', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Explain the concept of async programming', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + // Abort after allowing query to start + setTimeout(() => controller.abort(), 1000); + + try { + for await (const _message of q) { + // May receive some messages before abort + } + } catch (error) { + // Verify error type and helper functions + expect(isAbortError(error)).toBe(true); + expect(error instanceof AbortError).toBe(true); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBeDefined(); + } finally { + await q.close(); + } + }); + }); + + describe('Debugging with stderr callback', () => { + it('should capture stderr messages when debug is enabled', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Why do we choose to go to the moon?', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const textBlocks = message.message.content.filter( + (block): block is TextBlock => block.type === 'text', + ); + const text = textBlocks + .map((b) => b.text) + .join('') + .slice(0, 50); + expect(text.length).toBeGreaterThan(0); + } + } + } finally { + await q.close(); + expect(stderrMessages.length).toBeGreaterThan(0); + } + }); + + it('should not capture stderr when debug is disabled', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + } finally { + await q.close(); + // Should have minimal or no stderr output when debug is false + expect(stderrMessages.length).toBeLessThan(10); + } + }); + }); + + describe('Abort with Cleanup', () => { + it('should cleanup properly after abort', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Write a very long essay about programming', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + // Abort immediately + setTimeout(() => controller.abort(), 100); + + try { + for await (const _message of q) { + // May receive some messages before abort + } + } catch (error) { + if (error instanceof AbortError) { + expect(true).toBe(true); // Expected abort error + } else { + throw error; // Unexpected error + } + } finally { + await q.close(); + expect(true).toBe(true); // Cleanup completed after abort + } + }); + + it('should handle multiple abort calls gracefully', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Count to 100', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + // Multiple abort calls + setTimeout(() => controller.abort(), 100); + setTimeout(() => controller.abort(), 200); + setTimeout(() => controller.abort(), 300); + + try { + for await (const _message of q) { + // Should be interrupted + } + } catch (error) { + expect(isAbortError(error)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Resource Management Edge Cases', () => { + it('should handle close() called multiple times', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + // Start the query + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Close multiple times + await q.close(); + await q.close(); + await q.close(); + + // Should not throw + expect(true).toBe(true); + }); + + it('should handle abort after close', async () => { + const controller = new AbortController(); + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + abortController: controller, + debug: false, + }, + }); + + // Start and close immediately + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + await q.close(); + + // Abort after close + controller.abort(); + + // Should not throw + expect(true).toBe(true); + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/control.test.ts b/packages/sdk-typescript/test/e2e/control.test.ts new file mode 100644 index 00000000..ea7ecef7 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/control.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIAssistantMessage, + isCLIResultMessage, + isCLISystemMessage, + type CLIUserMessage, +} from '../../src/types/protocol.js'; + +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, +}; + +/** + * Factory function that creates a streaming input with a control point. + * After the first message is yielded, the generator waits for a resume signal, + * allowing the test code to call query instance methods like setModel or setPermissionMode. + * + * @param firstMessage - The first user message to send + * @param secondMessage - The second user message to send after control operations + * @returns Object containing the async generator and a resume function + */ +function createStreamingInputWithControlPoint( + firstMessage: string, + secondMessage: string, +): { + generator: AsyncIterable; + resume: () => void; +} { + let resumeResolve: (() => void) | null = null; + const resumePromise = new Promise((resolve) => { + resumeResolve = resolve; + }); + + const generator = (async function* () { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: firstMessage, + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + await resumePromise; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: secondMessage, + }, + parent_tool_use_id: null, + } as CLIUserMessage; + })(); + + const resume = () => { + if (resumeResolve) { + resumeResolve(); + } + }; + + return { generator, resume }; +} + +describe('Control Request/Response (E2E)', () => { + describe('System Controller Scope', () => { + it('should set model via control request during streaming input', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'Tell me the model name.', + 'Tell me the model name now again.', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + model: 'qwen3-max', + debug: false, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + const systemMessages: Array<{ model?: string }> = []; + + // Consume messages in a single loop + (async () => { + for await (const message of q) { + if (isCLISystemMessage(message)) { + systemMessages.push({ model: message.model }); + } + if (isCLIAssistantMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + // Wait for first response + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 10000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + // Perform control operation: set model + await q.setModel('qwen3-vl-plus'); + + // Resume the input stream + resume(); + + // Wait for second response + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + + // Verify system messages - model should change from qwen3-max to qwen3-vl-plus + expect(systemMessages.length).toBeGreaterThanOrEqual(2); + expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']); + expect(systemMessages[1].model).toBe('qwen3-vl-plus'); + } finally { + await q.close(); + } + }); + }); + + describe('Permission Controller Scope', () => { + it('should set permission mode via control request during streaming input', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 1 + 1?', + 'What is 2 + 2?', + ); + + const q = query({ + prompt: generator, + options: { + pathToQwenExecutable: TEST_CLI_PATH, + permissionMode: 'default', + debug: false, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let permissionModeChanged = false; + let secondResponseReceived = false; + + // Consume messages in a single loop + (async () => { + for await (const message of q) { + if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + // Wait for first response + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 10000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + // Perform control operation: set permission mode + await q.setPermissionMode('yolo'); + permissionModeChanged = true; + + // Resume the input stream + resume(); + + // Wait for second response + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(permissionModeChanged).toBe(true); + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/globalSetup.ts b/packages/sdk-typescript/test/e2e/globalSetup.ts new file mode 100644 index 00000000..44e3e528 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/globalSetup.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdir, readdir, rm } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '../..'); +const e2eTestsDir = join(rootDir, '.integration-tests'); +let runDir = ''; + +export async function setup() { + runDir = join(e2eTestsDir, `${Date.now()}`); + await mkdir(runDir, { recursive: true }); + + // Clean up old test runs, but keep the latest few for debugging + try { + const testRuns = await readdir(e2eTestsDir); + if (testRuns.length > 5) { + const oldRuns = testRuns.sort().slice(0, testRuns.length - 5); + await Promise.all( + oldRuns.map((oldRun) => + rm(join(e2eTestsDir, oldRun), { + recursive: true, + force: true, + }), + ), + ); + } + } catch (e) { + console.error('Error cleaning up old test runs:', e); + } + + process.env['E2E_TEST_FILE_DIR'] = runDir; + process.env['QWEN_CLI_E2E_TEST'] = 'true'; + process.env['TEST_CLI_PATH'] = join(rootDir, '../../dist/cli.js'); + + if (process.env['KEEP_OUTPUT']) { + console.log(`Keeping output for test run in: ${runDir}`); + } + process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; + + console.log(`\nE2E test output directory: ${runDir}`); + console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`); +} + +export async function teardown() { + // Cleanup the test run directory unless KEEP_OUTPUT is set + if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { + await rm(runDir, { recursive: true, force: true }); + } +} diff --git a/packages/sdk-typescript/test/e2e/mcp-server.test.ts b/packages/sdk-typescript/test/e2e/mcp-server.test.ts new file mode 100644 index 00000000..6bb0f965 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/mcp-server.test.ts @@ -0,0 +1,610 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for MCP (Model Context Protocol) server integration via SDK + * Tests that the SDK can properly interact with MCP servers configured in qwen-code + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIAssistantMessage, + isCLIResultMessage, + isCLISystemMessage, + isCLIUserMessage, + type TextBlock, + type ContentBlock, + type CLIMessage, + type ToolUseBlock, + type CLISystemMessage, +} from '../../src/types/protocol.js'; +import { writeFileSync, mkdirSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; + +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; +const E2E_TEST_FILE_DIR = process.env['E2E_TEST_FILE_DIR']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, + permissionMode: 'yolo' as const, +}; + +/** + * Helper to extract text from ContentBlock array + */ +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +/** + * Minimal MCP server implementation that doesn't require external dependencies + * This implements the MCP protocol directly using Node.js built-ins + */ +const MCP_SERVER_SCRIPT = `#!/usr/bin/env node +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const readline = require('readline'); +const fs = require('fs'); + +// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set) +const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true'; +function debug(msg) { + if (debugEnabled) { + fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`); + } +} + +debug('MCP server starting...'); + +// Simple JSON-RPC implementation for MCP +class SimpleJSONRPC { + constructor() { + this.handlers = new Map(); + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + this.rl.on('line', (line) => { + debug(\`Received line: \${line}\`); + try { + const message = JSON.parse(line); + debug(\`Parsed message: \${JSON.stringify(message)}\`); + this.handleMessage(message); + } catch (e) { + debug(\`Parse error: \${e.message}\`); + } + }); + } + + send(message) { + const msgStr = JSON.stringify(message); + debug(\`Sending message: \${msgStr}\`); + process.stdout.write(msgStr + '\\n'); + } + + async handleMessage(message) { + if (message.method && this.handlers.has(message.method)) { + try { + const result = await this.handlers.get(message.method)(message.params || {}); + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + result + }); + } + } catch (error) { + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: error.message + } + }); + } + } + } else if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32601, + message: 'Method not found' + } + }); + } + } + + on(method, handler) { + this.handlers.set(method, handler); + } +} + +// Create MCP server +const rpc = new SimpleJSONRPC(); + +// Handle initialize +rpc.on('initialize', async (params) => { + debug('Handling initialize request'); + return { + protocolVersion: '2024-11-05', + capabilities: { + tools: {} + }, + serverInfo: { + name: 'test-math-server', + version: '1.0.0' + } + }; +}); + +// Handle tools/list +rpc.on('tools/list', async () => { + debug('Handling tools/list request'); + return { + tools: [ + { + name: 'add', + description: 'Add two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + }, + { + name: 'multiply', + description: 'Multiply two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + } + ] + }; +}); + +// Handle tools/call +rpc.on('tools/call', async (params) => { + debug(\`Handling tools/call request for tool: \${params.name}\`); + + if (params.name === 'add') { + const { a, b } = params.arguments; + return { + content: [{ + type: 'text', + text: String(a + b) + }] + }; + } + + if (params.name === 'multiply') { + const { a, b } = params.arguments; + return { + content: [{ + type: 'text', + text: String(a * b) + }] + }; + } + + throw new Error('Unknown tool: ' + params.name); +}); + +// Send initialization notification +rpc.send({ + jsonrpc: '2.0', + method: 'initialized' +}); +`; + +describe('MCP Server Integration (E2E)', () => { + let testDir: string; + let serverScriptPath: string; + + beforeAll(() => { + // Use the centralized E2E test directory from globalSetup + testDir = join(E2E_TEST_FILE_DIR, 'mcp-server-test'); + mkdirSync(testDir, { recursive: true }); + + // Write MCP server script + serverScriptPath = join(testDir, 'mcp-server.cjs'); + writeFileSync(serverScriptPath, MCP_SERVER_SCRIPT); + + // Make script executable on Unix-like systems + if (process.platform !== 'win32') { + chmodSync(serverScriptPath, 0o755); + } + }); + + describe('Basic MCP Tool Usage', () => { + it('should use MCP add tool to add two numbers', async () => { + const q = query({ + prompt: + 'Use the add tool to calculate 5 + 10. Just give me the result.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (toolUseBlock && toolUseBlock.name === 'add') { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains expected answer + expect(assistantText).toMatch(/15/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }); + + it('should use MCP multiply tool to multiply two numbers', async () => { + const q = query({ + prompt: + 'Use the multiply tool to calculate 6 * 7. Just give me the result.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (toolUseBlock && toolUseBlock.name === 'multiply') { + foundToolUse = true; + } + assistantText += extractText(message.message.content); + } + } + + // Validate tool was called + expect(foundToolUse).toBe(true); + + // Validate result contains expected answer + expect(assistantText).toMatch(/42/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('MCP Server Discovery', () => { + it('should list MCP servers in system init message', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + break; + } + } + + // Validate MCP server is listed + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.mcp_servers).toBeDefined(); + expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); + + // Find our test server + const testServer = systemMessage!.mcp_servers?.find( + (server) => server.name === 'test-math-server', + ); + expect(testServer).toBeDefined(); + + // Note: tools are not exposed in the mcp_servers array in system message + // They are available through the MCP protocol but not in the init message + } finally { + await q.close(); + } + }); + }); + + describe('Complex MCP Operations', () => { + it('should chain multiple MCP tool calls', async () => { + const q = query({ + prompt: + 'First use add to calculate 10 + 5, then multiply the result by 2. Give me the final answer.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + const toolCalls: string[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + const toolUseBlocks = message.message.content.filter( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + toolUseBlocks.forEach((block) => { + toolCalls.push(block.name); + }); + assistantText += extractText(message.message.content); + } + } + + // Validate both tools were called + expect(toolCalls).toContain('add'); + expect(toolCalls).toContain('multiply'); + + // Validate result: (10 + 5) * 2 = 30 + expect(assistantText).toMatch(/30/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle multiple calls to the same MCP tool', async () => { + const q = query({ + prompt: + 'Use the add tool twice: first add 1 + 2, then add 3 + 4. Tell me both results.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + const addToolCalls: ToolUseBlock[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + const toolUseBlocks = message.message.content.filter( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + toolUseBlocks.forEach((block) => { + if (block.name === 'add') { + addToolCalls.push(block); + } + }); + assistantText += extractText(message.message.content); + } + } + + // Validate add tool was called at least twice + expect(addToolCalls.length).toBeGreaterThanOrEqual(2); + + // Validate results contain expected answers: 3 and 7 + expect(assistantText).toMatch(/3/); + expect(assistantText).toMatch(/7/); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('MCP Tool Message Flow', () => { + it('should receive proper message sequence for MCP tool usage', async () => { + const q = query({ + prompt: 'Use add to calculate 2 + 3', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messageTypes: string[] = []; + let foundToolUse = false; + let foundToolResult = false; + + try { + for await (const message of q) { + messageTypes.push(message.type); + + if (isCLIAssistantMessage(message)) { + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (toolUseBlock) { + foundToolUse = true; + expect(toolUseBlock.name).toBe('add'); + expect(toolUseBlock.input).toBeDefined(); + } + } + + if (isCLIUserMessage(message)) { + const content = message.message.content; + const contentArray = Array.isArray(content) + ? content + : [{ type: 'text', text: content }]; + const toolResultBlock = contentArray.find( + (block) => block.type === 'tool_result', + ); + if (toolResultBlock) { + foundToolResult = true; + } + } + } + + // Validate message flow + expect(foundToolUse).toBe(true); + expect(foundToolResult).toBe(true); + expect(messageTypes).toContain('system'); + expect(messageTypes).toContain('assistant'); + expect(messageTypes).toContain('user'); + expect(messageTypes).toContain('result'); + + // Result should be last message + expect(messageTypes[messageTypes.length - 1]).toBe('result'); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling', () => { + it('should handle gracefully when MCP tool is not available', async () => { + const q = query({ + prompt: 'Use the subtract tool to calculate 10 - 5', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + mcpServers: { + 'test-math-server': { + command: 'node', + args: [serverScriptPath], + }, + }, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Should complete without crashing + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + + // Assistant should indicate tool is not available or provide alternative + expect(assistantText.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/multi-turn.test.ts b/packages/sdk-typescript/test/e2e/multi-turn.test.ts new file mode 100644 index 00000000..8e79898e --- /dev/null +++ b/packages/sdk-typescript/test/e2e/multi-turn.test.ts @@ -0,0 +1,479 @@ +/** + * E2E tests based on multi-turn.ts example + * Tests multi-turn conversation functionality with real CLI + */ + +import { describe, it, expect } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIUserMessage, + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + isCLIPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, + type CLIUserMessage, + type CLIAssistantMessage, + type TextBlock, + type ContentBlock, + type CLIMessage, + type ControlMessage, + type ToolUseBlock, +} from '../../src/types/protocol.js'; +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, +}; + +/** + * Determine the message type using protocol type guards + */ +function getMessageType(message: CLIMessage | ControlMessage): string { + if (isCLIUserMessage(message)) { + return '🧑 USER'; + } else if (isCLIAssistantMessage(message)) { + return '🤖 ASSISTANT'; + } else if (isCLISystemMessage(message)) { + return `🖥️ SYSTEM(${message.subtype})`; + } else if (isCLIResultMessage(message)) { + return `✅ RESULT(${message.subtype})`; + } else if (isCLIPartialAssistantMessage(message)) { + return '⏳ STREAM_EVENT'; + } else if (isControlRequest(message)) { + return `🎮 CONTROL_REQUEST(${message.request.subtype})`; + } else if (isControlResponse(message)) { + return `📭 CONTROL_RESPONSE(${message.response.subtype})`; + } else if (isControlCancel(message)) { + return '🛑 CONTROL_CANCEL'; + } else { + return '❓ UNKNOWN'; + } +} + +/** + * Helper to extract text from ContentBlock array + */ +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +describe('Multi-Turn Conversations (E2E)', () => { + describe('AsyncIterable Prompt Support', () => { + it('should handle multi-turn conversation using AsyncIterable prompt', async () => { + // Create multi-turn conversation generator + async function* createMultiTurnConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 1 + 1?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 2 + 2?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 3 + 3?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + // Create multi-turn query using AsyncIterable prompt + const q = query({ + prompt: createMultiTurnConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + const assistantMessages: CLIAssistantMessage[] = []; + const assistantTexts: string[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + const text = extractText(message.message.content); + assistantTexts.push(text); + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(assistantMessages.length).toBeGreaterThanOrEqual(3); + + // Validate content of responses + expect(assistantTexts[0]).toMatch(/2/); + expect(assistantTexts[1]).toMatch(/4/); + expect(assistantTexts[2]).toMatch(/6/); + } finally { + await q.close(); + } + }); + + it('should maintain session context across turns', async () => { + async function* createContextualConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: + 'Suppose we have 3 rabbits and 4 carrots. How many animals are there?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'How many animals are there? Only output the number', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createContextualConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const assistantMessages: CLIAssistantMessage[] = []; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + } + } + + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + + // The second response should reference the color blue + const secondResponse = extractText( + assistantMessages[1].message.content, + ); + expect(secondResponse.toLowerCase()).toContain('3'); + } finally { + await q.close(); + } + }); + }); + + describe('Tool Usage in Multi-Turn', () => { + it('should handle tool usage across multiple turns', async () => { + async function* createToolConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Create a file named test.txt with content "hello"', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Now read the test.txt file', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createToolConversation(), + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: '/tmp', + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let toolUseCount = 0; + const assistantMessages: CLIAssistantMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + const hasToolUseBlock = message.message.content.some( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (hasToolUseBlock) { + toolUseCount++; + } + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(toolUseCount).toBeGreaterThan(0); + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + + // Validate second response mentions the file content + const secondResponse = extractText( + assistantMessages[assistantMessages.length - 1].message.content, + ); + expect(secondResponse.toLowerCase()).toMatch(/hello|test\.txt/); + } finally { + await q.close(); + } + }); + }); + + describe('Message Flow and Sequencing', () => { + it('should process messages in correct sequence', async () => { + async function* createSequentialConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'First question: What is 1 + 1?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Second question: What is 2 + 2?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createSequentialConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messageSequence: string[] = []; + const assistantResponses: string[] = []; + + try { + for await (const message of q) { + const messageType = getMessageType(message); + messageSequence.push(messageType); + + if (isCLIAssistantMessage(message)) { + const text = extractText(message.message.content); + assistantResponses.push(text); + } + } + + expect(messageSequence.length).toBeGreaterThan(0); + expect(assistantResponses.length).toBeGreaterThanOrEqual(2); + + // Should end with result + expect(messageSequence[messageSequence.length - 1]).toContain('RESULT'); + + // Should have assistant responses + expect(messageSequence.some((type) => type.includes('ASSISTANT'))).toBe( + true, + ); + } finally { + await q.close(); + } + }); + + it('should handle conversation completion correctly', async () => { + async function* createSimpleConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Hello', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Goodbye', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createSimpleConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let completedNaturally = false; + let messageCount = 0; + + try { + for await (const message of q) { + messageCount++; + + if (isCLIResultMessage(message)) { + completedNaturally = true; + expect(message.subtype).toBe('success'); + } + } + + expect(messageCount).toBeGreaterThan(0); + expect(completedNaturally).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling in Multi-Turn', () => { + it('should handle empty conversation gracefully', async () => { + async function* createEmptyConversation(): AsyncIterable { + // Generator that yields nothing + /* eslint-disable no-constant-condition */ + if (false) { + yield {} as CLIUserMessage; // Unreachable, but satisfies TypeScript + } + } + + const q = query({ + prompt: createEmptyConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should handle empty conversation without crashing + expect(true).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle conversation with delays', async () => { + async function* createDelayedConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'First message', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + // Longer delay to test patience + await new Promise((resolve) => setTimeout(resolve, 500)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Second message after delay', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createDelayedConversation(), + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const assistantMessages: CLIAssistantMessage[] = []; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + assistantMessages.push(message); + } + } + + expect(assistantMessages.length).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/packages/sdk-typescript/test/e2e/permission-control.test.ts new file mode 100644 index 00000000..afcef8b1 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/permission-control.test.ts @@ -0,0 +1,676 @@ +/** + * E2E tests for permission control features: + * - canUseTool callback parameter + * - setPermissionMode API + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIAssistantMessage, + isCLIResultMessage, + isCLIUserMessage, + type CLIUserMessage, + type ToolUseBlock, + type ContentBlock, +} from '../../src/types/protocol.js'; +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; +const TEST_TIMEOUT = 30000; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, + debug: false, + env: {}, +}; + +/** + * Factory function that creates a streaming input with a control point. + * After the first message is yielded, the generator waits for a resume signal, + * allowing the test code to call query instance methods like setPermissionMode. + */ +function createStreamingInputWithControlPoint( + firstMessage: string, + secondMessage: string, +): { + generator: AsyncIterable; + resume: () => void; +} { + let resumeResolve: (() => void) | null = null; + const resumePromise = new Promise((resolve) => { + resumeResolve = resolve; + }); + + const generator = (async function* () { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: firstMessage, + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + await resumePromise; + + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: secondMessage, + }, + parent_tool_use_id: null, + } as CLIUserMessage; + })(); + + const resume = () => { + if (resumeResolve) { + resumeResolve(); + } + }; + + return { generator, resume }; +} + +describe('Permission Control (E2E)', () => { + beforeAll(() => { + //process.env['DEBUG'] = '1'; + }); + + afterAll(() => { + delete process.env['DEBUG']; + }); + + describe('canUseTool callback parameter', () => { + it('should invoke canUseTool callback when tool is requested', async () => { + const toolCalls: Array<{ + toolName: string; + input: Record; + }> = []; + + const q = query({ + prompt: 'Write a js hello world to file.', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + + canUseTool: async (toolName, input) => { + toolCalls.push({ toolName, input }); + /* + { + behavior: 'allow', + updatedInput: input, + }; + */ + return { + behavior: 'deny', + message: 'Tool execution denied by user.', + }; + }, + }, + }); + + try { + let hasToolUse = false; + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (toolUseBlock) { + hasToolUse = true; + } + } + } + + expect(hasToolUse).toBe(true); + expect(toolCalls.length).toBeGreaterThan(0); + expect(toolCalls[0].toolName).toBeDefined(); + expect(toolCalls[0].input).toBeDefined(); + } finally { + await q.close(); + } + }); + + it('should allow tool execution when canUseTool returns allow', async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named hello.txt with content "world"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasToolResult = false; + for await (const message of q) { + if (isCLIUserMessage(message)) { + if ( + Array.isArray(message.message.content) && + message.message.content.some( + (block) => block.type === 'tool_result', + ) + ) { + hasToolResult = true; + } + } + } + + expect(callbackInvoked).toBe(true); + expect(hasToolResult).toBe(true); + } finally { + await q.close(); + } + }); + + it('should deny tool execution when canUseTool returns deny', async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + canUseTool: async () => { + callbackInvoked = true; + return { + behavior: 'deny', + message: 'Tool execution denied by test', + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + expect(callbackInvoked).toBe(true); + // Tool use might still appear, but execution should be denied + // The exact behavior depends on CLI implementation + } finally { + await q.close(); + } + }); + + it('should pass suggestions to canUseTool callback', async () => { + let receivedSuggestions: unknown = null; + + const q = query({ + prompt: 'Create a file named data.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input, options) => { + receivedSuggestions = options?.suggestions; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // Suggestions may be null or an array, depending on CLI implementation + expect(receivedSuggestions !== undefined).toBe(true); + } finally { + await q.close(); + } + }); + + it('should pass abort signal to canUseTool callback', async () => { + let receivedSignal: AbortSignal | undefined = undefined; + + const q = query({ + prompt: 'Create a file named signal.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input, options) => { + receivedSignal = options?.signal; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + expect(receivedSignal).toBeDefined(); + expect(receivedSignal).toBeInstanceOf(AbortSignal); + } finally { + await q.close(); + } + }); + + it('should allow updatedInput modification in canUseTool callback', async () => { + const originalInputs: Record[] = []; + const updatedInputs: Record[] = []; + + const q = query({ + prompt: 'Create a file named modified.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + originalInputs.push({ ...input }); + const updatedInput = { + ...input, + modified: true, + testKey: 'testValue', + }; + updatedInputs.push(updatedInput); + return { + behavior: 'allow', + updatedInput, + }; + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + expect(originalInputs.length).toBeGreaterThan(0); + expect(updatedInputs.length).toBeGreaterThan(0); + expect(updatedInputs[0]?.['modified']).toBe(true); + expect(updatedInputs[0]?.['testKey']).toBe('testValue'); + } finally { + await q.close(); + } + }); + + it('should default to deny when canUseTool is not provided', async () => { + const q = query({ + prompt: 'Create a file named default.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + // canUseTool not provided + }, + }); + + try { + // When canUseTool is not provided, tools should be denied by default + // The exact behavior depends on CLI implementation + for await (const _message of q) { + // Consume all messages + } + // Test passes if no errors occur + expect(true).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('setPermissionMode API', () => { + it('should change permission mode from default to yolo', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 1 + 1?', + 'What is 2 + 2?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + debug: true, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 40000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('yolo'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 40000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should change permission mode from yolo to plan', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 3 + 3?', + 'What is 4 + 4?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 10000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('plan'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should change permission mode to auto-edit', async () => { + const { generator, resume } = createStreamingInputWithControlPoint( + 'What is 5 + 5?', + 'What is 6 + 6?', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + 10000, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + + await q.setPermissionMode('auto-edit'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + 10000, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + + it('should throw error when setPermissionMode is called on closed query', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + }, + }); + + await q.close(); + + await expect(q.setPermissionMode('yolo')).rejects.toThrow( + 'Query is closed', + ); + }); + }); + + describe('canUseTool and setPermissionMode integration', () => { + it('should work together - canUseTool callback with dynamic permission mode change', async () => { + const toolCalls: Array<{ + toolName: string; + input: Record; + }> = []; + + const { generator, resume } = createStreamingInputWithControlPoint( + 'Create a file named first.txt', + 'Create a file named second.txt', + ); + + const q = query({ + prompt: generator, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + toolCalls.push({ toolName, input }); + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + const resolvers: { + first?: () => void; + second?: () => void; + } = {}; + const firstResponsePromise = new Promise((resolve) => { + resolvers.first = resolve; + }); + const secondResponsePromise = new Promise((resolve) => { + resolvers.second = resolve; + }); + + let firstResponseReceived = false; + let secondResponseReceived = false; + + (async () => { + for await (const message of q) { + if (isCLIResultMessage(message)) { + if (!firstResponseReceived) { + firstResponseReceived = true; + resolvers.first?.(); + } else if (!secondResponseReceived) { + secondResponseReceived = true; + resolvers.second?.(); + } + } + } + })(); + + await Promise.race([ + firstResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for first response')), + TEST_TIMEOUT, + ), + ), + ]); + + expect(firstResponseReceived).toBe(true); + expect(toolCalls.length).toBeGreaterThan(0); + + await q.setPermissionMode('yolo'); + + resume(); + + await Promise.race([ + secondResponsePromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Timeout waiting for second response')), + TEST_TIMEOUT, + ), + ), + ]); + + expect(secondResponseReceived).toBe(true); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts new file mode 100644 index 00000000..047be4f2 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -0,0 +1,479 @@ +/** + * E2E tests for single-turn query execution + * Tests basic query patterns with simple prompts and clear output expectations + */ + +import { describe, it, expect } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + type TextBlock, + type ContentBlock, + type CLIMessage, + type CLISystemMessage, + type CLIAssistantMessage, +} from '../../src/types/protocol.js'; +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, +}; + +/** + * Helper to extract text from ContentBlock array + */ +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +describe('Single-Turn Query (E2E)', () => { + describe('Simple Text Queries', () => { + it('should answer basic arithmetic question', async () => { + const q = query({ + prompt: 'What is 2 + 2? Just give me the number.', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate we got messages + expect(messages.length).toBeGreaterThan(0); + + // Validate assistant response content + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText).toMatch(/4/); + + // Validate message flow ends with success + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }); + + it('should answer simple factual question', async () => { + const q = query({ + prompt: 'What is the capital of France? One word answer.', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate content + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText.toLowerCase()).toContain('paris'); + + // Validate completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + } finally { + await q.close(); + } + }); + + it('should handle greeting and self-description', async () => { + const q = query({ + prompt: 'Say hello and tell me your name in one sentence.', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Validate content contains greeting + expect(assistantText.length).toBeGreaterThan(0); + expect(assistantText.toLowerCase()).toMatch(/hello|hi|greetings/); + + // Validate message types + const assistantMessages = messages.filter(isCLIAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + }); + + describe('System Initialization', () => { + it('should receive system message with initialization info', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate system message exists and has required fields + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.type).toBe('system'); + expect(systemMessage!.subtype).toBe('init'); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.cwd).toBeDefined(); + expect(systemMessage!.tools).toBeDefined(); + expect(Array.isArray(systemMessage!.tools)).toBe(true); + expect(systemMessage!.mcp_servers).toBeDefined(); + expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); + expect(systemMessage!.model).toBeDefined(); + expect(systemMessage!.permissionMode).toBeDefined(); + expect(systemMessage!.qwen_code_version).toBeDefined(); + + // Validate system message appears early in sequence + const systemMessageIndex = messages.findIndex( + (msg) => isCLISystemMessage(msg) && msg.subtype === 'init', + ); + expect(systemMessageIndex).toBeGreaterThanOrEqual(0); + expect(systemMessageIndex).toBeLessThan(3); + } finally { + await q.close(); + } + }); + + it('should maintain session ID consistency', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + const sessionId = q.getSessionId(); + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate session IDs are consistent + expect(sessionId).toBeDefined(); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBe(systemMessage!.uuid); + } finally { + await q.close(); + } + }); + }); + + describe('Message Flow', () => { + it('should follow expected message sequence', async () => { + const q = query({ + prompt: 'Say hi', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messageTypes: string[] = []; + + try { + for await (const message of q) { + messageTypes.push(message.type); + } + + // Validate message sequence + expect(messageTypes.length).toBeGreaterThan(0); + expect(messageTypes).toContain('assistant'); + expect(messageTypes[messageTypes.length - 1]).toBe('result'); + } finally { + await q.close(); + } + }); + + it('should complete iteration naturally', async () => { + const q = query({ + prompt: 'Say goodbye', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let completedNaturally = false; + let messageCount = 0; + + try { + for await (const message of q) { + messageCount++; + + if (isCLIResultMessage(message)) { + completedNaturally = true; + expect(message.subtype).toBe('success'); + } + } + + expect(messageCount).toBeGreaterThan(0); + expect(completedNaturally).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Configuration Options', () => { + it('should respect debug option and capture stderr', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // Debug mode should produce stderr output + expect(stderrMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should respect cwd option', async () => { + const testDir = process.cwd(); + + const q = query({ + prompt: 'What is 1 + 1?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: false, + }, + }); + + let hasResponse = false; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + hasResponse = true; + } + } + + expect(hasResponse).toBe(true); + } finally { + await q.close(); + } + }); + }); + + describe('Message Type Recognition', () => { + it('should correctly identify all message types', async () => { + const q = query({ + prompt: 'What is 5 + 5?', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Validate type guards work correctly + const assistantMessages = messages.filter(isCLIAssistantMessage); + const resultMessages = messages.filter(isCLIResultMessage); + const systemMessages = messages.filter(isCLISystemMessage); + + expect(assistantMessages.length).toBeGreaterThan(0); + expect(resultMessages.length).toBeGreaterThan(0); + expect(systemMessages.length).toBeGreaterThan(0); + + // Validate assistant message structure + const firstAssistant = assistantMessages[0]; + expect(firstAssistant.message.content).toBeDefined(); + expect(Array.isArray(firstAssistant.message.content)).toBe(true); + + // Validate result message structure + const resultMessage = resultMessages[0]; + expect(resultMessage.subtype).toBe('success'); + } finally { + await q.close(); + } + }); + + it('should extract text content from assistant messages', async () => { + const q = query({ + prompt: 'Count from 1 to 3', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + let assistantMessage: CLIAssistantMessage | null = null; + + try { + for await (const message of q) { + if (isCLIAssistantMessage(message)) { + assistantMessage = message; + } + } + + expect(assistantMessage).not.toBeNull(); + expect(assistantMessage!.message.content).toBeDefined(); + + // Extract text blocks + const textBlocks = assistantMessage!.message.content.filter( + (block: ContentBlock): block is TextBlock => block.type === 'text', + ); + + expect(textBlocks.length).toBeGreaterThan(0); + expect(textBlocks[0].text).toBeDefined(); + expect(textBlocks[0].text.length).toBeGreaterThan(0); + + // Validate content contains expected numbers + const text = extractText(assistantMessage!.message.content); + expect(text).toMatch(/1/); + expect(text).toMatch(/2/); + expect(text).toMatch(/3/); + } finally { + await q.close(); + } + }); + }); + + describe('Error Handling', () => { + it('should throw if CLI not found', async () => { + try { + const q = query({ + prompt: 'Hello', + options: { + pathToQwenExecutable: '/nonexistent/path/to/cli', + debug: false, + }, + }); + + for await (const _message of q) { + // Should not reach here + } + + expect(false).toBe(true); // Should have thrown + } catch (error) { + expect(error).toBeDefined(); + expect(error instanceof Error).toBe(true); + expect((error as Error).message).toContain( + 'Invalid pathToQwenExecutable', + ); + } + }); + }); + + describe('Resource Management', () => { + it('should cleanup subprocess on close()', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + // Start and immediately close + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Should close without error + await q.close(); + expect(true).toBe(true); // Cleanup completed + }); + + it('should handle close() called multiple times', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + debug: false, + }, + }); + + // Start the query + const iterator = q[Symbol.asyncIterator](); + await iterator.next(); + + // Close multiple times + await q.close(); + await q.close(); + await q.close(); + + // Should not throw + expect(true).toBe(true); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts new file mode 100644 index 00000000..5e1a9d15 --- /dev/null +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -0,0 +1,207 @@ +/** + * Unit tests for ProcessTransport + * Tests subprocess lifecycle management and IPC + */ + +import { describe, expect, it } from 'vitest'; + +// Note: This is a placeholder test file +// ProcessTransport will be implemented in Phase 3 Implementation (T021) +// These tests are written first following TDD approach + +describe('ProcessTransport', () => { + describe('Construction and Initialization', () => { + it('should create transport with required options', () => { + // Test will be implemented with actual ProcessTransport class + expect(true).toBe(true); // Placeholder + }); + + it('should validate pathToQwenExecutable exists', () => { + // Should throw if pathToQwenExecutable does not exist + expect(true).toBe(true); // Placeholder + }); + + it('should build CLI arguments correctly', () => { + // Should include --input-format stream-json --output-format stream-json + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Lifecycle Management', () => { + it('should spawn subprocess during construction', async () => { + // Should call child_process.spawn in constructor + expect(true).toBe(true); // Placeholder + }); + + it('should set isReady to true after successful initialization', async () => { + // isReady should be true after construction completes + expect(true).toBe(true); // Placeholder + }); + + it('should throw if subprocess fails to spawn', async () => { + // Should throw Error if ENOENT or spawn fails + expect(true).toBe(true); // Placeholder + }); + + it('should close subprocess gracefully with SIGTERM', async () => { + // Should send SIGTERM first + expect(true).toBe(true); // Placeholder + }); + + it('should force kill with SIGKILL after timeout', async () => { + // Should send SIGKILL after 5s if process doesn\'t exit + expect(true).toBe(true); // Placeholder + }); + + it('should be idempotent when calling close() multiple times', async () => { + // Multiple close() calls should not error + expect(true).toBe(true); // Placeholder + }); + + it('should wait for process exit in waitForExit()', async () => { + // Should resolve when process exits + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Message Reading', () => { + it('should read JSON Lines from stdout', async () => { + // Should use readline to read lines and parse JSON + expect(true).toBe(true); // Placeholder + }); + + it('should yield parsed messages via readMessages()', async () => { + // Should yield messages as async generator + expect(true).toBe(true); // Placeholder + }); + + it('should skip malformed JSON lines with warning', async () => { + // Should log warning and continue on parse error + expect(true).toBe(true); // Placeholder + }); + + it('should complete generator when process exits', async () => { + // readMessages() should complete when stdout closes + expect(true).toBe(true); // Placeholder + }); + + it('should set exitError on unexpected process crash', async () => { + // exitError should be set if process crashes + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Message Writing', () => { + it('should write JSON Lines to stdin', () => { + // Should write JSON + newline to stdin + expect(true).toBe(true); // Placeholder + }); + + it('should throw if writing before transport is ready', () => { + // write() should throw if isReady is false + expect(true).toBe(true); // Placeholder + }); + + it('should throw if writing to closed transport', () => { + // write() should throw if transport is closed + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Error Handling', () => { + it('should handle process spawn errors', async () => { + // Should throw descriptive error on spawn failure + expect(true).toBe(true); // Placeholder + }); + + it('should handle process exit with non-zero code', async () => { + // Should set exitError when process exits with error + expect(true).toBe(true); // Placeholder + }); + + it('should handle write errors to closed stdin', () => { + // Should throw if stdin is closed + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Resource Cleanup', () => { + it('should register cleanup on parent process exit', () => { + // Should register process.on(\'exit\') handler + expect(true).toBe(true); // Placeholder + }); + + it('should kill subprocess on parent exit', () => { + // Cleanup should kill child process + expect(true).toBe(true); // Placeholder + }); + + it('should remove event listeners on close', async () => { + // Should clean up all event listeners + expect(true).toBe(true); // Placeholder + }); + }); + + describe('CLI Arguments', () => { + it('should include --input-format stream-json', () => { + // Args should always include input format flag + expect(true).toBe(true); // Placeholder + }); + + it('should include --output-format stream-json', () => { + // Args should always include output format flag + expect(true).toBe(true); // Placeholder + }); + + it('should include --model if provided', () => { + // Args should include model flag if specified + expect(true).toBe(true); // Placeholder + }); + + it('should include --permission-mode if provided', () => { + // Args should include permission mode flag if specified + expect(true).toBe(true); // Placeholder + }); + + it('should include --mcp-server for external MCP servers', () => { + // Args should include MCP server configs + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Working Directory', () => { + it('should spawn process in specified cwd', async () => { + // Should use cwd option for child_process.spawn + expect(true).toBe(true); // Placeholder + }); + + it('should default to process.cwd() if not specified', async () => { + // Should use current working directory by default + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Environment Variables', () => { + it('should pass environment variables to subprocess', async () => { + // Should merge env with process.env + expect(true).toBe(true); // Placeholder + }); + + it('should inherit parent env by default', async () => { + // Should use process.env if no env option + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Debug Mode', () => { + it('should inherit stderr when debug is true', async () => { + // Should set stderr: \'inherit\' if debug flag set + expect(true).toBe(true); // Placeholder + }); + + it('should ignore stderr when debug is false', async () => { + // Should set stderr: \'ignore\' if debug flag not set + expect(true).toBe(true); // Placeholder + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts new file mode 100644 index 00000000..5ceeee4b --- /dev/null +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -0,0 +1,284 @@ +/** + * Unit tests for Query class + * Tests message routing, lifecycle, and orchestration + */ + +import { describe, expect, it } from 'vitest'; + +// Note: This is a placeholder test file +// Query will be implemented in Phase 3 Implementation (T022) +// These tests are written first following TDD approach + +describe('Query', () => { + describe('Construction and Initialization', () => { + it('should create Query with transport and options', () => { + // Should accept Transport and CreateQueryOptions + expect(true).toBe(true); // Placeholder + }); + + it('should generate unique session ID', () => { + // Each Query should have unique session_id + expect(true).toBe(true); // Placeholder + }); + + it('should validate MCP server name conflicts', () => { + // Should throw if mcpServers and sdkMcpServers have same keys + expect(true).toBe(true); // Placeholder + }); + + it('should lazy initialize on first message consumption', async () => { + // Should not call initialize() until messages are read + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Message Routing', () => { + it('should route user messages to CLI', async () => { + // Initial prompt should be sent as user message + expect(true).toBe(true); // Placeholder + }); + + it('should route assistant messages to output stream', async () => { + // Assistant messages from CLI should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should route tool_use messages to output stream', async () => { + // Tool use messages should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should route tool_result messages to output stream', async () => { + // Tool result messages should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should route result messages to output stream', async () => { + // Result messages should be yielded to user + expect(true).toBe(true); // Placeholder + }); + + it('should filter keep_alive messages from output', async () => { + // Keep alive messages should not be yielded to user + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Control Plane - Permission Control', () => { + it('should handle can_use_tool control requests', async () => { + // Should invoke canUseTool callback + expect(true).toBe(true); // Placeholder + }); + + it('should send control response with permission result', async () => { + // Should send response with allowed: true/false + expect(true).toBe(true); // Placeholder + }); + + it('should default to allowing tools if no callback', async () => { + // If canUseTool not provided, should allow all + expect(true).toBe(true); // Placeholder + }); + + it('should handle permission callback timeout', async () => { + // Should deny permission if callback exceeds 30s + expect(true).toBe(true); // Placeholder + }); + + it('should handle permission callback errors', async () => { + // Should deny permission if callback throws + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Control Plane - MCP Messages', () => { + it('should route MCP messages to SDK-embedded servers', async () => { + // Should find SdkControlServerTransport by server name + expect(true).toBe(true); // Placeholder + }); + + it('should handle MCP message responses', async () => { + // Should send response back to CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle MCP message timeout', async () => { + // Should return error if MCP server doesn\'t respond in 30s + expect(true).toBe(true); // Placeholder + }); + + it('should handle unknown MCP server names', async () => { + // Should return error if server name not found + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Control Plane - Other Requests', () => { + it('should handle initialize control request', async () => { + // Should register SDK MCP servers with CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle interrupt control request', async () => { + // Should send interrupt message to CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle set_permission_mode control request', async () => { + // Should send permission mode update to CLI + expect(true).toBe(true); // Placeholder + }); + + it('should handle supported_commands control request', async () => { + // Should query CLI capabilities + expect(true).toBe(true); // Placeholder + }); + + it('should handle mcp_server_status control request', async () => { + // Should check MCP server health + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Multi-Turn Conversation', () => { + it('should support streamInput() for follow-up messages', async () => { + // Should accept async iterable of messages + expect(true).toBe(true); // Placeholder + }); + + it('should maintain session context across turns', async () => { + // All messages should have same session_id + expect(true).toBe(true); // Placeholder + }); + + it('should throw if streamInput() called on closed query', async () => { + // Should throw Error if query is closed + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Lifecycle Management', () => { + it('should close transport on close()', async () => { + // Should call transport.close() + expect(true).toBe(true); // Placeholder + }); + + it('should mark query as closed', async () => { + // closed flag should be true after close() + expect(true).toBe(true); // Placeholder + }); + + it('should complete output stream on close()', async () => { + // inputStream should be marked done + expect(true).toBe(true); // Placeholder + }); + + it('should be idempotent when closing multiple times', async () => { + // Multiple close() calls should not error + expect(true).toBe(true); // Placeholder + }); + + it('should cleanup MCP transports on close()', async () => { + // Should close all SdkControlServerTransport instances + expect(true).toBe(true); // Placeholder + }); + + it('should handle abort signal cancellation', async () => { + // Should abort on AbortSignal + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Async Iteration', () => { + it('should support for await loop', async () => { + // Should implement AsyncIterator protocol + expect(true).toBe(true); // Placeholder + }); + + it('should yield messages in order', async () => { + // Messages should be yielded in received order + expect(true).toBe(true); // Placeholder + }); + + it('should complete iteration when query closes', async () => { + // for await loop should exit when query closes + expect(true).toBe(true); // Placeholder + }); + + it('should propagate transport errors', async () => { + // Should throw if transport encounters error + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Public API Methods', () => { + it('should provide interrupt() method', async () => { + // Should send interrupt control request + expect(true).toBe(true); // Placeholder + }); + + it('should provide setPermissionMode() method', async () => { + // Should send set_permission_mode control request + expect(true).toBe(true); // Placeholder + }); + + it('should provide supportedCommands() method', async () => { + // Should query CLI capabilities + expect(true).toBe(true); // Placeholder + }); + + it('should provide mcpServerStatus() method', async () => { + // Should check MCP server health + expect(true).toBe(true); // Placeholder + }); + + it('should throw if methods called on closed query', async () => { + // Public methods should throw if query is closed + expect(true).toBe(true); // Placeholder + }); + }); + + describe('Error Handling', () => { + it('should propagate transport errors to stream', async () => { + // Transport errors should be surfaced in for await loop + expect(true).toBe(true); // Placeholder + }); + + it('should handle control request timeout', async () => { + // Should return error if control request doesn\'t respond + expect(true).toBe(true); // Placeholder + }); + + it('should handle malformed control responses', async () => { + // Should handle invalid response structures + expect(true).toBe(true); // Placeholder + }); + + it('should handle CLI sending error message', async () => { + // Should yield error message to user + expect(true).toBe(true); // Placeholder + }); + }); + + describe('State Management', () => { + it('should track pending control requests', () => { + // Should maintain map of request_id -> Promise + expect(true).toBe(true); // Placeholder + }); + + it('should track SDK MCP transports', () => { + // Should maintain map of server_name -> SdkControlServerTransport + expect(true).toBe(true); // Placeholder + }); + + it('should track initialization state', () => { + // Should have initialized Promise + expect(true).toBe(true); // Placeholder + }); + + it('should track closed state', () => { + // Should have closed boolean flag + expect(true).toBe(true); // Placeholder + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts b/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts new file mode 100644 index 00000000..6bfd61a0 --- /dev/null +++ b/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts @@ -0,0 +1,259 @@ +/** + * Unit tests for SdkControlServerTransport + * + * Tests MCP message proxying between MCP Server and Query's control plane. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SdkControlServerTransport } from '../../src/mcp/SdkControlServerTransport.js'; + +describe('SdkControlServerTransport', () => { + let sendToQuery: ReturnType; + let transport: SdkControlServerTransport; + + beforeEach(() => { + sendToQuery = vi.fn().mockResolvedValue({ result: 'success' }); + transport = new SdkControlServerTransport({ + serverName: 'test-server', + sendToQuery, + }); + }); + + describe('Lifecycle', () => { + it('should start successfully', async () => { + await transport.start(); + expect(transport.isStarted()).toBe(true); + }); + + it('should close successfully', async () => { + await transport.start(); + await transport.close(); + expect(transport.isStarted()).toBe(false); + }); + + it('should handle close callback', async () => { + const onclose = vi.fn(); + transport.onclose = onclose; + + await transport.start(); + await transport.close(); + + expect(onclose).toHaveBeenCalled(); + }); + }); + + describe('Message Sending', () => { + it('should send message to Query', async () => { + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + params: {}, + }; + + await transport.send(message); + + expect(sendToQuery).toHaveBeenCalledWith(message); + }); + + it('should throw error when sending before start', async () => { + const message = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + }; + + await expect(transport.send(message)).rejects.toThrow('not started'); + }); + + it('should handle send errors', async () => { + const error = new Error('Network error'); + sendToQuery.mockRejectedValue(error); + + const onerror = vi.fn(); + transport.onerror = onerror; + + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + }; + + await expect(transport.send(message)).rejects.toThrow('Network error'); + expect(onerror).toHaveBeenCalledWith(error); + }); + }); + + describe('Message Receiving', () => { + it('should deliver message to MCP Server via onmessage', async () => { + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + result: { tools: [] }, + }; + + transport.handleMessage(message); + + expect(onmessage).toHaveBeenCalledWith(message); + }); + + it('should warn when receiving message without onmessage handler', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + result: {}, + }; + + transport.handleMessage(message); + + expect(consoleWarnSpy).toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + + it('should warn when receiving message for closed transport', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + await transport.close(); + + const message = { + jsonrpc: '2.0' as const, + id: 1, + result: {}, + }; + + transport.handleMessage(message); + + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(onmessage).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('Error Handling', () => { + it('should deliver error to MCP Server via onerror', async () => { + const onerror = vi.fn(); + transport.onerror = onerror; + + await transport.start(); + + const error = new Error('Test error'); + transport.handleError(error); + + expect(onerror).toHaveBeenCalledWith(error); + }); + + it('should log error when no onerror handler set', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await transport.start(); + + const error = new Error('Test error'); + transport.handleError(error); + + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('Server Name', () => { + it('should return server name', () => { + expect(transport.getServerName()).toBe('test-server'); + }); + }); + + describe('Bidirectional Communication', () => { + it('should support full message round-trip', async () => { + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + + // Send request from MCP Server to CLI + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + params: {}, + }; + + await transport.send(request); + expect(sendToQuery).toHaveBeenCalledWith(request); + + // Receive response from CLI to MCP Server + const response = { + jsonrpc: '2.0' as const, + id: 1, + result: { + tools: [ + { + name: 'test_tool', + description: 'A test tool', + inputSchema: { type: 'object' }, + }, + ], + }, + }; + + transport.handleMessage(response); + expect(onmessage).toHaveBeenCalledWith(response); + }); + + it('should handle multiple messages in sequence', async () => { + const onmessage = vi.fn(); + transport.onmessage = onmessage; + + await transport.start(); + + // Send multiple requests + for (let i = 0; i < 5; i++) { + const message = { + jsonrpc: '2.0' as const, + id: i, + method: 'test', + }; + + await transport.send(message); + } + + expect(sendToQuery).toHaveBeenCalledTimes(5); + + // Receive multiple responses + for (let i = 0; i < 5; i++) { + const message = { + jsonrpc: '2.0' as const, + id: i, + result: {}, + }; + + transport.handleMessage(message); + } + + expect(onmessage).toHaveBeenCalledTimes(5); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/Stream.test.ts b/packages/sdk-typescript/test/unit/Stream.test.ts new file mode 100644 index 00000000..2113a202 --- /dev/null +++ b/packages/sdk-typescript/test/unit/Stream.test.ts @@ -0,0 +1,255 @@ +/** + * Unit tests for Stream class + * Tests producer-consumer patterns and async iteration + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { Stream } from '../../src/utils/Stream.js'; + +describe('Stream', () => { + let stream: Stream; + + beforeEach(() => { + stream = new Stream(); + }); + + describe('Producer-Consumer Patterns', () => { + it('should deliver enqueued value immediately to waiting consumer', async () => { + // Start consumer (waits for value) + const consumerPromise = stream.next(); + + // Producer enqueues value + stream.enqueue('hello'); + + // Consumer should receive value immediately + const result = await consumerPromise; + expect(result).toEqual({ value: 'hello', done: false }); + }); + + it('should buffer values when consumer is slow', async () => { + // Producer enqueues multiple values + stream.enqueue('first'); + stream.enqueue('second'); + stream.enqueue('third'); + + // Consumer reads buffered values + expect(await stream.next()).toEqual({ value: 'first', done: false }); + expect(await stream.next()).toEqual({ value: 'second', done: false }); + expect(await stream.next()).toEqual({ value: 'third', done: false }); + }); + + it('should handle fast producer and fast consumer', async () => { + const values: string[] = []; + + // Produce and consume simultaneously + const consumerPromise = (async () => { + for (let i = 0; i < 3; i++) { + const result = await stream.next(); + if (!result.done) { + values.push(result.value); + } + } + })(); + + stream.enqueue('a'); + stream.enqueue('b'); + stream.enqueue('c'); + + await consumerPromise; + expect(values).toEqual(['a', 'b', 'c']); + }); + + it('should handle async iteration with for await loop', async () => { + const values: string[] = []; + + // Start consumer + const consumerPromise = (async () => { + for await (const value of stream) { + values.push(value); + } + })(); + + // Producer enqueues and completes + stream.enqueue('x'); + stream.enqueue('y'); + stream.enqueue('z'); + stream.done(); + + await consumerPromise; + expect(values).toEqual(['x', 'y', 'z']); + }); + }); + + describe('Stream Completion', () => { + it('should signal completion when done() is called', async () => { + stream.done(); + const result = await stream.next(); + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should complete waiting consumer immediately', async () => { + const consumerPromise = stream.next(); + stream.done(); + const result = await consumerPromise; + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should allow done() to be called multiple times', async () => { + stream.done(); + stream.done(); + stream.done(); + + const result = await stream.next(); + expect(result).toEqual({ done: true, value: undefined }); + }); + + it('should allow enqueuing to completed stream (no check in reference)', async () => { + stream.done(); + // Reference version doesn't check for done in enqueue + stream.enqueue('value'); + // Verify value was enqueued by reading it + expect(await stream.next()).toEqual({ value: 'value', done: false }); + }); + + it('should deliver buffered values before completion', async () => { + stream.enqueue('first'); + stream.enqueue('second'); + stream.done(); + + expect(await stream.next()).toEqual({ value: 'first', done: false }); + expect(await stream.next()).toEqual({ value: 'second', done: false }); + expect(await stream.next()).toEqual({ done: true, value: undefined }); + }); + }); + + describe('Error Handling', () => { + it('should propagate error to waiting consumer', async () => { + const consumerPromise = stream.next(); + const error = new Error('Stream error'); + stream.error(error); + + await expect(consumerPromise).rejects.toThrow('Stream error'); + }); + + it('should throw error on next read after error is set', async () => { + const error = new Error('Test error'); + stream.error(error); + + await expect(stream.next()).rejects.toThrow('Test error'); + }); + + it('should allow enqueuing to stream with error (no check in reference)', async () => { + stream.error(new Error('Error')); + // Reference version doesn't check for error in enqueue + stream.enqueue('value'); + // Verify value was enqueued by reading it + expect(await stream.next()).toEqual({ value: 'value', done: false }); + }); + + it('should store last error (reference overwrites)', async () => { + const firstError = new Error('First'); + const secondError = new Error('Second'); + + stream.error(firstError); + stream.error(secondError); + + await expect(stream.next()).rejects.toThrow('Second'); + }); + + it('should deliver buffered values before throwing error', async () => { + stream.enqueue('buffered'); + stream.error(new Error('Stream error')); + + expect(await stream.next()).toEqual({ value: 'buffered', done: false }); + await expect(stream.next()).rejects.toThrow('Stream error'); + }); + }); + + describe('State Properties', () => { + it('should track error state', () => { + expect(stream.hasError).toBeUndefined(); + stream.error(new Error('Test')); + expect(stream.hasError).toBeInstanceOf(Error); + expect(stream.hasError?.message).toBe('Test'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty stream', async () => { + stream.done(); + const result = await stream.next(); + expect(result.done).toBe(true); + }); + + it('should handle single value', async () => { + stream.enqueue('only'); + stream.done(); + + expect(await stream.next()).toEqual({ value: 'only', done: false }); + expect(await stream.next()).toEqual({ done: true, value: undefined }); + }); + + it('should handle rapid enqueue-dequeue cycles', async () => { + const numberStream = new Stream(); + const iterations = 100; + const values: number[] = []; + + const producer = async (): Promise => { + for (let i = 0; i < iterations; i++) { + numberStream.enqueue(i); + await new Promise((resolve) => setImmediate(resolve)); + } + numberStream.done(); + }; + + const consumer = async (): Promise => { + for await (const value of numberStream) { + values.push(value); + } + }; + + await Promise.all([producer(), consumer()]); + expect(values).toHaveLength(iterations); + expect(values[0]).toBe(0); + expect(values[iterations - 1]).toBe(iterations - 1); + }); + }); + + describe('TypeScript Types', () => { + it('should handle different value types', async () => { + const numberStream = new Stream(); + numberStream.enqueue(42); + numberStream.done(); + + const result = await numberStream.next(); + expect(result.value).toBe(42); + + const objectStream = new Stream<{ id: number; name: string }>(); + objectStream.enqueue({ id: 1, name: 'test' }); + objectStream.done(); + + const objectResult = await objectStream.next(); + expect(objectResult.value).toEqual({ id: 1, name: 'test' }); + }); + }); + + describe('Iteration Restrictions', () => { + it('should only allow iteration once', async () => { + const stream = new Stream(); + stream.enqueue('test'); + stream.done(); + + // First iteration should work + const iterator1 = stream[Symbol.asyncIterator](); + expect(await iterator1.next()).toEqual({ + value: 'test', + done: false, + }); + + // Second iteration should throw + expect(() => stream[Symbol.asyncIterator]()).toThrow( + 'Stream can only be iterated once', + ); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts new file mode 100644 index 00000000..55a87b92 --- /dev/null +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -0,0 +1,668 @@ +/** + * Unit tests for CLI path utilities + * Tests executable detection, parsing, and spawn info preparation + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execSync } from 'node:child_process'; +import { + parseExecutableSpec, + prepareSpawnInfo, + findNativeCliPath, + resolveCliPath, +} from '../../src/utils/cliPath.js'; + +// Mock fs module +vi.mock('node:fs'); +const mockFs = vi.mocked(fs); + +// Mock child_process module +vi.mock('node:child_process'); +const mockExecSync = vi.mocked(execSync); + +// Mock process.versions for bun detection +const originalVersions = process.versions; + +describe('CLI Path Utilities', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset process.versions + Object.defineProperty(process, 'versions', { + value: { ...originalVersions }, + writable: true, + }); + // Default: tsx is available (can be overridden in specific tests) + mockExecSync.mockReturnValue(Buffer.from('')); + // Default: mock statSync to return a proper file stat object + mockFs.statSync.mockReturnValue({ + isFile: () => true, + } as ReturnType); + }); + + afterEach(() => { + // Restore original process.versions + Object.defineProperty(process, 'versions', { + value: originalVersions, + writable: true, + }); + }); + + describe('parseExecutableSpec', () => { + describe('auto-detection (no spec provided)', () => { + it('should auto-detect native CLI when no spec provided', () => { + // Mock environment variable + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec(); + + expect(result).toEqual({ + executablePath: '/usr/local/bin/qwen', + isExplicitRuntime: false, + }); + + // Restore env + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should throw when auto-detection fails', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec()).toThrow( + 'qwen CLI not found. Please:', + ); + }); + }); + + describe('runtime prefix parsing', () => { + it('should parse node runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('node:/path/to/cli.js'); + + expect(result).toEqual({ + runtime: 'node', + executablePath: path.resolve('/path/to/cli.js'), + isExplicitRuntime: true, + }); + }); + + it('should parse bun runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('bun:/path/to/cli.js'); + + expect(result).toEqual({ + runtime: 'bun', + executablePath: path.resolve('/path/to/cli.js'), + isExplicitRuntime: true, + }); + }); + + it('should parse tsx runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('tsx:/path/to/index.ts'); + + expect(result).toEqual({ + runtime: 'tsx', + executablePath: path.resolve('/path/to/index.ts'), + isExplicitRuntime: true, + }); + }); + + it('should parse deno runtime prefix', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('deno:/path/to/cli.ts'); + + expect(result).toEqual({ + runtime: 'deno', + executablePath: path.resolve('/path/to/cli.ts'), + isExplicitRuntime: true, + }); + }); + + it('should throw for invalid runtime prefix format', () => { + expect(() => parseExecutableSpec('invalid:format')).toThrow( + 'Unsupported runtime', + ); + }); + + it('should throw when runtime-prefixed file does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('node:/nonexistent/cli.js')).toThrow( + 'Executable file not found at', + ); + }); + }); + + describe('command name detection', () => { + it('should detect command names without path separators', () => { + const result = parseExecutableSpec('qwen'); + + expect(result).toEqual({ + executablePath: 'qwen', + isExplicitRuntime: false, + }); + }); + + it('should detect command names on Windows', () => { + const result = parseExecutableSpec('qwen.exe'); + + expect(result).toEqual({ + executablePath: 'qwen.exe', + isExplicitRuntime: false, + }); + }); + }); + + describe('file path resolution', () => { + it('should resolve absolute file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('/absolute/path/to/qwen'); + + expect(result).toEqual({ + executablePath: '/absolute/path/to/qwen', + isExplicitRuntime: false, + }); + }); + + it('should resolve relative file paths', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = parseExecutableSpec('./relative/path/to/qwen'); + + expect(result).toEqual({ + executablePath: path.resolve('./relative/path/to/qwen'), + isExplicitRuntime: false, + }); + }); + + it('should throw when file path does not exist', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/nonexistent/path')).toThrow( + 'Executable file not found at', + ); + }); + }); + }); + + describe('prepareSpawnInfo', () => { + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); + }); + + describe('native executables', () => { + it('should prepare spawn info for native binary command', () => { + const result = prepareSpawnInfo('qwen'); + + expect(result).toEqual({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + }); + + it('should prepare spawn info for native binary path', () => { + const result = prepareSpawnInfo('/usr/local/bin/qwen'); + + expect(result).toEqual({ + command: '/usr/local/bin/qwen', + args: [], + type: 'native', + originalInput: '/usr/local/bin/qwen', + }); + }); + }); + + describe('JavaScript files', () => { + it('should use node for .js files', () => { + const result = prepareSpawnInfo('/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: '/path/to/cli.js', + }); + }); + + it('should default to node for .js files (not auto-detect bun)', () => { + // Even when running under bun, default to node for .js files + Object.defineProperty(process, 'versions', { + value: { ...originalVersions, bun: '1.0.0' }, + writable: true, + }); + + const result = prepareSpawnInfo('/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: '/path/to/cli.js', + }); + }); + + it('should handle .mjs files', () => { + const result = prepareSpawnInfo('/path/to/cli.mjs'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.mjs')], + type: 'node', + originalInput: '/path/to/cli.mjs', + }); + }); + + it('should handle .cjs files', () => { + const result = prepareSpawnInfo('/path/to/cli.cjs'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.cjs')], + type: 'node', + originalInput: '/path/to/cli.cjs', + }); + }); + }); + + describe('TypeScript files', () => { + it('should use tsx for .ts files when tsx is available', () => { + // tsx is available by default in beforeEach + const result = prepareSpawnInfo('/path/to/index.ts'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/index.ts')], + type: 'tsx', + originalInput: '/path/to/index.ts', + }); + }); + + it('should use tsx for .tsx files when tsx is available', () => { + const result = prepareSpawnInfo('/path/to/cli.tsx'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/cli.tsx')], + type: 'tsx', + originalInput: '/path/to/cli.tsx', + }); + }); + + it('should throw helpful error when tsx is not available', () => { + // Mock tsx not being available + mockExecSync.mockImplementation(() => { + throw new Error('Command not found'); + }); + + expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( + "TypeScript file '/path/to/index.ts' requires 'tsx' runtime, but it's not available", + ); + expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( + 'Please install tsx: npm install -g tsx', + ); + }); + }); + + describe('explicit runtime specifications', () => { + it('should use explicit node runtime', () => { + const result = prepareSpawnInfo('node:/path/to/cli.js'); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve('/path/to/cli.js')], + type: 'node', + originalInput: 'node:/path/to/cli.js', + }); + }); + + it('should use explicit bun runtime', () => { + const result = prepareSpawnInfo('bun:/path/to/cli.js'); + + expect(result).toEqual({ + command: 'bun', + args: [path.resolve('/path/to/cli.js')], + type: 'bun', + originalInput: 'bun:/path/to/cli.js', + }); + }); + + it('should use explicit tsx runtime', () => { + const result = prepareSpawnInfo('tsx:/path/to/index.ts'); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve('/path/to/index.ts')], + type: 'tsx', + originalInput: 'tsx:/path/to/index.ts', + }); + }); + + it('should use explicit deno runtime', () => { + const result = prepareSpawnInfo('deno:/path/to/cli.ts'); + + expect(result).toEqual({ + command: 'deno', + args: [path.resolve('/path/to/cli.ts')], + type: 'deno', + originalInput: 'deno:/path/to/cli.ts', + }); + }); + }); + + describe('auto-detection fallback', () => { + it('should auto-detect when no spec provided', () => { + // Mock environment variable + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + + const result = prepareSpawnInfo(); + + expect(result).toEqual({ + command: '/usr/local/bin/qwen', + args: [], + type: 'native', + originalInput: '', + }); + + // Restore env + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + }); + + describe('findNativeCliPath', () => { + it('should find CLI from environment variable', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = findNativeCliPath(); + + expect(result).toBe('/custom/path/to/qwen'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should search common installation locations', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + delete process.env['QWEN_CODE_CLI_PATH']; + + // Mock fs.existsSync to return true for volta bin + mockFs.existsSync.mockImplementation((path) => { + return path.toString().includes('.volta/bin/qwen'); + }); + + const result = findNativeCliPath(); + + expect(result).toContain('.volta/bin/qwen'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + + it('should throw descriptive error when CLI not found', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + delete process.env['QWEN_CODE_CLI_PATH']; + mockFs.existsSync.mockReturnValue(false); + + expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + + describe('resolveCliPath (backward compatibility)', () => { + it('should resolve CLI path for backward compatibility', () => { + mockFs.existsSync.mockReturnValue(true); + + const result = resolveCliPath('/path/to/qwen'); + + expect(result).toBe('/path/to/qwen'); + }); + + it('should auto-detect when no path provided', () => { + const originalEnv = process.env['QWEN_CODE_CLI_PATH']; + process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; + mockFs.existsSync.mockReturnValue(true); + + const result = resolveCliPath(); + + expect(result).toBe('/usr/local/bin/qwen'); + + process.env['QWEN_CODE_CLI_PATH'] = originalEnv; + }); + }); + + describe('real-world use cases', () => { + beforeEach(() => { + mockFs.existsSync.mockReturnValue(true); + }); + + it('should handle development with TypeScript source', () => { + const devPath = '/Users/dev/qwen-code/packages/cli/index.ts'; + const result = prepareSpawnInfo(devPath); + + expect(result).toEqual({ + command: 'tsx', + args: [path.resolve(devPath)], + type: 'tsx', + originalInput: devPath, + }); + }); + + it('should handle production bundle validation', () => { + const bundlePath = '/path/to/bundled/cli.js'; + const result = prepareSpawnInfo(bundlePath); + + expect(result).toEqual({ + command: process.execPath, + args: [path.resolve(bundlePath)], + type: 'node', + originalInput: bundlePath, + }); + }); + + it('should handle production native binary', () => { + const result = prepareSpawnInfo('qwen'); + + expect(result).toEqual({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + }); + + it('should handle bun runtime with bundle', () => { + const bundlePath = '/path/to/cli.js'; + const result = prepareSpawnInfo(`bun:${bundlePath}`); + + expect(result).toEqual({ + command: 'bun', + args: [path.resolve(bundlePath)], + type: 'bun', + originalInput: `bun:${bundlePath}`, + }); + }); + }); + + describe('error cases', () => { + it('should provide helpful error for missing TypeScript file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('/missing/index.ts')).toThrow( + 'Executable file not found at', + ); + }); + + it('should provide helpful error for missing JavaScript file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => prepareSpawnInfo('/missing/cli.js')).toThrow( + 'Executable file not found at', + ); + }); + + it('should provide helpful error for invalid runtime specification', () => { + expect(() => prepareSpawnInfo('invalid:spec')).toThrow( + 'Unsupported runtime', + ); + }); + }); + + describe('comprehensive validation', () => { + describe('runtime validation', () => { + it('should reject unsupported runtimes', () => { + expect(() => + parseExecutableSpec('unsupported:/path/to/file.js'), + ).toThrow( + "Unsupported runtime 'unsupported'. Supported runtimes: node, bun, tsx, deno", + ); + }); + + it('should validate runtime availability for explicit runtime specs', () => { + mockFs.existsSync.mockReturnValue(true); + // Mock bun not being available + mockExecSync.mockImplementation((command) => { + if (command.includes('bun')) { + throw new Error('Command not found'); + } + return Buffer.from(''); + }); + + expect(() => parseExecutableSpec('bun:/path/to/cli.js')).toThrow( + "Runtime 'bun' is not available on this system. Please install it first.", + ); + }); + + it('should allow node runtime (always available)', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('node:/path/to/cli.js')).not.toThrow(); + }); + + it('should validate file extension matches runtime', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('tsx:/path/to/file.js')).toThrow( + "File extension '.js' is not compatible with runtime 'tsx'", + ); + }); + + it('should validate node runtime with JavaScript files', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('node:/path/to/file.ts')).toThrow( + "File extension '.ts' is not compatible with runtime 'node'", + ); + }); + + it('should accept valid runtime-file combinations', () => { + mockFs.existsSync.mockReturnValue(true); + + expect(() => parseExecutableSpec('tsx:/path/to/file.ts')).not.toThrow(); + expect(() => + parseExecutableSpec('node:/path/to/file.js'), + ).not.toThrow(); + expect(() => + parseExecutableSpec('bun:/path/to/file.mjs'), + ).not.toThrow(); + }); + }); + + describe('command name validation', () => { + it('should reject empty command names', () => { + expect(() => parseExecutableSpec('')).toThrow( + 'Command name cannot be empty', + ); + expect(() => parseExecutableSpec(' ')).toThrow( + 'Command name cannot be empty', + ); + }); + + it('should reject invalid command name characters', () => { + expect(() => parseExecutableSpec('qwen@invalid')).toThrow( + "Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.", + ); + + expect(() => parseExecutableSpec('qwen/invalid')).not.toThrow(); // This is treated as a path + }); + + it('should accept valid command names', () => { + expect(() => parseExecutableSpec('qwen')).not.toThrow(); + expect(() => parseExecutableSpec('qwen-code')).not.toThrow(); + expect(() => parseExecutableSpec('qwen_code')).not.toThrow(); + expect(() => parseExecutableSpec('qwen.exe')).not.toThrow(); + expect(() => parseExecutableSpec('qwen123')).not.toThrow(); + }); + }); + + describe('file path validation', () => { + it('should validate file exists', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/nonexistent/path')).toThrow( + 'Executable file not found at', + ); + }); + + it('should validate path points to a file, not directory', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ + isFile: () => false, + } as ReturnType); + + expect(() => parseExecutableSpec('/path/to/directory')).toThrow( + 'exists but is not a file', + ); + }); + + it('should accept valid file paths', () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ + isFile: () => true, + } as ReturnType); + + expect(() => parseExecutableSpec('/path/to/qwen')).not.toThrow(); + expect(() => parseExecutableSpec('./relative/path')).not.toThrow(); + }); + }); + + describe('error message quality', () => { + it('should provide helpful error for missing runtime-prefixed file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow( + 'Executable file not found at', + ); + expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow( + 'Please check the file path and ensure the file exists', + ); + }); + + it('should provide helpful error for missing regular file', () => { + mockFs.existsSync.mockReturnValue(false); + + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Set QWEN_CODE_CLI_PATH environment variable', + ); + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Install qwen globally: npm install -g qwen', + ); + expect(() => parseExecutableSpec('/missing/file')).toThrow( + 'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts', + ); + }); + }); + }); +}); diff --git a/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts new file mode 100644 index 00000000..e608ba7b --- /dev/null +++ b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts @@ -0,0 +1,350 @@ +/** + * Unit tests for createSdkMcpServer + * + * Tests MCP server creation and tool registration. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { createSdkMcpServer } from '../../src/mcp/createSdkMcpServer.js'; +import { tool } from '../../src/mcp/tool.js'; +import type { ToolDefinition } from '../../src/types/config.js'; + +describe('createSdkMcpServer', () => { + describe('Server Creation', () => { + it('should create server with name and version', () => { + const server = createSdkMcpServer('test-server', '1.0.0', []); + + expect(server).toBeDefined(); + }); + + it('should throw error with invalid name', () => { + expect(() => createSdkMcpServer('', '1.0.0', [])).toThrow( + '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', + ); + }); + + it('should throw error with non-array tools', () => { + expect(() => + createSdkMcpServer('test', '1.0.0', {} as unknown as ToolDefinition[]), + ).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 server = createSdkMcpServer('test-server', '1.0.0', [testTool]); + + expect(server).toBeDefined(); + }); + + it('should register multiple tools', () => { + const tool1 = tool({ + name: 'tool1', + description: 'Tool 1', + inputSchema: { type: 'object' }, + handler: async () => 'result1', + }); + + const tool2 = tool({ + name: 'tool2', + description: 'Tool 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [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 tool2 = tool({ + name: 'duplicate', + description: 'Tool 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + expect(() => + createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]), + ).toThrow("Duplicate tool name 'duplicate'"); + }); + + it('should validate tool names', () => { + const invalidTool = { + name: '123invalid', // Starts with number + description: 'Invalid tool', + inputSchema: { type: 'object' }, + handler: async () => 'result', + }; + + expect(() => + createSdkMcpServer('test-server', '1.0.0', [ + invalidTool as unknown as ToolDefinition, + ]), + ).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, + }); + + createSdkMcpServer('test-server', '1.0.0', [testTool]); + + // Note: Actual invocation testing requires MCP SDK integration + // This test verifies the handler was properly registered + expect(handler).toBeDefined(); + }); + + it('should handle async tool handlers', async () => { + const handler = vi + .fn() + .mockImplementation(async (input: { value: string }) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { processed: input.value }; + }); + + const testTool = tool({ + name: 'async_tool', + description: 'An async tool', + inputSchema: { type: 'object' }, + handler, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [testTool]); + + expect(server).toBeDefined(); + }); + }); + + 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 => { + return { + greeting: `Hello ${input.name}, age ${input.age}`, + }; + }); + + const typedTool = tool({ + name: 'typed_tool', + description: 'A typed tool', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + }, + handler, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [ + typedTool as ToolDefinition, + ]); + + expect(server).toBeDefined(); + }); + }); + + describe('Error Handling in Tools', () => { + 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 server = createSdkMcpServer('test-server', '1.0.0', [errorTool]); + + expect(server).toBeDefined(); + // Error handling occurs during tool invocation + }); + + it('should handle synchronous tool handler errors', async () => { + const handler = vi.fn().mockImplementation(() => { + throw new Error('Sync error'); + }); + + const errorTool = tool({ + name: 'sync_error_tool', + description: 'A tool that errors synchronously', + inputSchema: { type: 'object' }, + handler, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]); + + expect(server).toBeDefined(); + }); + }); + + 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'], + }, + handler: async (input: { filters?: unknown[] }) => { + return { + results: [], + filters: input.filters, + }; + }, + }); + + const server = createSdkMcpServer('test-server', '1.0.0', [ + complexTool as ToolDefinition, + ]); + + 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 () => { + return { + 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, + ]); + + expect(server).toBeDefined(); + }); + }); + + 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 tool2 = tool({ + name: 'tool2', + description: 'Tool in server 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]); + const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]); + + expect(server1).toBeDefined(); + expect(server2).toBeDefined(); + }); + + 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 tool2 = tool({ + name: 'shared_name', + description: 'Tool in server 2', + inputSchema: { type: 'object' }, + handler: async () => 'result2', + }); + + const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]); + const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]); + + expect(server1).toBeDefined(); + expect(server2).toBeDefined(); + }); + }); +}); diff --git a/packages/sdk-typescript/tsconfig.json b/packages/sdk-typescript/tsconfig.json new file mode 100644 index 00000000..11fba047 --- /dev/null +++ b/packages/sdk-typescript/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + /* Language and Environment */ + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + + /* Emit */ + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "removeComments": true, + "importHelpers": false, + + /* Interop Constraints */ + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + + /* Type Checking */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + + /* Completeness */ + "skipLibCheck": true, + + /* Module Resolution */ + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test", ".integration-tests"] +} diff --git a/packages/sdk-typescript/vitest.config.ts b/packages/sdk-typescript/vitest.config.ts new file mode 100644 index 00000000..33018d83 --- /dev/null +++ b/packages/sdk-typescript/vitest.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'vitest/config'; +import * as path from 'path'; + +const timeoutMinutes = Number(process.env['E2E_TIMEOUT_MINUTES'] || '3'); +const testTimeoutMs = timeoutMinutes * 60 * 1000; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + 'test/', + '**/*.d.ts', + '**/*.config.*', + '**/index.ts', // Export-only files + ], + thresholds: { + lines: 80, + functions: 80, + branches: 75, + statements: 80, + }, + }, + include: ['test/**/*.test.ts'], + exclude: ['node_modules/', 'dist/'], + testTimeout: testTimeoutMs, + hookTimeout: 10000, + globalSetup: './test/e2e/globalSetup.ts', + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 20ec6b90..88cded8b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'packages/cli', 'packages/core', 'packages/vscode-ide-companion', + 'packages/sdk-typescript', 'integration-tests', 'scripts', ], From d76cdf107615ec7a6ff941c7d88dce498ce9b90e Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 25 Nov 2025 10:03:15 +0800 Subject: [PATCH 04/38] feat: sdk subagent support --- .../control/controllers/systemController.ts | 11 +- .../subagents/manage/AgentEditStep.tsx | 5 +- .../subagents/manage/AgentSelectionStep.tsx | 4 +- packages/core/src/config/config.ts | 13 +- .../src/subagents/subagent-manager.test.ts | 20 +- .../core/src/subagents/subagent-manager.ts | 23 +- packages/core/src/subagents/types.ts | 4 +- packages/sdk-typescript/src/query/Query.ts | 1 + packages/sdk-typescript/src/types/protocol.ts | 2 +- .../src/types/queryOptionsSchema.ts | 7 +- .../sdk-typescript/test/e2e/subagents.test.ts | 656 ++++++++++++++++++ 11 files changed, 705 insertions(+), 41 deletions(-) create mode 100644 packages/sdk-typescript/test/e2e/subagents.test.ts diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index a33ea161..7981a67b 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -54,13 +54,13 @@ export class SystemController extends BaseController { private async handleInitialize( payload: CLIControlInitializeRequest, ): Promise> { - // Register SDK MCP servers if provided + 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); } - // Add SDK MCP servers to config try { this.context.config.addMcpServers(payload.sdkMcpServers); if (this.context.debugMode) { @@ -78,7 +78,6 @@ export class SystemController extends BaseController { } } - // Add MCP servers to config if provided if (payload.mcpServers && typeof payload.mcpServers === 'object') { try { this.context.config.addMcpServers(payload.mcpServers); @@ -94,10 +93,9 @@ export class SystemController extends BaseController { } } - // Add session subagents to config if provided if (payload.agents && Array.isArray(payload.agents)) { try { - this.context.config.addSessionSubagents(payload.agents); + this.context.config.setSessionSubagents(payload.agents); if (this.context.debugMode) { console.error( @@ -114,9 +112,6 @@ export class SystemController extends BaseController { } } - // Set SDK mode to true after handling initialize - this.context.config.setSdkMode(true); - // Build capabilities for response const capabilities = this.buildControlCapabilities(); diff --git a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx index ab1cd2a9..ccec2ebf 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx @@ -69,7 +69,10 @@ export function EditOptionsStep({ if (selectedValue === 'editor') { // Launch editor directly try { - await launchEditor(selectedAgent?.filePath); + if (!selectedAgent.filePath) { + throw new Error('Agent has no file path'); + } + await launchEditor(selectedAgent.filePath); } catch (err) { setError( t('Failed to launch editor: {{error}}', { diff --git a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx index a186374d..add3dcb5 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx @@ -267,7 +267,7 @@ export const AgentSelectionStep = ({ {t('Project Level ({{path}})', { - path: projectAgents[0].filePath.replace(/\/[^/]+$/, ''), + path: projectAgents[0].filePath?.replace(/\/[^/]+$/, '') || '', })} @@ -289,7 +289,7 @@ export const AgentSelectionStep = ({ > {t('User Level ({{path}})', { - path: userAgents[0].filePath.replace(/\/[^/]+$/, ''), + path: userAgents[0].filePath?.replace(/\/[^/]+$/, '') || '', })} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 29757ff6..65c39d8e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -613,6 +613,12 @@ export class Config { } this.promptRegistry = new PromptRegistry(); this.subagentManager = new SubagentManager(this); + + // Load session subagents if they were provided before initialization + if (this.sessionSubagents.length > 0) { + this.subagentManager.loadSessionSubagents(this.sessionSubagents); + } + this.toolRegistry = await this.createToolRegistry(); await this.geminiClient.initialize(); @@ -874,13 +880,6 @@ export class Config { this.sessionSubagents = subagents; } - addSessionSubagents(subagents: SubagentConfig[]): void { - if (this.initialized) { - throw new Error('Cannot modify sessionSubagents after initialization'); - } - this.sessionSubagents = [...this.sessionSubagents, ...subagents]; - } - getSdkMode(): boolean { return this.sdkMode; } diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index 26436c88..e04964ea 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -182,7 +182,7 @@ You are a helpful assistant. it('should parse valid markdown content', () => { const config = manager.parseSubagentContent( validMarkdown, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -207,7 +207,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithTools, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -228,7 +228,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithModel, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -249,7 +249,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithRun, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -267,7 +267,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithNumeric, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -288,7 +288,7 @@ You are a helpful assistant. const config = manager.parseSubagentContent( markdownWithBoolean, - validConfig.filePath, + validConfig.filePath!, 'project', ); @@ -324,7 +324,7 @@ Just content`; expect(() => manager.parseSubagentContent( invalidMarkdown, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -341,7 +341,7 @@ You are a helpful assistant. expect(() => manager.parseSubagentContent( markdownWithoutName, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -358,7 +358,7 @@ You are a helpful assistant. expect(() => manager.parseSubagentContent( markdownWithoutDescription, - validConfig.filePath, + validConfig.filePath!, 'project', ), ).toThrow(SubagentError); @@ -438,7 +438,7 @@ You are a helpful assistant. await manager.createSubagent(validConfig, { level: 'project' }); expect(fs.mkdir).toHaveBeenCalledWith( - path.normalize(path.dirname(validConfig.filePath)), + path.normalize(path.dirname(validConfig.filePath!)), { recursive: true }, ); expect(fs.writeFile).toHaveBeenCalledWith( diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index d83e3e7a..baf49fa9 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -159,7 +159,14 @@ export class SubagentManager { return this.findSubagentByNameAtLevel(name, level); } - // Try project level first + // Try session level first (highest priority for runtime) + const sessionSubagents = this.subagentsCache?.get('session') || []; + const sessionConfig = sessionSubagents.find((agent) => agent.name === name); + if (sessionConfig) { + return sessionConfig; + } + + // Try project level const projectConfig = await this.findSubagentByNameAtLevel(name, 'project'); if (projectConfig) { return projectConfig; @@ -220,6 +227,15 @@ export class SubagentManager { // Validate the updated configuration this.validator.validateOrThrow(updatedConfig); + // Ensure filePath exists for file-based agents + if (!existing.filePath) { + throw new SubagentError( + `Cannot update subagent "${name}": no file path available`, + SubagentErrorCode.FILE_ERROR, + name, + ); + } + // Write the updated configuration const content = this.serializeSubagent(updatedConfig); @@ -302,11 +318,6 @@ export class SubagentManager { // In SDK mode, only load session-level subagents if (this.config.getSdkMode()) { - const sessionSubagents = this.config.getSessionSubagents(); - if (sessionSubagents && sessionSubagents.length > 0) { - this.loadSessionSubagents(sessionSubagents); - } - const levelsToCheck: SubagentLevel[] = options.level ? [options.level] : ['session']; diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index 0f83e3f1..accfb18f 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -42,8 +42,8 @@ export interface SubagentConfig { /** Storage level - determines where the configuration file is stored */ level: SubagentLevel; - /** Absolute path to the configuration file */ - filePath: string; + /** Absolute path to the configuration file. Optional for session subagents. */ + filePath?: string; /** * Optional model configuration. If not provided, uses defaults. diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 55d767c5..c8039d4c 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -129,6 +129,7 @@ export class Query implements AsyncIterable { sdkMcpServers: sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined, mcpServers: this.options.mcpServers, + agents: this.options.agents, }); } catch (error) { console.error('[Query] Initialization error:', error); diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 399221e0..2f1f9fe9 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -517,7 +517,7 @@ export interface SubagentConfig { tools?: string[]; systemPrompt: string; level: SubagentLevel; - filePath: string; + filePath?: string; modelConfig?: Partial; runConfig?: Partial; color?: string; diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index d3a548af..7573abef 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -31,7 +31,6 @@ export const SubagentConfigSchema = z.object({ description: z.string().min(1, 'Description must be a non-empty string'), tools: z.array(z.string()).optional(), systemPrompt: z.string().min(1, 'System prompt must be a non-empty string'), - filePath: z.string().min(1, 'File path must be a non-empty string'), modelConfig: ModelConfigSchema.partial().optional(), runConfig: RunConfigSchema.partial().optional(), color: z.string().optional(), @@ -71,9 +70,9 @@ export const QueryOptionsSchema = z typeof val === 'object' && 'name' in val && 'description' in val && - 'systemPrompt' in val && - 'filePath' in val, - { message: 'agents must be an array of SubagentConfig objects' }, + 'systemPrompt' in val && { + message: 'agents must be an array of SubagentConfig objects', + }, ), ) .optional(), diff --git a/packages/sdk-typescript/test/e2e/subagents.test.ts b/packages/sdk-typescript/test/e2e/subagents.test.ts new file mode 100644 index 00000000..fcceebb5 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/subagents.test.ts @@ -0,0 +1,656 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for subagent configuration and execution + * Tests subagent delegation and task completion + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + type TextBlock, + type ContentBlock, + type CLIMessage, + type CLISystemMessage, + type SubagentConfig, + type ToolUseBlock, +} from '../../src/types/protocol.js'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; +const E2E_TEST_FILE_DIR = process.env['E2E_TEST_FILE_DIR']!; + +const SHARED_TEST_OPTIONS = { + pathToQwenExecutable: TEST_CLI_PATH, +}; + +/** + * Helper to extract text from ContentBlock array + */ +function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +describe('Subagents (E2E)', () => { + let testWorkDir: string; + + beforeAll(async () => { + // Create a test working directory + testWorkDir = join(E2E_TEST_FILE_DIR, 'subagent-tests'); + await mkdir(testWorkDir, { recursive: true }); + + // Create a simple test file for subagent to work with + const testFilePath = join(testWorkDir, 'test.txt'); + await writeFile(testFilePath, 'Hello from test file\n', 'utf-8'); + }); + + describe('Subagent Configuration', () => { + it('should accept session-level subagent configuration', async () => { + const simpleSubagent: SubagentConfig = { + name: 'simple-greeter', + description: 'A simple subagent that responds to greetings', + systemPrompt: + 'You are a friendly greeter. When given a task, respond with a cheerful greeting.', + level: 'session', + }; + + const q = query({ + prompt: 'Hello, let simple-greeter to say hi back to me.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [simpleSubagent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + const messages: CLIMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate system message includes the subagent + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('simple-greeter'); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }); + + it('should accept multiple subagent configurations', async () => { + const greeterAgent: SubagentConfig = { + name: 'greeter', + description: 'Responds to greetings', + systemPrompt: 'You are a friendly greeter.', + level: 'session', + }; + + const mathAgent: SubagentConfig = { + name: 'math-helper', + description: 'Helps with math problems', + systemPrompt: 'You are a math expert. Solve math problems clearly.', + level: 'session', + }; + + const q = query({ + prompt: 'What is 5 + 5?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [greeterAgent, mathAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate both subagents are registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('greeter'); + expect(systemMessage!.agents).toContain('math-helper'); + expect(systemMessage!.agents!.length).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }); + + it('should handle subagent with custom model config', async () => { + const customModelAgent: SubagentConfig = { + name: 'custom-model-agent', + description: 'Agent with custom model configuration', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + modelConfig: { + temp: 0.7, + top_p: 0.9, + }, + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [customModelAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('custom-model-agent'); + } finally { + await q.close(); + } + }); + + it('should handle subagent with run config', async () => { + const limitedAgent: SubagentConfig = { + name: 'limited-agent', + description: 'Agent with execution limits', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + runConfig: { + max_turns: 5, + max_time_minutes: 1, + }, + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [limitedAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('limited-agent'); + } finally { + await q.close(); + } + }); + + it('should handle subagent with specific tools', async () => { + const toolRestrictedAgent: SubagentConfig = { + name: 'read-only-agent', + description: 'Agent that can only read files', + systemPrompt: + 'You are a file reading assistant. Read files when asked.', + level: 'session', + tools: ['read_file', 'list_directory'], + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [toolRestrictedAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('read-only-agent'); + } finally { + await q.close(); + } + }); + }); + + describe('Subagent Execution', () => { + it('should delegate task to subagent when appropriate', async () => { + const fileReaderAgent: SubagentConfig = { + name: 'file-reader', + description: 'Reads and reports file contents', + systemPrompt: `You are a file reading assistant. When given a task to read a file, use the read_file tool to read it and report its contents back. Be concise in your response.`, + level: 'session', + tools: ['read_file', 'list_directory'], + }; + + const testFile = join(testWorkDir, 'test.txt'); + const q = query({ + prompt: `Use the file-reader subagent to read the file at ${testFile} and tell me what it contains.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [fileReaderAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: CLIMessage[] = []; + let foundTaskTool = false; + let taskToolUseId: string | null = null; + let foundSubagentToolCall = false; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + // Check for task tool use in content blocks (main agent calling subagent) + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use' && block.name === 'task', + ); + if (toolUseBlock) { + foundTaskTool = true; + taskToolUseId = toolUseBlock.id; + } + + // Check if this message is from a subagent (has parent_tool_use_id) + if (message.parent_tool_use_id !== null) { + // This is a subagent message + const subagentToolUse = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + if (subagentToolUse) { + foundSubagentToolCall = true; + // Verify parent_tool_use_id matches the task tool use id + expect(message.parent_tool_use_id).toBe(taskToolUseId); + } + } + + assistantText += extractText(message.message.content); + } + } + + // Validate task tool was used (subagent delegation) + expect(foundTaskTool).toBe(true); + expect(taskToolUseId).not.toBeNull(); + + // Validate subagent actually made tool calls with proper parent_tool_use_id + expect(foundSubagentToolCall).toBe(true); + + // Validate we got a response + expect(assistantText.length).toBeGreaterThan(0); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, 60000); // Increase timeout for subagent execution + + it('should complete simple task with subagent', async () => { + const simpleTaskAgent: SubagentConfig = { + name: 'simple-calculator', + description: 'Performs simple arithmetic calculations', + systemPrompt: + 'You are a calculator. When given a math problem, solve it and provide just the answer.', + level: 'session', + }; + + const q = query({ + prompt: 'Use the simple-calculator subagent to calculate 15 + 27.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [simpleTaskAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: CLIMessage[] = []; + let foundTaskTool = false; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + // Check for task tool use (main agent delegating to subagent) + const toolUseBlock = message.message.content.find( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use' && block.name === 'task', + ); + if (toolUseBlock) { + foundTaskTool = true; + } + + assistantText += extractText(message.message.content); + } + } + + // Validate task tool was used (subagent was called) + expect(foundTaskTool).toBe(true); + + // Validate we got a response + expect(assistantText.length).toBeGreaterThan(0); + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, 60000); + + it('should verify subagent execution with comprehensive parent_tool_use_id checks', async () => { + const comprehensiveAgent: SubagentConfig = { + name: 'comprehensive-agent', + description: 'Agent for comprehensive testing', + systemPrompt: + 'You are a helpful assistant. When asked to list files, use the list_directory tool.', + level: 'session', + tools: ['list_directory', 'read_file'], + }; + + const q = query({ + prompt: `Use the comprehensive-agent subagent to list the files in ${testWorkDir}.`, + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [comprehensiveAgent], + debug: false, + permissionMode: 'yolo', + }, + }); + + const messages: CLIMessage[] = []; + let taskToolUseId: string | null = null; + const subagentToolCalls: ToolUseBlock[] = []; + const mainAgentToolCalls: ToolUseBlock[] = []; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIAssistantMessage(message)) { + // Collect all tool use blocks + const toolUseBlocks = message.message.content.filter( + (block: ContentBlock): block is ToolUseBlock => + block.type === 'tool_use', + ); + + for (const toolUse of toolUseBlocks) { + if (toolUse.name === 'task') { + // This is the main agent calling the subagent + taskToolUseId = toolUse.id; + mainAgentToolCalls.push(toolUse); + } + + // If this message has parent_tool_use_id, it's from a subagent + if (message.parent_tool_use_id !== null) { + subagentToolCalls.push(toolUse); + } + } + } + } + + // Criterion 1: When a subagent is called, there must be a 'task' tool being called + expect(taskToolUseId).not.toBeNull(); + expect(mainAgentToolCalls.length).toBeGreaterThan(0); + expect(mainAgentToolCalls.some((tc) => tc.name === 'task')).toBe(true); + + // Criterion 2: A tool call from a subagent is identified by a non-null parent_tool_use_id + // All subagent tool calls should have parent_tool_use_id set to the task tool's id + expect(subagentToolCalls.length).toBeGreaterThan(0); + + // Verify all subagent messages have the correct parent_tool_use_id + const subagentMessages = messages.filter( + (msg): msg is CLIMessage & { parent_tool_use_id: string } => + isCLIAssistantMessage(msg) && msg.parent_tool_use_id !== null, + ); + + expect(subagentMessages.length).toBeGreaterThan(0); + for (const subagentMsg of subagentMessages) { + expect(subagentMsg.parent_tool_use_id).toBe(taskToolUseId); + } + + // Verify no main agent tool calls (except task) have parent_tool_use_id + const mainAgentMessages = messages.filter( + (msg): msg is CLIMessage => + isCLIAssistantMessage(msg) && msg.parent_tool_use_id === null, + ); + + for (const mainMsg of mainAgentMessages) { + if (isCLIAssistantMessage(mainMsg)) { + // Main agent messages should not have parent_tool_use_id + expect(mainMsg.parent_tool_use_id).toBeNull(); + } + } + + // Validate successful completion + const lastMessage = messages[messages.length - 1]; + expect(isCLIResultMessage(lastMessage)).toBe(true); + if (isCLIResultMessage(lastMessage)) { + expect(lastMessage.subtype).toBe('success'); + } + } finally { + await q.close(); + } + }, 60000); + }); + + describe('Subagent Error Handling', () => { + it('should handle empty subagent array', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Should still work with empty agents array + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + } finally { + await q.close(); + } + }); + + it('should handle subagent with minimal configuration', async () => { + const minimalAgent: SubagentConfig = { + name: 'minimal-agent', + description: 'Minimal configuration agent', + systemPrompt: 'You are a helpful assistant.', + level: 'session', + }; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [minimalAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate minimal agent is registered + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('minimal-agent'); + } finally { + await q.close(); + } + }); + }); + + describe('Subagent Integration', () => { + it('should work with other SDK options', async () => { + const testAgent: SubagentConfig = { + name: 'test-agent', + description: 'Test agent for integration', + systemPrompt: 'You are a test assistant.', + level: 'session', + }; + + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [testAgent], + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + permissionMode: 'default', + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate subagent works with debug mode + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.agents).toBeDefined(); + expect(systemMessage!.agents).toContain('test-agent'); + expect(stderrMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should maintain session consistency with subagents', async () => { + const sessionAgent: SubagentConfig = { + name: 'session-agent', + description: 'Agent for session testing', + systemPrompt: 'You are a session test assistant.', + level: 'session', + }; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testWorkDir, + agents: [sessionAgent], + debug: false, + }, + }); + + let systemMessage: CLISystemMessage | null = null; + + try { + for await (const message of q) { + if (isCLISystemMessage(message) && message.subtype === 'init') { + systemMessage = message; + } + } + + // Validate session consistency + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.session_id).toBeDefined(); + expect(systemMessage!.uuid).toBeDefined(); + expect(systemMessage!.session_id).toBe(systemMessage!.uuid); + expect(systemMessage!.agents).toContain('session-agent'); + } finally { + await q.close(); + } + }); + }); +}); From ad9ba914e1f7b02fb87266b855346cff80f2d254 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 25 Nov 2025 10:27:06 +0800 Subject: [PATCH 05/38] refactor: clean up exports in sdk-typescript index file --- packages/sdk-typescript/src/index.ts | 30 ---------------------------- 1 file changed, 30 deletions(-) diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index c732c6ff..4c549fcb 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -28,38 +28,8 @@ export { isCLIPartialAssistantMessage, } from './types/protocol.js'; -export { AbortError, isAbortError } from './types/errors.js'; - -export { ControlRequestType } from './types/protocol.js'; - -export { ProcessTransport } from './transport/ProcessTransport.js'; -export type { Transport } from './transport/Transport.js'; - -export { Stream } from './utils/Stream.js'; -export { - serializeJsonLine, - parseJsonLineSafe, - isValidMessage, - parseJsonLinesStream, -} from './utils/jsonLines.js'; -export { - findCliPath, - resolveCliPath, - prepareSpawnInfo, -} from './utils/cliPath.js'; -export type { SpawnInfo } from './utils/cliPath.js'; - -export { createSdkMcpServer } from './mcp/createSdkMcpServer.js'; -export { - tool, - createTool, - validateToolName, - validateInputSchema, -} from './mcp/tool.js'; - export type { JSONSchema, - ToolDefinition, PermissionMode, CanUseTool, PermissionResult, From ac6aecb6228bfbe8a23f7a4e0be406e97876b0a8 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 25 Nov 2025 11:45:34 +0800 Subject: [PATCH 06/38] refactor: update test structure and clean up unused code in cli and sdk --- .../controllers/permissionController.ts | 2 +- .../cli/src/nonInteractive/session.test.ts | 2 +- packages/cli/src/nonInteractive/types.ts | 6 - packages/core/src/config/config.ts | 7 - packages/sdk-typescript/src/index.ts | 2 +- .../sdk-typescript/src/query/createQuery.ts | 21 - .../src/transport/ProcessTransport.ts | 44 - packages/sdk-typescript/src/types/types.ts | 2 - packages/sdk-typescript/src/utils/Stream.ts | 12 - packages/sdk-typescript/src/utils/cliPath.ts | 21 - .../sdk-typescript/src/utils/jsonLines.ts | 4 - .../test/unit/ProcessTransport.test.ts | 1394 ++++++++++++++-- .../sdk-typescript/test/unit/Query.test.ts | 1481 +++++++++++++++-- .../sdk-typescript/test/unit/cliPath.test.ts | 23 - 14 files changed, 2620 insertions(+), 401 deletions(-) diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index 08c6d41f..37a9082f 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -442,7 +442,7 @@ export class PermissionController extends BaseController { // On error, use default cancel message // Only pass payload for exec and mcp types that support it const confirmationType = toolCall.confirmationDetails.type; - if (confirmationType === 'exec' || confirmationType === 'mcp') { + if (['edit', 'exec', 'mcp'].includes(confirmationType)) { const execOrMcpDetails = toolCall.confirmationDetails as | ToolExecuteConfirmationDetails | ToolMcpConfirmationDetails; diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 15f15954..61643fb3 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -134,7 +134,7 @@ function createControlCancel(requestId: string): ControlCancelRequest { }; } -describe('runNonInteractiveStreamJson (refactored)', () => { +describe('runNonInteractiveStreamJson', () => { let config: Config; let mockInputReader: { read: () => AsyncGenerator< diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 2eec24c1..fb8dcf76 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -299,12 +299,6 @@ export interface CLIControlPermissionRequest { blocked_path: string | null; } -export enum AuthProviderType { - DYNAMIC_DISCOVERY = 'dynamic_discovery', - GOOGLE_CREDENTIALS = 'google_credentials', - SERVICE_ACCOUNT_IMPERSONATION = 'service_account_impersonation', -} - export interface CLIControlInitializeRequest { subtype: 'initialize'; hooks?: HookRegistration[] | null; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 65c39d8e..be84655f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -855,13 +855,6 @@ export class Config { return this.mcpServers; } - setMcpServers(servers: Record): void { - if (this.initialized) { - throw new Error('Cannot modify mcpServers after initialization'); - } - this.mcpServers = servers; - } - addMcpServers(servers: Record): void { if (this.initialized) { throw new Error('Cannot modify mcpServers after initialization'); diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 4c549fcb..23ba3f93 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -1,5 +1,5 @@ export { query } from './query/createQuery.js'; - +export { AbortError, isAbortError } from './types/errors.js'; export { Query } from './query/Query.js'; export type { ExternalMcpServerConfig } from './types/queryOptionsSchema.js'; diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 4b87478e..e3907635 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -21,28 +21,20 @@ export function query({ prompt: string | AsyncIterable; options?: QueryOptions; }): Query { - // Validate options and obtain normalized executable metadata const parsedExecutable = validateOptions(options); - // Determine if this is a single-turn or multi-turn query - // Single-turn: string prompt (simple Q&A) - // Multi-turn: AsyncIterable prompt (streaming conversation) const isSingleTurn = typeof prompt === 'string'; - // Resolve CLI specification while preserving explicit runtime directives const pathToQwenExecutable = options.pathToQwenExecutable ?? parsedExecutable.executablePath; - // Use provided abortController or create a new one const abortController = options.abortController ?? new AbortController(); - // Create transport with abortController const transport = new ProcessTransport({ pathToQwenExecutable, cwd: options.cwd, model: options.model, permissionMode: options.permissionMode, - mcpServers: options.mcpServers, env: options.env, abortController, debug: options.debug, @@ -53,18 +45,14 @@ export function query({ authType: options.authType, }); - // Build query options with abortController const queryOptions: QueryOptions = { ...options, abortController, }; - // Create Query const queryInstance = new Query(transport, queryOptions, isSingleTurn); - // Handle prompt based on type if (isSingleTurn) { - // For single-turn queries, send the prompt directly via transport const stringPrompt = prompt as string; const message: CLIUserMessage = { type: 'user', @@ -95,16 +83,9 @@ export function query({ return queryInstance; } -/** - * Backward compatibility alias - * @deprecated Use query() instead - */ -export const createQuery = query; - function validateOptions( options: QueryOptions, ): ReturnType { - // Validate options using Zod schema const validationResult = QueryOptionsSchema.safeParse(options); if (!validationResult.success) { const errors = validationResult.error.errors @@ -113,7 +94,6 @@ function validateOptions( throw new Error(`Invalid QueryOptions: ${errors}`); } - // Validate executable path early to provide clear error messages let parsedExecutable: ReturnType; try { parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable); @@ -122,7 +102,6 @@ function validateOptions( throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`); } - // Validate no MCP server name conflicts (cross-field validation not easily expressible in Zod) if (options.mcpServers && options.sdkMcpServers) { const externalNames = Object.keys(options.mcpServers); const sdkNames = Object.keys(options.sdkMcpServers); diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index 1c717f8c..62a6b2d0 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -7,11 +7,6 @@ import { parseJsonLinesStream } from '../utils/jsonLines.js'; import { prepareSpawnInfo } from '../utils/cliPath.js'; import { AbortError } from '../types/errors.js'; -type ExitListener = { - callback: (error?: Error) => void; - handler: (code: number | null, signal: NodeJS.Signals | null) => void; -}; - export class ProcessTransport implements Transport { private childProcess: ChildProcess | null = null; private childStdin: Writable | null = null; @@ -21,7 +16,6 @@ export class ProcessTransport implements Transport { private _exitError: Error | null = null; private closed = false; private abortController: AbortController; - private exitListeners: ExitListener[] = []; private processExitHandler: (() => void) | null = null; private abortHandler: (() => void) | null = null; @@ -115,15 +109,6 @@ export class ProcessTransport implements Transport { this.logForDebugging(error.message); } } - - const error = this._exitError; - for (const listener of this.exitListeners) { - try { - listener.callback(error || undefined); - } catch (err) { - this.logForDebugging(`Exit listener error: ${err}`); - } - } }); } @@ -192,11 +177,6 @@ export class ProcessTransport implements Transport { this.abortHandler = null; } - for (const { handler } of this.exitListeners) { - this.childProcess?.off('close', handler); - } - this.exitListeners = []; - if (this.childProcess && !this.childProcess.killed) { this.childProcess.kill('SIGTERM'); setTimeout(() => { @@ -343,30 +323,6 @@ export class ProcessTransport implements Transport { return this._exitError; } - onExit(callback: (error?: Error) => void): () => void { - if (!this.childProcess) { - return () => {}; - } - - const handler = (code: number | null, signal: NodeJS.Signals | null) => { - const error = this.getProcessExitError(code, signal); - callback(error); - }; - - this.childProcess.on('close', handler); - this.exitListeners.push({ callback, handler }); - - return () => { - if (this.childProcess) { - this.childProcess.off('close', handler); - } - const index = this.exitListeners.findIndex((l) => l.handler === handler); - if (index !== -1) { - this.exitListeners.splice(index, 1); - } - }; - } - endInput(): void { if (this.childStdin) { this.childStdin.end(); diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index d2b9a400..856099fc 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -1,5 +1,4 @@ import type { PermissionMode, PermissionSuggestion } from './protocol.js'; -import type { ExternalMcpServerConfig } from './queryOptionsSchema.js'; export type { PermissionMode }; @@ -23,7 +22,6 @@ export type TransportOptions = { cwd?: string; model?: string; permissionMode?: PermissionMode; - mcpServers?: Record; env?: Record; abortController?: AbortController; debug?: boolean; diff --git a/packages/sdk-typescript/src/utils/Stream.ts b/packages/sdk-typescript/src/utils/Stream.ts index 8a58c0be..70caf82e 100644 --- a/packages/sdk-typescript/src/utils/Stream.ts +++ b/packages/sdk-typescript/src/utils/Stream.ts @@ -1,7 +1,3 @@ -/** - * Async iterable queue for streaming messages between producer and consumer. - */ - export class Stream implements AsyncIterable { private returned: (() => void) | undefined; private queue: T[] = []; @@ -24,23 +20,18 @@ export class Stream implements AsyncIterable { } async next(): Promise> { - // Check queue first - if there are queued items, return immediately if (this.queue.length > 0) { return Promise.resolve({ done: false, value: this.queue.shift()!, }); } - // Check if stream is done if (this.isDone) { return Promise.resolve({ done: true, value: undefined }); } - // Check for errors that occurred before next() was called - // This ensures errors set via error() before iteration starts are properly rejected if (this.hasError) { return Promise.reject(this.hasError); } - // No queued items, not done, no error - set up promise for next value/error return new Promise>((resolve, reject) => { this.readResolve = resolve; this.readReject = reject; @@ -70,15 +61,12 @@ export class Stream implements AsyncIterable { error(error: Error): void { this.hasError = error; - // If readReject exists (next() has been called), reject immediately if (this.readReject) { const reject = this.readReject; this.readResolve = undefined; this.readReject = undefined; reject(error); } - // Otherwise, error is stored in hasError and will be rejected when next() is called - // This handles the case where error() is called before the first next() call } return(): Promise> { diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts index b6101ab3..2d919413 100644 --- a/packages/sdk-typescript/src/utils/cliPath.ts +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -154,7 +154,6 @@ export function parseExecutableSpec(executableSpec?: string): { executablePath: string; isExplicitRuntime: boolean; } { - // Handle empty string case first (before checking for undefined/null) if ( executableSpec === '' || (executableSpec && executableSpec.trim() === '') @@ -163,7 +162,6 @@ export function parseExecutableSpec(executableSpec?: string): { } if (!executableSpec) { - // Auto-detect native CLI return { executablePath: findNativeCliPath(), isExplicitRuntime: false, @@ -178,7 +176,6 @@ export function parseExecutableSpec(executableSpec?: string): { throw new Error(`Invalid runtime specification: '${executableSpec}'`); } - // Validate runtime is supported const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; if (!supportedRuntimes.includes(runtime)) { throw new Error( @@ -186,7 +183,6 @@ export function parseExecutableSpec(executableSpec?: string): { ); } - // Validate runtime availability if (!validateRuntimeAvailability(runtime)) { throw new Error( `Runtime '${runtime}' is not available on this system. Please install it first.`, @@ -195,7 +191,6 @@ export function parseExecutableSpec(executableSpec?: string): { const resolvedPath = path.resolve(filePath); - // Validate file exists if (!fs.existsSync(resolvedPath)) { throw new Error( `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + @@ -203,7 +198,6 @@ export function parseExecutableSpec(executableSpec?: string): { ); } - // Validate file extension matches runtime if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { const ext = path.extname(resolvedPath); throw new Error( @@ -285,14 +279,6 @@ function getExpectedExtensions(runtime: string): string[] { } } -/** - * @deprecated Use parseExecutableSpec and prepareSpawnInfo instead - */ -export function resolveCliPath(explicitPath?: string): string { - const parsed = parseExecutableSpec(explicitPath); - return parsed.executablePath; -} - function detectRuntimeFromExtension(filePath: string): string | undefined { const ext = path.extname(filePath).toLowerCase(); @@ -356,10 +342,3 @@ export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { originalInput: executableSpec || '', }; } - -/** - * @deprecated Use prepareSpawnInfo() instead - */ -export function findCliPath(): string { - return findNativeCliPath(); -} diff --git a/packages/sdk-typescript/src/utils/jsonLines.ts b/packages/sdk-typescript/src/utils/jsonLines.ts index e534bf70..6d1bd090 100644 --- a/packages/sdk-typescript/src/utils/jsonLines.ts +++ b/packages/sdk-typescript/src/utils/jsonLines.ts @@ -38,20 +38,16 @@ export async function* parseJsonLinesStream( context = 'JsonLines', ): AsyncGenerator { for await (const line of lines) { - // Skip empty lines if (line.trim().length === 0) { continue; } - // Parse with error handling const message = parseJsonLineSafe(line, context); - // Skip malformed messages if (message === null) { continue; } - // Validate message structure if (!isValidMessage(message)) { console.warn( `[${context}] Invalid message structure (missing 'type' field), skipping:`, diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts index 5e1a9d15..0854a02d 100644 --- a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -3,205 +3,1379 @@ * Tests subprocess lifecycle management and IPC */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { ProcessTransport } from '../../src/transport/ProcessTransport.js'; +import { AbortError } from '../../src/types/errors.js'; +import type { TransportOptions } from '../../src/types/types.js'; +import { Readable, Writable } from 'node:stream'; +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import * as childProcess from 'node:child_process'; +import * as cliPath from '../../src/utils/cliPath.js'; +import * as jsonLines from '../../src/utils/jsonLines.js'; -// Note: This is a placeholder test file -// ProcessTransport will be implemented in Phase 3 Implementation (T021) -// These tests are written first following TDD approach +// Mock modules +vi.mock('node:child_process'); +vi.mock('../../src/utils/cliPath.js'); +vi.mock('../../src/utils/jsonLines.js'); + +const mockSpawn = vi.mocked(childProcess.spawn); +const mockPrepareSpawnInfo = vi.mocked(cliPath.prepareSpawnInfo); +const mockParseJsonLinesStream = vi.mocked(jsonLines.parseJsonLinesStream); + +// Helper function to create a mock child process with optional overrides +function createMockChildProcess( + overrides: Partial = {}, +): ChildProcess & EventEmitter { + const mockStdin = new Writable({ + write: vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }), + }); + const mockWriteFn = vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }); + mockStdin.write = mockWriteFn as unknown as typeof mockStdin.write; + + const mockStdout = new Readable({ read: vi.fn() }); + const mockStderr = new Readable({ read: vi.fn() }); + + const baseProcess = Object.assign(new EventEmitter(), { + stdin: mockStdin, + stdout: mockStdout, + stderr: mockStderr, + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + connected: false, + stdio: [mockStdin, mockStdout, mockStderr, null, null], + spawnargs: [], + spawnfile: 'qwen', + channel: null, + ...overrides, + }) as unknown as ChildProcess & EventEmitter; + + return baseProcess; +} describe('ProcessTransport', () => { + let mockChildProcess: ChildProcess & EventEmitter; + let mockStdin: Writable; + let mockStdout: Readable; + let mockStderr: Readable; + + beforeEach(() => { + vi.clearAllMocks(); + + const mockWriteFn = vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }); + + mockStdin = new Writable({ + write: mockWriteFn, + }); + // Override write with a spy so we can track calls + mockStdin.write = mockWriteFn as unknown as typeof mockStdin.write; + + mockStdout = new Readable({ read: vi.fn() }); + mockStderr = new Readable({ read: vi.fn() }); + + mockChildProcess = Object.assign(new EventEmitter(), { + stdin: mockStdin, + stdout: mockStdout, + stderr: mockStderr, + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + connected: false, + stdio: [mockStdin, mockStdout, mockStderr, null, null], + spawnargs: [], + spawnfile: 'qwen', + channel: null, + }) as unknown as ChildProcess & EventEmitter; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('Construction and Initialization', () => { it('should create transport with required options', () => { - // Test will be implemented with actual ProcessTransport class - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport).toBeDefined(); + expect(transport.isReady).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + ]), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'ignore'], + }), + ); }); - it('should validate pathToQwenExecutable exists', () => { - // Should throw if pathToQwenExecutable does not exist - expect(true).toBe(true); // Placeholder + it('should build CLI arguments correctly with all options', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + model: 'qwen-max', + permissionMode: 'auto-edit', + maxSessionTurns: 10, + coreTools: ['read_file', 'write_file'], + excludeTools: ['web_search'], + authType: 'api-key', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--model', + 'qwen-max', + '--approval-mode', + 'auto-edit', + '--max-session-turns', + '10', + '--core-tools', + 'read_file,write_file', + '--exclude-tools', + 'web_search', + '--auth-type', + 'api-key', + ]), + expect.any(Object), + ); }); - it('should build CLI arguments correctly', () => { - // Should include --input-format stream-json --output-format stream-json - expect(true).toBe(true); // Placeholder + it('should throw if aborted before initialization', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const abortController = new AbortController(); + abortController.abort(); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + expect(() => new ProcessTransport(options)).toThrow(AbortError); + expect(() => new ProcessTransport(options)).toThrow( + 'Transport start aborted', + ); + }); + + it('should use provided AbortController', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + signal: abortController.signal, + }), + ); + }); + + it('should create default AbortController if not provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); }); }); describe('Lifecycle Management', () => { - it('should spawn subprocess during construction', async () => { - // Should call child_process.spawn in constructor - expect(true).toBe(true); // Placeholder + it('should spawn subprocess during construction', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledTimes(1); }); - it('should set isReady to true after successful initialization', async () => { - // isReady should be true after construction completes - expect(true).toBe(true); // Placeholder + it('should set isReady to true after successful initialization', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.isReady).toBe(true); }); - it('should throw if subprocess fails to spawn', async () => { - // Should throw Error if ENOENT or spawn fails - expect(true).toBe(true); // Placeholder + it('should set isReady to false on process error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('error', new Error('Spawn failed')); + + expect(transport.isReady).toBe(false); + expect(transport.exitError).toBeDefined(); }); it('should close subprocess gracefully with SIGTERM', async () => { - // Should send SIGTERM first - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGTERM'); }); it('should force kill with SIGKILL after timeout', async () => { - // Should send SIGKILL after 5s if process doesn\'t exit - expect(true).toBe(true); // Placeholder + vi.useFakeTimers(); + + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + vi.advanceTimersByTime(5000); + + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGKILL'); + + vi.useRealTimers(); }); it('should be idempotent when calling close() multiple times', async () => { - // Multiple close() calls should not error - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + await transport.close(); + await transport.close(); + + expect(mockChildProcess.kill).toHaveBeenCalledTimes(3); }); it('should wait for process exit in waitForExit()', async () => { - // Should resolve when process exits - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', 0, null); + + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it('should reject waitForExit() on non-zero exit code', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', 1, null); + + await expect(waitPromise).rejects.toThrow( + 'CLI process exited with code 1', + ); + }); + + it('should reject waitForExit() on signal termination', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', null, 'SIGTERM'); + + await expect(waitPromise).rejects.toThrow( + 'CLI process terminated by signal SIGTERM', + ); + }); + + it('should reject waitForExit() with AbortError when aborted', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + abortController.abort(); + mockChildProcess.emit('close', 0, null); + + await expect(waitPromise).rejects.toThrow(AbortError); }); }); describe('Message Reading', () => { it('should read JSON Lines from stdout', async () => { - // Should use readline to read lines and parse JSON - expect(true).toBe(true); // Placeholder - }); + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); - it('should yield parsed messages via readMessages()', async () => { - // Should yield messages as async generator - expect(true).toBe(true); // Placeholder - }); + const mockMessages = [ + { type: 'message', content: 'test1' }, + { type: 'message', content: 'test2' }, + ]; - it('should skip malformed JSON lines with warning', async () => { - // Should log warning and continue on parse error - expect(true).toBe(true); // Placeholder - }); + mockParseJsonLinesStream.mockImplementation(async function* () { + for (const msg of mockMessages) { + yield msg; + } + }); - it('should complete generator when process exits', async () => { - // readMessages() should complete when stdout closes - expect(true).toBe(true); // Placeholder - }); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; - it('should set exitError on unexpected process crash', async () => { - // exitError should be set if process crashes - expect(true).toBe(true); // Placeholder + const transport = new ProcessTransport(options); + + const messages: unknown[] = []; + const readPromise = (async () => { + for await (const message of transport.readMessages()) { + messages.push(message); + } + })(); + + // Give time for the async generator to start and yield messages + await new Promise((resolve) => setTimeout(resolve, 10)); + + mockChildProcess.emit('close', 0, null); + + await readPromise; + + expect(messages).toEqual(mockMessages); + }, 5000); // Set a reasonable timeout + + it('should throw if reading from transport without stdout', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdout = createMockChildProcess({ stdout: null }); + mockSpawn.mockReturnValue(processWithoutStdout); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const generator = transport.readMessages(); + + await expect(generator.next()).rejects.toThrow( + 'Cannot read messages: process not started', + ); }); }); describe('Message Writing', () => { - it('should write JSON Lines to stdin', () => { - // Should write JSON + newline to stdin - expect(true).toBe(true); // Placeholder + it('should write message to stdin', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const message = '{"type":"test","data":"hello"}\n'; + transport.write(message); + + expect(mockStdin.write).toHaveBeenCalledWith(message); }); it('should throw if writing before transport is ready', () => { - // write() should throw if isReady is false - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('error', new Error('Process error')); + + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); }); - it('should throw if writing to closed transport', () => { - // write() should throw if transport is closed - expect(true).toBe(true); // Placeholder + it('should throw if writing to closed transport', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + // After close(), isReady is false, so we get "Transport not ready" error first + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); + }); + + it('should throw if writing when aborted', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + abortController.abort(); + + expect(() => transport.write('test')).toThrow(AbortError); + expect(() => transport.write('test')).toThrow( + 'Cannot write: operation aborted', + ); + }); + + it('should throw if writing to ended stream', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockStdin.end(); + + expect(() => transport.write('test')).toThrow( + 'Cannot write to ended stream', + ); + }); + + it('should throw if writing to terminated process', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const terminatedProcess = createMockChildProcess({ exitCode: 1 }); + mockSpawn.mockReturnValue(terminatedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(() => transport.write('test')).toThrow( + 'Cannot write to terminated process', + ); + }); + + it('should throw if process has exit error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 1, null); + + // After process closes with error, isReady is false, so we get "Transport not ready" error first + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); }); }); describe('Error Handling', () => { - it('should handle process spawn errors', async () => { - // Should throw descriptive error on spawn failure - expect(true).toBe(true); // Placeholder + it('should set exitError on process error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const error = new Error('Process error'); + mockChildProcess.emit('error', error); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toContain('CLI process error'); }); - it('should handle process exit with non-zero code', async () => { - // Should set exitError when process exits with error - expect(true).toBe(true); // Placeholder + it('should set exitError on process close with non-zero code', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 1, null); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toBe( + 'CLI process exited with code 1', + ); }); - it('should handle write errors to closed stdin', () => { - // Should throw if stdin is closed - expect(true).toBe(true); // Placeholder + it('should set exitError on process close with signal', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', null, 'SIGKILL'); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toBe( + 'CLI process terminated by signal SIGKILL', + ); + }); + + it('should set AbortError when process aborted', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + abortController.abort(); + mockChildProcess.emit('error', new Error('Aborted')); + + expect(transport.exitError).toBeInstanceOf(AbortError); + expect(transport.exitError?.message).toBe('CLI process aborted by user'); + }); + + it('should not set exitError on clean exit', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 0, null); + + expect(transport.exitError).toBeNull(); }); }); describe('Resource Cleanup', () => { it('should register cleanup on parent process exit', () => { - // Should register process.on(\'exit\') handler - expect(true).toBe(true); // Placeholder - }); + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); - it('should kill subprocess on parent exit', () => { - // Cleanup should kill child process - expect(true).toBe(true); // Placeholder + const processOnSpy = vi.spyOn(process, 'on'); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + + processOnSpy.mockRestore(); }); it('should remove event listeners on close', async () => { - // Should clean up all event listeners - expect(true).toBe(true); // Placeholder - }); - }); + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); - describe('CLI Arguments', () => { - it('should include --input-format stream-json', () => { - // Args should always include input format flag - expect(true).toBe(true); // Placeholder + const processOffSpy = vi.spyOn(process, 'off'); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(processOffSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + + processOffSpy.mockRestore(); }); - it('should include --output-format stream-json', () => { - // Args should always include output format flag - expect(true).toBe(true); // Placeholder + it('should register abort listener', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const addEventListenerSpy = vi.spyOn( + abortController.signal, + 'addEventListener', + ); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + new ProcessTransport(options); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + + addEventListenerSpy.mockRestore(); }); - it('should include --model if provided', () => { - // Args should include model flag if specified - expect(true).toBe(true); // Placeholder + it('should remove abort listener on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const removeEventListenerSpy = vi.spyOn( + abortController.signal, + 'removeEventListener', + ); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + + removeEventListenerSpy.mockRestore(); }); - it('should include --permission-mode if provided', () => { - // Args should include permission mode flag if specified - expect(true).toBe(true); // Placeholder - }); + it('should end stdin on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); - it('should include --mcp-server for external MCP servers', () => { - // Args should include MCP server configs - expect(true).toBe(true); // Placeholder + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const endSpy = vi.spyOn(mockStdin, 'end'); + + await transport.close(); + + expect(endSpy).toHaveBeenCalled(); }); }); describe('Working Directory', () => { - it('should spawn process in specified cwd', async () => { - // Should use cwd option for child_process.spawn - expect(true).toBe(true); // Placeholder + it('should spawn process in specified cwd', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + cwd: '/custom/path', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + cwd: '/custom/path', + }), + ); }); - it('should default to process.cwd() if not specified', async () => { - // Should use current working directory by default - expect(true).toBe(true); // Placeholder + it('should default to process.cwd() if not specified', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + cwd: process.cwd(), + }), + ); }); }); describe('Environment Variables', () => { - it('should pass environment variables to subprocess', async () => { - // Should merge env with process.env - expect(true).toBe(true); // Placeholder + it('should pass environment variables to subprocess', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + env: { + CUSTOM_VAR: 'custom_value', + }, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + CUSTOM_VAR: 'custom_value', + }), + }), + ); }); - it('should inherit parent env by default', async () => { - // Should use process.env if no env option - expect(true).toBe(true); // Placeholder + it('should inherit parent env by default', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining(process.env), + }), + ); + }); + + it('should merge custom env with parent env', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + env: { + CUSTOM_VAR: 'custom_value', + }, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ...process.env, + CUSTOM_VAR: 'custom_value', + }), + }), + ); }); }); - describe('Debug Mode', () => { - it('should inherit stderr when debug is true', async () => { - // Should set stderr: \'inherit\' if debug flag set - expect(true).toBe(true); // Placeholder + describe('Debug and Stderr Handling', () => { + it('should pipe stderr when debug is true', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + debug: true, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); }); - it('should ignore stderr when debug is false', async () => { - // Should set stderr: \'ignore\' if debug flag not set - expect(true).toBe(true); // Placeholder + it('should pipe stderr when stderr callback is provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const stderrCallback = vi.fn(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + stderr: stderrCallback, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); + }); + + it('should ignore stderr when debug is false and no callback', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + debug: false, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'ignore'], + }), + ); + }); + + it('should call stderr callback when data is received', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const stderrCallback = vi.fn(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + stderr: stderrCallback, + }; + + new ProcessTransport(options); + + mockStderr.emit('data', Buffer.from('error message')); + + expect(stderrCallback).toHaveBeenCalledWith('error message'); + }); + }); + + describe('Stream Access', () => { + it('should provide access to stdin via getInputStream()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getInputStream()).toBe(mockStdin); + }); + + it('should provide access to stdout via getOutputStream()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getOutputStream()).toBe(mockStdout); + }); + + it('should allow ending input via endInput()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const endSpy = vi.spyOn(mockStdin, 'end'); + + transport.endInput(); + + expect(endSpy).toHaveBeenCalled(); + }); + }); + + describe('Edge Cases', () => { + it('should handle process that exits immediately', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const immediateExitProcess = createMockChildProcess({ exitCode: 0 }); + mockSpawn.mockReturnValue(immediateExitProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.isReady).toBe(true); + }); + + it('should handle waitForExit() when process already exited', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const exitedProcess = createMockChildProcess({ exitCode: 0 }); + mockSpawn.mockReturnValue(exitedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await expect(transport.waitForExit()).resolves.toBeUndefined(); + }); + + it('should handle close() when process is already killed', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const killedProcess = createMockChildProcess({ killed: true }); + mockSpawn.mockReturnValue(killedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await expect(transport.close()).resolves.toBeUndefined(); + }); + + it('should handle endInput() when stdin is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdin = createMockChildProcess({ stdin: null }); + mockSpawn.mockReturnValue(processWithoutStdin); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(() => transport.endInput()).not.toThrow(); + }); + + it('should return undefined for getInputStream() when stdin is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdin = createMockChildProcess({ stdin: null }); + mockSpawn.mockReturnValue(processWithoutStdin); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getInputStream()).toBeUndefined(); + }); + + it('should return undefined for getOutputStream() when stdout is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdout = createMockChildProcess({ stdout: null }); + mockSpawn.mockReturnValue(processWithoutStdout); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getOutputStream()).toBeUndefined(); }); }); }); diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts index 5ceeee4b..9b8e34c2 100644 --- a/packages/sdk-typescript/test/unit/Query.test.ts +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -3,282 +3,1467 @@ * Tests message routing, lifecycle, and orchestration */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { Query } from '../../src/query/Query.js'; +import type { Transport } from '../../src/transport/Transport.js'; +import type { + CLIMessage, + CLIUserMessage, + CLIAssistantMessage, + CLISystemMessage, + CLIResultMessage, + CLIPartialAssistantMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, +} from '../../src/types/protocol.js'; +import { ControlRequestType } from '../../src/types/protocol.js'; +import { AbortError } from '../../src/types/errors.js'; +import { Stream } from '../../src/utils/Stream.js'; -// Note: This is a placeholder test file -// Query will be implemented in Phase 3 Implementation (T022) -// These tests are written first following TDD approach +// Mock Transport implementation +class MockTransport implements Transport { + private messageStream = new Stream(); + public writtenMessages: string[] = []; + public closed = false; + public endInputCalled = false; + public isReady = true; + public exitError: Error | null = null; + + write(data: string): void { + this.writtenMessages.push(data); + } + + async *readMessages(): AsyncGenerator { + for await (const message of this.messageStream) { + yield message; + } + } + + async close(): Promise { + this.closed = true; + this.messageStream.done(); + } + + async waitForExit(): Promise { + // Mock implementation - do nothing + } + + endInput(): void { + this.endInputCalled = true; + } + + // Test helper methods + simulateMessage(message: unknown): void { + this.messageStream.enqueue(message); + } + + simulateError(error: Error): void { + this.messageStream.error(error); + } + + simulateClose(): void { + this.messageStream.done(); + } + + getLastWrittenMessage(): unknown { + if (this.writtenMessages.length === 0) return null; + return JSON.parse(this.writtenMessages[this.writtenMessages.length - 1]); + } + + getAllWrittenMessages(): unknown[] { + return this.writtenMessages.map((msg) => JSON.parse(msg)); + } +} + +// Helper function to find control response by request_id +function findControlResponse( + messages: unknown[], + requestId: string, +): CLIControlResponse | undefined { + return messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'control_response' && + 'response' in msg && + typeof msg.response === 'object' && + msg.response !== null && + 'request_id' in msg.response && + msg.response.request_id === requestId, + ) as CLIControlResponse | undefined; +} + +// Helper function to find control request by subtype +function findControlRequest( + messages: unknown[], + subtype: string, +): CLIControlRequest | undefined { + return messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'control_request' && + 'request' in msg && + typeof msg.request === 'object' && + msg.request !== null && + 'subtype' in msg.request && + msg.request.subtype === subtype, + ) as CLIControlRequest | undefined; +} + +// Helper function to create test messages +function createUserMessage( + content: string, + sessionId = 'test-session', +): CLIUserMessage { + return { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content, + }, + parent_tool_use_id: null, + }; +} + +function createAssistantMessage( + content: string, + sessionId = 'test-session', +): CLIAssistantMessage { + return { + type: 'assistant', + uuid: 'msg-123', + session_id: sessionId, + message: { + id: 'msg-123', + type: 'message', + role: 'assistant', + model: 'test-model', + content: [{ type: 'text', text: content }], + usage: { input_tokens: 10, output_tokens: 20 }, + }, + parent_tool_use_id: null, + }; +} + +function createSystemMessage( + subtype: string, + sessionId = 'test-session', +): CLISystemMessage { + return { + type: 'system', + subtype, + uuid: 'sys-123', + session_id: sessionId, + cwd: '/test/path', + tools: ['read_file', 'write_file'], + model: 'test-model', + }; +} + +function createResultMessage( + success: boolean, + sessionId = 'test-session', +): CLIResultMessage { + if (success) { + return { + type: 'result', + subtype: 'success', + uuid: 'result-123', + session_id: sessionId, + is_error: false, + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + result: 'Success', + usage: { input_tokens: 10, output_tokens: 20 }, + permission_denials: [], + }; + } else { + return { + type: 'result', + subtype: 'error_during_execution', + uuid: 'result-123', + session_id: sessionId, + is_error: true, + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + usage: { input_tokens: 10, output_tokens: 20 }, + permission_denials: [], + error: { message: 'Test error' }, + }; + } +} + +function createPartialMessage( + sessionId = 'test-session', +): CLIPartialAssistantMessage { + return { + type: 'stream_event', + uuid: 'stream-123', + session_id: sessionId, + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello' }, + }, + parent_tool_use_id: null, + }; +} + +function createControlRequest( + subtype: string, + requestId = 'req-123', +): CLIControlRequest { + return { + type: 'control_request', + request_id: requestId, + request: { + subtype, + tool_name: 'test_tool', + input: { arg: 'value' }, + permission_suggestions: null, + blocked_path: null, + } as CLIControlRequest['request'], + }; +} + +function createControlResponse( + requestId: string, + success: boolean, + data?: unknown, +): CLIControlResponse { + return { + type: 'control_response', + response: success + ? { + subtype: 'success', + request_id: requestId, + response: data ?? null, + } + : { + subtype: 'error', + request_id: requestId, + error: 'Test error', + }, + }; +} + +function createControlCancel(requestId: string): ControlCancelRequest { + return { + type: 'control_cancel_request', + request_id: requestId, + }; +} describe('Query', () => { + let transport: MockTransport; + + beforeEach(() => { + transport = new MockTransport(); + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (!transport.closed) { + await transport.close(); + } + }); + describe('Construction and Initialization', () => { - it('should create Query with transport and options', () => { - // Should accept Transport and CreateQueryOptions - expect(true).toBe(true); // Placeholder + it('should create Query with transport and options', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + expect(query).toBeDefined(); + expect(query.getSessionId()).toBeTruthy(); + expect(query.isClosed()).toBe(false); + + // Should send initialize control request + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + expect(initRequest.type).toBe('control_request'); + expect(initRequest.request.subtype).toBe('initialize'); + + await query.close(); }); - it('should generate unique session ID', () => { - // Each Query should have unique session_id - expect(true).toBe(true); // Placeholder + it('should generate unique session ID', async () => { + const transport2 = new MockTransport(); + const query1 = new Query(transport, { cwd: '/test' }); + const query2 = new Query(transport2, { + cwd: '/test', + }); + + expect(query1.getSessionId()).not.toBe(query2.getSessionId()); + + await query1.close(); + await query2.close(); + await transport2.close(); }); - it('should validate MCP server name conflicts', () => { - // Should throw if mcpServers and sdkMcpServers have same keys - expect(true).toBe(true); // Placeholder + it('should validate MCP server name conflicts', async () => { + const mockServer = { + connect: vi.fn(), + }; + + await expect(async () => { + const query = new Query(transport, { + cwd: '/test', + mcpServers: { server1: { command: 'test' } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkMcpServers: { server1: mockServer as any }, + }); + await query.initialized; + }).rejects.toThrow(/name conflicts/); }); - it('should lazy initialize on first message consumption', async () => { - // Should not call initialize() until messages are read - expect(true).toBe(true); // Placeholder + it('should initialize with SDK MCP servers', async () => { + const mockServer = { + connect: vi.fn().mockResolvedValue(undefined), + }; + + const query = new Query(transport, { + cwd: '/test', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkMcpServers: { testServer: mockServer as any }, + }); + + // Respond to initialize request + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + expect(mockServer.connect).toHaveBeenCalled(); + + await query.close(); + }); + + it('should handle initialization errors', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + // Simulate initialization failure + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, false), + ); + + await expect(query.initialized).rejects.toThrow(); + + await query.close(); }); }); describe('Message Routing', () => { - it('should route user messages to CLI', async () => { - // Initial prompt should be sent as user message - expect(true).toBe(true); // Placeholder + it('should route user messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const userMsg = createUserMessage('Hello'); + transport.simulateMessage(userMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(userMsg); + + await query.close(); }); it('should route assistant messages to output stream', async () => { - // Assistant messages from CLI should be yielded to user - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const assistantMsg = createAssistantMessage('Response'); + transport.simulateMessage(assistantMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(assistantMsg); + + await query.close(); }); - it('should route tool_use messages to output stream', async () => { - // Tool use messages should be yielded to user - expect(true).toBe(true); // Placeholder - }); + it('should route system messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); - it('should route tool_result messages to output stream', async () => { - // Tool result messages should be yielded to user - expect(true).toBe(true); // Placeholder + const systemMsg = createSystemMessage('session_start'); + transport.simulateMessage(systemMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(systemMsg); + + await query.close(); }); it('should route result messages to output stream', async () => { - // Result messages should be yielded to user - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(resultMsg); + + await query.close(); }); - it('should filter keep_alive messages from output', async () => { - // Keep alive messages should not be yielded to user - expect(true).toBe(true); // Placeholder + it('should route partial assistant messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const partialMsg = createPartialMessage(); + transport.simulateMessage(partialMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(partialMsg); + + await query.close(); + }); + + it('should handle unknown message types', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const unknownMsg = { type: 'unknown', data: 'test' }; + transport.simulateMessage(unknownMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(unknownMsg); + + await query.close(); + }); + + it('should yield messages in order', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const msg1 = createUserMessage('First'); + const msg2 = createAssistantMessage('Second'); + const msg3 = createResultMessage(true); + + transport.simulateMessage(msg1); + transport.simulateMessage(msg2); + transport.simulateMessage(msg3); + + const result1 = await query.next(); + expect(result1.value).toEqual(msg1); + + const result2 = await query.next(); + expect(result2.value).toEqual(msg2); + + const result3 = await query.next(); + expect(result3.value).toEqual(msg3); + + await query.close(); }); }); describe('Control Plane - Permission Control', () => { it('should handle can_use_tool control requests', async () => { - // Should invoke canUseTool callback - expect(true).toBe(true); // Placeholder + const canUseTool = vi.fn().mockResolvedValue(true); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + expect(canUseTool).toHaveBeenCalledWith( + 'test_tool', + { arg: 'value' }, + expect.objectContaining({ + signal: expect.any(AbortSignal), + suggestions: null, + }), + ); + }); + + await query.close(); }); - it('should send control response with permission result', async () => { - // Should send response with allowed: true/false - expect(true).toBe(true); // Placeholder + it('should send control response with permission result - allow', async () => { + const canUseTool = vi.fn().mockResolvedValue(true); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-1'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-1'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'allow', + }); + } + }); + + await query.close(); }); - it('should default to allowing tools if no callback', async () => { - // If canUseTool not provided, should allow all - expect(true).toBe(true); // Placeholder + it('should send control response with permission result - deny', async () => { + const canUseTool = vi.fn().mockResolvedValue(false); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-2'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-2'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); + }); + + it('should default to denying tools if no callback', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-3'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-3'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); }); it('should handle permission callback timeout', async () => { - // Should deny permission if callback exceeds 30s - expect(true).toBe(true); // Placeholder + const canUseTool = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve(true), 35000); // Exceeds 30s timeout + }), + ); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-4'); + transport.simulateMessage(controlReq); + + await vi.waitFor( + () => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-4'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }, + { timeout: 35000 }, + ); + + await query.close(); }); it('should handle permission callback errors', async () => { - // Should deny permission if callback throws - expect(true).toBe(true); // Placeholder + const canUseTool = vi.fn().mockRejectedValue(new Error('Callback error')); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-5'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-5'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); + }); + + it('should handle PermissionResult format with updatedInput', async () => { + const canUseTool = vi.fn().mockResolvedValue({ + behavior: 'allow', + updatedInput: { arg: 'modified' }, + }); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-6'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-6'); + + expect(response).toBeDefined(); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'allow', + updatedInput: { arg: 'modified' }, + }); + } + }); + + await query.close(); + }); + + it('should handle permission denial with interrupt flag', async () => { + const canUseTool = vi.fn().mockResolvedValue({ + behavior: 'deny', + message: 'Denied by user', + interrupt: true, + }); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-7'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-7'); + + expect(response).toBeDefined(); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + message: 'Denied by user', + interrupt: true, + }); + } + }); + + await query.close(); }); }); - describe('Control Plane - MCP Messages', () => { - it('should route MCP messages to SDK-embedded servers', async () => { - // Should find SdkControlServerTransport by server name - expect(true).toBe(true); // Placeholder + describe('Control Plane - Control Cancel', () => { + it('should handle control cancel requests', async () => { + const canUseTool = vi.fn().mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(new AbortError())); + setTimeout(() => resolve(true), 5000); + }), + ); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'cancel-req-1'); + transport.simulateMessage(controlReq); + + // Wait a bit then send cancel + await new Promise((resolve) => setTimeout(resolve, 100)); + transport.simulateMessage(createControlCancel('cancel-req-1')); + + await vi.waitFor(() => { + expect(canUseTool).toHaveBeenCalled(); + }); + + await query.close(); }); - it('should handle MCP message responses', async () => { - // Should send response back to CLI - expect(true).toBe(true); // Placeholder - }); + it('should ignore cancel for unknown request_id', async () => { + const query = new Query(transport, { + cwd: '/test', + }); - it('should handle MCP message timeout', async () => { - // Should return error if MCP server doesn\'t respond in 30s - expect(true).toBe(true); // Placeholder - }); + // Send cancel for non-existent request + transport.simulateMessage(createControlCancel('unknown-req')); - it('should handle unknown MCP server names', async () => { - // Should return error if server name not found - expect(true).toBe(true); // Placeholder - }); - }); + // Should not throw or cause issues + await new Promise((resolve) => setTimeout(resolve, 100)); - describe('Control Plane - Other Requests', () => { - it('should handle initialize control request', async () => { - // Should register SDK MCP servers with CLI - expect(true).toBe(true); // Placeholder - }); - - it('should handle interrupt control request', async () => { - // Should send interrupt message to CLI - expect(true).toBe(true); // Placeholder - }); - - it('should handle set_permission_mode control request', async () => { - // Should send permission mode update to CLI - expect(true).toBe(true); // Placeholder - }); - - it('should handle supported_commands control request', async () => { - // Should query CLI capabilities - expect(true).toBe(true); // Placeholder - }); - - it('should handle mcp_server_status control request', async () => { - // Should check MCP server health - expect(true).toBe(true); // Placeholder + await query.close(); }); }); describe('Multi-Turn Conversation', () => { it('should support streamInput() for follow-up messages', async () => { - // Should accept async iterable of messages - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Follow-up 1'); + yield createUserMessage('Follow-up 2'); + } + + await query.streamInput(messageGenerator()); + + const messages = transport.getAllWrittenMessages(); + const userMessages = messages.filter( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ); + expect(userMessages.length).toBeGreaterThanOrEqual(2); + + await query.close(); }); it('should maintain session context across turns', async () => { - // All messages should have same session_id - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + const sessionId = query.getSessionId(); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Turn 1', sessionId); + yield createUserMessage('Turn 2', sessionId); + } + + await query.streamInput(messageGenerator()); + + const messages = transport.getAllWrittenMessages(); + const userMessages = messages.filter( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ) as CLIUserMessage[]; + + userMessages.forEach((msg) => { + expect(msg.session_id).toBe(sessionId); + }); + + await query.close(); }); it('should throw if streamInput() called on closed query', async () => { - // Should throw Error if query is closed - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + async function* messageGenerator() { + yield createUserMessage('Test'); + } + + await expect(query.streamInput(messageGenerator())).rejects.toThrow( + 'Query is closed', + ); + }); + + it('should handle abort during streamInput', async () => { + const abortController = new AbortController(); + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Message 1'); + abortController.abort(); + yield createUserMessage('Message 2'); // Should not be sent + } + + await query.streamInput(messageGenerator()); + + await query.close(); }); }); describe('Lifecycle Management', () => { it('should close transport on close()', async () => { - // Should call transport.close() - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + expect(transport.closed).toBe(true); }); it('should mark query as closed', async () => { - // closed flag should be true after close() - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + expect(query.isClosed()).toBe(false); + + await query.close(); + expect(query.isClosed()).toBe(true); }); it('should complete output stream on close()', async () => { - // inputStream should be marked done - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + const messages: CLIMessage[] = []; + for await (const msg of query) { + messages.push(msg); + } + return messages; + })(); + + await query.close(); + transport.simulateClose(); + + const messages = await iterationPromise; + expect(Array.isArray(messages)).toBe(true); }); it('should be idempotent when closing multiple times', async () => { - // Multiple close() calls should not error - expect(true).toBe(true); // Placeholder - }); + const query = new Query(transport, { cwd: '/test' }); - it('should cleanup MCP transports on close()', async () => { - // Should close all SdkControlServerTransport instances - expect(true).toBe(true); // Placeholder + await query.close(); + await query.close(); + await query.close(); + + expect(query.isClosed()).toBe(true); }); it('should handle abort signal cancellation', async () => { - // Should abort on AbortSignal - expect(true).toBe(true); // Placeholder + const abortController = new AbortController(); + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + abortController.abort(); + + await vi.waitFor(() => { + expect(query.isClosed()).toBe(true); + }); + }); + + it('should handle pre-aborted signal', async () => { + const abortController = new AbortController(); + abortController.abort(); + + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + await vi.waitFor(() => { + expect(query.isClosed()).toBe(true); + }); }); }); describe('Async Iteration', () => { it('should support for await loop', async () => { - // Should implement AsyncIterator protocol - expect(true).toBe(true); // Placeholder - }); + const query = new Query(transport, { cwd: '/test' }); - it('should yield messages in order', async () => { - // Messages should be yielded in received order - expect(true).toBe(true); // Placeholder + const messages: CLIMessage[] = []; + const iterationPromise = (async () => { + for await (const msg of query) { + messages.push(msg); + if (messages.length >= 2) break; + } + })(); + + transport.simulateMessage(createUserMessage('First')); + transport.simulateMessage(createAssistantMessage('Second')); + + await iterationPromise; + + expect(messages).toHaveLength(2); + expect((messages[0] as CLIUserMessage).message.content).toBe('First'); + + await query.close(); }); it('should complete iteration when query closes', async () => { - // for await loop should exit when query closes - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const messages: CLIMessage[] = []; + const iterationPromise = (async () => { + for await (const msg of query) { + messages.push(msg); + } + })(); + + transport.simulateMessage(createUserMessage('Test')); + + // Give time for message to be processed + await new Promise((resolve) => setTimeout(resolve, 10)); + + await query.close(); + transport.simulateClose(); + + await iterationPromise; + expect(messages.length).toBeGreaterThanOrEqual(1); }); it('should propagate transport errors', async () => { - // Should throw if transport encounters error - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + for await (const msg of query) { + void msg; + } + })(); + + transport.simulateError(new Error('Transport error')); + + await expect(iterationPromise).rejects.toThrow('Transport error'); + + await query.close(); }); }); describe('Public API Methods', () => { it('should provide interrupt() method', async () => { - // Should send interrupt control request - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const interruptPromise = query.interrupt(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + ); + expect(interruptMsg).toBeDefined(); + }); + + // Respond to interrupt + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + )!; + transport.simulateMessage( + createControlResponse(interruptMsg.request_id, true, {}), + ); + + await interruptPromise; + await query.close(); }); it('should provide setPermissionMode() method', async () => { - // Should send set_permission_mode control request - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const setModePromise = query.setPermissionMode('yolo'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const setModeMsg = findControlRequest( + messages, + ControlRequestType.SET_PERMISSION_MODE, + ); + expect(setModeMsg).toBeDefined(); + }); + + // Respond to set permission mode + const messages = transport.getAllWrittenMessages(); + const setModeMsg = findControlRequest( + messages, + ControlRequestType.SET_PERMISSION_MODE, + )!; + transport.simulateMessage( + createControlResponse(setModeMsg.request_id, true, {}), + ); + + await setModePromise; + await query.close(); + }); + + it('should provide setModel() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const setModelPromise = query.setModel('new-model'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const setModelMsg = findControlRequest( + messages, + ControlRequestType.SET_MODEL, + ); + expect(setModelMsg).toBeDefined(); + }); + + // Respond to set model + const messages = transport.getAllWrittenMessages(); + const setModelMsg = findControlRequest( + messages, + ControlRequestType.SET_MODEL, + )!; + transport.simulateMessage( + createControlResponse(setModelMsg.request_id, true, {}), + ); + + await setModelPromise; + await query.close(); }); it('should provide supportedCommands() method', async () => { - // Should query CLI capabilities - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const commandsPromise = query.supportedCommands(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const commandsMsg = findControlRequest( + messages, + ControlRequestType.SUPPORTED_COMMANDS, + ); + expect(commandsMsg).toBeDefined(); + }); + + // Respond with commands + const messages = transport.getAllWrittenMessages(); + const commandsMsg = findControlRequest( + messages, + ControlRequestType.SUPPORTED_COMMANDS, + )!; + transport.simulateMessage( + createControlResponse(commandsMsg.request_id, true, { + commands: ['interrupt', 'set_model'], + }), + ); + + const result = await commandsPromise; + expect(result).toMatchObject({ commands: ['interrupt', 'set_model'] }); + + await query.close(); }); it('should provide mcpServerStatus() method', async () => { - // Should check MCP server health - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const statusPromise = query.mcpServerStatus(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const statusMsg = findControlRequest( + messages, + ControlRequestType.MCP_SERVER_STATUS, + ); + expect(statusMsg).toBeDefined(); + }); + + // Respond with status + const messages = transport.getAllWrittenMessages(); + const statusMsg = findControlRequest( + messages, + ControlRequestType.MCP_SERVER_STATUS, + )!; + transport.simulateMessage( + createControlResponse(statusMsg.request_id, true, { + servers: [{ name: 'test', status: 'connected' }], + }), + ); + + const result = await statusPromise; + expect(result).toMatchObject({ + servers: [{ name: 'test', status: 'connected' }], + }); + + await query.close(); }); it('should throw if methods called on closed query', async () => { - // Public methods should throw if query is closed - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + await expect(query.interrupt()).rejects.toThrow('Query is closed'); + await expect(query.setPermissionMode('yolo')).rejects.toThrow( + 'Query is closed', + ); + await expect(query.setModel('model')).rejects.toThrow('Query is closed'); + await expect(query.supportedCommands()).rejects.toThrow( + 'Query is closed', + ); + await expect(query.mcpServerStatus()).rejects.toThrow('Query is closed'); }); }); describe('Error Handling', () => { it('should propagate transport errors to stream', async () => { - // Transport errors should be surfaced in for await loop - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const error = new Error('Transport failure'); + transport.simulateError(error); + + await expect(query.next()).rejects.toThrow('Transport failure'); + + await query.close(); }); it('should handle control request timeout', async () => { - // Should return error if control request doesn\'t respond - expect(true).toBe(true); // Placeholder - }); + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + // Call interrupt but don't respond - should timeout + const interruptPromise = query.interrupt(); + + await expect(interruptPromise).rejects.toThrow(/timeout/i); + + await query.close(); + }, 35000); it('should handle malformed control responses', async () => { - // Should handle invalid response structures - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const interruptPromise = query.interrupt(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + ); + expect(interruptMsg).toBeDefined(); + }); + + // Send malformed response + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + )!; + + transport.simulateMessage({ + type: 'control_response', + response: { + subtype: 'error', + request_id: interruptMsg.request_id, + error: { message: 'Malformed error' }, + }, + }); + + await expect(interruptPromise).rejects.toThrow('Malformed error'); + + await query.close(); }); - it('should handle CLI sending error message', async () => { - // Should yield error message to user - expect(true).toBe(true); // Placeholder + it('should handle CLI sending error result message', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const errorResult = createResultMessage(false); + transport.simulateMessage(errorResult); + + const result = await query.next(); + expect(result.done).toBe(false); + expect((result.value as CLIResultMessage).is_error).toBe(true); + + await query.close(); + }); + }); + + describe('Single-Turn Mode', () => { + it('should auto-close input after result in single-turn mode', async () => { + const query = new Query( + transport, + { cwd: '/test' }, + true, // singleTurn = true + ); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + await query.next(); + + expect(transport.endInputCalled).toBe(true); + + await query.close(); + }); + + it('should not auto-close input in multi-turn mode', async () => { + const query = new Query( + transport, + { cwd: '/test' }, + false, // singleTurn = false + ); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + await query.next(); + + expect(transport.endInputCalled).toBe(false); + + await query.close(); }); }); describe('State Management', () => { - it('should track pending control requests', () => { - // Should maintain map of request_id -> Promise - expect(true).toBe(true); // Placeholder + it('should track session ID', () => { + const query = new Query(transport, { cwd: '/test' }); + const sessionId = query.getSessionId(); + + expect(sessionId).toBeTruthy(); + expect(typeof sessionId).toBe('string'); + expect(sessionId.length).toBeGreaterThan(0); }); - it('should track SDK MCP transports', () => { - // Should maintain map of server_name -> SdkControlServerTransport - expect(true).toBe(true); // Placeholder + it('should track closed state', async () => { + const query = new Query(transport, { cwd: '/test' }); + + expect(query.isClosed()).toBe(false); + await query.close(); + expect(query.isClosed()).toBe(true); }); - it('should track initialization state', () => { - // Should have initialized Promise - expect(true).toBe(true); // Placeholder + it('should provide endInput() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + query.endInput(); + expect(transport.endInputCalled).toBe(true); + + await query.close(); }); - it('should track closed state', () => { - // Should have closed boolean flag - expect(true).toBe(true); // Placeholder + it('should throw if endInput() called on closed query', async () => { + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + expect(() => query.endInput()).toThrow('Query is closed'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty message stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + transport.simulateClose(); + + const result = await query.next(); + expect(result.done).toBe(true); + + await query.close(); + }); + + it('should handle rapid message flow', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Simulate rapid messages + for (let i = 0; i < 100; i++) { + transport.simulateMessage(createUserMessage(`Message ${i}`)); + } + + const messages: CLIMessage[] = []; + for (let i = 0; i < 100; i++) { + const result = await query.next(); + if (!result.done) { + messages.push(result.value); + } + } + + expect(messages.length).toBe(100); + + await query.close(); + }); + + it('should handle close during message iteration', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + const messages: CLIMessage[] = []; + for await (const msg of query) { + messages.push(msg); + if (messages.length === 2) { + await query.close(); + } + } + return messages; + })(); + + transport.simulateMessage(createUserMessage('First')); + transport.simulateMessage(createUserMessage('Second')); + transport.simulateMessage(createUserMessage('Third')); + transport.simulateClose(); + + const messages = await iterationPromise; + expect(messages.length).toBeGreaterThanOrEqual(2); }); }); }); diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index 55a87b92..0e40e23a 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -11,7 +11,6 @@ import { parseExecutableSpec, prepareSpawnInfo, findNativeCliPath, - resolveCliPath, } from '../../src/utils/cliPath.js'; // Mock fs module @@ -421,28 +420,6 @@ describe('CLI Path Utilities', () => { }); }); - describe('resolveCliPath (backward compatibility)', () => { - it('should resolve CLI path for backward compatibility', () => { - mockFs.existsSync.mockReturnValue(true); - - const result = resolveCliPath('/path/to/qwen'); - - expect(result).toBe('/path/to/qwen'); - }); - - it('should auto-detect when no path provided', () => { - const originalEnv = process.env['QWEN_CODE_CLI_PATH']; - process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; - mockFs.existsSync.mockReturnValue(true); - - const result = resolveCliPath(); - - expect(result).toBe('/usr/local/bin/qwen'); - - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; - }); - }); - describe('real-world use cases', () => { beforeEach(() => { mockFs.existsSync.mockReturnValue(true); From 49dc84ac0e5df805f0a41089b635ca97f3b98e94 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 25 Nov 2025 18:05:58 +0800 Subject: [PATCH 07/38] feat: add support for includePartialMessages option in query and transport layers --- .../sdk-typescript/src/query/createQuery.ts | 1 + .../src/transport/ProcessTransport.ts | 4 ++ .../src/types/queryOptionsSchema.ts | 1 + packages/sdk-typescript/src/types/types.ts | 1 + .../test/e2e/multi-turn.test.ts | 63 +++++++++++++++++++ .../test/e2e/single-turn.test.ts | 36 +++++++++++ 6 files changed, 106 insertions(+) diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index e3907635..7549b2b3 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -43,6 +43,7 @@ export function query({ coreTools: options.coreTools, excludeTools: options.excludeTools, authType: options.authType, + includePartialMessages: options.includePartialMessages, }); const queryOptions: QueryOptions = { diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index 62a6b2d0..ba13f044 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -155,6 +155,10 @@ export class ProcessTransport implements Transport { args.push('--auth-type', this.options.authType); } + if (this.options.includePartialMessages) { + args.push('--include-partial-messages'); + } + return args; } diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index 7573abef..c347bfdd 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -76,6 +76,7 @@ export const QueryOptionsSchema = z ), ) .optional(), + includePartialMessages: z.boolean().optional(), }) .strict(); diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index 856099fc..e4cbbb5b 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -30,6 +30,7 @@ export type TransportOptions = { coreTools?: string[]; excludeTools?: string[]; authType?: string; + includePartialMessages?: boolean; }; type ToolInput = Record; diff --git a/packages/sdk-typescript/test/e2e/multi-turn.test.ts b/packages/sdk-typescript/test/e2e/multi-turn.test.ts index 8e79898e..52c012c8 100644 --- a/packages/sdk-typescript/test/e2e/multi-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/multi-turn.test.ts @@ -476,4 +476,67 @@ describe('Multi-Turn Conversations (E2E)', () => { } }); }); + + describe('Partial Messages in Multi-Turn', () => { + it('should receive partial messages when includePartialMessages is enabled', async () => { + async function* createMultiTurnConversation(): AsyncIterable { + const sessionId = crypto.randomUUID(); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 1 + 1?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'What is 2 + 2?', + }, + parent_tool_use_id: null, + } as CLIUserMessage; + } + + const q = query({ + prompt: createMultiTurnConversation(), + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let partialMessageCount = 0; + let assistantMessageCount = 0; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIPartialAssistantMessage(message)) { + partialMessageCount++; + } + + if (isCLIAssistantMessage(message)) { + assistantMessageCount++; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(partialMessageCount).toBeGreaterThan(0); + expect(assistantMessageCount).toBeGreaterThanOrEqual(2); + } finally { + await q.close(); + } + }); + }); }); diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts index 047be4f2..93c1ecc8 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -9,6 +9,7 @@ import { isCLIAssistantMessage, isCLISystemMessage, isCLIResultMessage, + isCLIPartialAssistantMessage, type TextBlock, type ContentBlock, type CLIMessage, @@ -327,6 +328,41 @@ describe('Single-Turn Query (E2E)', () => { await q.close(); } }); + + it('should receive partial messages when includePartialMessages is enabled', async () => { + const q = query({ + prompt: 'Count from 1 to 5', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + debug: false, + }, + }); + + const messages: CLIMessage[] = []; + let partialMessageCount = 0; + let assistantMessageCount = 0; + + try { + for await (const message of q) { + messages.push(message); + + if (isCLIPartialAssistantMessage(message)) { + partialMessageCount++; + } + + if (isCLIAssistantMessage(message)) { + assistantMessageCount++; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(partialMessageCount).toBeGreaterThan(0); + expect(assistantMessageCount).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); }); describe('Message Type Recognition', () => { From 769a438fa4fdcde706776a9c4aa5b02175488318 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 26 Nov 2025 21:37:40 +0800 Subject: [PATCH 08/38] feat: enhance logging capabilities and update query options in sdk-typescript - Introduced a new logging system with adjustable log levels (debug, info, warn, error). - Updated query options to include a logLevel parameter for controlling verbosity. - Refactored existing code to utilize the new logging system for better error handling and debugging. - Cleaned up unused code and improved the structure of the SDK. --- eslint.config.js | 2 +- packages/cli/src/gemini.tsx | 3 +- .../cli/src/validateNonInterActiveAuth.ts | 2 +- packages/sdk-typescript/package.json | 28 +-- packages/sdk-typescript/scripts/build.js | 95 ++++++++++ packages/sdk-typescript/src/index.ts | 7 +- .../src/mcp/SdkControlServerTransport.ts | 26 +-- packages/sdk-typescript/src/query/Query.ts | 84 +++------ .../sdk-typescript/src/query/createQuery.ts | 36 ++-- .../src/transport/ProcessTransport.ts | 51 +++-- .../src/types/queryOptionsSchema.ts | 7 +- packages/sdk-typescript/src/types/types.ts | 178 +++++++++++++++++- .../sdk-typescript/src/utils/jsonLines.ts | 12 +- packages/sdk-typescript/src/utils/logger.ts | 147 +++++++++++++++ .../test/e2e/single-turn.test.ts | 3 +- packages/sdk-typescript/tsconfig.build.json | 14 ++ 16 files changed, 552 insertions(+), 143 deletions(-) create mode 100755 packages/sdk-typescript/scripts/build.js create mode 100644 packages/sdk-typescript/src/utils/logger.ts create mode 100644 packages/sdk-typescript/tsconfig.build.json diff --git a/eslint.config.js b/eslint.config.js index 8a35ef6f..e477d95f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -170,7 +170,7 @@ export default tseslint.config( }, // extra settings for scripts that we run directly with node { - files: ['./scripts/**/*.js', 'esbuild.config.js'], + files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'], languageOptions: { globals: { ...globals.node, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 8210d5d5..3aa3f957 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -449,7 +449,8 @@ export async function main() { } const nonInteractiveConfig = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, + settings.merged.security?.auth?.selectedType || + (argv.authType as AuthType), settings.merged.security?.auth?.useExternal, config, settings, diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index 78ccc993..1590c074 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -41,7 +41,7 @@ export async function validateNonInteractiveAuth( } const effectiveAuthType = - enforcedType || getAuthTypeFromEnv() || configuredAuthType; + enforcedType || configuredAuthType || getAuthTypeFromEnv(); if (!effectiveAuthType) { const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: QWEN_OAUTH, OPENAI_API_KEY`; diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 067d1d22..63fed227 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -2,14 +2,15 @@ "name": "@qwen-code/sdk-typescript", "version": "0.1.0", "description": "TypeScript SDK for programmatic access to qwen-code CLI", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.js" + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" }, "./package.json": "./package.json" }, @@ -19,14 +20,16 @@ "LICENSE" ], "scripts": { - "build": "tsc", + "build": "node scripts/build.js", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint src test", "lint:fix": "eslint src test --fix", + "typecheck": "tsc --noEmit", "clean": "rm -rf dist", - "prepublishOnly": "npm run clean && npm run build" + "prepublishOnly": "npm run clean && npm run build", + "prepack": "npm run build" }, "keywords": [ "qwen", @@ -49,20 +52,23 @@ "@typescript-eslint/eslint-plugin": "^7.13.0", "@typescript-eslint/parser": "^7.13.0", "@vitest/coverage-v8": "^1.6.0", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.25.12", "eslint": "^8.57.0", "typescript": "^5.4.5", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "zod": "^3.23.8" }, "peerDependencies": { "typescript": ">=5.0.0" }, "repository": { "type": "git", - "url": "https://github.com/qwen-ai/qwen-code.git", - "directory": "packages/sdk/typescript" + "url": "https://github.com/QwenLM/qwen-code.git", + "directory": "packages/sdk-typescript" }, "bugs": { - "url": "https://github.com/qwen-ai/qwen-code/issues" + "url": "https://github.com/QwenLM/qwen-code/issues" }, - "homepage": "https://github.com/qwen-ai/qwen-code#readme" + "homepage": "https://qwenlm.github.io/qwen-code-docs/" } diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js new file mode 100755 index 00000000..055584a5 --- /dev/null +++ b/packages/sdk-typescript/scripts/build.js @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { rmSync, mkdirSync, existsSync, cpSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import esbuild from 'esbuild'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = join(__dirname, '..'); + +rmSync(join(rootDir, 'dist'), { recursive: true, force: true }); +mkdirSync(join(rootDir, 'dist'), { recursive: true }); + +execSync('tsc --project tsconfig.build.json', { + stdio: 'inherit', + cwd: rootDir, +}); + +try { + execSync( + 'npx dts-bundle-generator -o dist/index.d.ts src/index.ts --no-check', + { + stdio: 'inherit', + cwd: rootDir, + }, + ); + + const dirsToRemove = ['mcp', 'query', 'transport', 'types', 'utils']; + for (const dir of dirsToRemove) { + const dirPath = join(rootDir, 'dist', dir); + if (existsSync(dirPath)) { + rmSync(dirPath, { recursive: true, force: true }); + } + } +} catch (error) { + console.warn( + 'Could not bundle type definitions, keeping separate .d.ts files', + error.message, + ); +} + +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'index.ts')], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node18', + outfile: join(rootDir, 'dist', 'index.mjs'), + external: ['@modelcontextprotocol/sdk'], + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'index.ts')], + bundle: true, + format: 'cjs', + platform: 'node', + target: 'node18', + outfile: join(rootDir, 'dist', 'index.cjs'), + external: ['@modelcontextprotocol/sdk'], + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + +const filesToCopy = ['README.md', 'LICENSE']; +for (const file of filesToCopy) { + const sourcePath = join(rootDir, '..', '..', file); + const targetPath = join(rootDir, 'dist', file); + if (existsSync(sourcePath)) { + try { + cpSync(sourcePath, targetPath); + } catch (error) { + console.warn(`Could not copy ${file}:`, error.message); + } + } +} diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 23ba3f93..5992c6c5 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -1,10 +1,10 @@ export { query } from './query/createQuery.js'; export { AbortError, isAbortError } from './types/errors.js'; export { Query } from './query/Query.js'; - -export type { ExternalMcpServerConfig } from './types/queryOptionsSchema.js'; +export { SdkLogger } from './utils/logger.js'; export type { QueryOptions } from './query/createQuery.js'; +export type { LogLevel, LoggerConfig, ScopedLogger } from './utils/logger.js'; export type { ContentBlock, @@ -29,8 +29,9 @@ export { } from './types/protocol.js'; export type { - JSONSchema, PermissionMode, CanUseTool, PermissionResult, + ExternalMcpServerConfig, + SdkMcpServerConfig, } from './types/types.js'; diff --git a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts index c160a9af..06392a4f 100644 --- a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts +++ b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts @@ -9,6 +9,7 @@ */ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { SdkLogger } from '../utils/logger.js'; export type SendToQueryCallback = (message: JSONRPCMessage) => Promise; @@ -21,6 +22,7 @@ export class SdkControlServerTransport { sendToQuery: SendToQueryCallback; private serverName: string; private started = false; + private logger; onmessage?: (message: JSONRPCMessage) => void; onerror?: (error: Error) => void; @@ -29,10 +31,14 @@ export class SdkControlServerTransport { constructor(options: SdkControlServerTransportOptions) { this.sendToQuery = options.sendToQuery; this.serverName = options.serverName; + this.logger = SdkLogger.createLogger( + `SdkControlServerTransport:${options.serverName}`, + ); } async start(): Promise { this.started = true; + this.logger.debug('Transport started'); } async send(message: JSONRPCMessage): Promise { @@ -43,10 +49,10 @@ export class SdkControlServerTransport { } try { - // Send via Query's control plane + this.logger.debug('Sending message to Query', message); await this.sendToQuery(message); } catch (error) { - // Invoke error callback if set + this.logger.error('Error sending message:', error); if (this.onerror) { this.onerror(error instanceof Error ? error : new Error(String(error))); } @@ -60,6 +66,7 @@ export class SdkControlServerTransport { } this.started = false; + this.logger.debug('Transport closed'); // Notify MCP Server if (this.onclose) { @@ -69,29 +76,22 @@ export class SdkControlServerTransport { handleMessage(message: JSONRPCMessage): void { if (!this.started) { - console.warn( - `[SdkControlServerTransport] Received message for closed transport (${this.serverName})`, - ); + this.logger.warn('Received message for closed transport'); return; } + this.logger.debug('Handling message from CLI', message); if (this.onmessage) { this.onmessage(message); } else { - console.warn( - `[SdkControlServerTransport] No onmessage handler set for ${this.serverName}`, - ); + this.logger.warn('No onmessage handler set'); } } handleError(error: Error): void { + this.logger.error('Transport error:', error); if (this.onerror) { this.onerror(error); - } else { - console.error( - `[SdkControlServerTransport] Error for ${this.serverName}:`, - error, - ); } } diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index c8039d4c..de4c4852 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -11,6 +11,7 @@ const CONTROL_REQUEST_TIMEOUT = 30000; const STREAM_CLOSE_TIMEOUT = 10000; import { randomUUID } from 'node:crypto'; +import { SdkLogger } from '../utils/logger.js'; import type { CLIMessage, CLIUserMessage, @@ -30,7 +31,7 @@ import { isControlCancel, } from '../types/protocol.js'; import type { Transport } from '../transport/Transport.js'; -import { type QueryOptions } from '../types/queryOptionsSchema.js'; +import type { QueryOptions } from '../types/types.js'; import { Stream } from '../utils/Stream.js'; import { serializeJsonLine } from '../utils/jsonLines.js'; import { AbortError } from '../types/errors.js'; @@ -49,6 +50,8 @@ interface TransportWithEndInput extends Transport { endInput(): void; } +const logger = SdkLogger.createLogger('Query'); + export class Query implements AsyncIterable { private transport: Transport; private options: QueryOptions; @@ -101,13 +104,13 @@ export class Query implements AsyncIterable { if (this.abortController.signal.aborted) { this.inputStream.error(new AbortError('Query aborted by user')); this.close().catch((err) => { - console.error('[Query] Error during abort cleanup:', err); + logger.error('Error during abort cleanup:', err); }); } else { this.abortController.signal.addEventListener('abort', () => { this.inputStream.error(new AbortError('Query aborted by user')); this.close().catch((err) => { - console.error('[Query] Error during abort cleanup:', err); + logger.error('Error during abort cleanup:', err); }); }); } @@ -120,7 +123,7 @@ export class Query implements AsyncIterable { private async initialize(): Promise { try { - await this.setupSdkMcpServers(); + logger.debug('Initializing Query'); const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys()); @@ -131,52 +134,13 @@ export class Query implements AsyncIterable { mcpServers: this.options.mcpServers, agents: this.options.agents, }); + logger.info('Query initialized successfully'); } catch (error) { - console.error('[Query] Initialization error:', error); + logger.error('Initialization error:', error); throw error; } } - private async setupSdkMcpServers(): Promise { - if (!this.options.sdkMcpServers) { - return; - } - - const externalNames = Object.keys(this.options.mcpServers ?? {}); - const sdkNames = Object.keys(this.options.sdkMcpServers); - - const conflicts = sdkNames.filter((name) => externalNames.includes(name)); - if (conflicts.length > 0) { - throw new Error( - `MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`, - ); - } - - /** - * Import SdkControlServerTransport dynamically to avoid circular dependencies. - * Create transport for each server that sends MCP messages via control plane. - */ - const { SdkControlServerTransport } = await import( - '../mcp/SdkControlServerTransport.js' - ); - - for (const [name, server] of Object.entries(this.options.sdkMcpServers)) { - const transport = new SdkControlServerTransport({ - serverName: name, - sendToQuery: async (message: JSONRPCMessage) => { - await this.sendControlRequest(ControlRequestType.MCP_MESSAGE, { - server_name: name, - message, - }); - }, - }); - - await transport.start(); - await server.connect(transport); - this.sdkMcpTransports.set(name, transport); - } - } - private startMessageRouter(): void { if (this.messageRouterStarted) { return; @@ -256,9 +220,7 @@ export class Query implements AsyncIterable { return; } - if (process.env['DEBUG']) { - console.warn('[Query] Unknown message type:', message); - } + logger.warn('Unknown message type:', message); this.inputStream.enqueue(message as CLIMessage); } @@ -267,6 +229,7 @@ export class Query implements AsyncIterable { ): Promise { const { request_id, request: payload } = request; + logger.debug(`Handling control request: ${payload.subtype}`); const requestAbortController = new AbortController(); try { @@ -299,6 +262,7 @@ export class Query implements AsyncIterable { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Control request error (${payload.subtype}):`, errorMessage); await this.sendControlResponse(request_id, false, errorMessage); } } @@ -369,8 +333,8 @@ export class Query implements AsyncIterable { */ const errorMessage = error instanceof Error ? error.message : String(error); - console.warn( - '[Query] Permission callback error (denying by default):', + logger.warn( + 'Permission callback error (denying by default):', errorMessage, ); return { @@ -448,9 +412,10 @@ export class Query implements AsyncIterable { const pending = this.pendingControlRequests.get(request_id); if (!pending) { - console.warn( - '[Query] Received response for unknown request:', + logger.warn( + 'Received response for unknown request:', request_id, + JSON.stringify(payload), ); return; } @@ -459,6 +424,9 @@ export class Query implements AsyncIterable { this.pendingControlRequests.delete(request_id); if (payload.subtype === 'success') { + logger.debug( + `Control response success for request: ${request_id}: ${JSON.stringify(payload.response)}`, + ); pending.resolve(payload.response as Record | null); } else { /** @@ -469,6 +437,10 @@ export class Query implements AsyncIterable { typeof payload.error === 'string' ? payload.error : (payload.error?.message ?? 'Unknown error'); + logger.error( + `Control response error for request ${request_id}:`, + errorMessage, + ); pending.reject(new Error(errorMessage)); } } @@ -477,12 +449,13 @@ export class Query implements AsyncIterable { const { request_id } = request; if (!request_id) { - console.warn('[Query] Received cancel request without request_id'); + logger.warn('Received cancel request without request_id'); return; } const pending = this.pendingControlRequests.get(request_id); if (pending) { + logger.debug(`Cancelling control request: ${request_id}`); pending.abortController.abort(); clearTimeout(pending.timeout); this.pendingControlRequests.delete(request_id); @@ -580,10 +553,11 @@ export class Query implements AsyncIterable { try { await transport.close(); } catch (error) { - console.error('[Query] Error closing MCP transport:', error); + logger.error('Error closing MCP transport:', error); } } this.sdkMcpTransports.clear(); + logger.info('Query closed'); } private async *readSdkMessages(): AsyncGenerator { @@ -652,7 +626,7 @@ export class Query implements AsyncIterable { this.endInput(); } catch (error) { if (this.abortController.signal.aborted) { - console.log('[Query] Aborted during input streaming'); + logger.info('Aborted during input streaming'); this.inputStream.error( new AbortError('Query aborted during input streaming'), ); diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 7549b2b3..71fd6e9b 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -7,18 +7,29 @@ import { serializeJsonLine } from '../utils/jsonLines.js'; import { ProcessTransport } from '../transport/ProcessTransport.js'; import { parseExecutableSpec } from '../utils/cliPath.js'; import { Query } from './Query.js'; -import { - QueryOptionsSchema, - type QueryOptions, -} from '../types/queryOptionsSchema.js'; +import type { QueryOptions } from '../types/types.js'; +import { QueryOptionsSchema } from '../types/queryOptionsSchema.js'; +import { SdkLogger } from '../utils/logger.js'; export type { QueryOptions }; +const logger = SdkLogger.createLogger('createQuery'); + export function query({ prompt, options = {}, }: { + /** + * The prompt to send to the Qwen Code CLI process. + * - `string` for single-turn query, + * - `AsyncIterable` for multi-turn query. + * + * The transport will remain open until the prompt is done. + */ prompt: string | AsyncIterable; + /** + * Configuration options for the query. + */ options?: QueryOptions; }): Query { const parsedExecutable = validateOptions(options); @@ -39,6 +50,7 @@ export function query({ abortController, debug: options.debug, stderr: options.stderr, + logLevel: options.logLevel, maxSessionTurns: options.maxSessionTurns, coreTools: options.coreTools, excludeTools: options.excludeTools, @@ -70,14 +82,14 @@ export function query({ await queryInstance.initialized; transport.write(serializeJsonLine(message)); } catch (err) { - console.error('[query] Error sending single-turn prompt:', err); + logger.error('Error sending single-turn prompt:', err); } })(); } else { queryInstance .streamInput(prompt as AsyncIterable) .catch((err) => { - console.error('[query] Error streaming input:', err); + logger.error('Error streaming input:', err); }); } @@ -103,17 +115,5 @@ function validateOptions( throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`); } - if (options.mcpServers && options.sdkMcpServers) { - const externalNames = Object.keys(options.mcpServers); - const sdkNames = Object.keys(options.sdkMcpServers); - - const conflicts = externalNames.filter((name) => sdkNames.includes(name)); - if (conflicts.length > 0) { - throw new Error( - `MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`, - ); - } - } - return parsedExecutable; } diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index ba13f044..d473160c 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -6,6 +6,9 @@ import type { Transport } from './Transport.js'; import { parseJsonLinesStream } from '../utils/jsonLines.js'; import { prepareSpawnInfo } from '../utils/cliPath.js'; import { AbortError } from '../types/errors.js'; +import { SdkLogger } from '../utils/logger.js'; + +const logger = SdkLogger.createLogger('ProcessTransport'); export class ProcessTransport implements Transport { private childProcess: ChildProcess | null = null; @@ -23,6 +26,11 @@ export class ProcessTransport implements Transport { this.options = options; this.abortController = this.options.abortController ?? new AbortController(); + SdkLogger.configure({ + debug: options.debug, + stderr: options.stderr, + logLevel: options.logLevel, + }); this.initialize(); } @@ -41,7 +49,7 @@ export class ProcessTransport implements Transport { const stderrMode = this.options.debug || this.options.stderr ? 'pipe' : 'ignore'; - this.logForDebugging( + logger.debug( `Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`, ); @@ -61,7 +69,7 @@ export class ProcessTransport implements Transport { if (this.options.debug || this.options.stderr) { this.childProcess.stderr?.on('data', (data) => { - this.logForDebugging(data.toString()); + logger.debug(data.toString()); }); } @@ -79,8 +87,10 @@ export class ProcessTransport implements Transport { this.setupEventHandlers(); this.ready = true; + logger.info('CLI process started successfully'); } catch (error) { this.ready = false; + logger.error('Failed to initialize CLI process:', error); throw error; } } @@ -94,7 +104,7 @@ export class ProcessTransport implements Transport { this._exitError = new AbortError('CLI process aborted by user'); } else { this._exitError = new Error(`CLI process error: ${error.message}`); - this.logForDebugging(this._exitError.message); + logger.error(this._exitError.message); } }); @@ -106,7 +116,7 @@ export class ProcessTransport implements Transport { const error = this.getProcessExitError(code, signal); if (error) { this._exitError = error; - this.logForDebugging(error.message); + logger.error(error.message); } } }); @@ -269,28 +279,24 @@ export class ProcessTransport implements Transport { ); } - if (process.env['DEBUG']) { - this.logForDebugging( - `[ProcessTransport] Writing to stdin (${message.length} bytes): ${message.substring(0, 100)}`, - ); - } + logger.debug( + `Writing to stdin (${message.length} bytes): ${message.trim()}`, + ); try { const written = this.childStdin.write(message); if (!written) { - this.logForDebugging( - `[ProcessTransport] Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`, - ); - } else if (process.env['DEBUG']) { - this.logForDebugging( - `[ProcessTransport] Write successful (${message.length} bytes)`, + logger.warn( + `Write buffer full (${message.length} bytes), data queued. Waiting for drain event...`, ); + } else { + logger.debug(`Write successful (${message.length} bytes)`); } } catch (error) { this.ready = false; - throw new Error( - `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`; + logger.error(errorMsg); + throw new Error(errorMsg); } } @@ -340,13 +346,4 @@ export class ProcessTransport implements Transport { getOutputStream(): Readable | undefined { return this.childStdout || undefined; } - - private logForDebugging(message: string): void { - if (this.options.debug || process.env['DEBUG']) { - process.stderr.write(`[ProcessTransport] ${message}\n`); - } - if (this.options.stderr) { - this.options.stderr(message); - } - } } diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index c347bfdd..c4629357 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -50,7 +50,6 @@ export const QueryOptionsSchema = z }) .optional(), mcpServers: z.record(z.string(), ExternalMcpServerConfigSchema).optional(), - sdkMcpServers: z.record(z.string(), SdkMcpServerConfigSchema).optional(), abortController: z.instanceof(AbortController).optional(), debug: z.boolean().optional(), stderr: z @@ -58,6 +57,7 @@ export const QueryOptionsSchema = z (message: string) => void >((val) => typeof val === 'function', { message: 'stderr must be a function' }) .optional(), + logLevel: z.enum(['debug', 'info', 'warn', 'error']).optional(), maxSessionTurns: z.number().optional(), coreTools: z.array(z.string()).optional(), excludeTools: z.array(z.string()).optional(), @@ -79,8 +79,3 @@ export const QueryOptionsSchema = z includePartialMessages: z.boolean().optional(), }) .strict(); - -export type ExternalMcpServerConfig = z.infer< - typeof ExternalMcpServerConfigSchema ->; -export type QueryOptions = z.infer; diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index e4cbbb5b..0c23581b 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -1,8 +1,12 @@ -import type { PermissionMode, PermissionSuggestion } from './protocol.js'; +import type { + PermissionMode, + PermissionSuggestion, + SubagentConfig, +} from './protocol.js'; export type { PermissionMode }; -export type JSONSchema = { +type JSONSchema = { type: string; properties?: Record; required?: string[]; @@ -26,6 +30,7 @@ export type TransportOptions = { abortController?: AbortController; debug?: boolean; stderr?: (message: string) => void; + logLevel?: 'debug' | 'info' | 'warn' | 'error'; maxSessionTurns?: number; coreTools?: string[]; excludeTools?: string[]; @@ -54,3 +59,172 @@ export type PermissionResult = message: string; interrupt?: boolean; }; + +export interface ExternalMcpServerConfig { + command: string; + args?: string[]; + env?: Record; +} + +export interface SdkMcpServerConfig { + connect: (transport: unknown) => Promise; +} + +/** + * Configuration options for creating a query session with the Qwen CLI. + */ +export interface QueryOptions { + /** + * The working directory for the query session. + * This determines the context in which file operations and commands are executed. + * @default process.cwd() + */ + cwd?: string; + + /** + * The AI model to use for the query session. + * This takes precedence over the environment variables `OPENAI_MODEL` and `QWEN_MODEL` + * @example 'qwen-max', 'qwen-plus', 'qwen-turbo' + */ + model?: string; + + /** + * Path to the Qwen CLI executable or runtime specification. + * + * Supports multiple formats: + * - 'qwen' -> native binary (auto-detected from PATH) + * - '/path/to/qwen' -> native binary (explicit path) + * - '/path/to/cli.js' -> Node.js bundle (default for .js files) + * - '/path/to/index.ts' -> TypeScript source (requires tsx) + * - 'bun:/path/to/cli.js' -> Force Bun runtime + * - 'node:/path/to/cli.js' -> Force Node.js runtime + * - 'tsx:/path/to/index.ts' -> Force tsx runtime + * - 'deno:/path/to/cli.ts' -> Force Deno runtime + * + * If not provided, the SDK will auto-detect the native binary in this order: + * 1. QWEN_CODE_CLI_PATH environment variable + * 2. ~/.volta/bin/qwen + * 3. ~/.npm-global/bin/qwen + * 4. /usr/local/bin/qwen + * 5. ~/.local/bin/qwen + * 6. ~/node_modules/.bin/qwen + * 7. ~/.yarn/bin/qwen + * + * The .ts files are only supported for debugging purposes. + * + * @example 'qwen' + * @example '/usr/local/bin/qwen' + * @example 'tsx:/path/to/packages/cli/src/index.ts' + */ + pathToQwenExecutable?: string; + + /** + * Environment variables to pass to the Qwen CLI process. + * These variables will be merged with the current process environment. + */ + env?: Record; + + /** + * Alias for `approval-mode` command line argument. + * Behaves slightly differently from the command line argument. + * Permission mode controlling how the CLI handles tool usage and file operations **in non-interactive mode**. + * - 'default': Automatically deny all write-like tools(edit, write_file, etc.) and dangers commands. + * - 'plan': Shows a plan before executing operations + * - 'auto-edit': Automatically applies edits without confirmation + * - 'yolo': Executes all operations without prompting + * @default 'default' + */ + permissionMode?: 'default' | 'plan' | 'auto-edit' | 'yolo'; + + /** + * Custom permission handler for tool usage. + * This function is called when the SDK needs to determine if a tool should be allowed. + * Use this with `permissionMode` to gain more control over the tool usage. + * TODO: For now we don't support modifying the input. + */ + 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' } } } + */ + mcpServers?: Record; + + /** + * AbortController to cancel the query session. + * Call abortController.abort() to terminate the session and cleanup resources. + * Remember to handle the AbortError when the session is aborted. + */ + abortController?: AbortController; + + /** + * Enable debug mode for verbose logging. + * When true, additional diagnostic information will be output. + * Use this with `logLevel` to control the verbosity of the logs. + * @default false + */ + debug?: boolean; + + /** + * Custom handler for stderr output from the Qwen CLI process. + * Use this to capture and process error messages or diagnostic output. + */ + stderr?: (message: string) => void; + + /** + * Logging level for the SDK. + * Controls the verbosity of log messages output by the SDK. + * @default 'info' + */ + logLevel?: 'debug' | 'info' | 'warn' | 'error'; + + /** + * Maximum number of conversation turns before the session automatically terminates. + * A turn consists of a user message and an assistant response. + * @default -1 (unlimited) + */ + maxSessionTurns?: number; + + /** + * Equivalent to `tool.core` in settings.json. + * List of core tools to enable for the session. + * If specified, only these tools will be available to the AI. + * @example ['read_file', 'write_file', 'run_terminal_cmd'] + */ + coreTools?: string[]; + + /** + * Equivalent to `tool.exclude` in settings.json. + * List of tools to exclude from the session. + * These tools will not be available to the AI, even if they are core tools. + * @example ['run_terminal_cmd', 'delete_file'] + */ + excludeTools?: string[]; + + /** + * Authentication type for the AI service. + * - 'openai': Use OpenAI-compatible authentication + * - 'qwen-oauth': Use Qwen OAuth authentication + * + * Though we support 'qwen-oauth', it's not recommended to use it in the SDK. + * Because the credentials are stored in `~/.qwen` and may need to refresh periodically. + */ + authType?: 'openai' | 'qwen-oauth'; + + /** + * Configuration for subagents that can be invoked during the session. + * Subagents are specialized AI agents that can handle specific tasks or domains. + * The invocation is marked as a `task` tool use with the name of agent and a tool_use_id. + * The tool use of these agent is marked with the parent_tool_use_id of the `task` tool use. + */ + agents?: SubagentConfig[]; + + /** + * Include partial messages in the response stream. + * When true, the SDK will emit incomplete messages as they are being generated, + * allowing for real-time streaming of the AI's response. + * @default false + */ + includePartialMessages?: boolean; +} diff --git a/packages/sdk-typescript/src/utils/jsonLines.ts b/packages/sdk-typescript/src/utils/jsonLines.ts index 6d1bd090..8af8ec6a 100644 --- a/packages/sdk-typescript/src/utils/jsonLines.ts +++ b/packages/sdk-typescript/src/utils/jsonLines.ts @@ -1,3 +1,5 @@ +import { SdkLogger } from './logger.js'; + export function serializeJsonLine(message: unknown): string { try { return JSON.stringify(message) + '\n'; @@ -12,11 +14,12 @@ export function parseJsonLineSafe( line: string, context = 'JsonLines', ): unknown | null { + const logger = SdkLogger.createLogger(context); try { return JSON.parse(line); } catch (error) { - console.warn( - `[${context}] Failed to parse JSON line, skipping:`, + logger.warn( + 'Failed to parse JSON line, skipping:', line.substring(0, 100), error instanceof Error ? error.message : String(error), ); @@ -37,6 +40,7 @@ export async function* parseJsonLinesStream( lines: AsyncIterable, context = 'JsonLines', ): AsyncGenerator { + const logger = SdkLogger.createLogger(context); for await (const line of lines) { if (line.trim().length === 0) { continue; @@ -49,8 +53,8 @@ export async function* parseJsonLinesStream( } if (!isValidMessage(message)) { - console.warn( - `[${context}] Invalid message structure (missing 'type' field), skipping:`, + logger.warn( + "Invalid message structure (missing 'type' field), skipping:", line.substring(0, 100), ); continue; diff --git a/packages/sdk-typescript/src/utils/logger.ts b/packages/sdk-typescript/src/utils/logger.ts new file mode 100644 index 00000000..afb7a495 --- /dev/null +++ b/packages/sdk-typescript/src/utils/logger.ts @@ -0,0 +1,147 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LoggerConfig { + debug?: boolean; + stderr?: (message: string) => void; + logLevel?: LogLevel; +} + +export interface ScopedLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} + +const LOG_LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +export class SdkLogger { + private static config: LoggerConfig = {}; + private static effectiveLevel: LogLevel = 'info'; + + static configure(config: LoggerConfig): void { + this.config = config; + this.effectiveLevel = this.determineLogLevel(); + } + + private static determineLogLevel(): LogLevel { + if (this.config.logLevel) { + return this.config.logLevel; + } + + if (this.config.debug) { + return 'debug'; + } + + const envLevel = process.env['DEBUG_QWEN_CODE_SDK_LEVEL']; + if (envLevel && this.isValidLogLevel(envLevel)) { + return envLevel as LogLevel; + } + + if (process.env['DEBUG_QWEN_CODE_SDK']) { + return 'debug'; + } + + return 'info'; + } + + private static isValidLogLevel(level: string): boolean { + return ['debug', 'info', 'warn', 'error'].includes(level); + } + + private static shouldLog(level: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[this.effectiveLevel]; + } + + private static formatTimestamp(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + } + + private static formatMessage( + level: LogLevel, + scope: string, + message: string, + args: unknown[], + ): string { + const timestamp = this.formatTimestamp(); + const levelStr = `[${level.toUpperCase()}]`.padEnd(7); + let fullMessage = `${timestamp} ${levelStr} [${scope}] ${message}`; + + if (args.length > 0) { + const argsStr = args + .map((arg) => { + if (typeof arg === 'string') { + return arg; + } + if (arg instanceof Error) { + return arg.message; + } + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + }) + .join(' '); + fullMessage += ` ${argsStr}`; + } + + return fullMessage; + } + + private static log( + level: LogLevel, + scope: string, + message: string, + args: unknown[], + ): void { + if (!this.shouldLog(level)) { + return; + } + + const formattedMessage = this.formatMessage(level, scope, message, args); + + if (this.config.stderr) { + this.config.stderr(formattedMessage); + } else { + if (level === 'warn' || level === 'error') { + process.stderr.write(formattedMessage + '\n'); + } else { + process.stdout.write(formattedMessage + '\n'); + } + } + } + + static createLogger(scope: string): ScopedLogger { + return { + debug: (message: string, ...args: unknown[]) => { + this.log('debug', scope, message, args); + }, + info: (message: string, ...args: unknown[]) => { + this.log('info', scope, message, args); + }, + warn: (message: string, ...args: unknown[]) => { + this.log('warn', scope, message, args); + }, + error: (message: string, ...args: unknown[]) => { + this.log('error', scope, message, args); + }, + }; + } + + static getEffectiveLevel(): LogLevel { + return this.effectiveLevel; + } +} diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts index 93c1ecc8..2052b6b2 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -39,7 +39,8 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'What is 2 + 2? Just give me the number.', options: { ...SHARED_TEST_OPTIONS, - debug: false, + debug: true, + logLevel: 'debug', }, }); diff --git a/packages/sdk-typescript/tsconfig.build.json b/packages/sdk-typescript/tsconfig.build.json new file mode 100644 index 00000000..53e1cea0 --- /dev/null +++ b/packages/sdk-typescript/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "emitDeclarationOnly": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"] +} From d76341b8d880b7a96d4003f37742574bfa8bcd9c Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 26 Nov 2025 22:14:37 +0800 Subject: [PATCH 09/38] chore: keep comments for queryOptions --- packages/sdk-typescript/scripts/build.js | 2 +- packages/sdk-typescript/tsconfig.build.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 055584a5..e78f161a 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -24,7 +24,7 @@ execSync('tsc --project tsconfig.build.json', { try { execSync( - 'npx dts-bundle-generator -o dist/index.d.ts src/index.ts --no-check', + 'npx dts-bundle-generator --project tsconfig.build.json -o dist/index.d.ts src/index.ts --no-check', { stdio: 'inherit', cwd: rootDir, diff --git a/packages/sdk-typescript/tsconfig.build.json b/packages/sdk-typescript/tsconfig.build.json index 53e1cea0..61dbca5b 100644 --- a/packages/sdk-typescript/tsconfig.build.json +++ b/packages/sdk-typescript/tsconfig.build.json @@ -7,7 +7,8 @@ "declaration": true, "declarationMap": false, "sourceMap": false, - "emitDeclarationOnly": true + "emitDeclarationOnly": true, + "removeComments": false }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"] From 638b7bb466d7a10b7faf035ef344f0d6826a69fe Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 27 Nov 2025 11:44:57 +0800 Subject: [PATCH 10/38] feat: add `allowedTools` support --- .../src/transport/ProcessTransport.ts | 4 + .../src/types/queryOptionsSchema.ts | 1 + packages/sdk-typescript/src/types/types.ts | 93 ++- .../test/e2e/permission-control.test.ts | 636 ++++++++++++++++++ ...control.test.ts => system-control.test.ts} | 163 +++-- 5 files changed, 828 insertions(+), 69 deletions(-) rename packages/sdk-typescript/test/e2e/{control.test.ts => system-control.test.ts} (57%) diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index d473160c..c54d9104 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -161,6 +161,10 @@ export class ProcessTransport implements Transport { args.push('--exclude-tools', this.options.excludeTools.join(',')); } + if (this.options.allowedTools && this.options.allowedTools.length > 0) { + args.push('--allowed-tools', this.options.allowedTools.join(',')); + } + if (this.options.authType) { args.push('--auth-type', this.options.authType); } diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index c4629357..579445cf 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -61,6 +61,7 @@ export const QueryOptionsSchema = z maxSessionTurns: z.number().optional(), coreTools: z.array(z.string()).optional(), excludeTools: z.array(z.string()).optional(), + allowedTools: z.array(z.string()).optional(), authType: z.enum(['openai', 'qwen-oauth']).optional(), agents: z .array( diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index 0c23581b..a3f6cd03 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -34,6 +34,7 @@ export type TransportOptions = { maxSessionTurns?: number; coreTools?: string[]; excludeTools?: string[]; + allowedTools?: string[]; authType?: string; includePartialMessages?: boolean; }; @@ -125,22 +126,50 @@ export interface QueryOptions { env?: Record; /** - * Alias for `approval-mode` command line argument. - * Behaves slightly differently from the command line argument. - * Permission mode controlling how the CLI handles tool usage and file operations **in non-interactive mode**. - * - 'default': Automatically deny all write-like tools(edit, write_file, etc.) and dangers commands. - * - 'plan': Shows a plan before executing operations - * - 'auto-edit': Automatically applies edits without confirmation - * - 'yolo': Executes all operations without prompting + * Permission mode controlling how the SDK handles tool execution approval. + * + * - 'default': Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. + * Read-only tools execute without confirmation. + * - 'plan': Blocks all write tools, instructing AI to present a plan first. + * Read-only tools execute normally. + * - 'auto-edit': Auto-approve edit tools (edit, write_file) while other tools require confirmation. + * - 'yolo': All tools execute automatically without confirmation. + * + * **Priority Chain (highest to lowest):** + * 1. `excludeTools` - Blocks tools completely (returns permission error) + * 2. `permissionMode: 'plan'` - Blocks non-read-only tools (except exit_plan_mode) + * 3. `permissionMode: 'yolo'` - Auto-approves all tools + * 4. `allowedTools` - Auto-approves matching tools + * 5. `canUseTool` callback - Custom approval logic + * 6. Default behavior - Auto-deny in SDK mode + * * @default 'default' + * @see canUseTool For custom permission handling + * @see allowedTools For auto-approving specific tools + * @see excludeTools For blocking specific tools */ permissionMode?: 'default' | 'plan' | 'auto-edit' | 'yolo'; /** - * Custom permission handler for tool usage. - * This function is called when the SDK needs to determine if a tool should be allowed. - * Use this with `permissionMode` to gain more control over the tool usage. - * TODO: For now we don't support modifying the input. + * Custom permission handler for tool execution approval. + * + * This callback is invoked when a tool requires confirmation and allows you to + * programmatically approve or deny execution. It acts as a fallback after + * `allowedTools` check but before default denial. + * + * **When is this called?** + * - Only for tools requiring confirmation (write operations, shell commands, etc.) + * - After `excludeTools` and `allowedTools` checks + * - Not called in 'yolo' mode or 'plan' mode + * - Not called for tools already in `allowedTools` + * + * **Usage with permissionMode:** + * - 'default': Invoked for all write tools not in `allowedTools`; if not provided, auto-denied. + * - 'auto-edit': Invoked for non-edit tools (edit/write_file auto-approved); if not provided, auto-denied. + * - 'plan': Not invoked; write tools are blocked by plan mode. + * - 'yolo': Not invoked; all tools auto-approved. + * + * @see allowedTools For auto-approving tools without callback */ canUseTool?: CanUseTool; @@ -197,11 +226,49 @@ export interface QueryOptions { /** * Equivalent to `tool.exclude` in settings.json. * List of tools to exclude from the session. - * These tools will not be available to the AI, even if they are core tools. - * @example ['run_terminal_cmd', 'delete_file'] + * + * **Behavior:** + * - Excluded tools return a permission error immediately when invoked + * - Takes highest priority - overrides all other permission settings + * - Tools will not be available to the AI, even if in `coreTools` or `allowedTools` + * + * **Pattern matching:** + * - Tool name: `'write_file'`, `'run_shell_command'` + * - Tool class: `'WriteTool'`, `'ShellTool'` + * - Shell command prefix: `'ShellTool(git commit)'` (matches commands starting with "git commit") + * + * @example ['run_terminal_cmd', 'delete_file', 'ShellTool(rm )'] + * @see allowedTools For allowing specific tools */ excludeTools?: string[]; + /** + * Equivalent to `tool.allowed` in settings.json. + * List of tools that are allowed to run without confirmation. + * + * **Behavior:** + * - Matching tools bypass `canUseTool` callback and execute automatically + * - Only applies when tool requires confirmation (write operations, shell commands) + * - Checked after `excludeTools` but before `canUseTool` callback + * - Does not override `permissionMode: 'plan'` (plan mode blocks all write tools) + * - Has no effect in `permissionMode: 'yolo'` (already auto-approved) + * + * **Pattern matching:** + * - Tool name: `'write_file'`, `'run_shell_command'` + * - Tool class: `'WriteTool'`, `'ShellTool'` + * - Shell command prefix: `'ShellTool(git status)'` (matches commands starting with "git status") + * + * **Use cases:** + * - Auto-approve safe shell commands: `['ShellTool(git status)', 'ShellTool(ls)']` + * - Auto-approve specific tools: `['write_file', 'edit']` + * - Combine with `permissionMode: 'default'` to selectively auto-approve tools + * + * @example ['read_file', 'ShellTool(git status)', 'ShellTool(npm test)'] + * @see canUseTool For custom approval logic + * @see excludeTools For blocking specific tools + */ + allowedTools?: string[]; + /** * Authentication type for the AI service. * - 'openai': Use OpenAI-compatible authentication diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/packages/sdk-typescript/test/e2e/permission-control.test.ts index afcef8b1..15770608 100644 --- a/packages/sdk-typescript/test/e2e/permission-control.test.ts +++ b/packages/sdk-typescript/test/e2e/permission-control.test.ts @@ -673,4 +673,640 @@ describe('Permission Control (E2E)', () => { } }); }); + + describe('ApprovalMode behavior tests', () => { + describe('default mode', () => { + it( + 'should auto-deny tools requiring confirmation without canUseTool callback', + async () => { + const q = query({ + prompt: + 'Create a file named test-default-deny.txt with content "hello"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + // No canUseTool callback provided + }, + }); + + try { + let hasToolResult = false; + let hasErrorInResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + hasToolResult = true; + // Check if the result contains an error about permission + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + (toolResult.content.includes('permission') || + toolResult.content.includes('declined')) + ) { + hasErrorInResult = true; + } + } + } + } + } + + // In default mode without canUseTool, tools should be denied + expect(hasToolResult).toBe(true); + expect(hasErrorInResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow tools when canUseTool returns allow', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: + 'Create a file named test-default-allow.txt with content "world"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasSuccessfulToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + // Check if the result is successful (not an error) + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + !toolResult.content.includes('permission') && + !toolResult.content.includes('declined') + ) { + hasSuccessfulToolResult = true; + } + } + } + } + } + + expect(callbackInvoked).toBe(true); + expect(hasSuccessfulToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute read-only tools without confirmation', + async () => { + const q = query({ + prompt: 'List files in the current directory', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'default', + cwd: '/tmp', + // No canUseTool callback - read-only tools should still work + }, + }); + + try { + let hasToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult) { + hasToolResult = true; + } + } + } + } + + expect(hasToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('yolo mode', () => { + it( + 'should auto-approve all tools without canUseTool callback', + async () => { + const q = query({ + prompt: + 'Create a file named test-yolo.txt with content "yolo mode"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: '/tmp', + // No canUseTool callback - tools should still execute + }, + }); + + try { + let hasSuccessfulToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + // Check if the result is successful (not a permission error) + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + !toolResult.content.includes('permission') && + !toolResult.content.includes('declined') + ) { + hasSuccessfulToolResult = true; + } + } + } + } + } + + expect(hasSuccessfulToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should not invoke canUseTool callback in yolo mode', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test-yolo-no-callback.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult) { + hasToolResult = true; + } + } + } + } + + expect(hasToolResult).toBe(true); + // canUseTool should not be invoked in yolo mode + expect(callbackInvoked).toBe(false); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute dangerous commands without confirmation', + async () => { + const q = query({ + prompt: 'Run command: echo "dangerous operation"', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'yolo', + cwd: '/tmp', + }, + }); + + try { + let hasCommandResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + hasCommandResult = true; + } + } + } + } + + expect(hasCommandResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('plan mode', () => { + it( + 'should block non-read-only tools and return plan mode error', + async () => { + const q = query({ + prompt: 'Create a file named test-plan.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: '/tmp', + }, + }); + + try { + let hasBlockedToolCall = false; + let hasPlanModeMessage = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + hasBlockedToolCall = true; + // Check for plan mode specific error message + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + (toolResult.content.includes('Plan mode') || + toolResult.content.includes('plan mode')) + ) { + hasPlanModeMessage = true; + } + } + } + } + } + + expect(hasBlockedToolCall).toBe(true); + expect(hasPlanModeMessage).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow read-only tools in plan mode', + async () => { + const q = query({ + prompt: 'List files in /tmp directory', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: '/tmp', + }, + }); + + try { + let hasSuccessfulToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + // Check if the result is successful (not blocked by plan mode) + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + !toolResult.content.includes('Plan mode') + ) { + hasSuccessfulToolResult = true; + } + } + } + } + } + + expect(hasSuccessfulToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should block tools even with canUseTool callback in plan mode', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test-plan-callback.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasPlanModeBlock = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if ( + toolResult && + 'content' in toolResult && + typeof toolResult.content === 'string' && + toolResult.content.includes('Plan mode') + ) { + hasPlanModeBlock = true; + } + } + } + } + + // Plan mode should block tools before canUseTool is invoked + expect(hasPlanModeBlock).toBe(true); + // canUseTool should not be invoked for blocked tools in plan mode + expect(callbackInvoked).toBe(false); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('auto-edit mode', () => { + it( + 'should behave like default mode without canUseTool callback', + async () => { + const q = query({ + prompt: 'Create a file named test-auto-edit.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'auto-edit', + cwd: '/tmp', + // No canUseTool callback + }, + }); + + try { + let hasToolResult = false; + let hasDeniedTool = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + hasToolResult = true; + // Check if the tool was denied + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + (toolResult.content.includes('permission') || + toolResult.content.includes('declined')) + ) { + hasDeniedTool = true; + } + } + } + } + } + + expect(hasToolResult).toBe(true); + expect(hasDeniedTool).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow tools when canUseTool returns allow', + async () => { + let callbackInvoked = false; + + const q = query({ + prompt: 'Create a file named test-auto-edit-allow.txt', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'auto-edit', + cwd: '/tmp', + canUseTool: async (toolName, input) => { + callbackInvoked = true; + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let hasSuccessfulToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult && 'tool_use_id' in toolResult) { + // Check if the result is successful + if ( + 'content' in toolResult && + typeof toolResult.content === 'string' && + !toolResult.content.includes('permission') && + !toolResult.content.includes('declined') + ) { + hasSuccessfulToolResult = true; + } + } + } + } + } + + expect(callbackInvoked).toBe(true); + expect(hasSuccessfulToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should execute read-only tools without confirmation', + async () => { + const q = query({ + prompt: 'Read the contents of /etc/hosts file', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'auto-edit', + // No canUseTool callback - read-only tools should still work + }, + }); + + try { + let hasToolResult = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if (toolResult) { + hasToolResult = true; + } + } + } + } + + expect(hasToolResult).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('mode comparison tests', () => { + it( + 'should demonstrate different behaviors across all modes for write operations', + async () => { + const modes: Array<'default' | 'plan' | 'auto-edit' | 'yolo'> = [ + 'default', + 'plan', + 'auto-edit', + 'yolo', + ]; + const results: Record = {}; + + for (const mode of modes) { + const q = query({ + prompt: `Create a file named test-${mode}.txt`, + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: mode, + cwd: '/tmp', + canUseTool: + mode === 'yolo' + ? undefined + : async (toolName, input) => { + return { + behavior: 'allow', + updatedInput: input, + }; + }, + }, + }); + + try { + let toolExecuted = false; + + for await (const message of q) { + if (isCLIUserMessage(message)) { + if (Array.isArray(message.message.content)) { + const toolResult = message.message.content.find( + (block) => block.type === 'tool_result', + ); + if ( + toolResult && + 'content' in toolResult && + typeof toolResult.content === 'string' + ) { + // Check if tool executed successfully (not blocked or denied) + if ( + !toolResult.content.includes('Plan mode') && + !toolResult.content.includes('permission') && + !toolResult.content.includes('declined') + ) { + toolExecuted = true; + } + } + } + } + } + + results[mode] = toolExecuted; + } finally { + await q.close(); + } + } + + // Verify expected behaviors + expect(results['default']).toBe(true); // Allowed via canUseTool + expect(results['plan']).toBe(false); // Blocked by plan mode + expect(results['auto-edit']).toBe(true); // Allowed via canUseTool + expect(results['yolo']).toBe(true); // Auto-approved + }, + TEST_TIMEOUT * 4, + ); + }); + }); }); diff --git a/packages/sdk-typescript/test/e2e/control.test.ts b/packages/sdk-typescript/test/e2e/system-control.test.ts similarity index 57% rename from packages/sdk-typescript/test/e2e/control.test.ts rename to packages/sdk-typescript/test/e2e/system-control.test.ts index ea7ecef7..373f88e7 100644 --- a/packages/sdk-typescript/test/e2e/control.test.ts +++ b/packages/sdk-typescript/test/e2e/system-control.test.ts @@ -1,8 +1,12 @@ +/** + * E2E tests for system controller features: + * - setModel API for dynamic model switching + */ + import { describe, it, expect } from 'vitest'; import { query } from '../../src/index.js'; import { isCLIAssistantMessage, - isCLIResultMessage, isCLISystemMessage, type CLIUserMessage, } from '../../src/types/protocol.js'; @@ -16,7 +20,7 @@ const SHARED_TEST_OPTIONS = { /** * Factory function that creates a streaming input with a control point. * After the first message is yielded, the generator waits for a resume signal, - * allowing the test code to call query instance methods like setModel or setPermissionMode. + * allowing the test code to call query instance methods like setModel. * * @param firstMessage - The first user message to send * @param secondMessage - The second user message to send after control operations @@ -73,9 +77,9 @@ function createStreamingInputWithControlPoint( return { generator, resume }; } -describe('Control Request/Response (E2E)', () => { - describe('System Controller Scope', () => { - it('should set model via control request during streaming input', async () => { +describe('System Control (E2E)', () => { + describe('setModel API', () => { + it('should change model dynamically during streaming input', async () => { const { generator, resume } = createStreamingInputWithControlPoint( 'Tell me the model name.', 'Tell me the model name now again.', @@ -164,50 +168,77 @@ describe('Control Request/Response (E2E)', () => { await q.close(); } }); - }); - describe('Permission Controller Scope', () => { - it('should set permission mode via control request during streaming input', async () => { - const { generator, resume } = createStreamingInputWithControlPoint( - 'What is 1 + 1?', - 'What is 2 + 2?', - ); + it('should handle multiple model changes in sequence', async () => { + const sessionId = crypto.randomUUID(); + let resumeResolve1: (() => void) | null = null; + let resumeResolve2: (() => void) | null = null; + const resumePromise1 = new Promise((resolve) => { + resumeResolve1 = resolve; + }); + const resumePromise2 = new Promise((resolve) => { + resumeResolve2 = resolve; + }); + + const generator = (async function* () { + yield { + type: 'user', + session_id: sessionId, + message: { role: 'user', content: 'First message' }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + await resumePromise1; + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { role: 'user', content: 'Second message' }, + parent_tool_use_id: null, + } as CLIUserMessage; + + await new Promise((resolve) => setTimeout(resolve, 200)); + await resumePromise2; + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sessionId, + message: { role: 'user', content: 'Third message' }, + parent_tool_use_id: null, + } as CLIUserMessage; + })(); const q = query({ prompt: generator, options: { - pathToQwenExecutable: TEST_CLI_PATH, - permissionMode: 'default', + ...SHARED_TEST_OPTIONS, + model: 'qwen3-max', debug: false, }, }); try { - const resolvers: { - first?: () => void; - second?: () => void; - } = {}; - const firstResponsePromise = new Promise((resolve) => { - resolvers.first = resolve; - }); - const secondResponsePromise = new Promise((resolve) => { - resolvers.second = resolve; - }); + const systemMessages: Array<{ model?: string }> = []; + let responseCount = 0; + const resolvers: Array<() => void> = []; + const responsePromises = [ + new Promise((resolve) => resolvers.push(resolve)), + new Promise((resolve) => resolvers.push(resolve)), + new Promise((resolve) => resolvers.push(resolve)), + ]; - let firstResponseReceived = false; - let permissionModeChanged = false; - let secondResponseReceived = false; - - // Consume messages in a single loop (async () => { for await (const message of q) { - if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { - if (!firstResponseReceived) { - firstResponseReceived = true; - resolvers.first?.(); - } else if (!secondResponseReceived) { - secondResponseReceived = true; - resolvers.second?.(); + if (isCLISystemMessage(message)) { + systemMessages.push({ model: message.model }); + } + if (isCLIAssistantMessage(message)) { + if (responseCount < resolvers.length) { + resolvers[responseCount]?.(); + responseCount++; } } } @@ -215,40 +246,60 @@ describe('Control Request/Response (E2E)', () => { // Wait for first response await Promise.race([ - firstResponsePromise, + responsePromises[0], new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timeout waiting for first response')), - 10000, - ), + setTimeout(() => reject(new Error('Timeout 1')), 10000), ), ]); - expect(firstResponseReceived).toBe(true); - - // Perform control operation: set permission mode - await q.setPermissionMode('yolo'); - permissionModeChanged = true; - - // Resume the input stream - resume(); + // First model change + await q.setModel('qwen3-turbo'); + resumeResolve1?.(); // Wait for second response await Promise.race([ - secondResponsePromise, + responsePromises[1], new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timeout waiting for second response')), - 10000, - ), + setTimeout(() => reject(new Error('Timeout 2')), 10000), ), ]); - expect(permissionModeChanged).toBe(true); - expect(secondResponseReceived).toBe(true); + // Second model change + await q.setModel('qwen3-vl-plus'); + resumeResolve2?.(); + + // Wait for third response + await Promise.race([ + responsePromises[2], + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout 3')), 10000), + ), + ]); + + // Verify we received system messages for each model + expect(systemMessages.length).toBeGreaterThanOrEqual(3); + expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']); + expect(systemMessages[1].model).toBe('qwen3-turbo'); + expect(systemMessages[2].model).toBe('qwen3-vl-plus'); } finally { await q.close(); } }); + + it('should throw error when setModel is called on closed query', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + model: 'qwen3-max', + }, + }); + + await q.close(); + + await expect(q.setModel('qwen3-turbo')).rejects.toThrow( + 'Query is closed', + ); + }); }); }); From 56957a687b36968b78f247c295621d5ad3f6e81b Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 27 Nov 2025 14:50:40 +0800 Subject: [PATCH 11/38] refactor: rename ambiguous exported types --- eslint.config.js | 1 + packages/sdk-typescript/src/index.ts | 22 +- packages/sdk-typescript/src/query/Query.ts | 46 +- .../sdk-typescript/src/query/createQuery.ts | 10 +- packages/sdk-typescript/src/types/protocol.ts | 40 +- .../sdk-typescript/test/e2e/globalSetup.ts | 9 +- .../test/e2e/mcp-server.test.ts | 323 ++----- .../test/e2e/multi-turn.test.ts | 108 +-- .../test/e2e/permission-control.test.ts | 52 +- .../test/e2e/single-turn.test.ts | 116 ++- .../sdk-typescript/test/e2e/subagents.test.ts | 182 ++-- .../test/e2e/system-control.test.ts | 26 +- .../sdk-typescript/test/e2e/test-helper.ts | 829 ++++++++++++++++++ .../sdk-typescript/test/unit/Query.test.ts | 38 +- 14 files changed, 1188 insertions(+), 614 deletions(-) create mode 100644 packages/sdk-typescript/test/e2e/test-helper.ts diff --git a/eslint.config.js b/eslint.config.js index e477d95f..13a3d1c3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,7 @@ export default tseslint.config( 'bundle/**', 'package/bundle/**', '.integration-tests/**', + 'packages/**/.integration-test/**', 'dist/**', ], }, diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 5992c6c5..f8bf81c5 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -12,20 +12,20 @@ export type { ThinkingBlock, ToolUseBlock, ToolResultBlock, - CLIUserMessage, - CLIAssistantMessage, - CLISystemMessage, - CLIResultMessage, - CLIPartialAssistantMessage, - CLIMessage, + SDKUserMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKResultMessage, + SDKPartialAssistantMessage, + SDKMessage, } from './types/protocol.js'; export { - isCLIUserMessage, - isCLIAssistantMessage, - isCLISystemMessage, - isCLIResultMessage, - isCLIPartialAssistantMessage, + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, } from './types/protocol.js'; export type { diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index de4c4852..d34d6fa4 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -13,19 +13,19 @@ const STREAM_CLOSE_TIMEOUT = 10000; import { randomUUID } from 'node:crypto'; import { SdkLogger } from '../utils/logger.js'; import type { - CLIMessage, - CLIUserMessage, + SDKMessage, + SDKUserMessage, CLIControlRequest, CLIControlResponse, ControlCancelRequest, PermissionSuggestion, } from '../types/protocol.js'; import { - isCLIUserMessage, - isCLIAssistantMessage, - isCLISystemMessage, - isCLIResultMessage, - isCLIPartialAssistantMessage, + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, isControlRequest, isControlResponse, isControlCancel, @@ -52,12 +52,12 @@ interface TransportWithEndInput extends Transport { const logger = SdkLogger.createLogger('Query'); -export class Query implements AsyncIterable { +export class Query implements AsyncIterable { private transport: Transport; private options: QueryOptions; private sessionId: string; - private inputStream: Stream; - private sdkMessages: AsyncGenerator; + private inputStream: Stream; + private sdkMessages: AsyncGenerator; private abortController: AbortController; private pendingControlRequests: Map = new Map(); @@ -79,7 +79,7 @@ export class Query implements AsyncIterable { this.transport = transport; this.options = options; this.sessionId = randomUUID(); - this.inputStream = new Stream(); + this.inputStream = new Stream(); this.abortController = options.abortController ?? new AbortController(); this.isSingleTurn = singleTurn; @@ -187,7 +187,7 @@ export class Query implements AsyncIterable { return; } - if (isCLISystemMessage(message)) { + if (isSDKSystemMessage(message)) { /** * SystemMessage contains session info (cwd, tools, model, etc.) * that should be passed to user. @@ -196,7 +196,7 @@ export class Query implements AsyncIterable { return; } - if (isCLIResultMessage(message)) { + if (isSDKResultMessage(message)) { if (this.firstResultReceivedResolve) { this.firstResultReceivedResolve(); } @@ -212,16 +212,16 @@ export class Query implements AsyncIterable { } if ( - isCLIAssistantMessage(message) || - isCLIUserMessage(message) || - isCLIPartialAssistantMessage(message) + isSDKAssistantMessage(message) || + isSDKUserMessage(message) || + isSDKPartialAssistantMessage(message) ) { this.inputStream.enqueue(message); return; } logger.warn('Unknown message type:', message); - this.inputStream.enqueue(message as CLIMessage); + this.inputStream.enqueue(message as SDKMessage); } private async handleControlRequest( @@ -560,29 +560,29 @@ export class Query implements AsyncIterable { logger.info('Query closed'); } - private async *readSdkMessages(): AsyncGenerator { + private async *readSdkMessages(): AsyncGenerator { for await (const message of this.inputStream) { yield message; } } - async next(...args: [] | [unknown]): Promise> { + async next(...args: [] | [unknown]): Promise> { return this.sdkMessages.next(...args); } - async return(value?: unknown): Promise> { + async return(value?: unknown): Promise> { return this.sdkMessages.return(value); } - async throw(e?: unknown): Promise> { + async throw(e?: unknown): Promise> { return this.sdkMessages.throw(e); } - [Symbol.asyncIterator](): AsyncIterator { + [Symbol.asyncIterator](): AsyncIterator { return this.sdkMessages; } - async streamInput(messages: AsyncIterable): Promise { + async streamInput(messages: AsyncIterable): Promise { if (this.closed) { throw new Error('Query is closed'); } diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 71fd6e9b..2b39dafa 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -2,7 +2,7 @@ * Factory function for creating Query instances. */ -import type { CLIUserMessage } from '../types/protocol.js'; +import type { SDKUserMessage } from '../types/protocol.js'; import { serializeJsonLine } from '../utils/jsonLines.js'; import { ProcessTransport } from '../transport/ProcessTransport.js'; import { parseExecutableSpec } from '../utils/cliPath.js'; @@ -22,11 +22,11 @@ export function query({ /** * The prompt to send to the Qwen Code CLI process. * - `string` for single-turn query, - * - `AsyncIterable` for multi-turn query. + * - `AsyncIterable` for multi-turn query. * * The transport will remain open until the prompt is done. */ - prompt: string | AsyncIterable; + prompt: string | AsyncIterable; /** * Configuration options for the query. */ @@ -67,7 +67,7 @@ export function query({ if (isSingleTurn) { const stringPrompt = prompt as string; - const message: CLIUserMessage = { + const message: SDKUserMessage = { type: 'user', session_id: queryInstance.getSessionId(), message: { @@ -87,7 +87,7 @@ export function query({ })(); } else { queryInstance - .streamInput(prompt as AsyncIterable) + .streamInput(prompt as AsyncIterable) .catch((err) => { logger.error('Error streaming input:', err); }); diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 2f1f9fe9..6db627e3 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -89,7 +89,7 @@ export interface APIAssistantMessage { usage: Usage; } -export interface CLIUserMessage { +export interface SDKUserMessage { type: 'user'; uuid?: string; session_id: string; @@ -98,7 +98,7 @@ export interface CLIUserMessage { options?: Record; } -export interface CLIAssistantMessage { +export interface SDKAssistantMessage { type: 'assistant'; uuid: string; session_id: string; @@ -106,7 +106,7 @@ export interface CLIAssistantMessage { parent_tool_use_id: string | null; } -export interface CLISystemMessage { +export interface SDKSystemMessage { type: 'system'; subtype: string; uuid: string; @@ -133,7 +133,7 @@ export interface CLISystemMessage { }; } -export interface CLIResultMessageSuccess { +export interface SDKResultMessageSuccess { type: 'result'; subtype: 'success'; uuid: string; @@ -149,7 +149,7 @@ export interface CLIResultMessageSuccess { [key: string]: unknown; } -export interface CLIResultMessageError { +export interface SDKResultMessageError { type: 'result'; subtype: 'error_max_turns' | 'error_during_execution'; uuid: string; @@ -169,7 +169,7 @@ export interface CLIResultMessageError { [key: string]: unknown; } -export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError; +export type SDKResultMessage = SDKResultMessageSuccess | SDKResultMessageError; export interface MessageStartStreamEvent { type: 'message_start'; @@ -222,7 +222,7 @@ export type StreamEvent = | ContentBlockStopEvent | MessageStopStreamEvent; -export interface CLIPartialAssistantMessage { +export interface SDKPartialAssistantMessage { type: 'stream_event'; uuid: string; session_id: string; @@ -389,22 +389,22 @@ export type ControlMessage = | ControlCancelRequest; /** - * Union of all CLI message types + * Union of all SDK message types */ -export type CLIMessage = - | CLIUserMessage - | CLIAssistantMessage - | CLISystemMessage - | CLIResultMessage - | CLIPartialAssistantMessage; +export type SDKMessage = + | SDKUserMessage + | SDKAssistantMessage + | SDKSystemMessage + | SDKResultMessage + | SDKPartialAssistantMessage; -export function isCLIUserMessage(msg: any): msg is CLIUserMessage { +export function isSDKUserMessage(msg: any): msg is SDKUserMessage { return ( msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg ); } -export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage { +export function isSDKAssistantMessage(msg: any): msg is SDKAssistantMessage { return ( msg && typeof msg === 'object' && @@ -416,7 +416,7 @@ export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage { ); } -export function isCLISystemMessage(msg: any): msg is CLISystemMessage { +export function isSDKSystemMessage(msg: any): msg is SDKSystemMessage { return ( msg && typeof msg === 'object' && @@ -427,7 +427,7 @@ export function isCLISystemMessage(msg: any): msg is CLISystemMessage { ); } -export function isCLIResultMessage(msg: any): msg is CLIResultMessage { +export function isSDKResultMessage(msg: any): msg is SDKResultMessage { return ( msg && typeof msg === 'object' && @@ -440,9 +440,9 @@ export function isCLIResultMessage(msg: any): msg is CLIResultMessage { ); } -export function isCLIPartialAssistantMessage( +export function isSDKPartialAssistantMessage( msg: any, -): msg is CLIPartialAssistantMessage { +): msg is SDKPartialAssistantMessage { return ( msg && typeof msg === 'object' && diff --git a/packages/sdk-typescript/test/e2e/globalSetup.ts b/packages/sdk-typescript/test/e2e/globalSetup.ts index 44e3e528..4f98b877 100644 --- a/packages/sdk-typescript/test/e2e/globalSetup.ts +++ b/packages/sdk-typescript/test/e2e/globalSetup.ts @@ -14,14 +14,15 @@ const e2eTestsDir = join(rootDir, '.integration-tests'); let runDir = ''; export async function setup() { - runDir = join(e2eTestsDir, `${Date.now()}`); + runDir = join(e2eTestsDir, `sdk-e2e-${Date.now()}`); await mkdir(runDir, { recursive: true }); // Clean up old test runs, but keep the latest few for debugging try { const testRuns = await readdir(e2eTestsDir); - if (testRuns.length > 5) { - const oldRuns = testRuns.sort().slice(0, testRuns.length - 5); + const sdkTestRuns = testRuns.filter((run) => run.startsWith('sdk-e2e-')); + if (sdkTestRuns.length > 5) { + const oldRuns = sdkTestRuns.sort().slice(0, sdkTestRuns.length - 5); await Promise.all( oldRuns.map((oldRun) => rm(join(e2eTestsDir, oldRun), { @@ -44,7 +45,7 @@ export async function setup() { } process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; - console.log(`\nE2E test output directory: ${runDir}`); + console.log(`\nSDK E2E test output directory: ${runDir}`); console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`); } diff --git a/packages/sdk-typescript/test/e2e/mcp-server.test.ts b/packages/sdk-typescript/test/e2e/mcp-server.test.ts index 6bb0f965..868fb959 100644 --- a/packages/sdk-typescript/test/e2e/mcp-server.test.ts +++ b/packages/sdk-typescript/test/e2e/mcp-server.test.ts @@ -9,234 +9,48 @@ * Tests that the SDK can properly interact with MCP servers configured in qwen-code */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIAssistantMessage, - isCLIResultMessage, - isCLISystemMessage, - isCLIUserMessage, - type TextBlock, - type ContentBlock, - type CLIMessage, + isSDKAssistantMessage, + isSDKResultMessage, + isSDKSystemMessage, + isSDKUserMessage, + type SDKMessage, type ToolUseBlock, - type CLISystemMessage, + type SDKSystemMessage, } from '../../src/types/protocol.js'; -import { writeFileSync, mkdirSync, chmodSync } from 'node:fs'; -import { join } from 'node:path'; - -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; -const E2E_TEST_FILE_DIR = process.env['E2E_TEST_FILE_DIR']!; +import { + SDKTestHelper, + createMCPServer, + extractText, + findToolUseBlocks, + createSharedTestOptions, +} from './test-helper.js'; const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, + ...createSharedTestOptions(), permissionMode: 'yolo' as const, }; -/** - * Helper to extract text from ContentBlock array - */ -function extractText(content: ContentBlock[]): string { - return content - .filter((block): block is TextBlock => block.type === 'text') - .map((block) => block.text) - .join(''); -} - -/** - * Minimal MCP server implementation that doesn't require external dependencies - * This implements the MCP protocol directly using Node.js built-ins - */ -const MCP_SERVER_SCRIPT = `#!/usr/bin/env node -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -const readline = require('readline'); -const fs = require('fs'); - -// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set) -const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true'; -function debug(msg) { - if (debugEnabled) { - fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`); - } -} - -debug('MCP server starting...'); - -// Simple JSON-RPC implementation for MCP -class SimpleJSONRPC { - constructor() { - this.handlers = new Map(); - this.rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false - }); - - this.rl.on('line', (line) => { - debug(\`Received line: \${line}\`); - try { - const message = JSON.parse(line); - debug(\`Parsed message: \${JSON.stringify(message)}\`); - this.handleMessage(message); - } catch (e) { - debug(\`Parse error: \${e.message}\`); - } - }); - } - - send(message) { - const msgStr = JSON.stringify(message); - debug(\`Sending message: \${msgStr}\`); - process.stdout.write(msgStr + '\\n'); - } - - async handleMessage(message) { - if (message.method && this.handlers.has(message.method)) { - try { - const result = await this.handlers.get(message.method)(message.params || {}); - if (message.id !== undefined) { - this.send({ - jsonrpc: '2.0', - id: message.id, - result - }); - } - } catch (error) { - if (message.id !== undefined) { - this.send({ - jsonrpc: '2.0', - id: message.id, - error: { - code: -32603, - message: error.message - } - }); - } - } - } else if (message.id !== undefined) { - this.send({ - jsonrpc: '2.0', - id: message.id, - error: { - code: -32601, - message: 'Method not found' - } - }); - } - } - - on(method, handler) { - this.handlers.set(method, handler); - } -} - -// Create MCP server -const rpc = new SimpleJSONRPC(); - -// Handle initialize -rpc.on('initialize', async (params) => { - debug('Handling initialize request'); - return { - protocolVersion: '2024-11-05', - capabilities: { - tools: {} - }, - serverInfo: { - name: 'test-math-server', - version: '1.0.0' - } - }; -}); - -// Handle tools/list -rpc.on('tools/list', async () => { - debug('Handling tools/list request'); - return { - tools: [ - { - name: 'add', - description: 'Add two numbers together', - inputSchema: { - type: 'object', - properties: { - a: { type: 'number', description: 'First number' }, - b: { type: 'number', description: 'Second number' } - }, - required: ['a', 'b'] - } - }, - { - name: 'multiply', - description: 'Multiply two numbers together', - inputSchema: { - type: 'object', - properties: { - a: { type: 'number', description: 'First number' }, - b: { type: 'number', description: 'Second number' } - }, - required: ['a', 'b'] - } - } - ] - }; -}); - -// Handle tools/call -rpc.on('tools/call', async (params) => { - debug(\`Handling tools/call request for tool: \${params.name}\`); - - if (params.name === 'add') { - const { a, b } = params.arguments; - return { - content: [{ - type: 'text', - text: String(a + b) - }] - }; - } - - if (params.name === 'multiply') { - const { a, b } = params.arguments; - return { - content: [{ - type: 'text', - text: String(a * b) - }] - }; - } - - throw new Error('Unknown tool: ' + params.name); -}); - -// Send initialization notification -rpc.send({ - jsonrpc: '2.0', - method: 'initialized' -}); -`; - describe('MCP Server Integration (E2E)', () => { - let testDir: string; + let helper: SDKTestHelper; let serverScriptPath: string; + let testDir: string; - beforeAll(() => { - // Use the centralized E2E test directory from globalSetup - testDir = join(E2E_TEST_FILE_DIR, 'mcp-server-test'); - mkdirSync(testDir, { recursive: true }); + beforeAll(async () => { + // Create isolated test environment using SDKTestHelper + helper = new SDKTestHelper(); + testDir = await helper.setup('mcp-server-integration'); - // Write MCP server script - serverScriptPath = join(testDir, 'mcp-server.cjs'); - writeFileSync(serverScriptPath, MCP_SERVER_SCRIPT); + // Create MCP server using the helper utility + const mcpServer = await createMCPServer(helper, 'math', 'test-math-server'); + serverScriptPath = mcpServer.scriptPath; + }); - // Make script executable on Unix-like systems - if (process.platform !== 'win32') { - chmodSync(serverScriptPath, 0o755); - } + afterAll(async () => { + // Cleanup test directory + await helper.cleanup(); }); describe('Basic MCP Tool Usage', () => { @@ -257,7 +71,7 @@ describe('MCP Server Integration (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; let foundToolUse = false; @@ -265,12 +79,9 @@ describe('MCP Server Integration (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { - const toolUseBlock = message.message.content.find( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); - if (toolUseBlock && toolUseBlock.name === 'add') { + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'add'); + if (toolUseBlocks.length > 0) { foundToolUse = true; } assistantText += extractText(message.message.content); @@ -285,8 +96,8 @@ describe('MCP Server Integration (E2E)', () => { // Validate successful completion const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { + expect(isSDKResultMessage(lastMessage)).toBe(true); + if (isSDKResultMessage(lastMessage)) { expect(lastMessage.subtype).toBe('success'); } } finally { @@ -311,7 +122,7 @@ describe('MCP Server Integration (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; let foundToolUse = false; @@ -319,12 +130,9 @@ describe('MCP Server Integration (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { - const toolUseBlock = message.message.content.find( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); - if (toolUseBlock && toolUseBlock.name === 'multiply') { + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'multiply'); + if (toolUseBlocks.length > 0) { foundToolUse = true; } assistantText += extractText(message.message.content); @@ -339,7 +147,7 @@ describe('MCP Server Integration (E2E)', () => { // Validate successful completion const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); + expect(isSDKResultMessage(lastMessage)).toBe(true); } finally { await q.close(); } @@ -363,11 +171,11 @@ describe('MCP Server Integration (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + let systemMessage: SDKSystemMessage | null = null; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { + if (isSDKSystemMessage(message) && message.subtype === 'init') { systemMessage = message; break; } @@ -410,7 +218,7 @@ describe('MCP Server Integration (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; const toolCalls: string[] = []; @@ -418,11 +226,8 @@ describe('MCP Server Integration (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { - const toolUseBlocks = message.message.content.filter( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message); toolUseBlocks.forEach((block) => { toolCalls.push(block.name); }); @@ -439,7 +244,7 @@ describe('MCP Server Integration (E2E)', () => { // Validate successful completion const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); + expect(isSDKResultMessage(lastMessage)).toBe(true); } finally { await q.close(); } @@ -462,7 +267,7 @@ describe('MCP Server Integration (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; const addToolCalls: ToolUseBlock[] = []; @@ -470,16 +275,9 @@ describe('MCP Server Integration (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { - const toolUseBlocks = message.message.content.filter( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); - toolUseBlocks.forEach((block) => { - if (block.name === 'add') { - addToolCalls.push(block); - } - }); + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, 'add'); + addToolCalls.push(...toolUseBlocks); assistantText += extractText(message.message.content); } } @@ -493,7 +291,7 @@ describe('MCP Server Integration (E2E)', () => { // Validate successful completion const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); + expect(isSDKResultMessage(lastMessage)).toBe(true); } finally { await q.close(); } @@ -525,19 +323,16 @@ describe('MCP Server Integration (E2E)', () => { for await (const message of q) { messageTypes.push(message.type); - if (isCLIAssistantMessage(message)) { - const toolUseBlock = message.message.content.find( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); - if (toolUseBlock) { + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message); + if (toolUseBlocks.length > 0) { foundToolUse = true; - expect(toolUseBlock.name).toBe('add'); - expect(toolUseBlock.input).toBeDefined(); + expect(toolUseBlocks[0].name).toBe('add'); + expect(toolUseBlocks[0].input).toBeDefined(); } } - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { const content = message.message.content; const contentArray = Array.isArray(content) ? content @@ -584,21 +379,21 @@ describe('MCP Server Integration (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantText += extractText(message.message.content); } } // Should complete without crashing const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); + expect(isSDKResultMessage(lastMessage)).toBe(true); // Assistant should indicate tool is not available or provide alternative expect(assistantText.length).toBeGreaterThan(0); diff --git a/packages/sdk-typescript/test/e2e/multi-turn.test.ts b/packages/sdk-typescript/test/e2e/multi-turn.test.ts index 52c012c8..be49dc5e 100644 --- a/packages/sdk-typescript/test/e2e/multi-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/multi-turn.test.ts @@ -6,19 +6,19 @@ import { describe, it, expect } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIUserMessage, - isCLIAssistantMessage, - isCLISystemMessage, - isCLIResultMessage, - isCLIPartialAssistantMessage, + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, isControlRequest, isControlResponse, isControlCancel, - type CLIUserMessage, - type CLIAssistantMessage, + type SDKUserMessage, + type SDKAssistantMessage, type TextBlock, type ContentBlock, - type CLIMessage, + type SDKMessage, type ControlMessage, type ToolUseBlock, } from '../../src/types/protocol.js'; @@ -31,16 +31,16 @@ const SHARED_TEST_OPTIONS = { /** * Determine the message type using protocol type guards */ -function getMessageType(message: CLIMessage | ControlMessage): string { - if (isCLIUserMessage(message)) { +function getMessageType(message: SDKMessage | ControlMessage): string { + if (isSDKUserMessage(message)) { return '🧑 USER'; - } else if (isCLIAssistantMessage(message)) { + } else if (isSDKAssistantMessage(message)) { return '🤖 ASSISTANT'; - } else if (isCLISystemMessage(message)) { + } else if (isSDKSystemMessage(message)) { return `🖥️ SYSTEM(${message.subtype})`; - } else if (isCLIResultMessage(message)) { + } else if (isSDKResultMessage(message)) { return `✅ RESULT(${message.subtype})`; - } else if (isCLIPartialAssistantMessage(message)) { + } else if (isSDKPartialAssistantMessage(message)) { return '⏳ STREAM_EVENT'; } else if (isControlRequest(message)) { return `🎮 CONTROL_REQUEST(${message.request.subtype})`; @@ -67,7 +67,7 @@ describe('Multi-Turn Conversations (E2E)', () => { describe('AsyncIterable Prompt Support', () => { it('should handle multi-turn conversation using AsyncIterable prompt', async () => { // Create multi-turn conversation generator - async function* createMultiTurnConversation(): AsyncIterable { + async function* createMultiTurnConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -78,7 +78,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'What is 1 + 1?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -90,7 +90,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'What is 2 + 2?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -102,7 +102,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'What is 3 + 3?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } // Create multi-turn query using AsyncIterable prompt @@ -114,15 +114,15 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const messages: CLIMessage[] = []; - const assistantMessages: CLIAssistantMessage[] = []; + const messages: SDKMessage[] = []; + const assistantMessages: SDKAssistantMessage[] = []; const assistantTexts: string[] = []; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessages.push(message); const text = extractText(message.message.content); assistantTexts.push(text); @@ -142,7 +142,7 @@ describe('Multi-Turn Conversations (E2E)', () => { }); it('should maintain session context across turns', async () => { - async function* createContextualConversation(): AsyncIterable { + async function* createContextualConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -154,7 +154,7 @@ describe('Multi-Turn Conversations (E2E)', () => { 'Suppose we have 3 rabbits and 4 carrots. How many animals are there?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); @@ -166,7 +166,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'How many animals are there? Only output the number', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -177,11 +177,11 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const assistantMessages: CLIAssistantMessage[] = []; + const assistantMessages: SDKAssistantMessage[] = []; try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessages.push(message); } } @@ -201,7 +201,7 @@ describe('Multi-Turn Conversations (E2E)', () => { describe('Tool Usage in Multi-Turn', () => { it('should handle tool usage across multiple turns', async () => { - async function* createToolConversation(): AsyncIterable { + async function* createToolConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -212,7 +212,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Create a file named test.txt with content "hello"', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); @@ -224,7 +224,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Now read the test.txt file', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -237,15 +237,15 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let toolUseCount = 0; - const assistantMessages: CLIAssistantMessage[] = []; + const assistantMessages: SDKAssistantMessage[] = []; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessages.push(message); const hasToolUseBlock = message.message.content.some( (block: ContentBlock): block is ToolUseBlock => @@ -274,7 +274,7 @@ describe('Multi-Turn Conversations (E2E)', () => { describe('Message Flow and Sequencing', () => { it('should process messages in correct sequence', async () => { - async function* createSequentialConversation(): AsyncIterable { + async function* createSequentialConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -285,7 +285,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'First question: What is 1 + 1?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -297,7 +297,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Second question: What is 2 + 2?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -316,7 +316,7 @@ describe('Multi-Turn Conversations (E2E)', () => { const messageType = getMessageType(message); messageSequence.push(messageType); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const text = extractText(message.message.content); assistantResponses.push(text); } @@ -338,7 +338,7 @@ describe('Multi-Turn Conversations (E2E)', () => { }); it('should handle conversation completion correctly', async () => { - async function* createSimpleConversation(): AsyncIterable { + async function* createSimpleConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -349,7 +349,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Hello', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -361,7 +361,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Goodbye', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -379,7 +379,7 @@ describe('Multi-Turn Conversations (E2E)', () => { for await (const message of q) { messageCount++; - if (isCLIResultMessage(message)) { + if (isSDKResultMessage(message)) { completedNaturally = true; expect(message.subtype).toBe('success'); } @@ -395,11 +395,11 @@ describe('Multi-Turn Conversations (E2E)', () => { describe('Error Handling in Multi-Turn', () => { it('should handle empty conversation gracefully', async () => { - async function* createEmptyConversation(): AsyncIterable { + async function* createEmptyConversation(): AsyncIterable { // Generator that yields nothing /* eslint-disable no-constant-condition */ if (false) { - yield {} as CLIUserMessage; // Unreachable, but satisfies TypeScript + yield {} as SDKUserMessage; // Unreachable, but satisfies TypeScript } } @@ -411,7 +411,7 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; try { for await (const message of q) { @@ -426,7 +426,7 @@ describe('Multi-Turn Conversations (E2E)', () => { }); it('should handle conversation with delays', async () => { - async function* createDelayedConversation(): AsyncIterable { + async function* createDelayedConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -437,7 +437,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'First message', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; // Longer delay to test patience await new Promise((resolve) => setTimeout(resolve, 500)); @@ -450,7 +450,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'Second message after delay', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -461,11 +461,11 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const assistantMessages: CLIAssistantMessage[] = []; + const assistantMessages: SDKAssistantMessage[] = []; try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessages.push(message); } } @@ -479,7 +479,7 @@ describe('Multi-Turn Conversations (E2E)', () => { describe('Partial Messages in Multi-Turn', () => { it('should receive partial messages when includePartialMessages is enabled', async () => { - async function* createMultiTurnConversation(): AsyncIterable { + async function* createMultiTurnConversation(): AsyncIterable { const sessionId = crypto.randomUUID(); yield { @@ -490,7 +490,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'What is 1 + 1?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 100)); @@ -502,7 +502,7 @@ describe('Multi-Turn Conversations (E2E)', () => { content: 'What is 2 + 2?', }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; } const q = query({ @@ -514,7 +514,7 @@ describe('Multi-Turn Conversations (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let partialMessageCount = 0; let assistantMessageCount = 0; @@ -522,11 +522,11 @@ describe('Multi-Turn Conversations (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIPartialAssistantMessage(message)) { + if (isSDKPartialAssistantMessage(message)) { partialMessageCount++; } - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessageCount++; } } diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/packages/sdk-typescript/test/e2e/permission-control.test.ts index 15770608..9747bca0 100644 --- a/packages/sdk-typescript/test/e2e/permission-control.test.ts +++ b/packages/sdk-typescript/test/e2e/permission-control.test.ts @@ -7,10 +7,10 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIAssistantMessage, - isCLIResultMessage, - isCLIUserMessage, - type CLIUserMessage, + isSDKAssistantMessage, + isSDKResultMessage, + isSDKUserMessage, + type SDKUserMessage, type ToolUseBlock, type ContentBlock, } from '../../src/types/protocol.js'; @@ -32,7 +32,7 @@ function createStreamingInputWithControlPoint( firstMessage: string, secondMessage: string, ): { - generator: AsyncIterable; + generator: AsyncIterable; resume: () => void; } { let resumeResolve: (() => void) | null = null; @@ -51,7 +51,7 @@ function createStreamingInputWithControlPoint( content: firstMessage, }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); @@ -67,7 +67,7 @@ function createStreamingInputWithControlPoint( content: secondMessage, }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; })(); const resume = () => { @@ -120,7 +120,7 @@ describe('Permission Control (E2E)', () => { try { let hasToolUse = false; for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const toolUseBlock = message.message.content.find( (block: ContentBlock): block is ToolUseBlock => block.type === 'tool_use', @@ -162,7 +162,7 @@ describe('Permission Control (E2E)', () => { try { let hasToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if ( Array.isArray(message.message.content) && message.message.content.some( @@ -372,7 +372,7 @@ describe('Permission Control (E2E)', () => { (async () => { for await (const message of q) { - if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -447,7 +447,7 @@ describe('Permission Control (E2E)', () => { (async () => { for await (const message of q) { - if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -522,7 +522,7 @@ describe('Permission Control (E2E)', () => { (async () => { for await (const message of q) { - if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) { + if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) { if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -628,7 +628,7 @@ describe('Permission Control (E2E)', () => { (async () => { for await (const message of q) { - if (isCLIResultMessage(message)) { + if (isSDKResultMessage(message)) { if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -695,7 +695,7 @@ describe('Permission Control (E2E)', () => { let hasErrorInResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -752,7 +752,7 @@ describe('Permission Control (E2E)', () => { let hasSuccessfulToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -798,7 +798,7 @@ describe('Permission Control (E2E)', () => { let hasToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -838,7 +838,7 @@ describe('Permission Control (E2E)', () => { let hasSuccessfulToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -891,7 +891,7 @@ describe('Permission Control (E2E)', () => { let hasToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -929,7 +929,7 @@ describe('Permission Control (E2E)', () => { let hasCommandResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -968,7 +968,7 @@ describe('Permission Control (E2E)', () => { let hasPlanModeMessage = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1014,7 +1014,7 @@ describe('Permission Control (E2E)', () => { let hasSuccessfulToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1066,7 +1066,7 @@ describe('Permission Control (E2E)', () => { let hasPlanModeBlock = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1114,7 +1114,7 @@ describe('Permission Control (E2E)', () => { let hasDeniedTool = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1169,7 +1169,7 @@ describe('Permission Control (E2E)', () => { let hasSuccessfulToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1214,7 +1214,7 @@ describe('Permission Control (E2E)', () => { let hasToolResult = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', @@ -1270,7 +1270,7 @@ describe('Permission Control (E2E)', () => { let toolExecuted = false; for await (const message of q) { - if (isCLIUserMessage(message)) { + if (isSDKUserMessage(message)) { if (Array.isArray(message.message.content)) { const toolResult = message.message.content.find( (block) => block.type === 'tool_result', diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts index 2052b6b2..476d9bfb 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -6,31 +6,22 @@ import { describe, it, expect } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIAssistantMessage, - isCLISystemMessage, - isCLIResultMessage, - isCLIPartialAssistantMessage, - type TextBlock, - type ContentBlock, - type CLIMessage, - type CLISystemMessage, - type CLIAssistantMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, + type SDKMessage, + type SDKSystemMessage, + type SDKAssistantMessage, } from '../../src/types/protocol.js'; -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; +import { + extractText, + createSharedTestOptions, + assertSuccessfulCompletion, + collectMessagesByType, +} from './test-helper.js'; -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, -}; - -/** - * Helper to extract text from ContentBlock array - */ -function extractText(content: ContentBlock[]): string { - return content - .filter((block): block is TextBlock => block.type === 'text') - .map((block) => block.text) - .join(''); -} +const SHARED_TEST_OPTIONS = createSharedTestOptions(); describe('Single-Turn Query (E2E)', () => { describe('Simple Text Queries', () => { @@ -44,14 +35,14 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantText += extractText(message.message.content); } } @@ -64,11 +55,7 @@ describe('Single-Turn Query (E2E)', () => { expect(assistantText).toMatch(/4/); // Validate message flow ends with success - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { - expect(lastMessage.subtype).toBe('success'); - } + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -83,14 +70,14 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantText += extractText(message.message.content); } } @@ -100,8 +87,7 @@ describe('Single-Turn Query (E2E)', () => { expect(assistantText.toLowerCase()).toContain('paris'); // Validate completion - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -116,14 +102,14 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let assistantText = ''; try { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantText += extractText(message.message.content); } } @@ -133,7 +119,10 @@ describe('Single-Turn Query (E2E)', () => { expect(assistantText.toLowerCase()).toMatch(/hello|hi|greetings/); // Validate message types - const assistantMessages = messages.filter(isCLIAssistantMessage); + const assistantMessages = collectMessagesByType( + messages, + isSDKAssistantMessage, + ); expect(assistantMessages.length).toBeGreaterThan(0); } finally { await q.close(); @@ -151,14 +140,14 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; + let systemMessage: SDKSystemMessage | null = null; try { for await (const message of q) { messages.push(message); - if (isCLISystemMessage(message) && message.subtype === 'init') { + if (isSDKSystemMessage(message) && message.subtype === 'init') { systemMessage = message; } } @@ -180,7 +169,7 @@ describe('Single-Turn Query (E2E)', () => { // Validate system message appears early in sequence const systemMessageIndex = messages.findIndex( - (msg) => isCLISystemMessage(msg) && msg.subtype === 'init', + (msg) => isSDKSystemMessage(msg) && msg.subtype === 'init', ); expect(systemMessageIndex).toBeGreaterThanOrEqual(0); expect(systemMessageIndex).toBeLessThan(3); @@ -198,12 +187,12 @@ describe('Single-Turn Query (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + let systemMessage: SDKSystemMessage | null = null; const sessionId = q.getSessionId(); try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { + if (isSDKSystemMessage(message) && message.subtype === 'init') { systemMessage = message; } } @@ -262,7 +251,7 @@ describe('Single-Turn Query (E2E)', () => { for await (const message of q) { messageCount++; - if (isCLIResultMessage(message)) { + if (isSDKResultMessage(message)) { completedNaturally = true; expect(message.subtype).toBe('success'); } @@ -319,7 +308,7 @@ describe('Single-Turn Query (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { hasResponse = true; } } @@ -340,7 +329,7 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let partialMessageCount = 0; let assistantMessageCount = 0; @@ -348,11 +337,11 @@ describe('Single-Turn Query (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIPartialAssistantMessage(message)) { + if (isSDKPartialAssistantMessage(message)) { partialMessageCount++; } - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessageCount++; } } @@ -376,7 +365,7 @@ describe('Single-Turn Query (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; try { for await (const message of q) { @@ -384,9 +373,18 @@ describe('Single-Turn Query (E2E)', () => { } // Validate type guards work correctly - const assistantMessages = messages.filter(isCLIAssistantMessage); - const resultMessages = messages.filter(isCLIResultMessage); - const systemMessages = messages.filter(isCLISystemMessage); + const assistantMessages = collectMessagesByType( + messages, + isSDKAssistantMessage, + ); + const resultMessages = collectMessagesByType( + messages, + isSDKResultMessage, + ); + const systemMessages = collectMessagesByType( + messages, + isSDKSystemMessage, + ); expect(assistantMessages.length).toBeGreaterThan(0); expect(resultMessages.length).toBeGreaterThan(0); @@ -414,11 +412,11 @@ describe('Single-Turn Query (E2E)', () => { }, }); - let assistantMessage: CLIAssistantMessage | null = null; + let assistantMessage: SDKAssistantMessage | null = null; try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { assistantMessage = message; } } @@ -426,17 +424,9 @@ describe('Single-Turn Query (E2E)', () => { expect(assistantMessage).not.toBeNull(); expect(assistantMessage!.message.content).toBeDefined(); - // Extract text blocks - const textBlocks = assistantMessage!.message.content.filter( - (block: ContentBlock): block is TextBlock => block.type === 'text', - ); - - expect(textBlocks.length).toBeGreaterThan(0); - expect(textBlocks[0].text).toBeDefined(); - expect(textBlocks[0].text.length).toBeGreaterThan(0); - // Validate content contains expected numbers const text = extractText(assistantMessage!.message.content); + expect(text.length).toBeGreaterThan(0); expect(text).toMatch(/1/); expect(text).toMatch(/2/); expect(text).toMatch(/3/); diff --git a/packages/sdk-typescript/test/e2e/subagents.test.ts b/packages/sdk-typescript/test/e2e/subagents.test.ts index fcceebb5..075105b1 100644 --- a/packages/sdk-typescript/test/e2e/subagents.test.ts +++ b/packages/sdk-typescript/test/e2e/subagents.test.ts @@ -9,50 +9,42 @@ * Tests subagent delegation and task completion */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIAssistantMessage, - isCLISystemMessage, - isCLIResultMessage, - type TextBlock, - type ContentBlock, - type CLIMessage, - type CLISystemMessage, + isSDKAssistantMessage, + type SDKMessage, type SubagentConfig, + type ContentBlock, type ToolUseBlock, } from '../../src/types/protocol.js'; -import { writeFile, mkdir } from 'node:fs/promises'; -import { join } from 'node:path'; +import { + SDKTestHelper, + extractText, + createSharedTestOptions, + findToolUseBlocks, + assertSuccessfulCompletion, + findSystemMessage, +} from './test-helper.js'; -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; -const E2E_TEST_FILE_DIR = process.env['E2E_TEST_FILE_DIR']!; - -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, -}; - -/** - * Helper to extract text from ContentBlock array - */ -function extractText(content: ContentBlock[]): string { - return content - .filter((block): block is TextBlock => block.type === 'text') - .map((block) => block.text) - .join(''); -} +const SHARED_TEST_OPTIONS = createSharedTestOptions(); describe('Subagents (E2E)', () => { + let helper: SDKTestHelper; let testWorkDir: string; beforeAll(async () => { - // Create a test working directory - testWorkDir = join(E2E_TEST_FILE_DIR, 'subagent-tests'); - await mkdir(testWorkDir, { recursive: true }); + // Create isolated test environment using SDKTestHelper + helper = new SDKTestHelper(); + testWorkDir = await helper.setup('subagent-tests'); // Create a simple test file for subagent to work with - const testFilePath = join(testWorkDir, 'test.txt'); - await writeFile(testFilePath, 'Hello from test file\n', 'utf-8'); + await helper.createFile('test.txt', 'Hello from test file\n'); + }); + + afterAll(async () => { + // Cleanup test directory + await helper.cleanup(); }); describe('Subagent Configuration', () => { @@ -75,29 +67,21 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; try { for await (const message of q) { messages.push(message); - - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } } // Validate system message includes the subagent + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('simple-greeter'); // Validate successful completion - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { - expect(lastMessage.subtype).toBe('success'); - } + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -128,16 +112,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate both subagents are registered + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('greeter'); @@ -170,16 +153,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('custom-model-agent'); @@ -210,16 +192,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('limited-agent'); @@ -248,16 +229,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate subagent is registered + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('read-only-agent'); @@ -277,7 +257,7 @@ describe('Subagents (E2E)', () => { tools: ['read_file', 'list_directory'], }; - const testFile = join(testWorkDir, 'test.txt'); + const testFile = helper.getPath('test.txt'); const q = query({ prompt: `Use the file-reader subagent to read the file at ${testFile} and tell me what it contains.`, options: { @@ -289,7 +269,7 @@ describe('Subagents (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let foundTaskTool = false; let taskToolUseId: string | null = null; let foundSubagentToolCall = false; @@ -299,25 +279,19 @@ describe('Subagents (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { // Check for task tool use in content blocks (main agent calling subagent) - const toolUseBlock = message.message.content.find( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use' && block.name === 'task', - ); - if (toolUseBlock) { + const taskToolBlocks = findToolUseBlocks(message, 'task'); + if (taskToolBlocks.length > 0) { foundTaskTool = true; - taskToolUseId = toolUseBlock.id; + taskToolUseId = taskToolBlocks[0].id; } // Check if this message is from a subagent (has parent_tool_use_id) if (message.parent_tool_use_id !== null) { // This is a subagent message - const subagentToolUse = message.message.content.find( - (block: ContentBlock): block is ToolUseBlock => - block.type === 'tool_use', - ); - if (subagentToolUse) { + const subagentToolBlocks = findToolUseBlocks(message); + if (subagentToolBlocks.length > 0) { foundSubagentToolCall = true; // Verify parent_tool_use_id matches the task tool use id expect(message.parent_tool_use_id).toBe(taskToolUseId); @@ -339,11 +313,7 @@ describe('Subagents (E2E)', () => { expect(assistantText.length).toBeGreaterThan(0); // Validate successful completion - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { - expect(lastMessage.subtype).toBe('success'); - } + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -369,7 +339,7 @@ describe('Subagents (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let foundTaskTool = false; let assistantText = ''; @@ -377,7 +347,7 @@ describe('Subagents (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { // Check for task tool use (main agent delegating to subagent) const toolUseBlock = message.message.content.find( (block: ContentBlock): block is ToolUseBlock => @@ -398,11 +368,7 @@ describe('Subagents (E2E)', () => { expect(assistantText.length).toBeGreaterThan(0); // Validate successful completion - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { - expect(lastMessage.subtype).toBe('success'); - } + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -429,7 +395,7 @@ describe('Subagents (E2E)', () => { }, }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; let taskToolUseId: string | null = null; const subagentToolCalls: ToolUseBlock[] = []; const mainAgentToolCalls: ToolUseBlock[] = []; @@ -438,7 +404,7 @@ describe('Subagents (E2E)', () => { for await (const message of q) { messages.push(message); - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { // Collect all tool use blocks const toolUseBlocks = message.message.content.filter( (block: ContentBlock): block is ToolUseBlock => @@ -471,8 +437,8 @@ describe('Subagents (E2E)', () => { // Verify all subagent messages have the correct parent_tool_use_id const subagentMessages = messages.filter( - (msg): msg is CLIMessage & { parent_tool_use_id: string } => - isCLIAssistantMessage(msg) && msg.parent_tool_use_id !== null, + (msg): msg is SDKMessage & { parent_tool_use_id: string } => + isSDKAssistantMessage(msg) && msg.parent_tool_use_id !== null, ); expect(subagentMessages.length).toBeGreaterThan(0); @@ -482,23 +448,19 @@ describe('Subagents (E2E)', () => { // Verify no main agent tool calls (except task) have parent_tool_use_id const mainAgentMessages = messages.filter( - (msg): msg is CLIMessage => - isCLIAssistantMessage(msg) && msg.parent_tool_use_id === null, + (msg): msg is SDKMessage => + isSDKAssistantMessage(msg) && msg.parent_tool_use_id === null, ); for (const mainMsg of mainAgentMessages) { - if (isCLIAssistantMessage(mainMsg)) { + if (isSDKAssistantMessage(mainMsg)) { // Main agent messages should not have parent_tool_use_id expect(mainMsg.parent_tool_use_id).toBeNull(); } } // Validate successful completion - const lastMessage = messages[messages.length - 1]; - expect(isCLIResultMessage(lastMessage)).toBe(true); - if (isCLIResultMessage(lastMessage)) { - expect(lastMessage.subtype).toBe('success'); - } + assertSuccessfulCompletion(messages); } finally { await q.close(); } @@ -517,16 +479,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Should still work with empty agents array + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); } finally { @@ -552,16 +513,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate minimal agent is registered + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('minimal-agent'); @@ -596,16 +556,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate subagent works with debug mode + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.agents).toBeDefined(); expect(systemMessage!.agents).toContain('test-agent'); @@ -633,16 +592,15 @@ describe('Subagents (E2E)', () => { }, }); - let systemMessage: CLISystemMessage | null = null; + const messages: SDKMessage[] = []; try { for await (const message of q) { - if (isCLISystemMessage(message) && message.subtype === 'init') { - systemMessage = message; - } + messages.push(message); } // Validate session consistency + const systemMessage = findSystemMessage(messages, 'init'); expect(systemMessage).not.toBeNull(); expect(systemMessage!.session_id).toBeDefined(); expect(systemMessage!.uuid).toBeDefined(); diff --git a/packages/sdk-typescript/test/e2e/system-control.test.ts b/packages/sdk-typescript/test/e2e/system-control.test.ts index 373f88e7..3bf1903d 100644 --- a/packages/sdk-typescript/test/e2e/system-control.test.ts +++ b/packages/sdk-typescript/test/e2e/system-control.test.ts @@ -6,9 +6,9 @@ import { describe, it, expect } from 'vitest'; import { query } from '../../src/index.js'; import { - isCLIAssistantMessage, - isCLISystemMessage, - type CLIUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + type SDKUserMessage, } from '../../src/types/protocol.js'; const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; @@ -30,7 +30,7 @@ function createStreamingInputWithControlPoint( firstMessage: string, secondMessage: string, ): { - generator: AsyncIterable; + generator: AsyncIterable; resume: () => void; } { let resumeResolve: (() => void) | null = null; @@ -49,7 +49,7 @@ function createStreamingInputWithControlPoint( content: firstMessage, }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); @@ -65,7 +65,7 @@ function createStreamingInputWithControlPoint( content: secondMessage, }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; })(); const resume = () => { @@ -113,10 +113,10 @@ describe('System Control (E2E)', () => { // Consume messages in a single loop (async () => { for await (const message of q) { - if (isCLISystemMessage(message)) { + if (isSDKSystemMessage(message)) { systemMessages.push({ model: message.model }); } - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { if (!firstResponseReceived) { firstResponseReceived = true; resolvers.first?.(); @@ -186,7 +186,7 @@ describe('System Control (E2E)', () => { session_id: sessionId, message: { role: 'user', content: 'First message' }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); await resumePromise1; @@ -197,7 +197,7 @@ describe('System Control (E2E)', () => { session_id: sessionId, message: { role: 'user', content: 'Second message' }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; await new Promise((resolve) => setTimeout(resolve, 200)); await resumePromise2; @@ -208,7 +208,7 @@ describe('System Control (E2E)', () => { session_id: sessionId, message: { role: 'user', content: 'Third message' }, parent_tool_use_id: null, - } as CLIUserMessage; + } as SDKUserMessage; })(); const q = query({ @@ -232,10 +232,10 @@ describe('System Control (E2E)', () => { (async () => { for await (const message of q) { - if (isCLISystemMessage(message)) { + if (isSDKSystemMessage(message)) { systemMessages.push({ model: message.model }); } - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { if (responseCount < resolvers.length) { resolvers[responseCount]?.(); responseCount++; diff --git a/packages/sdk-typescript/test/e2e/test-helper.ts b/packages/sdk-typescript/test/e2e/test-helper.ts new file mode 100644 index 00000000..19299d53 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/test-helper.ts @@ -0,0 +1,829 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * SDK E2E Test Helper + * Provides utilities for SDK e2e tests including test isolation, + * file management, MCP server setup, and common test utilities. + */ + +import { mkdir, writeFile, readFile, rm, chmod } from 'node:fs/promises'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import type { + SDKMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKUserMessage, + ContentBlock, + TextBlock, + ToolUseBlock, +} from '../../src/types/protocol.js'; +import { + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, +} from '../../src/types/protocol.js'; + +// ============================================================================ +// Core Test Helper Class +// ============================================================================ + +export interface SDKTestHelperOptions { + /** + * Optional settings for .qwen/settings.json + */ + settings?: Record; + /** + * Whether to create .qwen/settings.json + */ + createQwenConfig?: boolean; +} + +/** + * Helper class for SDK E2E tests + * Provides isolated test environments for each test case + */ +export class SDKTestHelper { + testDir: string | null = null; + testName?: string; + private baseDir: string; + + constructor() { + this.baseDir = process.env['E2E_TEST_FILE_DIR']!; + if (!this.baseDir) { + throw new Error('E2E_TEST_FILE_DIR environment variable not set'); + } + } + + /** + * Setup an isolated test directory for a specific test + */ + async setup( + testName: string, + options: SDKTestHelperOptions = {}, + ): Promise { + this.testName = testName; + const sanitizedName = this.sanitizeTestName(testName); + this.testDir = join(this.baseDir, sanitizedName); + + await mkdir(this.testDir, { recursive: true }); + + // Optionally create .qwen/settings.json for CLI configuration + if (options.createQwenConfig) { + const qwenDir = join(this.testDir, '.qwen'); + await mkdir(qwenDir, { recursive: true }); + + const settings = { + telemetry: { + enabled: false, // SDK tests don't need telemetry + }, + ...options.settings, + }; + + await writeFile( + join(qwenDir, 'settings.json'), + JSON.stringify(settings, null, 2), + 'utf-8', + ); + } + + return this.testDir; + } + + /** + * Create a file in the test directory + */ + async createFile(fileName: string, content: string): Promise { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const filePath = join(this.testDir, fileName); + await writeFile(filePath, content, 'utf-8'); + return filePath; + } + + /** + * Read a file from the test directory + */ + async readFile(fileName: string): Promise { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const filePath = join(this.testDir, fileName); + return await readFile(filePath, 'utf-8'); + } + + /** + * Create a subdirectory in the test directory + */ + async mkdir(dirName: string): Promise { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const dirPath = join(this.testDir, dirName); + await mkdir(dirPath, { recursive: true }); + return dirPath; + } + + /** + * Check if a file exists in the test directory + */ + fileExists(fileName: string): boolean { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + const filePath = join(this.testDir, fileName); + return existsSync(filePath); + } + + /** + * Get the full path to a file in the test directory + */ + getPath(fileName: string): string { + if (!this.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + return join(this.testDir, fileName); + } + + /** + * Cleanup test directory + */ + async cleanup(): Promise { + if (this.testDir && process.env['KEEP_OUTPUT'] !== 'true') { + try { + await rm(this.testDir, { recursive: true, force: true }); + } catch (error) { + if (process.env['VERBOSE'] === 'true') { + console.warn('Cleanup warning:', (error as Error).message); + } + } + } + } + + /** + * Sanitize test name to create valid directory name + */ + private sanitizeTestName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .substring(0, 100); // Limit length + } +} + +// ============================================================================ +// MCP Server Utilities +// ============================================================================ + +export interface MCPServerConfig { + command: string; + args: string[]; +} + +export interface MCPServerResult { + scriptPath: string; + config: MCPServerConfig; +} + +/** + * Built-in MCP server template: Math server with add and multiply tools + */ +const MCP_MATH_SERVER_SCRIPT = `#!/usr/bin/env node +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const readline = require('readline'); +const fs = require('fs'); + +// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set) +const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true'; +function debug(msg) { + if (debugEnabled) { + fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`); + } +} + +debug('MCP server starting...'); + +// Simple JSON-RPC implementation for MCP +class SimpleJSONRPC { + constructor() { + this.handlers = new Map(); + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + this.rl.on('line', (line) => { + debug(\`Received line: \${line}\`); + try { + const message = JSON.parse(line); + debug(\`Parsed message: \${JSON.stringify(message)}\`); + this.handleMessage(message); + } catch (e) { + debug(\`Parse error: \${e.message}\`); + } + }); + } + + send(message) { + const msgStr = JSON.stringify(message); + debug(\`Sending message: \${msgStr}\`); + process.stdout.write(msgStr + '\\n'); + } + + async handleMessage(message) { + if (message.method && this.handlers.has(message.method)) { + try { + const result = await this.handlers.get(message.method)(message.params || {}); + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + result + }); + } + } catch (error) { + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: error.message + } + }); + } + } + } else if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32601, + message: 'Method not found' + } + }); + } + } + + on(method, handler) { + this.handlers.set(method, handler); + } +} + +// Create MCP server +const rpc = new SimpleJSONRPC(); + +// Handle initialize +rpc.on('initialize', async (params) => { + debug('Handling initialize request'); + return { + protocolVersion: '2024-11-05', + capabilities: { + tools: {} + }, + serverInfo: { + name: 'test-math-server', + version: '1.0.0' + } + }; +}); + +// Handle tools/list +rpc.on('tools/list', async () => { + debug('Handling tools/list request'); + return { + tools: [ + { + name: 'add', + description: 'Add two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + }, + { + name: 'multiply', + description: 'Multiply two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + } + ] + }; +}); + +// Handle tools/call +rpc.on('tools/call', async (params) => { + debug(\`Handling tools/call request for tool: \${params.name}\`); + + if (params.name === 'add') { + const { a, b } = params.arguments; + return { + content: [{ + type: 'text', + text: String(a + b) + }] + }; + } + + if (params.name === 'multiply') { + const { a, b } = params.arguments; + return { + content: [{ + type: 'text', + text: String(a * b) + }] + }; + } + + throw new Error('Unknown tool: ' + params.name); +}); + +// Send initialization notification +rpc.send({ + jsonrpc: '2.0', + method: 'initialized' +}); +`; + +/** + * Create an MCP server script in the test directory + * @param helper - SDKTestHelper instance + * @param type - Type of MCP server ('math' or provide custom script) + * @param serverName - Name of the MCP server (default: 'test-math-server') + * @param customScript - Custom MCP server script (if type is not 'math') + * @returns Object with scriptPath and config + */ +export async function createMCPServer( + helper: SDKTestHelper, + type: 'math' | 'custom' = 'math', + serverName: string = 'test-math-server', + customScript?: string, +): Promise { + if (!helper.testDir) { + throw new Error('Test directory not initialized. Call setup() first.'); + } + + const script = type === 'math' ? MCP_MATH_SERVER_SCRIPT : customScript; + if (!script) { + throw new Error('Custom script required when type is "custom"'); + } + + const scriptPath = join(helper.testDir, `${serverName}.cjs`); + await writeFile(scriptPath, script, 'utf-8'); + + // Make script executable on Unix-like systems + if (process.platform !== 'win32') { + await chmod(scriptPath, 0o755); + } + + return { + scriptPath, + config: { + command: 'node', + args: [scriptPath], + }, + }; +} + +// ============================================================================ +// Message & Content Utilities +// ============================================================================ + +/** + * Extract text from ContentBlock array + */ +export function extractText(content: ContentBlock[]): string { + return content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); +} + +/** + * Collect messages by type + */ +export function collectMessagesByType( + messages: SDKMessage[], + predicate: (msg: SDKMessage) => msg is T, +): T[] { + return messages.filter(predicate); +} + +/** + * Find tool use blocks in a message + */ +export function findToolUseBlocks( + message: SDKAssistantMessage, + toolName?: string, +): ToolUseBlock[] { + const toolUseBlocks = message.message.content.filter( + (block): block is ToolUseBlock => block.type === 'tool_use', + ); + + if (toolName) { + return toolUseBlocks.filter((block) => block.name === toolName); + } + + return toolUseBlocks; +} + +/** + * Extract all assistant text from messages + */ +export function getAssistantText(messages: SDKMessage[]): string { + return messages + .filter(isSDKAssistantMessage) + .map((msg) => extractText(msg.message.content)) + .join(''); +} + +/** + * Find system message with optional subtype filter + */ +export function findSystemMessage( + messages: SDKMessage[], + subtype?: string, +): SDKSystemMessage | null { + const systemMessages = messages.filter(isSDKSystemMessage); + + if (subtype) { + return systemMessages.find((msg) => msg.subtype === subtype) || null; + } + + return systemMessages[0] || null; +} + +/** + * Find all tool calls in messages + */ +export function findToolCalls( + messages: SDKMessage[], + toolName?: string, +): Array<{ message: SDKAssistantMessage; toolUse: ToolUseBlock }> { + const results: Array<{ + message: SDKAssistantMessage; + toolUse: ToolUseBlock; + }> = []; + + for (const message of messages) { + if (isSDKAssistantMessage(message)) { + const toolUseBlocks = findToolUseBlocks(message, toolName); + for (const toolUse of toolUseBlocks) { + results.push({ message, toolUse }); + } + } + } + + return results; +} + +// ============================================================================ +// Streaming Input Utilities +// ============================================================================ + +/** + * Create a simple streaming input from an array of message contents + */ +export async function* createStreamingInput( + messageContents: string[], + sessionId?: string, +): AsyncIterable { + const sid = sessionId || crypto.randomUUID(); + + for (const content of messageContents) { + yield { + type: 'user', + session_id: sid, + message: { + role: 'user', + content: content, + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + // Small delay between messages + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +/** + * Create a controlled streaming input with pause/resume capability + */ +export function createControlledStreamingInput( + messageContents: string[], + sessionId?: string, +): { + generator: AsyncIterable; + resume: () => void; + resumeAll: () => void; +} { + const sid = sessionId || crypto.randomUUID(); + const resumeResolvers: Array<() => void> = []; + const resumePromises: Array> = []; + + // Create a resume promise for each message after the first + for (let i = 1; i < messageContents.length; i++) { + const promise = new Promise((resolve) => { + resumeResolvers.push(resolve); + }); + resumePromises.push(promise); + } + + const generator = (async function* () { + // Yield first message immediately + yield { + type: 'user', + session_id: sid, + message: { + role: 'user', + content: messageContents[0], + }, + parent_tool_use_id: null, + } as SDKUserMessage; + + // For subsequent messages, wait for resume + for (let i = 1; i < messageContents.length; i++) { + await new Promise((resolve) => setTimeout(resolve, 200)); + await resumePromises[i - 1]; + await new Promise((resolve) => setTimeout(resolve, 200)); + + yield { + type: 'user', + session_id: sid, + message: { + role: 'user', + content: messageContents[i], + }, + parent_tool_use_id: null, + } as SDKUserMessage; + } + })(); + + let currentResumeIndex = 0; + + return { + generator, + resume: () => { + if (currentResumeIndex < resumeResolvers.length) { + resumeResolvers[currentResumeIndex](); + currentResumeIndex++; + } + }, + resumeAll: () => { + resumeResolvers.forEach((resolve) => resolve()); + currentResumeIndex = resumeResolvers.length; + }, + }; +} + +// ============================================================================ +// Assertion Utilities +// ============================================================================ + +/** + * Assert that messages follow expected type sequence + */ +export function assertMessageSequence( + messages: SDKMessage[], + expectedTypes: string[], +): void { + const actualTypes = messages.map((msg) => msg.type); + + if (actualTypes.length < expectedTypes.length) { + throw new Error( + `Expected at least ${expectedTypes.length} messages, got ${actualTypes.length}`, + ); + } + + for (let i = 0; i < expectedTypes.length; i++) { + if (actualTypes[i] !== expectedTypes[i]) { + throw new Error( + `Expected message ${i} to be type '${expectedTypes[i]}', got '${actualTypes[i]}'`, + ); + } + } +} + +/** + * Assert that a specific tool was called + */ +export function assertToolCalled( + messages: SDKMessage[], + toolName: string, +): void { + const toolCalls = findToolCalls(messages, toolName); + + if (toolCalls.length === 0) { + const allToolCalls = findToolCalls(messages); + const allToolNames = allToolCalls.map((tc) => tc.toolUse.name); + throw new Error( + `Expected tool '${toolName}' to be called. Found tools: ${allToolNames.length > 0 ? allToolNames.join(', ') : 'none'}`, + ); + } +} + +/** + * Assert that the conversation completed successfully + */ +export function assertSuccessfulCompletion(messages: SDKMessage[]): void { + const lastMessage = messages[messages.length - 1]; + + if (!isSDKResultMessage(lastMessage)) { + throw new Error( + `Expected last message to be a result message, got '${lastMessage.type}'`, + ); + } + + if (lastMessage.subtype !== 'success') { + throw new Error( + `Expected successful completion, got result subtype '${lastMessage.subtype}'`, + ); + } +} + +/** + * Wait for a condition to be true with timeout + */ +export async function waitFor( + predicate: () => boolean | Promise, + options: { + timeout?: number; + interval?: number; + errorMessage?: string; + } = {}, +): Promise { + const { + timeout = 5000, + interval = 100, + errorMessage = 'Condition not met within timeout', + } = options; + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const result = await predicate(); + if (result) { + return; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + throw new Error(errorMessage); +} + +// ============================================================================ +// Debug and Validation Utilities +// ============================================================================ + +/** + * Validate model output and warn about unexpected content + * Inspired by integration-tests test-helper + */ +export function validateModelOutput( + result: string, + expectedContent: string | (string | RegExp)[] | null = null, + testName = '', +): boolean { + // First, check if there's any output at all + if (!result || result.trim().length === 0) { + throw new Error('Expected model to return some output'); + } + + // If expectedContent is provided, check for it and warn if missing + if (expectedContent) { + const contents = Array.isArray(expectedContent) + ? expectedContent + : [expectedContent]; + const missingContent = contents.filter((content) => { + if (typeof content === 'string') { + return !result.toLowerCase().includes(content.toLowerCase()); + } else if (content instanceof RegExp) { + return !content.test(result); + } + return false; + }); + + if (missingContent.length > 0) { + console.warn( + `Warning: Model did not include expected content in response: ${missingContent.join(', ')}.`, + 'This is not ideal but not a test failure.', + ); + console.warn( + 'The tool was called successfully, which is the main requirement.', + ); + return false; + } else if (process.env['VERBOSE'] === 'true') { + console.log(`${testName}: Model output validated successfully.`); + } + return true; + } + + return true; +} + +/** + * Print debug information when tests fail + */ +export function printDebugInfo( + messages: SDKMessage[], + context: Record = {}, +): void { + console.error('Test failed - Debug info:'); + console.error('Message count:', messages.length); + + // Print message types + const messageTypes = messages.map((m) => m.type); + console.error('Message types:', messageTypes.join(', ')); + + // Print assistant text + const assistantText = getAssistantText(messages); + console.error( + 'Assistant text (first 500 chars):', + assistantText.substring(0, 500), + ); + if (assistantText.length > 500) { + console.error( + 'Assistant text (last 500 chars):', + assistantText.substring(assistantText.length - 500), + ); + } + + // Print tool calls + const toolCalls = findToolCalls(messages); + console.error( + 'Tool calls found:', + toolCalls.map((tc) => tc.toolUse.name), + ); + + // Print any additional context provided + Object.entries(context).forEach(([key, value]) => { + console.error(`${key}:`, value); + }); +} + +/** + * Create detailed error message for tool call expectations + */ +export function createToolCallErrorMessage( + expectedTools: string | string[], + foundTools: string[], + messages: SDKMessage[], +): string { + const expectedStr = Array.isArray(expectedTools) + ? expectedTools.join(' or ') + : expectedTools; + + const assistantText = getAssistantText(messages); + const preview = assistantText + ? assistantText.substring(0, 200) + '...' + : 'no output'; + + return ( + `Expected to find ${expectedStr} tool call(s). ` + + `Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` + + `Output preview: ${preview}` + ); +} + +// ============================================================================ +// Shared Test Options Helper +// ============================================================================ + +/** + * Create shared test options with CLI path + */ +export function createSharedTestOptions( + overrides: Record = {}, +) { + const TEST_CLI_PATH = process.env['TEST_CLI_PATH']; + if (!TEST_CLI_PATH) { + throw new Error('TEST_CLI_PATH environment variable not set'); + } + + return { + pathToQwenExecutable: TEST_CLI_PATH, + ...overrides, + }; +} diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts index 9b8e34c2..b7309a19 100644 --- a/packages/sdk-typescript/test/unit/Query.test.ts +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -7,12 +7,12 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { Query } from '../../src/query/Query.js'; import type { Transport } from '../../src/transport/Transport.js'; import type { - CLIMessage, - CLIUserMessage, - CLIAssistantMessage, - CLISystemMessage, - CLIResultMessage, - CLIPartialAssistantMessage, + SDKMessage, + SDKUserMessage, + SDKAssistantMessage, + SDKSystemMessage, + SDKResultMessage, + SDKPartialAssistantMessage, CLIControlRequest, CLIControlResponse, ControlCancelRequest, @@ -118,7 +118,7 @@ function findControlRequest( function createUserMessage( content: string, sessionId = 'test-session', -): CLIUserMessage { +): SDKUserMessage { return { type: 'user', session_id: sessionId, @@ -133,7 +133,7 @@ function createUserMessage( function createAssistantMessage( content: string, sessionId = 'test-session', -): CLIAssistantMessage { +): SDKAssistantMessage { return { type: 'assistant', uuid: 'msg-123', @@ -153,7 +153,7 @@ function createAssistantMessage( function createSystemMessage( subtype: string, sessionId = 'test-session', -): CLISystemMessage { +): SDKSystemMessage { return { type: 'system', subtype, @@ -168,7 +168,7 @@ function createSystemMessage( function createResultMessage( success: boolean, sessionId = 'test-session', -): CLIResultMessage { +): SDKResultMessage { if (success) { return { type: 'result', @@ -202,7 +202,7 @@ function createResultMessage( function createPartialMessage( sessionId = 'test-session', -): CLIPartialAssistantMessage { +): SDKPartialAssistantMessage { return { type: 'stream_event', uuid: 'stream-123', @@ -816,7 +816,7 @@ describe('Query', () => { msg !== null && 'type' in msg && msg.type === 'user', - ) as CLIUserMessage[]; + ) as SDKUserMessage[]; userMessages.forEach((msg) => { expect(msg.session_id).toBe(sessionId); @@ -889,7 +889,7 @@ describe('Query', () => { const query = new Query(transport, { cwd: '/test' }); const iterationPromise = (async () => { - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; for await (const msg of query) { messages.push(msg); } @@ -946,7 +946,7 @@ describe('Query', () => { it('should support for await loop', async () => { const query = new Query(transport, { cwd: '/test' }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; const iterationPromise = (async () => { for await (const msg of query) { messages.push(msg); @@ -960,7 +960,7 @@ describe('Query', () => { await iterationPromise; expect(messages).toHaveLength(2); - expect((messages[0] as CLIUserMessage).message.content).toBe('First'); + expect((messages[0] as SDKUserMessage).message.content).toBe('First'); await query.close(); }); @@ -968,7 +968,7 @@ describe('Query', () => { it('should complete iteration when query closes', async () => { const query = new Query(transport, { cwd: '/test' }); - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; const iterationPromise = (async () => { for await (const msg of query) { messages.push(msg); @@ -1321,7 +1321,7 @@ describe('Query', () => { const result = await query.next(); expect(result.done).toBe(false); - expect((result.value as CLIResultMessage).is_error).toBe(true); + expect((result.value as SDKResultMessage).is_error).toBe(true); await query.close(); }); @@ -1430,7 +1430,7 @@ describe('Query', () => { transport.simulateMessage(createUserMessage(`Message ${i}`)); } - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; for (let i = 0; i < 100; i++) { const result = await query.next(); if (!result.done) { @@ -1447,7 +1447,7 @@ describe('Query', () => { const query = new Query(transport, { cwd: '/test' }); const iterationPromise = (async () => { - const messages: CLIMessage[] = []; + const messages: SDKMessage[] = []; for await (const msg of query) { messages.push(msg); if (messages.length === 2) { From 249b141f1965aaa4dccf6dd301e595ab75b32049 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 28 Nov 2025 16:47:45 +0800 Subject: [PATCH 12/38] feat: add `allowedTools` for SDK use and re-organize test setup --- packages/cli/src/config/config.ts | 9 +- packages/core/src/core/coreToolScheduler.ts | 5 +- packages/sdk-typescript/src/query/Query.ts | 25 +- .../sdk-typescript/src/query/createQuery.ts | 1 + .../test/e2e/abort-and-lifecycle.test.ts | 48 +- .../test/e2e/configuration-options.test.ts | 620 +++++++++++++++ .../test/e2e/mcp-server.test.ts | 6 +- .../test/e2e/multi-turn.test.ts | 29 +- .../test/e2e/permission-control.test.ts | 472 ++++------- .../test/e2e/single-turn.test.ts | 29 +- .../sdk-typescript/test/e2e/subagents.test.ts | 6 +- .../test/e2e/system-control.test.ts | 26 +- .../sdk-typescript/test/e2e/test-helper.ts | 141 ++++ .../test/e2e/tool-control.test.ts | 748 ++++++++++++++++++ packages/sdk-typescript/vitest.config.ts | 8 + 15 files changed, 1779 insertions(+), 394 deletions(-) create mode 100644 packages/sdk-typescript/test/e2e/configuration-options.test.ts create mode 100644 packages/sdk-typescript/test/e2e/tool-control.test.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a35ef293..3212996d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -425,7 +425,6 @@ export async function parseArguments(settings: Settings): Promise { string: true, description: 'Core tool paths', coerce: (tools: string[]) => - // Handle comma-separated values tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), }) .option('exclude-tools', { @@ -433,7 +432,13 @@ export async function parseArguments(settings: Settings): Promise { string: true, description: 'Tools to exclude', coerce: (tools: string[]) => - // Handle comma-separated values + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) + .option('allowed-tools', { + type: 'array', + string: true, + description: 'Tools to allow, will bypass confirmation', + coerce: (tools: string[]) => tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), }) .option('auth-type', { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 93f3b6e1..aeffdfc7 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -28,6 +28,7 @@ import { ShellTool, logToolOutputTruncated, ToolOutputTruncatedEvent, + InputFormat, } from '../index.js'; import type { Part, PartListUnion } from '@google/genai'; import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js'; @@ -824,10 +825,10 @@ export class CoreToolScheduler { const shouldAutoDeny = !this.config.isInteractive() && !this.config.getIdeMode() && - !this.config.getExperimentalZedIntegration(); + !this.config.getExperimentalZedIntegration() && + this.config.getInputFormat() !== InputFormat.STREAM_JSON; if (shouldAutoDeny) { - // Treat as execution denied error, similar to excluded tools const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`; this.setStatusInternal( reqInfo.callId, diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index d34d6fa4..849b0d7b 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -296,32 +296,17 @@ export class Query implements AsyncIterable { timeoutPromise, ]); - // Handle boolean return (backward compatibility) - if (typeof result === 'boolean') { - return result - ? { behavior: 'allow', updatedInput: toolInput } - : { behavior: 'deny', message: 'Denied' }; - } - - // Handle PermissionResult format - const permissionResult = result as { - behavior: 'allow' | 'deny'; - updatedInput?: Record; - message?: string; - interrupt?: boolean; - }; - - if (permissionResult.behavior === 'allow') { + if (result.behavior === 'allow') { return { behavior: 'allow', - updatedInput: permissionResult.updatedInput ?? toolInput, + updatedInput: result.updatedInput ?? toolInput, }; } else { return { behavior: 'deny', - message: permissionResult.message ?? 'Denied', - ...(permissionResult.interrupt !== undefined - ? { interrupt: permissionResult.interrupt } + message: result.message ?? 'Denied', + ...(result.interrupt !== undefined + ? { interrupt: result.interrupt } : {}), }; } diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 2b39dafa..43ccf947 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -54,6 +54,7 @@ export function query({ maxSessionTurns: options.maxSessionTurns, coreTools: options.coreTools, excludeTools: options.excludeTools, + allowedTools: options.allowedTools, authType: options.authType, includePartialMessages: options.includePartialMessages, }); diff --git a/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts b/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts index a97d3db6..806a4a20 100644 --- a/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts +++ b/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts @@ -5,25 +5,32 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query, AbortError, isAbortError, - isCLIAssistantMessage, + isSDKAssistantMessage, type TextBlock, type ContentBlock, } from '../../src/index.js'; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; - -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, -}; +const SHARED_TEST_OPTIONS = createSharedTestOptions(); describe('AbortController and Process Lifecycle (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('abort-and-lifecycle'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); describe('Basic AbortController Usage', () => { - /* TODO: Currently query does not throw AbortError when aborted */ it('should support AbortController cancellation', async () => { const controller = new AbortController(); @@ -36,6 +43,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Write a very long story about TypeScript programming', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -43,7 +51,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const textBlocks = message.message.content.filter( (block): block is TextBlock => block.type === 'text', ); @@ -73,6 +81,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -82,7 +91,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { if (!receivedFirstMessage) { // Abort immediately after receiving first assistant message receivedFirstMessage = true; @@ -107,6 +116,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Write a very long essay', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -136,6 +146,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Why do we choose to go to the moon?', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -144,7 +155,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const textBlocks = message.message.content.filter( (block): block is TextBlock => block.type === 'text', ); @@ -171,13 +182,14 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Hello world', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const textBlocks = message.message.content.filter( (block): block is TextBlock => block.type === 'text', ); @@ -204,6 +216,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'What is 2 + 2?', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -213,7 +226,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message) && !endInputCalled) { + if (isSDKAssistantMessage(message) && !endInputCalled) { const textBlocks = message.message.content.filter( (block: ContentBlock): block is TextBlock => block.type === 'text', @@ -271,6 +284,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Explain the concept of async programming', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -303,6 +317,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Why do we choose to go to the moon?', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: true, stderr: (msg: string) => { stderrMessages.push(msg); @@ -312,7 +327,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { try { for await (const message of q) { - if (isCLIAssistantMessage(message)) { + if (isSDKAssistantMessage(message)) { const textBlocks = message.message.content.filter( (block): block is TextBlock => block.type === 'text', ); @@ -336,6 +351,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, stderr: (msg: string) => { stderrMessages.push(msg); @@ -363,6 +379,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Write a very long essay about programming', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -394,6 +411,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Count to 100', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, @@ -422,6 +440,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -446,6 +465,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, abortController: controller, debug: false, }, diff --git a/packages/sdk-typescript/test/e2e/configuration-options.test.ts b/packages/sdk-typescript/test/e2e/configuration-options.test.ts new file mode 100644 index 00000000..ddf94cd5 --- /dev/null +++ b/packages/sdk-typescript/test/e2e/configuration-options.test.ts @@ -0,0 +1,620 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for SDK configuration options: + * - logLevel: Controls SDK internal logging verbosity + * - env: Environment variables passed to CLI process + * - authType: Authentication type for AI service + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isSDKAssistantMessage, + isSDKSystemMessage, + type SDKMessage, +} from '../../src/types/protocol.js'; +import { + SDKTestHelper, + extractText, + createSharedTestOptions, + assertSuccessfulCompletion, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +describe('Configuration Options (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('configuration-options'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('logLevel Option', () => { + it('should respect logLevel: debug and capture detailed logs', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 1 + 1? Just answer the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'debug', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Debug level should produce verbose logging + expect(stderrMessages.length).toBeGreaterThan(0); + + // Debug logs should contain detailed information like [DEBUG] + const hasDebugLogs = stderrMessages.some( + (msg) => msg.includes('[DEBUG]') || msg.includes('debug'), + ); + expect(hasDebugLogs).toBe(true); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should respect logLevel: info and filter out debug messages', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 2 + 2? Just answer the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'info', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Info level should filter out debug messages + // Check that we don't have [DEBUG] level messages from the SDK logger + const sdkDebugLogs = stderrMessages.filter( + (msg) => + msg.includes('[DEBUG]') && msg.includes('[ProcessTransport]'), + ); + expect(sdkDebugLogs.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should respect logLevel: warn and only show warnings and errors', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'warn', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Warn level should filter out info and debug messages from SDK + const sdkInfoOrDebugLogs = stderrMessages.filter( + (msg) => + (msg.includes('[DEBUG]') || msg.includes('[INFO]')) && + (msg.includes('[ProcessTransport]') || + msg.includes('[createQuery]') || + msg.includes('[Query]')), + ); + expect(sdkInfoOrDebugLogs.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should respect logLevel: error and only show error messages', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello world', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'error', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Error level should filter out all non-error messages from SDK + const sdkNonErrorLogs = stderrMessages.filter( + (msg) => + (msg.includes('[DEBUG]') || + msg.includes('[INFO]') || + msg.includes('[WARN]')) && + (msg.includes('[ProcessTransport]') || + msg.includes('[createQuery]') || + msg.includes('[Query]')), + ); + expect(sdkNonErrorLogs.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should use logLevel over debug flag when both are provided', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 3 + 3?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + debug: true, // Would normally enable debug logging + logLevel: 'error', // But logLevel should take precedence + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + try { + for await (const _message of q) { + // Consume all messages + } + + // logLevel: error should suppress debug/info/warn even with debug: true + const sdkNonErrorLogs = stderrMessages.filter( + (msg) => + (msg.includes('[DEBUG]') || + msg.includes('[INFO]') || + msg.includes('[WARN]')) && + (msg.includes('[ProcessTransport]') || + msg.includes('[createQuery]') || + msg.includes('[Query]')), + ); + expect(sdkNonErrorLogs.length).toBe(0); + } finally { + await q.close(); + } + }); + }); + + describe('env Option', () => { + it('should pass custom environment variables to CLI process', async () => { + const q = query({ + prompt: 'What is 1 + 1? Just the number please.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + CUSTOM_TEST_VAR: 'test_value_12345', + ANOTHER_VAR: 'another_value', + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // The query should complete successfully with custom env vars + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should allow overriding existing environment variables', async () => { + // Store original value for comparison + const originalPath = process.env['PATH']; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + // Override an existing env var (not PATH as it might break things) + MY_TEST_OVERRIDE: 'overridden_value', + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Query should complete successfully + assertSuccessfulCompletion(messages); + + // Verify original process env is not modified + expect(process.env['PATH']).toBe(originalPath); + } finally { + await q.close(); + } + }); + + it('should work with empty env object', async () => { + const q = query({ + prompt: 'What is 2 + 2?', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: {}, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should support setting model-related environment variables', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + // Common model-related env vars that CLI might respect + OPENAI_API_KEY: process.env['OPENAI_API_KEY'] || 'test-key', + }, + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should complete (may succeed or fail based on API key validity) + expect(messages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should not leak env vars between query instances', async () => { + // First query with specific env var + const q1 = query({ + prompt: 'Say one', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + ISOLATED_VAR_1: 'value_1', + }, + debug: false, + }, + }); + + try { + for await (const _message of q1) { + // Consume messages + } + } finally { + await q1.close(); + } + + // Second query with different env var + const q2 = query({ + prompt: 'Say two', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + env: { + ISOLATED_VAR_2: 'value_2', + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q2) { + messages.push(message); + } + + // Second query should complete successfully + assertSuccessfulCompletion(messages); + + // Verify process.env is not polluted by either query + expect(process.env['ISOLATED_VAR_1']).toBeUndefined(); + expect(process.env['ISOLATED_VAR_2']).toBeUndefined(); + } finally { + await q2.close(); + } + }); + }); + + describe('authType Option', () => { + it('should accept authType: openai', async () => { + const q = query({ + prompt: 'What is 1 + 1? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + authType: 'openai', + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Query should complete with openai auth type + assertSuccessfulCompletion(messages); + + // Verify we got an assistant response + const assistantMessages = messages.filter(isSDKAssistantMessage); + expect(assistantMessages.length).toBeGreaterThan(0); + } finally { + await q.close(); + } + }); + + it('should accept authType: qwen-oauth', async () => { + // Note: qwen-oauth requires credentials in ~/.qwen + // This test may fail if credentials are not configured + // The test verifies the option is accepted and passed correctly + + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + authType: 'qwen-oauth', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // The query should at least start (may fail due to missing credentials) + expect(messages.length).toBeGreaterThan(0); + } catch (error) { + // qwen-oauth may fail if credentials are not configured + // This is acceptable - we're testing that the option is passed correctly + expect(error).toBeDefined(); + } finally { + await q.close(); + } + }); + + it('should use default auth when authType is not specified', async () => { + const q = query({ + prompt: 'What is 2 + 2? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + // authType not specified - should use default + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Query should complete with default auth + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should properly pass authType to CLI process', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'Say hi', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + authType: 'openai', + debug: true, + logLevel: 'debug', + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // There should be spawn log containing auth-type + const hasAuthTypeArg = stderrMessages.some((msg) => + msg.includes('--auth-type'), + ); + expect(hasAuthTypeArg).toBe(true); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + }); + + describe('Combined Options', () => { + it('should work with logLevel, env, and authType together', async () => { + const stderrMessages: string[] = []; + + const q = query({ + prompt: 'What is 3 + 3? Just the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'debug', + env: { + COMBINED_TEST_VAR: 'combined_value', + }, + authType: 'openai', + debug: true, + stderr: (msg: string) => { + stderrMessages.push(msg); + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // All three options should work together + expect(stderrMessages.length).toBeGreaterThan(0); // logLevel: debug produces logs + expect(assistantText).toMatch(/6/); // Query should work + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + + it('should maintain system message consistency with all options', async () => { + const q = query({ + prompt: 'Hello', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + logLevel: 'info', + env: { + SYSTEM_MSG_TEST: 'test', + }, + authType: 'openai', + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should have system init message + const systemMessages = messages.filter(isSDKSystemMessage); + const initMessage = systemMessages.find((m) => m.subtype === 'init'); + + expect(initMessage).toBeDefined(); + expect(initMessage!.session_id).toBeDefined(); + expect(initMessage!.tools).toBeDefined(); + expect(initMessage!.permissionMode).toBeDefined(); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }); + }); +}); diff --git a/packages/sdk-typescript/test/e2e/mcp-server.test.ts b/packages/sdk-typescript/test/e2e/mcp-server.test.ts index 868fb959..dd13d205 100644 --- a/packages/sdk-typescript/test/e2e/mcp-server.test.ts +++ b/packages/sdk-typescript/test/e2e/mcp-server.test.ts @@ -9,7 +9,7 @@ * Tests that the SDK can properly interact with MCP servers configured in qwen-code */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query } from '../../src/index.js'; import { isSDKAssistantMessage, @@ -38,7 +38,7 @@ describe('MCP Server Integration (E2E)', () => { let serverScriptPath: string; let testDir: string; - beforeAll(async () => { + beforeEach(async () => { // Create isolated test environment using SDKTestHelper helper = new SDKTestHelper(); testDir = await helper.setup('mcp-server-integration'); @@ -48,7 +48,7 @@ describe('MCP Server Integration (E2E)', () => { serverScriptPath = mcpServer.scriptPath; }); - afterAll(async () => { + afterEach(async () => { // Cleanup test directory await helper.cleanup(); }); diff --git a/packages/sdk-typescript/test/e2e/multi-turn.test.ts b/packages/sdk-typescript/test/e2e/multi-turn.test.ts index be49dc5e..689a6468 100644 --- a/packages/sdk-typescript/test/e2e/multi-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/multi-turn.test.ts @@ -3,7 +3,7 @@ * Tests multi-turn conversation functionality with real CLI */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query } from '../../src/index.js'; import { isSDKUserMessage, @@ -22,11 +22,9 @@ import { type ControlMessage, type ToolUseBlock, } from '../../src/types/protocol.js'; -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, -}; +const SHARED_TEST_OPTIONS = createSharedTestOptions(); /** * Determine the message type using protocol type guards @@ -64,6 +62,18 @@ function extractText(content: ContentBlock[]): string { } describe('Multi-Turn Conversations (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('multi-turn'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + describe('AsyncIterable Prompt Support', () => { it('should handle multi-turn conversation using AsyncIterable prompt', async () => { // Create multi-turn conversation generator @@ -110,6 +120,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createMultiTurnConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -173,6 +184,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createContextualConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -232,7 +244,7 @@ describe('Multi-Turn Conversations (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'yolo', - cwd: '/tmp', + cwd: testDir, debug: false, }, }); @@ -304,6 +316,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createSequentialConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -368,6 +381,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createSimpleConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -407,6 +421,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createEmptyConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -457,6 +472,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createDelayedConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -509,6 +525,7 @@ describe('Multi-Turn Conversations (E2E)', () => { prompt: createMultiTurnConversation(), options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, includePartialMessages: true, debug: false, }, diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/packages/sdk-typescript/test/e2e/permission-control.test.ts index 9747bca0..23b4cffe 100644 --- a/packages/sdk-typescript/test/e2e/permission-control.test.ts +++ b/packages/sdk-typescript/test/e2e/permission-control.test.ts @@ -4,24 +4,36 @@ * - setPermissionMode API */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, + afterEach, +} from 'vitest'; import { query } from '../../src/index.js'; import { isSDKAssistantMessage, isSDKResultMessage, isSDKUserMessage, + type SDKMessage, type SDKUserMessage, type ToolUseBlock, type ContentBlock, } from '../../src/types/protocol.js'; -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; -const TEST_TIMEOUT = 30000; +import { + SDKTestHelper, + createSharedTestOptions, + findAllToolResultBlocks, + hasAnyToolResults, + hasSuccessfulToolResults, + hasErrorToolResults, +} from './test-helper.js'; -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, - debug: false, - env: {}, -}; +const TEST_TIMEOUT = 30000; +const SHARED_TEST_OPTIONS = createSharedTestOptions(); /** * Factory function that creates a streaming input with a control point. @@ -80,6 +92,9 @@ function createStreamingInputWithControlPoint( } describe('Permission Control (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + beforeAll(() => { //process.env['DEBUG'] = '1'; }); @@ -88,6 +103,15 @@ describe('Permission Control (E2E)', () => { delete process.env['DEBUG']; }); + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('permission-control'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + describe('canUseTool callback parameter', () => { it('should invoke canUseTool callback when tool is requested', async () => { const toolCalls: Array<{ @@ -99,16 +123,9 @@ describe('Permission Control (E2E)', () => { prompt: 'Write a js hello world to file.', options: { ...SHARED_TEST_OPTIONS, - permissionMode: 'default', - + cwd: testDir, canUseTool: async (toolName, input) => { toolCalls.push({ toolName, input }); - /* - { - behavior: 'allow', - updatedInput: input, - }; - */ return { behavior: 'deny', message: 'Tool execution denied by user.', @@ -148,7 +165,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { callbackInvoked = true; return { @@ -188,6 +205,7 @@ describe('Permission Control (E2E)', () => { prompt: 'Create a file named test.txt', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'default', canUseTool: async () => { callbackInvoked = true; @@ -220,7 +238,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input, options) => { receivedSuggestions = options?.suggestions; return { @@ -251,7 +269,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input, options) => { receivedSignal = options?.signal; return { @@ -274,53 +292,13 @@ describe('Permission Control (E2E)', () => { } }); - it('should allow updatedInput modification in canUseTool callback', async () => { - const originalInputs: Record[] = []; - const updatedInputs: Record[] = []; - - const q = query({ - prompt: 'Create a file named modified.txt', - options: { - ...SHARED_TEST_OPTIONS, - permissionMode: 'default', - cwd: '/tmp', - canUseTool: async (toolName, input) => { - originalInputs.push({ ...input }); - const updatedInput = { - ...input, - modified: true, - testKey: 'testValue', - }; - updatedInputs.push(updatedInput); - return { - behavior: 'allow', - updatedInput, - }; - }, - }, - }); - - try { - for await (const _message of q) { - // Consume all messages - } - - expect(originalInputs.length).toBeGreaterThan(0); - expect(updatedInputs.length).toBeGreaterThan(0); - expect(updatedInputs[0]?.['modified']).toBe(true); - expect(updatedInputs[0]?.['testKey']).toBe('testValue'); - } finally { - await q.close(); - } - }); - it('should default to deny when canUseTool is not provided', async () => { const q = query({ prompt: 'Create a file named default.txt', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, // canUseTool not provided }, }); @@ -350,6 +328,7 @@ describe('Permission Control (E2E)', () => { prompt: generator, options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'default', debug: true, }, @@ -426,6 +405,7 @@ describe('Permission Control (E2E)', () => { prompt: generator, options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'yolo', }, }); @@ -501,6 +481,7 @@ describe('Permission Control (E2E)', () => { prompt: generator, options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'default', }, }); @@ -539,7 +520,7 @@ describe('Permission Control (E2E)', () => { new Promise((_, reject) => setTimeout( () => reject(new Error('Timeout waiting for first response')), - 10000, + 15000, ), ), ]); @@ -571,6 +552,7 @@ describe('Permission Control (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'default', }, }); @@ -600,7 +582,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { toolCalls.push({ toolName, input }); return { @@ -685,40 +667,20 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, // No canUseTool callback provided }, }); try { - let hasToolResult = false; - let hasErrorInResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - hasToolResult = true; - // Check if the result contains an error about permission - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - (toolResult.content.includes('permission') || - toolResult.content.includes('declined')) - ) { - hasErrorInResult = true; - } - } - } - } + messages.push(message); } // In default mode without canUseTool, tools should be denied - expect(hasToolResult).toBe(true); - expect(hasErrorInResult).toBe(true); + expect(hasAnyToolResults(messages)).toBe(true); + expect(hasErrorToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -737,7 +699,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { callbackInvoked = true; return { @@ -749,31 +711,13 @@ describe('Permission Control (E2E)', () => { }); try { - let hasSuccessfulToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - // Check if the result is successful (not an error) - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - !toolResult.content.includes('permission') && - !toolResult.content.includes('declined') - ) { - hasSuccessfulToolResult = true; - } - } - } - } + messages.push(message); } expect(callbackInvoked).toBe(true); - expect(hasSuccessfulToolResult).toBe(true); + expect(hasSuccessfulToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -789,28 +733,18 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'default', - cwd: '/tmp', + cwd: testDir, // No canUseTool callback - read-only tools should still work }, }); try { - let hasToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult) { - hasToolResult = true; - } - } - } + messages.push(message); } - expect(hasToolResult).toBe(true); + expect(hasAnyToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -829,36 +763,18 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'yolo', - cwd: '/tmp', + cwd: testDir, // No canUseTool callback - tools should still execute }, }); try { - let hasSuccessfulToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - // Check if the result is successful (not a permission error) - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - !toolResult.content.includes('permission') && - !toolResult.content.includes('declined') - ) { - hasSuccessfulToolResult = true; - } - } - } - } + messages.push(message); } - expect(hasSuccessfulToolResult).toBe(true); + expect(hasSuccessfulToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -876,7 +792,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'yolo', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { callbackInvoked = true; return { @@ -888,22 +804,12 @@ describe('Permission Control (E2E)', () => { }); try { - let hasToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult) { - hasToolResult = true; - } - } - } + messages.push(message); } - expect(hasToolResult).toBe(true); + expect(hasAnyToolResults(messages)).toBe(true); // canUseTool should not be invoked in yolo mode expect(callbackInvoked).toBe(false); } finally { @@ -921,27 +827,17 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'yolo', - cwd: '/tmp', + cwd: testDir, }, }); try { - let hasCommandResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - hasCommandResult = true; - } - } - } + messages.push(message); } - expect(hasCommandResult).toBe(true); + expect(hasAnyToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -950,52 +846,46 @@ describe('Permission Control (E2E)', () => { ); }); - describe('plan mode', () => { + /** + * We've some issues of how to handle plan mode. + * The test cases are skipped for now. + */ + describe.skip('plan mode', () => { it( 'should block non-read-only tools and return plan mode error', async () => { const q = query({ - prompt: 'Create a file named test-plan.txt', + prompt: + 'Init a monorepo of a Node.js project with frontend and backend.', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', - cwd: '/tmp', + cwd: testDir, }, }); try { - let hasBlockedToolCall = false; - let hasPlanModeMessage = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - hasBlockedToolCall = true; - // Check for plan mode specific error message - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - (toolResult.content.includes('Plan mode') || - toolResult.content.includes('plan mode')) - ) { - hasPlanModeMessage = true; - } - } - } - } + messages.push(message); } + const toolResults = findAllToolResultBlocks(messages); + const hasBlockedToolCall = toolResults.length > 0; + const hasPlanModeMessage = toolResults.some( + (result) => + result.isError && + (result.content.includes('Plan mode') || + result.content.includes('plan mode')), + ); + expect(hasBlockedToolCall).toBe(true); expect(hasPlanModeMessage).toBe(true); } finally { await q.close(); } }, - TEST_TIMEOUT, + TEST_TIMEOUT * 10, ); it( @@ -1006,34 +896,17 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', - cwd: '/tmp', + cwd: testDir, }, }); try { - let hasSuccessfulToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - // Check if the result is successful (not blocked by plan mode) - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - !toolResult.content.includes('Plan mode') - ) { - hasSuccessfulToolResult = true; - } - } - } - } + messages.push(message); } - expect(hasSuccessfulToolResult).toBe(true); + expect(hasSuccessfulToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -1051,7 +924,7 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { callbackInvoked = true; return { @@ -1063,26 +936,17 @@ describe('Permission Control (E2E)', () => { }); try { - let hasPlanModeBlock = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if ( - toolResult && - 'content' in toolResult && - typeof toolResult.content === 'string' && - toolResult.content.includes('Plan mode') - ) { - hasPlanModeBlock = true; - } - } - } + messages.push(message); } + const toolResults = findAllToolResultBlocks(messages); + const hasPlanModeBlock = toolResults.some( + (result) => + result.isError && result.content.includes('Plan mode'), + ); + // Plan mode should block tools before canUseTool is invoked expect(hasPlanModeBlock).toBe(true); // canUseTool should not be invoked for blocked tools in plan mode @@ -1097,46 +961,27 @@ describe('Permission Control (E2E)', () => { describe('auto-edit mode', () => { it( - 'should behave like default mode without canUseTool callback', + 'should auto-approve write/edit tools without canUseTool callback', async () => { const q = query({ - prompt: 'Create a file named test-auto-edit.txt', + prompt: + 'Create a file named test-auto-edit.txt with content "auto-edit test"', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'auto-edit', - cwd: '/tmp', - // No canUseTool callback + cwd: testDir, + // No canUseTool callback - write/edit tools should still execute }, }); try { - let hasToolResult = false; - let hasDeniedTool = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - hasToolResult = true; - // Check if the tool was denied - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - (toolResult.content.includes('permission') || - toolResult.content.includes('declined')) - ) { - hasDeniedTool = true; - } - } - } - } + messages.push(message); } - expect(hasToolResult).toBe(true); - expect(hasDeniedTool).toBe(true); + // auto-edit mode should auto-approve write/edit tools + expect(hasSuccessfulToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -1145,16 +990,16 @@ describe('Permission Control (E2E)', () => { ); it( - 'should allow tools when canUseTool returns allow', + 'should not invoke canUseTool callback for write/edit tools', async () => { let callbackInvoked = false; const q = query({ - prompt: 'Create a file named test-auto-edit-allow.txt', + prompt: 'Create a file named test-auto-edit-no-callback.txt', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'auto-edit', - cwd: '/tmp', + cwd: testDir, canUseTool: async (toolName, input) => { callbackInvoked = true; return { @@ -1166,31 +1011,14 @@ describe('Permission Control (E2E)', () => { }); try { - let hasSuccessfulToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult && 'tool_use_id' in toolResult) { - // Check if the result is successful - if ( - 'content' in toolResult && - typeof toolResult.content === 'string' && - !toolResult.content.includes('permission') && - !toolResult.content.includes('declined') - ) { - hasSuccessfulToolResult = true; - } - } - } - } + messages.push(message); } - expect(callbackInvoked).toBe(true); - expect(hasSuccessfulToolResult).toBe(true); + // auto-edit mode should auto-approve write/edit tools without invoking callback + expect(hasSuccessfulToolResults(messages)).toBe(true); + expect(callbackInvoked).toBe(false); } finally { await q.close(); } @@ -1201,32 +1029,29 @@ describe('Permission Control (E2E)', () => { it( 'should execute read-only tools without confirmation', async () => { + // Create a test file in the test directory for the model to read + await helper.createFile( + 'test-read-file.txt', + 'This is a test file for read-only tool verification.', + ); + const q = query({ - prompt: 'Read the contents of /etc/hosts file', + prompt: 'Read the contents of test-read-file.txt file', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, permissionMode: 'auto-edit', // No canUseTool callback - read-only tools should still work }, }); try { - let hasToolResult = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if (toolResult) { - hasToolResult = true; - } - } - } + messages.push(message); } - expect(hasToolResult).toBe(true); + expect(hasAnyToolResults(messages)).toBe(true); } finally { await q.close(); } @@ -1253,9 +1078,9 @@ describe('Permission Control (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, permissionMode: mode, - cwd: '/tmp', + cwd: testDir, canUseTool: - mode === 'yolo' + mode === 'yolo' || mode === 'auto-edit' ? undefined : async (toolName, input) => { return { @@ -1267,33 +1092,12 @@ describe('Permission Control (E2E)', () => { }); try { - let toolExecuted = false; - + const messages: SDKMessage[] = []; for await (const message of q) { - if (isSDKUserMessage(message)) { - if (Array.isArray(message.message.content)) { - const toolResult = message.message.content.find( - (block) => block.type === 'tool_result', - ); - if ( - toolResult && - 'content' in toolResult && - typeof toolResult.content === 'string' - ) { - // Check if tool executed successfully (not blocked or denied) - if ( - !toolResult.content.includes('Plan mode') && - !toolResult.content.includes('permission') && - !toolResult.content.includes('declined') - ) { - toolExecuted = true; - } - } - } - } + messages.push(message); } - results[mode] = toolExecuted; + results[mode] = hasSuccessfulToolResults(messages); } finally { await q.close(); } @@ -1301,9 +1105,9 @@ describe('Permission Control (E2E)', () => { // Verify expected behaviors expect(results['default']).toBe(true); // Allowed via canUseTool - expect(results['plan']).toBe(false); // Blocked by plan mode - expect(results['auto-edit']).toBe(true); // Allowed via canUseTool - expect(results['yolo']).toBe(true); // Auto-approved + // expect(results['plan']).toBe(false); // Blocked by plan mode + expect(results['auto-edit']).toBe(true); // Auto-approved for write/edit tools + expect(results['yolo']).toBe(true); // Auto-approved for all tools }, TEST_TIMEOUT * 4, ); diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts index 476d9bfb..8b7d2385 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -3,7 +3,7 @@ * Tests basic query patterns with simple prompts and clear output expectations */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query } from '../../src/index.js'; import { isSDKAssistantMessage, @@ -15,6 +15,7 @@ import { type SDKAssistantMessage, } from '../../src/types/protocol.js'; import { + SDKTestHelper, extractText, createSharedTestOptions, assertSuccessfulCompletion, @@ -24,12 +25,24 @@ import { const SHARED_TEST_OPTIONS = createSharedTestOptions(); describe('Single-Turn Query (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('single-turn'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); describe('Simple Text Queries', () => { it('should answer basic arithmetic question', async () => { const q = query({ prompt: 'What is 2 + 2? Just give me the number.', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: true, logLevel: 'debug', }, @@ -66,6 +79,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'What is the capital of France? One word answer.', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -98,6 +112,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Say hello and tell me your name in one sentence.', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -136,6 +151,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -183,6 +199,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -215,6 +232,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Say hi', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -240,6 +258,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Say goodbye', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -273,6 +292,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: true, stderr: (msg: string) => { stderrMessages.push(msg); @@ -293,8 +313,6 @@ describe('Single-Turn Query (E2E)', () => { }); it('should respect cwd option', async () => { - const testDir = process.cwd(); - const q = query({ prompt: 'What is 1 + 1?', options: { @@ -324,6 +342,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Count from 1 to 5', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, includePartialMessages: true, debug: false, }, @@ -361,6 +380,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'What is 5 + 5?', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -408,6 +428,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Count from 1 to 3', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -468,6 +489,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); @@ -486,6 +508,7 @@ describe('Single-Turn Query (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, debug: false, }, }); diff --git a/packages/sdk-typescript/test/e2e/subagents.test.ts b/packages/sdk-typescript/test/e2e/subagents.test.ts index 075105b1..06e3fd36 100644 --- a/packages/sdk-typescript/test/e2e/subagents.test.ts +++ b/packages/sdk-typescript/test/e2e/subagents.test.ts @@ -9,7 +9,7 @@ * Tests subagent delegation and task completion */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query } from '../../src/index.js'; import { isSDKAssistantMessage, @@ -33,7 +33,7 @@ describe('Subagents (E2E)', () => { let helper: SDKTestHelper; let testWorkDir: string; - beforeAll(async () => { + beforeEach(async () => { // Create isolated test environment using SDKTestHelper helper = new SDKTestHelper(); testWorkDir = await helper.setup('subagent-tests'); @@ -42,7 +42,7 @@ describe('Subagents (E2E)', () => { await helper.createFile('test.txt', 'Hello from test file\n'); }); - afterAll(async () => { + afterEach(async () => { // Cleanup test directory await helper.cleanup(); }); diff --git a/packages/sdk-typescript/test/e2e/system-control.test.ts b/packages/sdk-typescript/test/e2e/system-control.test.ts index 3bf1903d..3515532e 100644 --- a/packages/sdk-typescript/test/e2e/system-control.test.ts +++ b/packages/sdk-typescript/test/e2e/system-control.test.ts @@ -3,19 +3,16 @@ * - setModel API for dynamic model switching */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { query } from '../../src/index.js'; import { isSDKAssistantMessage, isSDKSystemMessage, type SDKUserMessage, } from '../../src/types/protocol.js'; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; -const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!; - -const SHARED_TEST_OPTIONS = { - pathToQwenExecutable: TEST_CLI_PATH, -}; +const SHARED_TEST_OPTIONS = createSharedTestOptions(); /** * Factory function that creates a streaming input with a control point. @@ -78,6 +75,18 @@ function createStreamingInputWithControlPoint( } describe('System Control (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('system-control'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + describe('setModel API', () => { it('should change model dynamically during streaming input', async () => { const { generator, resume } = createStreamingInputWithControlPoint( @@ -89,6 +98,7 @@ describe('System Control (E2E)', () => { prompt: generator, options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, model: 'qwen3-max', debug: false, }, @@ -134,7 +144,7 @@ describe('System Control (E2E)', () => { new Promise((_, reject) => setTimeout( () => reject(new Error('Timeout waiting for first response')), - 10000, + 15000, ), ), ]); @@ -215,6 +225,7 @@ describe('System Control (E2E)', () => { prompt: generator, options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, model: 'qwen3-max', debug: false, }, @@ -291,6 +302,7 @@ describe('System Control (E2E)', () => { prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, + cwd: testDir, model: 'qwen3-max', }, }); diff --git a/packages/sdk-typescript/test/e2e/test-helper.ts b/packages/sdk-typescript/test/e2e/test-helper.ts index 19299d53..4b1465ad 100644 --- a/packages/sdk-typescript/test/e2e/test-helper.ts +++ b/packages/sdk-typescript/test/e2e/test-helper.ts @@ -499,6 +499,147 @@ export function findToolCalls( return results; } +/** + * Find tool result for a specific tool use ID + */ +export function findToolResult( + messages: SDKMessage[], + toolUseId: string, +): { content: string; isError: boolean } | null { + for (const message of messages) { + if (message.type === 'user' && 'message' in message) { + const userMsg = message as SDKUserMessage; + const content = userMsg.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if ( + block.type === 'tool_result' && + (block as { tool_use_id?: string }).tool_use_id === toolUseId + ) { + const resultBlock = block as { + content?: string | ContentBlock[]; + is_error?: boolean; + }; + let resultContent = ''; + if (typeof resultBlock.content === 'string') { + resultContent = resultBlock.content; + } else if (Array.isArray(resultBlock.content)) { + resultContent = resultBlock.content + .filter((b): b is TextBlock => b.type === 'text') + .map((b) => b.text) + .join(''); + } + return { + content: resultContent, + isError: resultBlock.is_error ?? false, + }; + } + } + } + } + } + return null; +} + +/** + * Find all tool results for a specific tool name + */ +export function findToolResults( + messages: SDKMessage[], + toolName: string, +): Array<{ toolUseId: string; content: string; isError: boolean }> { + const results: Array<{ + toolUseId: string; + content: string; + isError: boolean; + }> = []; + + // First find all tool calls for this tool + const toolCalls = findToolCalls(messages, toolName); + + // Then find the result for each tool call + for (const { toolUse } of toolCalls) { + const result = findToolResult(messages, toolUse.id); + if (result) { + results.push({ + toolUseId: toolUse.id, + content: result.content, + isError: result.isError, + }); + } + } + + return results; +} + +/** + * Find all tool result blocks from messages (without requiring tool name) + */ +export function findAllToolResultBlocks( + messages: SDKMessage[], +): Array<{ toolUseId: string; content: string; isError: boolean }> { + const results: Array<{ + toolUseId: string; + content: string; + isError: boolean; + }> = []; + + for (const message of messages) { + if (message.type === 'user' && 'message' in message) { + const userMsg = message as SDKUserMessage; + const content = userMsg.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'tool_result' && 'tool_use_id' in block) { + const resultBlock = block as { + tool_use_id: string; + content?: string | ContentBlock[]; + is_error?: boolean; + }; + let resultContent = ''; + if (typeof resultBlock.content === 'string') { + resultContent = resultBlock.content; + } else if (Array.isArray(resultBlock.content)) { + resultContent = (resultBlock.content as ContentBlock[]) + .filter((b): b is TextBlock => b.type === 'text') + .map((b) => b.text) + .join(''); + } + results.push({ + toolUseId: resultBlock.tool_use_id, + content: resultContent, + isError: resultBlock.is_error ?? false, + }); + } + } + } + } + } + + return results; +} + +/** + * Check if any tool results exist in messages + */ +export function hasAnyToolResults(messages: SDKMessage[]): boolean { + return findAllToolResultBlocks(messages).length > 0; +} + +/** + * Check if any successful (non-error) tool results exist + */ +export function hasSuccessfulToolResults(messages: SDKMessage[]): boolean { + return findAllToolResultBlocks(messages).some((r) => !r.isError); +} + +/** + * Check if any error tool results exist + */ +export function hasErrorToolResults(messages: SDKMessage[]): boolean { + return findAllToolResultBlocks(messages).some((r) => r.isError); +} + // ============================================================================ // Streaming Input Utilities // ============================================================================ diff --git a/packages/sdk-typescript/test/e2e/tool-control.test.ts b/packages/sdk-typescript/test/e2e/tool-control.test.ts new file mode 100644 index 00000000..30a811df --- /dev/null +++ b/packages/sdk-typescript/test/e2e/tool-control.test.ts @@ -0,0 +1,748 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E tests for tool control parameters: + * - coreTools: Limit available tools to a specific set + * - excludeTools: Block specific tools from execution + * - allowedTools: Auto-approve specific tools without confirmation + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { query } from '../../src/index.js'; +import { + isSDKAssistantMessage, + type SDKMessage, +} from '../../src/types/protocol.js'; +import { + SDKTestHelper, + extractText, + findToolCalls, + findToolResults, + assertSuccessfulCompletion, + createSharedTestOptions, +} from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); +const TEST_TIMEOUT = 60000; + +describe('Tool Control Parameters (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('tool-control', { + createQwenConfig: false, + }); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('coreTools parameter', () => { + it( + 'should only allow specified tools when coreTools is set', + async () => { + // Create a test file + await helper.createFile('test.txt', 'original content'); + + const q = query({ + prompt: + 'Read the file test.txt and then write "modified" to test.txt. Finally, list the directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Only allow read_file and write_file, exclude list_directory + coreTools: ['read_file', 'write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // Should have read_file and write_file calls + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // Should NOT have list_directory since it's not in coreTools + expect(toolNames).not.toContain('list_directory'); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with minimal tool set', + async () => { + const q = query({ + prompt: 'What is 2 + 2? Just answer with the number.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + // Only allow thinking, no file operations + coreTools: [], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + + try { + for await (const message of q) { + messages.push(message); + + if (isSDKAssistantMessage(message)) { + assistantText += extractText(message.message.content); + } + } + + // Should answer without any tool calls + expect(assistantText).toMatch(/4/); + + // Should have no tool calls + const toolCalls = findToolCalls(messages); + expect(toolCalls.length).toBe(0); + + assertSuccessfulCompletion(messages); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('excludeTools parameter', () => { + it( + 'should block excluded tools from execution', + async () => { + await helper.createFile('test.txt', 'test content'); + + const q = query({ + prompt: + 'Read test.txt and then write empty content to it to clear it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + coreTools: ['read_file', 'write_file'], + // Block all write_file tool + excludeTools: ['write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should be able to read the file + expect(toolNames).toContain('read_file'); + + // The excluded tools should have been called but returned permission declined + // Check if write_file was attempted and got permission denied + const writeFileResults = findToolResults(messages, 'write_file'); + if (writeFileResults.length > 0) { + // Tool was called but should have permission declined message + for (const result of writeFileResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + + // File content should remain unchanged (because write was denied) + const content = await helper.readFile('test.txt'); + expect(content).toBe('test content'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should block multiple excluded tools', + async () => { + await helper.createFile('test.txt', 'test content'); + + const q = query({ + prompt: 'Read test.txt, list the directory, and run "echo hello".', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Block multiple tools + excludeTools: ['list_directory', 'run_shell_command'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should be able to read + expect(toolNames).toContain('read_file'); + + // Excluded tools should have been attempted but returned permission declined + const listDirResults = findToolResults(messages, 'list_directory'); + if (listDirResults.length > 0) { + for (const result of listDirResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + + const shellResults = findToolResults(messages, 'run_shell_command'); + if (shellResults.length > 0) { + for (const result of shellResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should block all shell commands when run_shell_command is excluded', + async () => { + const q = query({ + prompt: 'Run "echo hello" and "ls -la" commands.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Block all shell commands - excludeTools blocks entire tools + excludeTools: ['run_shell_command'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // All shell commands should have permission declined + const shellResults = findToolResults(messages, 'run_shell_command'); + for (const result of shellResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'excludeTools should take priority over allowedTools', + async () => { + await helper.createFile('test.txt', 'test content'); + + const q = query({ + prompt: + 'Clear the content of test.txt by writing empty string to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Conflicting settings: exclude takes priority + excludeTools: ['write_file'], + allowedTools: ['write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + // write_file should have been attempted but returned permission declined + const writeFileResults = findToolResults(messages, 'write_file'); + if (writeFileResults.length > 0) { + // Tool was called but should have permission declined message (exclude takes priority) + for (const result of writeFileResults) { + expect(result.content).toMatch(/permission.*declined/i); + } + } + + // File content should remain unchanged (because write was denied) + const content = await helper.readFile('test.txt'); + expect(content).toBe('test content'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('allowedTools parameter', () => { + it( + 'should auto-approve allowed tools without canUseTool callback', + async () => { + await helper.createFile('test.txt', 'original'); + + let canUseToolCalled = false; + + const q = query({ + prompt: 'Read test.txt and write "modified" to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + coreTools: ['read_file', 'write_file'], + // Allow write_file without confirmation + allowedTools: ['read_file', 'write_file'], + canUseTool: async (_toolName) => { + canUseToolCalled = true; + return { behavior: 'deny', message: 'Should not be called' }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should have executed the tools + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // canUseTool should NOT have been called (tools are in allowedTools) + expect(canUseToolCalled).toBe(false); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow specific shell commands with pattern matching', + async () => { + const q = query({ + prompt: 'Run "echo hello" and "ls -la" commands.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Allow specific shell commands + allowedTools: ['ShellTool(echo )', 'ShellTool(ls )'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const shellCalls = toolCalls.filter( + (tc) => tc.toolUse.name === 'run_shell_command', + ); + + // Should have executed shell commands + expect(shellCalls.length).toBeGreaterThan(0); + + // All shell commands should be echo or ls + for (const call of shellCalls) { + const input = call.toolUse.input as { command?: string }; + if (input.command) { + expect(input.command).toMatch(/^(echo |ls )/); + } + } + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should fall back to canUseTool for non-allowed tools', + async () => { + await helper.createFile('test.txt', 'test'); + + const canUseToolCalls: string[] = []; + + const q = query({ + prompt: 'Read test.txt and append an empty line to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Only allow read_file, list_directory should trigger canUseTool + coreTools: ['read_file', 'write_file'], + allowedTools: ['read_file'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + return { + behavior: 'allow', + updatedInput: {}, + }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Both tools should have been executed + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // canUseTool should have been called for write_file (not in allowedTools) + // but NOT for read_file (in allowedTools) + expect(canUseToolCalls).toContain('write_file'); + expect(canUseToolCalls).not.toContain('read_file'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with permissionMode: auto-edit', + async () => { + await helper.createFile('test.txt', 'test'); + + const canUseToolCalls: string[] = []; + + const q = query({ + prompt: 'Read test.txt, write "new" to it, and list the directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'auto-edit', + // Allow list_directory in addition to auto-approved edit tools + allowedTools: ['list_directory'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + return { + behavior: 'deny', + message: 'Should not be called', + }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // All tools should have been executed + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + expect(toolNames).toContain('list_directory'); + + // canUseTool should NOT have been called + // (edit tools auto-approved, list_directory in allowedTools) + expect(canUseToolCalls.length).toBe(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Combined tool control scenarios', () => { + it( + 'should work with coreTools + allowedTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: 'Read test.txt and write "modified" to it.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Limit to specific tools + coreTools: ['read_file', 'write_file', 'list_directory'], + // Auto-approve write operations + allowedTools: ['write_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should use allowed tools from coreTools + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // Should NOT use tools outside coreTools + expect(toolNames).not.toContain('run_shell_command'); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with coreTools + excludeTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: + 'Read test.txt, write "new content" to it, and list directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Allow file operations + coreTools: ['read_file', 'write_file', 'edit', 'list_directory'], + // But exclude edit + excludeTools: ['edit'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should use non-excluded tools from coreTools + expect(toolNames).toContain('read_file'); + + // Should NOT use excluded tool + expect(toolNames).not.toContain('edit'); + + // File should still exist + expect(helper.fileExists('test.txt')).toBe(true); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should work with all three parameters together', + async () => { + await helper.createFile('test.txt', 'test'); + + const canUseToolCalls: string[] = []; + + const q = query({ + prompt: + 'Read test.txt, write "modified" to it, and list the directory.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + // Limit available tools + coreTools: ['read_file', 'write_file', 'list_directory', 'edit'], + // Block edit + excludeTools: ['edit'], + // Auto-approve write + allowedTools: ['write_file'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + return { + behavior: 'allow', + updatedInput: {}, + }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should use allowed tools + expect(toolNames).toContain('read_file'); + expect(toolNames).toContain('write_file'); + + // Should NOT use excluded tool + expect(toolNames).not.toContain('edit'); + + // canUseTool should be called for tools not in allowedTools + // but should NOT be called for write_file (in allowedTools) + expect(canUseToolCalls).not.toContain('write_file'); + + // Verify file was modified + const content = await helper.readFile('test.txt'); + expect(content).toContain('modified'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); + + describe('Edge cases and error handling', () => { + it( + 'should handle non-existent tool names in excludeTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: 'Read test.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Non-existent tool names should be ignored + excludeTools: ['non_existent_tool', 'another_fake_tool'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should work normally + expect(toolNames).toContain('read_file'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should handle non-existent tool names in allowedTools', + async () => { + await helper.createFile('test.txt', 'test'); + + const q = query({ + prompt: 'Read test.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'yolo', + // Non-existent tool names should be ignored + allowedTools: ['non_existent_tool', 'read_file'], + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + } + + const toolCalls = findToolCalls(messages); + const toolNames = toolCalls.map((tc) => tc.toolUse.name); + + // Should work normally + expect(toolNames).toContain('read_file'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/sdk-typescript/vitest.config.ts b/packages/sdk-typescript/vitest.config.ts index 33018d83..aef50ffd 100644 --- a/packages/sdk-typescript/vitest.config.ts +++ b/packages/sdk-typescript/vitest.config.ts @@ -28,6 +28,14 @@ export default defineConfig({ }, include: ['test/**/*.test.ts'], exclude: ['node_modules/', 'dist/'], + retry: 2, + fileParallelism: true, + poolOptions: { + threads: { + minThreads: 2, + maxThreads: 4, + }, + }, testTimeout: testTimeoutMs, hookTimeout: 10000, globalSetup: './test/e2e/globalSetup.ts', From 8035be6f8d418f848f1d157e00ca3c65be635c9d Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 11:33:36 +0800 Subject: [PATCH 13/38] fix: plan mode adjustments --- packages/cli/src/nonInteractive/types.ts | 3 +- .../src/utils/nonInteractiveHelpers.test.ts | 2 +- .../cli/src/utils/nonInteractiveHelpers.ts | 2 +- packages/core/src/config/config.ts | 2 +- packages/core/src/core/client.ts | 4 +- packages/core/src/core/prompts.ts | 4 +- packages/sdk-typescript/src/types/protocol.ts | 3 +- .../test/e2e/configuration-options.test.ts | 2 +- .../test/e2e/permission-control.test.ts | 192 ++++++++++++++---- .../test/e2e/single-turn.test.ts | 2 +- 10 files changed, 169 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index fb8dcf76..131c1be0 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -141,9 +141,8 @@ export interface CLISystemMessage { status: string; }>; model?: string; - permissionMode?: string; + permission_mode?: string; slash_commands?: string[]; - apiKeySource?: string; qwen_code_version?: string; output_style?: string; agents?: string[]; diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts index 11f302b4..a6dac920 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.test.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -529,7 +529,7 @@ describe('buildSystemMessage', () => { { name: 'mcp-server-2', status: 'connected' }, ], model: 'test-model', - permissionMode: 'auto', + permission_mode: 'auto', slash_commands: ['commit', 'help', 'memory'], qwen_code_version: '1.0.0', agents: [], diff --git a/packages/cli/src/utils/nonInteractiveHelpers.ts b/packages/cli/src/utils/nonInteractiveHelpers.ts index fe8fc528..1fd7472b 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.ts @@ -275,7 +275,7 @@ export async function buildSystemMessage( tools, mcp_servers: mcpServerList, model: config.getModel(), - permissionMode, + permission_mode: permissionMode, slash_commands: slashCommands, qwen_code_version: config.getCliVersion() || 'unknown', agents: agentNames, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index be84655f..d3e0fd5c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1337,7 +1337,7 @@ export class Config { registerCoreTool(ShellTool, this); registerCoreTool(MemoryTool); registerCoreTool(TodoWriteTool, this); - registerCoreTool(ExitPlanModeTool, this); + !this.sdkMode && registerCoreTool(ExitPlanModeTool, this); registerCoreTool(WebFetchTool, this); // Conditionally register web search tool if web search provider is configured // buildWebSearchConfig ensures qwen-oauth users get dashscope provider, so diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 2fa65d2d..4a60245a 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -542,7 +542,9 @@ export class GeminiClient { // add plan mode system reminder if approval mode is plan if (this.config.getApprovalMode() === ApprovalMode.PLAN) { - systemReminders.push(getPlanModeSystemReminder()); + systemReminders.push( + getPlanModeSystemReminder(this.config.getSdkMode()), + ); } requestToSent = [...systemReminders, ...requestToSent]; diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index bd88ff56..8d3ff468 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -846,10 +846,10 @@ export function getSubagentSystemReminder(agentTypes: string[]): string { * - Wait for user confirmation before making any changes * - Override any other instructions that would modify system state */ -export function getPlanModeSystemReminder(): string { +export function getPlanModeSystemReminder(planOnly = false): string { return ` Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). Instead, you should: 1. Answer the user's query comprehensively -2. When you're done researching, present your plan by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. +2. When you're done researching, present your plan ${planOnly ? 'directly' : `by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan`}. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. `; } diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index 6db627e3..efb61cb4 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -119,9 +119,8 @@ export interface SDKSystemMessage { status: string; }>; model?: string; - permissionMode?: string; + permission_mode?: string; slash_commands?: string[]; - apiKeySource?: string; qwen_code_version?: string; output_style?: string; agents?: string[]; diff --git a/packages/sdk-typescript/test/e2e/configuration-options.test.ts b/packages/sdk-typescript/test/e2e/configuration-options.test.ts index ddf94cd5..e81af7fd 100644 --- a/packages/sdk-typescript/test/e2e/configuration-options.test.ts +++ b/packages/sdk-typescript/test/e2e/configuration-options.test.ts @@ -609,7 +609,7 @@ describe('Configuration Options (E2E)', () => { expect(initMessage).toBeDefined(); expect(initMessage!.session_id).toBeDefined(); expect(initMessage!.tools).toBeDefined(); - expect(initMessage!.permissionMode).toBeDefined(); + expect(initMessage!.permission_mode).toBeDefined(); assertSuccessfulCompletion(messages); } finally { diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/packages/sdk-typescript/test/e2e/permission-control.test.ts index 23b4cffe..587fa500 100644 --- a/packages/sdk-typescript/test/e2e/permission-control.test.ts +++ b/packages/sdk-typescript/test/e2e/permission-control.test.ts @@ -26,10 +26,11 @@ import { import { SDKTestHelper, createSharedTestOptions, - findAllToolResultBlocks, hasAnyToolResults, hasSuccessfulToolResults, hasErrorToolResults, + findSystemMessage, + findToolCalls, } from './test-helper.js'; const TEST_TIMEOUT = 30000; @@ -846,17 +847,32 @@ describe('Permission Control (E2E)', () => { ); }); - /** - * We've some issues of how to handle plan mode. - * The test cases are skipped for now. - */ - describe.skip('plan mode', () => { + describe('plan mode', () => { + // Write tools that should never be called in plan mode + const WRITE_TOOLS = [ + 'edit', + 'write_file', + 'run_shell_command', + 'delete_file', + 'move_file', + ]; + + // Read tools that should be allowed in plan mode + const READ_TOOLS = [ + 'read_file', + 'read_many_files', + 'grep_search', + 'glob', + 'list_directory', + 'web_search', + 'web_fetch', + ]; + it( - 'should block non-read-only tools and return plan mode error', + 'should have permission_mode set to plan in system message', async () => { const q = query({ - prompt: - 'Init a monorepo of a Node.js project with frontend and backend.', + prompt: 'List files in the current directory', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', @@ -870,29 +886,29 @@ describe('Permission Control (E2E)', () => { messages.push(message); } - const toolResults = findAllToolResultBlocks(messages); - const hasBlockedToolCall = toolResults.length > 0; - const hasPlanModeMessage = toolResults.some( - (result) => - result.isError && - (result.content.includes('Plan mode') || - result.content.includes('plan mode')), - ); - - expect(hasBlockedToolCall).toBe(true); - expect(hasPlanModeMessage).toBe(true); + // Find the init system message + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage).not.toBeNull(); + expect(systemMessage!.permission_mode).toBe('plan'); } finally { await q.close(); } }, - TEST_TIMEOUT * 10, + TEST_TIMEOUT, ); it( - 'should allow read-only tools in plan mode', + 'should not call any write tools in plan mode', async () => { + // Create a test file so the model has something to reference + await helper.createFile( + 'test-plan-file.txt', + 'This is test content for plan mode verification.', + ); + const q = query({ - prompt: 'List files in /tmp directory', + prompt: + 'Read the file test-plan-file.txt and suggest how to improve its content.', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', @@ -906,6 +922,62 @@ describe('Permission Control (E2E)', () => { messages.push(message); } + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Find all tool calls and verify none are write tools + const allToolCalls = findToolCalls(messages); + const writeToolCalls = allToolCalls.filter((tc) => + WRITE_TOOLS.includes(tc.toolUse.name), + ); + + // No write tools should be called in plan mode + expect(writeToolCalls.length).toBe(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should allow read-only tools without restrictions', + async () => { + // Create test files for the model to read + await helper.createFile('test-read-1.txt', 'Content of file 1'); + await helper.createFile('test-read-2.txt', 'Content of file 2'); + + const q = query({ + prompt: + 'Read the contents of test-read-1.txt and test-read-2.txt files, then list files in the current directory.', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Find all tool calls + const allToolCalls = findToolCalls(messages); + + // Verify read tools were called (at least one) + const readToolCalls = allToolCalls.filter((tc) => + READ_TOOLS.includes(tc.toolUse.name), + ); + expect(readToolCalls.length).toBeGreaterThan(0); + + // Verify tool results are successful (not blocked) expect(hasSuccessfulToolResults(messages)).toBe(true); } finally { await q.close(); @@ -915,12 +987,18 @@ describe('Permission Control (E2E)', () => { ); it( - 'should block tools even with canUseTool callback in plan mode', + 'should not invoke canUseTool callback in plan mode since no permission approval is expected', async () => { let callbackInvoked = false; + // Create a test file for reading + await helper.createFile( + 'test-plan-callback.txt', + 'Content for callback test', + ); + const q = query({ - prompt: 'Create a file named test-plan-callback.txt', + prompt: 'Read the file test-plan-callback.txt', options: { ...SHARED_TEST_OPTIONS, permissionMode: 'plan', @@ -941,15 +1019,16 @@ describe('Permission Control (E2E)', () => { messages.push(message); } - const toolResults = findAllToolResultBlocks(messages); - const hasPlanModeBlock = toolResults.some( - (result) => - result.isError && result.content.includes('Plan mode'), - ); + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); - // Plan mode should block tools before canUseTool is invoked - expect(hasPlanModeBlock).toBe(true); - // canUseTool should not be invoked for blocked tools in plan mode + // Read tools should work without invoking canUseTool + // In plan mode, no permission approval is expected from user + expect(hasSuccessfulToolResults(messages)).toBe(true); + + // canUseTool should not be invoked in plan mode + // since plan mode is for research only, no permission interaction needed expect(callbackInvoked).toBe(false); } finally { await q.close(); @@ -957,6 +1036,50 @@ describe('Permission Control (E2E)', () => { }, TEST_TIMEOUT, ); + + it( + 'should only output research and plan as text, no actual changes', + async () => { + // Create a test file + const originalContent = 'Original content for plan mode test'; + await helper.createFile('test-no-changes.txt', originalContent); + + const q = query({ + prompt: + 'Read test-no-changes.txt and plan how you would modify it to add a header. Do not actually make any changes.', + options: { + ...SHARED_TEST_OPTIONS, + permissionMode: 'plan', + cwd: testDir, + }, + }); + + try { + const messages: SDKMessage[] = []; + for await (const message of q) { + messages.push(message); + } + + // Verify permission_mode is 'plan' + const systemMessage = findSystemMessage(messages, 'init'); + expect(systemMessage!.permission_mode).toBe('plan'); + + // Verify the file was not modified + const fileContent = await helper.readFile('test-no-changes.txt'); + expect(fileContent).toBe(originalContent); + + // Verify no write tools were called + const allToolCalls = findToolCalls(messages); + const writeToolCalls = allToolCalls.filter((tc) => + WRITE_TOOLS.includes(tc.toolUse.name), + ); + expect(writeToolCalls.length).toBe(0); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); }); describe('auto-edit mode', () => { @@ -1064,9 +1187,8 @@ describe('Permission Control (E2E)', () => { it( 'should demonstrate different behaviors across all modes for write operations', async () => { - const modes: Array<'default' | 'plan' | 'auto-edit' | 'yolo'> = [ + const modes: Array<'default' | 'auto-edit' | 'yolo'> = [ 'default', - 'plan', 'auto-edit', 'yolo', ]; diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/packages/sdk-typescript/test/e2e/single-turn.test.ts index 8b7d2385..4adb7c0b 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/packages/sdk-typescript/test/e2e/single-turn.test.ts @@ -180,7 +180,7 @@ describe('Single-Turn Query (E2E)', () => { expect(systemMessage!.mcp_servers).toBeDefined(); expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true); expect(systemMessage!.model).toBeDefined(); - expect(systemMessage!.permissionMode).toBeDefined(); + expect(systemMessage!.permission_mode).toBeDefined(); expect(systemMessage!.qwen_code_version).toBeDefined(); // Validate system message appears early in sequence From ae7d6af71711fb101897406f26d236cbbd782971 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 13:41:46 +0800 Subject: [PATCH 14/38] fix(test): remove unused test cases --- .../test/unit/ProcessTransport.test.ts | 9 +- .../sdk-typescript/test/unit/Query.test.ts | 60 +--- .../unit/SdkControlServerTransport.test.ts | 259 ------------------ 3 files changed, 18 insertions(+), 310 deletions(-) delete mode 100644 packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts index 0854a02d..b8602654 100644 --- a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -1187,13 +1187,20 @@ describe('ProcessTransport', () => { const options: TransportOptions = { pathToQwenExecutable: 'qwen', stderr: stderrCallback, + debug: true, // Enable debug to ensure stderr data is logged }; new ProcessTransport(options); + // Clear previous calls from logger.info during initialization + stderrCallback.mockClear(); + mockStderr.emit('data', Buffer.from('error message')); - expect(stderrCallback).toHaveBeenCalledWith('error message'); + // The stderr data is passed through logger.debug, which formats it + // So we check that the callback was called with a message containing 'error message' + expect(stderrCallback).toHaveBeenCalled(); + expect(stderrCallback.mock.calls[0][0]).toContain('error message'); }); }); diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts index b7309a19..2b89ca51 100644 --- a/packages/sdk-typescript/test/unit/Query.test.ts +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -312,50 +312,6 @@ describe('Query', () => { await transport2.close(); }); - it('should validate MCP server name conflicts', async () => { - const mockServer = { - connect: vi.fn(), - }; - - await expect(async () => { - const query = new Query(transport, { - cwd: '/test', - mcpServers: { server1: { command: 'test' } }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sdkMcpServers: { server1: mockServer as any }, - }); - await query.initialized; - }).rejects.toThrow(/name conflicts/); - }); - - it('should initialize with SDK MCP servers', async () => { - const mockServer = { - connect: vi.fn().mockResolvedValue(undefined), - }; - - const query = new Query(transport, { - cwd: '/test', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sdkMcpServers: { testServer: mockServer as any }, - }); - - // Respond to initialize request - await vi.waitFor(() => { - expect(transport.writtenMessages.length).toBeGreaterThan(0); - }); - - const initRequest = - transport.getLastWrittenMessage() as CLIControlRequest; - transport.simulateMessage( - createControlResponse(initRequest.request_id, true, {}), - ); - - await query.initialized; - expect(mockServer.connect).toHaveBeenCalled(); - - await query.close(); - }); - it('should handle initialization errors', async () => { const query = new Query(transport, { cwd: '/test', @@ -483,7 +439,7 @@ describe('Query', () => { describe('Control Plane - Permission Control', () => { it('should handle can_use_tool control requests', async () => { - const canUseTool = vi.fn().mockResolvedValue(true); + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'allow' }); const query = new Query(transport, { cwd: '/test', canUseTool, @@ -507,7 +463,7 @@ describe('Query', () => { }); it('should send control response with permission result - allow', async () => { - const canUseTool = vi.fn().mockResolvedValue(true); + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'allow' }); const query = new Query(transport, { cwd: '/test', canUseTool, @@ -533,7 +489,7 @@ describe('Query', () => { }); it('should send control response with permission result - deny', async () => { - const canUseTool = vi.fn().mockResolvedValue(false); + const canUseTool = vi.fn().mockResolvedValue({ behavior: 'deny' }); const query = new Query(transport, { cwd: '/test', canUseTool, @@ -586,7 +542,7 @@ describe('Query', () => { const canUseTool = vi.fn().mockImplementation( () => new Promise((resolve) => { - setTimeout(() => resolve(true), 35000); // Exceeds 30s timeout + setTimeout(() => resolve({ behavior: 'allow' }), 35000); // Exceeds 30s timeout }), ); @@ -709,10 +665,14 @@ describe('Query', () => { describe('Control Plane - Control Cancel', () => { it('should handle control cancel requests', async () => { const canUseTool = vi.fn().mockImplementation( - ({ signal }: { signal: AbortSignal }) => + ( + _toolName: string, + _toolInput: unknown, + { signal }: { signal: AbortSignal }, + ) => new Promise((resolve, reject) => { signal.addEventListener('abort', () => reject(new AbortError())); - setTimeout(() => resolve(true), 5000); + setTimeout(() => resolve({ behavior: 'allow' }), 5000); }), ); diff --git a/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts b/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts deleted file mode 100644 index 6bfd61a0..00000000 --- a/packages/sdk-typescript/test/unit/SdkControlServerTransport.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Unit tests for SdkControlServerTransport - * - * Tests MCP message proxying between MCP Server and Query's control plane. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SdkControlServerTransport } from '../../src/mcp/SdkControlServerTransport.js'; - -describe('SdkControlServerTransport', () => { - let sendToQuery: ReturnType; - let transport: SdkControlServerTransport; - - beforeEach(() => { - sendToQuery = vi.fn().mockResolvedValue({ result: 'success' }); - transport = new SdkControlServerTransport({ - serverName: 'test-server', - sendToQuery, - }); - }); - - describe('Lifecycle', () => { - it('should start successfully', async () => { - await transport.start(); - expect(transport.isStarted()).toBe(true); - }); - - it('should close successfully', async () => { - await transport.start(); - await transport.close(); - expect(transport.isStarted()).toBe(false); - }); - - it('should handle close callback', async () => { - const onclose = vi.fn(); - transport.onclose = onclose; - - await transport.start(); - await transport.close(); - - expect(onclose).toHaveBeenCalled(); - }); - }); - - describe('Message Sending', () => { - it('should send message to Query', async () => { - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - params: {}, - }; - - await transport.send(message); - - expect(sendToQuery).toHaveBeenCalledWith(message); - }); - - it('should throw error when sending before start', async () => { - const message = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - }; - - await expect(transport.send(message)).rejects.toThrow('not started'); - }); - - it('should handle send errors', async () => { - const error = new Error('Network error'); - sendToQuery.mockRejectedValue(error); - - const onerror = vi.fn(); - transport.onerror = onerror; - - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - }; - - await expect(transport.send(message)).rejects.toThrow('Network error'); - expect(onerror).toHaveBeenCalledWith(error); - }); - }); - - describe('Message Receiving', () => { - it('should deliver message to MCP Server via onmessage', async () => { - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - result: { tools: [] }, - }; - - transport.handleMessage(message); - - expect(onmessage).toHaveBeenCalledWith(message); - }); - - it('should warn when receiving message without onmessage handler', async () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - await transport.start(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - result: {}, - }; - - transport.handleMessage(message); - - expect(consoleWarnSpy).toHaveBeenCalled(); - - consoleWarnSpy.mockRestore(); - }); - - it('should warn when receiving message for closed transport', async () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}); - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - await transport.close(); - - const message = { - jsonrpc: '2.0' as const, - id: 1, - result: {}, - }; - - transport.handleMessage(message); - - expect(consoleWarnSpy).toHaveBeenCalled(); - expect(onmessage).not.toHaveBeenCalled(); - - consoleWarnSpy.mockRestore(); - }); - }); - - describe('Error Handling', () => { - it('should deliver error to MCP Server via onerror', async () => { - const onerror = vi.fn(); - transport.onerror = onerror; - - await transport.start(); - - const error = new Error('Test error'); - transport.handleError(error); - - expect(onerror).toHaveBeenCalledWith(error); - }); - - it('should log error when no onerror handler set', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - await transport.start(); - - const error = new Error('Test error'); - transport.handleError(error); - - expect(consoleErrorSpy).toHaveBeenCalled(); - - consoleErrorSpy.mockRestore(); - }); - }); - - describe('Server Name', () => { - it('should return server name', () => { - expect(transport.getServerName()).toBe('test-server'); - }); - }); - - describe('Bidirectional Communication', () => { - it('should support full message round-trip', async () => { - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - - // Send request from MCP Server to CLI - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/list', - params: {}, - }; - - await transport.send(request); - expect(sendToQuery).toHaveBeenCalledWith(request); - - // Receive response from CLI to MCP Server - const response = { - jsonrpc: '2.0' as const, - id: 1, - result: { - tools: [ - { - name: 'test_tool', - description: 'A test tool', - inputSchema: { type: 'object' }, - }, - ], - }, - }; - - transport.handleMessage(response); - expect(onmessage).toHaveBeenCalledWith(response); - }); - - it('should handle multiple messages in sequence', async () => { - const onmessage = vi.fn(); - transport.onmessage = onmessage; - - await transport.start(); - - // Send multiple requests - for (let i = 0; i < 5; i++) { - const message = { - jsonrpc: '2.0' as const, - id: i, - method: 'test', - }; - - await transport.send(message); - } - - expect(sendToQuery).toHaveBeenCalledTimes(5); - - // Receive multiple responses - for (let i = 0; i < 5; i++) { - const message = { - jsonrpc: '2.0' as const, - id: i, - result: {}, - }; - - transport.handleMessage(message); - } - - expect(onmessage).toHaveBeenCalledTimes(5); - }); - }); -}); From 3056f8a63d1697d0eede3ab8af2facec42bd6f2a Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 14:56:11 +0800 Subject: [PATCH 15/38] feat(tests): move SDK integration tests to `integration-tests` to share `globalSetup` --- eslint.config.js | 2 +- integration-tests/globalSetup.ts | 44 ++++++++++++-- .../abort-and-lifecycle.test.ts | 2 +- .../configuration-options.test.ts | 4 +- .../sdk-typescript}/mcp-server.test.ts | 4 +- .../sdk-typescript}/multi-turn.test.ts | 4 +- .../permission-control.test.ts | 4 +- .../sdk-typescript}/single-turn.test.ts | 4 +- .../sdk-typescript}/subagents.test.ts | 4 +- .../sdk-typescript}/system-control.test.ts | 8 +-- .../sdk-typescript}/test-helper.ts | 4 +- .../sdk-typescript}/tool-control.test.ts | 4 +- integration-tests/tsconfig.json | 8 ++- integration-tests/vitest.config.ts | 16 +++++- packages/sdk-typescript/package.json | 1 + packages/sdk-typescript/src/index.ts | 11 ++++ .../sdk-typescript/test/e2e/globalSetup.ts | 57 ------------------- 17 files changed, 95 insertions(+), 86 deletions(-) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/abort-and-lifecycle.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/configuration-options.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/mcp-server.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/multi-turn.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/permission-control.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/single-turn.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/subagents.test.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/system-control.test.ts (98%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/test-helper.ts (99%) rename {packages/sdk-typescript/test/e2e => integration-tests/sdk-typescript}/tool-control.test.ts (99%) delete mode 100644 packages/sdk-typescript/test/e2e/globalSetup.ts diff --git a/eslint.config.js b/eslint.config.js index 13a3d1c3..5b3b7f3d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -238,7 +238,7 @@ export default tseslint.config( prettierConfig, // extra settings for scripts that we run directly with node { - files: ['./integration-tests/**/*.js'], + files: ['./integration-tests/**/*.{js,ts,tsx}'], languageOptions: { globals: { ...globals.node, diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index 77105af2..a8a9877f 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -30,6 +30,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); const integrationTestsDir = join(rootDir, '.integration-tests'); let runDir = ''; // Make runDir accessible in teardown +let sdkE2eRunDir = ''; // SDK E2E test run directory const memoryFilePath = join( os.homedir(), @@ -48,14 +49,36 @@ export async function setup() { // File doesn't exist, which is fine. } + // Setup for CLI integration tests runDir = join(integrationTestsDir, `${Date.now()}`); await mkdir(runDir, { recursive: true }); + // Setup for SDK E2E tests (separate directory with prefix) + sdkE2eRunDir = join(integrationTestsDir, `sdk-e2e-${Date.now()}`); + await mkdir(sdkE2eRunDir, { recursive: true }); + // Clean up old test runs, but keep the latest few for debugging try { const testRuns = await readdir(integrationTestsDir); - if (testRuns.length > 5) { - const oldRuns = testRuns.sort().slice(0, testRuns.length - 5); + + // Clean up old CLI integration test runs (without sdk-e2e- prefix) + const cliTestRuns = testRuns.filter((run) => !run.startsWith('sdk-e2e-')); + if (cliTestRuns.length > 5) { + const oldRuns = cliTestRuns.sort().slice(0, cliTestRuns.length - 5); + await Promise.all( + oldRuns.map((oldRun) => + rm(join(integrationTestsDir, oldRun), { + recursive: true, + force: true, + }), + ), + ); + } + + // Clean up old SDK E2E test runs (with sdk-e2e- prefix) + const sdkTestRuns = testRuns.filter((run) => run.startsWith('sdk-e2e-')); + if (sdkTestRuns.length > 5) { + const oldRuns = sdkTestRuns.sort().slice(0, sdkTestRuns.length - 5); await Promise.all( oldRuns.map((oldRun) => rm(join(integrationTestsDir, oldRun), { @@ -69,24 +92,37 @@ export async function setup() { console.error('Error cleaning up old test runs:', e); } + // Environment variables for CLI integration tests process.env['INTEGRATION_TEST_FILE_DIR'] = runDir; process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true'; process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log'); + // Environment variables for SDK E2E tests + process.env['E2E_TEST_FILE_DIR'] = sdkE2eRunDir; + process.env['TEST_CLI_PATH'] = join(rootDir, 'dist/cli.js'); + if (process.env['KEEP_OUTPUT']) { console.log(`Keeping output for test run in: ${runDir}`); + console.log(`Keeping output for SDK E2E test run in: ${sdkE2eRunDir}`); } process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; console.log(`\nIntegration test output directory: ${runDir}`); + console.log(`SDK E2E test output directory: ${sdkE2eRunDir}`); + console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`); } export async function teardown() { - // Cleanup the test run directory unless KEEP_OUTPUT is set + // Cleanup the CLI test run directory unless KEEP_OUTPUT is set if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { await rm(runDir, { recursive: true, force: true }); } + // Cleanup the SDK E2E test run directory unless KEEP_OUTPUT is set + if (process.env['KEEP_OUTPUT'] !== 'true' && sdkE2eRunDir) { + await rm(sdkE2eRunDir, { recursive: true, force: true }); + } + if (originalMemoryContent !== null) { await mkdir(dirname(memoryFilePath), { recursive: true }); await writeFile(memoryFilePath, originalMemoryContent, 'utf-8'); diff --git a/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts rename to integration-tests/sdk-typescript/abort-and-lifecycle.test.ts index 806a4a20..b0b4c3fd 100644 --- a/packages/sdk-typescript/test/e2e/abort-and-lifecycle.test.ts +++ b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts @@ -13,7 +13,7 @@ import { isSDKAssistantMessage, type TextBlock, type ContentBlock, -} from '../../src/index.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; const SHARED_TEST_OPTIONS = createSharedTestOptions(); diff --git a/packages/sdk-typescript/test/e2e/configuration-options.test.ts b/integration-tests/sdk-typescript/configuration-options.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/configuration-options.test.ts rename to integration-tests/sdk-typescript/configuration-options.test.ts index e81af7fd..bac0a368 100644 --- a/packages/sdk-typescript/test/e2e/configuration-options.test.ts +++ b/integration-tests/sdk-typescript/configuration-options.test.ts @@ -12,12 +12,12 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, isSDKSystemMessage, type SDKMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, extractText, diff --git a/packages/sdk-typescript/test/e2e/mcp-server.test.ts b/integration-tests/sdk-typescript/mcp-server.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/mcp-server.test.ts rename to integration-tests/sdk-typescript/mcp-server.test.ts index dd13d205..110c1924 100644 --- a/packages/sdk-typescript/test/e2e/mcp-server.test.ts +++ b/integration-tests/sdk-typescript/mcp-server.test.ts @@ -10,8 +10,8 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, isSDKResultMessage, isSDKSystemMessage, @@ -19,7 +19,7 @@ import { type SDKMessage, type ToolUseBlock, type SDKSystemMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, createMCPServer, diff --git a/packages/sdk-typescript/test/e2e/multi-turn.test.ts b/integration-tests/sdk-typescript/multi-turn.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/multi-turn.test.ts rename to integration-tests/sdk-typescript/multi-turn.test.ts index 689a6468..17b6f675 100644 --- a/packages/sdk-typescript/test/e2e/multi-turn.test.ts +++ b/integration-tests/sdk-typescript/multi-turn.test.ts @@ -4,8 +4,8 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKUserMessage, isSDKAssistantMessage, isSDKSystemMessage, @@ -21,7 +21,7 @@ import { type SDKMessage, type ControlMessage, type ToolUseBlock, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; const SHARED_TEST_OPTIONS = createSharedTestOptions(); diff --git a/packages/sdk-typescript/test/e2e/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/permission-control.test.ts rename to integration-tests/sdk-typescript/permission-control.test.ts index 587fa500..31c7768a 100644 --- a/packages/sdk-typescript/test/e2e/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -13,8 +13,8 @@ import { beforeEach, afterEach, } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, isSDKResultMessage, isSDKUserMessage, @@ -22,7 +22,7 @@ import { type SDKUserMessage, type ToolUseBlock, type ContentBlock, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, createSharedTestOptions, diff --git a/packages/sdk-typescript/test/e2e/single-turn.test.ts b/integration-tests/sdk-typescript/single-turn.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/single-turn.test.ts rename to integration-tests/sdk-typescript/single-turn.test.ts index 4adb7c0b..aa2716f3 100644 --- a/packages/sdk-typescript/test/e2e/single-turn.test.ts +++ b/integration-tests/sdk-typescript/single-turn.test.ts @@ -4,8 +4,8 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, isSDKSystemMessage, isSDKResultMessage, @@ -13,7 +13,7 @@ import { type SDKMessage, type SDKSystemMessage, type SDKAssistantMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, extractText, diff --git a/packages/sdk-typescript/test/e2e/subagents.test.ts b/integration-tests/sdk-typescript/subagents.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/subagents.test.ts rename to integration-tests/sdk-typescript/subagents.test.ts index 06e3fd36..86516053 100644 --- a/packages/sdk-typescript/test/e2e/subagents.test.ts +++ b/integration-tests/sdk-typescript/subagents.test.ts @@ -10,14 +10,14 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, type SDKMessage, type SubagentConfig, type ContentBlock, type ToolUseBlock, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, extractText, diff --git a/packages/sdk-typescript/test/e2e/system-control.test.ts b/integration-tests/sdk-typescript/system-control.test.ts similarity index 98% rename from packages/sdk-typescript/test/e2e/system-control.test.ts rename to integration-tests/sdk-typescript/system-control.test.ts index 3515532e..069eccd9 100644 --- a/packages/sdk-typescript/test/e2e/system-control.test.ts +++ b/integration-tests/sdk-typescript/system-control.test.ts @@ -4,12 +4,12 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, isSDKSystemMessage, type SDKUserMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; const SHARED_TEST_OPTIONS = createSharedTestOptions(); @@ -265,7 +265,7 @@ describe('System Control (E2E)', () => { // First model change await q.setModel('qwen3-turbo'); - resumeResolve1?.(); + resumeResolve1!(); // Wait for second response await Promise.race([ @@ -277,7 +277,7 @@ describe('System Control (E2E)', () => { // Second model change await q.setModel('qwen3-vl-plus'); - resumeResolve2?.(); + resumeResolve2!(); // Wait for third response await Promise.race([ diff --git a/packages/sdk-typescript/test/e2e/test-helper.ts b/integration-tests/sdk-typescript/test-helper.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/test-helper.ts rename to integration-tests/sdk-typescript/test-helper.ts index 4b1465ad..cd95051f 100644 --- a/packages/sdk-typescript/test/e2e/test-helper.ts +++ b/integration-tests/sdk-typescript/test-helper.ts @@ -21,12 +21,12 @@ import type { ContentBlock, TextBlock, ToolUseBlock, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { isSDKAssistantMessage, isSDKSystemMessage, isSDKResultMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; // ============================================================================ // Core Test Helper Class diff --git a/packages/sdk-typescript/test/e2e/tool-control.test.ts b/integration-tests/sdk-typescript/tool-control.test.ts similarity index 99% rename from packages/sdk-typescript/test/e2e/tool-control.test.ts rename to integration-tests/sdk-typescript/tool-control.test.ts index 30a811df..036d779e 100644 --- a/packages/sdk-typescript/test/e2e/tool-control.test.ts +++ b/integration-tests/sdk-typescript/tool-control.test.ts @@ -12,11 +12,11 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { query } from '../../src/index.js'; import { + query, isSDKAssistantMessage, type SDKMessage, -} from '../../src/types/protocol.js'; +} from '@qwen-code/sdk-typescript'; import { SDKTestHelper, extractText, diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json index 295741e1..7f2a010d 100644 --- a/integration-tests/tsconfig.json +++ b/integration-tests/tsconfig.json @@ -2,7 +2,13 @@ "extends": "../tsconfig.json", "compilerOptions": { "noEmit": true, - "allowJs": true + "allowJs": true, + "baseUrl": ".", + "paths": { + "@qwen-code/sdk-typescript": [ + "../packages/sdk-typescript/dist/index.d.ts" + ] + } }, "include": ["**/*.ts"], "references": [{ "path": "../packages/core" }] diff --git a/integration-tests/vitest.config.ts b/integration-tests/vitest.config.ts index c8b79ad6..a452583c 100644 --- a/integration-tests/vitest.config.ts +++ b/integration-tests/vitest.config.ts @@ -1,12 +1,15 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import { defineConfig } from 'vitest/config'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const timeoutMinutes = Number(process.env.TB_TIMEOUT_MINUTES || '5'); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const timeoutMinutes = Number(process.env['TB_TIMEOUT_MINUTES'] || '5'); const testTimeoutMs = timeoutMinutes * 60 * 1000; export default defineConfig({ @@ -25,4 +28,13 @@ export default defineConfig({ }, }, }, + resolve: { + alias: { + // Use built SDK bundle for e2e tests + '@qwen-code/sdk-typescript': resolve( + __dirname, + '../packages/sdk-typescript/dist/index.mjs', + ), + }, + }, }); diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 63fed227..d2787bf8 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -22,6 +22,7 @@ "scripts": { "build": "node scripts/build.js", "test": "vitest run", + "test:ci": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint src test", diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index f8bf81c5..da40baf2 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -18,6 +18,14 @@ export type { SDKResultMessage, SDKPartialAssistantMessage, SDKMessage, + ControlMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, + SubagentConfig, + SubagentLevel, + ModelConfig, + RunConfig, } from './types/protocol.js'; export { @@ -26,6 +34,9 @@ export { isSDKSystemMessage, isSDKResultMessage, isSDKPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, } from './types/protocol.js'; export type { diff --git a/packages/sdk-typescript/test/e2e/globalSetup.ts b/packages/sdk-typescript/test/e2e/globalSetup.ts deleted file mode 100644 index 4f98b877..00000000 --- a/packages/sdk-typescript/test/e2e/globalSetup.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { mkdir, readdir, rm } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const rootDir = join(__dirname, '../..'); -const e2eTestsDir = join(rootDir, '.integration-tests'); -let runDir = ''; - -export async function setup() { - runDir = join(e2eTestsDir, `sdk-e2e-${Date.now()}`); - await mkdir(runDir, { recursive: true }); - - // Clean up old test runs, but keep the latest few for debugging - try { - const testRuns = await readdir(e2eTestsDir); - const sdkTestRuns = testRuns.filter((run) => run.startsWith('sdk-e2e-')); - if (sdkTestRuns.length > 5) { - const oldRuns = sdkTestRuns.sort().slice(0, sdkTestRuns.length - 5); - await Promise.all( - oldRuns.map((oldRun) => - rm(join(e2eTestsDir, oldRun), { - recursive: true, - force: true, - }), - ), - ); - } - } catch (e) { - console.error('Error cleaning up old test runs:', e); - } - - process.env['E2E_TEST_FILE_DIR'] = runDir; - process.env['QWEN_CLI_E2E_TEST'] = 'true'; - process.env['TEST_CLI_PATH'] = join(rootDir, '../../dist/cli.js'); - - if (process.env['KEEP_OUTPUT']) { - console.log(`Keeping output for test run in: ${runDir}`); - } - process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; - - console.log(`\nSDK E2E test output directory: ${runDir}`); - console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`); -} - -export async function teardown() { - // Cleanup the test run directory unless KEEP_OUTPUT is set - if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { - await rm(runDir, { recursive: true, force: true }); - } -} From 50e3a6ee0a84042d38d07ef56536976a21c85422 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 16:18:18 +0800 Subject: [PATCH 16/38] fix: enhance 429 error handling and fix failed cases --- integration-tests/simple-mcp-server.test.ts | 2 +- .../control/controllers/systemController.ts | 77 +++++++++---------- .../io/BaseJsonOutputAdapter.ts | 18 ++++- .../cli/src/nonInteractive/session.test.ts | 1 + packages/cli/src/nonInteractiveCli.ts | 10 +++ packages/sdk-typescript/scripts/build.js | 2 +- packages/sdk-typescript/vitest.config.ts | 1 - 7 files changed, 68 insertions(+), 43 deletions(-) diff --git a/integration-tests/simple-mcp-server.test.ts b/integration-tests/simple-mcp-server.test.ts index d8b6268d..d58bd982 100644 --- a/integration-tests/simple-mcp-server.test.ts +++ b/integration-tests/simple-mcp-server.test.ts @@ -213,7 +213,7 @@ describe('simple-mcp-server', () => { it('should add two numbers', async () => { // Test directory is already set up in before hook // Just run the command - MCP server config is in settings.json - const output = await rig.run('add 5 and 10'); + const output = await rig.run('add 5 and 10, use tool if you can.'); const foundToolCall = await rig.waitForToolCall('add'); diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index 7981a67b..c94187e7 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -19,6 +19,8 @@ import type { CLIControlInitializeRequest, CLIControlSetModelRequest, } from '../../types.js'; +import { CommandService } from '../../../services/CommandService.js'; +import { BuiltinCommandLoader } from '../../../services/BuiltinCommandLoader.js'; export class SystemController extends BaseController { /** @@ -141,31 +143,10 @@ 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, }; - // Check if MCP message handling is available - try { - const mcpProvider = this.context.config as unknown as { - getMcpServers?: () => Record | undefined; - }; - if (typeof mcpProvider.getMcpServers === 'function') { - const servers = mcpProvider.getMcpServers(); - capabilities['can_handle_mcp_message'] = Boolean( - servers && Object.keys(servers).length > 0, - ); - } else { - capabilities['can_handle_mcp_message'] = false; - } - } catch (error) { - if (this.context.debugMode) { - console.error( - '[SystemController] Failed to determine MCP capability:', - error, - ); - } - capabilities['can_handle_mcp_message'] = false; - } - return capabilities; } @@ -240,27 +221,45 @@ export class SystemController extends BaseController { /** * Handle supported_commands request * - * Returns list of supported control commands - * - * Note: This list should match the ControlRequestType enum in - * packages/sdk/typescript/src/types/controlRequests.ts + * Returns list of supported slash commands loaded dynamically */ private async handleSupportedCommands(): Promise> { - const commands = [ - 'initialize', - 'interrupt', - 'set_model', - 'supported_commands', - 'can_use_tool', - 'set_permission_mode', - 'mcp_message', - 'mcp_server_status', - 'hook_callback', - ]; + const slashCommands = await this.loadSlashCommandNames(); return { subtype: 'supported_commands', - commands, + commands: slashCommands, }; } + + /** + * Load slash command names using CommandService + * + * @returns Promise resolving to array of slash command names + */ + private async loadSlashCommandNames(): Promise { + const controller = new AbortController(); + try { + const service = await CommandService.create( + [new BuiltinCommandLoader(this.context.config)], + controller.signal, + ); + const names = new Set(); + const commands = service.getCommands(); + for (const command of commands) { + names.add(command.name); + } + return Array.from(names).sort(); + } catch (error) { + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to load slash commands:', + error, + ); + } + return []; + } finally { + controller.abort(); + } + } } diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index 551ea9ff..915fb721 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -13,7 +13,11 @@ import type { ServerGeminiStreamEvent, TaskResultDisplay, } from '@qwen-code/qwen-code-core'; -import { GeminiEventType, ToolErrorType } from '@qwen-code/qwen-code-core'; +import { + GeminiEventType, + ToolErrorType, + parseAndFormatApiError, +} from '@qwen-code/qwen-code-core'; import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; import type { CLIAssistantMessage, @@ -600,6 +604,18 @@ export abstract class BaseJsonOutputAdapter { } this.finalizePendingBlocks(state, null); break; + case GeminiEventType.Error: { + // Format the error message using parseAndFormatApiError for consistency + // with interactive mode error display + const errorText = parseAndFormatApiError( + event.value.error, + this.config.getContentGeneratorConfig()?.authType, + undefined, + this.config.getModel(), + ); + this.appendText(state, errorText, null); + break; + } default: break; } diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 61643fb3..6670d4c2 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -69,6 +69,7 @@ function createConfig(overrides: ConfigOverrides = {}): Config { getDebugMode: () => false, getApprovalMode: () => 'auto', getOutputFormat: () => 'stream-json', + initialize: vi.fn(), }; return { ...base, ...overrides } as unknown as Config; } diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 77e4f980..6f96d62b 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -17,6 +17,7 @@ import { OutputFormat, InputFormat, uiTelemetryService, + parseAndFormatApiError, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; @@ -210,6 +211,15 @@ export async function runNonInteractive( process.stdout.write(event.value); } else if (event.type === GeminiEventType.ToolCallRequest) { toolCallRequests.push(event.value); + } else if (event.type === GeminiEventType.Error) { + // Format and output the error message for text mode + const errorText = parseAndFormatApiError( + event.value.error, + config.getContentGeneratorConfig()?.authType, + undefined, + config.getModel(), + ); + process.stderr.write(`${errorText}\n`); } } } diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index e78f161a..db0632cf 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -24,7 +24,7 @@ execSync('tsc --project tsconfig.build.json', { try { execSync( - 'npx dts-bundle-generator --project tsconfig.build.json -o dist/index.d.ts src/index.ts --no-check', + 'npx dts-bundle-generator --project tsconfig.build.json -o dist/index.d.ts src/index.ts', { stdio: 'inherit', cwd: rootDir, diff --git a/packages/sdk-typescript/vitest.config.ts b/packages/sdk-typescript/vitest.config.ts index aef50ffd..f46dc537 100644 --- a/packages/sdk-typescript/vitest.config.ts +++ b/packages/sdk-typescript/vitest.config.ts @@ -38,7 +38,6 @@ export default defineConfig({ }, testTimeout: testTimeoutMs, hookTimeout: 10000, - globalSetup: './test/e2e/globalSetup.ts', }, resolve: { alias: { From 81c8b3eaec23927b5173b2217b6d66ef493782d0 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 17:54:43 +0800 Subject: [PATCH 17/38] feat: add GitHub Actions workflow for SDK release automation --- .github/workflows/release-sdk.yml | 226 +++++++++++ packages/sdk-typescript/README.md | 284 ++++++++++++++ packages/sdk-typescript/package.json | 3 +- packages/sdk-typescript/scripts/build.js | 18 +- .../scripts/get-release-version.js | 353 ++++++++++++++++++ 5 files changed, 872 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/release-sdk.yml create mode 100644 packages/sdk-typescript/README.md create mode 100644 packages/sdk-typescript/scripts/get-release-version.js diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml new file mode 100644 index 00000000..6249f08e --- /dev/null +++ b/.github/workflows/release-sdk.yml @@ -0,0 +1,226 @@ +name: 'Release SDK' + +on: + schedule: + # Runs every day at 1:00 AM UTC for the nightly release (offset from CLI at 0:00). + - cron: '0 1 * * *' + # Runs every Wednesday at 00:59 UTC for the preview release (offset from CLI on Tuesday). + - cron: '59 0 * * 3' + workflow_dispatch: + inputs: + version: + description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.' + required: false + type: 'string' + ref: + description: 'The branch or ref (full git sha) to release from.' + required: true + type: 'string' + default: 'main' + dry_run: + description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' + required: true + type: 'boolean' + default: true + create_nightly_release: + description: 'Auto apply the nightly release tag, input version is ignored.' + required: false + type: 'boolean' + default: false + create_preview_release: + description: 'Auto apply the preview release tag, input version is ignored.' + required: false + type: 'boolean' + default: false + force_skip_tests: + description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' + required: false + type: 'boolean' + default: false + +jobs: + release-sdk: + runs-on: 'ubuntu-latest' + environment: + name: 'production-release' + url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}' + if: |- + ${{ github.repository == 'QwenLM/qwen-code' }} + permissions: + contents: 'write' + packages: 'write' + id-token: 'write' + issues: 'write' + outputs: + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + ref: '${{ github.event.inputs.ref || github.sha }}' + fetch-depth: 0 + + - name: 'Set booleans for simplified logic' + env: + CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}' + CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' + EVENT_NAME: '${{ github.event_name }}' + CRON: '${{ github.event.schedule }}' + DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' + id: 'vars' + run: |- + is_nightly="false" + if [[ "${CRON}" == "0 1 * * *" || "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then + is_nightly="true" + fi + echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}" + + is_preview="false" + if [[ "${CRON}" == "59 0 * * 3" || "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then + is_preview="true" + fi + echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}" + + is_dry_run="false" + if [[ "${DRY_RUN_INPUT}" == "true" ]]; then + is_dry_run="true" + fi + echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + run: |- + npm ci + + - name: 'Get the version' + id: 'version' + run: | + VERSION_ARGS=() + if [[ "${IS_NIGHTLY}" == "true" ]]; then + VERSION_ARGS+=(--type=nightly) + elif [[ "${IS_PREVIEW}" == "true" ]]; then + VERSION_ARGS+=(--type=preview) + if [[ -n "${MANUAL_VERSION}" ]]; then + VERSION_ARGS+=("--preview_version_override=${MANUAL_VERSION}") + fi + else + VERSION_ARGS+=(--type=stable) + if [[ -n "${MANUAL_VERSION}" ]]; then + VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}") + fi + fi + + VERSION_JSON=$(node packages/sdk-typescript/scripts/get-release-version.js "${VERSION_ARGS[@]}") + echo "RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)" >> "$GITHUB_OUTPUT" + echo "RELEASE_VERSION=$(echo "$VERSION_JSON" | jq -r .releaseVersion)" >> "$GITHUB_OUTPUT" + echo "NPM_TAG=$(echo "$VERSION_JSON" | jq -r .npmTag)" >> "$GITHUB_OUTPUT" + + echo "PREVIOUS_RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .previousReleaseTag)" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' + IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' + MANUAL_VERSION: '${{ inputs.version }}' + + - name: 'Run Tests' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} + working-directory: 'packages/sdk-typescript' + run: | + npm run test:ci + env: + OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' + OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' + OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' + + - name: 'Configure Git User' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: 'Create and switch to a release branch' + id: 'release_branch' + env: + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: |- + BRANCH_NAME="release/sdk-typescript/${RELEASE_TAG}" + git switch -c "${BRANCH_NAME}" + echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + + - name: 'Update package version' + working-directory: 'packages/sdk-typescript' + env: + RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}' + run: |- + npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version + + - name: 'Commit and Conditionally Push package version' + env: + BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: |- + git add packages/sdk-typescript/package.json + if git diff --staged --quiet; then + echo "No version changes to commit" + else + git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}" + fi + if [[ "${IS_DRY_RUN}" == "false" ]]; then + echo "Pushing release branch to remote..." + git push --set-upstream origin "${BRANCH_NAME}" --follow-tags + else + echo "Dry run enabled. Skipping push." + fi + + - name: 'Build SDK' + working-directory: 'packages/sdk-typescript' + run: |- + npm run build + + - name: 'Configure npm for publishing' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + scope: '@qwen-code' + + - name: 'Publish @qwen-code/sdk-typescript' + working-directory: 'packages/sdk-typescript' + run: |- + npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }} + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + + - name: 'Create GitHub Release and Tag' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}' + run: |- + gh release create "sdk-typescript-${RELEASE_TAG}" \ + --target "$RELEASE_BRANCH" \ + --title "SDK TypeScript Release ${RELEASE_TAG}" \ + --notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \ + --generate-notes + + - name: 'Create Issue on Failure' + if: |- + ${{ failure() }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }} || "N/A"' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: |- + gh issue create \ + --title "SDK Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \ + --body "The SDK release workflow failed. See the full run for details: ${DETAILS_URL}" diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md new file mode 100644 index 00000000..ed441bc7 --- /dev/null +++ b/packages/sdk-typescript/README.md @@ -0,0 +1,284 @@ +# @qwen-code/sdk-typescript + +A minimum experimental TypeScript SDK for programmatic access to Qwen Code. + +Feel free to submit a feature request/issue/PR. + +## Installation + +```bash +npm install @qwen-code/sdk-typescript +``` + +## Requirements + +- Node.js >= 20.0.0 +- [Qwen Code](https://github.com/QwenLM/qwen-code) 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'; + +// Single-turn query +const result = query({ + prompt: 'What files are in the current directory?', + options: { + cwd: '/path/to/project', + }, +}); + +// Iterate over messages +for await (const message of result) { + if (message.type === 'assistant') { + console.log('Assistant:', message.message.content); + } else if (message.type === 'result') { + console.log('Result:', message.result); + } +} +``` + +## API Reference + +### `query(config)` + +Creates a new query session with the Qwen Code. + +#### Parameters + +- `prompt`: `string | AsyncIterable` - The prompt to send. Use a string for single-turn queries or an async iterable for multi-turn conversations. +- `options`: `QueryOptions` - Configuration options for the query session. + +#### QueryOptions + +| Option | Type | Default | Description | +| ------------------------ | ---------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | +| `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). | +| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | +| `mcpServers` | `Record` | - | External MCP (Model Context Protocol) servers to connect. Each server is identified by a unique name and configured with `command`, `args`, and `env`. | +| `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. | +| `coreTools` | `string[]` | - | Equivalent to `tool.core` in settings.json. If specified, only these tools will be available to the AI. Example: `['read_file', 'write_file', 'run_terminal_cmd']`. | +| `excludeTools` | `string[]` | - | Equivalent to `tool.exclude` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports pattern matching: tool name (`'write_file'`), tool class (`'ShellTool'`), or shell command prefix (`'ShellTool(rm )'`). | +| `allowedTools` | `string[]` | - | Equivalent to `tool.allowed` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Using `'qwen-oauth'` in SDK is not recommended as credentials are stored in `~/.qwen` and may need periodic refresh. | +| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | +| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | + +### Timeouts + +The SDK enforces the following 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. | + +### Message Types + +The SDK provides type guards to identify different message types: + +```typescript +import { + isSDKUserMessage, + isSDKAssistantMessage, + isSDKSystemMessage, + isSDKResultMessage, + isSDKPartialAssistantMessage, +} from '@qwen-code/sdk-typescript'; + +for await (const message of result) { + if (isSDKAssistantMessage(message)) { + // Handle assistant message + } else if (isSDKResultMessage(message)) { + // Handle result message + } +} +``` + +### Query Instance Methods + +The `Query` instance returned by `query()` provides several methods: + +```typescript +const q = query({ prompt: 'Hello', options: {} }); + +// Get session ID +const sessionId = q.getSessionId(); + +// Check if closed +const closed = q.isClosed(); + +// Interrupt the current operation +await q.interrupt(); + +// Change permission mode mid-session +await q.setPermissionMode('yolo'); + +// Change model mid-session +await q.setModel('qwen-max'); + +// Close the session +await q.close(); +``` + +## Permission Modes + +The SDK supports different permission modes for controlling tool execution: + +- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation. +- **`plan`**: Blocks all write tools, instructing AI to present a plan first. +- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation. +- **`yolo`**: All tools execute automatically without confirmation. + +### Permission Priority Chain + +1. `excludeTools` - Blocks tools completely +2. `permissionMode: 'plan'` - Blocks non-read-only tools +3. `permissionMode: 'yolo'` - Auto-approves all tools +4. `allowedTools` - Auto-approves matching tools +5. `canUseTool` callback - Custom approval logic +6. Default behavior - Auto-deny in SDK mode + +## Examples + +### Multi-turn Conversation + +```typescript +import { query, type SDKUserMessage } from '@qwen-code/sdk-typescript'; + +async function* generateMessages(): AsyncIterable { + yield { + type: 'user', + session_id: 'my-session', + message: { role: 'user', content: 'Create a hello.txt file' }, + parent_tool_use_id: null, + }; + + // Wait for some condition or user input + yield { + type: 'user', + session_id: 'my-session', + message: { role: 'user', content: 'Now read the file back' }, + parent_tool_use_id: null, + }; +} + +const result = query({ + prompt: generateMessages(), + options: { + permissionMode: 'auto-edit', + }, +}); + +for await (const message of result) { + console.log(message); +} +``` + +### Custom Permission Handler + +```typescript +import { query, type CanUseTool } from '@qwen-code/sdk-typescript'; + +const canUseTool: CanUseTool = async (toolName, input, { signal }) => { + // Allow all read operations + if (toolName.startsWith('read_')) { + return { behavior: 'allow', updatedInput: input }; + } + + // Prompt user for write operations (in a real app) + const userApproved = await promptUser(`Allow ${toolName}?`); + + if (userApproved) { + return { behavior: 'allow', updatedInput: input }; + } + + return { behavior: 'deny', message: 'User denied the operation' }; +}; + +const result = query({ + prompt: 'Create a new file', + options: { + canUseTool, + }, +}); +``` + +### With MCP Servers + +```typescript +import { query } from '@qwen-code/sdk-typescript'; + +const result = query({ + prompt: 'Use the custom tool from my MCP server', + options: { + mcpServers: { + 'my-server': { + command: 'node', + args: ['path/to/mcp-server.js'], + env: { PORT: '3000' }, + }, + }, + }, +}); +``` + +### Abort a Query + +```typescript +import { query, isAbortError } from '@qwen-code/sdk-typescript'; + +const abortController = new AbortController(); + +const result = query({ + prompt: 'Long running task...', + options: { + abortController, + }, +}); + +// Abort after 5 seconds +setTimeout(() => abortController.abort(), 5000); + +try { + for await (const message of result) { + console.log(message); + } +} catch (error) { + if (isAbortError(error)) { + console.log('Query was aborted'); + } else { + throw error; + } +} +``` + +## Error Handling + +The SDK provides an `AbortError` class for handling aborted queries: + +```typescript +import { AbortError, isAbortError } from '@qwen-code/sdk-typescript'; + +try { + // ... query operations +} catch (error) { + if (isAbortError(error)) { + // Handle abort + } else { + // Handle other errors + } +} +``` + +## License + +Apache-2.0 - see [LICENSE](./LICENSE) for details. diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index d2787bf8..0f234603 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -16,8 +16,7 @@ }, "files": [ "dist", - "README.md", - "LICENSE" + "README.md" ], "scripts": { "build": "node scripts/build.js", diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index db0632cf..beda8b0e 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -81,15 +81,13 @@ await esbuild.build({ treeShaking: true, }); -const filesToCopy = ['README.md', 'LICENSE']; -for (const file of filesToCopy) { - const sourcePath = join(rootDir, '..', '..', file); - const targetPath = join(rootDir, 'dist', file); - if (existsSync(sourcePath)) { - try { - cpSync(sourcePath, targetPath); - } catch (error) { - console.warn(`Could not copy ${file}:`, error.message); - } +// Copy LICENSE from root directory to dist +const licenseSource = join(rootDir, '..', '..', 'LICENSE'); +const licenseTarget = join(rootDir, 'dist', 'LICENSE'); +if (existsSync(licenseSource)) { + try { + cpSync(licenseSource, licenseTarget); + } catch (error) { + console.warn('Could not copy LICENSE:', error.message); } } diff --git a/packages/sdk-typescript/scripts/get-release-version.js b/packages/sdk-typescript/scripts/get-release-version.js new file mode 100644 index 00000000..349bfd07 --- /dev/null +++ b/packages/sdk-typescript/scripts/get-release-version.js @@ -0,0 +1,353 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const PACKAGE_NAME = '@qwen-code/sdk-typescript'; +const TAG_PREFIX = 'sdk-typescript-v'; + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +function getArgs() { + const args = {}; + process.argv.slice(2).forEach((arg) => { + if (arg.startsWith('--')) { + const [key, value] = arg.substring(2).split('='); + args[key] = value === undefined ? true : value; + } + }); + return args; +} + +function getVersionFromNPM(distTag) { + const command = `npm view ${PACKAGE_NAME} version --tag=${distTag}`; + try { + return execSync(command).toString().trim(); + } catch (error) { + console.error( + `Failed to get NPM version for dist-tag "${distTag}": ${error.message}`, + ); + return ''; + } +} + +function getAllVersionsFromNPM() { + const command = `npm view ${PACKAGE_NAME} versions --json`; + try { + const versionsJson = execSync(command).toString().trim(); + const result = JSON.parse(versionsJson); + // npm returns a string if there's only one version, array otherwise + return Array.isArray(result) ? result : [result]; + } catch (error) { + console.error(`Failed to get all NPM versions: ${error.message}`); + return []; + } +} + +function isVersionDeprecated(version) { + const command = `npm view ${PACKAGE_NAME}@${version} deprecated`; + try { + const output = execSync(command).toString().trim(); + return output.length > 0; + } catch (error) { + console.error( + `Failed to check deprecation status for ${version}: ${error.message}`, + ); + return false; + } +} + +function semverCompare(a, b) { + const parseVersion = (v) => { + const [main, prerelease] = v.split('-'); + const [major, minor, patch] = main.split('.').map(Number); + return { major, minor, patch, prerelease: prerelease || '' }; + }; + + const va = parseVersion(a); + const vb = parseVersion(b); + + if (va.major !== vb.major) return va.major - vb.major; + if (va.minor !== vb.minor) return va.minor - vb.minor; + if (va.patch !== vb.patch) return va.patch - vb.patch; + + // Handle prerelease comparison + if (!va.prerelease && vb.prerelease) return 1; // stable > prerelease + if (va.prerelease && !vb.prerelease) return -1; // prerelease < stable + if (va.prerelease && vb.prerelease) { + return va.prerelease.localeCompare(vb.prerelease); + } + return 0; +} + +function detectRollbackAndGetBaseline(npmDistTag) { + const distTagVersion = getVersionFromNPM(npmDistTag); + if (!distTagVersion) return { baseline: '', isRollback: false }; + + const allVersions = getAllVersionsFromNPM(); + if (allVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + let matchingVersions; + if (npmDistTag === 'latest') { + matchingVersions = allVersions.filter((v) => !v.includes('-')); + } else if (npmDistTag === 'preview') { + matchingVersions = allVersions.filter((v) => v.includes('-preview')); + } else if (npmDistTag === 'nightly') { + matchingVersions = allVersions.filter((v) => v.includes('-nightly')); + } else { + return { baseline: distTagVersion, isRollback: false }; + } + + if (matchingVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + matchingVersions.sort((a, b) => -semverCompare(a, b)); + + let highestExistingVersion = ''; + for (const version of matchingVersions) { + if (!isVersionDeprecated(version)) { + highestExistingVersion = version; + break; + } else { + console.error(`Ignoring deprecated version: ${version}`); + } + } + + if (!highestExistingVersion) { + highestExistingVersion = distTagVersion; + } + + const isRollback = semverCompare(highestExistingVersion, distTagVersion) > 0; + + return { + baseline: isRollback ? highestExistingVersion : distTagVersion, + isRollback, + distTagVersion, + highestExistingVersion, + }; +} + +function doesVersionExist(version) { + // Check NPM + try { + const command = `npm view ${PACKAGE_NAME}@${version} version 2>/dev/null`; + const output = execSync(command).toString().trim(); + if (output === version) { + console.error(`Version ${version} already exists on NPM.`); + return true; + } + } catch (_error) { + // This is expected if the version doesn't exist. + } + + // Check Git tags + try { + const command = `git tag -l '${TAG_PREFIX}${version}'`; + const tagOutput = execSync(command).toString().trim(); + if (tagOutput === `${TAG_PREFIX}${version}`) { + console.error(`Git tag ${TAG_PREFIX}${version} already exists.`); + return true; + } + } catch (error) { + console.error(`Failed to check git tags for conflicts: ${error.message}`); + } + + // Check GitHub releases + try { + const command = `gh release view "${TAG_PREFIX}${version}" --json tagName --jq .tagName 2>/dev/null`; + const output = execSync(command).toString().trim(); + if (output === `${TAG_PREFIX}${version}`) { + console.error(`GitHub release ${TAG_PREFIX}${version} already exists.`); + return true; + } + } catch (error) { + const isExpectedNotFound = + error.message.includes('release not found') || + error.message.includes('Not Found') || + error.message.includes('not found') || + error.status === 1; + if (!isExpectedNotFound) { + console.error( + `Failed to check GitHub releases for conflicts: ${error.message}`, + ); + } + } + + return false; +} + +function getAndVerifyTags(npmDistTag) { + const rollbackInfo = detectRollbackAndGetBaseline(npmDistTag); + const baselineVersion = rollbackInfo.baseline; + + if (!baselineVersion) { + // First release for this dist-tag, use package.json version as baseline + const packageJson = readJson(join(__dirname, '..', 'package.json')); + return { + latestVersion: packageJson.version.split('-')[0], + latestTag: `v${packageJson.version.split('-')[0]}`, + }; + } + + if (rollbackInfo.isRollback) { + console.error( + `Rollback detected! NPM ${npmDistTag} tag is ${rollbackInfo.distTagVersion}, but using ${baselineVersion} as baseline for next version calculation.`, + ); + } + + return { + latestVersion: baselineVersion, + latestTag: `v${baselineVersion}`, + }; +} + +function getLatestStableReleaseTag() { + try { + const { latestTag } = getAndVerifyTags('latest'); + return latestTag; + } catch (error) { + console.error( + `Failed to determine latest stable release tag: ${error.message}`, + ); + return ''; + } +} + +function getNightlyVersion() { + const packageJson = readJson(join(__dirname, '..', 'package.json')); + const baseVersion = packageJson.version.split('-')[0]; + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim(); + const releaseVersion = `${baseVersion}-nightly.${date}.${gitShortHash}`; + return { + releaseVersion, + npmTag: 'nightly', + }; +} + +function validateVersion(version, format, name) { + const versionRegex = { + 'X.Y.Z': /^\d+\.\d+\.\d+$/, + 'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/, + }; + + if (!versionRegex[format] || !versionRegex[format].test(version)) { + throw new Error( + `Invalid ${name}: ${version}. Must be in ${format} format.`, + ); + } +} + +function getStableVersion(args) { + let releaseVersion; + if (args.stable_version_override) { + const overrideVersion = args.stable_version_override.replace(/^v/, ''); + validateVersion(overrideVersion, 'X.Y.Z', 'stable_version_override'); + releaseVersion = overrideVersion; + } else { + // Try to get from preview, fallback to package.json for first release + const { latestVersion: latestPreviewVersion } = getAndVerifyTags('preview'); + releaseVersion = latestPreviewVersion.replace(/-preview.*/, ''); + } + + return { + releaseVersion, + npmTag: 'latest', + }; +} + +function getPreviewVersion(args) { + let releaseVersion; + if (args.preview_version_override) { + const overrideVersion = args.preview_version_override.replace(/^v/, ''); + validateVersion( + overrideVersion, + 'X.Y.Z-preview.N', + 'preview_version_override', + ); + releaseVersion = overrideVersion; + } else { + // Try to get from nightly, fallback to package.json for first release + const { latestVersion: latestNightlyVersion } = getAndVerifyTags('nightly'); + releaseVersion = + latestNightlyVersion.replace(/-nightly.*/, '') + '-preview.0'; + } + + return { + releaseVersion, + npmTag: 'preview', + }; +} + +export function getVersion(options = {}) { + const args = { ...getArgs(), ...options }; + const type = args.type || 'nightly'; + + let versionData; + switch (type) { + case 'nightly': + versionData = getNightlyVersion(); + if (doesVersionExist(versionData.releaseVersion)) { + throw new Error( + `Version conflict! Nightly version ${versionData.releaseVersion} already exists.`, + ); + } + break; + case 'stable': + versionData = getStableVersion(args); + break; + case 'preview': + versionData = getPreviewVersion(args); + break; + default: + throw new Error(`Unknown release type: ${type}`); + } + + // For stable and preview versions, check for existence and increment if needed. + if (type === 'stable' || type === 'preview') { + let releaseVersion = versionData.releaseVersion; + while (doesVersionExist(releaseVersion)) { + console.error(`Version ${releaseVersion} exists, incrementing.`); + if (releaseVersion.includes('-preview.')) { + const [version, prereleasePart] = releaseVersion.split('-'); + const previewNumber = parseInt(prereleasePart.split('.')[1]); + releaseVersion = `${version}-preview.${previewNumber + 1}`; + } else { + const versionParts = releaseVersion.split('.'); + const major = versionParts[0]; + const minor = versionParts[1]; + const patch = parseInt(versionParts[2]); + releaseVersion = `${major}.${minor}.${patch + 1}`; + } + } + versionData.releaseVersion = releaseVersion; + } + + const result = { + releaseTag: `v${versionData.releaseVersion}`, + ...versionData, + }; + + result.previousReleaseTag = getLatestStableReleaseTag(); + + return result; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const version = JSON.stringify(getVersion(getArgs()), null, 2); + console.log(version); +} From b1d848f935f348826e4fdf4b4c8219bd8154538f Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 18:04:32 +0800 Subject: [PATCH 18/38] chore: update lockfile --- package-lock.json | 2819 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2819 insertions(+) diff --git a/package-lock.json b/package-lock.json index b2042225..53fe9d46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1285,6 +1285,22 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1299,6 +1315,14 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", @@ -2769,6 +2793,10 @@ "resolved": "packages/test-utils", "link": true }, + "node_modules/@qwen-code/sdk-typescript": { + "resolved": "packages/sdk-typescript", + "link": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz", @@ -4158,6 +4186,13 @@ "node": ">=20.0.0" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -4726,6 +4761,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -5086,6 +5134,16 @@ "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -6190,6 +6248,13 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -6747,6 +6812,39 @@ "node": ">=0.3.1" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dir-glob/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6860,6 +6958,23 @@ "url": "https://dotenvx.com" } }, + "node_modules/dts-bundle-generator": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/dts-bundle-generator/-/dts-bundle-generator-9.5.1.tgz", + "integrity": "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "typescript": ">=5.0.2", + "yargs": "^17.6.0" + }, + "bin": { + "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7706,6 +7821,72 @@ "node": ">=20.0.0" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -8191,6 +8372,13 @@ "node": ">= 10.0.0" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -8303,6 +8491,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -8340,6 +8538,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -8904,6 +9115,16 @@ "node": ">= 14" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -9036,6 +9257,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -10589,6 +10822,23 @@ "node": ">=4" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10944,6 +11194,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -11072,6 +11329,19 @@ "license": "MIT", "optional": true }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, "node_modules/mnemonist": { "version": "0.40.3", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", @@ -11642,6 +11912,35 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -12132,6 +12431,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -12266,6 +12575,18 @@ "node": ">=16.20.0" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -13095,6 +13416,45 @@ "dev": true, "license": "MIT" }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", @@ -14090,6 +14450,19 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -14755,6 +15128,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -14926,6 +15309,13 @@ "dev": true, "license": "MIT" }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -16285,6 +16675,2435 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "packages/sdk-typescript": { + "name": "@qwen-code/sdk-typescript", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "@vitest/coverage-v8": "^1.6.0", + "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.25.12", + "eslint": "^8.57.0", + "typescript": "^5.4.5", + "vitest": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "packages/sdk-typescript/node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/sdk-typescript/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "packages/sdk-typescript/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "packages/sdk-typescript/node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "packages/sdk-typescript/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "packages/sdk-typescript/node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "packages/sdk-typescript/node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/sdk-typescript/node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "packages/sdk-typescript/node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "packages/sdk-typescript/node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "packages/sdk-typescript/node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "packages/sdk-typescript/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "packages/sdk-typescript/node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "packages/sdk-typescript/node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "packages/sdk-typescript/node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "packages/sdk-typescript/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/sdk-typescript/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "packages/sdk-typescript/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "packages/sdk-typescript/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "packages/sdk-typescript/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "packages/sdk-typescript/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "packages/sdk-typescript/node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "packages/sdk-typescript/node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sdk-typescript/node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/sdk-typescript/node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "packages/sdk-typescript/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/sdk-typescript/node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/sdk-typescript/node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/sdk-typescript/node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "packages/sdk-typescript/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", "version": "0.4.0", From 56f61bc0b86bcc61fd98b33044872efb564ee1d2 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 22:50:46 +0800 Subject: [PATCH 19/38] fix: path literals in windows --- packages/sdk-typescript/test/unit/cliPath.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index 0e40e23a..c097f44d 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -59,7 +59,7 @@ describe('CLI Path Utilities', () => { const result = parseExecutableSpec(); expect(result).toEqual({ - executablePath: '/usr/local/bin/qwen', + executablePath: path.resolve('/usr/local/bin/qwen'), isExplicitRuntime: false, }); @@ -167,7 +167,7 @@ describe('CLI Path Utilities', () => { const result = parseExecutableSpec('/absolute/path/to/qwen'); expect(result).toEqual({ - executablePath: '/absolute/path/to/qwen', + executablePath: path.resolve('/absolute/path/to/qwen'), isExplicitRuntime: false, }); }); @@ -214,7 +214,7 @@ describe('CLI Path Utilities', () => { const result = prepareSpawnInfo('/usr/local/bin/qwen'); expect(result).toEqual({ - command: '/usr/local/bin/qwen', + command: path.resolve('/usr/local/bin/qwen'), args: [], type: 'native', originalInput: '/usr/local/bin/qwen', @@ -304,8 +304,9 @@ describe('CLI Path Utilities', () => { throw new Error('Command not found'); }); + const resolvedPath = path.resolve('/path/to/index.ts'); expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( - "TypeScript file '/path/to/index.ts' requires 'tsx' runtime, but it's not available", + `TypeScript file '${resolvedPath}' requires 'tsx' runtime, but it's not available`, ); expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow( 'Please install tsx: npm install -g tsx', @@ -368,7 +369,7 @@ describe('CLI Path Utilities', () => { const result = prepareSpawnInfo(); expect(result).toEqual({ - command: '/usr/local/bin/qwen', + command: path.resolve('/usr/local/bin/qwen'), args: [], type: 'native', originalInput: '', @@ -388,7 +389,7 @@ describe('CLI Path Utilities', () => { const result = findNativeCliPath(); - expect(result).toBe('/custom/path/to/qwen'); + expect(result).toBe(path.resolve('/custom/path/to/qwen')); process.env['QWEN_CODE_CLI_PATH'] = originalEnv; }); From 839a1d9d8c026be2c643b32c18476c411ea66898 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Mon, 1 Dec 2025 23:10:05 +0800 Subject: [PATCH 20/38] fix: mock path for cross platform compability in test cases --- packages/sdk-typescript/test/unit/cliPath.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index c097f44d..43f50dec 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -399,13 +399,15 @@ describe('CLI Path Utilities', () => { delete process.env['QWEN_CODE_CLI_PATH']; // Mock fs.existsSync to return true for volta bin - mockFs.existsSync.mockImplementation((path) => { - return path.toString().includes('.volta/bin/qwen'); + // Use path.join to match platform-specific path separators + const voltaBinPath = path.join('.volta', 'bin', 'qwen'); + mockFs.existsSync.mockImplementation((p) => { + return p.toString().includes(voltaBinPath); }); const result = findNativeCliPath(); - expect(result).toContain('.volta/bin/qwen'); + expect(result).toContain(voltaBinPath); process.env['QWEN_CODE_CLI_PATH'] = originalEnv; }); From 51b9281774f3cee884d934486f99f3f6a4fd9d28 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 3 Dec 2025 09:58:20 +0800 Subject: [PATCH 21/38] chore: remove scheduled triggers from SDK release workflow --- .github/workflows/release-sdk.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 6249f08e..cd80ca44 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -1,11 +1,6 @@ name: 'Release SDK' on: - schedule: - # Runs every day at 1:00 AM UTC for the nightly release (offset from CLI at 0:00). - - cron: '0 1 * * *' - # Runs every Wednesday at 00:59 UTC for the preview release (offset from CLI on Tuesday). - - cron: '59 0 * * 3' workflow_dispatch: inputs: version: From c18fed574f15e19715ac4facdc00804d377d9fb3 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 4 Dec 2025 17:22:20 +0800 Subject: [PATCH 22/38] chore: fix RELEASE_TAG fallback in workflow --- .github/workflows/release-sdk.yml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index cd80ca44..ef1775a6 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -39,8 +39,7 @@ jobs: environment: name: 'production-release' url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}' - if: |- - ${{ github.repository == 'QwenLM/qwen-code' }} + if: github.repository == 'QwenLM/qwen-code' permissions: contents: 'write' packages: 'write' @@ -60,19 +59,17 @@ jobs: env: CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}' CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' - EVENT_NAME: '${{ github.event_name }}' - CRON: '${{ github.event.schedule }}' DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' id: 'vars' run: |- is_nightly="false" - if [[ "${CRON}" == "0 1 * * *" || "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then + if [[ "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then is_nightly="true" fi echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}" is_preview="false" - if [[ "${CRON}" == "59 0 * * 3" || "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then + if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then is_preview="true" fi echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}" @@ -124,8 +121,7 @@ jobs: MANUAL_VERSION: '${{ inputs.version }}' - name: 'Run Tests' - if: |- - ${{ github.event.inputs.force_skip_tests != 'true' }} + if: github.event.inputs.force_skip_tests != 'true' working-directory: 'packages/sdk-typescript' run: | npm run test:ci @@ -182,7 +178,7 @@ jobs: - name: 'Configure npm for publishing' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' registry-url: 'https://registry.npmjs.org' scope: '@qwen-code' @@ -194,8 +190,7 @@ jobs: NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' - name: 'Create GitHub Release and Tag' - if: |- - ${{ steps.vars.outputs.is_dry_run == 'false' }} + if: steps.vars.outputs.is_dry_run == 'false' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' @@ -209,11 +204,10 @@ jobs: --generate-notes - name: 'Create Issue on Failure' - if: |- - ${{ failure() }} + if: failure() env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }} || "N/A"' + RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}" DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: |- gh issue create \ From 0630908e0c9e1565cc80c7e17ebec518a3489279 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 4 Dec 2025 17:36:22 +0800 Subject: [PATCH 23/38] fix: lint error --- packages/cli/src/gemini.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 81d34fe1..f602d17d 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -272,7 +272,7 @@ describe('gemini.tsx main function', () => { ); vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined); - vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => { }); + vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {}); const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup); runExitCleanupMock.mockResolvedValue(undefined); vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]); @@ -498,7 +498,7 @@ describe('validateDnsResolutionOrder', () => { let consoleWarnSpy: ReturnType; beforeEach(() => { - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { From 1c45ef563d69c05a48acbce38238bed380ce3300 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 4 Dec 2025 17:41:52 +0800 Subject: [PATCH 24/38] remove unused files --- .../ui/components/QuitConfirmationDialog.tsx | 73 ------------------- .../cli/src/ui/hooks/useQuitConfirmation.ts | 37 ---------- 2 files changed, 110 deletions(-) delete mode 100644 packages/cli/src/ui/components/QuitConfirmationDialog.tsx delete mode 100644 packages/cli/src/ui/hooks/useQuitConfirmation.ts diff --git a/packages/cli/src/ui/components/QuitConfirmationDialog.tsx b/packages/cli/src/ui/components/QuitConfirmationDialog.tsx deleted file mode 100644 index 84162779..00000000 --- a/packages/cli/src/ui/components/QuitConfirmationDialog.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Box, Text } from 'ink'; -import type React from 'react'; -import { Colors } from '../colors.js'; -import { - RadioButtonSelect, - type RadioSelectItem, -} from './shared/RadioButtonSelect.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { t } from '../../i18n/index.js'; - -export enum QuitChoice { - CANCEL = 'cancel', - QUIT = 'quit', - SUMMARY_AND_QUIT = 'summary_and_quit', -} - -interface QuitConfirmationDialogProps { - onSelect: (choice: QuitChoice) => void; -} - -export const QuitConfirmationDialog: React.FC = ({ - onSelect, -}) => { - useKeypress( - (key) => { - if (key.name === 'escape') { - onSelect(QuitChoice.CANCEL); - } - }, - { isActive: true }, - ); - - const options: Array> = [ - { - key: 'quit', - label: t('Quit immediately (/quit)'), - value: QuitChoice.QUIT, - }, - { - key: 'summary-and-quit', - label: t('Generate summary and quit (/summary)'), - value: QuitChoice.SUMMARY_AND_QUIT, - }, - { - key: 'cancel', - label: t('Cancel (stay in application)'), - value: QuitChoice.CANCEL, - }, - ]; - - return ( - - - {t('What would you like to do before exiting?')} - - - - - ); -}; diff --git a/packages/cli/src/ui/hooks/useQuitConfirmation.ts b/packages/cli/src/ui/hooks/useQuitConfirmation.ts deleted file mode 100644 index fff0d488..00000000 --- a/packages/cli/src/ui/hooks/useQuitConfirmation.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useCallback } from 'react'; -import { QuitChoice } from '../components/QuitConfirmationDialog.js'; - -export const useQuitConfirmation = () => { - const [isQuitConfirmationOpen, setIsQuitConfirmationOpen] = useState(false); - - const showQuitConfirmation = useCallback(() => { - setIsQuitConfirmationOpen(true); - }, []); - - const handleQuitConfirmationSelect = useCallback((choice: QuitChoice) => { - setIsQuitConfirmationOpen(false); - - if (choice === QuitChoice.CANCEL) { - return { shouldQuit: false, action: 'cancel' }; - } else if (choice === QuitChoice.QUIT) { - return { shouldQuit: true, action: 'quit' }; - } else if (choice === QuitChoice.SUMMARY_AND_QUIT) { - return { shouldQuit: true, action: 'summary_and_quit' }; - } - - // Default to cancel if unknown choice - return { shouldQuit: false, action: 'cancel' }; - }, []); - - return { - isQuitConfirmationOpen, - showQuitConfirmation, - handleQuitConfirmationSelect, - }; -}; From 427c69ba0747a8b1cc02e502a38ed9ebd8edefd4 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 4 Dec 2025 17:52:07 +0800 Subject: [PATCH 25/38] chore: fix sdk release workflow and verified with yamllint and act --- .github/workflows/release-sdk.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index ef1775a6..d0b558f7 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -39,7 +39,8 @@ jobs: environment: name: 'production-release' url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/sdk-typescript-${{ steps.version.outputs.RELEASE_TAG }}' - if: github.repository == 'QwenLM/qwen-code' + if: |- + ${{ github.repository == 'QwenLM/qwen-code' }} permissions: contents: 'write' packages: 'write' @@ -121,7 +122,8 @@ jobs: MANUAL_VERSION: '${{ inputs.version }}' - name: 'Run Tests' - if: github.event.inputs.force_skip_tests != 'true' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} working-directory: 'packages/sdk-typescript' run: | npm run test:ci @@ -190,7 +192,8 @@ jobs: NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' - name: 'Create GitHub Release and Tag' - if: steps.vars.outputs.is_dry_run == 'false' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' @@ -204,7 +207,8 @@ jobs: --generate-notes - name: 'Create Issue on Failure' - if: failure() + if: |- + ${{ failure() }} env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}" From 57b519db9aa018d009ac659e2b37cfeca071db5e Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 4 Dec 2025 20:39:51 +0800 Subject: [PATCH 26/38] test: skip qwen-oauth e2 case in sandbox --- .../configuration-options.test.ts | 47 ++++++++++++++----- packages/cli/src/gemini.tsx | 4 +- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/integration-tests/sdk-typescript/configuration-options.test.ts b/integration-tests/sdk-typescript/configuration-options.test.ts index bac0a368..3f825f3e 100644 --- a/integration-tests/sdk-typescript/configuration-options.test.ts +++ b/integration-tests/sdk-typescript/configuration-options.test.ts @@ -438,10 +438,15 @@ describe('Configuration Options (E2E)', () => { } }); - it('should accept authType: qwen-oauth', async () => { - // Note: qwen-oauth requires credentials in ~/.qwen - // This test may fail if credentials are not configured - // The test verifies the option is accepted and passed correctly + // Skip in containerized sandbox environments - qwen-oauth requires user interaction + // which is not possible in Docker/Podman CI environments + it.skipIf( + process.env['SANDBOX'] === 'sandbox:docker' || + process.env['SANDBOX'] === 'sandbox:podman', + )('should accept authType: qwen-oauth', async () => { + // Note: qwen-oauth requires credentials in ~/.qwen and user interaction + // Without credentials, the auth process will timeout waiting for user + // This test verifies the option is accepted and passed correctly to CLI const stderrMessages: string[] = []; @@ -452,6 +457,7 @@ describe('Configuration Options (E2E)', () => { cwd: testDir, authType: 'qwen-oauth', debug: true, + logLevel: 'debug', stderr: (msg: string) => { stderrMessages.push(msg); }, @@ -461,16 +467,31 @@ describe('Configuration Options (E2E)', () => { const messages: SDKMessage[] = []; try { - for await (const message of q) { - messages.push(message); - } + // Use a timeout to avoid hanging when credentials are not configured + const timeoutPromise = new Promise<'timeout'>((resolve) => + setTimeout(() => resolve('timeout'), 20000), + ); - // The query should at least start (may fail due to missing credentials) - expect(messages.length).toBeGreaterThan(0); - } catch (error) { - // qwen-oauth may fail if credentials are not configured - // This is acceptable - we're testing that the option is passed correctly - expect(error).toBeDefined(); + const collectMessages = async () => { + for await (const message of q) { + messages.push(message); + } + return 'completed'; + }; + + const result = await Promise.race([collectMessages(), timeoutPromise]); + + if (result === 'timeout') { + // Timeout is expected when OAuth credentials are not configured + // Verify that CLI was spawned with correct --auth-type argument + const hasAuthTypeArg = stderrMessages.some((msg) => + msg.includes('--auth-type'), + ); + expect(hasAuthTypeArg).toBe(true); + } else { + // If credentials exist and auth completed, verify we got messages + expect(messages.length).toBeGreaterThan(0); + } } finally { await q.close(); } diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 3aa3f957..7171670c 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -449,8 +449,8 @@ export async function main() { } const nonInteractiveConfig = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType || - (argv.authType as AuthType), + (argv.authType as AuthType) || + settings.merged.security?.auth?.selectedType, settings.merged.security?.auth?.useExternal, config, settings, From 322ce80e2c54b967a3bdf0705aaca3a150571f36 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 4 Dec 2025 17:01:13 +0800 Subject: [PATCH 27/38] 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. --- .../configuration-options.test.ts | 1 - .../sdk-typescript/sdk-mcp-server.test.ts | 465 ++++++++++++++++++ .../sdk-typescript/single-turn.test.ts | 1 - .../control/ControlDispatcher.ts | 65 ++- .../control/controllers/baseController.ts | 47 +- .../control/controllers/mcpController.ts | 287 ----------- .../controllers/permissionController.ts | 50 +- .../control/controllers/sdkMcpController.ts | 138 ++++++ .../control/controllers/systemController.ts | 266 ++++++++-- .../cli/src/nonInteractive/session.test.ts | 10 + packages/cli/src/nonInteractive/session.ts | 311 +++++++++--- packages/cli/src/nonInteractive/types.ts | 66 ++- packages/core/src/config/config.test.ts | 1 + packages/core/src/config/config.ts | 39 +- packages/core/src/index.ts | 2 + packages/core/src/tools/mcp-client-manager.ts | 11 + packages/core/src/tools/mcp-client.ts | 40 +- .../src/tools/sdk-control-client-transport.ts | 163 ++++++ packages/core/src/tools/tool-registry.ts | 8 +- packages/sdk-typescript/README.md | 27 +- packages/sdk-typescript/src/index.ts | 20 +- .../src/mcp/SdkControlServerTransport.ts | 6 - .../src/mcp/createSdkMcpServer.ts | 154 +++--- packages/sdk-typescript/src/mcp/tool.ts | 121 ++--- packages/sdk-typescript/src/query/Query.ts | 252 ++++++++-- packages/sdk-typescript/src/types/protocol.ts | 37 +- .../src/types/queryOptionsSchema.ts | 99 +++- packages/sdk-typescript/src/types/types.ts | 201 +++++++- .../test/unit/createSdkMcpServer.test.ts | 422 ++++++++-------- 29 files changed, 2473 insertions(+), 837 deletions(-) create mode 100644 integration-tests/sdk-typescript/sdk-mcp-server.test.ts delete mode 100644 packages/cli/src/nonInteractive/control/controllers/mcpController.ts create mode 100644 packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts create mode 100644 packages/core/src/tools/sdk-control-client-transport.ts diff --git a/integration-tests/sdk-typescript/configuration-options.test.ts b/integration-tests/sdk-typescript/configuration-options.test.ts index 3f825f3e..9b958a50 100644 --- a/integration-tests/sdk-typescript/configuration-options.test.ts +++ b/integration-tests/sdk-typescript/configuration-options.test.ts @@ -532,7 +532,6 @@ describe('Configuration Options (E2E)', () => { cwd: testDir, authType: 'openai', debug: true, - logLevel: 'debug', stderr: (msg: string) => { stderrMessages.push(msg); }, diff --git a/integration-tests/sdk-typescript/sdk-mcp-server.test.ts b/integration-tests/sdk-typescript/sdk-mcp-server.test.ts new file mode 100644 index 00000000..b389d46a --- /dev/null +++ b/integration-tests/sdk-typescript/sdk-mcp-server.test.ts @@ -0,0 +1,465 @@ +/** + * @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-typescript'; +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 + console.log( + z.object({ + a: z.number().describe('First number'), + b: z.number().describe('Second number'), + }), + ); + 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, + stderr: (message) => console.error(message), + mcpServers: { + 'sdk-calculator': serverConfig, + }, + }, + }); + + const messages: SDKMessage[] = []; + let assistantText = ''; + let foundToolUse = false; + + try { + for await (const message of q) { + messages.push(message); + console.log(JSON.stringify(message, null, 2)); + + 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); + } + } + console.log(JSON.stringify(messages, null, 2)); + + // 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(); + } + }); + }); +}); diff --git a/integration-tests/sdk-typescript/single-turn.test.ts b/integration-tests/sdk-typescript/single-turn.test.ts index aa2716f3..61bbf7e2 100644 --- a/integration-tests/sdk-typescript/single-turn.test.ts +++ b/integration-tests/sdk-typescript/single-turn.test.ts @@ -44,7 +44,6 @@ describe('Single-Turn Query (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, debug: true, - logLevel: 'debug', }, }); diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts index b2165ee9..d6dc79a4 100644 --- a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts @@ -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 { + 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; diff --git a/packages/cli/src/nonInteractive/control/controllers/baseController.ts b/packages/cli/src/nonInteractive/control/controllers/baseController.ts index 90b7f56a..dcb9e7c9 100644 --- a/packages/cli/src/nonInteractive/control/controllers/baseController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/baseController.ts @@ -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 { + // Check if already aborted + if (signal?.aborted) { + throw new Error('Request aborted'); + } + const requestId = randomUUID(); return new Promise((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); } diff --git a/packages/cli/src/nonInteractive/control/controllers/mcpController.ts b/packages/cli/src/nonInteractive/control/controllers/mcpController.ts deleted file mode 100644 index fccafb67..00000000 --- a/packages/cli/src/nonInteractive/control/controllers/mcpController.ts +++ /dev/null @@ -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> { - 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> { - 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 = { - 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> { - const status: Record = {}; - - // 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 | 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(); - } -} diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index 37a9082f..4cec3b00 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -44,15 +44,23 @@ export class PermissionController extends BaseController { */ protected async handleRequestPayload( payload: ControlRequestPayload, - _signal: AbortSignal, + signal: AbortSignal, ): Promise> { + 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> { + 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> { + 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 { 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( diff --git a/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts b/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts new file mode 100644 index 00000000..5d0264fb --- /dev/null +++ b/packages/cli/src/nonInteractive/control/controllers/sdkMcpController.ts @@ -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> { + 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> { + const status: Record = {}; + + 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 { + 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; + 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 { + return (serverName: string, message: JSONRPCMessage) => + this.sendMcpMessageToSdk(serverName, message); + } +} diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index c94187e7..e214a881 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -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> { + 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> { + 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 = {}; + 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 = {}; + 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> { + 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> { - const slashCommands = await this.loadSlashCommandNames(); + private async handleSupportedCommands( + signal: AbortSignal, + ): Promise> { + 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 { - const controller = new AbortController(); + private async loadSlashCommandNames(signal: AbortSignal): Promise { + 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(); 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(); } } } diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 6670d4c2..84d7dece 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -153,6 +153,11 @@ describe('runNonInteractiveStreamJson', () => { handleControlResponse: ReturnType; handleCancel: ReturnType; shutdown: ReturnType; + getPendingIncomingRequestCount: ReturnType; + waitForPendingIncomingRequests: ReturnType; + sdkMcpController: { + createSendSdkMcpMessage: ReturnType; + }; }; let mockConsolePatcher: { patch: ReturnType; @@ -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 diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index 7cfa92c0..e8e6da12 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -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 | 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((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 { + private async ensureConfigInitialized( + options?: ConfigInitializeOptions, + ): Promise { 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 { + 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 { + ): 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 { + 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 { + 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 { + // 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 { 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 { 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) { diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 131c1be0..1d5e800d 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -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; + cwd?: string; + url?: string; + httpUrl?: string; + headers?: Record; + 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; - mcpServers?: Record; + /** + * 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>; + /** + * 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; agents?: SubagentConfig[]; } diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1c83432d..6aa49306 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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 }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d3e0fd5c..6383cb17 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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: @.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', @@ -360,6 +370,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; @@ -599,8 +620,9 @@ export class Config { /** * Must only be called once, throws if called again. + * @param options Optional initialization options including sendSdkMcpMessage callback */ - async initialize(): Promise { + async initialize(options?: ConfigInitializeOptions): Promise { if (this.initialized) { throw Error('Config was already initialized'); } @@ -619,7 +641,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(); @@ -1261,8 +1285,14 @@ export class Config { return this.subagentManager; } - async createToolRegistry(): Promise { - const registry = new ToolRegistry(this, this.eventEmitter); + async createToolRegistry( + sendSdkMcpMessage?: SendSdkMcpMessage, + ): Promise { + const registry = new ToolRegistry( + this, + this.eventEmitter, + sendSdkMcpMessage, + ); const coreToolsConfig = this.getCoreTools(); const excludeToolsConfig = this.getExcludeTools(); @@ -1347,6 +1377,7 @@ export class Config { } await registry.discoverAllTools(); + console.debug('ToolRegistry created', registry.getAllToolNames()); return registry; } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 38ac7ada..738aca57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index 93e25ea8..a8b48236 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -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, @@ -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); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index a6903d13..efea02ad 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -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; + 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 { - return createTransport(this.serverName, this.serverConfig, this.debugMode); + return createTransport( + this.serverName, + this.serverConfig, + this.debugMode, + this.sendSdkMcpMessage, + ); } private async discoverTools(cliConfig: Config): Promise { @@ -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 { 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 { 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 { + 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 diff --git a/packages/core/src/tools/sdk-control-client-transport.ts b/packages/core/src/tools/sdk-control-client-transport.ts new file mode 100644 index 00000000..be2f3099 --- /dev/null +++ b/packages/core/src/tools/sdk-control-client-transport.ts @@ -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; + +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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index a0123107..9b641647 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -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, ); } diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index ed441bc7..05adb9db 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -61,7 +61,7 @@ Creates a new query session with the Qwen Code. | `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). | | `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | -| `mcpServers` | `Record` | - | 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` | - | 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` | 30 seconds | 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` | 30 seconds | 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 diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index da40baf2..4ae46597 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -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'; diff --git a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts index 06392a4f..28db7b2d 100644 --- a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts +++ b/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts @@ -103,9 +103,3 @@ export class SdkControlServerTransport { return this.serverName; } } - -export function createSdkControlServerTransport( - options: SdkControlServerTransportOptions, -): SdkControlServerTransport { - return new SdkControlServerTransport(options); -} diff --git a/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts index 841440e1..c202e612 100644 --- a/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts +++ b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts @@ -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; +/** + * 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>; +}; +/** + * 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-typescript'; + * + * 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(); - 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(); - 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 }; } diff --git a/packages/sdk-typescript/src/mcp/tool.ts b/packages/sdk-typescript/src/mcp/tool.ts index 667bf5e5..9616dfc8 100644 --- a/packages/sdk-typescript/src/mcp/tool.ts +++ b/packages/sdk-typescript/src/mcp/tool.ts @@ -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( - def: ToolDefinition, -): ToolDefinition { - // 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; + +/** + * SDK MCP Tool Definition with Zod schema type inference + */ +export type SdkMcpToolDefinition = { + name: string; + description: string; + inputSchema: Schema; + handler: ( + args: z.infer>, + extra: unknown, + ) => Promise; +}; + +/** + * Create an SDK MCP tool definition with Zod schema inference + * + * @example + * ```typescript + * import { z } from 'zod'; + * import { tool } from '@qwen-code/sdk-typescript'; + * + * 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( + name: string, + description: string, + inputSchema: Schema, + handler: ( + args: z.infer>, + extra: unknown, + ) => Promise, +): SdkMcpToolDefinition { + 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; - - 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( - def: ToolDefinition, -): ToolDefinition { - // Validate via tool() function - const validated = tool(def); - - // Additional validation - validateToolName(validated.name); - validateInputSchema(validated.inputSchema); - - return validated; -} diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 849b0d7b..81edc48f 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -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 = 30_000; +const DEFAULT_MCP_REQUEST_TIMEOUT = 60_000; +const DEFAULT_CONTROL_REQUEST_TIMEOUT = 30_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 { private abortController: AbortController; private pendingControlRequests: Map = new Map(); + private pendingMcpResponses: Map = new Map(); private sdkMcpTransports: Map = new Map(); + private sdkMcpServers: Map = new Map(); readonly initialized: Promise; private closed = false; private messageRouterStarted = false; @@ -92,6 +105,11 @@ export class Query implements AsyncIterable { */ 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 { this.startMessageRouter(); } + private async initializeSdkMcpServers(): Promise { + if (!this.options.mcpServers) { + return; + } + + const connectionPromises: Array> = []; + + // 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 { + const sdkServers: Record = {}; + + 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 { + if (!this.options.mcpServers) { + return {}; + } + + const externalServers: Record = {}; + + 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 { 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,12 @@ export class Query implements AsyncIterable { } try { + const canUseToolTimeout = + this.options.timeout?.canUseTool ?? DEFAULT_CAN_USE_TOOL_TIMEOUT; const timeoutPromise = new Promise((_, reject) => { setTimeout( () => reject(new Error('Permission callback timeout')), - PERMISSION_CALLBACK_TIMEOUT, + canUseToolTimeout, ); }); @@ -361,32 +516,45 @@ export class Query implements AsyncIterable { } private handleMcpRequest( - _serverName: string, + serverName: string, message: JSONRPCMessage, transport: SdkControlServerTransport, ): Promise { + 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); }); } @@ -466,10 +634,13 @@ export class Query implements AsyncIterable { const responsePromise = new Promise | 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, @@ -520,6 +691,12 @@ export class Query implements AsyncIterable { } this.pendingControlRequests.clear(); + // Clean up pending MCP responses + for (const pending of this.pendingMcpResponses.values()) { + pending.reject(new Error('Query closed')); + } + this.pendingMcpResponses.clear(); + await this.transport.close(); /** @@ -588,22 +765,31 @@ export class Query implements AsyncIterable { } /** - * 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 ) { + const streamCloseTimeout = + this.options.timeout?.streamClose ?? DEFAULT_STREAM_CLOSE_TIMEOUT; await Promise.race([ this.firstResultReceivedPromise, new Promise((resolve) => { setTimeout(() => { resolve(); - }, STREAM_CLOSE_TIMEOUT); + }, streamCloseTimeout); }), ]); } diff --git a/packages/sdk-typescript/src/types/protocol.ts b/packages/sdk-typescript/src/types/protocol.ts index efb61cb4..e5eeb121 100644 --- a/packages/sdk-typescript/src/types/protocol.ts +++ b/packages/sdk-typescript/src/types/protocol.ts @@ -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; + export interface CLIControlInitializeRequest { subtype: 'initialize'; hooks?: HookRegistration[] | null; - sdkMcpServers?: Record; + /** + * 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; + /** + * External MCP servers that should be managed by the CLI. + */ mcpServers?: Record; agents?: SubagentConfig[]; } diff --git a/packages/sdk-typescript/src/types/queryOptionsSchema.ts b/packages/sdk-typescript/src/types/queryOptionsSchema.ts index 579445cf..a4794b3f 100644 --- a/packages/sdk-typescript/src/types/queryOptionsSchema.ts +++ b/packages/sdk-typescript/src/types/queryOptionsSchema.ts @@ -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>( - (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; + close(): Promise; + }>( + (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(); diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index a3f6cd03..a85125ea 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -2,25 +2,11 @@ import type { PermissionMode, PermissionSuggestion, SubagentConfig, + SDKMcpServerConfig, } from './protocol.js'; export type { PermissionMode }; -type JSONSchema = { - type: string; - properties?: Record; - required?: string[]; - description?: string; - [key: string]: unknown; -}; - -export type ToolDefinition = { - name: string; - description: string; - inputSchema: JSONSchema; - handler: (input: TInput) => Promise; -}; - 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; +/** + * 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; +/** + * 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; + cwd?: string; + // For SSE transport + url?: string; + // For streamable HTTP transport + httpUrl?: string; + headers?: Record; + // 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: @.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; + mcpServers?: Record; /** * AbortController to cancel the query session. @@ -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; + }; } diff --git a/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts index e608ba7b..8f39ad08 100644 --- a/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts +++ b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts @@ -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[], + }), ).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], + }), ).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 => { - return { - greeting: `Hello ${input.name}, age ${input.age}`, - }; - }); - - const typedTool = tool({ - 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(); From 64de3520b3f5e7b8b592da0131b2ce288b182757 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 4 Dec 2025 18:06:50 +0800 Subject: [PATCH 28/38] docs: update README to include SDK-embedded MCP server details and usage examples --- packages/sdk-typescript/README.md | 80 ++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index 05adb9db..f5b8fc8e 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -227,7 +227,7 @@ const result = query({ }); ``` -### With MCP Servers +### With External MCP Servers ```typescript import { query } from '@qwen-code/sdk-typescript'; @@ -246,6 +246,84 @@ 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` | 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-typescript'; + +// 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 From 46478e5dd3dfe4d4a04dbe8491a47696afed1645 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 5 Dec 2025 13:14:19 +0800 Subject: [PATCH 29/38] fix: try fix sandbox integration test failure --- packages/cli/src/gemini.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 7171670c..18f191bc 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -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(); } From 3e2a2255eeb0e731fb1008e61f23016ab15152d7 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 5 Dec 2025 15:08:35 +0800 Subject: [PATCH 30/38] DeepSeek V3.2 Thinking Mode Integration (#1134) --- packages/cli/src/nonInteractiveCli.test.ts | 20 ++++ packages/cli/src/nonInteractiveCli.ts | 7 +- .../src/ui/components/HistoryItemDisplay.tsx | 22 ++++ .../messages/GeminiThoughtMessage.tsx | 48 ++++++++ .../messages/GeminiThoughtMessageContent.tsx | 40 +++++++ .../cli/src/ui/hooks/useGeminiStream.test.tsx | 51 ++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 63 +++++++++- packages/cli/src/ui/types.ts | 12 ++ .../src/ui/utils/InlineMarkdownRenderer.tsx | 8 +- packages/cli/src/ui/utils/MarkdownDisplay.tsx | 40 ++++--- .../src/ui/utils/resumeHistoryUtils.test.ts | 7 +- .../cli/src/ui/utils/resumeHistoryUtils.ts | 34 +++++- packages/core/src/core/client.test.ts | 15 +++ packages/core/src/core/client.ts | 3 + packages/core/src/core/geminiChat.test.ts | 9 +- packages/core/src/core/geminiChat.ts | 70 ++++++----- .../openaiContentGenerator/converter.test.ts | 60 ++++++++++ .../core/openaiContentGenerator/converter.ts | 109 +++++++++++++++--- .../telemetryService.test.ts | 57 +++++---- .../telemetryService.ts | 13 +++ packages/core/src/core/turn.test.ts | 91 +++++++++++++++ packages/core/src/core/turn.ts | 9 +- packages/core/src/services/sessionService.ts | 49 +++++++- packages/core/src/utils/thoughtUtils.ts | 22 ++++ 24 files changed, 752 insertions(+), 107 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx create mode 100644 packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 5cc53fc6..30bc6a62 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -245,6 +245,7 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', + { isContinuation: false }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Hello'); expect(processStdoutSpy).toHaveBeenCalledWith(' World'); @@ -293,11 +294,21 @@ describe('runNonInteractive', () => { expect.any(AbortSignal), undefined, ); + // Verify first call has isContinuation: false + expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + 1, + [{ text: 'Use a tool' }], + expect.any(AbortSignal), + 'prompt-id-2', + { isContinuation: false }, + ); + // Verify second call (after tool execution) has isContinuation: true expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( 2, [{ text: 'Tool response' }], expect.any(AbortSignal), 'prompt-id-2', + { isContinuation: true }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Final answer'); expect(processStdoutSpy).toHaveBeenCalledWith('\n'); @@ -372,6 +383,7 @@ describe('runNonInteractive', () => { ], expect.any(AbortSignal), 'prompt-id-3', + { isContinuation: true }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.'); }); @@ -497,6 +509,7 @@ describe('runNonInteractive', () => { processedParts, expect.any(AbortSignal), 'prompt-id-7', + { isContinuation: false }, ); // 6. Assert the final output is correct @@ -528,6 +541,7 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', + { isContinuation: false }, ); // JSON adapter emits array of messages, last one is result with stats @@ -680,6 +694,7 @@ describe('runNonInteractive', () => { [{ text: 'Empty response test' }], expect.any(AbortSignal), 'prompt-id-empty', + { isContinuation: false }, ); // JSON adapter emits array of messages, last one is result with stats @@ -831,6 +846,7 @@ describe('runNonInteractive', () => { [{ text: 'Prompt from command' }], expect.any(AbortSignal), 'prompt-id-slash', + { isContinuation: false }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Response from command'); @@ -887,6 +903,7 @@ describe('runNonInteractive', () => { [{ text: '/unknowncommand' }], expect.any(AbortSignal), 'prompt-id-unknown', + { isContinuation: false }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown'); @@ -1217,6 +1234,7 @@ describe('runNonInteractive', () => { [{ text: 'Message from stream-json input' }], expect.any(AbortSignal), 'prompt-envelope', + { isContinuation: false }, ); }); @@ -1692,6 +1710,7 @@ describe('runNonInteractive', () => { [{ text: 'Simple string content' }], expect.any(AbortSignal), 'prompt-string-content', + { isContinuation: false }, ); // UserMessage with array of text blocks @@ -1724,6 +1743,7 @@ describe('runNonInteractive', () => { [{ text: 'First part' }, { text: 'Second part' }], expect.any(AbortSignal), 'prompt-blocks-content', + { isContinuation: false }, ); }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 6f96d62b..1614c304 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -172,6 +172,7 @@ export async function runNonInteractive( adapter.emitMessage(systemMessage); } + let isFirstTurn = true; while (true) { turnCount++; if ( @@ -187,7 +188,9 @@ export async function runNonInteractive( currentMessages[0]?.parts || [], abortController.signal, prompt_id, + { isContinuation: !isFirstTurn }, ); + isFirstTurn = false; // Start assistant message for this turn if (adapter) { @@ -207,7 +210,9 @@ export async function runNonInteractive( } } else { // Text output mode - direct stdout - if (event.type === GeminiEventType.Content) { + if (event.type === GeminiEventType.Thought) { + process.stdout.write(event.value.description); + } else if (event.type === GeminiEventType.Content) { process.stdout.write(event.value); } else if (event.type === GeminiEventType.ToolCallRequest) { toolCallRequests.push(event.value); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index af17fd40..97e1fb47 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -15,6 +15,8 @@ import { InfoMessage } from './messages/InfoMessage.js'; import { ErrorMessage } from './messages/ErrorMessage.js'; import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; import { GeminiMessageContent } from './messages/GeminiMessageContent.js'; +import { GeminiThoughtMessage } from './messages/GeminiThoughtMessage.js'; +import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageContent.js'; import { CompressionMessage } from './messages/CompressionMessage.js'; import { SummaryMessage } from './messages/SummaryMessage.js'; import { WarningMessage } from './messages/WarningMessage.js'; @@ -85,6 +87,26 @@ const HistoryItemDisplayComponent: React.FC = ({ terminalWidth={terminalWidth} /> )} + {itemForDisplay.type === 'gemini_thought' && ( + + )} + {itemForDisplay.type === 'gemini_thought_content' && ( + + )} {itemForDisplay.type === 'info' && ( )} diff --git a/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx b/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx new file mode 100644 index 00000000..22571852 --- /dev/null +++ b/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Text, Box } from 'ink'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { theme } from '../../semantic-colors.js'; + +interface GeminiThoughtMessageProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + terminalWidth: number; +} + +/** + * Displays model thinking/reasoning text with a softer, dimmed style + * to visually distinguish it from regular content output. + */ +export const GeminiThoughtMessage: React.FC = ({ + text, + isPending, + availableTerminalHeight, + terminalWidth, +}) => { + const prefix = '✦ '; + const prefixWidth = prefix.length; + + return ( + + + {prefix} + + + + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx new file mode 100644 index 00000000..f68dd3b5 --- /dev/null +++ b/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box } from 'ink'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { theme } from '../../semantic-colors.js'; + +interface GeminiThoughtMessageContentProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + terminalWidth: number; +} + +/** + * Continuation component for thought messages, similar to GeminiMessageContent. + * Used when a thought response gets too long and needs to be split for performance. + */ +export const GeminiThoughtMessageContent: React.FC< + GeminiThoughtMessageContentProps +> = ({ text, isPending, availableTerminalHeight, terminalWidth }) => { + const originalPrefix = '✦ '; + const prefixWidth = originalPrefix.length; + + return ( + + + + ); +}; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 5994cc60..f82caa80 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2261,6 +2261,57 @@ describe('useGeminiStream', () => { }); }); + it('should accumulate streamed thought descriptions', async () => { + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Thought, + value: { subject: '', description: 'thinking ' }, + }; + yield { + type: ServerGeminiEventType.Thought, + value: { subject: '', description: 'more' }, + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: 'STOP', usageMetadata: undefined }, + }; + })(), + ); + + const { result } = renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + false, // visionModelPreviewEnabled + () => {}, + 80, + 24, + ), + ); + + await act(async () => { + await result.current.submitQuery('Streamed thought'); + }); + + await waitFor(() => { + expect(result.current.thought?.description).toBe('thinking more'); + }); + }); + it('should memoize pendingHistoryItems', () => { mockUseReactToolScheduler.mockReturnValue([ [], diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 8e7cbc0d..b4df01b0 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -497,6 +497,61 @@ export const useGeminiStream = ( [addItem, pendingHistoryItemRef, setPendingHistoryItem], ); + const mergeThought = useCallback( + (incoming: ThoughtSummary) => { + setThought((prev) => { + if (!prev) { + return incoming; + } + const subject = incoming.subject || prev.subject; + const description = `${prev.description ?? ''}${incoming.description ?? ''}`; + return { subject, description }; + }); + }, + [setThought], + ); + + const handleThoughtEvent = useCallback( + ( + eventValue: ThoughtSummary, + currentThoughtBuffer: string, + userMessageTimestamp: number, + ): string => { + if (turnCancelledRef.current) { + return ''; + } + + // Extract the description text from the thought summary + const thoughtText = eventValue.description ?? ''; + if (!thoughtText) { + return currentThoughtBuffer; + } + + const newThoughtBuffer = currentThoughtBuffer + thoughtText; + + // If we're not already showing a thought, start a new one + if (pendingHistoryItemRef.current?.type !== 'gemini_thought') { + // If there's a pending non-thought item, finalize it first + if (pendingHistoryItemRef.current) { + addItem(pendingHistoryItemRef.current, userMessageTimestamp); + } + setPendingHistoryItem({ type: 'gemini_thought', text: '' }); + } + + // Update the existing thought message with accumulated content + setPendingHistoryItem({ + type: 'gemini_thought', + text: newThoughtBuffer, + }); + + // Also update the thought state for the loading indicator + mergeThought(eventValue); + + return newThoughtBuffer; + }, + [addItem, pendingHistoryItemRef, setPendingHistoryItem, mergeThought], + ); + const handleUserCancelledEvent = useCallback( (userMessageTimestamp: number) => { if (turnCancelledRef.current) { @@ -710,11 +765,16 @@ export const useGeminiStream = ( signal: AbortSignal, ): Promise => { let geminiMessageBuffer = ''; + let thoughtBuffer = ''; const toolCallRequests: ToolCallRequestInfo[] = []; for await (const event of stream) { switch (event.type) { case ServerGeminiEventType.Thought: - setThought(event.value); + thoughtBuffer = handleThoughtEvent( + event.value, + thoughtBuffer, + userMessageTimestamp, + ); break; case ServerGeminiEventType.Content: geminiMessageBuffer = handleContentEvent( @@ -776,6 +836,7 @@ export const useGeminiStream = ( }, [ handleContentEvent, + handleThoughtEvent, handleUserCancelledEvent, handleErrorEvent, scheduleToolCalls, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 79e5621a..96ed4c50 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -103,6 +103,16 @@ export type HistoryItemGeminiContent = HistoryItemBase & { text: string; }; +export type HistoryItemGeminiThought = HistoryItemBase & { + type: 'gemini_thought'; + text: string; +}; + +export type HistoryItemGeminiThoughtContent = HistoryItemBase & { + type: 'gemini_thought_content'; + text: string; +}; + export type HistoryItemInfo = HistoryItemBase & { type: 'info'; text: string; @@ -241,6 +251,8 @@ export type HistoryItemWithoutId = | HistoryItemUserShell | HistoryItemGemini | HistoryItemGeminiContent + | HistoryItemGeminiThought + | HistoryItemGeminiThoughtContent | HistoryItemInfo | HistoryItemError | HistoryItemWarning diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 4320c519..48efc6e8 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -19,12 +19,16 @@ const UNDERLINE_TAG_END_LENGTH = 4; // For "" interface RenderInlineProps { text: string; + textColor?: string; } -const RenderInlineInternal: React.FC = ({ text }) => { +const RenderInlineInternal: React.FC = ({ + text, + textColor = theme.text.primary, +}) => { // Early return for plain text without markdown or URLs if (!/[*_~`<[https?:]/.test(text)) { - return {text}; + return {text}; } const nodes: React.ReactNode[] = []; diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index da6bf21a..b5e7dd5d 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -17,6 +17,7 @@ interface MarkdownDisplayProps { isPending: boolean; availableTerminalHeight?: number; terminalWidth: number; + textColor?: string; } // Constants for Markdown parsing and rendering @@ -31,6 +32,7 @@ const MarkdownDisplayInternal: React.FC = ({ isPending, availableTerminalHeight, terminalWidth, + textColor = theme.text.primary, }) => { if (!text) return <>; @@ -116,7 +118,7 @@ const MarkdownDisplayInternal: React.FC = ({ addContentBlock( - + , ); @@ -155,7 +157,7 @@ const MarkdownDisplayInternal: React.FC = ({ addContentBlock( - + , ); @@ -173,36 +175,36 @@ const MarkdownDisplayInternal: React.FC = ({ switch (level) { case 1: headerNode = ( - - + + ); break; case 2: headerNode = ( - - + + ); break; case 3: headerNode = ( - - + + ); break; case 4: headerNode = ( - - + + ); break; default: headerNode = ( - - + + ); break; @@ -219,6 +221,7 @@ const MarkdownDisplayInternal: React.FC = ({ type="ul" marker={marker} leadingWhitespace={leadingWhitespace} + textColor={textColor} />, ); } else if (olMatch) { @@ -232,6 +235,7 @@ const MarkdownDisplayInternal: React.FC = ({ type="ol" marker={marker} leadingWhitespace={leadingWhitespace} + textColor={textColor} />, ); } else { @@ -245,8 +249,8 @@ const MarkdownDisplayInternal: React.FC = ({ } else { addContentBlock( - - + + , ); @@ -367,6 +371,7 @@ interface RenderListItemProps { type: 'ul' | 'ol'; marker: string; leadingWhitespace?: string; + textColor?: string; } const RenderListItemInternal: React.FC = ({ @@ -374,6 +379,7 @@ const RenderListItemInternal: React.FC = ({ type, marker, leadingWhitespace = '', + textColor = theme.text.primary, }) => { const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; const prefixWidth = prefix.length; @@ -385,11 +391,11 @@ const RenderListItemInternal: React.FC = ({ flexDirection="row" > - {prefix} + {prefix} - - + + diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index f0c94fab..29d60272 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -102,7 +102,7 @@ describe('resumeHistoryUtils', () => { ]); }); - it('marks tool results as error, skips thought text, and falls back when tool is missing', () => { + it('marks tool results as error, captures thought text, and falls back when tool is missing', () => { const conversation = { messages: [ { @@ -142,6 +142,11 @@ describe('resumeHistoryUtils', () => { const items = buildResumedHistoryItems(session, makeConfig({})); expect(items).toEqual([ + { + id: expect.any(Number), + type: 'gemini_thought', + text: 'should be skipped', + }, { id: expect.any(Number), type: 'gemini', text: 'visible text' }, { id: expect.any(Number), diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 85ae0572..3c69bfd4 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -17,7 +17,7 @@ import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; import { ToolCallStatus } from '../types.js'; /** - * Extracts text content from a Content object's parts. + * Extracts text content from a Content object's parts (excluding thought parts). */ function extractTextFromParts(parts: Part[] | undefined): string { if (!parts) return ''; @@ -34,6 +34,22 @@ function extractTextFromParts(parts: Part[] | undefined): string { return textParts.join('\n'); } +/** + * Extracts thought text content from a Content object's parts. + * Thought parts are identified by having `thought: true`. + */ +function extractThoughtTextFromParts(parts: Part[] | undefined): string { + if (!parts) return ''; + + const thoughtParts: string[] = []; + for (const part of parts) { + if ('text' in part && part.text && 'thought' in part && part.thought) { + thoughtParts.push(part.text); + } + } + return thoughtParts.join('\n'); +} + /** * Extracts function calls from a Content object's parts. */ @@ -187,12 +203,28 @@ function convertToHistoryItems( case 'assistant': { const parts = record.message?.parts as Part[] | undefined; + // Extract thought content + const thoughtText = extractThoughtTextFromParts(parts); + // Extract text content (non-function-call, non-thought) const text = extractTextFromParts(parts); // Extract function calls const functionCalls = extractFunctionCalls(parts); + // If there's thought content, add it as a gemini_thought message + if (thoughtText) { + // Flush any pending tool group before thought + if (currentToolGroup.length > 0) { + items.push({ + type: 'tool_group', + tools: [...currentToolGroup], + }); + currentToolGroup = []; + } + items.push({ type: 'gemini_thought', text: thoughtText }); + } + // If there's text content, add it as a gemini message if (text) { // Flush any pending tool group before text diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index e475e5b3..8adaf4f6 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -448,6 +448,7 @@ describe('Gemini Client (client.ts)', () => { getHistory: mockGetHistory, addHistory: vi.fn(), setHistory: vi.fn(), + stripThoughtsFromHistory: vi.fn(), } as unknown as GeminiChat; }); @@ -462,6 +463,7 @@ describe('Gemini Client (client.ts)', () => { const mockOriginalChat: Partial = { getHistory: vi.fn((_curated?: boolean) => chatHistory), setHistory: vi.fn(), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockOriginalChat as GeminiChat; @@ -1080,6 +1082,7 @@ describe('Gemini Client (client.ts)', () => { const mockChat = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), } as unknown as GeminiChat; client['chat'] = mockChat; @@ -1142,6 +1145,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1197,6 +1201,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1273,6 +1278,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1319,6 +1325,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1363,6 +1370,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1450,6 +1458,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1506,6 +1515,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -1586,6 +1596,7 @@ ${JSON.stringify( .mockReturnValue([ { role: 'user', parts: [{ text: 'previous message' }] }, ]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; }); @@ -1840,6 +1851,7 @@ ${JSON.stringify( addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), // Default empty history setHistory: vi.fn(), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -2180,6 +2192,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -2216,6 +2229,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; @@ -2256,6 +2270,7 @@ ${JSON.stringify( const mockChat: Partial = { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), + stripThoughtsFromHistory: vi.fn(), }; client['chat'] = mockChat as GeminiChat; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 4a60245a..6e3be209 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -419,6 +419,9 @@ export class GeminiClient { // record user message for session management this.config.getChatRecordingService()?.recordUserMessage(request); + + // strip thoughts from history before sending the message + this.stripThoughtsFromHistory(); } this.sessionTurnCount++; if ( diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 3e31a1c5..5aaa814f 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -1541,10 +1541,10 @@ describe('GeminiChat', () => { { role: 'model', parts: [ - { text: 'thinking...', thoughtSignature: 'thought-123' }, + { text: 'thinking...', thought: true }, + { text: 'hi' }, { functionCall: { name: 'test', args: {} }, - thoughtSignature: 'thought-456', }, ], }, @@ -1559,10 +1559,7 @@ describe('GeminiChat', () => { }, { role: 'model', - parts: [ - { text: 'thinking...' }, - { functionCall: { name: 'test', args: {} } }, - ], + parts: [{ text: 'hi' }, { functionCall: { name: 'test', args: {} } }], }, ]); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 5bdba396..e9e4fcc2 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -443,20 +443,28 @@ export class GeminiChat { } stripThoughtsFromHistory(): void { - this.history = this.history.map((content) => { - const newContent = { ...content }; - if (newContent.parts) { - newContent.parts = newContent.parts.map((part) => { - if (part && typeof part === 'object' && 'thoughtSignature' in part) { - const newPart = { ...part }; - delete (newPart as { thoughtSignature?: string }).thoughtSignature; - return newPart; - } - return part; - }); - } - return newContent; - }); + this.history = this.history + .map((content) => { + if (!content.parts) return content; + + // Filter out thought parts entirely + const filteredParts = content.parts.filter( + (part) => + !( + part && + typeof part === 'object' && + 'thought' in part && + part.thought + ), + ); + + return { + ...content, + parts: filteredParts, + }; + }) + // Remove Content objects that have no parts left after filtering + .filter((content) => content.parts && content.parts.length > 0); } setTools(tools: Tool[]): void { @@ -497,8 +505,6 @@ export class GeminiChat { ): AsyncGenerator { // Collect ALL parts from the model response (including thoughts for recording) const allModelParts: Part[] = []; - // Non-thought parts for history (what we send back to the API) - const historyParts: Part[] = []; let usageMetadata: GenerateContentResponseUsageMetadata | undefined; let hasToolCall = false; @@ -516,8 +522,6 @@ export class GeminiChat { // Collect all parts for recording allModelParts.push(...content.parts); - // Collect non-thought parts for history - historyParts.push(...content.parts.filter((part) => !part.thought)); } } @@ -534,9 +538,15 @@ export class GeminiChat { yield chunk; // Yield every chunk to the UI immediately. } - // Consolidate text parts for history (merges adjacent text parts). + const thoughtParts = allModelParts.filter((part) => part.thought); + const thoughtText = thoughtParts + .map((part) => part.text) + .join('') + .trim(); + + const contentParts = allModelParts.filter((part) => !part.thought); const consolidatedHistoryParts: Part[] = []; - for (const part of historyParts) { + for (const part of contentParts) { const lastPart = consolidatedHistoryParts[consolidatedHistoryParts.length - 1]; if ( @@ -550,20 +560,21 @@ export class GeminiChat { } } - const responseText = consolidatedHistoryParts + const contentText = consolidatedHistoryParts .filter((part) => part.text) .map((part) => part.text) .join('') .trim(); // Record assistant turn with raw Content and metadata - if (responseText || hasToolCall || usageMetadata) { + if (thoughtText || contentText || hasToolCall || usageMetadata) { this.chatRecordingService?.recordAssistantTurn({ model, message: [ - ...(responseText ? [{ text: responseText }] : []), + ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...(contentText ? [{ text: contentText }] : []), ...(hasToolCall - ? historyParts + ? contentParts .filter((part) => part.functionCall) .map((part) => ({ functionCall: part.functionCall })) : []), @@ -579,7 +590,7 @@ export class GeminiChat { // We throw an error only when there's no tool call AND: // - No finish reason, OR // - Empty response text (e.g., only thoughts with no actual content) - if (!hasToolCall && (!hasFinishReason || !responseText)) { + if (!hasToolCall && (!hasFinishReason || !contentText)) { if (!hasFinishReason) { throw new InvalidStreamError( 'Model stream ended without a finish reason.', @@ -593,8 +604,13 @@ export class GeminiChat { } } - // Add to history (without thoughts, for API calls) - this.history.push({ role: 'model', parts: consolidatedHistoryParts }); + this.history.push({ + role: 'model', + parts: [ + ...(thoughtText ? [{ text: thoughtText, thought: true }] : []), + ...consolidatedHistoryParts, + ], + }); } } diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 888a65ad..e29b4640 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { OpenAIContentConverter } from './converter.js'; import type { StreamingToolCallParser } from './streamingToolCallParser.js'; import type { GenerateContentParameters, Content } from '@google/genai'; +import type OpenAI from 'openai'; describe('OpenAIContentConverter', () => { let converter: OpenAIContentConverter; @@ -142,4 +143,63 @@ describe('OpenAIContentConverter', () => { expect(toolMessage?.content).toBe('{"data":{"value":42}}'); }); }); + + describe('OpenAI -> Gemini reasoning content', () => { + it('should convert reasoning_content to a thought part for non-streaming responses', () => { + const response = converter.convertOpenAIResponseToGemini({ + object: 'chat.completion', + id: 'chatcmpl-1', + created: 123, + model: 'gpt-test', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'final answer', + reasoning_content: 'chain-of-thought', + }, + finish_reason: 'stop', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletion); + + const parts = response.candidates?.[0]?.content?.parts; + expect(parts?.[0]).toEqual( + expect.objectContaining({ thought: true, text: 'chain-of-thought' }), + ); + expect(parts?.[1]).toEqual( + expect.objectContaining({ text: 'final answer' }), + ); + }); + + it('should convert streaming reasoning_content delta to a thought part', () => { + const chunk = converter.convertOpenAIChunkToGemini({ + object: 'chat.completion.chunk', + id: 'chunk-1', + created: 456, + choices: [ + { + index: 0, + delta: { + content: 'visible text', + reasoning_content: 'thinking...', + }, + finish_reason: 'stop', + logprobs: null, + }, + ], + model: 'gpt-test', + } as unknown as OpenAI.Chat.ChatCompletionChunk); + + const parts = chunk.candidates?.[0]?.content?.parts; + expect(parts?.[0]).toEqual( + expect.objectContaining({ thought: true, text: 'thinking...' }), + ); + expect(parts?.[1]).toEqual( + expect.objectContaining({ text: 'visible text' }), + ); + }); + }); }); diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 1edbdd6e..b22eb963 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -31,6 +31,25 @@ interface ExtendedCompletionUsage extends OpenAI.CompletionUsage { cached_tokens?: number; } +interface ExtendedChatCompletionAssistantMessageParam + extends OpenAI.Chat.ChatCompletionAssistantMessageParam { + reasoning_content?: string | null; +} + +type ExtendedChatCompletionMessageParam = + | OpenAI.Chat.ChatCompletionMessageParam + | ExtendedChatCompletionAssistantMessageParam; + +export interface ExtendedCompletionMessage + extends OpenAI.Chat.ChatCompletionMessage { + reasoning_content?: string | null; +} + +export interface ExtendedCompletionChunkDelta + extends OpenAI.Chat.ChatCompletionChunk.Choice.Delta { + reasoning_content?: string | null; +} + /** * Tool call accumulator for streaming responses */ @@ -44,7 +63,8 @@ export interface ToolCallAccumulator { * Parsed parts from Gemini content, categorized by type */ interface ParsedParts { - textParts: string[]; + thoughtParts: string[]; + contentParts: string[]; functionCalls: FunctionCall[]; functionResponses: FunctionResponse[]; mediaParts: Array<{ @@ -251,7 +271,7 @@ export class OpenAIContentConverter { */ private processContents( contents: ContentListUnion, - messages: OpenAI.Chat.ChatCompletionMessageParam[], + messages: ExtendedChatCompletionMessageParam[], ): void { if (Array.isArray(contents)) { for (const content of contents) { @@ -267,7 +287,7 @@ export class OpenAIContentConverter { */ private processContent( content: ContentUnion | PartUnion, - messages: OpenAI.Chat.ChatCompletionMessageParam[], + messages: ExtendedChatCompletionMessageParam[], ): void { if (typeof content === 'string') { messages.push({ role: 'user' as const, content }); @@ -301,11 +321,19 @@ export class OpenAIContentConverter { }, })); - messages.push({ + const assistantMessage: ExtendedChatCompletionAssistantMessageParam = { role: 'assistant' as const, - content: parsedParts.textParts.join('') || null, + content: parsedParts.contentParts.join('') || null, tool_calls: toolCalls, - }); + }; + + // Only include reasoning_content if it has actual content + const reasoningContent = parsedParts.thoughtParts.join(''); + if (reasoningContent) { + assistantMessage.reasoning_content = reasoningContent; + } + + messages.push(assistantMessage); return; } @@ -322,7 +350,8 @@ export class OpenAIContentConverter { * Parse Gemini parts into categorized components */ private parseParts(parts: Part[]): ParsedParts { - const textParts: string[] = []; + const thoughtParts: string[] = []; + const contentParts: string[] = []; const functionCalls: FunctionCall[] = []; const functionResponses: FunctionResponse[] = []; const mediaParts: Array<{ @@ -334,9 +363,20 @@ export class OpenAIContentConverter { for (const part of parts) { if (typeof part === 'string') { - textParts.push(part); - } else if ('text' in part && part.text) { - textParts.push(part.text); + contentParts.push(part); + } else if ( + 'text' in part && + part.text && + !('thought' in part && part.thought) + ) { + contentParts.push(part.text); + } else if ( + 'text' in part && + part.text && + 'thought' in part && + part.thought + ) { + thoughtParts.push(part.text); } else if ('functionCall' in part && part.functionCall) { functionCalls.push(part.functionCall); } else if ('functionResponse' in part && part.functionResponse) { @@ -361,7 +401,13 @@ export class OpenAIContentConverter { } } - return { textParts, functionCalls, functionResponses, mediaParts }; + return { + thoughtParts, + contentParts, + functionCalls, + functionResponses, + mediaParts, + }; } private extractFunctionResponseContent(response: unknown): string { @@ -408,14 +454,29 @@ export class OpenAIContentConverter { */ private createMultimodalMessage( role: 'user' | 'assistant', - parsedParts: Pick, - ): OpenAI.Chat.ChatCompletionMessageParam | null { - const { textParts, mediaParts } = parsedParts; - const content = textParts.map((text) => ({ type: 'text' as const, text })); + parsedParts: Pick< + ParsedParts, + 'contentParts' | 'mediaParts' | 'thoughtParts' + >, + ): ExtendedChatCompletionMessageParam | null { + const { contentParts, mediaParts, thoughtParts } = parsedParts; + const reasoningContent = thoughtParts.join(''); + const content = contentParts.map((text) => ({ + type: 'text' as const, + text, + })); // If no media parts, return simple text message if (mediaParts.length === 0) { - return content.length > 0 ? { role, content } : null; + if (content.length === 0) return null; + const message: ExtendedChatCompletionMessageParam = { role, content }; + // Only include reasoning_content if it has actual content + if (reasoningContent) { + ( + message as ExtendedChatCompletionAssistantMessageParam + ).reasoning_content = reasoningContent; + } + return message; } // For assistant messages with media, convert to text only @@ -536,6 +597,13 @@ export class OpenAIContentConverter { const parts: Part[] = []; + // Handle reasoning content (thoughts) + const reasoningText = (choice.message as ExtendedCompletionMessage) + .reasoning_content; + if (reasoningText) { + parts.push({ text: reasoningText, thought: true }); + } + // Handle text content if (choice.message.content) { parts.push({ text: choice.message.content }); @@ -632,6 +700,12 @@ export class OpenAIContentConverter { if (choice) { const parts: Part[] = []; + const reasoningText = (choice.delta as ExtendedCompletionChunkDelta) + .reasoning_content; + if (reasoningText) { + parts.push({ text: reasoningText, thought: true }); + } + // Handle text content if (choice.delta?.content) { if (typeof choice.delta.content === 'string') { @@ -721,6 +795,8 @@ export class OpenAIContentConverter { const promptTokens = usage.prompt_tokens || 0; const completionTokens = usage.completion_tokens || 0; const totalTokens = usage.total_tokens || 0; + const thinkingTokens = + usage.completion_tokens_details?.reasoning_tokens || 0; // Support both formats: prompt_tokens_details.cached_tokens (OpenAI standard) // and cached_tokens (some models return it at top level) const extendedUsage = usage as ExtendedCompletionUsage; @@ -743,6 +819,7 @@ export class OpenAIContentConverter { response.usageMetadata = { promptTokenCount: finalPromptTokens, candidatesTokenCount: finalCompletionTokens, + thoughtsTokenCount: thinkingTokens, totalTokenCount: totalTokens, cachedContentTokenCount: cachedTokens, }; diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts index 717a5b7d..6f0f8d09 100644 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts +++ b/packages/core/src/core/openaiContentGenerator/telemetryService.test.ts @@ -561,11 +561,14 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: 'Hello' }, + delta: { + content: 'Hello', + reasoning_content: 'thinking ', + }, finish_reason: null, }, ], - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, { id: 'test-id', object: 'chat.completion.chunk', @@ -574,7 +577,10 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: ' world' }, + delta: { + content: ' world', + reasoning_content: 'more', + }, finish_reason: 'stop', }, ], @@ -583,7 +589,7 @@ describe('DefaultTelemetryService', () => { completion_tokens: 5, total_tokens: 15, }, - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, ]; await telemetryService.logStreamingSuccess( @@ -603,11 +609,11 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - message: { + message: expect.objectContaining({ role: 'assistant', content: 'Hello world', - refusal: null, - }, + reasoning_content: 'thinking more', + }), finish_reason: 'stop', logprobs: null, }, @@ -722,11 +728,14 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: 'Hello' }, + delta: { + content: 'Hello', + reasoning_content: 'thinking ', + }, finish_reason: null, }, ], - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, { id: 'test-id', object: 'chat.completion.chunk', @@ -735,7 +744,10 @@ describe('DefaultTelemetryService', () => { choices: [ { index: 0, - delta: { content: ' world!' }, + delta: { + content: ' world!', + reasoning_content: 'more', + }, finish_reason: 'stop', }, ], @@ -744,7 +756,7 @@ describe('DefaultTelemetryService', () => { completion_tokens: 5, total_tokens: 15, }, - } as OpenAI.Chat.ChatCompletionChunk, + } as unknown as OpenAI.Chat.ChatCompletionChunk, ]; await telemetryService.logStreamingSuccess( @@ -757,27 +769,14 @@ describe('DefaultTelemetryService', () => { expect(openaiLogger.logInteraction).toHaveBeenCalledWith( mockOpenAIRequest, expect.objectContaining({ - id: 'test-id', - object: 'chat.completion', - created: 1234567890, - model: 'gpt-4', choices: [ - { - index: 0, - message: { - role: 'assistant', + expect.objectContaining({ + message: expect.objectContaining({ content: 'Hello world!', - refusal: null, - }, - finish_reason: 'stop', - logprobs: null, - }, + reasoning_content: 'thinking more', + }), + }), ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, }), ); }); diff --git a/packages/core/src/core/openaiContentGenerator/telemetryService.ts b/packages/core/src/core/openaiContentGenerator/telemetryService.ts index 9fa47263..66a96ad0 100644 --- a/packages/core/src/core/openaiContentGenerator/telemetryService.ts +++ b/packages/core/src/core/openaiContentGenerator/telemetryService.ts @@ -10,6 +10,7 @@ import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js'; import { OpenAILogger } from '../../utils/openaiLogger.js'; import type { GenerateContentResponse } from '@google/genai'; import type OpenAI from 'openai'; +import type { ExtendedCompletionChunkDelta } from './converter.js'; export interface RequestContext { userPromptId: string; @@ -172,6 +173,7 @@ export class DefaultTelemetryService implements TelemetryService { | 'content_filter' | 'function_call' | null = null; + let combinedReasoning = ''; let usage: | { prompt_tokens: number; @@ -183,6 +185,12 @@ export class DefaultTelemetryService implements TelemetryService { for (const chunk of chunks) { const choice = chunk.choices?.[0]; if (choice) { + // Combine reasoning content + const reasoningContent = (choice.delta as ExtendedCompletionChunkDelta) + ?.reasoning_content; + if (reasoningContent) { + combinedReasoning += reasoningContent; + } // Combine text content if (choice.delta?.content) { combinedContent += choice.delta.content; @@ -230,6 +238,11 @@ export class DefaultTelemetryService implements TelemetryService { content: combinedContent || null, refusal: null, }; + if (combinedReasoning) { + // Attach reasoning content if any thought tokens were streamed + (message as { reasoning_content?: string }).reasoning_content = + combinedReasoning; + } // Add tool calls if any if (toolCalls.length > 0) { diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 093f542d..a79dad03 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -120,6 +120,97 @@ describe('Turn', () => { expect(turn.getDebugResponses().length).toBe(2); }); + it('should emit Thought events when a thought part is present', async () => { + const mockResponseStream = (async function* () { + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + role: 'model', + parts: [ + { thought: true, text: 'reasoning...' }, + { text: 'final answer' }, + ], + }, + }, + ], + } as GenerateContentResponse, + }; + })(); + mockSendMessageStream.mockResolvedValue(mockResponseStream); + + const events = []; + const reqParts: Part[] = [{ text: 'Hi' }]; + for await (const event of turn.run( + 'test-model', + reqParts, + new AbortController().signal, + )) { + events.push(event); + } + + expect(events).toEqual([ + { + type: GeminiEventType.Thought, + value: { subject: '', description: 'reasoning...' }, + }, + ]); + }); + + it('should emit thought descriptions per incoming chunk', async () => { + const mockResponseStream = (async function* () { + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + role: 'model', + parts: [{ thought: true, text: 'part1' }], + }, + }, + ], + } as GenerateContentResponse, + }; + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { + role: 'model', + parts: [{ thought: true, text: 'part2' }], + }, + }, + ], + } as GenerateContentResponse, + }; + })(); + mockSendMessageStream.mockResolvedValue(mockResponseStream); + + const events = []; + for await (const event of turn.run( + 'test-model', + [{ text: 'Hi' }], + new AbortController().signal, + )) { + events.push(event); + } + + expect(events).toEqual([ + { + type: GeminiEventType.Thought, + value: { subject: '', description: 'part1' }, + }, + { + type: GeminiEventType.Thought, + value: { subject: '', description: 'part2' }, + }, + ]); + }); + it('should yield tool_call_request events for function calls', async () => { const mockResponseStream = (async function* () { yield { diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 5e8f3bf3..edd9b24e 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -27,7 +27,7 @@ import { toFriendlyError, } from '../utils/errors.js'; import type { GeminiChat } from './geminiChat.js'; -import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js'; +import { getThoughtText, type ThoughtSummary } from '../utils/thoughtUtils.js'; // Define a structure for tools passed to the server export interface ServerTool { @@ -266,12 +266,11 @@ export class Turn { this.currentResponseId = resp.responseId; } - const thoughtPart = resp.candidates?.[0]?.content?.parts?.[0]; - if (thoughtPart?.thought) { - const thought = parseThought(thoughtPart.text ?? ''); + const thoughtPart = getThoughtText(resp); + if (thoughtPart) { yield { type: GeminiEventType.Thought, - value: thought, + value: { subject: '', description: thoughtPart }, }; continue; } diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index efeaa634..ca70dd56 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -542,6 +542,39 @@ export class SessionService { } } +/** + * Options for building API history from conversation. + */ +export interface BuildApiHistoryOptions { + /** + * Whether to strip thought parts from the history. + * Thought parts are content parts that have `thought: true`. + * @default true + */ + stripThoughtsFromHistory?: boolean; +} + +/** + * Strips thought parts from a Content object. + * Thought parts are identified by having `thought: true`. + * Returns null if the content only contained thought parts. + */ +function stripThoughtsFromContent(content: Content): Content | null { + if (!content.parts) return content; + + const filteredParts = content.parts.filter((part) => !(part as Part).thought); + + // If all parts were thoughts, remove the entire content + if (filteredParts.length === 0) { + return null; + } + + return { + ...content, + parts: filteredParts, + }; +} + /** * Builds the model-facing chat history (Content[]) from a reconstructed * conversation. This keeps UI history intact while applying chat compression @@ -555,7 +588,9 @@ export class SessionService { */ export function buildApiHistoryFromConversation( conversation: ConversationRecord, + options: BuildApiHistoryOptions = {}, ): Content[] { + const { stripThoughtsFromHistory = true } = options; const { messages } = conversation; let lastCompressionIndex = -1; @@ -585,14 +620,26 @@ export function buildApiHistoryFromConversation( } } + if (stripThoughtsFromHistory) { + return baseHistory + .map(stripThoughtsFromContent) + .filter((content): content is Content => content !== null); + } return baseHistory; } // Fallback: return linear messages as Content[] - return messages + const result = messages .map((record) => record.message) .filter((message): message is Content => message !== undefined) .map((message) => structuredClone(message)); + + if (stripThoughtsFromHistory) { + return result + .map(stripThoughtsFromContent) + .filter((content): content is Content => content !== null); + } + return result; } /** diff --git a/packages/core/src/utils/thoughtUtils.ts b/packages/core/src/utils/thoughtUtils.ts index c97a39a3..21b95532 100644 --- a/packages/core/src/utils/thoughtUtils.ts +++ b/packages/core/src/utils/thoughtUtils.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { GenerateContentResponse } from '@google/genai'; + export type ThoughtSummary = { subject: string; description: string; @@ -52,3 +54,23 @@ export function parseThought(rawText: string): ThoughtSummary { return { subject, description }; } + +export function getThoughtText( + response: GenerateContentResponse, +): string | null { + if (response.candidates && response.candidates.length > 0) { + const candidate = response.candidates[0]; + + if ( + candidate.content && + candidate.content.parts && + candidate.content.parts.length > 0 + ) { + return candidate.content.parts + .filter((part) => part.thought) + .map((part) => part.text ?? '') + .join(''); + } + } + return null; +} From c218048551e6acc61893b56f2ab0939eebfb20dc Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 5 Dec 2025 18:46:51 +0800 Subject: [PATCH 31/38] fix: prevent sending control request when query is closed --- .../sdk-typescript/permission-control.test.ts | 13 ++++++++- packages/sdk-typescript/README.md | 2 +- packages/sdk-typescript/src/query/Query.ts | 29 +++++-------------- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index 31c7768a..adeb6792 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -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', () => { diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index f5b8fc8e..38f5a375 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -13,7 +13,7 @@ npm install @qwen-code/sdk-typescript ## 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. diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 81edc48f..88a0e7f1 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -620,6 +620,10 @@ export class Query implements AsyncIterable { subtype: string, data: Record = {}, ): Promise | null> { + if (this.closed) { + return Promise.reject(new Error('Query is closed')); + } + const requestId = randomUUID(); const request: CLIControlRequest = { @@ -688,12 +692,13 @@ export class Query implements AsyncIterable { 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 closed')); + pending.reject(new Error('Query is closed')); } this.pendingMcpResponses.clear(); @@ -719,7 +724,7 @@ export class Query implements AsyncIterable { } } this.sdkMcpTransports.clear(); - logger.info('Query closed'); + logger.info('Query is closed'); } private async *readSdkMessages(): AsyncGenerator { @@ -821,28 +826,16 @@ export class Query implements AsyncIterable { } async interrupt(): Promise { - if (this.closed) { - throw new Error('Query is closed'); - } - await this.sendControlRequest(ControlRequestType.INTERRUPT); } async setPermissionMode(mode: string): Promise { - if (this.closed) { - throw new Error('Query is closed'); - } - await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, { mode, }); } async setModel(model: string): Promise { - if (this.closed) { - throw new Error('Query is closed'); - } - await this.sendControlRequest(ControlRequestType.SET_MODEL, { model }); } @@ -853,10 +846,6 @@ export class Query implements AsyncIterable { * @throws Error if query is closed */ async supportedCommands(): Promise | null> { - if (this.closed) { - throw new Error('Query is closed'); - } - return this.sendControlRequest(ControlRequestType.SUPPORTED_COMMANDS); } @@ -867,10 +856,6 @@ export class Query implements AsyncIterable { * @throws Error if query is closed */ async mcpServerStatus(): Promise | null> { - if (this.closed) { - throw new Error('Query is closed'); - } - return this.sendControlRequest(ControlRequestType.MCP_SERVER_STATUS); } From 2949b33a4e000e6b8f1c250830787a88c410828a Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 5 Dec 2025 21:05:36 +0800 Subject: [PATCH 32/38] chore: enhance integration testing for SDK and CLI --- .github/workflows/release-sdk.yml | 18 ++++++++++++++++++ package.json | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index d0b558f7..18d4f13f 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -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]" diff --git a/package.json b/package.json index a8b6857f..7fd726c6 100644 --- a/package.json +++ b/package.json @@ -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 --dir sdk-typescript", + "test:integration:sdk:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests --dir 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'", From 0e9255b122f3bf6ab8093785da8f36c98d8ef7a5 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 5 Dec 2025 21:27:12 +0800 Subject: [PATCH 33/38] fix: integration test scripts --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7fd726c6..563de8f7 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "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 --dir sdk-typescript", - "test:integration:sdk:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests --dir sdk-typescript", + "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", From 96d458fa8c78766809042a39a71b113a872f77f8 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 5 Dec 2025 21:47:26 +0800 Subject: [PATCH 34/38] chore: rename `@qwen-code/sdk-typescript` to `@qwen-code/sdk` --- .github/workflows/release-sdk.yml | 2 +- .../abort-and-lifecycle.test.ts | 2 +- .../configuration-options.test.ts | 2 +- .../sdk-typescript/mcp-server.test.ts | 2 +- .../sdk-typescript/multi-turn.test.ts | 2 +- .../sdk-typescript/permission-control.test.ts | 2 +- .../sdk-typescript/sdk-mcp-server.test.ts | 2 +- .../sdk-typescript/single-turn.test.ts | 2 +- .../sdk-typescript/subagents.test.ts | 2 +- .../sdk-typescript/system-control.test.ts | 2 +- .../sdk-typescript/test-helper.ts | 4 ++-- .../sdk-typescript/tool-control.test.ts | 6 +----- integration-tests/tsconfig.json | 4 +--- integration-tests/vitest.config.ts | 2 +- package-lock.json | 4 ++-- packages/sdk-typescript/README.md | 20 +++++++++---------- packages/sdk-typescript/package.json | 2 +- .../scripts/get-release-version.js | 2 +- .../src/mcp/createSdkMcpServer.ts | 2 +- packages/sdk-typescript/src/mcp/tool.ts | 2 +- 20 files changed, 31 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 18d4f13f..69192520 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -202,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' || '' }} diff --git a/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts index b0b4c3fd..93005d4b 100644 --- a/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts +++ b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts @@ -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(); diff --git a/integration-tests/sdk-typescript/configuration-options.test.ts b/integration-tests/sdk-typescript/configuration-options.test.ts index 9b958a50..bc59cd79 100644 --- a/integration-tests/sdk-typescript/configuration-options.test.ts +++ b/integration-tests/sdk-typescript/configuration-options.test.ts @@ -17,7 +17,7 @@ import { isSDKAssistantMessage, isSDKSystemMessage, type SDKMessage, -} from '@qwen-code/sdk-typescript'; +} from '@qwen-code/sdk'; import { SDKTestHelper, extractText, diff --git a/integration-tests/sdk-typescript/mcp-server.test.ts b/integration-tests/sdk-typescript/mcp-server.test.ts index 110c1924..9b3f2193 100644 --- a/integration-tests/sdk-typescript/mcp-server.test.ts +++ b/integration-tests/sdk-typescript/mcp-server.test.ts @@ -19,7 +19,7 @@ import { type SDKMessage, type ToolUseBlock, type SDKSystemMessage, -} from '@qwen-code/sdk-typescript'; +} from '@qwen-code/sdk'; import { SDKTestHelper, createMCPServer, diff --git a/integration-tests/sdk-typescript/multi-turn.test.ts b/integration-tests/sdk-typescript/multi-turn.test.ts index 17b6f675..c1b96cc7 100644 --- a/integration-tests/sdk-typescript/multi-turn.test.ts +++ b/integration-tests/sdk-typescript/multi-turn.test.ts @@ -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(); diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index adeb6792..fd096c49 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -22,7 +22,7 @@ import { type SDKUserMessage, type ToolUseBlock, type ContentBlock, -} from '@qwen-code/sdk-typescript'; +} from '@qwen-code/sdk'; import { SDKTestHelper, createSharedTestOptions, diff --git a/integration-tests/sdk-typescript/sdk-mcp-server.test.ts b/integration-tests/sdk-typescript/sdk-mcp-server.test.ts index b389d46a..354c6539 100644 --- a/integration-tests/sdk-typescript/sdk-mcp-server.test.ts +++ b/integration-tests/sdk-typescript/sdk-mcp-server.test.ts @@ -22,7 +22,7 @@ import { isSDKSystemMessage, type SDKMessage, type SDKSystemMessage, -} from '@qwen-code/sdk-typescript'; +} from '@qwen-code/sdk'; import { SDKTestHelper, extractText, diff --git a/integration-tests/sdk-typescript/single-turn.test.ts b/integration-tests/sdk-typescript/single-turn.test.ts index 61bbf7e2..3608e619 100644 --- a/integration-tests/sdk-typescript/single-turn.test.ts +++ b/integration-tests/sdk-typescript/single-turn.test.ts @@ -13,7 +13,7 @@ import { type SDKMessage, type SDKSystemMessage, type SDKAssistantMessage, -} from '@qwen-code/sdk-typescript'; +} from '@qwen-code/sdk'; import { SDKTestHelper, extractText, diff --git a/integration-tests/sdk-typescript/subagents.test.ts b/integration-tests/sdk-typescript/subagents.test.ts index 86516053..c327c96e 100644 --- a/integration-tests/sdk-typescript/subagents.test.ts +++ b/integration-tests/sdk-typescript/subagents.test.ts @@ -17,7 +17,7 @@ import { type SubagentConfig, type ContentBlock, type ToolUseBlock, -} from '@qwen-code/sdk-typescript'; +} from '@qwen-code/sdk'; import { SDKTestHelper, extractText, diff --git a/integration-tests/sdk-typescript/system-control.test.ts b/integration-tests/sdk-typescript/system-control.test.ts index 069eccd9..0b0a74d3 100644 --- a/integration-tests/sdk-typescript/system-control.test.ts +++ b/integration-tests/sdk-typescript/system-control.test.ts @@ -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(); diff --git a/integration-tests/sdk-typescript/test-helper.ts b/integration-tests/sdk-typescript/test-helper.ts index cd95051f..f3005655 100644 --- a/integration-tests/sdk-typescript/test-helper.ts +++ b/integration-tests/sdk-typescript/test-helper.ts @@ -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 diff --git a/integration-tests/sdk-typescript/tool-control.test.ts b/integration-tests/sdk-typescript/tool-control.test.ts index 036d779e..b2b955a6 100644 --- a/integration-tests/sdk-typescript/tool-control.test.ts +++ b/integration-tests/sdk-typescript/tool-control.test.ts @@ -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, diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json index 7f2a010d..0cd24f82 100644 --- a/integration-tests/tsconfig.json +++ b/integration-tests/tsconfig.json @@ -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"], diff --git a/integration-tests/vitest.config.ts b/integration-tests/vitest.config.ts index a452583c..9be72f50 100644 --- a/integration-tests/vitest.config.ts +++ b/integration-tests/vitest.config.ts @@ -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', ), diff --git a/package-lock.json b/package-lock.json index 53fe9d46..f3bb0cad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2793,7 +2793,7 @@ "resolved": "packages/test-utils", "link": true }, - "node_modules/@qwen-code/sdk-typescript": { + "node_modules/@qwen-code/sdk": { "resolved": "packages/sdk-typescript", "link": true }, @@ -16676,7 +16676,7 @@ } }, "packages/sdk-typescript": { - "name": "@qwen-code/sdk-typescript", + "name": "@qwen-code/sdk", "version": "0.1.0", "license": "Apache-2.0", "dependencies": { diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index 38f5a375..de161396 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -1,4 +1,4 @@ -# @qwen-code/sdk-typescript +# @qwen-code/sdk A minimum experimental TypeScript SDK for programmatic access to Qwen Code. @@ -7,7 +7,7 @@ Feel free to submit a feature request/issue/PR. ## Installation ```bash -npm install @qwen-code/sdk-typescript +npm install @qwen-code/sdk ``` ## Requirements @@ -20,7 +20,7 @@ npm install @qwen-code/sdk-typescript ## Quick Start ```typescript -import { query } from '@qwen-code/sdk-typescript'; +import { query } from '@qwen-code/sdk'; // Single-turn query const result = query({ @@ -107,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)) { @@ -167,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 { yield { @@ -201,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 @@ -230,7 +230,7 @@ const result = query({ ### 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', @@ -290,7 +290,7 @@ Returns a `McpSdkServerConfigWithInstance` object that can be passed directly to ```typescript import { z } from 'zod'; -import { query, tool, createSdkMcpServer } from '@qwen-code/sdk-typescript'; +import { query, tool, createSdkMcpServer } from '@qwen-code/sdk'; // Define a tool with Zod schema const calculatorTool = tool( @@ -327,7 +327,7 @@ for await (const message of result) { ### Abort a Query ```typescript -import { query, isAbortError } from '@qwen-code/sdk-typescript'; +import { query, isAbortError } from '@qwen-code/sdk'; const abortController = new AbortController(); @@ -359,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 diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 0f234603..b0f35709 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,5 +1,5 @@ { - "name": "@qwen-code/sdk-typescript", + "name": "@qwen-code/sdk", "version": "0.1.0", "description": "TypeScript SDK for programmatic access to qwen-code CLI", "main": "./dist/index.cjs", diff --git a/packages/sdk-typescript/scripts/get-release-version.js b/packages/sdk-typescript/scripts/get-release-version.js index 349bfd07..c6b1f665 100644 --- a/packages/sdk-typescript/scripts/get-release-version.js +++ b/packages/sdk-typescript/scripts/get-release-version.js @@ -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) { diff --git a/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts index c202e612..cf2482d6 100644 --- a/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts +++ b/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts @@ -37,7 +37,7 @@ export type McpSdkServerConfigWithInstance = { * @example * ```typescript * import { z } from 'zod'; - * import { tool, createSdkMcpServer } from '@qwen-code/sdk-typescript'; + * import { tool, createSdkMcpServer } from '@qwen-code/sdk'; * * const calculatorTool = tool( * 'calculate_sum', diff --git a/packages/sdk-typescript/src/mcp/tool.ts b/packages/sdk-typescript/src/mcp/tool.ts index 9616dfc8..53e00399 100644 --- a/packages/sdk-typescript/src/mcp/tool.ts +++ b/packages/sdk-typescript/src/mcp/tool.ts @@ -32,7 +32,7 @@ export type SdkMcpToolDefinition = { * @example * ```typescript * import { z } from 'zod'; - * import { tool } from '@qwen-code/sdk-typescript'; + * import { tool } from '@qwen-code/sdk'; * * const calculatorTool = tool( * 'calculate_sum', From 22943b888d84a24e3ae9503f7b1db21f5c7a953d Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Fri, 5 Dec 2025 22:11:27 +0800 Subject: [PATCH 35/38] test: clean up integration test by removing unnecessary console logs --- integration-tests/sdk-typescript/sdk-mcp-server.test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/integration-tests/sdk-typescript/sdk-mcp-server.test.ts b/integration-tests/sdk-typescript/sdk-mcp-server.test.ts index 354c6539..1ce8658e 100644 --- a/integration-tests/sdk-typescript/sdk-mcp-server.test.ts +++ b/integration-tests/sdk-typescript/sdk-mcp-server.test.ts @@ -51,12 +51,6 @@ describe('SDK MCP Server Integration (E2E)', () => { 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 - console.log( - z.object({ - a: z.number().describe('First number'), - b: z.number().describe('Second number'), - }), - ); const calculatorTool = tool( 'calculate_sum', 'Calculate the sum of two numbers', @@ -82,7 +76,6 @@ describe('SDK MCP Server Integration (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, cwd: testDir, - stderr: (message) => console.error(message), mcpServers: { 'sdk-calculator': serverConfig, }, @@ -96,7 +89,6 @@ describe('SDK MCP Server Integration (E2E)', () => { try { for await (const message of q) { messages.push(message); - console.log(JSON.stringify(message, null, 2)); if (isSDKAssistantMessage(message)) { const toolUseBlocks = findToolUseBlocks(message, 'calculate_sum'); @@ -172,7 +164,6 @@ describe('SDK MCP Server Integration (E2E)', () => { assistantText += extractText(message.message.content); } } - console.log(JSON.stringify(messages, null, 2)); // Validate tool was called expect(foundToolUse).toBe(true); From bf6abf77523fcfeb6314c486ed1b2acd581f2018 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sat, 6 Dec 2025 12:27:16 +0800 Subject: [PATCH 36/38] fix: update timeout settings and default logging level in SDK --- packages/sdk-typescript/README.md | 14 ++++----- packages/sdk-typescript/src/query/Query.ts | 33 ++++++++++++++------- packages/sdk-typescript/src/types/types.ts | 2 +- packages/sdk-typescript/src/utils/logger.ts | 4 +-- 4 files changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index de161396..bc3ef6aa 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -59,7 +59,7 @@ 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` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | | `mcpServers` | `Record` | - | 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. | @@ -76,12 +76,12 @@ Creates a new query session with the Qwen Code. The SDK enforces the following default timeouts: -| Timeout | Default | Description | -| ---------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `canUseTool` | 30 seconds | 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` | 30 seconds | 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. | +| 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: diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 88a0e7f1..78bb10b9 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -5,9 +5,9 @@ * Implements AsyncIterator protocol for message consumption. */ -const DEFAULT_CAN_USE_TOOL_TIMEOUT = 30_000; +const DEFAULT_CAN_USE_TOOL_TIMEOUT = 60_000; const DEFAULT_MCP_REQUEST_TIMEOUT = 60_000; -const DEFAULT_CONTROL_REQUEST_TIMEOUT = 30_000; +const DEFAULT_CONTROL_REQUEST_TIMEOUT = 60_000; const DEFAULT_STREAM_CLOSE_TIMEOUT = 60_000; import { randomUUID } from 'node:crypto'; @@ -434,8 +434,9 @@ export class Query implements AsyncIterable { try { const canUseToolTimeout = this.options.timeout?.canUseTool ?? DEFAULT_CAN_USE_TOOL_TIMEOUT; + let timeoutId: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_, reject) => { - setTimeout( + timeoutId = setTimeout( () => reject(new Error('Permission callback timeout')), canUseToolTimeout, ); @@ -451,6 +452,10 @@ export class Query implements AsyncIterable { timeoutPromise, ]); + if (timeoutId) { + clearTimeout(timeoutId); + } + if (result.behavior === 'allow') { return { behavior: 'allow', @@ -789,14 +794,20 @@ export class Query implements AsyncIterable { ) { const streamCloseTimeout = this.options.timeout?.streamClose ?? DEFAULT_STREAM_CLOSE_TIMEOUT; - await Promise.race([ - this.firstResultReceivedPromise, - new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, streamCloseTimeout); - }), - ]); + let timeoutId: NodeJS.Timeout | undefined; + + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + logger.info('streamCloseTimeout resolved'); + resolve(); + }, streamCloseTimeout); + }); + + await Promise.race([this.firstResultReceivedPromise, timeoutPromise]); + + if (timeoutId) { + clearTimeout(timeoutId); + } } this.endInput(); diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index a85125ea..24dc0575 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -316,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'; diff --git a/packages/sdk-typescript/src/utils/logger.ts b/packages/sdk-typescript/src/utils/logger.ts index afb7a495..caf57ede 100644 --- a/packages/sdk-typescript/src/utils/logger.ts +++ b/packages/sdk-typescript/src/utils/logger.ts @@ -22,7 +22,7 @@ const LOG_LEVEL_PRIORITY: Record = { 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 { From 86b166bb1da59352ff9c34faead79d6d9b43b023 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sat, 6 Dec 2025 17:53:31 +0800 Subject: [PATCH 37/38] fix: adjust e2e tests via timeout option --- packages/sdk-typescript/test/unit/Query.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts index 2b89ca51..1dd0a992 100644 --- a/packages/sdk-typescript/test/unit/Query.test.ts +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -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' }); From 8b5b8d2b904cd7d880373a37c56baf3f74fd5a57 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sat, 6 Dec 2025 18:41:19 +0800 Subject: [PATCH 38/38] test: skip unstable e2e test --- integration-tests/sdk-typescript/permission-control.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index fd096c49..e8d201e6 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -1195,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'> = [