mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
Headless enhancement: add stream-json as input-format/output-format to support programmatically use (#926)
This commit is contained in:
76
packages/cli/src/nonInteractive/control/ControlContext.ts
Normal file
76
packages/cli/src/nonInteractive/control/ControlContext.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control Context
|
||||
*
|
||||
* Layer 1 of the control plane architecture. Provides shared, session-scoped
|
||||
* state for all controllers and services, eliminating the need for prop
|
||||
* drilling. Mutable fields are intentionally exposed so controllers can track
|
||||
* runtime state (e.g. permission mode, active MCP clients).
|
||||
*/
|
||||
|
||||
import type { Config, MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js';
|
||||
import type { PermissionMode } from '../types.js';
|
||||
|
||||
/**
|
||||
* Control Context interface
|
||||
*
|
||||
* Provides shared access to session-scoped resources and mutable state
|
||||
* for all controllers across both ControlDispatcher (protocol routing) and
|
||||
* ControlService (programmatic API).
|
||||
*/
|
||||
export interface IControlContext {
|
||||
readonly config: Config;
|
||||
readonly streamJson: StreamJsonOutputAdapter;
|
||||
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: StreamJsonOutputAdapter;
|
||||
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: StreamJsonOutputAdapter;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ControlDispatcher } from './ControlDispatcher.js';
|
||||
import type { IControlContext } from './ControlContext.js';
|
||||
import type { SystemController } from './controllers/systemController.js';
|
||||
import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js';
|
||||
import type {
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlResponse,
|
||||
ControlRequestPayload,
|
||||
CLIControlInitializeRequest,
|
||||
CLIControlInterruptRequest,
|
||||
CLIControlSetModelRequest,
|
||||
CLIControlSupportedCommandsRequest,
|
||||
} from '../types.js';
|
||||
|
||||
/**
|
||||
* Creates a mock control context for testing
|
||||
*/
|
||||
function createMockContext(debugMode: boolean = false): IControlContext {
|
||||
const abortController = new AbortController();
|
||||
const mockStreamJson = {
|
||||
send: vi.fn(),
|
||||
} as unknown as StreamJsonOutputAdapter;
|
||||
|
||||
const mockConfig = {
|
||||
getDebugMode: vi.fn().mockReturnValue(debugMode),
|
||||
};
|
||||
|
||||
return {
|
||||
config: mockConfig as unknown as IControlContext['config'],
|
||||
streamJson: mockStreamJson,
|
||||
sessionId: 'test-session-id',
|
||||
abortSignal: abortController.signal,
|
||||
debugMode,
|
||||
permissionMode: 'default',
|
||||
sdkMcpServers: new Set<string>(),
|
||||
mcpClients: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock system controller for testing
|
||||
*/
|
||||
function createMockSystemController() {
|
||||
return {
|
||||
handleRequest: vi.fn(),
|
||||
sendControlRequest: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
} as unknown as SystemController;
|
||||
}
|
||||
|
||||
describe('ControlDispatcher', () => {
|
||||
let dispatcher: ControlDispatcher;
|
||||
let mockContext: IControlContext;
|
||||
let mockSystemController: SystemController;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockContext();
|
||||
mockSystemController = createMockSystemController();
|
||||
|
||||
// Mock SystemController constructor
|
||||
vi.doMock('./controllers/systemController.js', () => ({
|
||||
SystemController: vi.fn().mockImplementation(() => mockSystemController),
|
||||
}));
|
||||
|
||||
dispatcher = new ControlDispatcher(mockContext);
|
||||
// Replace with mock controller for easier testing
|
||||
(
|
||||
dispatcher as unknown as { systemController: SystemController }
|
||||
).systemController = mockSystemController;
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with context and create controllers', () => {
|
||||
expect(dispatcher).toBeDefined();
|
||||
expect(dispatcher.systemController).toBeDefined();
|
||||
});
|
||||
|
||||
it('should listen to abort signal and shutdown when aborted', () => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const context = {
|
||||
...createMockContext(),
|
||||
abortSignal: abortController.signal,
|
||||
};
|
||||
|
||||
const newDispatcher = new ControlDispatcher(context);
|
||||
vi.spyOn(newDispatcher, 'shutdown');
|
||||
|
||||
abortController.abort();
|
||||
|
||||
// Give event loop a chance to process
|
||||
return new Promise<void>((resolve) => {
|
||||
setImmediate(() => {
|
||||
expect(newDispatcher.shutdown).toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch', () => {
|
||||
it('should route initialize request to system controller', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: {
|
||||
subtype: 'initialize',
|
||||
} as CLIControlInitializeRequest,
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
subtype: 'initialize',
|
||||
capabilities: { test: true },
|
||||
};
|
||||
|
||||
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||
request.request,
|
||||
'req-1',
|
||||
);
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-1',
|
||||
response: mockResponse,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route interrupt request to system controller', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-2',
|
||||
request: {
|
||||
subtype: 'interrupt',
|
||||
} as CLIControlInterruptRequest,
|
||||
};
|
||||
|
||||
const mockResponse = { subtype: 'interrupt' };
|
||||
|
||||
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||
request.request,
|
||||
'req-2',
|
||||
);
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-2',
|
||||
response: mockResponse,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route set_model request to system controller', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-3',
|
||||
request: {
|
||||
subtype: 'set_model',
|
||||
model: 'test-model',
|
||||
} as CLIControlSetModelRequest,
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
subtype: 'set_model',
|
||||
model: 'test-model',
|
||||
};
|
||||
|
||||
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||
request.request,
|
||||
'req-3',
|
||||
);
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-3',
|
||||
response: mockResponse,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route supported_commands request to system controller', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-4',
|
||||
request: {
|
||||
subtype: 'supported_commands',
|
||||
} as CLIControlSupportedCommandsRequest,
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
subtype: 'supported_commands',
|
||||
commands: ['initialize', 'interrupt'],
|
||||
};
|
||||
|
||||
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||
request.request,
|
||||
'req-4',
|
||||
);
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-4',
|
||||
response: mockResponse,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send error response when controller throws error', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-5',
|
||||
request: {
|
||||
subtype: 'initialize',
|
||||
} as CLIControlInitializeRequest,
|
||||
};
|
||||
|
||||
const error = new Error('Test error');
|
||||
vi.mocked(mockSystemController.handleRequest).mockRejectedValue(error);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: 'req-5',
|
||||
error: 'Test error',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-Error thrown values', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-6',
|
||||
request: {
|
||||
subtype: 'initialize',
|
||||
} as CLIControlInitializeRequest,
|
||||
};
|
||||
|
||||
vi.mocked(mockSystemController.handleRequest).mockRejectedValue(
|
||||
'String error',
|
||||
);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: 'req-6',
|
||||
error: 'String error',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send error response for unknown request subtype', async () => {
|
||||
const request = {
|
||||
type: 'control_request' as const,
|
||||
request_id: 'req-7',
|
||||
request: {
|
||||
subtype: 'unknown_subtype',
|
||||
} as unknown as ControlRequestPayload,
|
||||
};
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
// Dispatch catches errors and sends error response instead of throwing
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: 'req-7',
|
||||
error: 'Unknown control request subtype: unknown_subtype',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleControlResponse', () => {
|
||||
it('should resolve pending outgoing request on success response', () => {
|
||||
const requestId = 'outgoing-req-1';
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: { result: 'success' },
|
||||
},
|
||||
};
|
||||
|
||||
// Register a pending outgoing request
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
// Access private method through type casting
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
|
||||
expect(resolve).toHaveBeenCalledWith(response.response);
|
||||
expect(reject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject pending outgoing request on error response', () => {
|
||||
const requestId = 'outgoing-req-2';
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error: 'Request failed',
|
||||
},
|
||||
};
|
||||
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
|
||||
expect(reject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Request failed',
|
||||
}),
|
||||
);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle error object in error response', () => {
|
||||
const requestId = 'outgoing-req-3';
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error: { message: 'Detailed error', code: 500 },
|
||||
},
|
||||
};
|
||||
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
|
||||
expect(reject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Detailed error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle response for non-existent pending request gracefully', () => {
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'non-existent',
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
expect(() => dispatcher.handleControlResponse(response)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle response for non-existent request in debug mode', () => {
|
||||
const context = createMockContext(true);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const dispatcherWithDebug = new ControlDispatcher(context);
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'non-existent',
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
|
||||
dispatcherWithDebug.handleControlResponse(response);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'[ControlDispatcher] No pending outgoing request for: non-existent',
|
||||
),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendControlRequest', () => {
|
||||
it('should delegate to system controller sendControlRequest', async () => {
|
||||
const payload: ControlRequestPayload = {
|
||||
subtype: 'initialize',
|
||||
} as CLIControlInitializeRequest;
|
||||
|
||||
const expectedResponse: ControlResponse = {
|
||||
subtype: 'success',
|
||||
request_id: 'test-id',
|
||||
response: {},
|
||||
};
|
||||
|
||||
vi.mocked(mockSystemController.sendControlRequest).mockResolvedValue(
|
||||
expectedResponse,
|
||||
);
|
||||
|
||||
const result = await dispatcher.sendControlRequest(payload, 5000);
|
||||
|
||||
expect(mockSystemController.sendControlRequest).toHaveBeenCalledWith(
|
||||
payload,
|
||||
5000,
|
||||
);
|
||||
expect(result).toBe(expectedResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCancel', () => {
|
||||
it('should cancel specific incoming request', () => {
|
||||
const requestId = 'cancel-req-1';
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
const abortSpy = vi.spyOn(abortController, 'abort');
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerIncomingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
abortController,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcher.handleCancel(requestId);
|
||||
|
||||
expect(abortSpy).toHaveBeenCalled();
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error: 'Request cancelled',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should cancel all incoming requests when no requestId provided', () => {
|
||||
const requestId1 = 'cancel-req-2';
|
||||
const requestId2 = 'cancel-req-3';
|
||||
|
||||
const abortController1 = new AbortController();
|
||||
const abortController2 = new AbortController();
|
||||
const timeoutId1 = setTimeout(() => {}, 1000);
|
||||
const timeoutId2 = setTimeout(() => {}, 1000);
|
||||
|
||||
const abortSpy1 = vi.spyOn(abortController1, 'abort');
|
||||
const abortSpy2 = vi.spyOn(abortController2, 'abort');
|
||||
|
||||
const register = (
|
||||
dispatcher as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerIncomingRequest.bind(dispatcher);
|
||||
|
||||
register(requestId1, 'SystemController', abortController1, timeoutId1);
|
||||
register(requestId2, 'SystemController', abortController2, timeoutId2);
|
||||
|
||||
dispatcher.handleCancel();
|
||||
|
||||
expect(abortSpy1).toHaveBeenCalled();
|
||||
expect(abortSpy2).toHaveBeenCalled();
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledTimes(2);
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId1,
|
||||
error: 'All requests cancelled',
|
||||
},
|
||||
});
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId2,
|
||||
error: 'All requests cancelled',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cancel of non-existent request gracefully', () => {
|
||||
expect(() => dispatcher.handleCancel('non-existent')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should log cancellation in debug mode', () => {
|
||||
const context = createMockContext(true);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const dispatcherWithDebug = new ControlDispatcher(context);
|
||||
const requestId = 'cancel-req-debug';
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcherWithDebug as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerIncomingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
abortController,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcherWithDebug.handleCancel(requestId);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'[ControlDispatcher] Cancelled incoming request: cancel-req-debug',
|
||||
),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('should cancel all pending incoming requests', () => {
|
||||
const requestId1 = 'shutdown-req-1';
|
||||
const requestId2 = 'shutdown-req-2';
|
||||
|
||||
const abortController1 = new AbortController();
|
||||
const abortController2 = new AbortController();
|
||||
const timeoutId1 = setTimeout(() => {}, 1000);
|
||||
const timeoutId2 = setTimeout(() => {}, 1000);
|
||||
|
||||
const abortSpy1 = vi.spyOn(abortController1, 'abort');
|
||||
const abortSpy2 = vi.spyOn(abortController2, 'abort');
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
const register = (
|
||||
dispatcher as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerIncomingRequest.bind(dispatcher);
|
||||
|
||||
register(requestId1, 'SystemController', abortController1, timeoutId1);
|
||||
register(requestId2, 'SystemController', abortController2, timeoutId2);
|
||||
|
||||
dispatcher.shutdown();
|
||||
|
||||
expect(abortSpy1).toHaveBeenCalled();
|
||||
expect(abortSpy2).toHaveBeenCalled();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
|
||||
});
|
||||
|
||||
it('should reject all pending outgoing requests', () => {
|
||||
const requestId1 = 'outgoing-shutdown-1';
|
||||
const requestId2 = 'outgoing-shutdown-2';
|
||||
|
||||
const reject1 = vi.fn();
|
||||
const reject2 = vi.fn();
|
||||
const timeoutId1 = setTimeout(() => {}, 1000);
|
||||
const timeoutId2 = setTimeout(() => {}, 1000);
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
const register = (
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest.bind(dispatcher);
|
||||
|
||||
register(requestId1, 'SystemController', vi.fn(), reject1, timeoutId1);
|
||||
register(requestId2, 'SystemController', vi.fn(), reject2, timeoutId2);
|
||||
|
||||
dispatcher.shutdown();
|
||||
|
||||
expect(reject1).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Dispatcher shutdown',
|
||||
}),
|
||||
);
|
||||
expect(reject2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Dispatcher shutdown',
|
||||
}),
|
||||
);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
|
||||
});
|
||||
|
||||
it('should cleanup all controllers', () => {
|
||||
vi.mocked(mockSystemController.cleanup).mockImplementation(() => {});
|
||||
|
||||
dispatcher.shutdown();
|
||||
|
||||
expect(mockSystemController.cleanup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log shutdown in debug mode', () => {
|
||||
const context = createMockContext(true);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const dispatcherWithDebug = new ControlDispatcher(context);
|
||||
|
||||
dispatcherWithDebug.shutdown();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[ControlDispatcher] Shutting down',
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending request registry', () => {
|
||||
describe('registerIncomingRequest', () => {
|
||||
it('should register incoming request', () => {
|
||||
const requestId = 'reg-incoming-1';
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerIncomingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
abortController,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
// Verify it was registered by trying to cancel it
|
||||
dispatcher.handleCancel(requestId);
|
||||
expect(abortController.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deregisterIncomingRequest', () => {
|
||||
it('should deregister incoming request', () => {
|
||||
const requestId = 'dereg-incoming-1';
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
deregisterIncomingRequest: (id: string) => void;
|
||||
}
|
||||
).registerIncomingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
abortController,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
deregisterIncomingRequest: (id: string) => void;
|
||||
}
|
||||
).deregisterIncomingRequest(requestId);
|
||||
|
||||
// Verify it was deregistered - cancel should not find it
|
||||
const sendMock = vi.mocked(mockContext.streamJson.send);
|
||||
const sendCallCount = sendMock.mock.calls.length;
|
||||
dispatcher.handleCancel(requestId);
|
||||
// Should not send cancel response for non-existent request
|
||||
expect(sendMock.mock.calls.length).toBe(sendCallCount);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
|
||||
});
|
||||
|
||||
it('should handle deregister of non-existent request gracefully', () => {
|
||||
expect(() => {
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
deregisterIncomingRequest: (id: string) => void;
|
||||
}
|
||||
).deregisterIncomingRequest('non-existent');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerOutgoingRequest', () => {
|
||||
it('should register outgoing request', () => {
|
||||
const requestId = 'reg-outgoing-1';
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
// Verify it was registered by handling a response
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
expect(resolve).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deregisterOutgoingRequest', () => {
|
||||
it('should deregister outgoing request', () => {
|
||||
const requestId = 'dereg-outgoing-1';
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
deregisterOutgoingRequest: (id: string) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
deregisterOutgoingRequest: (id: string) => void;
|
||||
}
|
||||
).deregisterOutgoingRequest(requestId);
|
||||
|
||||
// Verify it was deregistered - response should not find it
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
|
||||
});
|
||||
|
||||
it('should handle deregister of non-existent request gracefully', () => {
|
||||
expect(() => {
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
deregisterOutgoingRequest: (id: string) => void;
|
||||
}
|
||||
).deregisterOutgoingRequest('non-existent');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
353
packages/cli/src/nonInteractive/control/ControlDispatcher.ts
Normal file
353
packages/cli/src/nonInteractive/control/ControlDispatcher.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control Dispatcher
|
||||
*
|
||||
* Layer 2 of the control plane architecture. Routes control requests between
|
||||
* SDK and CLI to appropriate controllers, manages pending request registries,
|
||||
* and handles cancellation/cleanup. Application code MUST NOT depend on
|
||||
* controller instances exposed by this class; instead, use ControlService,
|
||||
* which wraps these controllers with a stable programmatic API.
|
||||
*
|
||||
* 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.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);
|
||||
} 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 {
|
||||
const errorMessage =
|
||||
typeof responsePayload.error === 'string'
|
||||
? responsePayload.error
|
||||
: (responsePayload.error?.message ?? 'Unknown error');
|
||||
pending.reject(new Error(errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
191
packages/cli/src/nonInteractive/control/ControlService.ts
Normal file
191
packages/cli/src/nonInteractive/control/ControlService.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control Service - Public Programmatic API
|
||||
*
|
||||
* Provides type-safe access to control plane functionality for internal
|
||||
* CLI code. This is the ONLY programmatic interface that should be used by:
|
||||
* - nonInteractiveCli
|
||||
* - Session managers
|
||||
* - Tool execution handlers
|
||||
* - Internal CLI logic
|
||||
*
|
||||
* DO NOT use ControlDispatcher or controllers directly from application code.
|
||||
*
|
||||
* Architecture:
|
||||
* - ControlContext stores shared session state (Layer 1)
|
||||
* - ControlDispatcher handles protocol-level routing (Layer 2)
|
||||
* - ControlService provides programmatic API for internal CLI usage (Layer 3)
|
||||
*
|
||||
* ControlService and ControlDispatcher share controller instances to ensure
|
||||
* a single source of truth. All higher level code MUST access the control
|
||||
* plane exclusively through ControlService.
|
||||
*/
|
||||
|
||||
import type { IControlContext } from './ControlContext.js';
|
||||
import type { ControlDispatcher } from './ControlDispatcher.js';
|
||||
import type {
|
||||
// PermissionServiceAPI,
|
||||
SystemServiceAPI,
|
||||
// McpServiceAPI,
|
||||
// HookServiceAPI,
|
||||
} from './types/serviceAPIs.js';
|
||||
|
||||
/**
|
||||
* Control Service
|
||||
*
|
||||
* Facade layer providing domain-grouped APIs for control plane operations.
|
||||
* Shares controller instances with ControlDispatcher to ensure single source
|
||||
* of truth and state consistency.
|
||||
*/
|
||||
export class ControlService {
|
||||
private dispatcher: ControlDispatcher;
|
||||
|
||||
/**
|
||||
* Construct ControlService
|
||||
*
|
||||
* @param context - Control context (unused directly, passed to dispatcher)
|
||||
* @param dispatcher - Control dispatcher that owns the controller instances
|
||||
*/
|
||||
constructor(context: IControlContext, dispatcher: ControlDispatcher) {
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission Domain API
|
||||
*
|
||||
* Handles tool execution permissions, approval checks, and callbacks.
|
||||
* Delegates to the shared PermissionController instance.
|
||||
*/
|
||||
// get permission(): PermissionServiceAPI {
|
||||
// const controller = this.dispatcher.permissionController;
|
||||
// return {
|
||||
// /**
|
||||
// * Check if a tool should be allowed based on current permission settings
|
||||
// *
|
||||
// * Evaluates permission mode and tool registry to determine if execution
|
||||
// * should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||
// *
|
||||
// * @param toolRequest - Tool call request information
|
||||
// * @param confirmationDetails - Optional confirmation details for UI
|
||||
// * @returns Permission decision with optional updated arguments
|
||||
// */
|
||||
// shouldAllowTool: controller.shouldAllowTool.bind(controller),
|
||||
//
|
||||
// /**
|
||||
// * Build UI suggestions for tool confirmation dialogs
|
||||
// *
|
||||
// * Creates actionable permission suggestions based on tool confirmation details.
|
||||
// *
|
||||
// * @param confirmationDetails - Tool confirmation details
|
||||
// * @returns Array of permission suggestions or null
|
||||
// */
|
||||
// buildPermissionSuggestions:
|
||||
// controller.buildPermissionSuggestions.bind(controller),
|
||||
//
|
||||
// /**
|
||||
// * Get callback for monitoring tool call status updates
|
||||
// *
|
||||
// * Returns callback function for integration with CoreToolScheduler.
|
||||
// *
|
||||
// * @returns Callback function for tool call updates
|
||||
// */
|
||||
// getToolCallUpdateCallback:
|
||||
// controller.getToolCallUpdateCallback.bind(controller),
|
||||
// };
|
||||
// }
|
||||
|
||||
/**
|
||||
* System Domain API
|
||||
*
|
||||
* Handles system-level operations and session management.
|
||||
* Delegates to the shared SystemController instance.
|
||||
*/
|
||||
get system(): SystemServiceAPI {
|
||||
const controller = this.dispatcher.systemController;
|
||||
return {
|
||||
/**
|
||||
* Get control capabilities
|
||||
*
|
||||
* Returns the control capabilities object indicating what control
|
||||
* features are available. Used exclusively for the initialize
|
||||
* control response. System messages do not include capabilities.
|
||||
*
|
||||
* @returns Control capabilities object
|
||||
*/
|
||||
getControlCapabilities: () => controller.buildControlCapabilities(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Domain API
|
||||
*
|
||||
* Handles Model Context Protocol server interactions.
|
||||
* Delegates to the shared MCPController instance.
|
||||
*/
|
||||
// get mcp(): McpServiceAPI {
|
||||
// return {
|
||||
// /**
|
||||
// * Get or create MCP client for a server (lazy initialization)
|
||||
// *
|
||||
// * Returns existing client or creates new connection.
|
||||
// *
|
||||
// * @param serverName - Name of the MCP server
|
||||
// * @returns Promise with client and config
|
||||
// */
|
||||
// getMcpClient: async (serverName: string) => {
|
||||
// // MCPController has a private method getOrCreateMcpClient
|
||||
// // We need to expose it via the API
|
||||
// // For now, throw error as placeholder
|
||||
// // The actual implementation will be added when we update MCPController
|
||||
// throw new Error(
|
||||
// `getMcpClient not yet implemented in ControlService. Server: ${serverName}`,
|
||||
// );
|
||||
// },
|
||||
//
|
||||
// /**
|
||||
// * List all available MCP servers
|
||||
// *
|
||||
// * Returns names of configured/connected MCP servers.
|
||||
// *
|
||||
// * @returns Array of server names
|
||||
// */
|
||||
// listServers: () => {
|
||||
// // Get servers from context
|
||||
// const sdkServers = Array.from(
|
||||
// this.dispatcher.mcpController['context'].sdkMcpServers,
|
||||
// );
|
||||
// const cliServers = Array.from(
|
||||
// this.dispatcher.mcpController['context'].mcpClients.keys(),
|
||||
// );
|
||||
// return [...new Set([...sdkServers, ...cliServers])];
|
||||
// },
|
||||
// };
|
||||
// }
|
||||
|
||||
/**
|
||||
* Hook Domain API
|
||||
*
|
||||
* Handles hook callback processing (placeholder for future expansion).
|
||||
* Delegates to the shared HookController instance.
|
||||
*/
|
||||
// get hook(): HookServiceAPI {
|
||||
// // HookController has no public methods yet - controller access reserved for future use
|
||||
// return {};
|
||||
// }
|
||||
|
||||
/**
|
||||
* Cleanup all controllers
|
||||
*
|
||||
* Should be called on session shutdown. Delegates to dispatcher's shutdown
|
||||
* method to ensure all controllers are properly cleaned up.
|
||||
*/
|
||||
cleanup(): void {
|
||||
// Delegate to dispatcher which manages controller cleanup
|
||||
this.dispatcher.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -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.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.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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.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,483 @@
|
||||
/**
|
||||
* @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 {
|
||||
InputFormat,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
CLIControlPermissionRequest,
|
||||
CLIControlSetPermissionModeRequest,
|
||||
ControlRequestPayload,
|
||||
PermissionMode,
|
||||
PermissionSuggestion,
|
||||
} from '../../types.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 === 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,215 @@
|
||||
/**
|
||||
* @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 type {
|
||||
ControlRequestPayload,
|
||||
CLIControlInitializeRequest,
|
||||
CLIControlSetModelRequest,
|
||||
} from '../../types.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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build control capabilities for initialize control response
|
||||
*
|
||||
* This method constructs the control capabilities object that indicates
|
||||
* what control features are available. It is used exclusively in the
|
||||
* initialize control response.
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
139
packages/cli/src/nonInteractive/control/types/serviceAPIs.ts
Normal file
139
packages/cli/src/nonInteractive/control/types/serviceAPIs.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Service API Types
|
||||
*
|
||||
* These interfaces define the public API contract for the ControlService facade.
|
||||
* They provide type-safe, domain-grouped access to control plane functionality
|
||||
* for internal CLI code (nonInteractiveCli, session managers, etc.).
|
||||
*/
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
MCPServerConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { PermissionSuggestion } from '../../types.js';
|
||||
|
||||
/**
|
||||
* Permission Service API
|
||||
*
|
||||
* Provides permission-related operations including tool execution approval,
|
||||
* permission suggestions, and tool call monitoring callbacks.
|
||||
*/
|
||||
export interface PermissionServiceAPI {
|
||||
/**
|
||||
* Check if a tool should be allowed based on current permission settings
|
||||
*
|
||||
* Evaluates permission mode and tool registry to determine if execution
|
||||
* should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||
*
|
||||
* @param toolRequest - Tool call request information containing name, args, and call ID
|
||||
* @param confirmationDetails - Optional confirmation details for UI-driven approvals
|
||||
* @returns Promise resolving to permission decision with optional updated arguments
|
||||
*/
|
||||
shouldAllowTool(
|
||||
toolRequest: ToolCallRequestInfo,
|
||||
confirmationDetails?: unknown,
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
updatedArgs?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Build UI suggestions for tool confirmation dialogs
|
||||
*
|
||||
* Creates actionable permission suggestions based on tool confirmation details,
|
||||
* helping host applications present appropriate approval/denial options.
|
||||
*
|
||||
* @param confirmationDetails - Tool confirmation details (type, title, metadata)
|
||||
* @returns Array of permission suggestions or null if details are invalid
|
||||
*/
|
||||
buildPermissionSuggestions(
|
||||
confirmationDetails: unknown,
|
||||
): PermissionSuggestion[] | null;
|
||||
|
||||
/**
|
||||
* Get callback for monitoring tool call status updates
|
||||
*
|
||||
* Returns a callback function that should be passed to executeToolCall
|
||||
* to enable integration with CoreToolScheduler updates. This callback
|
||||
* handles outgoing permission requests for tools awaiting approval.
|
||||
*
|
||||
* @returns Callback function that processes tool call updates
|
||||
*/
|
||||
getToolCallUpdateCallback(): (toolCalls: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* System Service API
|
||||
*
|
||||
* Provides system-level operations for the control system.
|
||||
*
|
||||
* Note: System messages and slash commands are NOT part of the control system API.
|
||||
* They are handled independently via buildSystemMessage() from nonInteractiveHelpers.ts,
|
||||
* regardless of whether the control system is available.
|
||||
*/
|
||||
export interface SystemServiceAPI {
|
||||
/**
|
||||
* Get control capabilities
|
||||
*
|
||||
* Returns the control capabilities object indicating what control
|
||||
* features are available. Used exclusively for the initialize control
|
||||
* response. System messages do not include capabilities as they are
|
||||
* independent of the control system.
|
||||
*
|
||||
* @returns Control capabilities object
|
||||
*/
|
||||
getControlCapabilities(): Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Service API
|
||||
*
|
||||
* Provides Model Context Protocol server interaction including
|
||||
* lazy client initialization and server discovery.
|
||||
*/
|
||||
export interface McpServiceAPI {
|
||||
/**
|
||||
* Get or create MCP client for a server (lazy initialization)
|
||||
*
|
||||
* Returns an existing client from cache or creates a new connection
|
||||
* if this is the first request for the server. Handles connection
|
||||
* lifecycle and error recovery.
|
||||
*
|
||||
* @param serverName - Name of the MCP server to connect to
|
||||
* @returns Promise resolving to client instance and server configuration
|
||||
* @throws Error if server is not configured or connection fails
|
||||
*/
|
||||
getMcpClient(serverName: string): Promise<{
|
||||
client: Client;
|
||||
config: MCPServerConfig;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* List all available MCP servers
|
||||
*
|
||||
* Returns names of both SDK-managed and CLI-managed MCP servers
|
||||
* that are currently configured or connected.
|
||||
*
|
||||
* @returns Array of server names
|
||||
*/
|
||||
listServers(): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook Service API
|
||||
*
|
||||
* Provides hook callback processing (placeholder for future expansion).
|
||||
*/
|
||||
export interface HookServiceAPI {
|
||||
// Future: Hook-related methods will be added here
|
||||
// For now, hook functionality is handled only via control requests
|
||||
registerHookCallback(callback: unknown): void;
|
||||
}
|
||||
1571
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts
Normal file
1571
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1228
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
1228
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
File diff suppressed because it is too large
Load Diff
791
packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts
Normal file
791
packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts
Normal file
@@ -0,0 +1,791 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type {
|
||||
Config,
|
||||
ServerGeminiStreamEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { GeminiEventType } from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { JsonOutputAdapter } from './JsonOutputAdapter.js';
|
||||
|
||||
function createMockConfig(): Config {
|
||||
return {
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('JsonOutputAdapter', () => {
|
||||
let adapter: JsonOutputAdapter;
|
||||
let mockConfig: Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let stdoutWriteSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = createMockConfig();
|
||||
adapter = new JsonOutputAdapter(mockConfig);
|
||||
stdoutWriteSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutWriteSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('startAssistantMessage', () => {
|
||||
it('should reset state for new message', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.startAssistantMessage(); // Start second message
|
||||
// Should not throw
|
||||
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEvent', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should append text content from Content events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const event2: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Content,
|
||||
value: ' World',
|
||||
};
|
||||
adapter.processEvent(event2);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Hello World',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append citation content from Citation events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Citation,
|
||||
value: 'Citation text',
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: expect.stringContaining('Citation text'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore non-string citation values', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Citation,
|
||||
value: 123,
|
||||
} as unknown as ServerGeminiStreamEvent;
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should append thinking from Thought events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
thinking: 'Planning: Thinking about the task',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle thinking with only subject', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: '',
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append tool use from ToolCallRequest events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
id: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
input: { param1: 'value1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains text blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Some text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains thinking blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool_1',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-2',
|
||||
name: 'test_tool_2',
|
||||
args: { param2: 'value2' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(2);
|
||||
expect(
|
||||
message.message.content.every((block) => block.type === 'tool_use'),
|
||||
).toBe(true);
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should update usage from Finished event', () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
cachedContentTokenCount: 10,
|
||||
totalTokenCount: 160,
|
||||
};
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: undefined,
|
||||
usageMetadata,
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.usage).toMatchObject({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 10,
|
||||
total_tokens: 160,
|
||||
});
|
||||
});
|
||||
|
||||
it('should finalize pending blocks on Finished event', () => {
|
||||
// Add some text first
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Some text',
|
||||
});
|
||||
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: undefined },
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
// Should not throw when finalizing
|
||||
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should ignore events after finalization', () => {
|
||||
adapter.finalizeAssistantMessage();
|
||||
const originalContent =
|
||||
adapter.finalizeAssistantMessage().message.content;
|
||||
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Should be ignored',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toEqual(originalContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeAssistantMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should build and emit a complete assistant message', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test response',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message.type).toBe('assistant');
|
||||
expect(message.uuid).toBeTruthy();
|
||||
expect(message.session_id).toBe('test-session-id');
|
||||
expect(message.parent_tool_use_id).toBeNull();
|
||||
expect(message.message.role).toBe('assistant');
|
||||
expect(message.message.model).toBe('test-model');
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return same message on subsequent calls', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
const message1 = adapter.finalizeAssistantMessage();
|
||||
const message2 = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message1).toEqual(message2);
|
||||
});
|
||||
|
||||
it('should split different block types into separate assistant messages', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: { subject: 'Thinking', description: 'Thought' },
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0].type).toBe('thinking');
|
||||
|
||||
const storedMessages = (adapter as unknown as { messages: unknown[] })
|
||||
.messages;
|
||||
const assistantMessages = storedMessages.filter(
|
||||
(
|
||||
msg,
|
||||
): msg is {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string }> };
|
||||
} => {
|
||||
if (
|
||||
typeof msg !== 'object' ||
|
||||
msg === null ||
|
||||
!('type' in msg) ||
|
||||
(msg as { type?: string }).type !== 'assistant' ||
|
||||
!('message' in msg)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const message = (msg as { message?: unknown }).message;
|
||||
return (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'content' in message &&
|
||||
Array.isArray((message as { content?: unknown }).content)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(assistantMessages).toHaveLength(2);
|
||||
for (const assistant of assistantMessages) {
|
||||
const uniqueTypes = new Set(
|
||||
assistant.message.content.map((block) => block.type),
|
||||
);
|
||||
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw if message not started', () => {
|
||||
adapter = new JsonOutputAdapter(mockConfig);
|
||||
expect(() => adapter.finalizeAssistantMessage()).toThrow(
|
||||
'Message not started',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitResult', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Response text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
});
|
||||
|
||||
it('should emit success result as JSON array', () => {
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage.subtype).toBe('success');
|
||||
expect(resultMessage.result).toBe('Response text');
|
||||
expect(resultMessage.duration_ms).toBe(1000);
|
||||
expect(resultMessage.num_turns).toBe(1);
|
||||
});
|
||||
|
||||
it('should emit error result', () => {
|
||||
adapter.emitResult({
|
||||
isError: true,
|
||||
errorMessage: 'Test error',
|
||||
durationMs: 500,
|
||||
apiDurationMs: 300,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.is_error).toBe(true);
|
||||
expect(resultMessage.subtype).toBe('error_during_execution');
|
||||
expect(resultMessage.error?.message).toBe('Test error');
|
||||
});
|
||||
|
||||
it('should use provided summary over extracted text', () => {
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
summary: 'Custom summary',
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.result).toBe('Custom summary');
|
||||
});
|
||||
|
||||
it('should include usage information', () => {
|
||||
const usage = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
total_tokens: 150,
|
||||
};
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
usage,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.usage).toEqual(usage);
|
||||
});
|
||||
|
||||
it('should include stats when provided', () => {
|
||||
const stats = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 5,
|
||||
totalSuccess: 4,
|
||||
totalFail: 1,
|
||||
totalDurationMs: 1000,
|
||||
totalDecisions: {
|
||||
accept: 3,
|
||||
reject: 1,
|
||||
modify: 0,
|
||||
auto_accept: 1,
|
||||
},
|
||||
byName: {},
|
||||
},
|
||||
files: {
|
||||
totalLinesAdded: 10,
|
||||
totalLinesRemoved: 5,
|
||||
},
|
||||
};
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
stats,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.stats).toEqual(stats);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUserMessage', () => {
|
||||
it('should add user message to collection', () => {
|
||||
const parts: Part[] = [{ text: 'Hello user' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const userMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user',
|
||||
);
|
||||
|
||||
expect(userMessage).toBeDefined();
|
||||
expect(Array.isArray(userMessage.message.content)).toBe(true);
|
||||
if (Array.isArray(userMessage.message.content)) {
|
||||
expect(userMessage.message.content).toHaveLength(1);
|
||||
expect(userMessage.message.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Hello user',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle parent_tool_use_id', () => {
|
||||
const parts: Part[] = [{ text: 'Tool response' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const userMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user',
|
||||
);
|
||||
|
||||
// emitUserMessage currently sets parent_tool_use_id to null
|
||||
expect(userMessage.parent_tool_use_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitToolResult', () => {
|
||||
it('should emit tool result message', () => {
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: 'Tool executed successfully',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const toolResult = parsed.find(
|
||||
(
|
||||
msg: unknown,
|
||||
): msg is { type: 'user'; message: { content: unknown[] } } =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user' &&
|
||||
'message' in msg &&
|
||||
typeof msg.message === 'object' &&
|
||||
msg.message !== null &&
|
||||
'content' in msg.message &&
|
||||
Array.isArray(msg.message.content) &&
|
||||
msg.message.content[0] &&
|
||||
typeof msg.message.content[0] === 'object' &&
|
||||
'type' in msg.message.content[0] &&
|
||||
msg.message.content[0].type === 'tool_result',
|
||||
);
|
||||
|
||||
expect(toolResult).toBeDefined();
|
||||
const block = toolResult.message.content[0] as {
|
||||
type: 'tool_result';
|
||||
tool_use_id: string;
|
||||
content?: string;
|
||||
is_error?: boolean;
|
||||
};
|
||||
expect(block).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-1',
|
||||
content: 'Tool executed successfully',
|
||||
is_error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark error tool results', () => {
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: undefined,
|
||||
error: new Error('Tool failed'),
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const toolResult = parsed.find(
|
||||
(
|
||||
msg: unknown,
|
||||
): msg is { type: 'user'; message: { content: unknown[] } } =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user' &&
|
||||
'message' in msg &&
|
||||
typeof msg.message === 'object' &&
|
||||
msg.message !== null &&
|
||||
'content' in msg.message &&
|
||||
Array.isArray(msg.message.content),
|
||||
);
|
||||
|
||||
const block = toolResult.message.content[0] as {
|
||||
is_error?: boolean;
|
||||
};
|
||||
expect(block.is_error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitSystemMessage', () => {
|
||||
it('should add system message to collection', () => {
|
||||
adapter.emitSystemMessage('test_subtype', { data: 'value' });
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const systemMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'system',
|
||||
);
|
||||
|
||||
expect(systemMessage).toBeDefined();
|
||||
expect(systemMessage.subtype).toBe('test_subtype');
|
||||
expect(systemMessage.data).toEqual({ data: 'value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionId and getModel', () => {
|
||||
it('should return session ID from config', () => {
|
||||
expect(adapter.getSessionId()).toBe('test-session-id');
|
||||
expect(mockConfig.getSessionId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return model from config', () => {
|
||||
expect(adapter.getModel()).toBe('test-model');
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple messages in collection', () => {
|
||||
it('should collect all messages and emit as array', () => {
|
||||
adapter.emitSystemMessage('init', {});
|
||||
adapter.emitUserMessage([{ text: 'User input' }]);
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Assistant response',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThanOrEqual(3);
|
||||
const systemMsg = parsed[0] as { type?: string };
|
||||
const userMsg = parsed[1] as { type?: string };
|
||||
expect(systemMsg.type).toBe('system');
|
||||
expect(userMsg.type).toBe('user');
|
||||
expect(
|
||||
parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
(msg as { type?: string }).type === 'assistant',
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
(msg as { type?: string }).type === 'result',
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
81
packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Normal file
81
packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { CLIAssistantMessage, CLIMessage } from '../types.js';
|
||||
import {
|
||||
BaseJsonOutputAdapter,
|
||||
type JsonOutputAdapterInterface,
|
||||
type ResultOptions,
|
||||
} from './BaseJsonOutputAdapter.js';
|
||||
|
||||
/**
|
||||
* JSON output adapter that collects all messages and emits them
|
||||
* as a single JSON array at the end of the turn.
|
||||
* Supports both main agent and subagent messages through distinct APIs.
|
||||
*/
|
||||
export class JsonOutputAdapter
|
||||
extends BaseJsonOutputAdapter
|
||||
implements JsonOutputAdapterInterface
|
||||
{
|
||||
private readonly messages: CLIMessage[] = [];
|
||||
|
||||
constructor(config: Config) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits message to the messages array (batch mode).
|
||||
* Tracks the last assistant message for efficient result text extraction.
|
||||
*/
|
||||
protected emitMessageImpl(message: CLIMessage): void {
|
||||
this.messages.push(message);
|
||||
// Track assistant messages for result generation
|
||||
if (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'type' in message &&
|
||||
message.type === 'assistant'
|
||||
) {
|
||||
this.updateLastAssistantMessage(message as CLIAssistantMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON mode does not emit stream events.
|
||||
*/
|
||||
protected shouldEmitStreamEvents(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||
const message = this.finalizeAssistantMessageInternal(
|
||||
this.mainAgentMessageState,
|
||||
null,
|
||||
);
|
||||
this.updateLastAssistantMessage(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
emitResult(options: ResultOptions): void {
|
||||
const resultMessage = this.buildResultMessage(
|
||||
options,
|
||||
this.lastAssistantMessage,
|
||||
);
|
||||
this.messages.push(resultMessage);
|
||||
|
||||
// Emit the entire messages array as JSON (includes all main agent + subagent messages)
|
||||
const json = JSON.stringify(this.messages);
|
||||
process.stdout.write(`${json}\n`);
|
||||
}
|
||||
|
||||
emitMessage(message: CLIMessage): void {
|
||||
// In JSON mode, messages are collected in the messages array
|
||||
// This is called by the base class's finalizeAssistantMessageInternal
|
||||
// but can also be called directly for user/tool/system messages
|
||||
this.messages.push(message);
|
||||
}
|
||||
}
|
||||
215
packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts
Normal file
215
packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
StreamJsonInputReader,
|
||||
StreamJsonParseError,
|
||||
type StreamJsonInputMessage,
|
||||
} from './StreamJsonInputReader.js';
|
||||
|
||||
describe('StreamJsonInputReader', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('read', () => {
|
||||
/**
|
||||
* Test parsing all supported message types in a single test
|
||||
*/
|
||||
it('should parse valid messages of all types', async () => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
const messages = [
|
||||
{
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello world' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
},
|
||||
{
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: { subtype: 'initialize' },
|
||||
},
|
||||
{
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-1',
|
||||
response: { initialized: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'control_cancel_request',
|
||||
request_id: 'req-1',
|
||||
},
|
||||
];
|
||||
|
||||
for (const msg of messages) {
|
||||
input.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
input.end();
|
||||
|
||||
const parsed: StreamJsonInputMessage[] = [];
|
||||
for await (const msg of reader.read()) {
|
||||
parsed.push(msg);
|
||||
}
|
||||
|
||||
expect(parsed).toHaveLength(messages.length);
|
||||
expect(parsed).toEqual(messages);
|
||||
});
|
||||
|
||||
it('should parse multiple messages', async () => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
const message1 = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: { subtype: 'initialize' },
|
||||
};
|
||||
|
||||
const message2 = {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
input.write(JSON.stringify(message1) + '\n');
|
||||
input.write(JSON.stringify(message2) + '\n');
|
||||
input.end();
|
||||
|
||||
const messages: StreamJsonInputMessage[] = [];
|
||||
for await (const msg of reader.read()) {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(message1);
|
||||
expect(messages[1]).toEqual(message2);
|
||||
});
|
||||
|
||||
it('should skip empty lines and trim whitespace', async () => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
const message = {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
input.write('\n');
|
||||
input.write(' ' + JSON.stringify(message) + ' \n');
|
||||
input.write(' \n');
|
||||
input.write('\t\n');
|
||||
input.end();
|
||||
|
||||
const messages: StreamJsonInputMessage[] = [];
|
||||
for await (const msg of reader.read()) {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual(message);
|
||||
});
|
||||
|
||||
/**
|
||||
* Consolidated error handling test cases
|
||||
*/
|
||||
it.each([
|
||||
{
|
||||
name: 'invalid JSON',
|
||||
input: '{"invalid": json}\n',
|
||||
expectedError: 'Failed to parse stream-json line',
|
||||
},
|
||||
{
|
||||
name: 'missing type field',
|
||||
input:
|
||||
JSON.stringify({ session_id: 'test-session', message: 'hello' }) +
|
||||
'\n',
|
||||
expectedError: 'Missing required "type" field',
|
||||
},
|
||||
{
|
||||
name: 'non-object value (string)',
|
||||
input: '"just a string"\n',
|
||||
expectedError: 'Parsed value is not an object',
|
||||
},
|
||||
{
|
||||
name: 'non-object value (null)',
|
||||
input: 'null\n',
|
||||
expectedError: 'Parsed value is not an object',
|
||||
},
|
||||
{
|
||||
name: 'array value',
|
||||
input: '[1, 2, 3]\n',
|
||||
expectedError: 'Missing required "type" field',
|
||||
},
|
||||
{
|
||||
name: 'type field not a string',
|
||||
input: JSON.stringify({ type: 123, session_id: 'test-session' }) + '\n',
|
||||
expectedError: 'Missing required "type" field',
|
||||
},
|
||||
])(
|
||||
'should throw StreamJsonParseError for $name',
|
||||
async ({ input: inputLine, expectedError }) => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
input.write(inputLine);
|
||||
input.end();
|
||||
|
||||
const messages: StreamJsonInputMessage[] = [];
|
||||
let error: unknown;
|
||||
|
||||
try {
|
||||
for await (const msg of reader.read()) {
|
||||
messages.push(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(messages).toHaveLength(0);
|
||||
expect(error).toBeInstanceOf(StreamJsonParseError);
|
||||
expect((error as StreamJsonParseError).message).toContain(
|
||||
expectedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('should use process.stdin as default input', () => {
|
||||
const reader = new StreamJsonInputReader();
|
||||
// Access private field for testing constructor default parameter
|
||||
expect((reader as unknown as { input: typeof process.stdin }).input).toBe(
|
||||
process.stdin,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided input stream', () => {
|
||||
const customInput = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(customInput);
|
||||
// Access private field for testing constructor parameter
|
||||
expect((reader as unknown as { input: typeof customInput }).input).toBe(
|
||||
customInput,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
73
packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts
Normal file
73
packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import type { Readable } from 'node:stream';
|
||||
import process from 'node:process';
|
||||
import type {
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
CLIMessage,
|
||||
ControlCancelRequest,
|
||||
} from '../types.js';
|
||||
|
||||
export type StreamJsonInputMessage =
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest;
|
||||
|
||||
export class StreamJsonParseError extends Error {}
|
||||
|
||||
export class StreamJsonInputReader {
|
||||
private readonly input: Readable;
|
||||
|
||||
constructor(input: Readable = process.stdin) {
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
async *read(): AsyncGenerator<StreamJsonInputMessage> {
|
||||
const rl = createInterface({
|
||||
input: this.input,
|
||||
crlfDelay: Number.POSITIVE_INFINITY,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const rawLine of rl) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield this.parse(line);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
private parse(line: string): StreamJsonInputMessage {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as StreamJsonInputMessage;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new StreamJsonParseError('Parsed value is not an object');
|
||||
}
|
||||
if (!('type' in parsed) || typeof parsed.type !== 'string') {
|
||||
throw new StreamJsonParseError('Missing required "type" field');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error instanceof StreamJsonParseError) {
|
||||
throw error;
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new StreamJsonParseError(
|
||||
`Failed to parse stream-json line: ${reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type {
|
||||
Config,
|
||||
ServerGeminiStreamEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { GeminiEventType } from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { StreamJsonOutputAdapter } from './StreamJsonOutputAdapter.js';
|
||||
|
||||
function createMockConfig(): Config {
|
||||
return {
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('StreamJsonOutputAdapter', () => {
|
||||
let adapter: StreamJsonOutputAdapter;
|
||||
let mockConfig: Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let stdoutWriteSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = createMockConfig();
|
||||
stdoutWriteSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutWriteSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('with partial messages enabled', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
||||
});
|
||||
|
||||
describe('startAssistantMessage', () => {
|
||||
it('should reset state for new message', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'First',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Second',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Second',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEvent with stream events', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should emit stream events for text deltas', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
|
||||
const deltaEventCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_delta'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(deltaEventCall).toBeDefined();
|
||||
const parsed = JSON.parse(deltaEventCall![0] as string);
|
||||
expect(parsed.event.type).toBe('content_block_delta');
|
||||
expect(parsed.event.delta).toMatchObject({
|
||||
type: 'text_delta',
|
||||
text: 'Hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit message_start event on first content', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'First',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const messageStartCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'message_start'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(messageStartCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should emit content_block_start for new blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const blockStartCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_start'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(blockStartCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should emit thinking delta events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking',
|
||||
},
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const deltaCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_delta' &&
|
||||
parsed.event.delta.type === 'thinking_delta'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(deltaCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should emit message_stop on finalization', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const messageStopCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'message_stop'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(messageStopCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with partial messages disabled', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should not emit stream events', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const streamEventCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return parsed.type === 'stream_event';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(streamEventCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should still emit final assistant message', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const assistantCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return parsed.type === 'assistant';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(assistantCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEvent', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should append text content from Content events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: ' World',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Hello World',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append citation content from Citation events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Citation,
|
||||
value: 'Citation text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: expect.stringContaining('Citation text'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore non-string citation values', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Citation,
|
||||
value: 123,
|
||||
} as unknown as ServerGeminiStreamEvent);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should append thinking from Thought events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
thinking: 'Planning: Thinking about the task',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle thinking with only subject', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: '',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append tool use from ToolCallRequest events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
id: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
input: { param1: 'value1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains text blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Some text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains thinking blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool_1',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-2',
|
||||
name: 'test_tool_2',
|
||||
args: { param2: 'value2' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(2);
|
||||
expect(
|
||||
message.message.content.every((block) => block.type === 'tool_use'),
|
||||
).toBe(true);
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should update usage from Finished event', () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
cachedContentTokenCount: 10,
|
||||
totalTokenCount: 160,
|
||||
};
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: undefined,
|
||||
usageMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.usage).toMatchObject({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 10,
|
||||
total_tokens: 160,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore events after finalization', () => {
|
||||
adapter.finalizeAssistantMessage();
|
||||
const originalContent =
|
||||
adapter.finalizeAssistantMessage().message.content;
|
||||
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Should be ignored',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toEqual(originalContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeAssistantMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should build and emit a complete assistant message', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test response',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message.type).toBe('assistant');
|
||||
expect(message.uuid).toBeTruthy();
|
||||
expect(message.session_id).toBe('test-session-id');
|
||||
expect(message.parent_tool_use_id).toBeNull();
|
||||
expect(message.message.role).toBe('assistant');
|
||||
expect(message.message.model).toBe('test-model');
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should emit message to stdout immediately', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed.type).toBe('assistant');
|
||||
});
|
||||
|
||||
it('should store message in lastAssistantMessage', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
// Access protected property for testing
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((adapter as any).lastAssistantMessage).toEqual(message);
|
||||
});
|
||||
|
||||
it('should return same message on subsequent calls', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
const message1 = adapter.finalizeAssistantMessage();
|
||||
const message2 = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message1).toEqual(message2);
|
||||
});
|
||||
|
||||
it('should split different block types into separate assistant messages', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: { subject: 'Thinking', description: 'Thought' },
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0].type).toBe('thinking');
|
||||
|
||||
const assistantMessages = stdoutWriteSpy.mock.calls
|
||||
.map((call: unknown[]) => JSON.parse(call[0] as string))
|
||||
.filter(
|
||||
(
|
||||
payload: unknown,
|
||||
): payload is {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string }> };
|
||||
} => {
|
||||
if (
|
||||
typeof payload !== 'object' ||
|
||||
payload === null ||
|
||||
!('type' in payload) ||
|
||||
(payload as { type?: string }).type !== 'assistant' ||
|
||||
!('message' in payload)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const message = (payload as { message?: unknown }).message;
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
message === null ||
|
||||
!('content' in message)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
return (
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every(
|
||||
(block: unknown) =>
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block,
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(assistantMessages).toHaveLength(2);
|
||||
const observedTypes = assistantMessages.map(
|
||||
(payload: {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string }> };
|
||||
}) => payload.message.content[0]?.type ?? '',
|
||||
);
|
||||
expect(observedTypes).toEqual(['text', 'thinking']);
|
||||
for (const payload of assistantMessages) {
|
||||
const uniqueTypes = new Set(
|
||||
payload.message.content.map((block: { type: string }) => block.type),
|
||||
);
|
||||
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw if message not started', () => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
expect(() => adapter.finalizeAssistantMessage()).toThrow(
|
||||
'Message not started',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitResult', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Response text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
});
|
||||
|
||||
it('should emit success result immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('result');
|
||||
expect(parsed.is_error).toBe(false);
|
||||
expect(parsed.subtype).toBe('success');
|
||||
expect(parsed.result).toBe('Response text');
|
||||
expect(parsed.duration_ms).toBe(1000);
|
||||
expect(parsed.num_turns).toBe(1);
|
||||
});
|
||||
|
||||
it('should emit error result', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: true,
|
||||
errorMessage: 'Test error',
|
||||
durationMs: 500,
|
||||
apiDurationMs: 300,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.is_error).toBe(true);
|
||||
expect(parsed.subtype).toBe('error_during_execution');
|
||||
expect(parsed.error?.message).toBe('Test error');
|
||||
});
|
||||
|
||||
it('should use provided summary over extracted text', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
summary: 'Custom summary',
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.result).toBe('Custom summary');
|
||||
});
|
||||
|
||||
it('should include usage information', () => {
|
||||
const usage = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
total_tokens: 150,
|
||||
};
|
||||
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
usage,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.usage).toEqual(usage);
|
||||
});
|
||||
|
||||
it('should handle result without assistant message', () => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUserMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should emit user message immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
const parts: Part[] = [{ text: 'Hello user' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('user');
|
||||
expect(Array.isArray(parsed.message.content)).toBe(true);
|
||||
if (Array.isArray(parsed.message.content)) {
|
||||
expect(parsed.message.content).toHaveLength(1);
|
||||
expect(parsed.message.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Hello user',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle parent_tool_use_id', () => {
|
||||
const parts: Part[] = [{ text: 'Tool response' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
// emitUserMessage currently sets parent_tool_use_id to null
|
||||
expect(parsed.parent_tool_use_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitToolResult', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should emit tool result message immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: 'Tool executed successfully',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('user');
|
||||
expect(parsed.parent_tool_use_id).toBeNull();
|
||||
const block = parsed.message.content[0];
|
||||
expect(block).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-1',
|
||||
content: 'Tool executed successfully',
|
||||
is_error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark error tool results', () => {
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: undefined,
|
||||
error: new Error('Tool failed'),
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
const block = parsed.message.content[0];
|
||||
expect(block.is_error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitSystemMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should emit system message immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitSystemMessage('test_subtype', { data: 'value' });
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('system');
|
||||
expect(parsed.subtype).toBe('test_subtype');
|
||||
expect(parsed.data).toEqual({ data: 'value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionId and getModel', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should return session ID from config', () => {
|
||||
expect(adapter.getSessionId()).toBe('test-session-id');
|
||||
expect(mockConfig.getSessionId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return model from config', () => {
|
||||
expect(adapter.getModel()).toBe('test-model');
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message_id in stream events', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should include message_id in stream events after message starts', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
// Process another event to ensure messageStarted is true
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'More',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
// Find all delta events
|
||||
const deltaCalls = calls.filter((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_delta'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(deltaCalls.length).toBeGreaterThan(0);
|
||||
// The second delta event should have message_id (after messageStarted becomes true)
|
||||
// message_id is added to the event object, so check parsed.event.message_id
|
||||
if (deltaCalls.length > 1) {
|
||||
const secondDelta = JSON.parse(
|
||||
(deltaCalls[1] as unknown[])[0] as string,
|
||||
);
|
||||
// message_id is on the enriched event object
|
||||
expect(
|
||||
secondDelta.event.message_id || secondDelta.message_id,
|
||||
).toBeTruthy();
|
||||
} else {
|
||||
// If only one delta, check if message_id exists
|
||||
const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string);
|
||||
// message_id is added when messageStarted is true
|
||||
// First event may or may not have it, but subsequent ones should
|
||||
expect(delta.event.message_id || delta.message_id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple text blocks', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should split assistant messages when block types change repeatedly', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text content',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: { subject: 'Thinking', description: 'Thought' },
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'More text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'More text',
|
||||
});
|
||||
|
||||
const assistantMessages = stdoutWriteSpy.mock.calls
|
||||
.map((call: unknown[]) => JSON.parse(call[0] as string))
|
||||
.filter(
|
||||
(
|
||||
payload: unknown,
|
||||
): payload is {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string; text?: string }> };
|
||||
} => {
|
||||
if (
|
||||
typeof payload !== 'object' ||
|
||||
payload === null ||
|
||||
!('type' in payload) ||
|
||||
(payload as { type?: string }).type !== 'assistant' ||
|
||||
!('message' in payload)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const message = (payload as { message?: unknown }).message;
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
message === null ||
|
||||
!('content' in message)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
return (
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every(
|
||||
(block: unknown) =>
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block,
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(assistantMessages).toHaveLength(3);
|
||||
const observedTypes = assistantMessages.map(
|
||||
(msg: {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string; text?: string }> };
|
||||
}) => msg.message.content[0]?.type ?? '',
|
||||
);
|
||||
expect(observedTypes).toEqual(['text', 'thinking', 'text']);
|
||||
for (const msg of assistantMessages) {
|
||||
const uniqueTypes = new Set(
|
||||
msg.message.content.map((block: { type: string }) => block.type),
|
||||
);
|
||||
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should merge consecutive text fragments', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: ' ',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'World',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Hello World',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
300
packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts
Normal file
300
packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
CLIAssistantMessage,
|
||||
CLIMessage,
|
||||
CLIPartialAssistantMessage,
|
||||
ControlMessage,
|
||||
StreamEvent,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolUseBlock,
|
||||
} from '../types.js';
|
||||
import {
|
||||
BaseJsonOutputAdapter,
|
||||
type MessageState,
|
||||
type ResultOptions,
|
||||
type JsonOutputAdapterInterface,
|
||||
} from './BaseJsonOutputAdapter.js';
|
||||
|
||||
/**
|
||||
* Stream JSON output adapter that emits messages immediately
|
||||
* as they are completed during the streaming process.
|
||||
* Supports both main agent and subagent messages through distinct APIs.
|
||||
*/
|
||||
export class StreamJsonOutputAdapter
|
||||
extends BaseJsonOutputAdapter
|
||||
implements JsonOutputAdapterInterface
|
||||
{
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly includePartialMessages: boolean,
|
||||
) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits message immediately to stdout (stream mode).
|
||||
*/
|
||||
protected emitMessageImpl(message: CLIMessage | ControlMessage): void {
|
||||
// Track assistant messages for result generation
|
||||
if (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'type' in message &&
|
||||
message.type === 'assistant'
|
||||
) {
|
||||
this.updateLastAssistantMessage(message as CLIAssistantMessage);
|
||||
}
|
||||
|
||||
// Emit messages immediately in stream mode
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream mode emits stream events when includePartialMessages is enabled.
|
||||
*/
|
||||
protected shouldEmitStreamEvents(): boolean {
|
||||
return this.includePartialMessages;
|
||||
}
|
||||
|
||||
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||
const state = this.mainAgentMessageState;
|
||||
if (state.finalized) {
|
||||
return this.buildMessage(null);
|
||||
}
|
||||
state.finalized = true;
|
||||
|
||||
this.finalizePendingBlocks(state, null);
|
||||
const orderedOpenBlocks = Array.from(state.openBlocks).sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
for (const index of orderedOpenBlocks) {
|
||||
this.onBlockClosed(state, index, null);
|
||||
this.closeBlock(state, index);
|
||||
}
|
||||
|
||||
if (state.messageStarted && this.includePartialMessages) {
|
||||
this.emitStreamEventIfEnabled({ type: 'message_stop' }, null);
|
||||
}
|
||||
|
||||
const message = this.buildMessage(null);
|
||||
this.updateLastAssistantMessage(message);
|
||||
this.emitMessageImpl(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
emitResult(options: ResultOptions): void {
|
||||
const resultMessage = this.buildResultMessage(
|
||||
options,
|
||||
this.lastAssistantMessage,
|
||||
);
|
||||
this.emitMessageImpl(resultMessage);
|
||||
}
|
||||
|
||||
emitMessage(message: CLIMessage | ControlMessage): void {
|
||||
// In stream mode, emit immediately
|
||||
this.emitMessageImpl(message);
|
||||
}
|
||||
|
||||
send(message: CLIMessage | ControlMessage): void {
|
||||
this.emitMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when text block is created.
|
||||
*/
|
||||
protected override onTextBlockCreated(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
block: TextBlock,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: block,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when text is appended.
|
||||
*/
|
||||
protected override onTextAppended(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
fragment: string,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: { type: 'text_delta', text: fragment },
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when thinking block is created.
|
||||
*/
|
||||
protected override onThinkingBlockCreated(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
block: ThinkingBlock,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: block,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when thinking is appended.
|
||||
*/
|
||||
protected override onThinkingAppended(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
fragment: string,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: { type: 'thinking_delta', thinking: fragment },
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when tool_use block is created.
|
||||
*/
|
||||
protected override onToolUseBlockCreated(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
block: ToolUseBlock,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: block,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when tool_use input is set.
|
||||
*/
|
||||
protected override onToolUseInputSet(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
input: unknown,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: JSON.stringify(input),
|
||||
},
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when block is closed.
|
||||
*/
|
||||
protected override onBlockClosed(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
if (this.includePartialMessages) {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_stop',
|
||||
index,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit message_start event when message is started.
|
||||
* Only emits for main agent, not for subagents.
|
||||
*/
|
||||
protected override onEnsureMessageStarted(
|
||||
state: MessageState,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
// Only emit message_start for main agent, not for subagents
|
||||
if (parentToolUseId === null) {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: state.messageId!,
|
||||
role: 'assistant',
|
||||
model: this.config.getModel(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits stream events when partial messages are enabled.
|
||||
* This is a private method specific to StreamJsonOutputAdapter.
|
||||
* @param event - Stream event to emit
|
||||
* @param parentToolUseId - null for main agent, string for subagent
|
||||
*/
|
||||
private emitStreamEventIfEnabled(
|
||||
event: StreamEvent,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
if (!this.includePartialMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.getMessageState(parentToolUseId);
|
||||
const enrichedEvent = state.messageStarted
|
||||
? ({ ...event, message_id: state.messageId } as StreamEvent & {
|
||||
message_id: string;
|
||||
})
|
||||
: event;
|
||||
|
||||
const partial: CLIPartialAssistantMessage = {
|
||||
type: 'stream_event',
|
||||
uuid: randomUUID(),
|
||||
session_id: this.getSessionId(),
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
event: enrichedEvent,
|
||||
};
|
||||
this.emitMessageImpl(partial);
|
||||
}
|
||||
}
|
||||
591
packages/cli/src/nonInteractive/session.test.ts
Normal file
591
packages/cli/src/nonInteractive/session.test.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { runNonInteractiveStreamJson } from './session.js';
|
||||
import type {
|
||||
CLIUserMessage,
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlCancelRequest,
|
||||
} from './types.js';
|
||||
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
|
||||
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
|
||||
import { ControlDispatcher } from './control/ControlDispatcher.js';
|
||||
import { ControlContext } from './control/ControlContext.js';
|
||||
import { ControlService } from './control/ControlService.js';
|
||||
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
||||
|
||||
const runNonInteractiveMock = vi.fn();
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../nonInteractiveCli.js', () => ({
|
||||
runNonInteractive: (...args: unknown[]) => runNonInteractiveMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./io/StreamJsonInputReader.js', () => ({
|
||||
StreamJsonInputReader: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./io/StreamJsonOutputAdapter.js', () => ({
|
||||
StreamJsonOutputAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./control/ControlDispatcher.js', () => ({
|
||||
ControlDispatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./control/ControlContext.js', () => ({
|
||||
ControlContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./control/ControlService.js', () => ({
|
||||
ControlService: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/utils/ConsolePatcher.js', () => ({
|
||||
ConsolePatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
interface ConfigOverrides {
|
||||
getSessionId?: () => string;
|
||||
getModel?: () => string;
|
||||
getIncludePartialMessages?: () => boolean;
|
||||
getDebugMode?: () => boolean;
|
||||
getApprovalMode?: () => string;
|
||||
getOutputFormat?: () => string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function createConfig(overrides: ConfigOverrides = {}): Config {
|
||||
const base = {
|
||||
getSessionId: () => 'test-session',
|
||||
getModel: () => 'test-model',
|
||||
getIncludePartialMessages: () => false,
|
||||
getDebugMode: () => false,
|
||||
getApprovalMode: () => 'auto',
|
||||
getOutputFormat: () => 'stream-json',
|
||||
};
|
||||
return { ...base, ...overrides } as unknown as Config;
|
||||
}
|
||||
|
||||
function createUserMessage(content: string): CLIUserMessage {
|
||||
return {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createControlRequest(
|
||||
subtype: 'initialize' | 'set_model' | 'interrupt' = 'initialize',
|
||||
): CLIControlRequest {
|
||||
if (subtype === 'set_model') {
|
||||
return {
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: {
|
||||
subtype: 'set_model',
|
||||
model: 'test-model',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (subtype === 'interrupt') {
|
||||
return {
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: {
|
||||
subtype: 'interrupt',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: {
|
||||
subtype: 'initialize',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createControlResponse(requestId: string): CLIControlResponse {
|
||||
return {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createControlCancel(requestId: string): ControlCancelRequest {
|
||||
return {
|
||||
type: 'control_cancel_request',
|
||||
request_id: requestId,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runNonInteractiveStreamJson', () => {
|
||||
let config: Config;
|
||||
let mockInputReader: {
|
||||
read: () => AsyncGenerator<
|
||||
| CLIUserMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest
|
||||
>;
|
||||
};
|
||||
let mockOutputAdapter: {
|
||||
emitResult: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockDispatcher: {
|
||||
dispatch: ReturnType<typeof vi.fn>;
|
||||
handleControlResponse: ReturnType<typeof vi.fn>;
|
||||
handleCancel: ReturnType<typeof vi.fn>;
|
||||
shutdown: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockConsolePatcher: {
|
||||
patch: ReturnType<typeof vi.fn>;
|
||||
cleanup: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
config = createConfig();
|
||||
runNonInteractiveMock.mockReset();
|
||||
|
||||
// Setup mocks
|
||||
mockConsolePatcher = {
|
||||
patch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
(ConsolePatcher as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => mockConsolePatcher,
|
||||
);
|
||||
|
||||
mockOutputAdapter = {
|
||||
emitResult: vi.fn(),
|
||||
} as {
|
||||
emitResult: ReturnType<typeof vi.fn>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
(
|
||||
StreamJsonOutputAdapter as unknown as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(() => mockOutputAdapter);
|
||||
|
||||
mockDispatcher = {
|
||||
dispatch: vi.fn().mockResolvedValue(undefined),
|
||||
handleControlResponse: vi.fn(),
|
||||
handleCancel: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
};
|
||||
(
|
||||
ControlDispatcher as unknown as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(() => mockDispatcher);
|
||||
(ControlContext as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => ({}),
|
||||
);
|
||||
(ControlService as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => ({}),
|
||||
);
|
||||
|
||||
mockInputReader = {
|
||||
async *read() {
|
||||
// Default: empty stream
|
||||
// Override in tests as needed
|
||||
},
|
||||
};
|
||||
(
|
||||
StreamJsonInputReader as unknown as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(() => mockInputReader);
|
||||
|
||||
runNonInteractiveMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('initializes session and processes initialize control request', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
|
||||
expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('processes user message when received as first message', async () => {
|
||||
const userMessage = createUserMessage('Hello world');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
|
||||
const runCall = runNonInteractiveMock.mock.calls[0];
|
||||
expect(runCall[2]).toBe('Hello world'); // Direct text, not processed
|
||||
expect(typeof runCall[3]).toBe('string'); // promptId
|
||||
expect(runCall[4]).toEqual(
|
||||
expect.objectContaining({
|
||||
abortController: expect.any(AbortController),
|
||||
adapter: mockOutputAdapter,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('processes multiple user messages sequentially', async () => {
|
||||
// Initialize first to enable multi-query mode
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage1 = createUserMessage('First message');
|
||||
const userMessage2 = createUserMessage('Second message');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield userMessage1;
|
||||
yield userMessage2;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('enqueues user messages received during processing', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage1 = createUserMessage('First message');
|
||||
const userMessage2 = createUserMessage('Second message');
|
||||
|
||||
// Make runNonInteractive take some time to simulate processing
|
||||
runNonInteractiveMock.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 10)),
|
||||
);
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield userMessage1;
|
||||
yield userMessage2;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
// Both messages should be processed
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('processes control request in idle state', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const controlRequest = createControlRequest('set_model');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield controlRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(1, initRequest);
|
||||
expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(2, controlRequest);
|
||||
});
|
||||
|
||||
it('handles control response in idle state', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const controlResponse = createControlResponse('req-2');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield controlResponse;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
|
||||
controlResponse,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles control cancel in idle state', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const cancelRequest = createControlCancel('req-2');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield cancelRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.handleCancel).toHaveBeenCalledWith('req-2');
|
||||
});
|
||||
|
||||
it('handles control request during processing state', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage = createUserMessage('Process me');
|
||||
const controlRequest = createControlRequest('set_model');
|
||||
|
||||
runNonInteractiveMock.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 10)),
|
||||
);
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield userMessage;
|
||||
yield controlRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(controlRequest);
|
||||
});
|
||||
|
||||
it('handles control response during processing state', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage = createUserMessage('Process me');
|
||||
const controlResponse = createControlResponse('req-1');
|
||||
|
||||
runNonInteractiveMock.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 10)),
|
||||
);
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield userMessage;
|
||||
yield controlResponse;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
|
||||
controlResponse,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles user message with text content', async () => {
|
||||
const userMessage = createUserMessage('Test message');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.objectContaining({ merged: expect.any(Object) }),
|
||||
'Test message',
|
||||
expect.stringContaining('test-session'),
|
||||
expect.objectContaining({
|
||||
abortController: expect.any(AbortController),
|
||||
adapter: mockOutputAdapter,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles user message with array content blocks', async () => {
|
||||
const userMessage: CLIUserMessage = {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'First part' },
|
||||
{ type: 'text', text: 'Second part' },
|
||||
],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.objectContaining({ merged: expect.any(Object) }),
|
||||
'First part\nSecond part',
|
||||
expect.stringContaining('test-session'),
|
||||
expect.objectContaining({
|
||||
abortController: expect.any(AbortController),
|
||||
adapter: mockOutputAdapter,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips user message with no text content', async () => {
|
||||
const userMessage: CLIUserMessage = {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles error from processUserMessage', async () => {
|
||||
const userMessage = createUserMessage('Test message');
|
||||
|
||||
const error = new Error('Processing error');
|
||||
runNonInteractiveMock.mockRejectedValue(error);
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
// Error should be caught and handled gracefully
|
||||
});
|
||||
|
||||
it('handles stream error gracefully', async () => {
|
||||
const streamError = new Error('Stream error');
|
||||
// eslint-disable-next-line require-yield
|
||||
mockInputReader.read = async function* () {
|
||||
throw streamError;
|
||||
} as typeof mockInputReader.read;
|
||||
|
||||
await expect(runNonInteractiveStreamJson(config, '')).rejects.toThrow(
|
||||
'Stream error',
|
||||
);
|
||||
|
||||
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops processing when abort signal is triggered', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage = createUserMessage('Test message');
|
||||
|
||||
// Capture abort signal from ControlContext
|
||||
let abortSignal: AbortSignal | null = null;
|
||||
(ControlContext as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(options: { abortSignal?: AbortSignal }) => {
|
||||
abortSignal = options.abortSignal ?? null;
|
||||
return {};
|
||||
},
|
||||
);
|
||||
|
||||
// Create input reader that aborts after first message
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
// Abort the signal after initialization
|
||||
if (abortSignal && !abortSignal.aborted) {
|
||||
// The signal doesn't have an abort method, but the controller does
|
||||
// Since we can't access the controller directly, we'll test by
|
||||
// verifying that cleanup happens properly
|
||||
}
|
||||
// Yield second message - if abort works, it should be checked
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
// Verify initialization happened
|
||||
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
|
||||
expect(mockDispatcher.shutdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates unique prompt IDs for each message', async () => {
|
||||
// Initialize first to enable multi-query mode
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage1 = createUserMessage('First');
|
||||
const userMessage2 = createUserMessage('Second');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield userMessage1;
|
||||
yield userMessage2;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||
const promptId1 = runNonInteractiveMock.mock.calls[0][3] as string;
|
||||
const promptId2 = runNonInteractiveMock.mock.calls[1][3] as string;
|
||||
expect(promptId1).not.toBe(promptId2);
|
||||
expect(promptId1).toContain('test-session');
|
||||
expect(promptId2).toContain('test-session');
|
||||
});
|
||||
|
||||
it('ignores non-initialize control request during initialization', async () => {
|
||||
const controlRequest = createControlRequest('set_model');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield controlRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
// Should not transition to idle since it's not an initialize request
|
||||
expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cleans up console patcher on completion', async () => {
|
||||
mockInputReader.read = async function* () {
|
||||
// Empty stream - should complete immediately
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
|
||||
expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up output adapter on completion', async () => {
|
||||
mockInputReader.read = async function* () {
|
||||
// Empty stream
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
});
|
||||
|
||||
it('calls dispatcher shutdown on completion', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.shutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles empty stream gracefully', async () => {
|
||||
mockInputReader.read = async function* () {
|
||||
// Empty stream
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
721
packages/cli/src/nonInteractive/session.ts
Normal file
721
packages/cli/src/nonInteractive/session.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* @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 } from '@qwen-code/qwen-code-core';
|
||||
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
|
||||
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
|
||||
import { ControlContext } from './control/ControlContext.js';
|
||||
import { ControlDispatcher } from './control/ControlDispatcher.js';
|
||||
import { ControlService } from './control/ControlService.js';
|
||||
import type {
|
||||
CLIMessage,
|
||||
CLIUserMessage,
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlCancelRequest,
|
||||
} from './types.js';
|
||||
import {
|
||||
isCLIUserMessage,
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
} from './types.js';
|
||||
import { createMinimalSettings } from '../config/settings.js';
|
||||
import { runNonInteractive } from '../nonInteractiveCli.js';
|
||||
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
||||
|
||||
const SESSION_STATE = {
|
||||
INITIALIZING: 'initializing',
|
||||
IDLE: 'idle',
|
||||
PROCESSING_QUERY: 'processing_query',
|
||||
SHUTTING_DOWN: 'shutting_down',
|
||||
} as const;
|
||||
|
||||
type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE];
|
||||
|
||||
/**
|
||||
* Message type classification for routing
|
||||
*/
|
||||
type MessageType =
|
||||
| 'control_request'
|
||||
| 'control_response'
|
||||
| 'control_cancel'
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'system'
|
||||
| 'result'
|
||||
| 'stream_event'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Routed message with classification
|
||||
*/
|
||||
interface RoutedMessage {
|
||||
type: MessageType;
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session Manager
|
||||
*
|
||||
* Manages the session lifecycle and message processing state machine.
|
||||
*/
|
||||
class SessionManager {
|
||||
private state: SessionState = SESSION_STATE.INITIALIZING;
|
||||
private userMessageQueue: CLIUserMessage[] = [];
|
||||
private abortController: AbortController;
|
||||
private config: Config;
|
||||
private sessionId: string;
|
||||
private promptIdCounter: number = 0;
|
||||
private inputReader: StreamJsonInputReader;
|
||||
private outputAdapter: StreamJsonOutputAdapter;
|
||||
private controlContext: ControlContext | null = null;
|
||||
private dispatcher: ControlDispatcher | null = null;
|
||||
private controlService: ControlService | null = null;
|
||||
private controlSystemEnabled: boolean | null = null;
|
||||
private debugMode: boolean;
|
||||
private shutdownHandler: (() => void) | null = null;
|
||||
private initialPrompt: CLIUserMessage | null = null;
|
||||
|
||||
constructor(config: Config, initialPrompt?: CLIUserMessage) {
|
||||
this.config = config;
|
||||
this.sessionId = config.getSessionId();
|
||||
this.debugMode = config.getDebugMode();
|
||||
this.abortController = new AbortController();
|
||||
this.initialPrompt = initialPrompt ?? null;
|
||||
|
||||
this.inputReader = new StreamJsonInputReader();
|
||||
this.outputAdapter = new StreamJsonOutputAdapter(
|
||||
config,
|
||||
config.getIncludePartialMessages(),
|
||||
);
|
||||
|
||||
// Setup signal handlers for graceful shutdown
|
||||
this.setupSignalHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next prompt ID
|
||||
*/
|
||||
private getNextPromptId(): string {
|
||||
this.promptIdCounter++;
|
||||
return `${this.sessionId}########${this.promptIdCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a message to the appropriate handler based on its type
|
||||
*
|
||||
* Classifies incoming messages and routes them to appropriate handlers.
|
||||
*/
|
||||
private route(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): RoutedMessage {
|
||||
// Check control messages first
|
||||
if (isControlRequest(message)) {
|
||||
return { type: 'control_request', message };
|
||||
}
|
||||
if (isControlResponse(message)) {
|
||||
return { type: 'control_response', message };
|
||||
}
|
||||
if (isControlCancel(message)) {
|
||||
return { type: 'control_cancel', message };
|
||||
}
|
||||
|
||||
// Check data messages
|
||||
if (isCLIUserMessage(message)) {
|
||||
return { type: 'user', message };
|
||||
}
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
return { type: 'assistant', message };
|
||||
}
|
||||
if (isCLISystemMessage(message)) {
|
||||
return { type: 'system', message };
|
||||
}
|
||||
if (isCLIResultMessage(message)) {
|
||||
return { type: 'result', message };
|
||||
}
|
||||
if (isCLIPartialAssistantMessage(message)) {
|
||||
return { type: 'stream_event', message };
|
||||
}
|
||||
|
||||
// Unknown message type
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Unknown message type:',
|
||||
JSON.stringify(message, null, 2),
|
||||
);
|
||||
}
|
||||
return { type: 'unknown', message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single message with unified logic for both initial prompt and stream messages.
|
||||
*
|
||||
* Handles:
|
||||
* - Abort check
|
||||
* - First message detection and handling
|
||||
* - Normal message processing
|
||||
* - Shutdown state checks
|
||||
*
|
||||
* @param message - Message to process
|
||||
* @returns true if the calling code should exit (break/return), false to continue
|
||||
*/
|
||||
private async processSingleMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<boolean> {
|
||||
// Check for abort
|
||||
if (this.abortController.signal.aborted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle first message if control system not yet initialized
|
||||
if (this.controlSystemEnabled === null) {
|
||||
const handled = await this.handleFirstMessage(message);
|
||||
if (handled) {
|
||||
// If handled, check if we should shutdown
|
||||
return this.state === SESSION_STATE.SHUTTING_DOWN;
|
||||
}
|
||||
// If not handled, fall through to normal processing
|
||||
}
|
||||
|
||||
// Process message normally
|
||||
await this.processMessage(message);
|
||||
|
||||
// Check for shutdown after processing
|
||||
return this.state === SESSION_STATE.SHUTTING_DOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point - run the session
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Starting session', this.sessionId);
|
||||
}
|
||||
|
||||
// Process initial prompt if provided
|
||||
if (this.initialPrompt !== null) {
|
||||
const shouldExit = await this.processSingleMessage(this.initialPrompt);
|
||||
if (shouldExit) {
|
||||
await this.shutdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Process messages from stream
|
||||
for await (const message of this.inputReader.read()) {
|
||||
const shouldExit = await this.processSingleMessage(message);
|
||||
if (shouldExit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream closed, shutdown
|
||||
await this.shutdown();
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Error:', error);
|
||||
}
|
||||
await this.shutdown();
|
||||
throw error;
|
||||
} finally {
|
||||
// Ensure signal handlers are always cleaned up even if shutdown wasn't called
|
||||
this.cleanupSignalHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
private ensureControlSystem(): void {
|
||||
if (this.controlContext && this.dispatcher && this.controlService) {
|
||||
return;
|
||||
}
|
||||
// The control system follows a strict three-layer architecture:
|
||||
// 1. ControlContext (shared session state)
|
||||
// 2. ControlDispatcher (protocol routing SDK ↔ CLI)
|
||||
// 3. ControlService (programmatic API for CLI runtime)
|
||||
//
|
||||
// Application code MUST interact with the control plane exclusively through
|
||||
// ControlService. ControlDispatcher is reserved for protocol-level message
|
||||
// routing and should never be used directly outside of this file.
|
||||
this.controlContext = new ControlContext({
|
||||
config: this.config,
|
||||
streamJson: this.outputAdapter,
|
||||
sessionId: this.sessionId,
|
||||
abortSignal: this.abortController.signal,
|
||||
permissionMode: this.config.getApprovalMode(),
|
||||
onInterrupt: () => this.handleInterrupt(),
|
||||
});
|
||||
this.dispatcher = new ControlDispatcher(this.controlContext);
|
||||
this.controlService = new ControlService(
|
||||
this.controlContext,
|
||||
this.dispatcher,
|
||||
);
|
||||
}
|
||||
|
||||
private getDispatcher(): ControlDispatcher | null {
|
||||
if (this.controlSystemEnabled !== true) {
|
||||
return null;
|
||||
}
|
||||
if (!this.dispatcher) {
|
||||
this.ensureControlSystem();
|
||||
}
|
||||
return this.dispatcher;
|
||||
}
|
||||
|
||||
private async handleFirstMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<boolean> {
|
||||
const routed = this.route(message);
|
||||
|
||||
if (routed.type === 'control_request') {
|
||||
const request = routed.message as CLIControlRequest;
|
||||
this.controlSystemEnabled = true;
|
||||
this.ensureControlSystem();
|
||||
if (request.request.subtype === 'initialize') {
|
||||
await this.dispatcher?.dispatch(request);
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (routed.type === 'user') {
|
||||
this.controlSystemEnabled = false;
|
||||
this.state = SESSION_STATE.PROCESSING_QUERY;
|
||||
this.userMessageQueue.push(routed.message as CLIUserMessage);
|
||||
await this.processUserMessageQueue();
|
||||
return true;
|
||||
}
|
||||
|
||||
this.controlSystemEnabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single message from the stream
|
||||
*/
|
||||
private async processMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<void> {
|
||||
const routed = this.route(message);
|
||||
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
`[SessionManager] State: ${this.state}, Message type: ${routed.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (this.state) {
|
||||
case SESSION_STATE.INITIALIZING:
|
||||
await this.handleInitializingState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.IDLE:
|
||||
await this.handleIdleState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.PROCESSING_QUERY:
|
||||
await this.handleProcessingState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.SHUTTING_DOWN:
|
||||
// Ignore all messages during shutdown
|
||||
break;
|
||||
|
||||
default: {
|
||||
// Exhaustive check
|
||||
const _exhaustiveCheck: never = this.state;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Unknown state:', _exhaustiveCheck);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages in initializing state
|
||||
*/
|
||||
private async handleInitializingState(routed: RoutedMessage): Promise<void> {
|
||||
if (routed.type === 'control_request') {
|
||||
const request = routed.message as CLIControlRequest;
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Control request received before control system initialization',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.request.subtype === 'initialize') {
|
||||
await dispatcher.dispatch(request);
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Initialized, transitioning to idle');
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring non-initialize control request during initialization',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring non-control message during initialization',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages in idle state
|
||||
*/
|
||||
private async handleIdleState(routed: RoutedMessage): Promise<void> {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (routed.type === 'control_request') {
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Ignoring control request (disabled)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const request = routed.message as CLIControlRequest;
|
||||
await dispatcher.dispatch(request);
|
||||
// Stay in idle state
|
||||
} else if (routed.type === 'control_response') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const response = routed.message as CLIControlResponse;
|
||||
dispatcher.handleControlResponse(response);
|
||||
// Stay in idle state
|
||||
} else if (routed.type === 'control_cancel') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const cancelRequest = routed.message as ControlCancelRequest;
|
||||
dispatcher.handleCancel(cancelRequest.request_id);
|
||||
} else if (routed.type === 'user') {
|
||||
const userMessage = routed.message as CLIUserMessage;
|
||||
this.userMessageQueue.push(userMessage);
|
||||
// Start processing queue
|
||||
await this.processUserMessageQueue();
|
||||
} else {
|
||||
if (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> {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (routed.type === 'control_request') {
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Control request ignored during processing (disabled)',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const request = routed.message as CLIControlRequest;
|
||||
await dispatcher.dispatch(request);
|
||||
// Continue processing
|
||||
} else if (routed.type === 'control_response') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const response = routed.message as CLIControlResponse;
|
||||
dispatcher.handleControlResponse(response);
|
||||
// Continue processing
|
||||
} else if (routed.type === 'user') {
|
||||
// Enqueue for later
|
||||
const userMessage = routed.message as CLIUserMessage;
|
||||
this.userMessageQueue.push(userMessage);
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Enqueued user message during processing',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring message type during processing:',
|
||||
routed.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process user message queue (FIFO)
|
||||
*/
|
||||
private async processUserMessageQueue(): Promise<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.emitErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
// If control system is disabled (single-query mode) and queue is empty,
|
||||
// automatically shutdown instead of returning to idle
|
||||
if (
|
||||
!this.abortController.signal.aborted &&
|
||||
this.state === SESSION_STATE.PROCESSING_QUERY &&
|
||||
this.controlSystemEnabled === false &&
|
||||
this.userMessageQueue.length === 0
|
||||
) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Single-query mode: queue processed, shutting down',
|
||||
);
|
||||
}
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
return;
|
||||
}
|
||||
|
||||
// Return to idle after processing queue (for multi-query mode with control system)
|
||||
if (
|
||||
!this.abortController.signal.aborted &&
|
||||
this.state === SESSION_STATE.PROCESSING_QUERY
|
||||
) {
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Queue processed, returning to idle');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single user message
|
||||
*/
|
||||
private async processUserMessage(userMessage: CLIUserMessage): Promise<void> {
|
||||
const input = extractUserMessageText(userMessage);
|
||||
if (!input) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] No text content in user message');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const promptId = this.getNextPromptId();
|
||||
|
||||
try {
|
||||
await runNonInteractive(
|
||||
this.config,
|
||||
createMinimalSettings(),
|
||||
input,
|
||||
promptId,
|
||||
{
|
||||
abortController: this.abortController,
|
||||
adapter: this.outputAdapter,
|
||||
controlService: this.controlService ?? undefined,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// Error already handled by runNonInteractive via adapter.emitResult
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Query execution error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tool results as user message
|
||||
*/
|
||||
private emitErrorResult(
|
||||
error: unknown,
|
||||
numTurns: number = 0,
|
||||
durationMs: number = 0,
|
||||
apiDurationMs: number = 0,
|
||||
): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.outputAdapter.emitResult({
|
||||
isError: true,
|
||||
errorMessage: message,
|
||||
durationMs,
|
||||
apiDurationMs,
|
||||
numTurns,
|
||||
usage: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
this.shutdownHandler = () => {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Shutdown signal received');
|
||||
}
|
||||
this.abortController.abort();
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
};
|
||||
|
||||
process.on('SIGINT', this.shutdownHandler);
|
||||
process.on('SIGTERM', this.shutdownHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown session and cleanup resources
|
||||
*/
|
||||
private async shutdown(): Promise<void> {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Shutting down');
|
||||
}
|
||||
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
this.dispatcher?.shutdown();
|
||||
this.cleanupSignalHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove signal handlers to prevent memory leaks
|
||||
*/
|
||||
private cleanupSignalHandlers(): void {
|
||||
if (this.shutdownHandler) {
|
||||
process.removeListener('SIGINT', this.shutdownHandler);
|
||||
process.removeListener('SIGTERM', this.shutdownHandler);
|
||||
this.shutdownHandler = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractUserMessageText(message: CLIUserMessage): string | null {
|
||||
const content = message.message.content;
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== 'object') {
|
||||
return '';
|
||||
}
|
||||
if ('type' in block && block.type === 'text' && 'text' in block) {
|
||||
return typeof block.text === 'string' ? block.text : '';
|
||||
}
|
||||
return JSON.stringify(block);
|
||||
})
|
||||
.filter((part) => part.length > 0);
|
||||
|
||||
return parts.length > 0 ? parts.join('\n') : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for stream-json mode
|
||||
*
|
||||
* @param config - Configuration object
|
||||
* @param input - Optional initial prompt input to process before reading from stream
|
||||
*/
|
||||
export async function runNonInteractiveStreamJson(
|
||||
config: Config,
|
||||
input: string,
|
||||
): Promise<void> {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
consolePatcher.patch();
|
||||
|
||||
try {
|
||||
// Create initial user message from prompt input if provided
|
||||
let initialPrompt: CLIUserMessage | undefined = undefined;
|
||||
if (input && input.trim().length > 0) {
|
||||
const sessionId = config.getSessionId();
|
||||
initialPrompt = {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
const manager = new SessionManager(config, initialPrompt);
|
||||
await manager.run();
|
||||
} finally {
|
||||
consolePatcher.cleanup();
|
||||
}
|
||||
}
|
||||
509
packages/cli/src/nonInteractive/types.ts
Normal file
509
packages/cli/src/nonInteractive/types.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* Annotation for attaching metadata to content blocks
|
||||
*/
|
||||
export interface Annotation {
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage information types
|
||||
*/
|
||||
export interface Usage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
export interface ExtendedUsage extends Usage {
|
||||
server_tool_use?: {
|
||||
web_search_requests: number;
|
||||
};
|
||||
service_tier?: string;
|
||||
cache_creation?: {
|
||||
ephemeral_1h_input_tokens: number;
|
||||
ephemeral_5m_input_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
webSearchRequests: number;
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission denial information
|
||||
*/
|
||||
export interface CLIPermissionDenial {
|
||||
tool_name: string;
|
||||
tool_use_id: string;
|
||||
tool_input: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block types from Anthropic SDK
|
||||
*/
|
||||
export interface TextBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export interface ThinkingBlock {
|
||||
type: 'thinking';
|
||||
thinking: string;
|
||||
signature?: string;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export interface ToolUseBlock {
|
||||
type: 'tool_use';
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export interface ToolResultBlock {
|
||||
type: 'tool_result';
|
||||
tool_use_id: string;
|
||||
content?: string | ContentBlock[];
|
||||
is_error?: boolean;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export type ContentBlock =
|
||||
| TextBlock
|
||||
| ThinkingBlock
|
||||
| ToolUseBlock
|
||||
| ToolResultBlock;
|
||||
|
||||
/**
|
||||
* Anthropic SDK Message types
|
||||
*/
|
||||
export interface APIUserMessage {
|
||||
role: 'user';
|
||||
content: string | ContentBlock[];
|
||||
}
|
||||
|
||||
export interface APIAssistantMessage {
|
||||
id: string;
|
||||
type: 'message';
|
||||
role: 'assistant';
|
||||
model: string;
|
||||
content: ContentBlock[];
|
||||
stop_reason?: string | null;
|
||||
usage: Usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI Message wrapper types
|
||||
*/
|
||||
export interface CLIUserMessage {
|
||||
type: 'user';
|
||||
uuid?: string;
|
||||
session_id: string;
|
||||
message: APIUserMessage;
|
||||
parent_tool_use_id: string | null;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CLIAssistantMessage {
|
||||
type: 'assistant';
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
message: APIAssistantMessage;
|
||||
parent_tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export interface CLISystemMessage {
|
||||
type: 'system';
|
||||
subtype: string;
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
data?: unknown;
|
||||
cwd?: string;
|
||||
tools?: string[];
|
||||
mcp_servers?: Array<{
|
||||
name: string;
|
||||
status: string;
|
||||
}>;
|
||||
model?: string;
|
||||
permissionMode?: string;
|
||||
slash_commands?: string[];
|
||||
apiKeySource?: string;
|
||||
qwen_code_version?: string;
|
||||
output_style?: string;
|
||||
agents?: string[];
|
||||
skills?: string[];
|
||||
capabilities?: Record<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;
|
||||
usage: ExtendedUsage;
|
||||
modelUsage?: Record<string, ModelUsage>;
|
||||
permission_denials: CLIPermissionDenial[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CLIResultMessageError {
|
||||
type: 'result';
|
||||
subtype: 'error_max_turns' | 'error_during_execution';
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
is_error: true;
|
||||
duration_ms: number;
|
||||
duration_api_ms: number;
|
||||
num_turns: number;
|
||||
usage: ExtendedUsage;
|
||||
modelUsage?: Record<string, ModelUsage>;
|
||||
permission_denials: CLIPermissionDenial[];
|
||||
error?: {
|
||||
type?: string;
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
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 type ContentBlockDelta =
|
||||
| {
|
||||
type: 'text_delta';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'thinking_delta';
|
||||
thinking: string;
|
||||
}
|
||||
| {
|
||||
type: 'input_json_delta';
|
||||
partial_json: string;
|
||||
};
|
||||
|
||||
export interface ContentBlockDeltaEvent {
|
||||
type: 'content_block_delta';
|
||||
index: number;
|
||||
delta: ContentBlockDelta;
|
||||
}
|
||||
|
||||
export interface ContentBlockStopEvent {
|
||||
type: 'content_block_stop';
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface MessageStopStreamEvent {
|
||||
type: 'message_stop';
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| MessageStartStreamEvent
|
||||
| ContentBlockStartEvent
|
||||
| ContentBlockDeltaEvent
|
||||
| ContentBlockStopEvent
|
||||
| MessageStopStreamEvent;
|
||||
|
||||
export interface CLIPartialAssistantMessage {
|
||||
type: 'stream_event';
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
event: StreamEvent;
|
||||
parent_tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo';
|
||||
|
||||
/**
|
||||
* Permission suggestion for tool use requests
|
||||
* TODO: Align with `ToolCallConfirmationDetails`
|
||||
*/
|
||||
export interface PermissionSuggestion {
|
||||
type: 'allow' | 'deny' | 'modify';
|
||||
label: string;
|
||||
description?: string;
|
||||
modifiedInput?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: unknown;
|
||||
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: unknown;
|
||||
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?: unknown;
|
||||
}
|
||||
|
||||
export interface ControlResponse {
|
||||
subtype: 'success';
|
||||
request_id: string;
|
||||
response: unknown;
|
||||
}
|
||||
|
||||
export interface ControlErrorResponse {
|
||||
subtype: 'error';
|
||||
request_id: string;
|
||||
error: string | { message: string; [key: string]: unknown };
|
||||
}
|
||||
|
||||
export interface CLIControlResponse {
|
||||
type: 'control_response';
|
||||
response: ControlResponse | ControlErrorResponse;
|
||||
}
|
||||
|
||||
export interface ControlCancelRequest {
|
||||
type: 'control_cancel_request';
|
||||
request_id?: string;
|
||||
}
|
||||
|
||||
export type ControlMessage =
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest;
|
||||
|
||||
/**
|
||||
* Union of all CLI message types
|
||||
*/
|
||||
export type CLIMessage =
|
||||
| CLIUserMessage
|
||||
| CLIAssistantMessage
|
||||
| CLISystemMessage
|
||||
| CLIResultMessage
|
||||
| CLIPartialAssistantMessage;
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
|
||||
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