mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
feat: create draft framework for SDK-support CLI
This commit is contained in:
@@ -8,9 +8,20 @@
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"qwen": "dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./protocol": {
|
||||
"types": "./dist/src/types/protocol.d.ts",
|
||||
"import": "./dist/src/types/protocol.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build_package.js",
|
||||
"start": "node dist/index.js",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
WriteFileTool,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
InputFormat,
|
||||
OutputFormat,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Settings } from './settings.js';
|
||||
@@ -126,12 +127,12 @@ export interface CliArgs {
|
||||
|
||||
function normalizeOutputFormat(
|
||||
format: string | OutputFormat | undefined,
|
||||
): OutputFormat | 'stream-json' | undefined {
|
||||
): OutputFormat | undefined {
|
||||
if (!format) {
|
||||
return undefined;
|
||||
}
|
||||
if (format === 'stream-json') {
|
||||
return 'stream-json';
|
||||
return OutputFormat.STREAM_JSON;
|
||||
}
|
||||
if (format === 'json' || format === OutputFormat.JSON) {
|
||||
return OutputFormat.JSON;
|
||||
@@ -210,8 +211,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
})
|
||||
.option('proxy', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Proxy for Qwen Code, like schema://user:password@host:port',
|
||||
description: 'Proxy for Qwen Code, like schema://user:password@host:port',
|
||||
})
|
||||
.deprecateOption(
|
||||
'proxy',
|
||||
@@ -601,8 +601,8 @@ export async function loadCliConfig(
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
const inputFormat =
|
||||
(argv.inputFormat as 'text' | 'stream-json' | undefined) ?? 'text';
|
||||
const inputFormat: InputFormat =
|
||||
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
|
||||
const argvOutputFormat = normalizeOutputFormat(
|
||||
argv.outputFormat as string | OutputFormat | undefined,
|
||||
);
|
||||
@@ -610,8 +610,9 @@ export async function loadCliConfig(
|
||||
const outputFormat =
|
||||
argvOutputFormat ?? settingsOutputFormat ?? OutputFormat.TEXT;
|
||||
const outputSettingsFormat: OutputFormat =
|
||||
outputFormat === 'stream-json'
|
||||
? settingsOutputFormat && settingsOutputFormat !== 'stream-json'
|
||||
outputFormat === OutputFormat.STREAM_JSON
|
||||
? settingsOutputFormat &&
|
||||
settingsOutputFormat !== OutputFormat.STREAM_JSON
|
||||
? settingsOutputFormat
|
||||
: OutputFormat.TEXT
|
||||
: (outputFormat as OutputFormat);
|
||||
|
||||
@@ -4,59 +4,59 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import { basename } from 'node:path';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import dns from 'node:dns';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { start_sandbox } from './utils/sandbox.js';
|
||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
||||
import { themeManager } from './ui/themes/theme-manager.js';
|
||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { runStreamJsonSession } from './streamJson/session.js';
|
||||
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||
import {
|
||||
cleanupCheckpoints,
|
||||
registerCleanup,
|
||||
runExitCleanup,
|
||||
} from './utils/cleanup.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
getOauthClient,
|
||||
logUserPrompt,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { render } from 'ink';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import dns from 'node:dns';
|
||||
import os from 'node:os';
|
||||
import { basename } from 'node:path';
|
||||
import v8 from 'node:v8';
|
||||
import React from 'react';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
} from './core/initializer.js';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { runStreamJsonSession } from './streamJson/session.js';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { themeManager } from './ui/themes/theme-manager.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import {
|
||||
relaunchOnExitCode,
|
||||
cleanupCheckpoints,
|
||||
registerCleanup,
|
||||
runExitCleanup,
|
||||
} from './utils/cleanup.js';
|
||||
import { AppEvent, appEvents } from './utils/events.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import {
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
} from './utils/relaunch.js';
|
||||
import { start_sandbox } from './utils/sandbox.js';
|
||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
@@ -107,9 +107,9 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||
|
||||
export function setupUnhandledRejectionHandler() {
|
||||
let unhandledRejectionOccurred = false;
|
||||
|
||||
732
packages/cli/src/nonInteractiveStreamJson.ts
Normal file
732
packages/cli/src/nonInteractiveStreamJson.ts
Normal file
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* 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, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
|
||||
import { GeminiEventType, executeToolCall } from '@qwen-code/qwen-code-core';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||
import { StreamJson, extractUserMessageText } from './services/StreamJson.js';
|
||||
import { MessageRouter, type RoutedMessage } from './services/MessageRouter.js';
|
||||
import { ControlContext } from './services/control/ControlContext.js';
|
||||
import { ControlDispatcher } from './services/control/ControlDispatcher.js';
|
||||
import type {
|
||||
CLIMessage,
|
||||
CLIUserMessage,
|
||||
CLIResultMessage,
|
||||
ToolResultBlock,
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlCancelRequest,
|
||||
} from './types/protocol.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];
|
||||
|
||||
/**
|
||||
* Session Manager
|
||||
*
|
||||
* Manages the session lifecycle and message processing state machine.
|
||||
*/
|
||||
class SessionManager {
|
||||
private state: SessionState = SESSION_STATE.INITIALIZING;
|
||||
private userMessageQueue: CLIUserMessage[] = [];
|
||||
private abortController: AbortController;
|
||||
private config: Config;
|
||||
private sessionId: string;
|
||||
private promptIdCounter: number = 0;
|
||||
private streamJson: StreamJson;
|
||||
private router: MessageRouter;
|
||||
private controlContext: ControlContext;
|
||||
private dispatcher: ControlDispatcher;
|
||||
private consolePatcher: ConsolePatcher;
|
||||
private debugMode: boolean;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
this.sessionId = config.getSessionId();
|
||||
this.debugMode = config.getDebugMode();
|
||||
this.abortController = new AbortController();
|
||||
|
||||
this.consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
debugMode: this.debugMode,
|
||||
});
|
||||
|
||||
this.streamJson = new StreamJson({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
this.router = new MessageRouter(config);
|
||||
|
||||
// Create control context
|
||||
this.controlContext = new ControlContext({
|
||||
config,
|
||||
streamJson: this.streamJson,
|
||||
sessionId: this.sessionId,
|
||||
abortSignal: this.abortController.signal,
|
||||
permissionMode: this.config.getApprovalMode(),
|
||||
onInterrupt: () => this.handleInterrupt(),
|
||||
});
|
||||
|
||||
// Create dispatcher with context (creates controllers internally)
|
||||
this.dispatcher = new ControlDispatcher(this.controlContext);
|
||||
|
||||
// Setup signal handlers for graceful shutdown
|
||||
this.setupSignalHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next prompt ID
|
||||
*/
|
||||
private getNextPromptId(): string {
|
||||
this.promptIdCounter++;
|
||||
return `${this.sessionId}########${this.promptIdCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point - run the session
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
this.consolePatcher.patch();
|
||||
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Starting session', this.sessionId);
|
||||
}
|
||||
|
||||
// Main message processing loop
|
||||
for await (const message of this.streamJson.readMessages()) {
|
||||
if (this.abortController.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
await this.processMessage(message);
|
||||
|
||||
// Check if we should exit
|
||||
if (this.state === SESSION_STATE.SHUTTING_DOWN) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream closed, shutdown
|
||||
await this.shutdown();
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Error:', error);
|
||||
}
|
||||
await this.shutdown();
|
||||
throw error;
|
||||
} finally {
|
||||
this.consolePatcher.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single message from the stream
|
||||
*/
|
||||
private async processMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<void> {
|
||||
const routed = this.router.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<void> {
|
||||
if (routed.type === 'control_request') {
|
||||
const request = routed.message as CLIControlRequest;
|
||||
if (request.request.subtype === 'initialize') {
|
||||
await this.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<void> {
|
||||
if (routed.type === 'control_request') {
|
||||
const request = routed.message as CLIControlRequest;
|
||||
await this.dispatcher.dispatch(request);
|
||||
// Stay in idle state
|
||||
} else if (routed.type === 'control_response') {
|
||||
const response = routed.message as CLIControlResponse;
|
||||
this.dispatcher.handleControlResponse(response);
|
||||
// Stay in idle state
|
||||
} else if (routed.type === 'control_cancel') {
|
||||
// Handle cancellation
|
||||
const cancelRequest = routed.message as ControlCancelRequest;
|
||||
this.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 (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring message type in idle state:',
|
||||
routed.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages in processing state
|
||||
*/
|
||||
private async handleProcessingState(routed: RoutedMessage): Promise<void> {
|
||||
if (routed.type === 'control_request') {
|
||||
const request = routed.message as CLIControlRequest;
|
||||
await this.dispatcher.dispatch(request);
|
||||
// Continue processing
|
||||
} else if (routed.type === 'control_response') {
|
||||
const response = routed.message as CLIControlResponse;
|
||||
this.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<void> {
|
||||
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.sendErrorResult(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Return to idle after processing queue
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single user message
|
||||
*/
|
||||
private async processUserMessage(userMessage: CLIUserMessage): Promise<void> {
|
||||
// Extract text from user message
|
||||
const texts = extractUserMessageText(userMessage);
|
||||
if (texts.length === 0) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] No text content in user message');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const input = texts.join('\n');
|
||||
|
||||
// Handle @command preprocessing
|
||||
const { processedQuery, shouldProceed } = await handleAtCommand({
|
||||
query: input,
|
||||
config: this.config,
|
||||
addItem: (_item, _timestamp) => 0,
|
||||
onDebugMessage: () => {},
|
||||
messageId: Date.now(),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
if (!shouldProceed || !processedQuery) {
|
||||
this.sendErrorResult('Error processing input');
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute query via Gemini client
|
||||
await this.executeQuery(processedQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute query through Gemini client
|
||||
*/
|
||||
private async executeQuery(query: PartListUnion): Promise<void> {
|
||||
const geminiClient = this.config.getGeminiClient();
|
||||
const promptId = this.getNextPromptId();
|
||||
let accumulatedContent = '';
|
||||
let turnCount = 0;
|
||||
const maxTurns = this.config.getMaxSessionTurns();
|
||||
|
||||
try {
|
||||
let currentMessages: PartListUnion = query;
|
||||
|
||||
while (true) {
|
||||
turnCount++;
|
||||
|
||||
if (maxTurns >= 0 && turnCount > maxTurns) {
|
||||
this.sendErrorResult(`Reached max turns: ${turnCount}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
|
||||
// Create assistant message builder for this turn
|
||||
const assistantBuilder = this.streamJson.createAssistantBuilder(
|
||||
this.sessionId,
|
||||
null, // parent_tool_use_id
|
||||
this.config.getModel(),
|
||||
false, // includePartialMessages - TODO: make this configurable
|
||||
);
|
||||
|
||||
// Stream response from Gemini
|
||||
const responseStream = geminiClient.sendMessageStream(
|
||||
currentMessages,
|
||||
this.abortController.signal,
|
||||
promptId,
|
||||
);
|
||||
|
||||
for await (const event of responseStream) {
|
||||
if (this.abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case GeminiEventType.Content:
|
||||
// Process content through builder
|
||||
assistantBuilder.processEvent(event);
|
||||
accumulatedContent += event.value;
|
||||
break;
|
||||
|
||||
case GeminiEventType.Thought:
|
||||
// Process thinking through builder
|
||||
assistantBuilder.processEvent(event);
|
||||
break;
|
||||
|
||||
case GeminiEventType.ToolCallRequest:
|
||||
// Process tool call through builder
|
||||
assistantBuilder.processEvent(event);
|
||||
toolCallRequests.push(event.value);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Finished: {
|
||||
// Finalize and send assistant message
|
||||
assistantBuilder.processEvent(event);
|
||||
const assistantMessage = assistantBuilder.finalize();
|
||||
this.streamJson.send(assistantMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
case GeminiEventType.Error:
|
||||
this.sendErrorResult(event.value.error.message);
|
||||
return;
|
||||
|
||||
case GeminiEventType.MaxSessionTurns:
|
||||
this.sendErrorResult('Max session turns exceeded');
|
||||
return;
|
||||
|
||||
case GeminiEventType.SessionTokenLimitExceeded:
|
||||
this.sendErrorResult(event.value.message);
|
||||
return;
|
||||
|
||||
default:
|
||||
// Ignore other event types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls - execute tools and continue conversation
|
||||
if (toolCallRequests.length > 0) {
|
||||
// Execute tools and prepare response
|
||||
const toolResponseParts: Part[] = [];
|
||||
for (const requestInfo of toolCallRequests) {
|
||||
// Check permissions before executing tool
|
||||
const permissionResult =
|
||||
await this.checkToolPermission(requestInfo);
|
||||
if (!permissionResult.allowed) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
`[SessionManager] Tool execution denied: ${requestInfo.name} - ${permissionResult.message}`,
|
||||
);
|
||||
}
|
||||
// Skip this tool and continue with others
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use updated args if provided by permission check
|
||||
const finalRequestInfo = permissionResult.updatedArgs
|
||||
? { ...requestInfo, args: permissionResult.updatedArgs }
|
||||
: requestInfo;
|
||||
|
||||
// Execute tool
|
||||
const toolResponse = await executeToolCall(
|
||||
this.config,
|
||||
finalRequestInfo,
|
||||
this.abortController.signal,
|
||||
{
|
||||
onToolCallsUpdate:
|
||||
this.dispatcher.permissionController.getToolCallUpdateCallback(),
|
||||
},
|
||||
);
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
toolResponseParts.push(...toolResponse.responseParts);
|
||||
}
|
||||
|
||||
if (toolResponse.error && this.debugMode) {
|
||||
console.error(
|
||||
`[SessionManager] Tool execution error: ${requestInfo.name}`,
|
||||
toolResponse.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send tool results as user message
|
||||
this.sendToolResultsAsUserMessage(
|
||||
toolCallRequests,
|
||||
toolResponseParts,
|
||||
);
|
||||
|
||||
// Continue with tool responses for next turn
|
||||
currentMessages = toolResponseParts;
|
||||
} else {
|
||||
// No more tool calls, done
|
||||
this.sendSuccessResult(accumulatedContent);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Query execution error:', error);
|
||||
}
|
||||
this.sendErrorResult(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check tool permission before execution
|
||||
*/
|
||||
private async checkToolPermission(requestInfo: ToolCallRequestInfo): Promise<{
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
updatedArgs?: Record<string, unknown>;
|
||||
}> {
|
||||
try {
|
||||
// Get permission controller from dispatcher
|
||||
const permissionController = this.dispatcher.permissionController;
|
||||
if (!permissionController) {
|
||||
// Fallback: allow if no permission controller available
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] No permission controller available, allowing tool execution',
|
||||
);
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Check permission using the controller
|
||||
return await permissionController.shouldAllowTool(requestInfo);
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Error checking tool permission:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
// Fail safe: deny on error
|
||||
return {
|
||||
allowed: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? `Permission check failed: ${error.message}`
|
||||
: 'Permission check failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tool results as user message
|
||||
*/
|
||||
private sendToolResultsAsUserMessage(
|
||||
toolCallRequests: ToolCallRequestInfo[],
|
||||
toolResponseParts: Part[],
|
||||
): void {
|
||||
// Create a map of function response names to call IDs
|
||||
const callIdMap = new Map<string, string>();
|
||||
for (const request of toolCallRequests) {
|
||||
callIdMap.set(request.name, request.callId);
|
||||
}
|
||||
|
||||
// Convert Part[] to ToolResultBlock[]
|
||||
const toolResultBlocks: ToolResultBlock[] = [];
|
||||
|
||||
for (const part of toolResponseParts) {
|
||||
if (part.functionResponse) {
|
||||
const functionName = part.functionResponse.name;
|
||||
if (!functionName) continue;
|
||||
|
||||
const callId = callIdMap.get(functionName) || functionName;
|
||||
|
||||
// Extract content from function response
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let content: string | Array<Record<string, any>> | null = null;
|
||||
if (part.functionResponse.response?.['output']) {
|
||||
const output = part.functionResponse.response['output'];
|
||||
if (typeof output === 'string') {
|
||||
content = output;
|
||||
} else if (Array.isArray(output)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
content = output as Array<Record<string, any>>;
|
||||
} else {
|
||||
content = JSON.stringify(output);
|
||||
}
|
||||
}
|
||||
|
||||
const toolResultBlock: ToolResultBlock = {
|
||||
type: 'tool_result',
|
||||
tool_use_id: callId,
|
||||
content,
|
||||
is_error: false,
|
||||
};
|
||||
toolResultBlocks.push(toolResultBlock);
|
||||
}
|
||||
}
|
||||
|
||||
// Only send if we have tool result blocks
|
||||
if (toolResultBlocks.length > 0) {
|
||||
const userMessage: CLIUserMessage = {
|
||||
type: 'user',
|
||||
uuid: `${this.sessionId}-tool-result-${Date.now()}`,
|
||||
session_id: this.sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: toolResultBlocks,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
this.streamJson.send(userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send success result
|
||||
*/
|
||||
private sendSuccessResult(message: string): void {
|
||||
const result: CLIResultMessage = {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
uuid: `${this.sessionId}-result-${Date.now()}`,
|
||||
session_id: this.sessionId,
|
||||
is_error: false,
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 0,
|
||||
result: message || 'Query completed successfully',
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
},
|
||||
permission_denials: [],
|
||||
};
|
||||
this.streamJson.send(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error result
|
||||
*/
|
||||
private sendErrorResult(_errorMessage: string): void {
|
||||
// Note: CLIResultMessageError doesn't have a result field
|
||||
// Error details would need to be logged separately or the type needs updating
|
||||
const result: CLIResultMessage = {
|
||||
type: 'result',
|
||||
subtype: 'error_during_execution',
|
||||
uuid: `${this.sessionId}-result-${Date.now()}`,
|
||||
session_id: this.sessionId,
|
||||
is_error: true,
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 0,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
},
|
||||
permission_denials: [],
|
||||
};
|
||||
this.streamJson.send(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup signal handlers for graceful shutdown
|
||||
*/
|
||||
private setupSignalHandlers(): void {
|
||||
const shutdownHandler = () => {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Shutdown signal received');
|
||||
}
|
||||
this.abortController.abort();
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdownHandler);
|
||||
process.on('SIGTERM', shutdownHandler);
|
||||
|
||||
// Handle stdin close - let the session complete naturally
|
||||
// instead of immediately aborting when input stream ends
|
||||
process.stdin.on('close', () => {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] stdin closed - waiting for generation to complete',
|
||||
);
|
||||
}
|
||||
// Don't abort immediately - let the message processing loop exit naturally
|
||||
// when streamJson.readMessages() completes, which will trigger shutdown()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown session and cleanup resources
|
||||
*/
|
||||
private async shutdown(): Promise<void> {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Shutting down');
|
||||
}
|
||||
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
this.dispatcher.shutdown();
|
||||
this.streamJson.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for stream-json mode
|
||||
*/
|
||||
export async function runNonInteractiveStreamJson(
|
||||
config: Config,
|
||||
_input: string,
|
||||
_promptId: string,
|
||||
): Promise<void> {
|
||||
const manager = new SessionManager(config);
|
||||
await manager.run();
|
||||
}
|
||||
111
packages/cli/src/services/MessageRouter.ts
Normal file
111
packages/cli/src/services/MessageRouter.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Message Router
|
||||
*
|
||||
* Routes incoming messages to appropriate handlers based on message type.
|
||||
* Provides classification for control messages vs data messages.
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
CLIMessage,
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlCancelRequest,
|
||||
} from '../types/protocol.js';
|
||||
import {
|
||||
isCLIUserMessage,
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
} from '../types/protocol.js';
|
||||
|
||||
export type MessageType =
|
||||
| 'control_request'
|
||||
| 'control_response'
|
||||
| 'control_cancel'
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'system'
|
||||
| 'result'
|
||||
| 'stream_event'
|
||||
| 'unknown';
|
||||
|
||||
export interface RoutedMessage {
|
||||
type: MessageType;
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message Router
|
||||
*
|
||||
* Classifies incoming messages and routes them to appropriate handlers.
|
||||
*/
|
||||
export class MessageRouter {
|
||||
private debugMode: boolean;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.debugMode = config.getDebugMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a message to the appropriate handler based on its type
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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(
|
||||
'[MessageRouter] Unknown message type:',
|
||||
JSON.stringify(message, null, 2),
|
||||
);
|
||||
}
|
||||
return { type: 'unknown', message };
|
||||
}
|
||||
}
|
||||
633
packages/cli/src/services/StreamJson.ts
Normal file
633
packages/cli/src/services/StreamJson.ts
Normal file
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* Transport-agnostic JSON Lines protocol handler for bidirectional communication.
|
||||
* Works with any Readable/Writable stream (stdin/stdout, HTTP, WebSocket, etc.)
|
||||
*/
|
||||
|
||||
import * as readline from 'node:readline';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
import type {
|
||||
CLIMessage,
|
||||
CLIUserMessage,
|
||||
ContentBlock,
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlCancelRequest,
|
||||
CLIAssistantMessage,
|
||||
CLIPartialAssistantMessage,
|
||||
StreamEvent,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolUseBlock,
|
||||
Usage,
|
||||
} from '../types/protocol.js';
|
||||
import type { ServerGeminiStreamEvent } from '@qwen-code/qwen-code-core';
|
||||
import { GeminiEventType } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* Stream JSON I/O Class
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
export interface StreamJsonOptions {
|
||||
input?: Readable;
|
||||
output?: Writable;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles JSON Lines communication over arbitrary streams.
|
||||
*/
|
||||
export class StreamJson {
|
||||
private input: Readable;
|
||||
private output: Writable;
|
||||
private rl?: readline.Interface;
|
||||
private onError?: (error: Error) => void;
|
||||
|
||||
constructor(options: StreamJsonOptions = {}) {
|
||||
this.input = options.input || process.stdin;
|
||||
this.output = options.output || process.stdout;
|
||||
this.onError = options.onError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read messages from input stream as async generator.
|
||||
*/
|
||||
async *readMessages(): AsyncGenerator<
|
||||
CLIMessage | CLIControlRequest | CLIControlResponse | ControlCancelRequest,
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
this.rl = readline.createInterface({
|
||||
input: this.input,
|
||||
crlfDelay: Infinity,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const line of this.rl) {
|
||||
if (!line.trim()) {
|
||||
continue; // Skip empty lines
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
yield message;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[StreamJson] Failed to parse message:',
|
||||
line.substring(0, 100),
|
||||
error,
|
||||
);
|
||||
// Continue processing (skip bad line)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Cleanup on exit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to output stream.
|
||||
*/
|
||||
send(message: CLIMessage | CLIControlResponse | CLIControlRequest): void {
|
||||
try {
|
||||
const line = JSON.stringify(message) + '\n';
|
||||
this.output.write(line);
|
||||
} catch (error) {
|
||||
console.error('[StreamJson] Failed to send message:', error);
|
||||
if (this.onError) {
|
||||
this.onError(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an assistant message builder.
|
||||
*/
|
||||
createAssistantBuilder(
|
||||
sessionId: string,
|
||||
parentToolUseId: string | null,
|
||||
model: string,
|
||||
includePartialMessages: boolean = false,
|
||||
): AssistantMessageBuilder {
|
||||
return new AssistantMessageBuilder({
|
||||
sessionId,
|
||||
parentToolUseId,
|
||||
includePartialMessages,
|
||||
model,
|
||||
streamJson: this,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources.
|
||||
*/
|
||||
cleanup(): void {
|
||||
if (this.rl) {
|
||||
this.rl.close();
|
||||
this.rl = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* Assistant Message Builder
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
export interface AssistantMessageBuilderOptions {
|
||||
sessionId: string;
|
||||
parentToolUseId: string | null;
|
||||
includePartialMessages: boolean;
|
||||
model: string;
|
||||
streamJson: StreamJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds assistant messages from Gemini stream events.
|
||||
* Accumulates content blocks and emits streaming events in real-time.
|
||||
*/
|
||||
export class AssistantMessageBuilder {
|
||||
private sessionId: string;
|
||||
private parentToolUseId: string | null;
|
||||
private includePartialMessages: boolean;
|
||||
private model: string;
|
||||
private streamJson: StreamJson;
|
||||
|
||||
private messageId: string;
|
||||
private contentBlocks: ContentBlock[] = [];
|
||||
private openBlocks = new Set<number>();
|
||||
private messageStarted: boolean = false;
|
||||
private finalized: boolean = false;
|
||||
private usage: Usage | null = null;
|
||||
|
||||
// Current block state
|
||||
private currentBlockType: 'text' | 'thinking' | null = null;
|
||||
private currentTextContent: string = '';
|
||||
private currentThinkingContent: string = '';
|
||||
private currentThinkingSignature: string = '';
|
||||
|
||||
constructor(options: AssistantMessageBuilderOptions) {
|
||||
this.sessionId = options.sessionId;
|
||||
this.parentToolUseId = options.parentToolUseId;
|
||||
this.includePartialMessages = options.includePartialMessages;
|
||||
this.model = options.model;
|
||||
this.streamJson = options.streamJson;
|
||||
this.messageId = randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a Gemini stream event and update internal state.
|
||||
*/
|
||||
processEvent(event: ServerGeminiStreamEvent): void {
|
||||
if (this.finalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case GeminiEventType.Content:
|
||||
this.handleContentEvent(event.value);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Thought:
|
||||
this.handleThoughtEvent(event.value.subject, event.value.description);
|
||||
break;
|
||||
|
||||
case GeminiEventType.ToolCallRequest:
|
||||
this.handleToolCallRequest(event.value);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Finished:
|
||||
this.finalizePendingBlocks();
|
||||
break;
|
||||
|
||||
default:
|
||||
// Ignore other event types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle text content event.
|
||||
*/
|
||||
private handleContentEvent(content: string): void {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureMessageStarted();
|
||||
|
||||
// If we're not in a text block, switch to text mode
|
||||
if (this.currentBlockType !== 'text') {
|
||||
this.switchToTextBlock();
|
||||
}
|
||||
|
||||
// Accumulate content
|
||||
this.currentTextContent += content;
|
||||
|
||||
// Emit delta for streaming updates
|
||||
const currentIndex = this.contentBlocks.length;
|
||||
this.emitContentBlockDelta(currentIndex, {
|
||||
type: 'text_delta',
|
||||
text: content,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle thinking event.
|
||||
*/
|
||||
private handleThoughtEvent(subject: string, description: string): void {
|
||||
this.ensureMessageStarted();
|
||||
|
||||
const thinkingFragment = `${subject}: ${description}`;
|
||||
|
||||
// If we're not in a thinking block, switch to thinking mode
|
||||
if (this.currentBlockType !== 'thinking') {
|
||||
this.switchToThinkingBlock(subject);
|
||||
}
|
||||
|
||||
// Accumulate thinking content
|
||||
this.currentThinkingContent += thinkingFragment;
|
||||
|
||||
// Emit delta for streaming updates
|
||||
const currentIndex = this.contentBlocks.length;
|
||||
this.emitContentBlockDelta(currentIndex, {
|
||||
type: 'thinking_delta',
|
||||
thinking: thinkingFragment,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tool call request.
|
||||
*/
|
||||
private handleToolCallRequest(request: any): void {
|
||||
this.ensureMessageStarted();
|
||||
|
||||
// Finalize any open blocks first
|
||||
this.finalizePendingBlocks();
|
||||
|
||||
// Create and add tool use block
|
||||
const index = this.contentBlocks.length;
|
||||
const toolUseBlock: ToolUseBlock = {
|
||||
type: 'tool_use',
|
||||
id: request.callId,
|
||||
name: request.name,
|
||||
input: request.args,
|
||||
};
|
||||
|
||||
this.contentBlocks.push(toolUseBlock);
|
||||
this.openBlock(index, toolUseBlock);
|
||||
this.closeBlock(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize any pending content blocks.
|
||||
*/
|
||||
private finalizePendingBlocks(): void {
|
||||
if (this.currentBlockType === 'text' && this.currentTextContent) {
|
||||
this.finalizeTextBlock();
|
||||
} else if (
|
||||
this.currentBlockType === 'thinking' &&
|
||||
this.currentThinkingContent
|
||||
) {
|
||||
this.finalizeThinkingBlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to text block mode.
|
||||
*/
|
||||
private switchToTextBlock(): void {
|
||||
this.finalizePendingBlocks();
|
||||
|
||||
this.currentBlockType = 'text';
|
||||
this.currentTextContent = '';
|
||||
|
||||
const index = this.contentBlocks.length;
|
||||
const textBlock: TextBlock = {
|
||||
type: 'text',
|
||||
text: '',
|
||||
};
|
||||
|
||||
this.openBlock(index, textBlock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to thinking block mode.
|
||||
*/
|
||||
private switchToThinkingBlock(signature: string): void {
|
||||
this.finalizePendingBlocks();
|
||||
|
||||
this.currentBlockType = 'thinking';
|
||||
this.currentThinkingContent = '';
|
||||
this.currentThinkingSignature = signature;
|
||||
|
||||
const index = this.contentBlocks.length;
|
||||
const thinkingBlock: ThinkingBlock = {
|
||||
type: 'thinking',
|
||||
thinking: '',
|
||||
signature,
|
||||
};
|
||||
|
||||
this.openBlock(index, thinkingBlock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize current text block.
|
||||
*/
|
||||
private finalizeTextBlock(): void {
|
||||
if (!this.currentTextContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.contentBlocks.length;
|
||||
const textBlock: TextBlock = {
|
||||
type: 'text',
|
||||
text: this.currentTextContent,
|
||||
};
|
||||
this.contentBlocks.push(textBlock);
|
||||
this.closeBlock(index);
|
||||
|
||||
this.currentBlockType = null;
|
||||
this.currentTextContent = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize current thinking block.
|
||||
*/
|
||||
private finalizeThinkingBlock(): void {
|
||||
if (!this.currentThinkingContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.contentBlocks.length;
|
||||
const thinkingBlock: ThinkingBlock = {
|
||||
type: 'thinking',
|
||||
thinking: this.currentThinkingContent,
|
||||
signature: this.currentThinkingSignature,
|
||||
};
|
||||
this.contentBlocks.push(thinkingBlock);
|
||||
this.closeBlock(index);
|
||||
|
||||
this.currentBlockType = null;
|
||||
this.currentThinkingContent = '';
|
||||
this.currentThinkingSignature = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set usage information for the final message.
|
||||
*/
|
||||
setUsage(usage: Usage): void {
|
||||
this.usage = usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and return the final assistant message.
|
||||
*/
|
||||
finalize(): CLIAssistantMessage {
|
||||
if (this.finalized) {
|
||||
return this.buildFinalMessage();
|
||||
}
|
||||
|
||||
this.finalized = true;
|
||||
|
||||
// Finalize any pending blocks
|
||||
this.finalizePendingBlocks();
|
||||
|
||||
// Close all open blocks in order
|
||||
const orderedOpenBlocks = [...this.openBlocks].sort((a, b) => a - b);
|
||||
for (const index of orderedOpenBlocks) {
|
||||
this.closeBlock(index);
|
||||
}
|
||||
|
||||
// Emit message stop event
|
||||
if (this.messageStarted) {
|
||||
this.emitMessageStop();
|
||||
}
|
||||
|
||||
return this.buildFinalMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final message structure.
|
||||
*/
|
||||
private buildFinalMessage(): CLIAssistantMessage {
|
||||
return {
|
||||
type: 'assistant',
|
||||
uuid: this.messageId,
|
||||
session_id: this.sessionId,
|
||||
parent_tool_use_id: this.parentToolUseId,
|
||||
message: {
|
||||
id: this.messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: this.model,
|
||||
content: this.contentBlocks,
|
||||
stop_reason: null,
|
||||
usage: this.usage || {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure message has been started.
|
||||
*/
|
||||
private ensureMessageStarted(): void {
|
||||
if (this.messageStarted) {
|
||||
return;
|
||||
}
|
||||
this.messageStarted = true;
|
||||
this.emitMessageStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a content block and emit start event.
|
||||
*/
|
||||
private openBlock(index: number, block: ContentBlock): void {
|
||||
this.openBlocks.add(index);
|
||||
this.emitContentBlockStart(index, block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a content block and emit stop event.
|
||||
*/
|
||||
private closeBlock(index: number): void {
|
||||
if (!this.openBlocks.has(index)) {
|
||||
return;
|
||||
}
|
||||
this.openBlocks.delete(index);
|
||||
this.emitContentBlockStop(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit message_start stream event.
|
||||
*/
|
||||
private emitMessageStart(): void {
|
||||
const event: StreamEvent = {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: this.messageId,
|
||||
role: 'assistant',
|
||||
model: this.model,
|
||||
},
|
||||
};
|
||||
this.emitStreamEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit content_block_start stream event.
|
||||
*/
|
||||
private emitContentBlockStart(
|
||||
index: number,
|
||||
contentBlock: ContentBlock,
|
||||
): void {
|
||||
const event: StreamEvent = {
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: contentBlock,
|
||||
};
|
||||
this.emitStreamEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit content_block_delta stream event.
|
||||
*/
|
||||
private emitContentBlockDelta(
|
||||
index: number,
|
||||
delta: {
|
||||
type: 'text_delta' | 'thinking_delta';
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
},
|
||||
): void {
|
||||
const event: StreamEvent = {
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta,
|
||||
};
|
||||
this.emitStreamEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit content_block_stop stream event
|
||||
*/
|
||||
private emitContentBlockStop(index: number): void {
|
||||
const event: StreamEvent = {
|
||||
type: 'content_block_stop',
|
||||
index,
|
||||
};
|
||||
this.emitStreamEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit message_stop stream event
|
||||
*/
|
||||
private emitMessageStop(): void {
|
||||
const event: StreamEvent = {
|
||||
type: 'message_stop',
|
||||
};
|
||||
this.emitStreamEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a stream event as SDKPartialAssistantMessage
|
||||
*/
|
||||
private emitStreamEvent(event: StreamEvent): void {
|
||||
if (!this.includePartialMessages) return;
|
||||
|
||||
const message: CLIPartialAssistantMessage = {
|
||||
type: 'stream_event',
|
||||
uuid: randomUUID(),
|
||||
session_id: this.sessionId,
|
||||
event,
|
||||
parent_tool_use_id: this.parentToolUseId,
|
||||
};
|
||||
this.streamJson.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from user message
|
||||
*/
|
||||
export function extractUserMessageText(message: CLIUserMessage): string[] {
|
||||
const texts: string[] = [];
|
||||
const content = message.message.content;
|
||||
|
||||
if (typeof content === 'string') {
|
||||
texts.push(content);
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if ('content' in block && typeof block.content === 'string') {
|
||||
texts.push(block.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return texts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from content blocks
|
||||
*/
|
||||
export function extractTextFromContent(content: ContentBlock[]): string {
|
||||
return content
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create text content block
|
||||
*/
|
||||
export function createTextContent(text: string): ContentBlock {
|
||||
return {
|
||||
type: 'text',
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tool use content block
|
||||
*/
|
||||
export function createToolUseContent(
|
||||
id: string,
|
||||
name: string,
|
||||
input: Record<string, any>,
|
||||
): ContentBlock {
|
||||
return {
|
||||
type: 'tool_use',
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tool result content block
|
||||
*/
|
||||
export function createToolResultContent(
|
||||
tool_use_id: string,
|
||||
content: string | Array<Record<string, any>> | null,
|
||||
is_error?: boolean,
|
||||
): ContentBlock {
|
||||
return {
|
||||
type: 'tool_result',
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
};
|
||||
}
|
||||
73
packages/cli/src/services/control/ControlContext.ts
Normal file
73
packages/cli/src/services/control/ControlContext.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control Context
|
||||
*
|
||||
* Shared context for control plane communication, providing access to
|
||||
* session state, configuration, and I/O without prop drilling.
|
||||
*/
|
||||
|
||||
import type { Config, MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { StreamJson } from '../StreamJson.js';
|
||||
import type { PermissionMode } from '../../types/protocol.js';
|
||||
|
||||
/**
|
||||
* Control Context interface
|
||||
*
|
||||
* Provides shared access to session-scoped resources and mutable state
|
||||
* for all controllers.
|
||||
*/
|
||||
export interface IControlContext {
|
||||
readonly config: Config;
|
||||
readonly streamJson: StreamJson;
|
||||
readonly sessionId: string;
|
||||
readonly abortSignal: AbortSignal;
|
||||
readonly debugMode: boolean;
|
||||
|
||||
permissionMode: PermissionMode;
|
||||
sdkMcpServers: Set<string>;
|
||||
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
|
||||
|
||||
onInterrupt?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Control Context implementation
|
||||
*/
|
||||
export class ControlContext implements IControlContext {
|
||||
readonly config: Config;
|
||||
readonly streamJson: StreamJson;
|
||||
readonly sessionId: string;
|
||||
readonly abortSignal: AbortSignal;
|
||||
readonly debugMode: boolean;
|
||||
|
||||
permissionMode: PermissionMode;
|
||||
sdkMcpServers: Set<string>;
|
||||
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
|
||||
|
||||
onInterrupt?: () => void;
|
||||
|
||||
constructor(options: {
|
||||
config: Config;
|
||||
streamJson: StreamJson;
|
||||
sessionId: string;
|
||||
abortSignal: AbortSignal;
|
||||
permissionMode?: PermissionMode;
|
||||
onInterrupt?: () => void;
|
||||
}) {
|
||||
this.config = options.config;
|
||||
this.streamJson = options.streamJson;
|
||||
this.sessionId = options.sessionId;
|
||||
this.abortSignal = options.abortSignal;
|
||||
this.debugMode = options.config.getDebugMode();
|
||||
this.permissionMode = options.permissionMode || 'default';
|
||||
this.sdkMcpServers = new Set();
|
||||
this.mcpClients = new Map();
|
||||
this.onInterrupt = options.onInterrupt;
|
||||
}
|
||||
}
|
||||
351
packages/cli/src/services/control/ControlDispatcher.ts
Normal file
351
packages/cli/src/services/control/ControlDispatcher.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control Dispatcher
|
||||
*
|
||||
* Routes control requests between SDK and CLI to appropriate controllers.
|
||||
* Manages pending request registry and handles cancellation/cleanup.
|
||||
*
|
||||
* Controllers:
|
||||
* - SystemController: initialize, interrupt, set_model, supported_commands
|
||||
* - PermissionController: can_use_tool, set_permission_mode
|
||||
* - MCPController: mcp_message, mcp_server_status
|
||||
* - HookController: hook_callback
|
||||
*
|
||||
* Note: Control request types are centrally defined in the ControlRequestType
|
||||
* enum in packages/sdk/typescript/src/types/controlRequests.ts
|
||||
*/
|
||||
|
||||
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 { HookController } from './controllers/hookController.js';
|
||||
import type {
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlResponse,
|
||||
ControlRequestPayload,
|
||||
} from '../../types/protocol.js';
|
||||
|
||||
/**
|
||||
* Tracks an incoming request from SDK awaiting CLI response
|
||||
*/
|
||||
interface PendingIncomingRequest {
|
||||
controller: string;
|
||||
abortController: AbortController;
|
||||
timeoutId: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks an outgoing request from CLI awaiting SDK response
|
||||
*/
|
||||
interface PendingOutgoingRequest {
|
||||
controller: string;
|
||||
resolve: (response: ControlResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central coordinator for control plane communication.
|
||||
* Routes requests to controllers and manages request lifecycle.
|
||||
*/
|
||||
export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
private context: IControlContext;
|
||||
|
||||
// Make controllers publicly accessible
|
||||
readonly systemController: SystemController;
|
||||
readonly permissionController: PermissionController;
|
||||
readonly mcpController: MCPController;
|
||||
readonly hookController: HookController;
|
||||
|
||||
// Central pending request registries
|
||||
private pendingIncomingRequests: Map<string, PendingIncomingRequest> =
|
||||
new Map();
|
||||
private pendingOutgoingRequests: Map<string, PendingOutgoingRequest> =
|
||||
new Map();
|
||||
|
||||
constructor(context: IControlContext) {
|
||||
this.context = context;
|
||||
|
||||
// Create domain controllers with context and registry
|
||||
this.systemController = new SystemController(
|
||||
context,
|
||||
this,
|
||||
'SystemController',
|
||||
);
|
||||
this.permissionController = new PermissionController(
|
||||
context,
|
||||
this,
|
||||
'PermissionController',
|
||||
);
|
||||
this.mcpController = new MCPController(context, this, 'MCPController');
|
||||
this.hookController = new HookController(context, this, 'HookController');
|
||||
|
||||
// Listen for main abort signal
|
||||
this.context.abortSignal.addEventListener('abort', () => {
|
||||
this.shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes an incoming request to the appropriate controller and sends response
|
||||
*/
|
||||
async dispatch(request: CLIControlRequest): Promise<void> {
|
||||
const { request_id, request: payload } = request;
|
||||
|
||||
try {
|
||||
// Route to appropriate controller
|
||||
const controller = this.getControllerForRequest(payload.subtype);
|
||||
const response = await controller.handleRequest(payload, request_id);
|
||||
|
||||
// Send success response
|
||||
this.sendSuccessResponse(request_id, response);
|
||||
|
||||
// Special handling for initialize: send SystemMessage after success response
|
||||
if (payload.subtype === 'initialize') {
|
||||
this.systemController.sendSystemMessage();
|
||||
}
|
||||
} catch (error) {
|
||||
// Send error response
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
this.sendErrorResponse(request_id, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes response from SDK for an outgoing request
|
||||
*/
|
||||
handleControlResponse(response: CLIControlResponse): void {
|
||||
const responsePayload = response.response;
|
||||
const requestId = responsePayload.request_id;
|
||||
|
||||
const pending = this.pendingOutgoingRequests.get(requestId);
|
||||
if (!pending) {
|
||||
// No pending request found - may have timed out or been cancelled
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[ControlDispatcher] No pending outgoing request for: ${requestId}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Deregister
|
||||
this.deregisterOutgoingRequest(requestId);
|
||||
|
||||
// Resolve or reject based on response type
|
||||
if (responsePayload.subtype === 'success') {
|
||||
pending.resolve(responsePayload);
|
||||
} else {
|
||||
pending.reject(new Error(responsePayload.error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a control request to SDK and waits for response
|
||||
*/
|
||||
async sendControlRequest(
|
||||
payload: ControlRequestPayload,
|
||||
timeoutMs?: number,
|
||||
): Promise<ControlResponse> {
|
||||
// Delegate to system controller (or any controller, they all have the same method)
|
||||
return this.systemController.sendControlRequest(payload, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a specific request or all pending requests
|
||||
*/
|
||||
handleCancel(requestId?: string): void {
|
||||
if (requestId) {
|
||||
// Cancel specific incoming request
|
||||
const pending = this.pendingIncomingRequests.get(requestId);
|
||||
if (pending) {
|
||||
pending.abortController.abort();
|
||||
this.deregisterIncomingRequest(requestId);
|
||||
this.sendErrorResponse(requestId, 'Request cancelled');
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[ControlDispatcher] Cancelled incoming request: ${requestId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Cancel ALL pending incoming requests
|
||||
const requestIds = Array.from(this.pendingIncomingRequests.keys());
|
||||
for (const id of requestIds) {
|
||||
const pending = this.pendingIncomingRequests.get(id);
|
||||
if (pending) {
|
||||
pending.abortController.abort();
|
||||
this.deregisterIncomingRequest(id);
|
||||
this.sendErrorResponse(id, 'All requests cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[ControlDispatcher] Cancelled all ${requestIds.length} pending incoming requests`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops all pending requests and cleans up all controllers
|
||||
*/
|
||||
shutdown(): void {
|
||||
if (this.context.debugMode) {
|
||||
console.error('[ControlDispatcher] Shutting down');
|
||||
}
|
||||
|
||||
// Cancel all incoming requests
|
||||
for (const [
|
||||
_requestId,
|
||||
pending,
|
||||
] of this.pendingIncomingRequests.entries()) {
|
||||
pending.abortController.abort();
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
this.pendingIncomingRequests.clear();
|
||||
|
||||
// Cancel all outgoing requests
|
||||
for (const [
|
||||
_requestId,
|
||||
pending,
|
||||
] of this.pendingOutgoingRequests.entries()) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
pending.reject(new Error('Dispatcher shutdown'));
|
||||
}
|
||||
this.pendingOutgoingRequests.clear();
|
||||
|
||||
// Cleanup controllers (MCP controller will close all clients)
|
||||
this.systemController.cleanup();
|
||||
this.permissionController.cleanup();
|
||||
this.mcpController.cleanup();
|
||||
this.hookController.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an incoming request in the pending registry
|
||||
*/
|
||||
registerIncomingRequest(
|
||||
requestId: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
): void {
|
||||
this.pendingIncomingRequests.set(requestId, {
|
||||
controller,
|
||||
abortController,
|
||||
timeoutId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an incoming request from the pending registry
|
||||
*/
|
||||
deregisterIncomingRequest(requestId: string): void {
|
||||
const pending = this.pendingIncomingRequests.get(requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pendingIncomingRequests.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an outgoing request in the pending registry
|
||||
*/
|
||||
registerOutgoingRequest(
|
||||
requestId: string,
|
||||
controller: string,
|
||||
resolve: (response: ControlResponse) => void,
|
||||
reject: (error: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
): void {
|
||||
this.pendingOutgoingRequests.set(requestId, {
|
||||
controller,
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an outgoing request from the pending registry
|
||||
*/
|
||||
deregisterOutgoingRequest(requestId: string): void {
|
||||
const pending = this.pendingOutgoingRequests.get(requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pendingOutgoingRequests.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the controller that handles the given request subtype
|
||||
*/
|
||||
private getControllerForRequest(subtype: string) {
|
||||
switch (subtype) {
|
||||
case 'initialize':
|
||||
case 'interrupt':
|
||||
case 'set_model':
|
||||
case 'supported_commands':
|
||||
return this.systemController;
|
||||
|
||||
case 'can_use_tool':
|
||||
case 'set_permission_mode':
|
||||
return this.permissionController;
|
||||
|
||||
case 'mcp_message':
|
||||
case 'mcp_server_status':
|
||||
return this.mcpController;
|
||||
|
||||
case 'hook_callback':
|
||||
return this.hookController;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown control request subtype: ${subtype}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a success response back to SDK
|
||||
*/
|
||||
private sendSuccessResponse(
|
||||
requestId: string,
|
||||
response: Record<string, unknown>,
|
||||
): void {
|
||||
const controlResponse: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response,
|
||||
},
|
||||
};
|
||||
this.context.streamJson.send(controlResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an error response back to SDK
|
||||
*/
|
||||
private sendErrorResponse(requestId: string, error: string): void {
|
||||
const controlResponse: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error,
|
||||
},
|
||||
};
|
||||
this.context.streamJson.send(controlResponse);
|
||||
}
|
||||
}
|
||||
180
packages/cli/src/services/control/controllers/baseController.ts
Normal file
180
packages/cli/src/services/control/controllers/baseController.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base Controller
|
||||
*
|
||||
* Abstract base class for domain-specific control plane controllers.
|
||||
* Provides common functionality for:
|
||||
* - Handling incoming control requests (SDK -> CLI)
|
||||
* - Sending outgoing control requests (CLI -> SDK)
|
||||
* - Request lifecycle management with timeout and cancellation
|
||||
* - Integration with central pending request registry
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { IControlContext } from '../ControlContext.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
ControlResponse,
|
||||
CLIControlRequest,
|
||||
} from '../../../types/protocol.js';
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 30000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Registry interface for controllers to register/deregister pending requests
|
||||
*/
|
||||
export interface IPendingRequestRegistry {
|
||||
registerIncomingRequest(
|
||||
requestId: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
): void;
|
||||
deregisterIncomingRequest(requestId: string): void;
|
||||
|
||||
registerOutgoingRequest(
|
||||
requestId: string,
|
||||
controller: string,
|
||||
resolve: (response: ControlResponse) => void,
|
||||
reject: (error: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
): void;
|
||||
deregisterOutgoingRequest(requestId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base controller class
|
||||
*
|
||||
* Subclasses should implement handleRequestPayload() to process specific
|
||||
* control request types.
|
||||
*/
|
||||
export abstract class BaseController {
|
||||
protected context: IControlContext;
|
||||
protected registry: IPendingRequestRegistry;
|
||||
protected controllerName: string;
|
||||
|
||||
constructor(
|
||||
context: IControlContext,
|
||||
registry: IPendingRequestRegistry,
|
||||
controllerName: string,
|
||||
) {
|
||||
this.context = context;
|
||||
this.registry = registry;
|
||||
this.controllerName = controllerName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming control request
|
||||
*
|
||||
* Manages lifecycle: register -> process -> deregister
|
||||
*/
|
||||
async handleRequest(
|
||||
payload: ControlRequestPayload,
|
||||
requestId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const requestAbortController = new AbortController();
|
||||
|
||||
// Setup timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
requestAbortController.abort();
|
||||
this.registry.deregisterIncomingRequest(requestId);
|
||||
if (this.context.debugMode) {
|
||||
console.error(`[${this.controllerName}] Request timeout: ${requestId}`);
|
||||
}
|
||||
}, DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
|
||||
// Register with central registry
|
||||
this.registry.registerIncomingRequest(
|
||||
requestId,
|
||||
this.controllerName,
|
||||
requestAbortController,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await this.handleRequestPayload(
|
||||
payload,
|
||||
requestAbortController.signal,
|
||||
);
|
||||
|
||||
// Success - deregister
|
||||
this.registry.deregisterIncomingRequest(requestId);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Error - deregister
|
||||
this.registry.deregisterIncomingRequest(requestId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an outgoing control request to SDK
|
||||
*
|
||||
* Manages lifecycle: register -> send -> wait for response -> deregister
|
||||
*/
|
||||
async sendControlRequest(
|
||||
payload: ControlRequestPayload,
|
||||
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
): Promise<ControlResponse> {
|
||||
const requestId = randomUUID();
|
||||
|
||||
return new Promise<ControlResponse>((resolve, reject) => {
|
||||
// Setup timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(new Error('Control request timeout'));
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[${this.controllerName}] Outgoing request timeout: ${requestId}`,
|
||||
);
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// Register with central registry
|
||||
this.registry.registerOutgoingRequest(
|
||||
requestId,
|
||||
this.controllerName,
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
// Send control request
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: requestId,
|
||||
request: payload,
|
||||
};
|
||||
|
||||
try {
|
||||
this.context.streamJson.send(request);
|
||||
} catch (error) {
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract method: Handle specific request payload
|
||||
*
|
||||
* Subclasses must implement this to process their domain-specific requests.
|
||||
*/
|
||||
protected abstract handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
// Subclasses can override to add cleanup logic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hook Controller
|
||||
*
|
||||
* Handles hook-related control requests:
|
||||
* - hook_callback: Process hook callbacks (placeholder for future)
|
||||
*/
|
||||
|
||||
import { BaseController } from './baseController.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
CLIHookCallbackRequest,
|
||||
} from '../../../types/protocol.js';
|
||||
|
||||
export class HookController extends BaseController {
|
||||
/**
|
||||
* Handle hook control requests
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (payload.subtype) {
|
||||
case 'hook_callback':
|
||||
return this.handleHookCallback(payload as CLIHookCallbackRequest);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in HookController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hook_callback request
|
||||
*
|
||||
* Processes hook callbacks (placeholder implementation)
|
||||
*/
|
||||
private async handleHookCallback(
|
||||
payload: CLIHookCallbackRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (this.context.debugMode) {
|
||||
console.error(`[HookController] Hook callback: ${payload.callback_id}`);
|
||||
}
|
||||
|
||||
// Hook callback processing not yet implemented
|
||||
return {
|
||||
result: 'Hook callback processing not yet implemented',
|
||||
callback_id: payload.callback_id,
|
||||
tool_use_id: payload.tool_use_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
287
packages/cli/src/services/control/controllers/mcpController.ts
Normal file
287
packages/cli/src/services/control/controllers/mcpController.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* @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/protocol.js';
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
WorkspaceContext,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
connectToMcpServer,
|
||||
MCP_DEFAULT_TIMEOUT_MSEC,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class MCPController extends BaseController {
|
||||
/**
|
||||
* Handle MCP control requests
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (payload.subtype) {
|
||||
case 'mcp_message':
|
||||
return this.handleMcpMessage(payload as CLIControlMcpMessageRequest);
|
||||
|
||||
case 'mcp_server_status':
|
||||
return this.handleMcpStatus();
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in MCPController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_message request
|
||||
*
|
||||
* Routes JSON-RPC messages to MCP servers
|
||||
*/
|
||||
private async handleMcpMessage(
|
||||
payload: CLIControlMcpMessageRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const serverNameRaw = payload.server_name;
|
||||
if (
|
||||
typeof serverNameRaw !== 'string' ||
|
||||
serverNameRaw.trim().length === 0
|
||||
) {
|
||||
throw new Error('Missing server_name in mcp_message request');
|
||||
}
|
||||
|
||||
const message = payload.message;
|
||||
if (!message || typeof message !== 'object') {
|
||||
throw new Error(
|
||||
'Missing or invalid message payload for mcp_message request',
|
||||
);
|
||||
}
|
||||
|
||||
// Get or create MCP client
|
||||
let clientEntry: { client: Client; config: MCPServerConfig };
|
||||
try {
|
||||
clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim());
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to connect to MCP server',
|
||||
);
|
||||
}
|
||||
|
||||
const method = message.method;
|
||||
if (typeof method !== 'string' || method.trim().length === 0) {
|
||||
throw new Error('Invalid MCP message: missing method');
|
||||
}
|
||||
|
||||
const jsonrpcVersion =
|
||||
typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0';
|
||||
const messageId = message.id;
|
||||
const params = message.params;
|
||||
const timeout =
|
||||
typeof clientEntry.config.timeout === 'number'
|
||||
? clientEntry.config.timeout
|
||||
: MCP_DEFAULT_TIMEOUT_MSEC;
|
||||
|
||||
try {
|
||||
// Handle notification (no id)
|
||||
if (messageId === undefined) {
|
||||
await clientEntry.client.notification({
|
||||
method,
|
||||
params,
|
||||
});
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: null,
|
||||
result: { success: true, acknowledged: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle request (with id)
|
||||
const result = await clientEntry.client.request(
|
||||
{
|
||||
method,
|
||||
params,
|
||||
},
|
||||
ResultSchema,
|
||||
{ timeout },
|
||||
);
|
||||
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: messageId,
|
||||
result,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If connection closed, remove from cache
|
||||
if (error instanceof Error && /closed/i.test(error.message)) {
|
||||
this.context.mcpClients.delete(serverNameRaw.trim());
|
||||
}
|
||||
|
||||
const errorCode =
|
||||
typeof (error as { code?: unknown })?.code === 'number'
|
||||
? ((error as { code: number }).code as number)
|
||||
: -32603;
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to execute MCP request';
|
||||
const errorData = (error as { data?: unknown })?.data;
|
||||
|
||||
const errorBody: Record<string, unknown> = {
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
};
|
||||
if (errorData !== undefined) {
|
||||
errorBody['data'] = errorData;
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: messageId ?? null,
|
||||
error: errorBody,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_server_status request
|
||||
*
|
||||
* Returns status of registered MCP servers
|
||||
*/
|
||||
private async handleMcpStatus(): Promise<Record<string, unknown>> {
|
||||
const status: Record<string, string> = {};
|
||||
|
||||
// Include SDK MCP servers
|
||||
for (const serverName of this.context.sdkMcpServers) {
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
// Include CLI-managed MCP clients
|
||||
for (const serverName of this.context.mcpClients.keys()) {
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] MCP status: ${Object.keys(status).length} servers`,
|
||||
);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create MCP client for a server
|
||||
*
|
||||
* Implements lazy connection and caching
|
||||
*/
|
||||
private async getOrCreateMcpClient(
|
||||
serverName: string,
|
||||
): Promise<{ client: Client; config: MCPServerConfig }> {
|
||||
// Check cache first
|
||||
const cached = this.context.mcpClients.get(serverName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get server configuration
|
||||
const provider = this.context.config as unknown as {
|
||||
getMcpServers?: () => Record<string, MCPServerConfig> | undefined;
|
||||
getDebugMode?: () => boolean;
|
||||
getWorkspaceContext?: () => unknown;
|
||||
};
|
||||
|
||||
if (typeof provider.getMcpServers !== 'function') {
|
||||
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||
}
|
||||
|
||||
const servers = provider.getMcpServers() ?? {};
|
||||
const serverConfig = servers[serverName];
|
||||
if (!serverConfig) {
|
||||
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||
}
|
||||
|
||||
const debugMode =
|
||||
typeof provider.getDebugMode === 'function'
|
||||
? provider.getDebugMode()
|
||||
: false;
|
||||
|
||||
const workspaceContext =
|
||||
typeof provider.getWorkspaceContext === 'function'
|
||||
? provider.getWorkspaceContext()
|
||||
: undefined;
|
||||
|
||||
if (!workspaceContext) {
|
||||
throw new Error('Workspace context is not available for MCP connection');
|
||||
}
|
||||
|
||||
// Connect to MCP server
|
||||
const client = await connectToMcpServer(
|
||||
serverName,
|
||||
serverConfig,
|
||||
debugMode,
|
||||
workspaceContext as WorkspaceContext,
|
||||
);
|
||||
|
||||
// Cache the client
|
||||
const entry = { client, config: serverConfig };
|
||||
this.context.mcpClients.set(serverName, entry);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(`[MCPController] Connected to MCP server: ${serverName}`);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup MCP clients
|
||||
*/
|
||||
override cleanup(): void {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`,
|
||||
);
|
||||
}
|
||||
|
||||
// Close all MCP clients
|
||||
for (const [serverName, { client }] of this.context.mcpClients.entries()) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] Failed to close MCP client ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.context.mcpClients.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Permission Controller
|
||||
*
|
||||
* Handles permission-related control requests:
|
||||
* - can_use_tool: Check if tool usage is allowed
|
||||
* - set_permission_mode: Change permission mode at runtime
|
||||
*
|
||||
* Abstracts all permission logic from the session manager to keep it clean.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
WaitingToolCall,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
CLIControlPermissionRequest,
|
||||
CLIControlSetPermissionModeRequest,
|
||||
ControlRequestPayload,
|
||||
PermissionMode,
|
||||
PermissionSuggestion,
|
||||
} from '../../../types/protocol.js';
|
||||
import { BaseController } from './baseController.js';
|
||||
|
||||
// Import ToolCallConfirmationDetails types for type alignment
|
||||
type ToolConfirmationType = 'edit' | 'exec' | 'mcp' | 'info' | 'plan';
|
||||
|
||||
export class PermissionController extends BaseController {
|
||||
private pendingOutgoingRequests = new Set<string>();
|
||||
|
||||
/**
|
||||
* Handle permission control requests
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (payload.subtype) {
|
||||
case 'can_use_tool':
|
||||
return this.handleCanUseTool(payload as CLIControlPermissionRequest);
|
||||
|
||||
case 'set_permission_mode':
|
||||
return this.handleSetPermissionMode(
|
||||
payload as CLIControlSetPermissionModeRequest,
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in PermissionController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle can_use_tool request
|
||||
*
|
||||
* Comprehensive permission evaluation based on:
|
||||
* - Permission mode (approval level)
|
||||
* - Tool registry validation
|
||||
* - Error handling with safe defaults
|
||||
*/
|
||||
private async handleCanUseTool(
|
||||
payload: CLIControlPermissionRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const toolName = payload.tool_name;
|
||||
if (
|
||||
!toolName ||
|
||||
typeof toolName !== 'string' ||
|
||||
toolName.trim().length === 0
|
||||
) {
|
||||
return {
|
||||
subtype: 'can_use_tool',
|
||||
behavior: 'deny',
|
||||
message: 'Missing or invalid tool_name in can_use_tool request',
|
||||
};
|
||||
}
|
||||
|
||||
let behavior: 'allow' | 'deny' = 'allow';
|
||||
let message: string | undefined;
|
||||
|
||||
try {
|
||||
// Check permission mode first
|
||||
const permissionResult = this.checkPermissionMode();
|
||||
if (!permissionResult.allowed) {
|
||||
behavior = 'deny';
|
||||
message = permissionResult.message;
|
||||
}
|
||||
|
||||
// Check tool registry if permission mode allows
|
||||
if (behavior === 'allow') {
|
||||
const registryResult = this.checkToolRegistry(toolName);
|
||||
if (!registryResult.allowed) {
|
||||
behavior = 'deny';
|
||||
message = registryResult.message;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
behavior = 'deny';
|
||||
message =
|
||||
error instanceof Error
|
||||
? `Failed to evaluate tool permission: ${error.message}`
|
||||
: 'Failed to evaluate tool permission';
|
||||
}
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
subtype: 'can_use_tool',
|
||||
behavior,
|
||||
};
|
||||
|
||||
if (message) {
|
||||
response['message'] = message;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check permission mode for tool execution
|
||||
*/
|
||||
private checkPermissionMode(): { allowed: boolean; message?: string } {
|
||||
const mode = this.context.permissionMode;
|
||||
|
||||
// Map permission modes to approval logic (aligned with VALID_APPROVAL_MODE_VALUES)
|
||||
switch (mode) {
|
||||
case 'yolo': // Allow all tools
|
||||
case 'auto-edit': // Auto-approve edit operations
|
||||
case 'plan': // Auto-approve planning operations
|
||||
return { allowed: true };
|
||||
|
||||
case 'default': // TODO: allow all tools for test
|
||||
default:
|
||||
return {
|
||||
allowed: false,
|
||||
message:
|
||||
'Tool execution requires manual approval. Update permission mode or approve via host.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool exists in registry
|
||||
*/
|
||||
private checkToolRegistry(toolName: string): {
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
} {
|
||||
try {
|
||||
// Access tool registry through config
|
||||
const config = this.context.config;
|
||||
const registryProvider = config as unknown as {
|
||||
getToolRegistry?: () => {
|
||||
getTool?: (name: string) => unknown;
|
||||
};
|
||||
};
|
||||
|
||||
if (typeof registryProvider.getToolRegistry === 'function') {
|
||||
const registry = registryProvider.getToolRegistry();
|
||||
if (
|
||||
registry &&
|
||||
typeof registry.getTool === 'function' &&
|
||||
!registry.getTool(toolName)
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: `Tool "${toolName}" is not registered.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: `Failed to check tool registry: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle set_permission_mode request
|
||||
*
|
||||
* Updates the permission mode in the context
|
||||
*/
|
||||
private async handleSetPermissionMode(
|
||||
payload: CLIControlSetPermissionModeRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const mode = payload.mode;
|
||||
const validModes: PermissionMode[] = [
|
||||
'default',
|
||||
'plan',
|
||||
'auto-edit',
|
||||
'yolo',
|
||||
];
|
||||
|
||||
if (!validModes.includes(mode)) {
|
||||
throw new Error(
|
||||
`Invalid permission mode: ${mode}. Valid values are: ${validModes.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.context.permissionMode = mode;
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[PermissionController] Permission mode updated to: ${mode}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { status: 'updated', mode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build permission suggestions for tool confirmation UI
|
||||
*
|
||||
* This method creates UI suggestions based on tool confirmation details,
|
||||
* helping the host application present appropriate permission options.
|
||||
*/
|
||||
buildPermissionSuggestions(
|
||||
confirmationDetails: unknown,
|
||||
): PermissionSuggestion[] | null {
|
||||
if (
|
||||
!confirmationDetails ||
|
||||
typeof confirmationDetails !== 'object' ||
|
||||
!('type' in confirmationDetails)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const details = confirmationDetails as Record<string, unknown>;
|
||||
const type = String(details['type'] ?? '');
|
||||
const title =
|
||||
typeof details['title'] === 'string' ? details['title'] : undefined;
|
||||
|
||||
// Ensure type matches ToolCallConfirmationDetails union
|
||||
const confirmationType = type as ToolConfirmationType;
|
||||
|
||||
switch (confirmationType) {
|
||||
case 'exec': // ToolExecuteConfirmationDetails
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Allow Command',
|
||||
description: `Execute: ${details['command']}`,
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Deny',
|
||||
description: 'Block this command execution',
|
||||
},
|
||||
];
|
||||
|
||||
case 'edit': // ToolEditConfirmationDetails
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Allow Edit',
|
||||
description: `Edit file: ${details['fileName']}`,
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Deny',
|
||||
description: 'Block this file edit',
|
||||
},
|
||||
{
|
||||
type: 'modify',
|
||||
label: 'Review Changes',
|
||||
description: 'Review the proposed changes before applying',
|
||||
},
|
||||
];
|
||||
|
||||
case 'plan': // ToolPlanConfirmationDetails
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Approve Plan',
|
||||
description: title || 'Execute the proposed plan',
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Reject Plan',
|
||||
description: 'Do not execute this plan',
|
||||
},
|
||||
];
|
||||
|
||||
case 'mcp': // ToolMcpConfirmationDetails
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Allow MCP Call',
|
||||
description: `${details['serverName']}: ${details['toolName']}`,
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Deny',
|
||||
description: 'Block this MCP server call',
|
||||
},
|
||||
];
|
||||
|
||||
case 'info': // ToolInfoConfirmationDetails
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Allow Info Request',
|
||||
description: title || 'Allow information request',
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Deny',
|
||||
description: 'Block this information request',
|
||||
},
|
||||
];
|
||||
|
||||
default:
|
||||
// Fallback for unknown types
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Allow',
|
||||
description: title || `Allow ${type} operation`,
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Deny',
|
||||
description: `Block ${type} operation`,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool should be executed based on current permission settings
|
||||
*
|
||||
* This is a convenience method for direct tool execution checks without
|
||||
* going through the control request flow.
|
||||
*/
|
||||
async shouldAllowTool(
|
||||
toolRequest: ToolCallRequestInfo,
|
||||
confirmationDetails?: unknown,
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
updatedArgs?: Record<string, unknown>;
|
||||
}> {
|
||||
// Check permission mode
|
||||
const modeResult = this.checkPermissionMode();
|
||||
if (!modeResult.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: modeResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Check tool registry
|
||||
const registryResult = this.checkToolRegistry(toolRequest.name);
|
||||
if (!registryResult.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: registryResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// If we have confirmation details, we could potentially modify args
|
||||
// This is a hook for future enhancement
|
||||
if (confirmationDetails) {
|
||||
// Future: handle argument modifications based on confirmation details
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get callback for monitoring tool calls and handling outgoing permission requests
|
||||
* This is passed to executeToolCall to hook into CoreToolScheduler updates
|
||||
*/
|
||||
getToolCallUpdateCallback(): (toolCalls: unknown[]) => void {
|
||||
return (toolCalls: unknown[]) => {
|
||||
for (const call of toolCalls) {
|
||||
if (
|
||||
call &&
|
||||
typeof call === 'object' &&
|
||||
(call as { status?: string }).status === 'awaiting_approval'
|
||||
) {
|
||||
const awaiting = call as WaitingToolCall;
|
||||
if (
|
||||
typeof awaiting.confirmationDetails?.onConfirm === 'function' &&
|
||||
!this.pendingOutgoingRequests.has(awaiting.request.callId)
|
||||
) {
|
||||
this.pendingOutgoingRequests.add(awaiting.request.callId);
|
||||
void this.handleOutgoingPermissionRequest(awaiting);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle outgoing permission request
|
||||
*
|
||||
* Behavior depends on input format:
|
||||
* - stream-json mode: Send can_use_tool to SDK and await response
|
||||
* - Other modes: Check local approval mode and decide immediately
|
||||
*/
|
||||
private async handleOutgoingPermissionRequest(
|
||||
toolCall: WaitingToolCall,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const inputFormat = this.context.config.getInputFormat?.();
|
||||
const isStreamJsonMode = inputFormat === 'stream-json';
|
||||
|
||||
if (!isStreamJsonMode) {
|
||||
// No SDK available - use local permission check
|
||||
const modeCheck = this.checkPermissionMode();
|
||||
const outcome = modeCheck.allowed
|
||||
? ToolConfirmationOutcome.ProceedOnce
|
||||
: ToolConfirmationOutcome.Cancel;
|
||||
|
||||
await toolCall.confirmationDetails.onConfirm(outcome);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream-json mode: ask SDK for permission
|
||||
const permissionSuggestions = this.buildPermissionSuggestions(
|
||||
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,
|
||||
);
|
||||
|
||||
if (response.subtype !== 'success') {
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (response.response || {}) as Record<string, unknown>;
|
||||
const behavior = String(payload['behavior'] || '').toLowerCase();
|
||||
|
||||
if (behavior === 'allow') {
|
||||
// Handle updated input if provided
|
||||
const updatedInput = payload['updatedInput'];
|
||||
if (updatedInput && typeof updatedInput === 'object') {
|
||||
toolCall.request.args = updatedInput as Record<string, unknown>;
|
||||
}
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
} else {
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[PermissionController] Outgoing permission failed:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
} finally {
|
||||
this.pendingOutgoingRequests.delete(toolCall.request.callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* System Controller
|
||||
*
|
||||
* Handles system-level control requests:
|
||||
* - initialize: Setup session and return system info
|
||||
* - interrupt: Cancel current operations
|
||||
* - set_model: Switch model (placeholder)
|
||||
*/
|
||||
|
||||
import { BaseController } from './baseController.js';
|
||||
import { CommandService } from '../../CommandService.js';
|
||||
import { BuiltinCommandLoader } from '../../BuiltinCommandLoader.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlInitializeRequest,
|
||||
CLIControlSetModelRequest,
|
||||
CLISystemMessage,
|
||||
} from '../../../types/protocol.js';
|
||||
|
||||
export class SystemController extends BaseController {
|
||||
/**
|
||||
* Handle system control requests
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (payload.subtype) {
|
||||
case 'initialize':
|
||||
return this.handleInitialize(payload as CLIControlInitializeRequest);
|
||||
|
||||
case 'interrupt':
|
||||
return this.handleInterrupt();
|
||||
|
||||
case 'set_model':
|
||||
return this.handleSetModel(payload as CLIControlSetModelRequest);
|
||||
|
||||
case 'supported_commands':
|
||||
return this.handleSupportedCommands();
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in SystemController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle initialize request
|
||||
*
|
||||
* Registers SDK MCP servers and returns capabilities
|
||||
*/
|
||||
private async handleInitialize(
|
||||
payload: CLIControlInitializeRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Register SDK MCP servers if provided
|
||||
if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) {
|
||||
for (const serverName of payload.sdkMcpServers) {
|
||||
this.context.sdkMcpServers.add(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
// Build capabilities for response
|
||||
const capabilities = this.buildControlCapabilities();
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Initialized with ${this.context.sdkMcpServers.size} SDK MCP servers`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'initialize',
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send system message to SDK
|
||||
*
|
||||
* Called after successful initialize response is sent
|
||||
*/
|
||||
async sendSystemMessage(): Promise<void> {
|
||||
const toolRegistry = this.context.config.getToolRegistry();
|
||||
const tools = toolRegistry ? toolRegistry.getAllToolNames() : [];
|
||||
|
||||
const mcpServers = this.context.config.getMcpServers();
|
||||
const mcpServerList = mcpServers
|
||||
? Object.keys(mcpServers).map((name) => ({
|
||||
name,
|
||||
status: 'connected',
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Load slash commands
|
||||
const slashCommands = await this.loadSlashCommandNames();
|
||||
|
||||
// Build capabilities
|
||||
const capabilities = this.buildControlCapabilities();
|
||||
|
||||
const systemMessage: CLISystemMessage = {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
uuid: this.context.sessionId,
|
||||
session_id: this.context.sessionId,
|
||||
cwd: this.context.config.getTargetDir(),
|
||||
tools,
|
||||
mcp_servers: mcpServerList,
|
||||
model: this.context.config.getModel(),
|
||||
permissionMode: this.context.permissionMode,
|
||||
slash_commands: slashCommands,
|
||||
apiKeySource: 'none',
|
||||
qwen_code_version: this.context.config.getCliVersion() || 'unknown',
|
||||
output_style: 'default',
|
||||
agents: [],
|
||||
skills: [],
|
||||
capabilities,
|
||||
};
|
||||
|
||||
this.context.streamJson.send(systemMessage);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error('[SystemController] System message sent');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build control capabilities for initialize response
|
||||
*/
|
||||
private buildControlCapabilities(): Record<string, unknown> {
|
||||
const capabilities: Record<string, unknown> = {
|
||||
can_handle_can_use_tool: true,
|
||||
can_handle_hook_callback: true,
|
||||
can_set_permission_mode:
|
||||
typeof this.context.config.setApprovalMode === 'function',
|
||||
can_set_model: typeof this.context.config.setModel === 'function',
|
||||
};
|
||||
|
||||
// Check if MCP message handling is available
|
||||
try {
|
||||
const mcpProvider = this.context.config as unknown as {
|
||||
getMcpServers?: () => Record<string, unknown> | undefined;
|
||||
};
|
||||
if (typeof mcpProvider.getMcpServers === 'function') {
|
||||
const servers = mcpProvider.getMcpServers();
|
||||
capabilities['can_handle_mcp_message'] = Boolean(
|
||||
servers && Object.keys(servers).length > 0,
|
||||
);
|
||||
} else {
|
||||
capabilities['can_handle_mcp_message'] = false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to determine MCP capability:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
capabilities['can_handle_mcp_message'] = false;
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle interrupt request
|
||||
*
|
||||
* Triggers the interrupt callback to cancel current operations
|
||||
*/
|
||||
private async handleInterrupt(): Promise<Record<string, unknown>> {
|
||||
// Trigger interrupt callback if available
|
||||
if (this.context.onInterrupt) {
|
||||
this.context.onInterrupt();
|
||||
}
|
||||
|
||||
// Abort the main signal to cancel ongoing operations
|
||||
if (this.context.abortSignal && !this.context.abortSignal.aborted) {
|
||||
// Note: We can't directly abort the signal, but the onInterrupt callback should handle this
|
||||
if (this.context.debugMode) {
|
||||
console.error('[SystemController] Interrupt signal triggered');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error('[SystemController] Interrupt handled');
|
||||
}
|
||||
|
||||
return { subtype: 'interrupt' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle set_model request
|
||||
*
|
||||
* Implements actual model switching with validation and error handling
|
||||
*/
|
||||
private async handleSetModel(
|
||||
payload: CLIControlSetModelRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const model = payload.model;
|
||||
|
||||
// Validate model parameter
|
||||
if (typeof model !== 'string' || model.trim() === '') {
|
||||
throw new Error('Invalid model specified for set_model request');
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt to set the model using config
|
||||
await this.context.config.setModel(model);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(`[SystemController] Model switched to: ${model}`);
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'set_model',
|
||||
model,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to set model';
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Failed to set model ${model}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
private async handleSupportedCommands(): Promise<Record<string, unknown>> {
|
||||
const commands = [
|
||||
'initialize',
|
||||
'interrupt',
|
||||
'set_model',
|
||||
'supported_commands',
|
||||
'can_use_tool',
|
||||
'set_permission_mode',
|
||||
'mcp_message',
|
||||
'mcp_server_status',
|
||||
'hook_callback',
|
||||
];
|
||||
|
||||
return {
|
||||
subtype: 'supported_commands',
|
||||
commands,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load slash command names using CommandService
|
||||
*/
|
||||
private async loadSlashCommandNames(): Promise<string[]> {
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const service = await CommandService.create(
|
||||
[new BuiltinCommandLoader(this.context.config)],
|
||||
controller.signal,
|
||||
);
|
||||
const names = new Set<string>();
|
||||
const commands = service.getCommands();
|
||||
for (const command of commands) {
|
||||
names.add(command.name);
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to load slash commands:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
485
packages/cli/src/types/protocol.ts
Normal file
485
packages/cli/src/types/protocol.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* Usage information types
|
||||
*/
|
||||
export interface Usage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
cache_read_input_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;
|
||||
costUSD: number;
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission denial information
|
||||
*/
|
||||
export interface CLIPermissionDenial {
|
||||
tool_name: string;
|
||||
tool_use_id: string;
|
||||
tool_input: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block types from Anthropic SDK
|
||||
*/
|
||||
export interface TextBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ThinkingBlock {
|
||||
type: 'thinking';
|
||||
thinking: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface ToolUseBlock {
|
||||
type: 'tool_use';
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ToolResultBlock {
|
||||
type: 'tool_result';
|
||||
tool_use_id: string;
|
||||
content: string | Array<Record<string, any>> | null;
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
export type ContentBlock =
|
||||
| TextBlock
|
||||
| ThinkingBlock
|
||||
| ToolUseBlock
|
||||
| ToolResultBlock;
|
||||
|
||||
/**
|
||||
* Anthropic SDK Message types
|
||||
*/
|
||||
export interface APIUserMessage {
|
||||
role: 'user';
|
||||
content: string | ToolResultBlock[];
|
||||
}
|
||||
|
||||
export interface APIAssistantMessage {
|
||||
id: string;
|
||||
type: 'message';
|
||||
role: 'assistant';
|
||||
model: string;
|
||||
content: ContentBlock[];
|
||||
stop_reason?: string | null;
|
||||
usage: Usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI Message wrapper types
|
||||
*/
|
||||
export interface CLIUserMessage {
|
||||
type: 'user';
|
||||
uuid?: string;
|
||||
session_id: string;
|
||||
message: APIUserMessage;
|
||||
parent_tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export interface CLIAssistantMessage {
|
||||
type: 'assistant';
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
message: APIAssistantMessage;
|
||||
parent_tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export interface CLISystemMessage {
|
||||
type: 'system';
|
||||
subtype: 'init' | 'compact_boundary';
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
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<string, unknown>;
|
||||
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;
|
||||
total_cost_usd: number;
|
||||
usage: ExtendedUsage;
|
||||
modelUsage?: Record<string, ModelUsage>;
|
||||
permission_denials: CLIPermissionDenial[];
|
||||
}
|
||||
|
||||
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;
|
||||
total_cost_usd: number;
|
||||
usage: ExtendedUsage;
|
||||
modelUsage?: Record<string, ModelUsage>;
|
||||
permission_denials: CLIPermissionDenial[];
|
||||
}
|
||||
|
||||
export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError;
|
||||
|
||||
/**
|
||||
* Stream event types for real-time message updates
|
||||
*/
|
||||
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 interface ContentBlockDeltaEvent {
|
||||
type: 'content_block_delta';
|
||||
index: number;
|
||||
delta: {
|
||||
type: 'text_delta' | 'thinking_delta';
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* Permission suggestion for tool use requests
|
||||
* TODO: Align with `ToolCallConfirmationDetails`
|
||||
*/
|
||||
export interface PermissionSuggestion {
|
||||
type: 'allow' | 'deny' | 'modify';
|
||||
label: string;
|
||||
description?: string;
|
||||
modifiedInput?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook callback placeholder for future implementation
|
||||
*/
|
||||
export interface HookRegistration {
|
||||
event: string;
|
||||
callback_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook callback result placeholder for future implementation
|
||||
*/
|
||||
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: Record<string, any>;
|
||||
permission_suggestions: PermissionSuggestion[] | null;
|
||||
blocked_path: string | null;
|
||||
}
|
||||
|
||||
export interface CLIControlInitializeRequest {
|
||||
subtype: 'initialize';
|
||||
hooks?: HookRegistration[] | null;
|
||||
sdkMcpServers?: string[];
|
||||
}
|
||||
|
||||
export interface CLIControlSetPermissionModeRequest {
|
||||
subtype: 'set_permission_mode';
|
||||
mode: PermissionMode;
|
||||
}
|
||||
|
||||
export interface CLIHookCallbackRequest {
|
||||
subtype: 'hook_callback';
|
||||
callback_id: string;
|
||||
input: any;
|
||||
tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export interface CLIControlMcpMessageRequest {
|
||||
subtype: 'mcp_message';
|
||||
server_name: string;
|
||||
message: {
|
||||
jsonrpc?: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission approval result
|
||||
*/
|
||||
export interface PermissionApproval {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
modifiedInput?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ControlResponse {
|
||||
subtype: 'success';
|
||||
request_id: string;
|
||||
response: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface ControlErrorResponse {
|
||||
subtype: 'error';
|
||||
request_id: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Type guard functions for message discrimination
|
||||
*/
|
||||
|
||||
export function isCLIUserMessage(msg: any): msg is CLIUserMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'user' &&
|
||||
'message' in msg &&
|
||||
'session_id' in msg &&
|
||||
'parent_tool_use_id' 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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block type guards
|
||||
*/
|
||||
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user