diff --git a/README.en.md b/README.en.md index 1cd867ae..7496230b 100644 --- a/README.en.md +++ b/README.en.md @@ -57,6 +57,8 @@ pnpm add -g blade-code blade blade "Help me analyze this project" blade --print "Write a quicksort" +blade --headless "Analyze this repo and propose a refactor" +blade --headless --output-format jsonl "Run the full agent loop in CI" # Web UI mode (new in 0.2.0) blade web # Start and open browser @@ -97,10 +99,17 @@ See docs for the full schema. **Common Options** - `--print/-p` print mode (pipe-friendly) -- `--output-format` output: text/json/stream-json +- `--headless` full agent mode without Ink UI, prints streamed events to the terminal +- `--output-format` output: text/json/stream-json/jsonl - `--permission-mode` permission mode - `--resume/-r` resume session / `--session-id` set session +**Headless Mode** + +- `blade --headless "..."` runs the full agent loop without the interactive Ink UI +- default permission mode is `yolo`, unless explicitly overridden with `--permission-mode` +- `--output-format jsonl` emits a stable machine-friendly event stream for CI, sandbox runs, and tests + --- ## 📖 Documentation diff --git a/README.md b/README.md index 982e2da0..bfad6be1 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ pnpm add -g blade-code blade blade "帮我分析这个项目" blade --print "写一个快排算法" +blade --headless "分析这个仓库并给出重构建议" +blade --headless --output-format jsonl "在 CI 中运行完整 agent 循环" # Web UI 模式(0.2.0 新增) blade web # 启动并打开浏览器 @@ -97,10 +99,17 @@ blade serve --port 3000 # 无头服务器模式 **常用选项** - `--print/-p` 打印模式(适合管道) -- `--output-format` 输出格式(text/json/stream-json) +- `--headless` 无 Ink UI 的完整 agent 模式,按终端事件流输出 +- `--output-format` 输出格式(text/json/stream-json/jsonl) - `--permission-mode` 权限模式 - `--resume/-r` 恢复会话 / `--session-id` 指定会话 +**Headless 模式** + +- `blade --headless "..."` 会运行完整 agent loop,但不启动交互式 Ink UI +- 默认权限模式为 `yolo`,除非显式传入 `--permission-mode` +- `--output-format jsonl` 会输出稳定的机器可消费事件流,适合 CI、sandbox 和测试场景 + **交互式命令(会话内)** - `/memory list` 列出所有记忆文件 diff --git a/packages/cli/src/acp/BladeAgent.ts b/packages/cli/src/acp/BladeAgent.ts index fed2790d..9919ec03 100644 --- a/packages/cli/src/acp/BladeAgent.ts +++ b/packages/cli/src/acp/BladeAgent.ts @@ -13,6 +13,7 @@ import { } from '@agentclientprotocol/sdk'; import { nanoid } from 'nanoid'; import { createLogger, LogCategory } from '../logging/Logger.js'; +import { McpRegistry } from '../mcp/McpRegistry.js'; import { getConfig } from '../store/vanilla.js'; import { AcpSession } from './Session.js'; @@ -217,5 +218,6 @@ export class BladeAgent implements AcpAgentInterface { await session.destroy(); } this.sessions.clear(); + await McpRegistry.getInstance().disconnectAll(); } } diff --git a/packages/cli/src/acp/Session.ts b/packages/cli/src/acp/Session.ts index 8894d683..8e5b5757 100644 --- a/packages/cli/src/acp/Session.ts +++ b/packages/cli/src/acp/Session.ts @@ -22,6 +22,7 @@ import type { } from '@agentclientprotocol/sdk'; import { nanoid } from 'nanoid'; import { Agent } from '../agent/Agent.js'; +import { SessionRuntime } from '../agent/runtime/SessionRuntime.js'; import type { ChatContext, LoopOptions } from '../agent/types.js'; import { PermissionMode } from '../config/types.js'; import { createLogger, LogCategory } from '../logging/Logger.js'; @@ -53,6 +54,7 @@ type AcpModeId = 'default' | 'auto-edit' | 'yolo' | 'plan'; export class AcpSession { private agent: Agent | null = null; + private runtime: SessionRuntime | null = null; private pendingPrompt: AbortController | null = null; private messages: Message[] = []; private mode: AcpModeId = 'default'; @@ -82,8 +84,8 @@ export class AcpSession { ); logger.debug(`[AcpSession ${this.id}] ACP service context initialized`); - // 创建 Agent(cwd 通过 ChatContext.workspaceRoot 传递,不修改全局工作目录) - this.agent = await Agent.create({}); + this.runtime = await SessionRuntime.create({ sessionId: this.id }); + this.agent = await Agent.createWithRuntime(this.runtime, { sessionId: this.id }); logger.debug(`[AcpSession ${this.id}] Agent created successfully`); // 注意:available_commands_update 在 BladeAgent.newSession 响应后延迟发送 @@ -541,6 +543,10 @@ export class AcpSession { await this.agent.destroy(); this.agent = null; } + if (this.runtime) { + await this.runtime.dispose(); + this.runtime = null; + } // 销毁此会话的 ACP 服务(不影响其他会话) AcpServiceContext.destroySession(this.id); logger.debug(`[AcpSession ${this.id}] Destroyed`); diff --git a/packages/cli/src/agent/Agent.ts b/packages/cli/src/agent/Agent.ts index 65b650f7..806d397b 100644 --- a/packages/cli/src/agent/Agent.ts +++ b/packages/cli/src/agent/Agent.ts @@ -71,6 +71,7 @@ import { type Tool, ToolErrorType, type ToolResult } from '../tools/types/index. import { getEnvironmentContext } from '../utils/environment.js'; import { isThinkingModel } from '../utils/modelDetection.js'; import { ExecutionEngine } from './ExecutionEngine.js'; +import { SessionRuntime } from './runtime/SessionRuntime.js'; import { subagentRegistry } from './subagents/SubagentRegistry.js'; import type { AgentOptions, @@ -115,15 +116,18 @@ export class Agent { // 当前模型的上下文窗口大小(用于 tokenUsage 上报) private currentModelMaxContextTokens!: number; private currentModelId?: string; + private sessionRuntime?: SessionRuntime; constructor( config: BladeConfig, runtimeOptions: AgentOptions = {}, - executionPipeline?: ExecutionPipeline + executionPipeline?: ExecutionPipeline, + sessionRuntime?: SessionRuntime ) { this.config = config; this.runtimeOptions = runtimeOptions; this.executionPipeline = executionPipeline || this.createDefaultPipeline(); + this.sessionRuntime = sessionRuntime; // sessionId 不再存储在 Agent 内部,改为从 context 传入 } @@ -190,6 +194,11 @@ export class Agent { } private async switchModelIfNeeded(modelId: string): Promise { + if (this.sessionRuntime) { + await this.sessionRuntime.refresh({ modelId }); + this.syncRuntimeState(); + return; + } if (!modelId || modelId === this.currentModelId) return; const modelConfig = getModelById(modelId); if (!modelConfig) { @@ -204,6 +213,12 @@ export class Agent { * 使用 Store 获取配置 */ static async create(options: AgentOptions = {}): Promise { + if (options.sessionId) { + throw new Error( + 'Agent.create() does not accept sessionId. Create a SessionRuntime explicitly and use Agent.createWithRuntime().' + ); + } + // 0. 确保 store 已初始化(防御性检查) await ensureStoreInitialized(); @@ -242,6 +257,20 @@ export class Agent { return agent; } + static async createWithRuntime( + runtime: SessionRuntime, + options: AgentOptions = {} + ): Promise { + const agent = new Agent( + runtime.getConfig(), + options, + runtime.createExecutionPipeline(options), + runtime + ); + await agent.initialize(); + return agent; + } + /** * 初始化Agent */ @@ -253,6 +282,17 @@ export class Agent { try { this.log('初始化Agent...'); + if (this.sessionRuntime) { + await this.initializeSystemPrompt(); + await this.sessionRuntime.refresh(this.runtimeOptions); + this.syncRuntimeState(); + this.isInitialized = true; + this.log( + `Agent初始化完成,已加载 ${this.executionPipeline.getRegistry().getAll().length} 个工具` + ); + return; + } + // 1. 初始化系统提示 await this.initializeSystemPrompt(); @@ -1903,6 +1943,19 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl } } + private syncRuntimeState(): void { + if (!this.sessionRuntime) { + return; + } + + this.chatService = this.sessionRuntime.getChatService(); + this.executionEngine = this.sessionRuntime.getExecutionEngine(); + this.attachmentCollector = this.sessionRuntime.getAttachmentCollector(); + this.currentModelId = this.sessionRuntime.getCurrentModelId(); + this.currentModelMaxContextTokens = + this.sessionRuntime.getCurrentModelMaxContextTokens(); + } + /** * 生成任务ID */ diff --git a/packages/cli/src/agent/runtime/SessionRuntime.ts b/packages/cli/src/agent/runtime/SessionRuntime.ts new file mode 100644 index 00000000..15c7dcc9 --- /dev/null +++ b/packages/cli/src/agent/runtime/SessionRuntime.ts @@ -0,0 +1,302 @@ +import * as os from 'os'; +import * as path from 'path'; +import { ConfigManager, type BladeConfig, type PermissionConfig } from '../../config/index.js'; +import { PermissionMode } from '../../config/index.js'; +import type { ModelConfig } from '../../config/types.js'; +import { createLogger, LogCategory } from '../../logging/Logger.js'; +import { loadMcpConfigFromCli } from '../../mcp/loadMcpConfig.js'; +import { McpRegistry } from '../../mcp/McpRegistry.js'; +import { buildSystemPrompt } from '../../prompts/index.js'; +import { AttachmentCollector } from '../../prompts/processors/AttachmentCollector.js'; +import { + createChatServiceAsync, + type IChatService, +} from '../../services/ChatServiceInterface.js'; +import { discoverSkills } from '../../skills/index.js'; +import { + ensureStoreInitialized, + getAllModels, + getConfig, + getCurrentModel, + getMcpServers, + getModelById, + getThinkingModeEnabled, +} from '../../store/vanilla.js'; +import { getBuiltinTools } from '../../tools/builtin/index.js'; +import { ExecutionPipeline } from '../../tools/execution/ExecutionPipeline.js'; +import { InMemorySessionApprovalStore } from '../../tools/execution/SessionApprovalStore.js'; +import { ToolRegistry } from '../../tools/registry/ToolRegistry.js'; +import { isThinkingModel } from '../../utils/modelDetection.js'; +import { ExecutionEngine } from '../ExecutionEngine.js'; +import type { AgentOptions } from '../types.js'; +import { subagentRegistry } from '../subagents/SubagentRegistry.js'; + +const logger = createLogger(LogCategory.AGENT); + +export interface SessionRuntimeOptions { + sessionId: string; + modelId?: string; + mcpConfig?: string[]; + strictMcpConfig?: boolean; +} + +export class SessionRuntime { + private readonly approvalStore = new InMemorySessionApprovalStore(); + private readonly baseRegistry = new ToolRegistry(); + private readonly attachmentCollector: AttachmentCollector; + + private chatService!: IChatService; + private executionEngine!: ExecutionEngine; + private currentModelId?: string; + private currentModelMaxContextTokens!: number; + private initialized = false; + + constructor( + private readonly config: BladeConfig, + private readonly options: SessionRuntimeOptions + ) { + this.attachmentCollector = new AttachmentCollector({ + cwd: process.cwd(), + maxFileSize: 1024 * 1024, + maxLines: 2000, + maxTokens: 32000, + }); + } + + static async create(options: SessionRuntimeOptions): Promise { + await ensureStoreInitialized(); + + const models = getAllModels(); + if (models.length === 0) { + throw new Error( + '❌ 没有可用的模型配置\n\n' + + '请先使用以下命令添加模型:\n' + + ' /model add\n\n' + + '或运行初始化向导:\n' + + ' /init' + ); + } + + const config = getConfig(); + if (!config) { + throw new Error('❌ 配置未初始化,请确保应用已正确启动'); + } + + ConfigManager.getInstance().validateConfig(config); + + const runtime = new SessionRuntime(config, options); + await runtime.initialize(); + return runtime; + } + + get sessionId(): string { + return this.options.sessionId; + } + + getConfig(): BladeConfig { + return this.config; + } + + getChatService(): IChatService { + return this.chatService; + } + + getExecutionEngine(): ExecutionEngine { + return this.executionEngine; + } + + getAttachmentCollector(): AttachmentCollector { + return this.attachmentCollector; + } + + getCurrentModelId(): string | undefined { + return this.currentModelId; + } + + getCurrentModelMaxContextTokens(): number { + return this.currentModelMaxContextTokens; + } + + async initialize(): Promise { + if (this.initialized) { + return; + } + + await this.validateSystemPromptConfig(); + await this.registerBuiltinTools(); + await this.loadSubagents(); + await this.discoverSkills(); + await this.applyModelConfig(this.resolveModelConfig(this.options.modelId), '🚀 使用模型:'); + + this.initialized = true; + logger.debug( + `[SessionRuntime ${this.sessionId}] initialized with ${this.baseRegistry.getAll().length} tools` + ); + } + + async refresh(options: Partial): Promise { + if (!this.initialized) { + await this.initialize(); + return; + } + + const nextModelId = + options.modelId && options.modelId !== 'inherit' ? options.modelId : undefined; + if (nextModelId && nextModelId !== this.currentModelId) { + await this.applyModelConfig(this.resolveModelConfig(nextModelId), '🔁 切换模型'); + } + } + + createExecutionPipeline(options: AgentOptions = {}): ExecutionPipeline { + const registry = new ToolRegistry(); + const allowed = options.toolWhitelist ? new Set(options.toolWhitelist) : null; + + for (const tool of this.baseRegistry.getBuiltinTools()) { + if (!allowed || allowed.has(tool.name)) { + registry.register(tool); + } + } + for (const tool of this.baseRegistry.getMcpTools()) { + if (!allowed || allowed.has(tool.name)) { + registry.registerMcpTool(tool); + } + } + + const permissions: PermissionConfig = { + ...this.config.permissions, + ...options.permissions, + }; + const permissionMode = + options.permissionMode ?? this.config.permissionMode ?? PermissionMode.DEFAULT; + + return new ExecutionPipeline(registry, { + permissionConfig: permissions, + permissionMode, + approvalStore: this.approvalStore, + maxHistorySize: 1000, + }); + } + + async dispose(): Promise { + this.approvalStore.clear(); + const disposableChatService = this.chatService as + | (IChatService & { dispose?: () => Promise | void }) + | undefined; + await disposableChatService?.dispose?.(); + this.currentModelId = undefined; + this.initialized = false; + } + + private resolveModelConfig(requestedModelId?: string): ModelConfig { + const modelId = + requestedModelId && requestedModelId !== 'inherit' ? requestedModelId : undefined; + const modelConfig = modelId ? getModelById(modelId) : getCurrentModel(); + if (!modelConfig) { + throw new Error(`❌ 模型配置未找到: ${modelId ?? 'current'}`); + } + return modelConfig; + } + + private async applyModelConfig(modelConfig: ModelConfig, label: string): Promise { + logger.debug(`${label} ${modelConfig.name} (${modelConfig.model})`); + + const modelSupportsThinking = isThinkingModel(modelConfig); + const thinkingModeEnabled = getThinkingModeEnabled(); + const supportsThinking = modelSupportsThinking && thinkingModeEnabled; + + this.currentModelMaxContextTokens = + modelConfig.maxContextTokens ?? this.config.maxContextTokens; + + this.chatService = await createChatServiceAsync({ + provider: modelConfig.provider, + apiKey: modelConfig.apiKey, + model: modelConfig.model, + baseUrl: modelConfig.baseUrl, + temperature: modelConfig.temperature ?? this.config.temperature, + maxContextTokens: this.currentModelMaxContextTokens, + maxOutputTokens: modelConfig.maxOutputTokens ?? this.config.maxOutputTokens, + timeout: this.config.timeout, + supportsThinking, + }); + + const contextManager = this.executionEngine?.getContextManager(); + this.executionEngine = new ExecutionEngine(this.chatService, contextManager); + this.currentModelId = modelConfig.id; + } + + private async validateSystemPromptConfig(): Promise { + try { + await buildSystemPrompt({ + projectPath: process.cwd(), + includeEnvironment: false, + language: this.config.language, + }); + } catch (error) { + logger.warn('[SessionRuntime] Failed to validate system prompt configuration:', error); + } + } + + private async registerBuiltinTools(): Promise { + const builtinTools = await getBuiltinTools({ + sessionId: this.sessionId, + configDir: path.join(os.homedir(), '.blade'), + }); + + const builtin = builtinTools.filter((tool) => !tool.name.startsWith('mcp__')); + + this.baseRegistry.registerAll(builtin); + + await this.registerMcpTools(); + } + + private async registerMcpTools(): Promise { + try { + if (this.options.mcpConfig && this.options.mcpConfig.length > 0) { + await loadMcpConfigFromCli(this.options.mcpConfig); + } + + const mcpServers = getMcpServers(); + if (Object.keys(mcpServers).length === 0) { + return; + } + + const registry = McpRegistry.getInstance(); + for (const [name, config] of Object.entries(mcpServers)) { + try { + await registry.registerServer(name, config); + } catch (error) { + logger.warn(`⚠️ MCP server "${name}" connection failed:`, error); + } + } + + const mcpTools = await registry.getAvailableTools(); + for (const tool of mcpTools) { + this.baseRegistry.registerMcpTool(tool); + } + } catch (error) { + logger.warn('Failed to register MCP tools:', error); + } + } + + private async loadSubagents(): Promise { + if (subagentRegistry.getAllNames().length > 0) { + return; + } + + try { + subagentRegistry.loadFromStandardLocations(); + } catch (error) { + logger.warn('Failed to load subagents:', error); + } + } + + private async discoverSkills(): Promise { + try { + await discoverSkills({ + cwd: process.cwd(), + }); + } catch (error) { + logger.warn('Failed to discover skills:', error); + } + } +} diff --git a/packages/cli/src/agent/subagents/BackgroundAgentManager.ts b/packages/cli/src/agent/subagents/BackgroundAgentManager.ts index 546249cb..5d1a54cd 100644 --- a/packages/cli/src/agent/subagents/BackgroundAgentManager.ts +++ b/packages/cli/src/agent/subagents/BackgroundAgentManager.ts @@ -12,6 +12,7 @@ import type { PermissionMode } from '../../config/types.js'; import { createLogger, LogCategory } from '../../logging/Logger.js'; import type { Message } from '../../services/ChatServiceInterface.js'; import { Agent } from '../Agent.js'; +import { SessionRuntime } from '../runtime/SessionRuntime.js'; import { type AgentSession, type AgentSessionStatus, @@ -192,6 +193,7 @@ export class BackgroundAgentManager { existingMessages?: Message[] ): Promise { const startTime = Date.now(); + let runtime: SessionRuntime | undefined; try { if (signal.aborted) { @@ -201,7 +203,12 @@ export class BackgroundAgentManager { const systemPrompt = config.systemPrompt || ''; const modelId = config.model && config.model !== 'inherit' ? config.model : undefined; - const agent = await Agent.create({ + runtime = await SessionRuntime.create({ + sessionId: agentId, + modelId, + }); + const agent = await Agent.createWithRuntime(runtime, { + sessionId: agentId, systemPrompt, toolWhitelist: config.tools, modelId, @@ -283,6 +290,8 @@ export class BackgroundAgentManager { error: errorMessage, stats: { duration }, }; + } finally { + await runtime?.dispose(); } } diff --git a/packages/cli/src/agent/types.ts b/packages/cli/src/agent/types.ts index d5d950cf..d6b69edf 100644 --- a/packages/cli/src/agent/types.ts +++ b/packages/cli/src/agent/types.ts @@ -51,6 +51,7 @@ export interface ChatContext { * Agent 的配置来自 Store (通过 getConfig() 获取 BladeConfig) */ export interface AgentOptions { + sessionId?: string; // 运行时参数 systemPrompt?: string; // 完全替换系统提示 appendSystemPrompt?: string; // 追加系统提示 diff --git a/packages/cli/src/blade.tsx b/packages/cli/src/blade.tsx index c9f81228..26006a9c 100644 --- a/packages/cli/src/blade.tsx +++ b/packages/cli/src/blade.tsx @@ -15,6 +15,7 @@ import { } from './cli/middleware.js'; // 导入命令处理器 import { doctorCommands } from './commands/doctor.js'; +import { handleHeadlessMode } from './commands/headless.js'; import { installCommands } from './commands/install.js'; import { mcpCommands } from './commands/mcp.js'; import { handlePrintMode } from './commands/print.js'; @@ -79,6 +80,11 @@ export async function main() { // 版本检查不依赖任何配置状态,可以立即开始网络请求 const versionCheckPromise = checkVersionOnStartup(); + // 首先检查是否是 print 模式 + if (await handleHeadlessMode()) { + return; + } + // 首先检查是否是 print 模式 if (await handlePrintMode()) { return; diff --git a/packages/cli/src/cli/config.ts b/packages/cli/src/cli/config.ts index 39430629..24bc8ba7 100644 --- a/packages/cli/src/cli/config.ts +++ b/packages/cli/src/cli/config.ts @@ -24,12 +24,17 @@ export const globalOptions = { describe: 'Print response and exit (useful for pipes)', group: 'Output Options:', }, + headless: { + type: 'boolean', + describe: 'Run full agent loop without Ink UI and print all events to the terminal', + group: 'Output Options:', + }, 'output-format': { alias: ['outputFormat'], type: 'string', - choices: ['text', 'json', 'stream-json'], + choices: ['text', 'json', 'stream-json', 'jsonl'], default: 'text', - describe: 'Output format (only works with --print)', + describe: 'Output format (works with --print and --headless)', group: 'Output Options:', }, 'include-partial-messages': { diff --git a/packages/cli/src/cli/middleware.ts b/packages/cli/src/cli/middleware.ts index c1bd4673..696b3890 100644 --- a/packages/cli/src/cli/middleware.ts +++ b/packages/cli/src/cli/middleware.ts @@ -79,8 +79,8 @@ export const loadConfiguration: MiddlewareFunction = async (argv) => { */ export const validateOutput: MiddlewareFunction = (argv) => { // 验证输出格式组合 - if (argv.outputFormat && argv.outputFormat !== 'text' && !argv.print) { - throw new Error('--output-format can only be used with --print flag'); + if (argv.outputFormat && argv.outputFormat !== 'text' && !argv.print && !argv.headless) { + throw new Error('--output-format can only be used with --print or --headless'); } // 验证输入格式 diff --git a/packages/cli/src/cli/types.ts b/packages/cli/src/cli/types.ts index 2d359bba..866df031 100644 --- a/packages/cli/src/cli/types.ts +++ b/packages/cli/src/cli/types.ts @@ -5,7 +5,8 @@ export interface GlobalOptions { debug?: string; print?: boolean; - outputFormat?: 'text' | 'json' | 'stream-json'; + headless?: boolean; + outputFormat?: 'text' | 'json' | 'stream-json' | 'jsonl'; includePartialMessages?: boolean; inputFormat?: 'text' | 'stream-json'; replayUserMessages?: boolean; diff --git a/packages/cli/src/commands/headless.ts b/packages/cli/src/commands/headless.ts new file mode 100644 index 00000000..0006b5b0 --- /dev/null +++ b/packages/cli/src/commands/headless.ts @@ -0,0 +1,525 @@ +/** + * Headless CLI runner for the full agent loop. + * + * This keeps the agent behavior intact while replacing Ink rendering with + * terminal output. Internal callbacks stay camelCase to match `LoopOptions`, + * while the exported JSONL contract remains snake_case and versioned. + */ +import type { Argv } from 'yargs'; +import type { ChatCompletionMessageToolCall } from 'openai/resources/chat'; +import { z } from 'zod'; +import { Agent } from '../agent/Agent.js'; +import type { ChatContext, LoopOptions } from '../agent/types.js'; +import { PermissionMode } from '../config/types.js'; +import type { Message } from '../services/ChatServiceInterface.js'; +import type { TodoItem } from '../tools/builtin/todo/types.js'; +import type { + ConfirmationDetails, + ConfirmationResponse, +} from '../tools/types/ExecutionTypes.js'; +import type { ToolResult } from '../tools/types/index.js'; +import { + initializeCliPlugins, + normalizeCliInput, + readCliInput, +} from './shared/commandInput.js'; +import { + type HeadlessJsonlEventPayload, + type HeadlessJsonlEventType, + createHeadlessJsonlEvent, +} from './headlessEvents.js'; +import { + formatToolCallSummary, + generateToolDetail, + shouldShowToolDetail, +} from '../ui/utils/toolFormatters.js'; + +/** Minimal writable stream contract used by headless output sinks. */ +interface WritableLike { + write(chunk: string): boolean | void; +} + +/** Output streams used by the headless runner. */ +interface HeadlessIO { + stdout: WritableLike; + stderr: WritableLike; +} + +type HeadlessOutputFormat = 'text' | 'jsonl'; + +const HeadlessOutputFormatSchema = z.enum(['text', 'jsonl']); + +export const HeadlessOptionsSchema = z.object({ + headless: z.boolean().optional(), + message: z.string().optional(), + _: z.array(z.union([z.string(), z.number()])).optional(), + model: z.string().optional(), + systemPrompt: z.string().optional(), + appendSystemPrompt: z.string().optional(), + maxTurns: z + .number() + .int() + .refine((value) => value === -1 || value > 0, { + message: 'must be -1 or a positive integer', + }) + .optional(), + permissionMode: z.nativeEnum(PermissionMode).optional(), + mcpConfig: z.array(z.string()).optional(), + strictMcpConfig: z.boolean().optional(), + sessionId: z.string().optional(), + outputFormat: HeadlessOutputFormatSchema.optional(), +}); + +export interface HeadlessOptions { + /** Enables headless execution instead of the Ink UI. */ + headless?: boolean; + /** Primary user message for this run. */ + message?: string; + /** Positional arguments forwarded by yargs. */ + _?: (string | number)[]; + /** Optional model override for the current run. */ + model?: string; + /** Replaces the default system prompt when provided. */ + systemPrompt?: string; + /** Appends to the default system prompt when provided. */ + appendSystemPrompt?: string; + /** Maximum number of agent turns for this run. */ + maxTurns?: number; + /** Permission mode override; defaults to YOLO in headless mode. */ + permissionMode?: PermissionMode | string; + /** Optional MCP config sources for this run. */ + mcpConfig?: string[]; + /** Whether MCP config loading should fail hard. */ + strictMcpConfig?: boolean; + /** Session identifier used in the chat context. */ + sessionId?: string; + /** Terminal output format. */ + outputFormat?: string; +} + +type ValidatedHeadlessOptions = z.infer; + +interface HeadlessStreamSnapshot { + openedThinking: boolean; + wroteAssistantContent: boolean; +} + +class HeadlessStreamState { + private openedThinking = false; + private wroteAssistantContent = false; + + markAssistantContent(): void { + this.wroteAssistantContent = true; + } + + setThinkingOpened(isOpened: boolean): void { + this.openedThinking = isOpened; + } + + hasOpenThinking(): boolean { + return this.openedThinking; + } + + completeStream(): HeadlessStreamSnapshot { + const snapshot = { + openedThinking: this.openedThinking, + wroteAssistantContent: this.wroteAssistantContent, + }; + this.openedThinking = false; + this.wroteAssistantContent = false; + return snapshot; + } +} + +function formatValidationIssues(error: z.ZodError): string { + return error.issues + .map((issue) => `${issue.path.join('.') || 'options'}: ${issue.message}`) + .join('; '); +} + +function validateHeadlessOptions(options: HeadlessOptions): ValidatedHeadlessOptions { + const result = HeadlessOptionsSchema.safeParse(options); + if (!result.success) { + throw new Error(`Invalid headless options: ${formatValidationIssues(result.error)}`); + } + return result.data; +} + +function headlessCommand(yargs: Argv) { + return yargs.command( + '* [message]', + 'Run full agent loop without Ink UI and print events to the terminal', + (y) => + y + .positional('message', { + describe: 'Message to process', + type: 'string', + }) + .option('headless', { + type: 'boolean', + describe: 'Run full agent loop without Ink UI and print events to the terminal', + }) + .option('model', { + describe: 'Model ID for this run', + type: 'string', + }) + .option('system-prompt', { + describe: 'Replace the default system prompt', + type: 'string', + }) + .option('append-system-prompt', { + describe: 'Append a system prompt to the default system prompt', + type: 'string', + }) + .option('max-turns', { + alias: ['maxTurns'], + describe: 'Maximum conversation turns (-1: unlimited, N>0: limit to N turns)', + type: 'number', + }) + .option('output-format', { + alias: ['outputFormat'], + choices: ['text', 'jsonl'], + describe: 'Headless output format', + type: 'string', + }), + async (argv: HeadlessOptions) => { + if (!argv.headless) { + return; + } + + const exitCode = await runHeadless(argv); + process.exit(exitCode); + } + ); +} + +function writeLine(writer: WritableLike, line = ''): void { + writer.write(`${line}\n`); +} + +function formatTodo(todo: TodoItem): string { + return `[todo] [${todo.status}] ${todo.content}`; +} + +function createConfirmationHandler() { + return { + requestConfirmation: async ( + _details: ConfirmationDetails + ): Promise => ({ + approved: true, + reason: 'headless-auto-approved', + scope: 'session', + }), + }; +} + +function resolveOutputFormat(outputFormat?: string): HeadlessOutputFormat { + return outputFormat === 'jsonl' ? 'jsonl' : 'text'; +} + +function createEventWriter( + io: HeadlessIO, + outputFormat: HeadlessOutputFormat +) { + const writeJsonl = ( + type: TType, + payload: HeadlessJsonlEventPayload + ) => { + io.stdout.write( + `${JSON.stringify(createHeadlessJsonlEvent(type, payload))}\n` + ); + }; + + return { + contentDelta(delta: string) { + if (outputFormat === 'jsonl') { + writeJsonl('content_delta', { delta }); + return; + } + io.stdout.write(delta); + }, + thinkingDelta(delta: string, openedThinking: boolean): boolean { + if (outputFormat === 'jsonl') { + writeJsonl('thinking_delta', { delta }); + return openedThinking; + } + if (!openedThinking) { + io.stderr.write('[thinking] '); + } + io.stderr.write(delta); + return true; + }, + thinking(content: string) { + if (outputFormat === 'jsonl') { + writeJsonl('thinking', { content }); + return; + } + writeLine(io.stderr, `[thinking] ${content}`); + }, + streamEnd(wroteAssistantContent: boolean, openedThinking: boolean) { + if (outputFormat === 'jsonl') { + writeJsonl('stream_end', {}); + return; + } + if (openedThinking) { + io.stderr.write('\n'); + } + if (wroteAssistantContent) { + io.stdout.write('\n'); + } + }, + content(content: string) { + if (outputFormat === 'jsonl') { + writeJsonl('content', { content }); + return; + } + writeLine(io.stdout, content); + }, + toolStart(toolName: string, summary: string) { + if (outputFormat === 'jsonl') { + writeJsonl('tool_start', { tool_name: toolName, summary }); + return; + } + writeLine(io.stderr, `[tool:start] ${summary}`); + }, + toolResult(toolName: string, summary: string) { + if (outputFormat === 'jsonl') { + writeJsonl('tool_result', { tool_name: toolName, summary }); + return; + } + writeLine(io.stderr, `[tool:result] ${summary}`); + }, + toolDetail(toolName: string, detail: string) { + if (outputFormat === 'jsonl') { + writeJsonl('tool_detail', { tool_name: toolName, detail }); + return; + } + writeLine(io.stderr, detail); + }, + todoUpdate(todos: TodoItem[]) { + if (outputFormat === 'jsonl') { + writeJsonl('todo_update', { todos }); + return; + } + for (const todo of todos) { + writeLine(io.stderr, formatTodo(todo)); + } + }, + tokenUsage(usage: { + inputTokens: number; + outputTokens: number; + totalTokens: number; + maxContextTokens: number; + }) { + if (outputFormat === 'jsonl') { + writeJsonl('token_usage', { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + total_tokens: usage.totalTokens, + max_context_tokens: usage.maxContextTokens, + }); + return; + } + writeLine( + io.stderr, + `[tokens] in=${usage.inputTokens} out=${usage.outputTokens} total=${usage.totalTokens} / ${usage.maxContextTokens}` + ); + }, + compacting(isCompacting: boolean) { + if (outputFormat === 'jsonl') { + writeJsonl('compacting', { + state: isCompacting ? 'started' : 'completed', + }); + return; + } + writeLine( + io.stderr, + isCompacting ? '[context] compacting started' : '[context] compacting completed' + ); + }, + turnLimit(turnsCount: number) { + if (outputFormat === 'jsonl') { + writeJsonl('turn_limit', { + turns_count: turnsCount, + action: 'continue', + }); + return; + } + writeLine(io.stderr, `[turn-limit] continuing after ${turnsCount} turns`); + }, + output(content: string, exitCode = 0) { + if (outputFormat === 'jsonl') { + writeJsonl('output', { content, exit_code: exitCode }); + return; + } + writeLine(exitCode === 0 ? io.stdout : io.stderr, content); + }, + error(message: string) { + if (outputFormat === 'jsonl') { + writeJsonl('error', { message }); + return; + } + writeLine(io.stderr, message); + }, + }; +} + +export async function runHeadless( + options: HeadlessOptions, + io: HeadlessIO = { stdout: process.stdout, stderr: process.stderr } +): Promise { + let outputFormat: HeadlessOutputFormat = 'text'; + let eventWriter = createEventWriter(io, outputFormat); + const streamState = new HeadlessStreamState(); + + try { + const validatedOptions = validateHeadlessOptions(options); + outputFormat = resolveOutputFormat(validatedOptions.outputFormat); + eventWriter = createEventWriter(io, outputFormat); + + await initializeCliPlugins(); + + const rawInput = await readCliInput(validatedOptions); + const normalized = await normalizeCliInput(rawInput); + if (normalized.mode === 'output') { + if (normalized.content) { + eventWriter.output(normalized.content, normalized.exitCode ?? 0); + } + return normalized.exitCode ?? 0; + } + + const permissionMode = + (validatedOptions.permissionMode as PermissionMode | undefined) ?? + PermissionMode.YOLO; + const contextMessages: Message[] = []; + const chatContext: ChatContext = { + messages: contextMessages, + userId: 'cli-user', + sessionId: validatedOptions.sessionId ?? `headless-${Date.now()}`, + workspaceRoot: process.cwd(), + permissionMode, + confirmationHandler: createConfirmationHandler(), + }; + + const loopOptions: LoopOptions = { + stream: true, + maxTurns: validatedOptions.maxTurns, + onContentDelta: (delta: string) => { + streamState.markAssistantContent(); + eventWriter.contentDelta(delta); + }, + onThinkingDelta: (delta: string) => { + streamState.setThinkingOpened( + eventWriter.thinkingDelta(delta, streamState.hasOpenThinking()) + ); + }, + onThinking: (content: string) => { + if (!content) return; + eventWriter.thinking(content); + }, + onStreamEnd: () => { + const snapshot = streamState.completeStream(); + eventWriter.streamEnd( + snapshot.wroteAssistantContent, + snapshot.openedThinking + ); + }, + onContent: (content: string) => { + if (!content.trim()) return; + eventWriter.content(content); + streamState.markAssistantContent(); + }, + onToolStart: (toolCall: ChatCompletionMessageToolCall) => { + if (toolCall.type !== 'function') return; + // TodoWrite 由 onTodoUpdate 处理,避免重复输出 + if (toolCall.function.name === 'TodoWrite') return; + try { + const params = JSON.parse(toolCall.function.arguments); + const summary = formatToolCallSummary(toolCall.function.name, params); + eventWriter.toolStart(toolCall.function.name, summary); + } catch { + // JSON 解析失败,使用工具名作为 fallback + eventWriter.toolStart(toolCall.function.name, toolCall.function.name); + } + }, + onToolResult: async ( + toolCall: ChatCompletionMessageToolCall, + result: ToolResult + ) => { + if (toolCall.type !== 'function') return; + const summary = result.metadata?.summary; + if (summary) { + eventWriter.toolResult(toolCall.function.name, summary); + } + + if (shouldShowToolDetail(toolCall.function.name, result)) { + const detail = + generateToolDetail(toolCall.function.name, result) || + result.displayContent; + if (detail) { + eventWriter.toolDetail(toolCall.function.name, detail); + } + } + }, + onTodoUpdate: (todos: TodoItem[]) => { + eventWriter.todoUpdate(todos); + }, + onTokenUsage: (usage) => { + eventWriter.tokenUsage(usage); + }, + onCompacting: (isCompacting: boolean) => { + eventWriter.compacting(isCompacting); + }, + onTurnLimitReached: async (data) => { + eventWriter.turnLimit(data.turnsCount); + return { continue: true, reason: 'headless-auto-continue' }; + }, + }; + + const agent = await Agent.create({ + systemPrompt: validatedOptions.systemPrompt, + appendSystemPrompt: validatedOptions.appendSystemPrompt, + maxTurns: validatedOptions.maxTurns, + modelId: validatedOptions.model, + permissionMode, + mcpConfig: validatedOptions.mcpConfig, + strictMcpConfig: validatedOptions.strictMcpConfig, + }); + + await agent.chat(normalized.content, chatContext, loopOptions); + return 0; + } catch (error) { + if (streamState.hasOpenThinking() && outputFormat === 'text') { + io.stderr.write('\n'); + } + eventWriter.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + return 1; + } +} + +export async function handleHeadlessMode(): Promise { + const argv = process.argv.slice(2); + const headlessRequested = argv.includes('--headless'); + if (!headlessRequested) { + return false; + } + + const yargs = (await import('yargs')).default; + const { hideBin } = await import('yargs/helpers'); + const { globalOptions } = await import('../cli/config.js'); + const { + loadConfiguration, + validateOutput, + validatePermissions, + } = await import('../cli/middleware.js'); + + const cli = yargs(hideBin(process.argv)) + .scriptName('blade') + .strict(false) + .options(globalOptions) + .middleware([validatePermissions, loadConfiguration, validateOutput]); + + headlessCommand(cli); + await cli.parse(); + return true; +} diff --git a/packages/cli/src/commands/headlessEvents.ts b/packages/cli/src/commands/headlessEvents.ts new file mode 100644 index 00000000..00169d41 --- /dev/null +++ b/packages/cli/src/commands/headlessEvents.ts @@ -0,0 +1,126 @@ +/** + * Stable JSONL event contract for headless CLI consumers. + * + * The external wire format intentionally uses snake_case so tests and sandbox + * integrations can consume it without depending on internal TypeScript naming. + */ +import { z } from 'zod'; +import { TodoItemSchema } from '../tools/builtin/todo/types.js'; + +export const HEADLESS_EVENT_VERSION = 1 as const; + +const HeadlessEventBaseSchema = z.object({ + event_version: z.literal(HEADLESS_EVENT_VERSION), +}); + +const ContentDeltaEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('content_delta'), + delta: z.string(), +}); + +const ThinkingDeltaEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('thinking_delta'), + delta: z.string(), +}); + +const ThinkingEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('thinking'), + content: z.string(), +}); + +const StreamEndEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('stream_end'), +}); + +const ContentEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('content'), + content: z.string(), +}); + +const ToolStartEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('tool_start'), + tool_name: z.string(), + summary: z.string(), +}); + +const ToolResultEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('tool_result'), + tool_name: z.string(), + summary: z.string(), +}); + +const ToolDetailEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('tool_detail'), + tool_name: z.string(), + detail: z.string(), +}); + +const TodoUpdateEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('todo_update'), + todos: z.array(TodoItemSchema), +}); + +const TokenUsageEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('token_usage'), + input_tokens: z.number(), + output_tokens: z.number(), + total_tokens: z.number(), + max_context_tokens: z.number(), +}); + +const CompactingEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('compacting'), + state: z.enum(['started', 'completed']), +}); + +const TurnLimitEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('turn_limit'), + turns_count: z.number(), + action: z.literal('continue'), +}); + +const OutputEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('output'), + content: z.string(), + exit_code: z.number(), +}); + +const ErrorEventSchema = HeadlessEventBaseSchema.extend({ + type: z.literal('error'), + message: z.string(), +}); + +export const HeadlessJsonlEventSchema = z.discriminatedUnion('type', [ + ContentDeltaEventSchema, + ThinkingDeltaEventSchema, + ThinkingEventSchema, + StreamEndEventSchema, + ContentEventSchema, + ToolStartEventSchema, + ToolResultEventSchema, + ToolDetailEventSchema, + TodoUpdateEventSchema, + TokenUsageEventSchema, + CompactingEventSchema, + TurnLimitEventSchema, + OutputEventSchema, + ErrorEventSchema, +]); + +export type HeadlessJsonlEvent = z.infer; +export type HeadlessJsonlEventType = HeadlessJsonlEvent['type']; +export type HeadlessJsonlEventPayload = Omit< + Extract, + 'event_version' | 'type' +>; + +export function createHeadlessJsonlEvent( + type: TType, + payload: HeadlessJsonlEventPayload +): Extract { + return { + event_version: HEADLESS_EVENT_VERSION, + type, + ...payload, + } as Extract; +} diff --git a/packages/cli/src/commands/print.ts b/packages/cli/src/commands/print.ts index 0e8b28b1..b0da9321 100644 --- a/packages/cli/src/commands/print.ts +++ b/packages/cli/src/commands/print.ts @@ -1,7 +1,10 @@ import type { Argv } from 'yargs'; import { Agent } from '../agent/Agent.js'; -import { getPluginRegistry, integrateAllPlugins } from '../plugins/index.js'; -import { executeSlashCommand, isSlashCommand } from '../slash-commands/index.js'; +import { + initializeCliPlugins, + normalizeCliInput, + readCliInput, +} from './shared/commandInput.js'; interface PrintOptions { print?: boolean; @@ -70,67 +73,21 @@ function printCommand(yargs: Argv) { } try { - // 初始化插件系统 - const pluginRegistry = getPluginRegistry(); - const pluginResult = await pluginRegistry.initialize(process.cwd(), []); - if (pluginResult.plugins.length > 0) { - await integrateAllPlugins(); - } - - let input = ''; - - // 如果有 message 参数,使用它 - // 优先使用命名参数 argv.message,其次使用位置参数 argv._[0] - const message = argv.message || argv._?.[0]; - if (message && typeof message === 'string') { - input = message; - } else if (!process.stdin.isTTY) { - // 从 stdin 读取输入(管道输入) - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(chunk); - } - input = Buffer.concat(chunks).toString('utf-8').trim(); - } else { - input = 'Hello'; - } + await initializeCliPlugins(); - // 检查是否为 slash 命令 - if (isSlashCommand(input)) { - const result = await executeSlashCommand(input, { - cwd: process.cwd(), - workspaceRoot: process.cwd(), - }); - - // 处理不同的 slash 命令结果 - if (!result.success) { - console.error(`Error: ${result.error || '未知错误'}`); - process.exit(1); - } - - // 检查是否需要通过 Agent 处理(invoke_skill, invoke_custom_command, invoke_plugin_command) - const data = result.data as Record | undefined; - if (data?.action === 'invoke_skill') { - const skillName = data.skillName as string; - const skillArgs = data.skillArgs as string | undefined; - input = skillArgs - ? `Please use the "${skillName}" skill to help me with: ${skillArgs}` - : `Please use the "${skillName}" skill.`; - } else if ( - data?.action === 'invoke_custom_command' || - data?.action === 'invoke_plugin_command' - ) { - const processedContent = data.processedContent as string; - const commandName = data.commandName as string; - input = `# Custom Command: /${commandName}\n\n${processedContent}`; - } else { - // 普通 slash 命令,直接输出结果 - if (result.message) { - console.log(result.message); - } - process.exit(0); + const rawInput = await readCliInput({ + message: argv.message, + _: argv._, + defaultMessage: 'Hello', + }); + const normalized = await normalizeCliInput(rawInput); + if (normalized.mode === 'output') { + if (normalized.content) { + console.log(normalized.content); } + process.exit(normalized.exitCode ?? 0); } + const input = normalized.content; const agent = await Agent.create({ systemPrompt: argv.systemPrompt, diff --git a/packages/cli/src/commands/shared/commandInput.ts b/packages/cli/src/commands/shared/commandInput.ts new file mode 100644 index 00000000..4bf91029 --- /dev/null +++ b/packages/cli/src/commands/shared/commandInput.ts @@ -0,0 +1,95 @@ +import { getPluginRegistry, integrateAllPlugins } from '../../plugins/index.js'; +import { executeSlashCommand, isSlashCommand } from '../../slash-commands/index.js'; + +interface MessageLikeOptions { + message?: string; + _?: (string | number)[]; +} + +interface ReadCliInputOptions extends MessageLikeOptions { + stdin?: NodeJS.ReadStream; + defaultMessage?: string; +} + +export async function initializeCliPlugins(): Promise { + const pluginRegistry = getPluginRegistry(); + const pluginResult = await pluginRegistry.initialize(process.cwd(), []); + if (pluginResult.plugins.length > 0) { + await integrateAllPlugins(); + } +} + +export async function readCliInput(options: ReadCliInputOptions): Promise { + const message = options.message || options._?.[0]; + if (typeof message === 'string' && message.trim()) { + return message; + } + + const stdin = options.stdin ?? process.stdin; + if (!stdin.isTTY) { + const chunks: Buffer[] = []; + for await (const chunk of stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf-8').trim(); + } + + if (options.defaultMessage !== undefined) { + return options.defaultMessage; + } + + throw new Error('No input provided'); +} + +export async function normalizeCliInput(input: string): Promise<{ + mode: 'agent' | 'output'; + content: string; + exitCode?: number; +}> { + if (!isSlashCommand(input)) { + return { mode: 'agent', content: input }; + } + + const result = await executeSlashCommand(input, { + cwd: process.cwd(), + workspaceRoot: process.cwd(), + }); + + if (!result.success) { + return { + mode: 'output', + content: `Error: ${result.error || '未知错误'}`, + exitCode: 1, + }; + } + + const data = result.data as Record | undefined; + if (data?.action === 'invoke_skill') { + const skillName = data.skillName as string; + const skillArgs = data.skillArgs as string | undefined; + return { + mode: 'agent', + content: skillArgs + ? `Please use the "${skillName}" skill to help me with: ${skillArgs}` + : `Please use the "${skillName}" skill.`, + }; + } + + if ( + data?.action === 'invoke_custom_command' || + data?.action === 'invoke_plugin_command' + ) { + const processedContent = data.processedContent as string; + const commandName = data.commandName as string; + return { + mode: 'agent', + content: `# Custom Command: /${commandName}\n\n${processedContent}`, + }; + } + + return { + mode: 'output', + content: result.message || '', + exitCode: 0, + }; +} diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 70ce5447..1eb86a3b 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -174,7 +174,7 @@ export interface RuntimeConfig extends BladeConfig { // CLI 专属字段 - 其他 addDirs?: string[]; // 额外允许访问的目录 - outputFormat?: 'text' | 'json' | 'stream-json'; // 输出格式 + outputFormat?: 'text' | 'json' | 'stream-json' | 'jsonl'; // 输出格式 inputFormat?: 'text' | 'stream-json'; // 输入格式 print?: boolean; // 打印响应后退出 includePartialMessages?: boolean; // 包含部分消息 diff --git a/packages/cli/src/server/routes/session.ts b/packages/cli/src/server/routes/session.ts index 867c99aa..f51cbab5 100644 --- a/packages/cli/src/server/routes/session.ts +++ b/packages/cli/src/server/routes/session.ts @@ -4,9 +4,11 @@ import { LRUCache } from 'lru-cache'; import { nanoid } from 'nanoid'; import { z } from 'zod'; import { Agent } from '../../agent/Agent.js'; +import { SessionRuntime } from '../../agent/runtime/SessionRuntime.js'; import type { ChatContext, LoopOptions } from '../../agent/types.js'; import { PermissionMode } from '../../config/types.js'; import { createLogger, LogCategory } from '../../logging/Logger.js'; +import { McpRegistry } from '../../mcp/McpRegistry.js'; import type { Message } from '../../services/ChatServiceInterface.js'; import { SessionService } from '../../services/SessionService.js'; import type { ConfirmationDetails, ConfirmationResponse } from '../../tools/types/ExecutionTypes.js'; @@ -91,6 +93,7 @@ const sanitizeToolMetadata = (metadata: ToolResultMetadata | undefined) => { export const SessionRoutes = () => { const app = new Hono<{ Variables: Variables }>(); + const runtimes = new Map(); app.get('/', async (c) => { try { @@ -260,6 +263,14 @@ export const SessionRoutes = () => { logger.warn('[SessionRoutes] Failed to delete session file:', error); } sessions.delete(sessionId); + const runtime = runtimes.get(sessionId); + if (runtime) { + await runtime.dispose(); + runtimes.delete(sessionId); + if (runtimes.size === 0) { + await McpRegistry.getInstance().disconnectAll(); + } + } return c.json({ success: true }); }); @@ -366,7 +377,7 @@ export const SessionRoutes = () => { activeRuns.set(runId, run); session.currentRunId = runId; - executeRunAsync(run, session, content, permissionMode).catch((error) => { + executeRunAsync(run, session, content, permissionMode, runtimes).catch((error) => { logger.error(`[SessionRoutes] Run ${runId} failed:`, error); }); @@ -412,7 +423,8 @@ async function executeRunAsync( run: RunState, session: SessionInfo, content: string, - permissionMode: PermissionMode + permissionMode: PermissionMode, + runtimes: Map ): Promise { const { abortController, sessionId, id: runId } = run; const userMessageId = nanoid(12); @@ -427,7 +439,12 @@ async function executeRunAsync( emit('session.status', { status: 'running' }); emit('message.created', { messageId: assistantMessageId, role: 'assistant', content: '' }); - const agent = await Agent.create({}); + let runtime = runtimes.get(sessionId); + if (!runtime) { + runtime = await SessionRuntime.create({ sessionId }); + runtimes.set(sessionId, runtime); + } + const agent = await Agent.createWithRuntime(runtime, { sessionId }); const requestConfirmation = async (details: ConfirmationDetails): Promise => { const permissionId = nanoid(12); diff --git a/packages/cli/src/tools/execution/ExecutionPipeline.ts b/packages/cli/src/tools/execution/ExecutionPipeline.ts index 63eba146..29bd871e 100644 --- a/packages/cli/src/tools/execution/ExecutionPipeline.ts +++ b/packages/cli/src/tools/execution/ExecutionPipeline.ts @@ -14,6 +14,10 @@ import type { } from '../types/index.js'; import { ToolErrorType } from '../types/ToolTypes.js'; import { FileLockManager } from './FileLockManager.js'; +import { + InMemorySessionApprovalStore, + type SessionApprovalStore, +} from './SessionApprovalStore.js'; import { ConfirmationStage, DiscoveryStage, @@ -30,7 +34,7 @@ export class ExecutionPipeline extends EventEmitter { private stages: PipelineStage[]; private executionHistory: ExecutionHistoryEntry[] = []; private readonly maxHistorySize: number; - private readonly sessionApprovals = new Set(); + private readonly sessionApprovals: SessionApprovalStore; constructor( private registry: ToolRegistry, @@ -39,6 +43,7 @@ export class ExecutionPipeline extends EventEmitter { super(); this.maxHistorySize = config.maxHistorySize || 1000; + this.sessionApprovals = config.approvalStore || new InMemorySessionApprovalStore(); // 使用提供的权限配置或默认配置 const permissionConfig: PermissionConfig = config.permissionConfig || { @@ -435,6 +440,7 @@ export interface ExecutionPipelineConfig { customStages?: PipelineStage[]; permissionConfig?: PermissionConfig; permissionMode?: PermissionMode; + approvalStore?: SessionApprovalStore; } /** diff --git a/packages/cli/src/tools/execution/PipelineStages.ts b/packages/cli/src/tools/execution/PipelineStages.ts index 6dd6ef02..33d1e9ad 100644 --- a/packages/cli/src/tools/execution/PipelineStages.ts +++ b/packages/cli/src/tools/execution/PipelineStages.ts @@ -10,6 +10,7 @@ import { HookManager } from '../../hooks/HookManager.js'; import { createLogger, LogCategory } from '../../logging/Logger.js'; import { configActions, getConfig } from '../../store/vanilla.js'; import type { ToolRegistry } from '../registry/ToolRegistry.js'; +import type { SessionApprovalStore } from './SessionApprovalStore.js'; import type { PipelineStage, ToolExecution } from '../types/index.js'; import { isReadOnlyKind, ToolKind } from '../types/index.js'; import { @@ -50,14 +51,14 @@ export class DiscoveryStage implements PipelineStage { export class PermissionStage implements PipelineStage { readonly name = 'permission'; private permissionChecker: PermissionChecker; - private readonly sessionApprovals: Set; + private readonly sessionApprovals: SessionApprovalStore; // 🔧 重命名为 defaultPermissionMode,作为回退值 // 实际权限检查时优先使用 execution.context.permissionMode(动态值) private readonly defaultPermissionMode: PermissionMode; constructor( permissionConfig: PermissionConfig, - sessionApprovals: Set, + sessionApprovals: SessionApprovalStore, permissionMode: PermissionMode ) { this.permissionChecker = new PermissionChecker(permissionConfig); @@ -306,7 +307,7 @@ export class ConfirmationStage implements PipelineStage { private permissionChecker: PermissionChecker; constructor( - private readonly sessionApprovals: Set, + private readonly sessionApprovals: SessionApprovalStore, permissionChecker: PermissionChecker ) { this.permissionChecker = permissionChecker; diff --git a/packages/cli/src/tools/execution/SessionApprovalStore.ts b/packages/cli/src/tools/execution/SessionApprovalStore.ts new file mode 100644 index 00000000..5cbfa777 --- /dev/null +++ b/packages/cli/src/tools/execution/SessionApprovalStore.ts @@ -0,0 +1,21 @@ +export interface SessionApprovalStore { + has(signature: string): boolean; + add(signature: string): void; + clear(): void; +} + +export class InMemorySessionApprovalStore implements SessionApprovalStore { + private readonly approvals = new Set(); + + has(signature: string): boolean { + return this.approvals.has(signature); + } + + add(signature: string): void { + this.approvals.add(signature); + } + + clear(): void { + this.approvals.clear(); + } +} diff --git a/packages/cli/src/tools/registry/ToolRegistry.ts b/packages/cli/src/tools/registry/ToolRegistry.ts index 73ac3cd4..a6ef73ac 100644 --- a/packages/cli/src/tools/registry/ToolRegistry.ts +++ b/packages/cli/src/tools/registry/ToolRegistry.ts @@ -276,6 +276,20 @@ export class ToolRegistry extends EventEmitter { }; } + /** + * 克隆注册表(共享工具实例,但隔离注册表状态) + */ + clone(): ToolRegistry { + const cloned = new ToolRegistry(); + for (const tool of this.tools.values()) { + cloned.register(tool); + } + for (const tool of this.mcpTools.values()) { + cloned.registerMcpTool(tool); + } + return cloned; + } + /** * 注册MCP工具 */ diff --git a/packages/cli/src/ui/hooks/useAgent.ts b/packages/cli/src/ui/hooks/useAgent.ts index cc59936e..666ee457 100644 --- a/packages/cli/src/ui/hooks/useAgent.ts +++ b/packages/cli/src/ui/hooks/useAgent.ts @@ -6,8 +6,10 @@ import { useMemoizedFn } from 'ahooks'; import { useRef } from 'react'; import { Agent } from '../../agent/Agent.js'; +import { SessionRuntime } from '../../agent/runtime/SessionRuntime.js'; export interface AgentOptions { + sessionId?: string; systemPrompt?: string; appendSystemPrompt?: string; maxTurns?: number; @@ -26,18 +28,49 @@ export interface AgentOptions { */ export function useAgent(options: AgentOptions) { const agentRef = useRef(undefined); + const runtimeRef = useRef(undefined); /** * 创建并设置 Agent 实例 */ const createAgent = useMemoizedFn(async (overrides?: Partial): Promise => { - // 创建新 Agent - const agent = await Agent.create({ - systemPrompt: overrides?.systemPrompt ?? options.systemPrompt, - appendSystemPrompt: overrides?.appendSystemPrompt ?? options.appendSystemPrompt, - maxTurns: overrides?.maxTurns ?? options.maxTurns, - modelId: overrides?.modelId ?? options.modelId, - }); + const sessionId = overrides?.sessionId ?? options.sessionId; + const shouldUseEphemeralRuntime = + !!overrides?.modelId && overrides.modelId !== options.modelId; + + let agent: Agent; + if (!shouldUseEphemeralRuntime && sessionId) { + if (runtimeRef.current && runtimeRef.current.sessionId !== sessionId) { + await runtimeRef.current.dispose(); + runtimeRef.current = undefined; + } + + if (!runtimeRef.current) { + runtimeRef.current = await SessionRuntime.create({ + sessionId, + modelId: overrides?.modelId ?? options.modelId, + }); + } else { + await runtimeRef.current.refresh({ + modelId: overrides?.modelId ?? options.modelId, + }); + } + + agent = await Agent.createWithRuntime(runtimeRef.current, { + sessionId, + systemPrompt: overrides?.systemPrompt ?? options.systemPrompt, + appendSystemPrompt: overrides?.appendSystemPrompt ?? options.appendSystemPrompt, + maxTurns: overrides?.maxTurns ?? options.maxTurns, + modelId: overrides?.modelId ?? options.modelId, + }); + } else { + agent = await Agent.create({ + systemPrompt: overrides?.systemPrompt ?? options.systemPrompt, + appendSystemPrompt: overrides?.appendSystemPrompt ?? options.appendSystemPrompt, + maxTurns: overrides?.maxTurns ?? options.maxTurns, + modelId: overrides?.modelId ?? options.modelId, + }); + } agentRef.current = agent; // Agent 现在直接通过 vanilla store 更新 UI 状态 diff --git a/packages/cli/src/ui/hooks/useCommandHandler.ts b/packages/cli/src/ui/hooks/useCommandHandler.ts index 39a3254a..4f4f8b66 100644 --- a/packages/cli/src/ui/hooks/useCommandHandler.ts +++ b/packages/cli/src/ui/hooks/useCommandHandler.ts @@ -281,6 +281,7 @@ export const useCommandHandler = ( // 使用 Agent 管理 Hook // Agent 现在直接通过 vanilla store 更新 todos,不需要回调 const { createAgent, cleanupAgent } = useAgent({ + sessionId, systemPrompt: replaceSystemPrompt, appendSystemPrompt: appendSystemPrompt, maxTurns: maxTurns, diff --git a/packages/cli/tests/integration/cli/blade-help.test.ts b/packages/cli/tests/integration/cli/blade-help.test.ts index 12b1f1ed..d5f7fe66 100644 --- a/packages/cli/tests/integration/cli/blade-help.test.ts +++ b/packages/cli/tests/integration/cli/blade-help.test.ts @@ -27,5 +27,28 @@ describe('Blade CLI 基本行为', () => { const combinedOutput = `${result.stdout}\n${result.stderr}`; expect(combinedOutput.length).toBeGreaterThan(0); expect(combinedOutput.toLowerCase()).toContain('blade'); + expect(combinedOutput).toContain('--headless'); + }); + + it('执行 --headless /help 应该走 headless 入口并成功退出', () => { + if (!existsSync(CLI_ENTRY)) { + console.warn( + '[cli] dist/blade.js 不存在,跳过 CLI 测试(请先运行 npm run build)' + ); + return; + } + + const result = spawnSync('node', [CLI_ENTRY, '--headless', '/help'], { + encoding: 'utf-8', + env: { + ...process.env, + BLADE_TELEMETRY_DISABLED: '1', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const combinedOutput = `${result.stdout}\n${result.stderr}`; + expect(combinedOutput).toContain('帮助信息已显示'); }); }); diff --git a/packages/cli/tests/integration/pipeline.test.ts b/packages/cli/tests/integration/pipeline.test.ts index 1344a887..6787b476 100644 --- a/packages/cli/tests/integration/pipeline.test.ts +++ b/packages/cli/tests/integration/pipeline.test.ts @@ -90,6 +90,69 @@ describe('ExecutionPipeline 权限集成', () => { expect(confirmation).toHaveBeenCalledTimes(1); }); + it('共享审批状态时应跨 turn 的 pipeline 复用 session 批准', async () => { + const registry = new ToolRegistry(); + registry.register(createTestTool() as any); + + const approvals = new Set(); + const approvalStore = { + has: vi.fn((signature: string) => approvals.has(signature)), + add: vi.fn((signature: string) => { + approvals.add(signature); + }), + clear: vi.fn(() => { + approvals.clear(); + }), + }; + + const firstPipeline = new ExecutionPipeline(registry, { + permissionConfig: { + allow: [], + ask: ['TestTool'], + deny: [], + }, + approvalStore, + }); + + const secondPipeline = new ExecutionPipeline(registry, { + permissionConfig: { + allow: [], + ask: ['TestTool'], + deny: [], + }, + approvalStore, + }); + + const confirmation = vi.fn(async () => ({ + approved: true, + scope: 'session' as const, + })); + + const context: ExecutionContext = { + signal: new AbortController().signal, + confirmationHandler: { + requestConfirmation: confirmation, + }, + }; + + const first = await firstPipeline.execute( + 'TestTool', + { value: 'same' } as any, + context + ); + const second = await secondPipeline.execute( + 'TestTool', + { value: 'same' } as any, + context + ); + + expect(first.success).toBe(true); + expect(second.success).toBe(true); + expect(approvalStore.has).toHaveBeenCalled(); + expect(approvalStore.add).toHaveBeenCalledTimes(1); + expect(confirmation).toHaveBeenCalledTimes(1); + }); + it('DENY 规则应直接拒绝执行', async () => { const registry = new ToolRegistry(); registry.register(createTestTool() as any); diff --git a/packages/cli/tests/unit/agent-runtime/acp/session.test.ts b/packages/cli/tests/unit/agent-runtime/acp/session.test.ts index 54e8e7d8..8d071c77 100644 --- a/packages/cli/tests/unit/agent-runtime/acp/session.test.ts +++ b/packages/cli/tests/unit/agent-runtime/acp/session.test.ts @@ -7,6 +7,13 @@ import { AcpSession } from '../../../../src/acp/Session.js'; import { createMockACPClient } from '../../../support/mocks/mockACPClient.js'; import { createMockAgent } from '../../../support/mocks/mockAgent.js'; +const runtimeState = vi.hoisted(() => ({ + runtime: { + sessionId: 'test-session-id', + dispose: vi.fn().mockResolvedValue(undefined), + }, +})); + // Mock Agent vi.mock('../../../../src/agent/Agent.js', () => { let mockAgentInstance: any = null; @@ -26,6 +33,13 @@ vi.mock('../../../../src/agent/Agent.js', () => { mockAgentInstance = mockAgent; return mockAgent; }), + createWithRuntime: vi.fn().mockImplementation(async () => { + const mockAgent = createMockAgent(); + mockAgent.chat = vi.fn().mockResolvedValue('Mock response'); + mockAgent.destroy = vi.fn().mockResolvedValue(undefined); + mockAgentInstance = mockAgent; + return mockAgent; + }), } ); @@ -35,6 +49,12 @@ vi.mock('../../../../src/agent/Agent.js', () => { }; }); +vi.mock('../../../../src/agent/runtime/SessionRuntime.js', () => ({ + SessionRuntime: { + create: vi.fn(async () => runtimeState.runtime), + }, +})); + // Mock AcpServiceContext vi.mock('../../../../src/acp/AcpServiceContext.js', () => ({ isAcpMode: vi.fn(() => true), @@ -90,6 +110,7 @@ describe('AcpSession', () => { afterEach(() => { vi.clearAllMocks(); + runtimeState.runtime.dispose.mockClear(); }); describe('initialize', () => { @@ -106,12 +127,15 @@ describe('AcpSession', () => { ); }); - it('应该创建 Agent 实例', async () => { + it('应该创建 SessionRuntime 并注入 Agent 实例', async () => { await session.initialize(); - // 简单验证 initialize 方法不抛出错误 - // Agent 实例的创建在 mock 中完成 - expect(true).toBe(true); + const { SessionRuntime } = await import('../../../../src/agent/runtime/SessionRuntime.js'); + const { Agent } = await import('../../../../src/agent/Agent.js'); + expect(SessionRuntime.create).toHaveBeenCalledWith({ sessionId: 'test-session-id' }); + expect(Agent.createWithRuntime).toHaveBeenCalledWith(runtimeState.runtime, { + sessionId: 'test-session-id', + }); }); }); @@ -289,6 +313,7 @@ describe('AcpSession', () => { // 验证 ACP 服务上下文已销毁 const { AcpServiceContext } = await import('../../../../src/acp/AcpServiceContext.js'); expect(AcpServiceContext.destroySession).toHaveBeenCalledWith('test-session-id'); + expect(runtimeState.runtime.dispose).toHaveBeenCalledTimes(1); }); it('应该取消挂起的提示', async () => { diff --git a/packages/cli/tests/unit/agent-runtime/agent/agent-create.test.ts b/packages/cli/tests/unit/agent-runtime/agent/agent-create.test.ts new file mode 100644 index 00000000..5506a0d6 --- /dev/null +++ b/packages/cli/tests/unit/agent-runtime/agent/agent-create.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { Agent } from '../../../../src/agent/Agent.js'; + +describe('Agent.create', () => { + it('rejects session-scoped creation and requires an explicit runtime owner', async () => { + await expect(Agent.create({ sessionId: 'session-1' })).rejects.toThrow( + 'Agent.create() does not accept sessionId' + ); + }); +}); diff --git a/packages/cli/tests/unit/agent-runtime/agent/background-agent-manager.test.ts b/packages/cli/tests/unit/agent-runtime/agent/background-agent-manager.test.ts index b3328090..4efc5db4 100644 --- a/packages/cli/tests/unit/agent-runtime/agent/background-agent-manager.test.ts +++ b/packages/cli/tests/unit/agent-runtime/agent/background-agent-manager.test.ts @@ -7,9 +7,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Message } from '../../../../src/services/ChatServiceInterface.js'; +const runtimeState = vi.hoisted(() => ({ + runtime: { + sessionId: 'session_test-uuid-1234', + dispose: vi.fn().mockResolvedValue(undefined), + }, +})); + // Mock 所有依赖 vi.mock('../../../../src/agent/subagents/AgentSessionStore.js'); -vi.mock('../../../../src/agent/Agent.js'); +vi.mock('../../../../src/agent/Agent.js', () => ({ + Agent: { + create: vi.fn(), + createWithRuntime: vi.fn(), + }, +})); +vi.mock('../../../../src/agent/runtime/SessionRuntime.js', () => ({ + SessionRuntime: { + create: vi.fn(async () => runtimeState.runtime), + }, +})); vi.mock('../../../../src/logging/Logger.js', () => ({ createLogger: () => ({ debug: vi.fn(), @@ -24,6 +41,7 @@ vi.mock('nanoid', () => ({ })); import { Agent } from '../../../../src/agent/Agent.js'; +import { SessionRuntime } from '../../../../src/agent/runtime/SessionRuntime.js'; import { AgentSessionStore } from '../../../../src/agent/subagents/AgentSessionStore.js'; import { BackgroundAgentManager } from '../../../../src/agent/subagents/BackgroundAgentManager.js'; @@ -54,7 +72,7 @@ describe('BackgroundAgentManager', () => { metadata: { tokensUsed: 100, toolCallsCount: 5 }, }), }; - vi.mocked(Agent.create).mockResolvedValue(mockAgent as any); + vi.mocked(Agent.createWithRuntime).mockResolvedValue(mockAgent as any); manager = BackgroundAgentManager.getInstance(); }); @@ -73,6 +91,32 @@ describe('BackgroundAgentManager', () => { }); describe('startBackgroundAgent', () => { + it('应为后台 agent 创建独立 runtime', async () => { + manager.startBackgroundAgent({ + config: { + name: 'Explore', + description: 'Explore agent', + systemPrompt: 'You are an explorer', + }, + description: 'Test task', + prompt: 'Do something', + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(SessionRuntime.create).toHaveBeenCalledWith({ + sessionId: 'session_test-uuid-1234', + modelId: undefined, + }); + expect(Agent.createWithRuntime).toHaveBeenCalledWith( + runtimeState.runtime, + expect.objectContaining({ + sessionId: 'session_test-uuid-1234', + systemPrompt: 'You are an explorer', + }) + ); + }); + it('应启动后台 agent 并返回 ID', () => { const agentId = manager.startBackgroundAgent({ config: { diff --git a/packages/cli/tests/unit/agent-runtime/agent/session-runtime.test.ts b/packages/cli/tests/unit/agent-runtime/agent/session-runtime.test.ts new file mode 100644 index 00000000..a9c2fdb3 --- /dev/null +++ b/packages/cli/tests/unit/agent-runtime/agent/session-runtime.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SessionRuntime } from '../../../../src/agent/runtime/SessionRuntime.js'; + +vi.mock('../../../../src/store/vanilla.js', () => ({ + ensureStoreInitialized: vi.fn(async () => {}), + getAllModels: vi.fn(() => [{ id: 'model-1' }]), + getConfig: vi.fn(() => ({ + permissionMode: 'default', + permissions: {}, + language: 'zh-CN', + maxContextTokens: 128000, + temperature: 0, + maxOutputTokens: 8192, + timeout: 30000, + })), + getCurrentModel: vi.fn(() => ({ + id: 'model-1', + name: 'Model 1', + model: 'model-1', + provider: 'openai', + apiKey: 'test', + temperature: 0, + maxContextTokens: 128000, + maxOutputTokens: 8192, + })), + getMcpServers: vi.fn(() => ({})), + getModelById: vi.fn(() => undefined), + getThinkingModeEnabled: vi.fn(() => false), +})); + +vi.mock('../../../../src/config/index.js', async () => { + const actual = await vi.importActual('../../../../src/config/index.js'); + return { + ...actual, + ConfigManager: { + getInstance: vi.fn(() => ({ + validateConfig: vi.fn(), + })), + }, + }; +}); + +vi.mock('../../../../src/prompts/index.js', () => ({ + buildSystemPrompt: vi.fn(async () => ({ prompt: '', sources: [] })), +})); + +vi.mock('../../../../src/tools/builtin/index.js', () => ({ + getBuiltinTools: vi.fn(async () => []), +})); + +vi.mock('../../../../src/skills/index.js', () => ({ + discoverSkills: vi.fn(async () => ({ skills: [], errors: [] })), +})); + +vi.mock('../../../../src/services/ChatServiceInterface.js', () => ({ + createChatServiceAsync: vi.fn(async () => ({ + chat: vi.fn(), + streamChat: vi.fn(), + getConfig: vi.fn(() => ({ + model: 'model-1', + maxContextTokens: 128000, + maxOutputTokens: 8192, + })), + updateConfig: vi.fn(), + })), +})); + +describe('SessionRuntime', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates a runtime from the current store config', async () => { + const runtime = await SessionRuntime.create({ sessionId: 'session-1' }); + + expect(runtime.sessionId).toBe('session-1'); + }); + + it('disposes the chat service when it supports disposal', async () => { + const runtime = new SessionRuntime({} as any, { sessionId: 'session-1' }); + const chatDispose = vi.fn(async () => {}); + + (runtime as any).chatService = { + dispose: chatDispose, + }; + (runtime as any).initialized = true; + + await runtime.dispose(); + + expect(chatDispose).toHaveBeenCalledTimes(1); + expect((runtime as any).initialized).toBe(false); + }); +}); diff --git a/packages/cli/tests/unit/agent-runtime/server/session-routes.test.ts b/packages/cli/tests/unit/agent-runtime/server/session-routes.test.ts new file mode 100644 index 00000000..d2406ca5 --- /dev/null +++ b/packages/cli/tests/unit/agent-runtime/server/session-routes.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const runtimeState = vi.hoisted(() => ({ + runtime: { + sessionId: 'session-1', + dispose: vi.fn().mockResolvedValue(undefined), + refresh: vi.fn().mockResolvedValue(undefined), + getConfig: vi.fn(() => ({})), + createExecutionPipeline: vi.fn(() => ({})), + getChatService: vi.fn(), + getExecutionEngine: vi.fn(), + getAttachmentCollector: vi.fn(), + getCurrentModelId: vi.fn(() => 'model-1'), + getCurrentModelMaxContextTokens: vi.fn(() => 128000), + }, +})); + +const agentState = vi.hoisted(() => ({ + chat: vi.fn().mockResolvedValue('assistant reply'), +})); + +vi.mock('../../../../src/agent/runtime/SessionRuntime.js', () => ({ + SessionRuntime: { + create: vi.fn(async () => runtimeState.runtime), + }, +})); + +vi.mock('../../../../src/agent/Agent.js', () => ({ + Agent: { + createWithRuntime: vi.fn(async () => ({ + chat: agentState.chat, + })), + }, +})); + +vi.mock('../../../../src/server/bus.js', () => ({ + Bus: { + publish: vi.fn(), + subscribe: vi.fn(() => () => {}), + }, +})); + +vi.mock('../../../../src/services/SessionService.js', () => ({ + SessionService: { + listSessions: vi.fn(async () => []), + loadSession: vi.fn(async () => []), + deleteSession: vi.fn(async () => {}), + }, +})); + +vi.mock('../../../../src/logging/Logger.js', () => ({ + LogCategory: { + SERVICE: 'service', + }, + createLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), +})); + +describe('SessionRoutes runtime reuse', () => { + beforeEach(() => { + vi.clearAllMocks(); + runtimeState.runtime.dispose.mockClear(); + runtimeState.runtime.refresh.mockClear(); + agentState.chat.mockResolvedValue('assistant reply'); + }); + + afterEach(() => { + vi.resetModules(); + }); + + it('reuses one SessionRuntime for repeated messages in the same session', async () => { + const { SessionRoutes } = await import('../../../../src/server/routes/session.js'); + const { SessionRuntime } = await import('../../../../src/agent/runtime/SessionRuntime.js'); + const { Agent } = await import('../../../../src/agent/Agent.js'); + + const app = SessionRoutes(); + + const sendMessage = async (content: string) => { + const response = await app.request('/session-1/message', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ content }), + }); + + expect(response.status).toBe(202); + await new Promise((resolve) => setTimeout(resolve, 0)); + }; + + await sendMessage('first'); + await sendMessage('second'); + + expect(SessionRuntime.create).toHaveBeenCalledTimes(1); + expect(SessionRuntime.create).toHaveBeenCalledWith({ sessionId: 'session-1' }); + expect(Agent.createWithRuntime).toHaveBeenCalledTimes(2); + expect(Agent.createWithRuntime).toHaveBeenNthCalledWith(1, runtimeState.runtime, { + sessionId: 'session-1', + }); + expect(Agent.createWithRuntime).toHaveBeenNthCalledWith(2, runtimeState.runtime, { + sessionId: 'session-1', + }); + }); +}); diff --git a/packages/cli/tests/unit/cli/command-input.test.ts b/packages/cli/tests/unit/cli/command-input.test.ts new file mode 100644 index 00000000..9b6f359c --- /dev/null +++ b/packages/cli/tests/unit/cli/command-input.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const pluginState = vi.hoisted(() => ({ + initialize: vi.fn(), + integrateAllPlugins: vi.fn(), +})); + +const slashState = vi.hoisted(() => ({ + isSlashCommand: vi.fn(), + executeSlashCommand: vi.fn(), +})); + +vi.mock('../../../src/plugins/index.js', () => ({ + getPluginRegistry: vi.fn(() => ({ + initialize: pluginState.initialize, + })), + integrateAllPlugins: pluginState.integrateAllPlugins, +})); + +vi.mock('../../../src/slash-commands/index.js', () => ({ + isSlashCommand: slashState.isSlashCommand, + executeSlashCommand: slashState.executeSlashCommand, +})); + +describe('command input helpers', () => { + beforeEach(() => { + vi.clearAllMocks(); + pluginState.initialize.mockResolvedValue({ plugins: [] }); + pluginState.integrateAllPlugins.mockResolvedValue(undefined); + slashState.isSlashCommand.mockReturnValue(false); + }); + + it('initializes plugins and only integrates when plugins are present', async () => { + const { initializeCliPlugins } = await import('../../../src/commands/shared/commandInput.js'); + + await initializeCliPlugins(); + expect(pluginState.integrateAllPlugins).not.toHaveBeenCalled(); + + pluginState.initialize.mockResolvedValueOnce({ plugins: [{ name: 'demo' }] }); + await initializeCliPlugins(); + expect(pluginState.integrateAllPlugins).toHaveBeenCalledTimes(1); + }); + + it('normalizes slash command requests into agent prompts', async () => { + slashState.isSlashCommand.mockReturnValue(true); + slashState.executeSlashCommand.mockResolvedValue({ + success: true, + data: { + action: 'invoke_skill', + skillName: 'brainstorming', + skillArgs: 'design a runner', + }, + }); + + const { normalizeCliInput } = await import('../../../src/commands/shared/commandInput.js'); + const result = await normalizeCliInput('/brainstorming design a runner'); + + expect(result).toEqual({ + mode: 'agent', + content: 'Please use the "brainstorming" skill to help me with: design a runner', + }); + }); +}); diff --git a/packages/cli/tests/unit/cli/headless-events.test.ts b/packages/cli/tests/unit/cli/headless-events.test.ts new file mode 100644 index 00000000..6ca0fd53 --- /dev/null +++ b/packages/cli/tests/unit/cli/headless-events.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +describe('headless event contract', () => { + it('exports a stable event version and validates tool events', async () => { + const { + HEADLESS_EVENT_VERSION, + HeadlessJsonlEventSchema, + createHeadlessJsonlEvent, + } = await import('../../../src/commands/headlessEvents.js'); + + expect(HEADLESS_EVENT_VERSION).toBe(1); + + const event = createHeadlessJsonlEvent('tool_start', { + tool_name: 'Read', + summary: 'Reading demo.ts', + }); + + expect(event).toEqual({ + event_version: 1, + type: 'tool_start', + tool_name: 'Read', + summary: 'Reading demo.ts', + }); + + expect(() => HeadlessJsonlEventSchema.parse(event)).not.toThrow(); + }); +}); diff --git a/packages/cli/tests/unit/cli/headless.test.ts b/packages/cli/tests/unit/cli/headless.test.ts new file mode 100644 index 00000000..0de6ae2e --- /dev/null +++ b/packages/cli/tests/unit/cli/headless.test.ts @@ -0,0 +1,281 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const agentState = vi.hoisted(() => ({ + create: vi.fn(), + chat: vi.fn(), +})); + +vi.mock('../../../src/agent/Agent.js', () => ({ + Agent: { + create: agentState.create, + }, +})); + +describe('headless runner', () => { + beforeEach(() => { + vi.clearAllMocks(); + agentState.chat.mockResolvedValue('final response'); + agentState.create.mockResolvedValue({ + chat: agentState.chat, + }); + }); + + it('defaults to yolo permissions and prints streamed frontend events', async () => { + const stdout = { write: vi.fn<(chunk: string) => boolean>(() => true) }; + const stderr = { write: vi.fn<(chunk: string) => boolean>(() => true) }; + + agentState.chat.mockImplementationOnce(async (_input, _context, loopOptions) => { + loopOptions?.onThinkingDelta?.('reasoning'); + loopOptions?.onContentDelta?.('hello'); + loopOptions?.onToolStart?.({ + id: 'tool-1', + type: 'function', + function: { + name: 'Read', + arguments: JSON.stringify({ file_path: '/tmp/demo.ts' }), + }, + }); + await loopOptions?.onToolResult?.( + { + id: 'tool-1', + type: 'function', + function: { + name: 'Read', + arguments: JSON.stringify({ file_path: '/tmp/demo.ts' }), + }, + }, + { + success: true, + displayContent: 'const demo = true;', + metadata: { + summary: 'Read demo.ts', + content_preview: 'const demo = true;', + }, + } + ); + loopOptions?.onTodoUpdate?.([ + { + id: 'todo-1', + content: 'Ship headless mode', + status: 'in_progress', + activeForm: 'Shipping headless mode', + priority: 'high', + createdAt: new Date().toISOString(), + }, + ]); + loopOptions?.onTokenUsage?.({ + inputTokens: 10, + outputTokens: 20, + totalTokens: 30, + maxContextTokens: 1000, + }); + loopOptions?.onStreamEnd?.(); + return 'final response'; + }); + + const { runHeadless } = await import('../../../src/commands/headless.js'); + + const exitCode = await runHeadless( + { + headless: true, + message: 'inspect this repo', + }, + { stdout, stderr } + ); + const stderrOutput = stderr.write.mock.calls + .map((call) => String(call[0] ?? '')) + .join(''); + + expect(exitCode).toBe(0); + expect(agentState.create).toHaveBeenCalledWith( + expect.objectContaining({ + permissionMode: 'yolo', + }) + ); + expect(stdout.write).toHaveBeenCalledWith('hello'); + expect(stderrOutput).toContain('[thinking] reasoning'); + expect(stderrOutput).toContain('Reading demo.ts'); + expect(stderrOutput).toContain('Read demo.ts'); + expect(stderrOutput).toContain('[todo] [in_progress] Ship headless mode'); + expect(stderrOutput).toContain('[tokens] in=10 out=20 total=30 / 1000'); + }); + + it('emits structured jsonl events when outputFormat=jsonl', async () => { + const stdout = { write: vi.fn<(chunk: string) => boolean>(() => true) }; + const stderr = { write: vi.fn<(chunk: string) => boolean>(() => true) }; + + agentState.chat.mockImplementationOnce(async (_input, _context, loopOptions) => { + loopOptions?.onContentDelta?.('hello'); + loopOptions?.onToolStart?.({ + id: 'tool-2', + type: 'function', + function: { + name: 'Read', + arguments: JSON.stringify({ file_path: '/tmp/demo.ts' }), + }, + }); + loopOptions?.onTodoUpdate?.([ + { + id: 'todo-2', + content: 'Capture jsonl', + status: 'pending', + activeForm: 'Capturing jsonl', + priority: 'medium', + createdAt: new Date().toISOString(), + }, + ]); + loopOptions?.onStreamEnd?.(); + return 'final response'; + }); + + const { runHeadless } = await import('../../../src/commands/headless.js'); + const { HeadlessJsonlEventSchema } = await import( + '../../../src/commands/headlessEvents.js' + ); + const exitCode = await runHeadless( + { + headless: true, + outputFormat: 'jsonl', + message: 'inspect this repo', + }, + { stdout, stderr } + ); + + const lines = stdout.write.mock.calls + .map((call) => String(call[0] ?? '')) + .join('') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => HeadlessJsonlEventSchema.parse(JSON.parse(line))); + + expect(exitCode).toBe(0); + expect(stderr.write).not.toHaveBeenCalled(); + expect(lines.every((line) => line.event_version === 1)).toBe(true); + expect(lines).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'content_delta', + event_version: 1, + delta: 'hello', + }), + expect.objectContaining({ + type: 'tool_start', + event_version: 1, + tool_name: 'Read', + }), + expect.objectContaining({ + type: 'todo_update', + event_version: 1, + todos: expect.arrayContaining([ + expect.objectContaining({ content: 'Capture jsonl' }), + ]), + }), + expect.objectContaining({ type: 'stream_end', event_version: 1 }), + ]) + ); + }); + + it('rejects invalid runtime options before creating the agent', async () => { + const stdout = { write: vi.fn<(chunk: string) => boolean>(() => true) }; + const stderr = { write: vi.fn<(chunk: string) => boolean>(() => true) }; + + const { runHeadless } = await import('../../../src/commands/headless.js'); + + const exitCode = await runHeadless( + { + headless: true, + message: 'inspect this repo', + outputFormat: 'xml', + }, + { stdout, stderr } + ); + + const stderrOutput = stderr.write.mock.calls + .map((call) => String(call[0] ?? '')) + .join(''); + + expect(exitCode).toBe(1); + expect(agentState.create).not.toHaveBeenCalled(); + expect(stderrOutput).toContain('outputFormat'); + }); + + it('emits compacting markers and resets streamed state across stream cycles', async () => { + const stdout = { write: vi.fn<(chunk: string) => boolean>(() => true) }; + const stderr = { write: vi.fn<(chunk: string) => boolean>(() => true) }; + + agentState.chat.mockImplementationOnce(async (_input, _context, loopOptions) => { + loopOptions?.onThinkingDelta?.('first'); + loopOptions?.onContentDelta?.('hello'); + loopOptions?.onStreamEnd?.(); + loopOptions?.onCompacting?.(true); + loopOptions?.onCompacting?.(false); + loopOptions?.onThinkingDelta?.('second'); + loopOptions?.onStreamEnd?.(); + return 'final response'; + }); + + const { runHeadless } = await import('../../../src/commands/headless.js'); + + const exitCode = await runHeadless( + { + headless: true, + message: 'inspect this repo', + }, + { stdout, stderr } + ); + + const stdoutOutput = stdout.write.mock.calls + .map((call) => String(call[0] ?? '')) + .join(''); + const stderrOutput = stderr.write.mock.calls + .map((call) => String(call[0] ?? '')) + .join(''); + + expect(exitCode).toBe(0); + expect(stdoutOutput).toBe('hello\n'); + expect(stderrOutput).toContain('[thinking] first\n'); + expect(stderrOutput).toContain('[thinking] second'); + expect(stderrOutput).toContain('[context] compacting started'); + expect(stderrOutput).toContain('[context] compacting completed'); + }); + + it('prints structured error events when agent execution fails', async () => { + const stdout = { write: vi.fn<(chunk: string) => boolean>(() => true) }; + const stderr = { write: vi.fn<(chunk: string) => boolean>(() => true) }; + + agentState.chat.mockRejectedValueOnce(new Error('boom')); + + const { runHeadless } = await import('../../../src/commands/headless.js'); + const { HeadlessJsonlEventSchema } = await import( + '../../../src/commands/headlessEvents.js' + ); + + const exitCode = await runHeadless( + { + headless: true, + outputFormat: 'jsonl', + message: 'inspect this repo', + }, + { stdout, stderr } + ); + + const lines = stdout.write.mock.calls + .map((call) => String(call[0] ?? '')) + .join('') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => HeadlessJsonlEventSchema.parse(JSON.parse(line))); + + expect(exitCode).toBe(1); + expect(stderr.write).not.toHaveBeenCalled(); + expect(lines).toEqual([ + expect.objectContaining({ + type: 'error', + event_version: 1, + message: 'Error: boom', + }), + ]); + }); +});