diff --git a/docs/hooks.md b/docs/hooks.md index ce8b8b2d..3a4f25c2 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -1,142 +1,108 @@ # 钩子系统 -Coding Code 提供可插拔的钩子点,用户可以在关键节点注入自定义逻辑。本文档介绍所有钩子点、回调签名、注册 API 和用户钩子配置。 +Coding Code 提供可插拔的钩子点,用户可以在关键节点注入自定义逻辑。本文档介绍所有钩子点、回调签名、触发 API 和用户钩子配置。 --- ## 钩子点 +共 12 个。**表里列出的每个点在生产代码里都有真实触发点**;`type` 列是该点有意义的钩子类型(用户配置里写错类型不会报错,但决策不会被消费)。 + ### 工具执行 -| 钩子点 | 触发时机 | 类型 | +| 钩子点 | 触发时机 | type | |--------|---------|------| -| `tool.execute.before` | 工具执行前 | observer | +| `tool.execute.before` | 工具执行前(已通过审批) | observer | | `tool.execute.after` | 工具执行成功后 | observer | | `tool.execute.error` | 工具执行失败后 | observer | -| `tool.execute.denied` | 工具被审批拒绝后 | observer | -| `tool.approval.pre` | 审批决策前 | decision | -| `tool.approval.post` | 审批决策后 | observer | - -### LLM 调用 - -| 钩子点 | 触发时机 | 类型 | -|--------|---------|------| -| `llm.request.before` | LLM 调用前 | observer | -| `llm.response.after` | LLM 响应成功后 | observer | -| `llm.response.error` | LLM 调用失败后 | observer | - -### 会话 +| `tool.approval.pre` | 审批决策前(第 3 层) | **decision** | +| `tool.approval.post` | 审批决策后(审计,含 `decision` 与 `layers`) | observer | -| 钩子点 | 触发时机 | 类型 | -|--------|---------|------| -| `session.save.before` | 会话保存前 | observer | -| `session.save.after` | 会话保存后 | observer | +工具被拒绝时不触发 `tool.execute.*`(工具没被执行),拒绝结果从 `tool.approval.post` 的 `decision.type === 'deny'` 读取。 ### Agent 生命周期 -| 钩子点 | 触发时机 | 类型 | +| 钩子点 | 触发时机 | type | |--------|---------|------| -| `agent.turn.start` | Agent 轮次开始 | observer | -| `agent.step.before` | Agent 步骤执行前 | observer | -| `agent.turn.stop` | Agent 轮次停止 | observer | -| `agent.turn.end` | Agent 轮次结束 | observer | +| `agent.turn.start` | 轮次开始 | observer | +| `agent.step.before` | 每个推理步骤前 | **decision**(返回值当前未被消费) | +| `agent.turn.stop` | 本轮无工具调用、准备停止时裁决 | **decision** | +| `agent.turn.end` | 轮次最终结束(`status`: done/error/aborted/maxSteps) | observer | ### 子智能体 -| 钩子点 | 触发时机 | 类型 | +| 钩子点 | 触发时机 | type | |--------|---------|------| -| `agent.subagent.spawn.before` | 子智能体创建前 | decision(可 deny) | +| `agent.subagent.spawn.before` | 子智能体创建前 | **decision**(可 deny) | | `agent.subagent.spawn.after` | 子智能体创建后 | observer | | `agent.subagent.complete` | 子智能体完成时 | observer | --- -## 回调函数签名 +## 回调签名 -### Observer 钩子 +钩子以子进程形式运行,payload 是一份 JSON,所以签名只描述数据的形状: ```typescript -type ObserverHandler = (payload: Record) => void | Promise; -``` - -Observer 钩子只观察事件,不返回决策。适用于日志、监控、通知等场景。 - -### Decision 钩子 +type ObserverHandler = (payload: Record) => Effect.Effect; -```typescript -type DecisionHandler = (payload: Record) => HookDecision | null | Promise; +type DecisionHandler = ( + payload: Record +) => HookDecision | null | Promise; interface HookDecision { decision?: 'allow' | 'deny' | 'ask' | 'continue'; reason?: string; - injection?: string; // 注入到 LLM 上下文的文本 + injection?: string; // 注入到 LLM 上下文的文本 modifiedInput?: Record; // 修改工具调用参数 - modifiedOutput?: unknown; // 修改工具输出 } ``` -Decision 钩子可以返回决策,影响后续流程: +Decision 钩子的返回语义: - `allow`:直接放行,跳过后续审批层 -- `deny`:拒绝执行,附带 reason +- `deny`:拒绝,附带 `reason` - `ask`:要求用户确认 -- `continue`:继续到下一层 -- `null`:不干预,继续正常流程 +- `continue`:在 `tool.approval.pre` 上表示「不干预,继续到下一层」 +- `null`:不干预(多个 decision 钩子按 priority 升序取**首个非 null**) ---- +**每个 payload 都带 `projectPath`** —— 它是钩子作用域的定位键(见下),也是 hook 脚本判断「我在哪个项目里跑」的依据。 -## HookRegistry API +--- -`HookService` 是 Effect.Service,提供以下方法: +## 触发 API -### 注册钩子 +`HookService` 是 Effect Service,只有三个方法: -```typescript -// 注册 observer 钩子,返回取消函数 -const unsubscribe = hookService.register('tool.execute.after', async (payload) => { - console.log(`工具 ${payload.toolName} 执行完成,耗时 ${payload.duration}ms`); -}); +| 方法 | 说明 | +|------|------| +| `emit(point, payload)` | 触发该点上所有 **observer** 钩子;单个钩子抛错只记日志,不带垮整轮 | +| `emitDecision(point, payload)` | 触发该点上所有 **decision** 钩子,按 priority 升序取首个非 null | +| `reloadUserHooks(projectPath)` | 重新解析该项目的 YAML 配置并重建注册表 | -// 注册 decision 钩子,支持 priority -const unsub = hookService.registerDecision('tool.approval.pre', async (payload) => { - if (payload.toolName === 'execute_command' && payload.args.command.includes('rm')) { - return { decision: 'ask', reason: '删除命令需要确认' }; - } - return null; // 不干预 -}, { priority: 100 }); -``` +没有代码级注册 API:钩子只有 YAML 一个来源。运行时**每次 `emit` 都用 payload 里的 `projectPath` 查注册表**,查不到就是空表(no-op)。 -### 生命周期管理 +### 作用域 -| 方法 | 说明 | -|------|------| -| `register(point, handler, opts?)` | 注册 observer 钩子,返回取消函数 | -| `registerDecision(point, handler, opts?)` | 注册 decision 钩子,支持 priority | -| `emit(point, payload)` | 触发 observer 钩子 | -| `emitDecision(point, payload)` | 触发 decision 钩子,返回第一个非 null 决策 | -| `reloadUserHooks(projectPath)` | 重新加载项目级用户钩子配置 | -| `attachSessionHooks(sessionId, hooks)` | 附加会话级钩子 | -| `disableHook(projectPath, name)` | 禁用指定钩子 | -| `enableHook(projectPath, name)` | 启用指定钩子 | -| `disposeSession(sessionId)` | 清理会话级钩子 | -| `disposeProject(projectPath)` | 清理项目级钩子 | +钩子按层解析,**字段级合并**: -### 钩子作用域 +1. **project** — `.codingcode/hooks.yaml` +2. **global** — `~/.codingcode/hooks.yaml` -钩子按作用域分层,优先级从高到低: +两层都用 `name` 对齐。项目层只覆盖它**显式声明**的字段,其余字段继承全局。所以「在项目里关掉一个只在全局定义的钩子」只需写一条最小补丁: -1. **session** — 会话级,通过 `attachSessionHooks` 附加 -2. **project** — 项目级,从 `.codingcode/hooks.yaml` 加载 -3. **global** — 全局级,从 `~/.codingcode/hooks.yaml` 加载 +```yaml +hooks: + - name: log-llm-calls + enabled: false +``` -同一作用域内按 `priority` 排序,数值越大优先级越高。 +同一层内按 `priority` **升序**执行,**数值小的先跑**。 --- ## 用户钩子配置 -通过 YAML 文件配置钩子,无需编写代码: - ### 配置文件位置 | 级别 | 路径 | @@ -148,14 +114,13 @@ const unsub = hookService.registerDecision('tool.approval.pre', async (payload) ```yaml hooks: - - name: log-llm-calls - description: 记录所有 LLM 调用 - point: llm.request.before + - name: audit-log + description: 记录每次审批结果 + point: tool.approval.post type: observer command: node - args: ["./scripts/log-llm.js"] + args: ["./scripts/audit.js"] priority: 10 - enabled: true - name: block-dangerous-commands description: 阻止危险命令 @@ -166,71 +131,82 @@ hooks: env: BLOCKED_COMMANDS: "rm,rmdir,format" priority: 100 - enabled: true ``` -### UserHookConfig 完整字段 +### UserHookConfig 字段 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| `name` | `string` | 是 | 钩子名称,用于 enable/disable | -| `description` | `string` | 否 | 钩子描述 | -| `point` | `HookPoint` | 是 | 钩子点名称 | -| `type` | `'observer' \| 'decision'` | 是 | 钩子类型 | -| `command` | `string` | 是 | 执行命令 | +| `name` | `string` | 是 | 钩子名称,跨层对齐与开关都靠它 | +| `description` | `string` | 否 | 描述 | +| `point` | `HookPoint` | 是 | 钩子点名称(上表 12 个之一) | +| `type` | `'observer' \| 'decision'` | 是 | 决定是否读 stdout | +| `command` | `string` | 是 | 可执行文件 | | `args` | `string[]` | 否 | 命令参数 | -| `env` | `Record` | 否 | 环境变量 | -| `priority` | `number` | 否 | 优先级,默认 0 | -| `enabled` | `boolean` | 是 | 是否启用 | +| `env` | `Record` | 否 | 追加到 `process.env` 之上 | +| `priority` | `number` | 否 | 升序执行,默认 0 | +| `enabled` | `boolean` | 否 | **缺省(不写)等于启用**;`false` 表示禁用 | ### 执行机制 -用户钩子通过子进程执行: - -- payload 通过 stdin 传入 JSON -- decision 钩子从 stdout 读取 JSON 响应(需符合 `HookDecision` 格式) -- 超时时间 30 秒 -- 非零退出码视为错误,decision 钩子错误时返回 `continue` +- payload 以 JSON 写入子进程 stdin,随后关闭 stdin +- `type: decision` 时读 stdout 并 `JSON.parse`;退出码非 0、超时(30 秒)、解析失败一律降级为 `null` +- `type: observer` 忽略 stdout 与退出码,只保证跑完 +- `command` / `args` / `env` 里的 `${VAR}` 目前**不做展开**(`mcp.yaml` 会展开,两者不一致) --- ## 使用示例 -### 记录 LLM 调用 token 估算 +### 记录每次工具调用的耗时 -```typescript -hookService.register('llm.request.before', async (payload) => { - const messages = payload.messages as unknown[]; - const estimatedTokens = JSON.stringify(messages).length / 4; - console.log(`[Hook] 即将调用 LLM,预估 ${Math.round(estimatedTokens)} tokens`); +```yaml +hooks: + - name: slow-tool-alert + point: tool.execute.after + type: observer + command: node + args: ["./scripts/slow.js"] +``` + +```javascript +// scripts/slow.js +let raw = ''; +process.stdin.on('data', (c) => (raw += c)); +process.stdin.on('end', () => { + const { toolName, durationMs, projectPath } = JSON.parse(raw); + if (durationMs > 5000) console.error(`[slow] ${toolName} took ${durationMs}ms in ${projectPath}`); }); ``` ### 拦截危险命令 -```typescript -hookService.registerDecision('tool.approval.pre', async (payload) => { - if (payload.toolName === 'execute_command') { - const command = payload.args?.command as string; - if (command?.includes('rm -rf')) { - return { decision: 'deny', reason: '禁止递归强制删除' }; - } +```yaml +hooks: + - name: block-rm-rf + point: tool.approval.pre + type: decision + command: node + args: ["./scripts/check-command.js"] +``` + +```javascript +// scripts/check-command.js +let raw = ''; +process.stdin.on('data', (c) => (raw += c)); +process.stdin.on('end', () => { + const { toolName, args } = JSON.parse(raw); + if (toolName === 'execute_command' && String(args?.command ?? '').includes('rm -rf')) { + process.stdout.write(JSON.stringify({ decision: 'deny', reason: '禁止递归强制删除' })); } - return null; + // 否则什么都不输出 ⇒ 视为 null,不干预 }); ``` -### 修改工具参数 +### 让 Agent 继续跑 -```typescript -hookService.registerDecision('tool.approval.pre', async (payload) => { - if (payload.toolName === 'execute_command') { - // 强制所有命令在项目目录下执行 - return { - decision: 'continue', - modifiedInput: { ...payload.args, cwd: '/safe/directory' } - }; - } - return null; -}); +`agent.turn.stop` 返回 `continue` 且带 `injection` 时,`injection` 会作为 system 消息写入会话并续行(受 `maxStopContinuations` 限制,默认 3): + +```javascript +process.stdout.write(JSON.stringify({ decision: 'continue', injection: '还没跑测试,继续。' })); ``` diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index e42b54ce..4669fc1e 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -63,7 +63,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { rules.evictProjectRules(normalizedCwd); yield* hooks.reloadUserHooks(normalizedCwd); - yield* hooks.emit('agent.turn.start', { sessionId: '' }); + yield* hooks.emit('agent.turn.start', { sessionId: '', projectPath: normalizedCwd }); yield* mcp.syncConnections(normalizedCwd); let sessionId = opts.sessionId; @@ -136,14 +136,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const q = Effect.runSync(Queue.unbounded()); // agentLoopInternal 只经闭包引用服务,不消费任何 Tag;工具执行所需的服务由 toolEnv 注入 - const program = Effect.scoped( - Effect.gen(function* () { - yield* Effect.addFinalizer(() => - Effect.sync(() => { hooks.disposeSession(opts.sid); }) - ); - return yield* agentLoopInternal(opts, q); - }) - ); + const program = agentLoopInternal(opts, q); return (async function* () { const fiber = Effect.runFork(opts.toolEnv.provide(program)); @@ -199,11 +192,11 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { let lastResult: Result | null = null; - yield* hooks.emit('agent.turn.start', { sessionId: sid }); + yield* hooks.emit('agent.turn.start', { sessionId: sid, projectPath }); yield* q.offer({ family: 'transition', transition: { to: 'start', turnId: state.currentTurnId } }); for (let step = 0; step < maxSteps; step++) { - yield* hooks.emitDecision('agent.step.before', { sessionId: sid, step: step + 1 }); + yield* hooks.emitDecision('agent.step.before', { sessionId: sid, step: step + 1, projectPath }); if (step === 0) { yield* q.offer({ family: 'transition', transition: { to: 'executing' } }); @@ -217,7 +210,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { })); if (Either.isLeft(willCompact)) { yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(willCompact.left) }); - yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error', projectPath }); return Result.err(willCompact.left); } if (willCompact.right) { @@ -230,7 +223,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { })); if (Either.isLeft(assembled)) { yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(assembled.left) }); - yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error', projectPath }); return Result.err(assembled.left); } if (willCompact.right) { @@ -269,19 +262,19 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { })); if (Either.isLeft(streamed)) { yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(streamed.left) }); - yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error', projectPath }); return Result.err(streamed.left); } if (toolCalls.length === 0) { yield* session.recordAssistant(state, content, [], responded.usage); - const stopDecision = yield* hooks.emitDecision('agent.turn.stop', { sessionId: sid, content, turnId: state.currentTurnId }); + const stopDecision = yield* hooks.emitDecision('agent.turn.stop', { sessionId: sid, content, turnId: state.currentTurnId, projectPath }); if (stopDecision && stopDecision.decision === 'continue') { if (stopContinuations >= effectiveMaxStopContinuations) { const loopErr = new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded'); yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(loopErr) }); - yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error', projectPath }); memory.flushSessionToMemory(state.sessionId, llm, state.cwd).catch((e) => logger.error('memory flush failed:', e)); return Result.err(loopErr); } @@ -293,7 +286,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { yield* offerEnd({ to: 'end', reason: 'done' }); lastResult = Result.ok(content); - yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'done' }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'done', projectPath }); break; } @@ -355,14 +348,14 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const maxErr = AgentError.maxStepsReached(maxSteps); yield* offerEnd({ to: 'end', reason: 'maxSteps' }); - yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'maxSteps' }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'maxSteps', projectPath }); return Result.err(maxErr); }).pipe( Effect.interruptible, Effect.onInterrupt(() => Effect.gen(function* () { yield* offerEnd({ to: 'end', reason: 'aborted' }); - yield* hooks.emit('agent.turn.end', { sessionId: opts.sid, turnId: opts.state.currentTurnId, status: 'aborted' }).pipe(Effect.ignore); + yield* hooks.emit('agent.turn.end', { sessionId: opts.sid, turnId: opts.state.currentTurnId, status: 'aborted', projectPath: opts.projectPath }).pipe(Effect.ignore); }) ), Effect.ensuring( diff --git a/packages/codingcode/src/approval/approval.ts b/packages/codingcode/src/approval/approval.ts index 984dc4d1..44bd84d1 100644 --- a/packages/codingcode/src/approval/approval.ts +++ b/packages/codingcode/src/approval/approval.ts @@ -70,7 +70,8 @@ function recordAuditAndReturn( hooks: HookShape, request: ToolCallRequest, decision: ApprovalDecision, - passedLayers: string[] + passedLayers: string[], + projectPath: string | undefined ): Effect.Effect { return Effect.gen(function* () { passedLayers.push(LAYER_NAMES[4]); @@ -79,6 +80,7 @@ function recordAuditAndReturn( input: request.input, decision, layers: passedLayers, + projectPath, }); return decision; }); @@ -99,7 +101,7 @@ export function runPipeline( const result = opts.ruleEngine.evaluate(request.tool, request.input); if (result) { layers.push(LAYER_NAMES[0]); - const final = yield* recordAuditAndReturn(hooks, request, result, layers); + const final = yield* recordAuditAndReturn(hooks, request, result, layers, opts.projectPath); return final; } } @@ -114,7 +116,7 @@ export function runPipeline( ); if (modeResult) { layers.push(LAYER_NAMES[1]); - const final = yield* recordAuditAndReturn(hooks, request, modeResult, layers); + const final = yield* recordAuditAndReturn(hooks, request, modeResult, layers, opts.projectPath); return final; } } @@ -141,12 +143,12 @@ export function runPipeline( reason: hookResult.reason ?? 'Denied by PreToolUse hook', source: 'hook', }; - const final = yield* recordAuditAndReturn(hooks, request, result, layers); + const final = yield* recordAuditAndReturn(hooks, request, result, layers, opts.projectPath); return final; } if (hookResult.decision === 'allow') { const result: ApprovalDecision = { type: 'allow', source: 'hook' }; - const final = yield* recordAuditAndReturn(hooks, request, result, layers); + const final = yield* recordAuditAndReturn(hooks, request, result, layers, opts.projectPath); return final; } const nextRequest: ToolCallRequest = { ...request }; @@ -167,7 +169,7 @@ export function runPipeline( reason: 'Approval required but no UI available', source: 'system', }; - const final = yield* recordAuditAndReturn(hooks, request, result, layers); + const final = yield* recordAuditAndReturn(hooks, request, result, layers, opts.projectPath); return final; } @@ -196,7 +198,7 @@ export function runPipeline( break; } - const final = yield* recordAuditAndReturn(hooks, request, result, layers); + const final = yield* recordAuditAndReturn(hooks, request, result, layers, opts.projectPath); return final; } }); diff --git a/packages/codingcode/src/contracts/hooks.ts b/packages/codingcode/src/contracts/hooks.ts index 13e3e0ed..41aea350 100644 --- a/packages/codingcode/src/contracts/hooks.ts +++ b/packages/codingcode/src/contracts/hooks.ts @@ -2,14 +2,8 @@ export type HookPoint = | 'tool.execute.before' | 'tool.execute.after' | 'tool.execute.error' - | 'tool.execute.denied' | 'tool.approval.pre' | 'tool.approval.post' - | 'llm.request.before' - | 'llm.response.after' - | 'llm.response.error' - | 'session.save.before' - | 'session.save.after' | 'agent.turn.start' | 'agent.step.before' | 'agent.turn.stop' @@ -23,7 +17,6 @@ export interface HookDecision { reason?: string; injection?: string; modifiedInput?: Record; - modifiedOutput?: unknown; } export interface UserHookConfig { diff --git a/packages/codingcode/src/contracts/mcp.ts b/packages/codingcode/src/contracts/mcp.ts index 89c0b20b..b9411fc4 100644 --- a/packages/codingcode/src/contracts/mcp.ts +++ b/packages/codingcode/src/contracts/mcp.ts @@ -23,11 +23,8 @@ export interface McpServerConfig { export interface McpStatus { name: string; - connected: boolean; toolCount: number; transport: 'stdio' | 'http'; - reconnectAttempts: number; - leaseCount: number; } /** MCP 远端工具的纯数据描述:zod schema 与 SDK client 等机制形状由实现层持有 */ diff --git a/packages/codingcode/src/core/path.ts b/packages/codingcode/src/core/path.ts index 2a00bb6a..2d5a3615 100644 --- a/packages/codingcode/src/core/path.ts +++ b/packages/codingcode/src/core/path.ts @@ -2,7 +2,16 @@ import { mkdirSync } from 'fs'; import { homedir } from 'os'; import { join, resolve } from 'path'; -/** 空值 / `'global'` 表示操作全局(home)配置,而非某个项目。 */ +export const CODINGCODE_DIRNAME = '.codingcode'; + +export function getGlobalDir(): string { + return join(homedir(), CODINGCODE_DIRNAME); +} + +export function getProjectDir(projectRoot: string): string { + return join(projectRoot, CODINGCODE_DIRNAME); +} + export function isGlobalCwd(cwd: string | undefined): boolean { return !cwd || cwd === '' || cwd === 'global'; } @@ -22,19 +31,13 @@ export function encodeProjectPath(p: string): string { .toLowerCase(); } -let _projectBaseOverride: string | undefined; - -export function setProjectBaseDir(dir: string | undefined): void { - _projectBaseOverride = dir; -} - export function getProjectBaseDir(): string { - return _projectBaseOverride ?? join(homedir(), '.codingcode', 'project'); + return join(getGlobalDir(), 'project'); } /** 无 cwd 的请求共用的工作目录。 */ export function getTempCwd(): string { - return join(homedir(), '.codingcode', 'temp'); + return join(getGlobalDir(), 'temp'); } export function ensureTempCwd(): void { diff --git a/packages/codingcode/src/direct/settings.ts b/packages/codingcode/src/direct/settings.ts index 17c718f0..f0c74618 100644 --- a/packages/codingcode/src/direct/settings.ts +++ b/packages/codingcode/src/direct/settings.ts @@ -257,10 +257,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { result.push({ ...(r ?? { name: s.name, - connected: false, transport: 'stdio' as const, - reconnectAttempts: 0, - leaseCount: 0, toolCount: 0, }), name: s.name, @@ -275,10 +272,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { result.push({ ...(r ?? { name: s.name, - connected: false, transport: 'stdio' as const, - reconnectAttempts: 0, - leaseCount: 0, toolCount: 0, }), name: s.name, diff --git a/packages/codingcode/src/hooks/config.ts b/packages/codingcode/src/hooks/config.ts index 9c8b50dc..c9d57cf8 100644 --- a/packages/codingcode/src/hooks/config.ts +++ b/packages/codingcode/src/hooks/config.ts @@ -1,109 +1,42 @@ -import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { + mergeNamed, + patchNamed, + readNamedList, + writeNamedList, + type NamedListFile, +} from '@codingcode/infra/yaml-store'; +import { getGlobalDir, getProjectDir } from '../core/path.js'; import type { UserHookConfig } from '../contracts/hooks.js'; -let _globalConfigDirOverride: string | undefined; +const HOOKS_FILE: NamedListFile = { fileName: 'hooks', key: 'hooks' }; -export function getGlobalConfigDir(): string { - return _globalConfigDirOverride ?? join(homedir(), '.codingcode'); -} - -/** @internal Test-only hook to override the global config directory */ -export function _setGlobalConfigDir(dir: string | undefined): void { - _globalConfigDirOverride = dir; -} - -/** 丢掉值为 undefined 的键,避免下层未显式写的字段被擦掉 */ -function definedOnly(obj: T): Partial { - return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial; -} - -/** 按 name 做字段级合并:项目层只覆盖它显式写出的字段,其余继承全局 */ -function mergeConfigs(global: T[], project: T[]): T[] { - const map = new Map(); - for (const item of global) map.set(item.name, { ...item }); - for (const item of project) { - const base = map.get(item.name); - map.set(item.name, base ? { ...base, ...definedOnly(item) } : { ...item }); - } - return Array.from(map.values()); -} - -/** 该目录下实际生效的配置文件;都不存在时给出默认写入位置 */ -function hookConfigPath(dir: string): string { - const candidates = [join(dir, 'hooks.yaml'), join(dir, 'hooks.yml')]; - return candidates.find((p) => existsSync(p)) ?? candidates[0]!; -} - -/** 项目层的覆盖项可能只写了 name/enabled,故条目类型按部分字段读 */ type RawHookConfig = Partial & { name: string }; -function readRawHooks(dir: string): RawHookConfig[] { - const p = hookConfigPath(dir); - if (!existsSync(p)) return []; - try { - const parsed = parseYaml(readFileSync(p, 'utf8')) as { hooks?: RawHookConfig[] } | null; - return parsed?.hooks ?? []; - } catch { - return []; - } -} - -function writeHooks(dir: string, hooks: RawHookConfig[]): void { - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = hookConfigPath(dir); - let existing: Record = {}; - if (existsSync(p)) { - existing = (parseYaml(readFileSync(p, 'utf8')) as Record) ?? {}; - } - existing.hooks = hooks; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - export function loadHookConfigs(projectRoot: string): UserHookConfig[] { - return readRawHooks(join(projectRoot, '.codingcode')) as UserHookConfig[]; + return readNamedList(getProjectDir(projectRoot), HOOKS_FILE) as UserHookConfig[]; } export function writeHookConfigs(projectRoot: string, hooks: UserHookConfig[]): void { - writeHooks(join(projectRoot, '.codingcode'), hooks); + writeNamedList(getProjectDir(projectRoot), HOOKS_FILE, hooks); } export function loadGlobalHookConfigs(): UserHookConfig[] { - return readRawHooks(getGlobalConfigDir()) as UserHookConfig[]; + return readNamedList(getGlobalDir(), HOOKS_FILE) as UserHookConfig[]; } export function writeGlobalHookConfigs(hooks: UserHookConfig[]): void { - writeHooks(getGlobalConfigDir(), hooks); + writeNamedList(getGlobalDir(), HOOKS_FILE, hooks); } /** 合并 global 与 project 两层,得到运行时真正生效的 hook 列表 */ export function resolveHookConfigs(projectRoot: string): UserHookConfig[] { - return mergeConfigs(loadGlobalHookConfigs(), loadHookConfigs(projectRoot)) as UserHookConfig[]; -} - -// ---- Hook 开关 ---- -// 开关就是定义里的一个布尔字段 `enabled`,没有独立的开关状态存储。 -// 改开关 = 改写某一层 hooks.yaml 里该 hook 的 `enabled`。 - -/** - * 就地改写某一层配置文件里某个 hook 的 `enabled`。 - * 该层还没有这个 hook 时补一条最小覆盖 `{ name, enabled }`—— - * 字段级合并下这就足以关掉/打开上层定义的 hook,而不必复制 address/command 等字段。 - */ -function patchHookEnabled(dir: string, name: string, enabled: boolean): void { - const hooks = readRawHooks(dir); - const idx = hooks.findIndex((h) => h?.name === name); - if (idx === -1) hooks.push({ name, enabled }); - else hooks[idx] = { ...hooks[idx]!, enabled }; - writeHooks(dir, hooks); + return mergeNamed(loadGlobalHookConfigs(), loadHookConfigs(projectRoot)); } export function setGlobalHookEnabled(name: string, enabled: boolean): void { - patchHookEnabled(getGlobalConfigDir(), name, enabled); + patchNamed(getGlobalDir(), HOOKS_FILE, name, { enabled }); } export function setProjectHookEnabled(projectRoot: string, name: string, enabled: boolean): void { - patchHookEnabled(join(projectRoot, '.codingcode'), name, enabled); + patchNamed(getProjectDir(projectRoot), HOOKS_FILE, name, { enabled }); } diff --git a/packages/codingcode/src/hooks/executor.ts b/packages/codingcode/src/hooks/executor.ts index 75568958..8e809ae8 100644 --- a/packages/codingcode/src/hooks/executor.ts +++ b/packages/codingcode/src/hooks/executor.ts @@ -1,21 +1,37 @@ import { spawn } from 'child_process'; -export async function executeHookCommand( - config: { command: string; args?: string[]; env?: Record }, - payload: Record -): Promise { +/** hook 子进程的超时上限 */ +const HOOK_TIMEOUT_MS = 30000; + +export interface HookRunConfig { + command: string; + args?: string[]; + env?: Record; +} + +function runHook( + config: HookRunConfig, + payload: Record, + captureStdout: boolean +): Promise<{ code: number | null; stdout: string }> { return new Promise((resolve, reject) => { const child = spawn(config.command, config.args ?? [], { env: { ...process.env, ...(config.env ?? {}) }, - stdio: ['pipe', 'ignore', 'pipe'], + stdio: captureStdout ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'ignore', 'pipe'], }); + let stdout = ''; const timer = setTimeout(() => { child.kill(); reject(new Error('Hook timed out')); - }, 30000); + }, HOOK_TIMEOUT_MS); + if (captureStdout) { + child.stdout!.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + } child.on('close', (code) => { clearTimeout(timer); - resolve(); + resolve({ code, stdout }); }); child.on('error', (err) => { clearTimeout(timer); @@ -26,39 +42,24 @@ export async function executeHookCommand( }); } +/** 观察者 hook:只求跑完,不看输出;失败向调用方抛出 */ +export async function executeHookCommand( + config: HookRunConfig, + payload: Record +): Promise { + await runHook(config, payload, false); +} + +/** 决策 hook:读 stdout 的 JSON;超时 / 非零退出 / 解析失败一律降级为 null */ export async function executeDecisionHookCommand( - config: { command: string; args?: string[]; env?: Record }, + config: HookRunConfig, payload: Record ): Promise | null> { - return new Promise((resolve) => { - const child = spawn(config.command, config.args ?? [], { - env: { ...process.env, ...(config.env ?? {}) }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - const timer = setTimeout(() => { - child.kill(); - resolve(null); - }, 30000); - child.stdout!.on('data', (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.on('close', (code) => { - clearTimeout(timer); - if (code !== 0) { - resolve(null); - return; - } - try { - resolve(JSON.parse(stdout.trim())); - } catch { - resolve(null); - } - }); - child.on('error', () => { - resolve(null); - }); - child.stdin!.write(JSON.stringify(payload)); - child.stdin!.end(); - }); + const result = await runHook(config, payload, true).catch(() => null); + if (!result || result.code !== 0) return null; + try { + return JSON.parse(result.stdout.trim()); + } catch { + return null; + } } diff --git a/packages/codingcode/src/hooks/hooks.ts b/packages/codingcode/src/hooks/hooks.ts index 1fa7024b..20332845 100644 --- a/packages/codingcode/src/hooks/hooks.ts +++ b/packages/codingcode/src/hooks/hooks.ts @@ -4,132 +4,29 @@ import { executeHookCommand, executeDecisionHookCommand } from './executor.js'; import { createLogger } from '@codingcode/infra/logger'; import { HookService } from './port.js'; import type { HookPoint, HookDecision } from '../contracts/hooks.js'; -import type { ObserverHandler, DecisionHandler, HandlerEntry, ProjectPath, SessionId } from './types.js'; +import type { ObserverHandler, DecisionHandler, HandlerEntry, ProjectPath } from './types.js'; const logger = createLogger(); export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { - let entryCounter = 0; - const globalHooks = new Map(); const hooksByProject = new Map>(); - const hooksBySession = new Map>(); - function getMapForScope( - projectPath?: string, - sessionId?: string - ): Map[] { - const maps: Map[] = [globalHooks]; - if (projectPath) { - let pmap = hooksByProject.get(projectPath); - if (!pmap) { - pmap = new Map(); - hooksByProject.set(projectPath, pmap); - } - maps.push(pmap); - } - if (sessionId) { - let smap = hooksBySession.get(sessionId); - if (!smap) { - smap = new Map(); - hooksBySession.set(sessionId, smap); - } - maps.push(smap); - } - return maps; - } - - function sortedEntries(point: HookPoint, entries: HandlerEntry[]): HandlerEntry[] { - return entries.slice().sort((a, b) => a.priority - b.priority); - } - - function allHandlers( - point: HookPoint, - projectPath?: string, - sessionId?: string - ): HandlerEntry[] { - const result: HandlerEntry[] = []; - // global → project → session - const globalList = globalHooks.get(point); - if (globalList) result.push(...globalList); - if (projectPath) { - const projectList = hooksByProject.get(projectPath)?.get(point); - if (projectList) result.push(...projectList); - } - if (sessionId) { - const sessionList = hooksBySession.get(sessionId)?.get(point); - if (sessionList) result.push(...sessionList); - } - return sortedEntries(point, result); + /** 某个项目在某个 hook 点上生效的 handler,按 priority 升序 */ + function handlersFor(point: HookPoint, projectPath?: string): HandlerEntry[] { + const list = projectPath ? hooksByProject.get(projectPath)?.get(point) : undefined; + if (!list) return []; + return list.slice().sort((a, b) => a.priority - b.priority); } return { - register: ( - point: HookPoint, - handler: ObserverHandler, - opts?: { source?: 'system' | 'user' } - ): Effect.Effect<() => void> => - Effect.sync(() => { - const entry: HandlerEntry = { - id: `obs-${++entryCounter}`, - handler, - priority: 0, - source: opts?.source ?? 'user', - type: 'observer', - }; - const set = globalHooks.get(point) ?? []; - set.push(entry); - globalHooks.set(point, set); - return () => { - const idx = set.indexOf(entry); - if (idx >= 0) set.splice(idx, 1); - }; - }), - - registerDecision: ( - point: HookPoint, - handler: DecisionHandler, - opts?: { priority?: number; source?: 'system' | 'user' } - ): Effect.Effect<() => void> => - Effect.sync(() => { - const entry: HandlerEntry = { - id: `dec-${++entryCounter}`, - handler, - priority: opts?.priority ?? 0, - source: opts?.source ?? 'system', - type: 'decision', - }; - const set = globalHooks.get(point) ?? []; - set.push(entry); - globalHooks.set(point, set); - return () => { - const idx = set.indexOf(entry); - if (idx >= 0) set.splice(idx, 1); - }; - }), - emit: (point: HookPoint, payload: Record): Effect.Effect => { const projectPath = payload.projectPath as string | undefined; - const sessionId = payload.sessionId as string | undefined; return Effect.gen(function* () { - for (const entry of allHandlers(point, projectPath, sessionId)) { - if (entry.type === 'observer') { - const result = entry.handler(payload); - if (result == null) { - continue; - } - if (typeof (result as { pipe?: unknown }).pipe === 'function') { - yield* (result as Effect.Effect).pipe( - Effect.catchAll((e) => - Effect.sync(() => logger.error(`hook emit error [${point}]:`, e)) - ) - ) as Effect.Effect; - } else { - yield* Effect.tryPromise({ - try: () => result as Promise, - catch: (e) => logger.error(`hook emit error [${point}]:`, e), - }).pipe(Effect.ignore); - } - } + for (const entry of handlersFor(point, projectPath)) { + if (entry.type !== 'observer') continue; + yield* (entry.handler as ObserverHandler)(payload).pipe( + Effect.catchAll((e) => Effect.sync(() => logger.error(`hook emit error [${point}]:`, e))) + ); } }) as Effect.Effect; }, @@ -139,16 +36,14 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { payload: Record ): Effect.Effect => { const projectPath = payload.projectPath as string | undefined; - const sessionId = payload.sessionId as string | undefined; return Effect.promise(async () => { - for (const entry of allHandlers(point, projectPath, sessionId)) { - if (entry.type === 'decision') { - try { - const result = await (entry.handler as DecisionHandler)(payload); - if (result != null) return result; - } catch (e) { - logger.error(`hook emitDecision error [${point}]:`, e); - } + for (const entry of handlersFor(point, projectPath)) { + if (entry.type !== 'decision') continue; + try { + const result = await (entry.handler as DecisionHandler)(payload); + if (result != null) return result; + } catch (e) { + logger.error(`hook emitDecision error [${point}]:`, e); } } return null; @@ -157,13 +52,6 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { reloadUserHooks: (projectPath: string): Effect.Effect => Effect.sync(() => { - // Clear user-sourced hooks from globalHooks - for (const [point, entries] of globalHooks) { - const filtered = entries.filter((e) => e.source !== 'user'); - if (filtered.length === 0) globalHooks.delete(point); - else globalHooks.set(point, filtered); - } - hooksByProject.delete(projectPath); const projectMap = new Map(); for (const hc of resolveHookConfigs(projectPath)) { // 开关就是配置里的 enabled 字段:被禁用的 hook 不注册 @@ -174,31 +62,24 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { try: () => executeHookCommand(hc, payload), catch: (e) => logger.error(`user hook ${hookName} error:`, e), }).pipe(Effect.ignore); - const decisionHandler: DecisionHandler = (payload) => - Effect.tryPromise({ - try: () => executeDecisionHookCommand(hc, payload), - catch: (e) => { - logger.error(`user decision hook ${hookName} error:`, e); - return null; - }, - }) as unknown as Promise; + const decisionHandler: DecisionHandler = async (payload) => { + try { + return (await executeDecisionHookCommand(hc, payload)) as HookDecision | null; + } catch (e) { + logger.error(`user decision hook ${hookName} error:`, e); + return null; + } + }; const entry: HandlerEntry = { - id: `${hc.type === 'observer' ? 'obs' : 'dec'}-${++entryCounter}`, handler: hc.type === 'observer' ? observerHandler : decisionHandler, priority: hc.priority ?? 0, - source: 'user', type: hc.type, }; - const set = projectMap.get(hc.point) ?? []; - set.push(entry); - projectMap.set(hc.point, set); + const list = projectMap.get(hc.point) ?? []; + list.push(entry); + projectMap.set(hc.point, list); } hooksByProject.set(projectPath, projectMap); }), - - disposeSession: (sessionId: string): Effect.Effect => - Effect.sync(() => { - hooksBySession.delete(sessionId); - }), }; })); diff --git a/packages/codingcode/src/hooks/port.ts b/packages/codingcode/src/hooks/port.ts index 0df128e5..4e688d45 100644 --- a/packages/codingcode/src/hooks/port.ts +++ b/packages/codingcode/src/hooks/port.ts @@ -1,15 +1,11 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; import type { HookPoint, HookDecision } from '../contracts/hooks.js'; -import type { ObserverHandler, DecisionHandler } from './types.js'; export interface HookShape { - register(point: HookPoint, handler: ObserverHandler, opts?: { source?: 'system' | 'user' }): Effect.Effect<() => void>; - registerDecision(point: HookPoint, handler: DecisionHandler, opts?: { priority?: number; source?: 'system' | 'user' }): Effect.Effect<() => void>; emit(point: HookPoint, payload: Record): Effect.Effect; emitDecision(point: HookPoint, payload: Record): Effect.Effect; reloadUserHooks(projectPath: string): Effect.Effect; - disposeSession(sessionId: string): Effect.Effect; } export class HookService extends Context.Tag('HookService')() {} diff --git a/packages/codingcode/src/hooks/types.ts b/packages/codingcode/src/hooks/types.ts index f560b5ca..7e507f1b 100644 --- a/packages/codingcode/src/hooks/types.ts +++ b/packages/codingcode/src/hooks/types.ts @@ -1,22 +1,18 @@ import type { Effect } from 'effect'; import type { HookDecision } from '../contracts/hooks.js'; -export type ObserverHandler = ( - payload: Record -) => Effect.Effect | void | Promise; +/** 观察者:只被通知,返回值被忽略 */ +export type ObserverHandler = (payload: Record) => Effect.Effect; +/** 决策者:返回首个非 null 结果即短路 */ export type DecisionHandler = ( payload: Record ) => HookDecision | null | Promise; export interface HandlerEntry { - id: string; handler: ObserverHandler | DecisionHandler; priority: number; - source: 'system' | 'user'; type: 'observer' | 'decision'; } export type ProjectPath = string; -export type SessionId = string; -export type HookName = string; diff --git a/packages/codingcode/src/mcp/client.ts b/packages/codingcode/src/mcp/client.ts index a995bc6b..9024f17e 100644 --- a/packages/codingcode/src/mcp/client.ts +++ b/packages/codingcode/src/mcp/client.ts @@ -4,6 +4,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { Effect, TSemaphore, STM } from 'effect'; import type { McpServerConfig } from '../contracts/mcp.js'; +/** callTool 的失败类型;导出仅为满足 d.ts 生成(模块外无 import) */ export class McpError extends Error { constructor( public readonly serverName: string, @@ -21,7 +22,6 @@ export class McpClient { private reconnectAttempts = 0; private maxReconnect = 3; private destroyed = false; - private toolNames: string[] = []; constructor(private config: McpServerConfig) { this.client = new Client({ name: 'codingcode', version: '1.0.0' }, { capabilities: {} }); @@ -44,29 +44,11 @@ export class McpClient { } } - get serverName(): string { - return this.config.name; - } - - get connected(): boolean { - return this.transport !== null; - } - get transportType(): 'stdio' | 'http' { return this.config.command ? 'stdio' : 'http'; } - get tools(): string[] { - return [...this.toolNames]; - } - async connect(): Promise { - if (this.config.command) { - // stdio: pipe stderr to avoid buffer deadlock - const tp = this.transport as StdioClientTransport; - // SDK handles stderr internally when 'pipe' is set - } - await this.client.connect(this.transport); this.reconnectAttempts = 0; @@ -108,7 +90,6 @@ export class McpClient { }> > { const result = await this.client.listTools(); - this.toolNames = result.tools.map((t) => t.name); return result.tools.map((t) => ({ name: t.name, description: t.description ?? '', diff --git a/packages/codingcode/src/mcp/config.ts b/packages/codingcode/src/mcp/config.ts index 568062ff..ccfe05d4 100644 --- a/packages/codingcode/src/mcp/config.ts +++ b/packages/codingcode/src/mcp/config.ts @@ -1,9 +1,19 @@ -import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { + mergeNamed, + patchNamed, + readNamedList, + writeNamedList, + type NamedListFile, +} from '@codingcode/infra/yaml-store'; +import { getGlobalDir, getProjectDir } from '../core/path.js'; import type { McpServerConfig } from '../contracts/mcp.js'; +/** mcp 的落盘形状:`/.codingcode/mcp.yaml` 的 `servers:` */ +const MCP_FILE: NamedListFile = { fileName: 'mcp', key: 'servers' }; + +/** 项目层的覆盖项可能只写了 name/enabled,故条目类型按部分字段读 */ +type RawMcpServerConfig = Partial & { name: string }; + function resolveEnvVars(value: unknown): unknown { if (typeof value === 'string') { return value.replace(/\$\{(\w+)\}/g, (_, key) => process.env[key] ?? ''); @@ -17,110 +27,32 @@ function resolveEnvVars(value: unknown): unknown { return value; } -let _globalConfigDirOverride: string | undefined; - -export function getGlobalConfigDir(): string { - return _globalConfigDirOverride ?? join(homedir(), '.codingcode'); -} - -/** @internal Test-only hook to override the global config directory */ -export function _setGlobalConfigDir(dir: string | undefined): void { - _globalConfigDirOverride = dir; -} - -/** 丢掉值为 undefined 的键,避免下层未显式写的字段被擦掉 */ -function definedOnly(obj: T): Partial { - return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial; -} - -/** 按 name 做字段级合并:项目层只覆盖它显式写出的字段,其余继承全局 */ -function mergeConfigs(global: T[], project: T[]): T[] { - const map = new Map(); - for (const item of global) map.set(item.name, { ...item }); - for (const item of project) { - const base = map.get(item.name); - map.set(item.name, base ? { ...base, ...definedOnly(item) } : { ...item }); - } - return Array.from(map.values()); -} - -/** 该目录下实际生效的配置文件;都不存在时给出默认写入位置 */ -function mcpConfigPath(dir: string): string { - const candidates = [join(dir, 'mcp.yaml'), join(dir, 'mcp.yml')]; - return candidates.find((p) => existsSync(p)) ?? candidates[0]!; -} - -/** 读原始条目:不解析 ${VAR},写路径必须用这个,否则会把解析后的值固化回文件 */ -function readRawServers(dir: string): McpServerConfig[] { - const p = mcpConfigPath(dir); - if (!existsSync(p)) return []; - try { - const parsed = parseYaml(readFileSync(p, 'utf8')) as { servers?: McpServerConfig[] } | null; - return parsed?.servers ?? []; - } catch { - return []; - } -} - -function writeServers(dir: string, servers: McpServerConfig[]): void { - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = mcpConfigPath(dir); - let existing: Record = {}; - if (existsSync(p)) { - existing = (parseYaml(readFileSync(p, 'utf8')) as Record) ?? {}; - } - existing.servers = servers; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - export function loadMcpConfig(projectRoot: string): McpServerConfig[] { - return readRawServers(join(projectRoot, '.codingcode')).map( + return readNamedList(getProjectDir(projectRoot), MCP_FILE).map( (s) => resolveEnvVars(s) as McpServerConfig ); } export function writeMcpConfig(projectRoot: string, servers: McpServerConfig[]): void { - writeServers(join(projectRoot, '.codingcode'), servers); + writeNamedList(getProjectDir(projectRoot), MCP_FILE, servers); } export function loadGlobalMcpConfig(): McpServerConfig[] { - return readRawServers(getGlobalConfigDir()).map((s) => resolveEnvVars(s) as McpServerConfig); + return readNamedList(getGlobalDir(), MCP_FILE).map( + (s) => resolveEnvVars(s) as McpServerConfig + ); } export function writeGlobalMcpConfig(servers: McpServerConfig[]): void { - writeServers(getGlobalConfigDir(), servers); + writeNamedList(getGlobalDir(), MCP_FILE, servers); } export function resolveMcpConfig(projectRoot: string): McpServerConfig[] { - return mergeConfigs(loadGlobalMcpConfig(), loadMcpConfig(projectRoot)); -} - -// ---- MCP 开关 ---- -// 开关就是定义里的一个布尔字段 `enabled`,没有独立的开关状态存储。 -// 改开关 = 改写某一层 mcp.yaml 里该 server 的 `enabled`。 - -/** - * 就地改写某一层配置文件里某个 server 的 `enabled`。 - * 该层还没有这个 server 时补一条最小覆盖 `{ name, enabled }`—— - * 字段级合并下这就足以关掉/打开上层定义的 server,而不必复制它的其它字段。 - */ -function patchServerEnabled(dir: string, name: string, enabled: boolean): void { - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = mcpConfigPath(dir); - let existing: Record = {}; - if (existsSync(p)) { - existing = (parseYaml(readFileSync(p, 'utf8')) as Record) ?? {}; - } - const servers = (existing.servers as Array> | undefined) ?? []; - const idx = servers.findIndex((s) => s?.name === name); - if (idx === -1) servers.push({ name, enabled }); - else servers[idx] = { ...servers[idx], enabled }; - existing.servers = servers; - writeFileSync(p, stringifyYaml(existing), 'utf8'); + return mergeNamed(loadGlobalMcpConfig(), loadMcpConfig(projectRoot)); } export function setGlobalMcpServerEnabled(name: string, enabled: boolean): void { - patchServerEnabled(getGlobalConfigDir(), name, enabled); + patchNamed(getGlobalDir(), MCP_FILE, name, { enabled }); } export function setProjectMcpServerEnabled( @@ -128,5 +60,5 @@ export function setProjectMcpServerEnabled( name: string, enabled: boolean ): void { - patchServerEnabled(join(projectRoot, '.codingcode'), name, enabled); + patchNamed(getProjectDir(projectRoot), MCP_FILE, name, { enabled }); } diff --git a/packages/codingcode/src/mcp/mcp.ts b/packages/codingcode/src/mcp/mcp.ts index 1f8d2112..e6dd7a42 100644 --- a/packages/codingcode/src/mcp/mcp.ts +++ b/packages/codingcode/src/mcp/mcp.ts @@ -8,6 +8,7 @@ import { AgentError } from '../core/error.js'; const logger = createLogger(); +/** 客户端 listTools() 的原始返回形状(SDK 类型在 client.ts 内部收口) */ interface McpRawTool { name: string; description: string; @@ -17,22 +18,14 @@ interface McpRawTool { interface ServerEntry { client: McpClient; - config: McpServerConfig; - toolNames: string[]; rawTools: McpRawTool[]; } -interface LeaseEntry { - projectPath: string; - serverName: string; -} - type ProjectPath = string; type ServerName = string; export const McpLayer = Layer.effect(McpService, Effect.sync(() => { const clientsByProject = new Map>(); - const leasesBySession = new Map>(); function getProjectClients(projectPath: string): Map { let map = clientsByProject.get(projectPath); @@ -43,10 +36,6 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { return map; } - /** - * 被禁用的 server 名集合。 - * 开关就是 mcp.yaml 里的 `enabled` 字段,这里每次实时读配置:改开关即落盘,落盘即生效。 - */ function disabledServerNames(projectPath: string): Set { return new Set( resolveMcpConfig(projectPath) @@ -55,21 +44,10 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { ); } - function doConnect( - cfg: McpServerConfig, - projectPath: string, - bumpRef: boolean, - sessionId?: string - ): Effect.Effect { + function doConnect(cfg: McpServerConfig, projectPath: string): Effect.Effect { return Effect.gen(function* () { const projectClients = getProjectClients(projectPath); - const existing = projectClients.get(cfg.name); - if (existing) { - if (bumpRef && sessionId) { - addLease(sessionId, projectPath, cfg.name); - } - return existing.toolNames; - } + if (projectClients.has(cfg.name)) return; const result = yield* Effect.tryPromise(async () => { const client = new McpClient(cfg); @@ -85,7 +63,7 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { }) ); - if (!result) return []; + if (!result) return; const rawTools: McpRawTool[] = result.mcpTools.map((mt: any) => ({ name: mt.name, @@ -94,34 +72,17 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { readOnlyHint: mt.readOnlyHint ?? false, })); - const registeredNames: string[] = rawTools.map((mt) => namespacedName(cfg.name, mt.name)); - - projectClients.set(cfg.name, { - client: result.client, - config: cfg, - toolNames: registeredNames, - rawTools, - }); - - if (bumpRef && sessionId) { - addLease(sessionId, projectPath, cfg.name); - } - - return registeredNames; + projectClients.set(cfg.name, { client: result.client, rawTools }); }); } - function doDisconnect(projectPath: string, name: string, force: boolean): Effect.Effect { + function doDisconnect(projectPath: string, name: string): Effect.Effect { return Effect.gen(function* () { const projectClients = clientsByProject.get(projectPath); if (!projectClients) return; const entry = projectClients.get(name); if (!entry) return; - if (!force) { - if (hasActiveLeases(projectPath, name)) return; - } - yield* Effect.tryPromise(() => entry.client.disconnect()).pipe( Effect.catchAll(() => Effect.succeed(undefined)) ); @@ -133,52 +94,6 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { }); } - function addLease(sessionId: string, projectPath: string, serverName: string): void { - let leases = leasesBySession.get(sessionId); - if (!leases) { - leases = new Set(); - leasesBySession.set(sessionId, leases); - } - leases.add({ projectPath, serverName }); - } - - function removeLease(sessionId: string, projectPath: string, serverName: string): void { - const leases = leasesBySession.get(sessionId); - if (!leases) return; - for (const lease of leases) { - if (lease.projectPath === projectPath && lease.serverName === serverName) { - leases.delete(lease); - break; - } - } - if (leases.size === 0) { - leasesBySession.delete(sessionId); - } - } - - function hasActiveLeases(projectPath: string, serverName: string): boolean { - for (const [, leases] of leasesBySession) { - for (const lease of leases) { - if (lease.projectPath === projectPath && lease.serverName === serverName) { - return true; - } - } - } - return false; - } - - function countLeases(projectPath: string, serverName: string): number { - let count = 0; - for (const [, leases] of leasesBySession) { - for (const lease of leases) { - if (lease.projectPath === projectPath && lease.serverName === serverName) { - count++; - } - } - } - return count; - } - return { syncConnections: (projectPath: string): Effect.Effect => Effect.gen(function* () { @@ -189,61 +104,20 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { if (projectClients) { for (const [name] of projectClients) { if (!configNames.has(name)) { - yield* doDisconnect(projectPath, name, true); + yield* doDisconnect(projectPath, name); } } } for (const cfg of configs) { if (cfg.enabled === false) { - yield* doDisconnect(projectPath, cfg.name, true); - continue; - } - yield* doConnect(cfg, projectPath, false); - } - }), - - connectServers: ( - projectPath: string, - sessionId: string, - names: string[] - ): Effect.Effect => - Effect.gen(function* () { - const configs = resolveMcpConfig(projectPath); - const configMap = new Map(configs.map((c) => [c.name, c])); - - for (const name of names) { - const cfg = configMap.get(name); - if (!cfg) { - logger.warn( - `[MCP] Server '${name}' not found in mcp.yaml for project '${projectPath}', skipping` - ); + yield* doDisconnect(projectPath, cfg.name); continue; } - if (cfg.enabled === false) continue; - yield* doConnect(cfg, projectPath, true, sessionId); + yield* doConnect(cfg, projectPath); } }), - disconnectServers: ( - projectPath: string, - sessionId: string, - names: string[] - ): Effect.Effect => - Effect.gen(function* () { - for (const name of names) { - removeLease(sessionId, projectPath, name); - yield* doDisconnect(projectPath, name, false); - } - }), - - getServerToolNames: (projectPath: string, name: string): string[] => { - const projectClients = clientsByProject.get(projectPath); - if (!projectClients) return []; - const entry = projectClients.get(name); - return entry ? [...entry.toolNames] : []; - }, - listProjectMcpTools: (projectPath: string): Effect.Effect => Effect.sync(() => { const projectClients = clientsByProject.get(projectPath); @@ -269,49 +143,14 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { if (!projectClients) return []; return Array.from(projectClients.entries()).map(([name, entry]) => ({ name, - connected: entry.client.connected, toolCount: entry.rawTools.length, transport: entry.client.transportType, - reconnectAttempts: 0, - leaseCount: countLeases(projectPath, name), })); }), - - disposeSession: (sessionId: string): Effect.Effect => - Effect.gen(function* () { - const leases = leasesBySession.get(sessionId); - if (!leases) return; - for (const lease of leases) { - yield* doDisconnect(lease.projectPath, lease.serverName, false); - } - leasesBySession.delete(sessionId); - }), - - disposeProject: (projectPath: string): Effect.Effect => - Effect.gen(function* () { - const projectClients = clientsByProject.get(projectPath); - if (!projectClients) return; - for (const [name] of projectClients) { - for (const [sessionId, leases] of leasesBySession) { - for (const lease of leases) { - if (lease.projectPath === projectPath) { - leases.delete(lease); - } - } - if (leases.size === 0) leasesBySession.delete(sessionId); - } - yield* doDisconnect(projectPath, name, true); - } - clientsByProject.delete(projectPath); - }), }; } )); -function namespacedName(serverName: string, toolName: string): string { - return `${serverName}:${toolName}`; -} - function mcpToolToSpec( serverName: string, mcpTool: McpRawTool, diff --git a/packages/codingcode/src/mcp/port.ts b/packages/codingcode/src/mcp/port.ts index 19f5e559..d5b69a71 100644 --- a/packages/codingcode/src/mcp/port.ts +++ b/packages/codingcode/src/mcp/port.ts @@ -4,13 +4,8 @@ import type { McpStatus, McpToolSpec } from '../contracts/mcp.js'; export interface McpShape { syncConnections(projectPath: string): Effect.Effect; - connectServers(projectPath: string, sessionId: string, names: string[]): Effect.Effect; - disconnectServers(projectPath: string, sessionId: string, names: string[]): Effect.Effect; - getServerToolNames(projectPath: string, name: string): string[]; listProjectMcpTools(projectPath: string): Effect.Effect; status(projectPath: string): Effect.Effect; - disposeSession(sessionId: string): Effect.Effect; - disposeProject(projectPath: string): Effect.Effect; } export class McpService extends Context.Tag('Mcp')() {} diff --git a/packages/codingcode/src/memory/storage.ts b/packages/codingcode/src/memory/storage.ts index 5a883920..5303b9f8 100644 --- a/packages/codingcode/src/memory/storage.ts +++ b/packages/codingcode/src/memory/storage.ts @@ -1,8 +1,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { getProjectDir } from '../core/path.js'; export function resolveMemoryPath(cwd: string): string { - return path.join(cwd, '.codingcode', 'memory.md'); + return path.join(getProjectDir(cwd), 'memory.md'); } export function readMemoryFile(absPath: string): string { diff --git a/packages/codingcode/src/rules/rules.ts b/packages/codingcode/src/rules/rules.ts index 4be63ee3..8c2946fc 100644 --- a/packages/codingcode/src/rules/rules.ts +++ b/packages/codingcode/src/rules/rules.ts @@ -1,13 +1,13 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; import { Layer, Effect } from 'effect'; import { RulesService } from './port.js'; +import { getGlobalDir } from '../core/path.js'; // ── Paths ── function getGlobalRulesPath(): string { - return path.join(os.homedir(), '.codingcode', 'rules.md'); + return path.join(getGlobalDir(), 'rules.md'); } function getProjectRulesPath(projectPath?: string): string { diff --git a/packages/codingcode/src/scheduler/store.ts b/packages/codingcode/src/scheduler/store.ts index 6bebb0e8..5da1d64c 100644 --- a/packages/codingcode/src/scheduler/store.ts +++ b/packages/codingcode/src/scheduler/store.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { homedir } from 'os'; +import { dirname, join } from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { getGlobalDir } from '../core/path.js'; import type { Automation } from './types.js'; interface AutomationsFile { @@ -9,7 +9,7 @@ interface AutomationsFile { } function getAutomationsPath(): string { - return resolve(homedir(), '.codingcode', 'automations.yaml'); + return join(getGlobalDir(), 'automations.yaml'); } export function readAutomations(configPath?: string): Automation[] { diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index e74abfd9..41c2d6e9 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -11,9 +11,8 @@ import { unlinkSync, rmSync, } from 'fs'; -import { homedir } from 'os'; import { join, dirname } from 'path'; -import { getProjectBaseDir, computePaths } from '../core/path.js'; +import { getGlobalDir, getProjectBaseDir, computePaths } from '../core/path.js'; import type { PermissionMode } from '../contracts/permission.js'; import type { SessionEvent, SessionMetaEvent, SessionIndex } from '../contracts/session.js'; @@ -23,7 +22,7 @@ export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string } export function ensureDirs(transcriptPath: string): void { - const codingcodeDir = join(homedir(), '.codingcode'); + const codingcodeDir = getGlobalDir(); if (!existsSync(codingcodeDir)) mkdirSync(codingcodeDir, { recursive: true }); const dir = dirname(transcriptPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); diff --git a/packages/codingcode/src/skills/source.ts b/packages/codingcode/src/skills/source.ts index d669f56f..890ec4ec 100644 --- a/packages/codingcode/src/skills/source.ts +++ b/packages/codingcode/src/skills/source.ts @@ -1,7 +1,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; import { join, basename } from 'path'; -import { homedir } from 'os'; import { parse as parseYaml } from 'yaml'; +import { getGlobalDir, getProjectDir } from '../core/path.js'; interface SkillFrontMatter { name?: string; @@ -15,32 +15,9 @@ export interface SkillDirectory { name: string; } +/** 全局在前、项目在后:同名 skill 由后者覆盖(消费方按 name 去重,后写者胜)。 */ export function discoverSkillDirs(projectRoot: string): SkillDirectory[] { - const dirs: SkillDirectory[] = []; - - // Global skills (~/.codingcode/skills/) — loaded first, project overrides - const globalSkillsDir = join(homedir(), '.codingcode', 'skills'); - if (existsSync(globalSkillsDir)) { - for (const entry of readdirSync(globalSkillsDir)) { - const dirPath = join(globalSkillsDir, entry); - if (statSync(dirPath).isDirectory()) { - dirs.push({ dirPath, name: entry }); - } - } - } - - // Project-level skills (.codingcode/skills/) — loaded after, takes priority - const projectSkillsDir = join(projectRoot, '.codingcode', 'skills'); - if (existsSync(projectSkillsDir)) { - for (const entry of readdirSync(projectSkillsDir)) { - const dirPath = join(projectSkillsDir, entry); - if (statSync(dirPath).isDirectory()) { - dirs.push({ dirPath, name: entry }); - } - } - } - - return dirs; + return [...discoverGlobalSkillDirs(), ...discoverProjectSkillDirs(projectRoot)]; } /** Parse only the SKILL.md front matter used for skill discovery. */ @@ -64,9 +41,10 @@ export function readSkillFrontMatter(dirPath: string): SkillFrontMatter | null { // ---- 辅助函数:分别获取全局/项目级 Skill 目录 ---- +/** `~/.codingcode/skills/` —— 先加载,同名的项目级 skill 会覆盖它 */ export function discoverGlobalSkillDirs(): SkillDirectory[] { const dirs: SkillDirectory[] = []; - const globalSkillsDir = join(homedir(), '.codingcode', 'skills'); + const globalSkillsDir = join(getGlobalDir(), 'skills'); if (existsSync(globalSkillsDir)) { for (const entry of readdirSync(globalSkillsDir)) { const dirPath = join(globalSkillsDir, entry); @@ -78,9 +56,10 @@ export function discoverGlobalSkillDirs(): SkillDirectory[] { return dirs; } +/** `/.codingcode/skills/` —— 后加载,优先级更高 */ export function discoverProjectSkillDirs(projectRoot: string): SkillDirectory[] { const dirs: SkillDirectory[] = []; - const projectSkillsDir = join(projectRoot, '.codingcode', 'skills'); + const projectSkillsDir = join(getProjectDir(projectRoot), 'skills'); if (existsSync(projectSkillsDir)) { for (const entry of readdirSync(projectSkillsDir)) { const dirPath = join(projectSkillsDir, entry); diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index dc4cc5d9..efbd08b0 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -3,13 +3,10 @@ import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; import type { ToolDefinition } from '../../types.js'; import { HookService } from '../../../hooks/port.js'; -import { McpService } from '../../../mcp/port.js'; import { SubagentRunnerService } from '../../../subagent/port.js'; import { resolveSubagentProfile } from '../../../agent/profile.js'; -export const dispatchAgentTool: ToolDefinition< - HookService | McpService | SubagentRunnerService -> = { +export const dispatchAgentTool: ToolDefinition = { name: 'dispatch_agent', concurrencySafe: false, description: @@ -21,7 +18,6 @@ export const dispatchAgentTool: ToolDefinition< execute: (args, ctx) => Effect.gen(function* () { const hooks = yield* HookService; - const mcp = yield* McpService; const runner = yield* SubagentRunnerService; const { agent: agentName, prompt } = args as { agent: string; prompt: string }; @@ -36,7 +32,7 @@ export const dispatchAgentTool: ToolDefinition< const parentSessionId = ctx?.sessionId; const spawnDecision = yield* hooks.emitDecision('agent.subagent.spawn.before', { - profile: agentName, prompt, parentSessionId, + profile: agentName, prompt, parentSessionId, projectPath, }); if (spawnDecision && spawnDecision.decision === 'deny') { return yield* Effect.fail( @@ -52,7 +48,7 @@ export const dispatchAgentTool: ToolDefinition< agentName, }); - yield* hooks.emit('agent.subagent.spawn.after', { childSessionId: childUuid, profile: agentName }); + yield* hooks.emit('agent.subagent.spawn.after', { childSessionId: childUuid, profile: agentName, projectPath }); let didComplete = false; const finalContent = yield* Effect.async((resume) => { @@ -73,15 +69,9 @@ export const dispatchAgentTool: ToolDefinition< return; } } - await Effect.runPromise(mcp.disposeSession(childUuid)); - await Effect.runPromise(hooks.disposeSession(childUuid)); didComplete = true; resume(Effect.succeed(content || '(subagent completed without output)')); } catch (e) { - try { - await Effect.runPromise(mcp.disposeSession(childUuid)); - await Effect.runPromise(hooks.disposeSession(childUuid)); - } catch { /* ignore */ } const msg = e instanceof Error ? e.message : String(e); resume(Effect.fail(new AgentError('TOOL_EXECUTION_FAILED', msg))); } @@ -89,7 +79,7 @@ export const dispatchAgentTool: ToolDefinition< }); if (didComplete) { - yield* hooks.emit('agent.subagent.complete', { childSessionId: childUuid, profile: agentName, status: 'done' }).pipe(Effect.ignore); + yield* hooks.emit('agent.subagent.complete', { childSessionId: childUuid, profile: agentName, status: 'done', projectPath }).pipe(Effect.ignore); } return finalContent; diff --git a/packages/codingcode/src/tools/tools.ts b/packages/codingcode/src/tools/tools.ts index 75ba16dd..6a5b80ac 100644 --- a/packages/codingcode/src/tools/tools.ts +++ b/packages/codingcode/src/tools/tools.ts @@ -86,6 +86,7 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu toolName: name, args: args as Record, error, + projectPath: opts?.projectPath, }) ) ); diff --git a/packages/codingcode/test/agent/agent-on-interrupt-emit.test.ts b/packages/codingcode/test/agent/agent-on-interrupt-emit.test.ts index ab2d0901..a8382848 100644 --- a/packages/codingcode/test/agent/agent-on-interrupt-emit.test.ts +++ b/packages/codingcode/test/agent/agent-on-interrupt-emit.test.ts @@ -1,68 +1,70 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +// 必须排在 hooks.js 前面:vi.mock 工厂要引用 fakeSpawn +import { spawnRecords, resetFakeSpawn, fakeSpawn } from '../hooks/fake-spawn.js'; import { Effect, Fiber } from 'effect'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; +import { join, resolve } from 'path'; +import { tmpdir } from 'os'; import { HookService } from '../../src/hooks/port.js'; import { HookLayer } from '../../src/hooks/hooks.js'; +import { useTempHome } from '../helpers/temp-home.js'; -// This file pins the fix to `Effect.onInterrupt` callback in agent.ts -// (around the `agent.turn.end` emit on abort). The old code wrapped the -// emit in `Effect.sync(() => { ... Effect.runPromise(emit) ... })`, which -// runs the emit in a fresh fiber with no service context — so any -// observer that yield*'d a service (HookService, SessionService, …) would -// Die with "Service not found: …". The fix wraps the callback in -// `Effect.gen` and `yield*`s the emit so it runs in the agent's fiber -// (the onInterrupt callback's fiber inherits the agent's services via -// `Effect.provideService` in `AgentService.runStream`). +vi.mock('child_process', () => ({ + spawn: (command: string, args: string[]) => fakeSpawn(command, args), +})); + +// 这个文件钉住 agent.ts 里 `Effect.onInterrupt` 回调的修法(`agent.turn.end` 在 abort 时的 emit)。 +// 旧代码把 emit 包在 `Effect.sync(() => { Effect.runPromise(emit) })` 里,会开一个**没有服务上下文**的新 +// fiber —— 回调里 `yield* HookService` 会直接 Die("Service not found")。修法是把回调写成 +// `Effect.gen` 并 `yield*` emit,让它跑在 agent 的 fiber 里。 // -// This test exercises the same `Effect.onInterrupt` + `yield* emit` -// pattern with an observer that yield*'s HookService. Before the fix -// the observer would Die; after the fix it resolves HookService from -// the fiber's context. +// 断言方式:用 hooks.yaml 定义一个观察者。回调里 `const hooks = yield* HookService` 若失败, +// emit 根本不会发生、子进程不会被 spawn。所以「spawn 被调到」即证明服务解析成功。 -describe('Effect.onInterrupt callback can yield* emit (agent.ts abort hook fix)', () => { - it('observer services resolve from the interrupted fiber context', async () => { - let observerRan = false; - let serviceResolved = false; +describe('Effect.onInterrupt 回调里 yield* HookService + emit(agent.ts abort 修法)', () => { + const testDir = resolve(tmpdir(), 'codingcode-test-oninterrupt-emit'); + // 全局层落在临时 home 里,避免读到开发机上的 ~/.codingcode(全局配置目录不可指定) + useTempHome('codingcode-test-oninterrupt-emit-'); - const AppLayer = HookLayer; + beforeEach(() => { + resetFakeSpawn(); + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + mkdirSync(join(testDir, '.codingcode'), { recursive: true }); + writeFileSync( + join(testDir, '.codingcode', 'hooks.yaml'), + 'hooks:\n - name: on-abort\n point: agent.turn.end\n type: observer\n command: cmd-abort\n args: []\n' + ); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + it('被中断时回调能解析出 HookService 并把 agent.turn.end 跑出去', async () => { const program = Effect.gen(function* () { const hooks = yield* HookService; - yield* hooks.register( - 'agent.turn.end', - () => - Effect.gen(function* () { - // This yield* is the contract under test. With the old - // Effect.runPromise path it would Die because the emit ran - // on a default runtime. With the yield* path it resolves - // from the agent's fiber context. - const h = yield* HookService; - observerRan = true; - serviceResolved = typeof h.register === 'function'; - }), - { source: 'system' } - ); - // Suspend forever so the only way out is via Fiber.interrupt, - // which triggers Effect.onInterrupt's callback. + yield* hooks.reloadUserHooks(testDir); + // 永远挂起,唯一的出口是被 Fiber.interrupt,从而触发 onInterrupt 回调 yield* Effect.never; }).pipe( Effect.onInterrupt(() => Effect.gen(function* () { const hooks = yield* HookService; - yield* hooks.emit('agent.turn.end', { status: 'aborted' }).pipe(Effect.ignore); + yield* hooks + .emit('agent.turn.end', { projectPath: testDir, status: 'aborted' }) + .pipe(Effect.ignore); }) ) ); - const fiber = Effect.runFork(Effect.provide(program, AppLayer)); - // Yield to the event loop so the registration's Effect.sync - // completes before we interrupt. + const fiber = Effect.runFork(Effect.provide(program, HookLayer)); + // 让 reloadUserHooks 先跑完 await new Promise((resolve) => setTimeout(resolve, 10)); await Effect.runPromise(Fiber.interrupt(fiber)); - // Yield again so the onInterrupt callback's emit (and its observer) - // get a chance to finish before we assert. + // 让 onInterrupt 回调里的 emit 有机会跑完 await new Promise((resolve) => setTimeout(resolve, 50)); - expect(observerRan).toBe(true); - expect(serviceResolved).toBe(true); + expect(spawnRecords.map((r) => r.command)).toEqual(['cmd-abort']); + expect(spawnRecords[0]!.payload).toMatchObject({ status: 'aborted' }); }); }); diff --git a/packages/codingcode/test/agent/stop-decision-type.test.ts b/packages/codingcode/test/agent/stop-decision-type.test.ts index 8b7c6096..702abf37 100644 --- a/packages/codingcode/test/agent/stop-decision-type.test.ts +++ b/packages/codingcode/test/agent/stop-decision-type.test.ts @@ -1,31 +1,54 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +// 必须排在 hooks.js 前面:vi.mock 工厂要引用 fakeSpawn +import { resetFakeSpawn, whenCommand, fakeSpawn } from '../hooks/fake-spawn.js'; import { Effect } from 'effect'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; +import { join, resolve } from 'path'; +import { tmpdir } from 'os'; import { HookService } from '../../src/hooks/port.js'; import { HookLayer } from '../../src/hooks/hooks.js'; +import { useTempHome } from '../helpers/temp-home.js'; + +vi.mock('child_process', () => ({ + spawn: (command: string, args: string[]) => fakeSpawn(command, args), +})); + +describe('agent.turn.stop 的决策类型推断', () => { + const testDir = resolve(tmpdir(), 'codingcode-test-stop-decision'); + // 全局层落在临时 home 里,避免读到开发机上的 ~/.codingcode(全局配置目录不可指定) + useTempHome('codingcode-test-stop-decision-'); + + beforeEach(() => { + resetFakeSpawn(); + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + mkdirSync(join(testDir, '.codingcode'), { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it('emitDecision 直接给出 HookDecision,读 decision / injection 不需要 any', async () => { + writeFileSync( + join(testDir, '.codingcode', 'hooks.yaml'), + 'hooks:\n - name: loop\n point: agent.turn.stop\n type: decision\n command: cmd-loop\n args: []\n' + ); + whenCommand('cmd-loop', { stdout: '{"decision":"continue","injection":"(test continue)"}' }); -describe('agent.turn.stop decision type inference', () => { - it('should infer HookDecision from emitDecision without any cast', async () => { - // Verify emitDecision returns a typed HookDecision that does NOT need `any` - // to access `decision` and `injection` fields. const program = Effect.gen(function* () { const hooks = yield* HookService; - yield* hooks.registerDecision('agent.turn.stop', () => ({ - decision: 'continue' as const, - injection: '(test continue)', - })); + yield* hooks.reloadUserHooks(testDir); const stopDecision = yield* hooks.emitDecision('agent.turn.stop', { + projectPath: testDir, sessionId: 'test-sid', content: 'hello', turnId: 1, }); - // Type-level check: stopDecision should be HookDecision | null - // This line must compile without `as any`. + // 类型级检查:stopDecision 是 HookDecision | null,下面两行必须无 `as any` 通过编译 return stopDecision?.decision === 'continue' ? stopDecision.injection : null; }); - const result = await Effect.runPromise( - program.pipe(Effect.provide(HookLayer) as any) - ); + const result = await Effect.runPromise(program.pipe(Effect.provide(HookLayer) as any)); expect(result).toBe('(test continue)'); }); }); diff --git a/packages/codingcode/test/approval/pipeline.test.ts b/packages/codingcode/test/approval/pipeline.test.ts index 984daa1f..60197d74 100644 --- a/packages/codingcode/test/approval/pipeline.test.ts +++ b/packages/codingcode/test/approval/pipeline.test.ts @@ -7,12 +7,9 @@ import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { HookService } from '../../src/hooks/port.js'; const mockHookService = { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), emit: () => Effect.succeed(undefined), emitDecision: () => Effect.succeed(null), reloadUserHooks: () => Effect.succeed(undefined), - disposeSession: () => Effect.succeed(undefined), }; const mockApprovalWaitService = { diff --git a/packages/codingcode/test/client/get-session-plan.test.ts b/packages/codingcode/test/client/get-session-plan.test.ts index b869b786..ff6b8e1a 100644 --- a/packages/codingcode/test/client/get-session-plan.test.ts +++ b/packages/codingcode/test/client/get-session-plan.test.ts @@ -3,10 +3,11 @@ import { ManagedRuntime } from 'effect'; import { createHttpSessionClient } from '../../src/client/http/sessions.js'; import { createDirectSessionClient } from '../../src/direct/sessions.js'; import { SessionLayer } from '../../src/session/session.js'; -import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; -import { setProjectBaseDir, encodeProjectPath } from '../../src/core/path.js'; +import { getProjectBaseDir, encodeProjectPath } from '../../src/core/path.js'; +import { setFakeHome, restoreHome } from '../helpers/temp-home.js'; describe('getSessionPlan: http + direct both implement', () => { it('http calls GET /api/sessions/:id/plan?cwd=...', async () => { @@ -26,20 +27,22 @@ describe('getSessionPlan: http + direct both implement', () => { }); it('direct reads latest .md from project plan directory', async () => { - const base = join(tmpdir(), `plan-test-${Date.now()}`); + const home = join(tmpdir(), `plan-test-${Date.now()}`); + const prevHome = setFakeHome(home); + const base = getProjectBaseDir(); const projectDir = join(base, encodeProjectPath('/my/cwd')); - mkdirSync(projectDir, { recursive: true }); - writeFileSync(join(projectDir, 'first.md'), '# first'); - writeFileSync(join(projectDir, 'second.md'), '# second'); - setProjectBaseDir(base); try { + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, 'first.md'), '# first'); + writeFileSync(join(projectDir, 'second.md'), '# second'); const rt = ManagedRuntime.make(SessionLayer); const c = createDirectSessionClient(rt as any); const res = await c.getSessionPlan({ sessionId: 's1', cwd: '/my/cwd' }); expect(res.exists).toBe(true); expect(res.content === '# first' || res.content === '# second').toBe(true); } finally { - setProjectBaseDir(undefined); + restoreHome(prevHome); + rmSync(home, { recursive: true, force: true }); } }); }); diff --git a/packages/codingcode/test/core/path.test.ts b/packages/codingcode/test/core/path.test.ts index 8316e664..ccb47039 100644 --- a/packages/codingcode/test/core/path.test.ts +++ b/packages/codingcode/test/core/path.test.ts @@ -1,14 +1,19 @@ import { describe, it, expect } from 'vitest'; import { join, resolve } from 'path'; -import { tmpdir } from 'os'; +import { homedir, tmpdir } from 'os'; import { normalizePath, encodeProjectPath, resolveCwd, getTempCwd, isGlobalCwd, + CODINGCODE_DIRNAME, + getGlobalDir, + getProjectDir, } from '../../src/core/path.js'; +import { CODINGCODE_DIRNAME as INFRA_DIRNAME } from '@codingcode/infra/paths'; import { ShadowGit } from '../../src/checkpoint/shadow-git.js'; +import { setFakeHome, restoreHome } from '../helpers/temp-home.js'; describe('core/path', () => { it('normalizePath unifies Windows path variants', () => { @@ -58,3 +63,29 @@ describe('core/path', () => { expect(isGlobalCwd('/some/project')).toBe(false); }); }); + +describe('core/path:全局与工作区共用的私有目录名', () => { + it('目录名字面量被钉住:改它必须显式改这里(含 infra 那份副本)', () => { + expect(CODINGCODE_DIRNAME).toBe('.codingcode'); + // infra 是独立 workspace 包、位于更低层,无法 import codingcode,只能各持一份 + expect(INFRA_DIRNAME).toBe(CODINGCODE_DIRNAME); + }); + + it('全局目录落在用户目录下', () => { + expect(getGlobalDir()).toBe(join(homedir(), '.codingcode')); + }); + + it('工作区目录落在 projectRoot 下', () => { + expect(getProjectDir('/some/project')).toBe(join('/some/project', '.codingcode')); + }); + + it('每次调用时求值:把 home 指到临时目录立刻生效(测试隔离依赖这一点)', () => { + const fake = join(tmpdir(), `codingcode-dirs-${Date.now()}`); + const prev = setFakeHome(fake); + try { + expect(getGlobalDir()).toBe(join(fake, '.codingcode')); + } finally { + restoreHome(prev); + } + }); +}); diff --git a/packages/codingcode/test/helpers/agent-harness.ts b/packages/codingcode/test/helpers/agent-harness.ts index cca465e8..515290ab 100644 --- a/packages/codingcode/test/helpers/agent-harness.ts +++ b/packages/codingcode/test/helpers/agent-harness.ts @@ -286,12 +286,9 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { } as any), // agent 与 dispatch_agent 工具消费同一个 HookService Layer.succeed(HookService, { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), emit: hooks.emit, emitDecision: hooks.emitDecision, reloadUserHooks: () => Effect.void, - disposeSession: () => Effect.void, } as any), mcpLayer, Layer.succeed(SubagentRunnerService, {} as any), diff --git a/packages/codingcode/test/helpers/cleanup-test-artifacts.ts b/packages/codingcode/test/helpers/cleanup-test-artifacts.ts deleted file mode 100644 index 3a103138..00000000 --- a/packages/codingcode/test/helpers/cleanup-test-artifacts.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { afterAll } from 'vitest'; -import { existsSync, readdirSync, rmSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; - -const TEST_DIR_PATTERN = /^c-users-10116-appdata-local-temp-codingcode-test-/; - -/** - * Registers a process-wide afterAll that wipes any leftover test artifacts - * from the real `~/.codingcode/projects/` directory. Safety net for tests - * that don't use `useTempProjectBase`. - * - * Pattern matches encoded tmp cwd paths (e.g. on Windows: - * `c-users-10116-appdata-local-temp-codingcode-test-submit-plan-flow`). - */ -export function cleanupTestArtifacts(): void { - afterAll(() => { - const plansBase = join(homedir(), '.codingcode', 'projects'); - if (!existsSync(plansBase)) return; - try { - for (const entry of readdirSync(plansBase)) { - if (TEST_DIR_PATTERN.test(entry)) { - rmSync(join(plansBase, entry), { recursive: true, force: true }); - } - } - } catch { - // best-effort cleanup - } - }); -} diff --git a/packages/codingcode/test/helpers/project-base.ts b/packages/codingcode/test/helpers/project-base.ts index 95a89322..cc1ac9a8 100644 --- a/packages/codingcode/test/helpers/project-base.ts +++ b/packages/codingcode/test/helpers/project-base.ts @@ -1,22 +1,22 @@ -import { mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { afterEach, beforeEach } from 'vitest'; -import { setProjectBaseDir, getProjectBaseDir } from '../../src/core/path.js'; +import { mkdirSync } from 'fs'; +import { beforeEach } from 'vitest'; +import { getProjectBaseDir } from '../../src/core/path.js'; +import { useTempHome } from './temp-home.js'; export interface TempProjectBase { readonly dir: string; } +/** + * 把 `getProjectBaseDir()`(`~/.codingcode/project`)关进临时目录。 + * + * 它本身没有独立开关(应用不提供「可指定」入口),只能靠把进程 home 指到沙箱, + * 因此本助手 = `useTempHome` + 建出 project 目录。 + */ export function useTempProjectBase(prefix = 'codingcode-test-project-base-'): TempProjectBase { - let dir = ''; + useTempHome(prefix); beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), prefix)); - setProjectBaseDir(dir); - }); - afterEach(() => { - setProjectBaseDir(undefined); - rmSync(dir, { recursive: true, force: true }); + mkdirSync(getProjectBaseDir(), { recursive: true }); }); return { get dir() { diff --git a/packages/codingcode/test/helpers/temp-home.ts b/packages/codingcode/test/helpers/temp-home.ts new file mode 100644 index 00000000..0c142f4b --- /dev/null +++ b/packages/codingcode/test/helpers/temp-home.ts @@ -0,0 +1,69 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach } from 'vitest'; + +/** home 相关环境变量的快照,供 `restoreHome` 还原 */ +export interface HomeSnapshot { + readonly HOME: string | undefined; + readonly USERPROFILE: string | undefined; +} + +export interface TempHome { + /** 临时 home 目录本身 */ + readonly home: string; + /** + * 全局配置目录 = `join(home, '.codingcode')`;未必已存在,写配置的代码会自行创建。 + * + * 这里刻意写字面量而不引用源码常量:本助手模拟的是「OS 提供的 home 布局」, + * 常量若被改错,正需要在这一侧显性同步才能暴露出来(另有 `test/core/path.test.ts` 钉住字面量)。 + */ + readonly configDir: string; +} + +/** 立即把 home 指向 `dir`(不创建目录);返回旧值,交给 `restoreHome` 还原 */ +export function setFakeHome(dir: string): HomeSnapshot { + const prev: HomeSnapshot = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + process.env.HOME = dir; + process.env.USERPROFILE = dir; + return prev; +} + +/** 还原 `setFakeHome` 捕获的环境变量 */ +export function restoreHome(prev: HomeSnapshot): void { + if (prev.HOME === undefined) delete process.env.HOME; + else process.env.HOME = prev.HOME; + if (prev.USERPROFILE === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prev.USERPROFILE; +} + +/** + * 把进程 home 指向临时目录,使 `~/.codingcode` 落到沙箱里,不碰开发机真实用户目录。 + * + * 两个变量都要设:`os.homedir()` 在 Windows 只认 `USERPROFILE`、在 POSIX 只认 `HOME` + * (实测 Windows 下仅设 `HOME` 无效)。⇒ 被测代码必须**每次调用时**才解析 home, + * 不能在模块加载期把路径算成常量,否则这里改的变量起不到作用。 + */ +export function useTempHome(prefix = 'codingcode-test-home-'): TempHome { + let home = ''; + let prev: HomeSnapshot = { HOME: undefined, USERPROFILE: undefined }; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), prefix)); + prev = setFakeHome(home); + }); + + afterEach(() => { + restoreHome(prev); + rmSync(home, { recursive: true, force: true }); + }); + + return { + get home() { + return home; + }, + get configDir() { + return join(home, '.codingcode'); + }, + }; +} diff --git a/packages/codingcode/test/hooks/config-merge.test.ts b/packages/codingcode/test/hooks/config-merge.test.ts index 7eecc9da..d65f3caf 100644 --- a/packages/codingcode/test/hooks/config-merge.test.ts +++ b/packages/codingcode/test/hooks/config-merge.test.ts @@ -11,12 +11,13 @@ import { resolveHookConfigs, setGlobalHookEnabled, setProjectHookEnabled, - _setGlobalConfigDir, } from '../../src/hooks/config.js'; import type { UserHookConfig } from '../../src/contracts/hooks.js'; +import { useTempHome, setFakeHome } from '../helpers/temp-home.js'; let projectDir: string; -let globalDir: string; +// 全局层落在临时 home 里,避免读到/写坏开发机的 ~/.codingcode(全局配置目录不可指定) +const tempHome = useTempHome('codingcode-test-hooks-merge-'); function readYaml(p: string): any { return parseYaml(readFileSync(p, 'utf8')); @@ -28,19 +29,25 @@ function hook(name: string, command: string): UserHookConfig { beforeEach(() => { projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-project-')); - globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-global-')); mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); - mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); - _setGlobalConfigDir(globalDir); }); afterEach(() => { - _setGlobalConfigDir(undefined); rmSync(projectDir, { recursive: true, force: true }); - rmSync(globalDir, { recursive: true, force: true }); }); describe('Hooks config merge', () => { + it('全局目录不存在时写入会自行创建', () => { + const nestedHome = join(tempHome.home, 'nested', 'home'); // 连 home 本身都还不存在 + setFakeHome(nestedHome); + + writeGlobalHookConfigs([hook('a', 'x')]); + + expect(readYaml(join(nestedHome, '.codingcode', 'hooks.yaml')).hooks).toEqual([ + { name: 'a', point: 'tool.execute.before', type: 'observer', command: 'x' }, + ]); + }); + it('merges global and project by name, project wins', () => { writeGlobalHookConfigs([hook('global-hook', 'global-cmd'), hook('shared-hook', 'global-shared')]); writeHookConfigs(projectDir, [ @@ -84,7 +91,7 @@ describe('Hook enabled switch (a plain boolean field in hooks.yaml)', () => { setGlobalHookEnabled('a', false); - expect(readYaml(join(globalDir, 'hooks.yaml')).hooks[0]).toEqual({ + expect(readYaml(join(tempHome.configDir, 'hooks.yaml')).hooks[0]).toEqual({ name: 'a', point: 'tool.execute.before', type: 'observer', diff --git a/packages/codingcode/test/hooks/config.test.ts b/packages/codingcode/test/hooks/config.test.ts index a0143fa0..029ddfde 100644 --- a/packages/codingcode/test/hooks/config.test.ts +++ b/packages/codingcode/test/hooks/config.test.ts @@ -37,7 +37,7 @@ describe('writeHookConfigs', () => { writeHookConfigs(testDir, [ { name: 'test-hook', - point: 'session.save.before', + point: 'agent.turn.end', type: 'observer', command: 'echo', enabled: true, @@ -46,7 +46,7 @@ describe('writeHookConfigs', () => { const result = loadHookConfigs(testDir); expect(result).toHaveLength(1); expect(result[0]!.name).toBe('test-hook'); - expect(result[0]!.point).toBe('session.save.before'); + expect(result[0]!.point).toBe('agent.turn.end'); expect(result[0]!.type).toBe('observer'); }); diff --git a/packages/codingcode/test/hooks/decision.test.ts b/packages/codingcode/test/hooks/decision.test.ts index 98df5bdc..2aa9a8f2 100644 --- a/packages/codingcode/test/hooks/decision.test.ts +++ b/packages/codingcode/test/hooks/decision.test.ts @@ -1,7 +1,13 @@ -import { describe, it, expect } from 'vitest'; -import { Effect, Layer } from 'effect'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +// 必须排在 hooks.js 前面:vi.mock 工厂要引用 fakeSpawn +import { spawnRecords, whenCommand, resetFakeSpawn, fakeSpawn } from './fake-spawn.js'; +import { Effect } from 'effect'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; +import { join, resolve } from 'path'; +import { tmpdir } from 'os'; import { HookService } from '../../src/hooks/port.js'; import { HookLayer } from '../../src/hooks/hooks.js'; +import { useTempHome } from '../helpers/temp-home.js'; const TestLayer = HookLayer; @@ -9,120 +15,208 @@ function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(TestLayer) as any)); } -describe('HookService 鈥?Decision Handlers', () => { - it('should return null from emitDecision when no handlers registered', async () => { +vi.mock('child_process', () => ({ + spawn: (command: string, args: string[]) => fakeSpawn(command, args), +})); + +interface HookLine { + name: string; + point: string; + type: 'observer' | 'decision'; + command: string; + priority?: number; +} + +describe('HookService.emitDecision(YAML 定义的决策 hook)', () => { + const testDir = resolve(tmpdir(), 'codingcode-test-hooks-decision'); + // 全局层落在临时 home 里,避免读到开发机上的 ~/.codingcode(全局配置目录不可指定) + useTempHome('codingcode-test-hooks-decision-'); + + beforeEach(() => { + resetFakeSpawn(); + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + mkdirSync(join(testDir, '.codingcode'), { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + function writeHooksYaml(hooks: HookLine[]) { + const lines = hooks.map((h) => { + const parts = [ + ` - name: ${h.name}`, + ` point: ${h.point}`, + ` type: ${h.type}`, + ` command: ${h.command}`, + ' args: []', + ]; + if (h.priority !== undefined) parts.push(` priority: ${h.priority}`); + return parts.join('\n'); + }); + writeFileSync(join(testDir, '.codingcode', 'hooks.yaml'), `hooks:\n${lines.join('\n')}\n`); + } + + it('没有决策 hook 时返回 null,且不 spawn', async () => { const result = await run( Effect.gen(function* () { const hooks = yield* HookService; - return yield* hooks.emitDecision('tool.approval.pre', {}); + yield* hooks.reloadUserHooks(testDir); + return yield* hooks.emitDecision('tool.approval.pre', { projectPath: testDir }); }) ); + expect(result).toBeNull(); + expect(spawnRecords).toHaveLength(0); }); - it('should return first non-null decision from registered handlers', async () => { + it('返回决策 hook 写到 stdout 的 JSON,并把 payload 送进 stdin', async () => { + writeHooksYaml([ + { name: 'd', point: 'tool.approval.pre', type: 'decision', command: 'cmd-deny' }, + ]); + whenCommand('cmd-deny', { stdout: '{"decision":"deny","reason":"blocked by policy"}' }); + const result = await run( Effect.gen(function* () { const hooks = yield* HookService; - - yield* hooks.registerDecision('tool.approval.pre', () => null, { priority: 10 }); - yield* hooks.registerDecision( - 'tool.approval.pre', - () => ({ decision: 'deny' as const, reason: 'Second handler' }), - { priority: 20 } - ); - yield* hooks.registerDecision('tool.approval.pre', () => ({ decision: 'allow' as const }), { - priority: 30, + yield* hooks.reloadUserHooks(testDir); + return yield* hooks.emitDecision('tool.approval.pre', { + projectPath: testDir, + toolName: 'Bash', }); - - return yield* hooks.emitDecision('tool.approval.pre', {}); }) ); - expect(result).toEqual({ decision: 'deny', reason: 'Second handler' }); + + expect(result).toEqual({ decision: 'deny', reason: 'blocked by policy' }); + expect(spawnRecords[0]!.payload).toEqual({ projectPath: testDir, toolName: 'Bash' }); }); - it('should prioritize lower priority number (runs first)', async () => { - const order: number[] = []; + it('按 priority 升序取首个非 null 的决策,命中即不再跑后面的', async () => { + writeHooksYaml([ + { name: 'null', point: 'tool.approval.pre', type: 'decision', command: 'cmd-null', priority: 1 }, + { name: 'deny', point: 'tool.approval.pre', type: 'decision', command: 'cmd-deny', priority: 2 }, + { name: 'never', point: 'tool.approval.pre', type: 'decision', command: 'cmd-never', priority: 3 }, + ]); + whenCommand('cmd-deny', { stdout: '{"decision":"deny"}' }); + const result = await run( Effect.gen(function* () { const hooks = yield* HookService; - - yield* hooks.registerDecision( - 'tool.approval.pre', - () => { - order.push(20); - return null; - }, - { priority: 20 } - ); - yield* hooks.registerDecision( - 'tool.approval.pre', - () => { - order.push(10); - return { decision: 'deny' as const }; - }, - { priority: 10 } - ); - - const r = yield* hooks.emitDecision('tool.approval.pre', {}); - return { order, r }; + yield* hooks.reloadUserHooks(testDir); + return yield* hooks.emitDecision('tool.approval.pre', { projectPath: testDir }); }) ); - expect(order).toEqual([10]); // priority 10 ran first, returned non-null - expect(result.r).toEqual({ decision: 'deny' }); + + expect(result).toEqual({ decision: 'deny' }); + expect(spawnRecords.map((r) => r.command)).toEqual(['cmd-null', 'cmd-deny']); }); - it('should separate observer and decision handlers', async () => { - const calls: string[] = []; + it('非零退出降级为 null,落到下一个决策 hook', async () => { + writeHooksYaml([ + { name: 'fail', point: 'tool.approval.pre', type: 'decision', command: 'cmd-fail', priority: 1 }, + { name: 'ask', point: 'tool.approval.pre', type: 'decision', command: 'cmd-ask', priority: 2 }, + ]); + whenCommand('cmd-fail', { code: 1, stdout: '{"decision":"deny"}' }); + whenCommand('cmd-ask', { stdout: '{"decision":"ask"}' }); + const result = await run( Effect.gen(function* () { const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + return yield* hooks.emitDecision('tool.approval.pre', { projectPath: testDir }); + }) + ); - yield* hooks.register('tool.approval.pre', () => { - calls.push('observer'); - }); - yield* hooks.registerDecision( - 'tool.approval.pre', - () => { - calls.push('decision'); - return { decision: 'deny' as const }; - }, - { priority: 5 } - ); - - const r = yield* hooks.emitDecision('tool.approval.pre', {}); - return { calls, r }; + expect(result).toEqual({ decision: 'ask' }); + }); + + it('stdout 不是合法 JSON 时降级为 null,落到下一个决策 hook', async () => { + writeHooksYaml([ + { name: 'junk', point: 'tool.approval.pre', type: 'decision', command: 'cmd-junk', priority: 1 }, + { name: 'allow', point: 'tool.approval.pre', type: 'decision', command: 'cmd-allow', priority: 2 }, + ]); + whenCommand('cmd-junk', { stdout: 'not json at all' }); + whenCommand('cmd-allow', { stdout: '{"decision":"allow"}' }); + + const result = await run( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + return yield* hooks.emitDecision('tool.approval.pre', { projectPath: testDir }); }) ); - // emitDecision only runs decision handlers, not observers - expect(result.calls).toEqual(['decision']); - expect(result.r).toEqual({ decision: 'deny' }); + + expect(result).toEqual({ decision: 'allow' }); }); - it('should unregister decision handler via returned function', async () => { + it('观察者不参与 emitDecision', async () => { + writeHooksYaml([ + { name: 'obs', point: 'tool.approval.pre', type: 'observer', command: 'cmd-obs' }, + ]); + const result = await run( Effect.gen(function* () { const hooks = yield* HookService; - const unregister = yield* hooks.registerDecision('tool.approval.pre', () => ({ - decision: 'deny' as const, - })); - yield* Effect.sync(() => unregister()); - return yield* hooks.emitDecision('tool.approval.pre', {}); + yield* hooks.reloadUserHooks(testDir); + return yield* hooks.emitDecision('tool.approval.pre', { projectPath: testDir }); }) ); + expect(result).toBeNull(); + expect(spawnRecords).toHaveLength(0); + }); + + it('决策 hook 不参与 emit', async () => { + writeHooksYaml([ + { name: 'd', point: 'tool.execute.before', type: 'decision', command: 'cmd-deny' }, + ]); + whenCommand('cmd-deny', { stdout: '{"decision":"deny"}' }); + + await run( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + yield* hooks.emit('tool.execute.before', { projectPath: testDir }); + }) + ); + + expect(spawnRecords).toHaveLength(0); }); - it('should support allow/ask/deny from decision handler', async () => { + it('continue 决策连同 injection 一起透传(agent.turn.stop 用)', async () => { + writeHooksYaml([ + { name: 'loop', point: 'agent.turn.stop', type: 'decision', command: 'cmd-loop' }, + ]); + whenCommand('cmd-loop', { stdout: '{"decision":"continue","injection":"keep going"}' }); + const result = await run( Effect.gen(function* () { const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + return yield* hooks.emitDecision('agent.turn.stop', { projectPath: testDir, content: 'hi' }); + }) + ); + + expect(result?.decision).toBe('continue'); + expect(result?.injection).toBe('keep going'); + }); - yield* hooks.registerDecision('tool.approval.pre', () => ({ decision: 'ask' as const })); + it('所有决策 hook 都返回 null 时整体返回 null', async () => { + writeHooksYaml([ + { name: 'n1', point: 'tool.approval.pre', type: 'decision', command: 'cmd-n1', priority: 1 }, + { name: 'n2', point: 'tool.approval.pre', type: 'decision', command: 'cmd-n2', priority: 2 }, + ]); - return yield* hooks.emitDecision('tool.approval.pre', {}); + const result = await run( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + return yield* hooks.emitDecision('tool.approval.pre', { projectPath: testDir }); }) ); - expect(result).toEqual({ decision: 'ask' }); + + expect(result).toBeNull(); + // 两个都跑过(没有短路) + expect(spawnRecords.map((r) => r.command)).toEqual(['cmd-n1', 'cmd-n2']); }); }); diff --git a/packages/codingcode/test/hooks/fake-spawn.ts b/packages/codingcode/test/hooks/fake-spawn.ts new file mode 100644 index 00000000..d0bba41b --- /dev/null +++ b/packages/codingcode/test/hooks/fake-spawn.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from 'events'; + +export interface FakeProcOpts { + /** 退出码,默认 0 */ + code?: number | null; + /** 写到 stdout 的内容(决策 hook 读它) */ + stdout?: string; + /** 触发 'error' 事件 */ + error?: Error; + /** 延迟多少毫秒才 close(测超时/顺序用) */ + delayMs?: number; +} + +export interface SpawnRecord { + command: string; + args: string[]; + payload: unknown; +} + +export type FakeProc = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + stdin: { write: (chunk: string) => void; end: () => void }; + kill: () => void; +}; + +/** 每次 spawn 的记录,按发生顺序(emit 是顺序执行,故顺序即 handler 顺序) */ +export const spawnRecords: SpawnRecord[] = []; + +const responses = new Map(); + +/** 按 command 配置该次 spawn 的行为;未配置则 code 0 + 空 stdout */ +export function whenCommand(command: string, opts: FakeProcOpts): void { + responses.set(command, opts); +} + +export function resetFakeSpawn(): void { + spawnRecords.length = 0; + responses.clear(); +} + +/** 替代 child_process.spawn 的桩 */ +export function fakeSpawn(command: string, args: string[] = []): FakeProc { + const proc = new EventEmitter() as FakeProc; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = () => { + /* 超时路径:不再触发 close */ + }; + let payload: unknown; + proc.stdin = { + write: (chunk: string) => { + payload = JSON.parse(chunk); + }, + end: () => { + /* noop */ + }, + }; + + const opts = responses.get(command) ?? {}; + const finish = () => { + spawnRecords.push({ command, args, payload }); + if (opts.error) { + proc.emit('error', opts.error); + return; + } + if (opts.stdout) proc.stdout.emit('data', Buffer.from(opts.stdout)); + proc.emit('close', opts.code ?? 0); + }; + if (opts.delayMs) setTimeout(finish, opts.delayMs); + else queueMicrotask(finish); + + return proc; +} diff --git a/packages/codingcode/test/hooks/points-coverage.test.ts b/packages/codingcode/test/hooks/points-coverage.test.ts new file mode 100644 index 00000000..f4117441 --- /dev/null +++ b/packages/codingcode/test/hooks/points-coverage.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'fs'; +import { join, resolve } from 'path'; + +const SRC = resolve(__dirname, '../../src'); + +function listTsFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) out.push(...listTsFiles(p)); + else if (entry.name.endsWith('.ts')) out.push(p); + } + return out; +} + +/** 契约里声明的 HookPoint 联合成员 */ +function declaredPoints(): string[] { + const src = readFileSync(join(SRC, 'contracts', 'hooks.ts'), 'utf8'); + const union = src.match(/export type HookPoint =([\s\S]*?);/)![1]!; + return [...union.matchAll(/'([^']+)'/g)].map((m) => m[1]!); +} + +/** 生产代码里真正 emit 的点 */ +function emittedPoints(): Set { + const found = new Set(); + for (const file of listTsFiles(SRC)) { + const text = readFileSync(file, 'utf8'); + for (const m of text.matchAll(/hooks\.emit(?:Decision)?\(\s*'([^']+)'/g)) { + found.add(m[1]!); + } + } + return found; +} + +describe('HookPoint 契约与触发点一致', () => { + it('每个声明的钩子点都有真实触发点(不存在「定义了但从不 emit」的点)', () => { + const emitted = emittedPoints(); + const missing = declaredPoints().filter((p) => !emitted.has(p)); + expect(missing).toEqual([]); + }); + + it('不存在 emit 了但没写进契约的点', () => { + const declared = new Set(declaredPoints()); + const undeclared = [...emittedPoints()].filter((p) => !declared.has(p)); + expect(undeclared).toEqual([]); + }); + + it('契约本身非空(防止上面两条正则失配后静默通过)', () => { + expect(declaredPoints().length).toBeGreaterThanOrEqual(12); + expect(emittedPoints().size).toBeGreaterThanOrEqual(12); + }); +}); diff --git a/packages/codingcode/test/hooks/registry.test.ts b/packages/codingcode/test/hooks/registry.test.ts index b122028a..ac054dd2 100644 --- a/packages/codingcode/test/hooks/registry.test.ts +++ b/packages/codingcode/test/hooks/registry.test.ts @@ -1,266 +1,258 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +// 这个 import 必须排在 hooks.js 前面:vi.mock 的工厂要用 fakeSpawn, +// 而工厂会在 hooks.js 首次 import child_process 时执行。 +import { spawnRecords, whenCommand, resetFakeSpawn, fakeSpawn } from './fake-spawn.js'; import { Effect } from 'effect'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join, resolve } from 'path'; import { tmpdir } from 'os'; import { HookService } from '../../src/hooks/port.js'; import { HookLayer } from '../../src/hooks/hooks.js'; +import { useTempHome } from '../helpers/temp-home.js'; + const AppLayer = HookLayer; function runWithLayer(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(AppLayer) as any)); } -describe('HookService', () => { - it('should register and emit a hook', async () => { - const handler = vi.fn(); +vi.mock('child_process', () => ({ + spawn: (command: string, args: string[]) => fakeSpawn(command, args), +})); - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.register('tool.execute.before', handler); - yield* hooks.emit('tool.execute.before', { key: 'val' }); - return handler.mock.calls.length; - }); +describe('HookService.emit(YAML 定义的观察者)', () => { + const testDir = resolve(tmpdir(), 'codingcode-test-hooks-emit'); + // 全局层落在临时 home 里,避免读到开发机上的 ~/.codingcode(全局配置目录不可指定) + const tempHome = useTempHome('codingcode-test-hooks-emit-'); - const count = await runWithLayer(program); - expect(count).toBe(1); - expect(handler).toHaveBeenCalledWith({ key: 'val' }); + beforeEach(() => { + resetFakeSpawn(); + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + mkdirSync(join(testDir, '.codingcode'), { recursive: true }); }); - it('should not throw on emit with no handlers', async () => { - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.emit('tool.execute.after', {}); - return true; - }); - - const result = await runWithLayer(program); - expect(result).toBe(true); + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); }); - it('should return unregister function', async () => { - const handler = vi.fn(); + interface HookLine { + name: string; + point: string; + type?: 'observer' | 'decision'; + command: string; + priority?: number; + enabled?: boolean; + } - const program = Effect.gen(function* () { - const hooks = yield* HookService; - const unregister = yield* hooks.register('llm.request.before', handler); - unregister(); // remove handler - yield* hooks.emit('llm.request.before', {}); - return handler.mock.calls.length; + function hookYaml(hooks: HookLine[]): string { + const lines = hooks.map((h) => { + const parts = [ + ` - name: ${h.name}`, + ` point: ${h.point}`, + ` type: ${h.type ?? 'observer'}`, + ` command: ${h.command}`, + ' args: []', + ]; + if (h.priority !== undefined) parts.push(` priority: ${h.priority}`); + if (h.enabled !== undefined) parts.push(` enabled: ${h.enabled}`); + return parts.join('\n'); }); + return `hooks:\n${lines.join('\n')}\n`; + } - const count = await runWithLayer(program); - expect(count).toBe(0); - }); + function writeHooksYaml(hooks: HookLine[]) { + writeFileSync(join(testDir, '.codingcode', 'hooks.yaml'), hookYaml(hooks)); + } - it('should call multiple handlers for same hook point', async () => { - const h1 = vi.fn(); - const h2 = vi.fn(); + function writeGlobalHooksYaml(hooks: HookLine[]) { + mkdirSync(tempHome.configDir, { recursive: true }); + writeFileSync(join(tempHome.configDir, 'hooks.yaml'), hookYaml(hooks)); + } - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.register('session.save.after', h1); - yield* hooks.register('session.save.after', h2); - yield* hooks.emit('session.save.after', {}); - }); + it('把 hooks.yaml 里的观察者注册到对应点,并把 payload 原样送进子进程 stdin', async () => { + writeHooksYaml([{ name: 'h-a', point: 'tool.execute.before', command: 'cmd-a' }]); - await runWithLayer(program); - expect(h1).toHaveBeenCalledTimes(1); - expect(h2).toHaveBeenCalledTimes(1); + await runWithLayer( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + yield* hooks.emit('tool.execute.before', { projectPath: testDir, toolName: 'read_file' }); + }) + ); + + expect(spawnRecords).toHaveLength(1); + expect(spawnRecords[0]!.command).toBe('cmd-a'); + expect(spawnRecords[0]!.payload).toEqual({ projectPath: testDir, toolName: 'read_file' }); }); - it('should support async handlers', async () => { - const results: string[] = []; - const handler = async () => { - await new Promise((r) => setTimeout(r, 5)); - results.push('done'); - }; - - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.register('tool.execute.after', handler); - yield* hooks.emit('tool.execute.after', {}); - }); - - await runWithLayer(program); - expect(results).toEqual(['done']); + it('emit 在没有 handler 的点上是 no-op:不抛错、不 spawn', async () => { + await runWithLayer( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.emit('tool.execute.after', { projectPath: testDir }); + }) + ); + expect(spawnRecords).toHaveLength(0); }); - it('should isolate handler exceptions 鈥?later handlers still run after one throws', async () => { - const called: string[] = []; + it('enabled: false 的 hook 不注册', async () => { + writeHooksYaml([{ name: 'off', point: 'tool.execute.before', command: 'cmd-off', enabled: false }]); - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.register('session.save.before', async () => { - throw new Error('bad handler'); - }); - yield* hooks.register('session.save.before', () => { - called.push('second'); - }); - yield* hooks.emit('session.save.before', {}); - }); + await runWithLayer( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + yield* hooks.emit('tool.execute.before', { projectPath: testDir }); + }) + ); - await runWithLayer(program); - expect(called).toEqual(['second']); + expect(spawnRecords).toHaveLength(0); }); - it('should isolate decision handler exceptions 鈥?skips erroring handler and tries next', async () => { - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.registerDecision( - 'agent.turn.stop', - async () => { - throw new Error('bad decision'); - }, - { priority: 0 } - ); - yield* hooks.registerDecision( - 'agent.turn.stop', - async () => ({ decision: 'continue' as const }), - { priority: 1 } - ); - return yield* hooks.emitDecision('agent.turn.stop', {}); - }); + it('同一点的多个观察者按 priority 升序依次执行', async () => { + writeHooksYaml([ + { name: 'late', point: 'tool.execute.before', command: 'cmd-late', priority: 20 }, + { name: 'early', point: 'tool.execute.before', command: 'cmd-early', priority: 10 }, + { name: 'zero', point: 'tool.execute.before', command: 'cmd-zero' }, + ]); - const result = await runWithLayer(program); - expect(result?.decision).toBe('continue'); + await runWithLayer( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + yield* hooks.emit('tool.execute.before', { projectPath: testDir }); + }) + ); + + expect(spawnRecords.map((r) => r.command)).toEqual(['cmd-zero', 'cmd-early', 'cmd-late']); }); - it('runs Effect-returning observers in the emit fiber context (yield* services)', async () => { - // The whole reason ObserverHandler is allowed to return an Effect: the - // observer should be able to yield* services from the caller's fiber - // (e.g. HookService) without resorting to Effect.runFork / default - // runtime. This test pins that contract. - const sideEffect: { ran: boolean; usedService: boolean } = { - ran: false, - usedService: false, - }; - - const observer: import('../../src/hooks/types.js').ObserverHandler = (payload) => + it('reloadUserHooks 替换上一次注册的,不累积', async () => { + writeHooksYaml([{ name: 'first', point: 'tool.execute.before', command: 'cmd-first' }]); + + const program = (label: string) => Effect.gen(function* () { - // yield* in the observer body — this is the contract under test. - // If emit runs the observer on a default runtime (no services), - // this line throws "Service not found: HookService". const hooks = yield* HookService; - sideEffect.ran = true; - sideEffect.usedService = typeof hooks.register === 'function'; - void payload; + yield* hooks.reloadUserHooks(testDir); + yield* hooks.emit('tool.execute.before', { projectPath: testDir, label }); }); - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.register('tool.execute.after', observer, { source: 'system' }); - yield* hooks.emit('tool.execute.after', { - toolName: 'submit_plan', - sessionId: 'sess-1', - projectPath: '/proj', - args: { plan_content: 'x' }, - result: { output: 'Plan written to /x' }, - }); - return sideEffect; - }); - - const result = await runWithLayer(program); - expect(result.ran).toBe(true); - expect(result.usedService).toBe(true); - }); -}); - -describe('HookService.reloadUserHooks', () => { - const testDir = resolve(tmpdir(), 'codingcode-test-hooks-reload'); + await runWithLayer(program('one')); + expect(spawnRecords).toHaveLength(1); - beforeEach(() => { - if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); - mkdirSync(join(testDir, '.codingcode'), { recursive: true }); - }); + writeHooksYaml([{ name: 'second', point: 'tool.execute.before', command: 'cmd-second' }]); + await runWithLayer(program('two')); - afterEach(() => { - if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + expect(spawnRecords.map((r) => r.command)).toEqual(['cmd-first', 'cmd-second']); }); - function writeHooksYaml(hookName: string, point: string, enabled: boolean) { - const content = `hooks:\n - name: ${hookName}\n point: ${point}\n type: observer\n command: echo\n args: []\n enabled: ${enabled}\n`; - writeFileSync(join(testDir, '.codingcode', 'hooks.yaml'), content); - } - - it('clears old user hooks and loads new ones from disk', async () => { - const called: string[] = []; + it('hook 只在它所属的 projectPath 下生效', async () => { + writeHooksYaml([{ name: 'h-a', point: 'tool.execute.before', command: 'cmd-a' }]); - writeHooksYaml('hook-a', 'tool.execute.before', true); + await runWithLayer( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + yield* hooks.emit('tool.execute.before', { projectPath: '/somewhere/else' }); + yield* hooks.emit('tool.execute.before', { projectPath: undefined }); + yield* hooks.emit('tool.execute.before', { projectPath: testDir }); + }) + ); + + expect(spawnRecords).toHaveLength(1); + }); - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.register('tool.execute.before', () => { - called.push('system'); - }); - yield* hooks.reloadUserHooks(testDir); + it('单个观察者失败不阻断后面的观察者,emit 也不失败', async () => { + writeHooksYaml([ + { name: 'bad', point: 'tool.execute.before', command: 'cmd-bad', priority: 1 }, + { name: 'good', point: 'tool.execute.before', command: 'cmd-good', priority: 2 }, + ]); + whenCommand('cmd-bad', { error: new Error('boom') }); - writeHooksYaml('hook-b', 'tool.execute.before', true); - yield* hooks.reloadUserHooks(testDir); - }); + const result = await runWithLayer( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + yield* hooks.emit('tool.execute.before', { projectPath: testDir }); + return 'emit survived'; + }) + ); + + expect(result).toBe('emit survived'); + // cmd-bad 挂在 error 上(error 先于 close,记录已入),cmd-good 必须照跑 + expect(spawnRecords.map((r) => r.command)).toEqual(['cmd-bad', 'cmd-good']); + }); - await runWithLayer(program); - expect(called).toHaveLength(0); + it('reloadUserHooks 指向不存在的目录时不注册任何 hook', async () => { + await runWithLayer( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(join(testDir, 'nope')); + yield* hooks.emit('tool.execute.before', { projectPath: join(testDir, 'nope') }); + }) + ); + expect(spawnRecords).toHaveLength(0); }); - it('disabled hooks in yaml are not registered', async () => { - writeHooksYaml('disabled-hook', 'tool.execute.before', false); + it('emit 把 payload 的全部字段(含非字符串值)序列化给子进程', async () => { + writeHooksYaml([{ name: 'h-a', point: 'tool.execute.after', command: 'cmd-a' }]); - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.reloadUserHooks(testDir); + await runWithLayer( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + yield* hooks.emit('tool.execute.after', { + projectPath: testDir, + toolName: 'write_file', + durationMs: 42, + args: { path: '/tmp/x', content: 'hi' }, + }); + }) + ); + + expect(spawnRecords[0]!.payload).toEqual({ + projectPath: testDir, + toolName: 'write_file', + durationMs: 42, + args: { path: '/tmp/x', content: 'hi' }, }); - - await runWithLayer(program); }); - it('reloadUserHooks with empty cwd clears all user hooks', async () => { - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.reloadUserHooks(join(process.cwd(), 'nonexistent-dir-xyzzy')); - }); - - await runWithLayer(program); - }); + it('只在全局层定义的 hook 在项目层没写任何东西时照常生效', async () => { + writeGlobalHooksYaml([ + { name: 'g', point: 'tool.execute.before', command: 'cmd-global' }, + ]); - it('system hooks survive reloadUserHooks', async () => { - const called: string[] = []; - - const program = Effect.gen(function* () { - const hooks = yield* HookService; - // Register with source: 'system' 鈥?should survive reload - yield* hooks.register( - 'tool.execute.before', - () => { - called.push('system'); - }, - { source: 'system' } - ); - yield* hooks.reloadUserHooks(testDir); - - // Emit should still call the system handler - yield* hooks.emit('tool.execute.before', {}); - }); + await runWithLayer( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + yield* hooks.emit('tool.execute.before', { projectPath: testDir }); + }) + ); - await runWithLayer(program); - expect(called).toEqual(['system']); + expect(spawnRecords.map((r) => r.command)).toEqual(['cmd-global']); }); - it('register with source option defaults to user', async () => { - const called: string[] = []; - - const program = Effect.gen(function* () { - const hooks = yield* HookService; - // No source option 鈥?defaults to 'user', should be cleared - yield* hooks.register('tool.execute.before', () => { - called.push('default-user'); - }); - yield* hooks.reloadUserHooks(testDir); - - yield* hooks.emit('tool.execute.before', {}); - }); + it('项目层用 {name, enabled:false} 最小补丁就能关掉全局定义的 hook', async () => { + writeGlobalHooksYaml([ + { name: 'stays', point: 'tool.execute.before', command: 'cmd-stays', priority: 1 }, + { name: 'killed', point: 'tool.execute.before', command: 'cmd-killed', priority: 2 }, + ]); + // 只写 name + enabled,不复制 command/point,靠字段级合并继承全局的其余字段 + writeFileSync( + join(testDir, '.codingcode', 'hooks.yaml'), + 'hooks:\n - name: killed\n enabled: false\n' + ); + + await runWithLayer( + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.reloadUserHooks(testDir); + yield* hooks.emit('tool.execute.before', { projectPath: testDir }); + }) + ); - await runWithLayer(program); - expect(called).toHaveLength(0); + expect(spawnRecords.map((r) => r.command)).toEqual(['cmd-stays']); }); }); diff --git a/packages/codingcode/test/mcp/config-merge.test.ts b/packages/codingcode/test/mcp/config-merge.test.ts index cc0d4a75..787fc370 100644 --- a/packages/codingcode/test/mcp/config-merge.test.ts +++ b/packages/codingcode/test/mcp/config-merge.test.ts @@ -11,11 +11,12 @@ import { resolveMcpConfig, setGlobalMcpServerEnabled, setProjectMcpServerEnabled, - _setGlobalConfigDir, } from '../../src/mcp/config.js'; +import { useTempHome, setFakeHome } from '../helpers/temp-home.js'; let projectDir: string; -let globalDir: string; +// 全局层落在临时 home 里,避免读到/写坏开发机的 ~/.codingcode(全局配置目录不可指定) +const tempHome = useTempHome('codingcode-test-mcp-merge-'); function readYaml(p: string): any { return parseYaml(readFileSync(p, 'utf8')); @@ -23,19 +24,25 @@ function readYaml(p: string): any { beforeEach(() => { projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-project-')); - globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-global-')); mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); - mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); - _setGlobalConfigDir(globalDir); }); afterEach(() => { - _setGlobalConfigDir(undefined); rmSync(projectDir, { recursive: true, force: true }); - rmSync(globalDir, { recursive: true, force: true }); }); describe('MCP config merge', () => { + it('全局目录不存在时写入会自行创建', () => { + const nestedHome = join(tempHome.home, 'nested', 'home'); // 连 home 本身都还不存在 + setFakeHome(nestedHome); + + writeGlobalMcpConfig([{ name: 's', command: 'c' }]); + + expect(readYaml(join(nestedHome, '.codingcode', 'mcp.yaml')).servers).toEqual([ + { name: 's', command: 'c' }, + ]); + }); + it('merges global and project by name, project wins', () => { writeGlobalMcpConfig([ { name: 'global-server', command: 'global-cmd' }, @@ -97,7 +104,7 @@ describe('MCP enabled switch (a plain boolean field in mcp.yaml)', () => { setGlobalMcpServerEnabled('a', false); - expect(readYaml(join(globalDir, 'mcp.yaml')).servers).toEqual([ + expect(readYaml(join(tempHome.configDir, 'mcp.yaml')).servers).toEqual([ { name: 'a', command: 'x', enabled: false }, ]); expect(loadGlobalMcpConfig()[0]!.enabled).toBe(false); diff --git a/packages/codingcode/test/mcp/service.test.ts b/packages/codingcode/test/mcp/service.test.ts index 8e45a979..445d2c01 100644 --- a/packages/codingcode/test/mcp/service.test.ts +++ b/packages/codingcode/test/mcp/service.test.ts @@ -1,247 +1,239 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Effect, Layer } from 'effect'; +import { Effect } from 'effect'; import { McpService } from '../../src/mcp/port.js'; -import { HookService } from '../../src/hooks/port.js'; import { McpLayer } from '../../src/mcp/mcp.js'; -// Mock McpClient +interface MockTool { + name: string; + description?: string; + inputSchema?: Record; + readOnlyHint?: boolean; +} + +// McpClient 桩:把实例留在 static 数组里,供测试断言建连/断连次数 vi.mock('../../src/mcp/client.js', () => { class MockMcpClient { - connected = true; - tools: string[] = []; + static instances: MockMcpClient[] = []; transportType = 'stdio' as const; - private _tools: Array<{ - name: string; - description: string; - inputSchema: Record; - }>; - + connectCalls = 0; + disconnectCalls = 0; + private _tools: MockTool[]; constructor(public config: any) { this._tools = config._mockTools ?? [ { name: 'query', description: 'Run a query', inputSchema: {} }, ]; + MockMcpClient.instances.push(this); + } + async connect() { + this.connectCalls++; + if (this.config._mockFail) throw new Error('boom'); } - async connect() {} async listTools() { - this.tools = this._tools.map((t) => t.name); return this._tools; } callTool(_name: string, _args: Record) { return Effect.succeed('mock-result'); } - async disconnect() {} + async disconnect() { + this.disconnectCalls++; + } } return { McpClient: MockMcpClient, McpError: class McpError extends Error {} }; }); -// Mock loadMcpConfig vi.mock('../../src/mcp/config.js', () => ({ resolveMcpConfig: vi.fn(() => []), })); -function makeHookLayer() { - return Layer.succeed(HookService, { - emit: () => Effect.void, - emitDecision: () => Effect.succeed({ decision: 'allow' } as any), - reloadUserHooks: () => Effect.void, - disableHook: () => Effect.void, - enableHook: () => Effect.void, - attachSessionHooks: () => Effect.void, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, - } as any); -} - const TEST_PROJECT = '/fake'; -const TEST_SESSION = 'test-session'; function run(eff: Effect.Effect): Promise { - const testLayer = Layer.mergeAll( - makeHookLayer(), - McpLayer.pipe(Layer.provide(makeHookLayer())) - ); - return Effect.runPromise(eff.pipe(Effect.provide(testLayer) as any)); + return Effect.runPromise(eff.pipe(Effect.provide(McpLayer)) as any); +} + +/** 桩 client 的实际实例(vi.mock 工厂里的 static 数组) */ +async function clients(): Promise { + const mod = await import('../../src/mcp/client.js'); + return (mod.McpClient as any).instances; +} + +function tool(name: string, extra: Partial = {}): MockTool { + return { name, description: name, inputSchema: {}, ...extra }; } -describe('McpService granular methods', () => { +function cfg(name: string, tools: MockTool[], enabled?: boolean) { + return { + name, + command: 'echo', + ...(enabled === undefined ? {} : { enabled }), + _mockTools: tools, + }; +} + +/** 按声明序返回 `server:tool` 列表,便于断言 */ +function specKeys(specs: Array<{ server: string; name: string }>): string[] { + return specs.map((s) => `${s.server}:${s.name}`); +} + +describe('McpService(syncConnections / listProjectMcpTools / status)', () => { let mockConfigs: any[]; beforeEach(async () => { mockConfigs = []; + (await clients()).length = 0; const { resolveMcpConfig } = await import('../../src/mcp/config.js'); (resolveMcpConfig as any).mockImplementation(() => mockConfigs); }); - it('connectServers connects only specified servers', async () => { - mockConfigs = [ - { - name: 'server-a', - command: 'echo', - _mockTools: [{ name: 'tool-a', description: 'A', inputSchema: {} }], - }, - { - name: 'server-b', - command: 'echo', - _mockTools: [{ name: 'tool-b', description: 'B', inputSchema: {} }], - }, - ]; - - const program = Effect.gen(function* () { - const mcp = yield* McpService; - - yield* mcp.connectServers(TEST_PROJECT, TEST_SESSION, ['server-a']); - - const toolNames = mcp.getServerToolNames(TEST_PROJECT, 'server-a'); - expect(toolNames).toContain('server-a:tool-a'); - - const toolBNames = mcp.getServerToolNames(TEST_PROJECT, 'server-b'); - expect(toolBNames).toEqual([]); - }); - - await run(program); + it('syncConnections 连接配置里全部启用的 server', async () => { + mockConfigs = [cfg('server-a', [tool('tool-a')]), cfg('server-b', [tool('tool-b')])]; + + await run( + Effect.gen(function* () { + const mcp = yield* McpService; + yield* mcp.syncConnections(TEST_PROJECT); + const specs = yield* mcp.listProjectMcpTools(TEST_PROJECT); + expect(specKeys(specs).sort()).toEqual(['server-a:tool-a', 'server-b:tool-b']); + }) + ); }); - it('disconnectServers removes tools and connection', async () => { - mockConfigs = [ - { - name: 'srv', - command: 'echo', - _mockTools: [{ name: 'do', description: 'Do', inputSchema: {} }], - }, - ]; - - const program = Effect.gen(function* () { - const mcp = yield* McpService; - - yield* mcp.connectServers(TEST_PROJECT, TEST_SESSION, ['srv']); - expect(mcp.getServerToolNames(TEST_PROJECT, 'srv')).toContain('srv:do'); - - yield* mcp.disconnectServers(TEST_PROJECT, TEST_SESSION, ['srv']); - expect(mcp.getServerToolNames(TEST_PROJECT, 'srv')).toEqual([]); - }); - - await run(program); + it('enabled:false 的 server 既不建连也不出工具', async () => { + mockConfigs = [cfg('on', [tool('op')]), cfg('off', [tool('nope')], false)]; + + await run( + Effect.gen(function* () { + const mcp = yield* McpService; + yield* mcp.syncConnections(TEST_PROJECT); + const specs = yield* mcp.listProjectMcpTools(TEST_PROJECT); + expect(specKeys(specs)).toEqual(['on:op']); + }) + ); + expect((await clients()).length).toBe(1); }); - it('lease prevents premature disconnect', async () => { - mockConfigs = [ - { - name: 'shared', - command: 'echo', - _mockTools: [{ name: 'op', description: 'Op', inputSchema: {} }], - }, - ]; + it('重复调用 syncConnections 不会重复建连', async () => { + mockConfigs = [cfg('srv', [tool('do')])]; + + await run( + Effect.gen(function* () { + const mcp = yield* McpService; + yield* mcp.syncConnections(TEST_PROJECT); + yield* mcp.syncConnections(TEST_PROJECT); + const specs = yield* mcp.listProjectMcpTools(TEST_PROJECT); + expect(specKeys(specs)).toEqual(['srv:do']); + }) + ); + const created = await clients(); + expect(created.length).toBe(1); + expect(created[0]!.connectCalls).toBe(1); + }); - const program = Effect.gen(function* () { - const mcp = yield* McpService; + it('配置里消失的 server 会被断连并清掉工具', async () => { + mockConfigs = [cfg('srv', [tool('do')])]; + + await run( + Effect.gen(function* () { + const mcp = yield* McpService; + yield* mcp.syncConnections(TEST_PROJECT); + expect(specKeys(yield* mcp.listProjectMcpTools(TEST_PROJECT))).toEqual(['srv:do']); + + mockConfigs = []; + yield* mcp.syncConnections(TEST_PROJECT); + expect(yield* mcp.listProjectMcpTools(TEST_PROJECT)).toEqual([]); + }) + ); + const created = await clients(); + expect(created[0]!.disconnectCalls).toBe(1); + }); - // Two sessions connect - yield* mcp.connectServers(TEST_PROJECT, 'session-1', ['shared']); - yield* mcp.connectServers(TEST_PROJECT, 'session-2', ['shared']); + it('运行时改成 enabled:false:查询立刻过滤,syncConnections 负责断连', async () => { + mockConfigs = [cfg('srv', [tool('do')])]; - // First disconnect (lease 2 -> 1, should stay) - yield* mcp.disconnectServers(TEST_PROJECT, 'session-1', ['shared']); - expect(mcp.getServerToolNames(TEST_PROJECT, 'shared')).toContain('shared:op'); + await run( + Effect.gen(function* () { + const mcp = yield* McpService; + yield* mcp.syncConnections(TEST_PROJECT); - // Second disconnect (lease 1 -> 0, should remove) - yield* mcp.disconnectServers(TEST_PROJECT, 'session-2', ['shared']); - expect(mcp.getServerToolNames(TEST_PROJECT, 'shared')).toEqual([]); - }); + mockConfigs = [cfg('srv', [tool('do')], false)]; + // 尚未 sync:连接还在,但 listProjectMcpTools 已按开关过滤 + expect(yield* mcp.listProjectMcpTools(TEST_PROJECT)).toEqual([]); - await run(program); + yield* mcp.syncConnections(TEST_PROJECT); + expect(yield* mcp.listProjectMcpTools(TEST_PROJECT)).toEqual([]); + }) + ); + const created = await clients(); + expect(created[0]!.disconnectCalls).toBe(1); }); - it('getServerToolNames returns namespaced names', async () => { + it('建连失败的 server 被跳过,不影响同批其它 server', async () => { mockConfigs = [ - { - name: 'db', - command: 'echo', - _mockTools: [ - { name: 'query', description: 'Query', inputSchema: {} }, - { name: 'schema', description: 'Schema', inputSchema: {} }, - ], - }, + { ...cfg('bad', [tool('x')]), _mockFail: true }, + cfg('good', [tool('ok')]), ]; - const program = Effect.gen(function* () { - const mcp = yield* McpService; - - yield* mcp.connectServers(TEST_PROJECT, TEST_SESSION, ['db']); - const names = mcp.getServerToolNames(TEST_PROJECT, 'db'); - - expect(names).toEqual(['db:query', 'db:schema']); - }); - - await run(program); - }); - - it('getServerToolNames returns empty for unknown server', async () => { - const program = Effect.gen(function* () { - const mcp = yield* McpService; - const names = mcp.getServerToolNames(TEST_PROJECT, 'nonexistent'); - expect(names).toEqual([]); - }); - - await run(program); + await run( + Effect.gen(function* () { + const mcp = yield* McpService; + yield* mcp.syncConnections(TEST_PROJECT); + expect(specKeys(yield* mcp.listProjectMcpTools(TEST_PROJECT))).toEqual(['good:ok']); + }) + ); }); - it('exposes MCP tools as pure-data specs, keeping the JSON Schema raw', async () => { + it('listProjectMcpTools 暴露纯数据 spec:原始 JSON Schema + readOnlyHint 透传 + execute 已绑 client', async () => { mockConfigs = [ - { - name: 'typed', - command: 'echo', - _mockTools: [ - { - name: 'query', - description: 'Query', - inputSchema: { - type: 'object', - properties: { text: { type: 'string' } }, - required: ['text'], - }, + cfg('typed', [ + tool('query', { + description: 'Query', + inputSchema: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], }, - ], - }, + readOnlyHint: true, + }), + // 未声明 readOnlyHint ⇒ fail-closed 为 false(并发调度依赖它) + tool('write'), + ]), ]; - const program = Effect.gen(function* () { - const mcp = yield* McpService; - yield* mcp.connectServers(TEST_PROJECT, TEST_SESSION, ['typed']); - const [tool] = yield* mcp.listProjectMcpTools(TEST_PROJECT); - - expect(tool).toBeDefined(); - expect(tool!.server).toBe('typed'); - expect(tool!.name).toBe('query'); - expect(tool!.inputSchema).toMatchObject({ - type: 'object', - properties: { text: { type: 'string' } }, - required: ['text'], - }); - // execute 已绑定 client,zod 化留给 tools 层 - expect(yield* tool!.execute({ text: 'hello' })).toBe('mock-result'); - }); - - await run(program); + await run( + Effect.gen(function* () { + const mcp = yield* McpService; + yield* mcp.syncConnections(TEST_PROJECT); + const specs = yield* mcp.listProjectMcpTools(TEST_PROJECT); + + expect(specs).toHaveLength(2); + const query = specs[0]!; + expect(query.server).toBe('typed'); + expect(query.name).toBe('query'); + expect(query.inputSchema).toMatchObject({ + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }); + expect(query.readOnlyHint).toBe(true); + expect(specs[1]!.readOnlyHint).toBe(false); + // execute 已绑定 client,zod 化留给 tools 层 + expect(yield* query.execute({ text: 'hello' })).toBe('mock-result'); + }) + ); }); - it('connectServers warns and skips unknown server name', async () => { - mockConfigs = [ - { - name: 'real-server', - command: 'echo', - _mockTools: [{ name: 'op', description: 'Op', inputSchema: {} }], - }, - ]; - - const program = Effect.gen(function* () { - const mcp = yield* McpService; - - yield* mcp.connectServers(TEST_PROJECT, TEST_SESSION, ['real-server', 'nonexistent']); - - expect(mcp.getServerToolNames(TEST_PROJECT, 'real-server')).toContain('real-server:op'); - expect(mcp.getServerToolNames(TEST_PROJECT, 'nonexistent')).toEqual([]); - }); - - await run(program); + it('status 返回 name/toolCount/transport,未连接的项目返回空数组', async () => { + mockConfigs = [cfg('db', [tool('query'), tool('schema')])]; + + await run( + Effect.gen(function* () { + const mcp = yield* McpService; + yield* mcp.syncConnections(TEST_PROJECT); + expect(yield* mcp.status(TEST_PROJECT)).toEqual([ + { name: 'db', toolCount: 2, transport: 'stdio' }, + ]); + expect(yield* mcp.status('/other-project')).toEqual([]); + }) + ); }); }); diff --git a/packages/codingcode/test/plan/gate-pipeline.test.ts b/packages/codingcode/test/plan/gate-pipeline.test.ts index 89d44f7d..a54290d4 100644 --- a/packages/codingcode/test/plan/gate-pipeline.test.ts +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -13,16 +13,9 @@ import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase(); const mockHookService = { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), emit: () => Effect.succeed(undefined), emitDecision: () => Effect.succeed(null), reloadUserHooks: () => Effect.succeed(undefined), - attachSessionHooks: () => Effect.succeed(undefined), - disableHook: () => Effect.succeed(undefined), - enableHook: () => Effect.succeed(undefined), - disposeSession: () => Effect.succeed(undefined), - disposeProject: () => Effect.succeed(undefined), }; let capturedApproval: any = null; diff --git a/packages/codingcode/test/security/plan-profile-restart.test.ts b/packages/codingcode/test/security/plan-profile-restart.test.ts index ee542781..54274ec4 100644 --- a/packages/codingcode/test/security/plan-profile-restart.test.ts +++ b/packages/codingcode/test/security/plan-profile-restart.test.ts @@ -16,16 +16,9 @@ import { ApprovalLayer } from '../../src/approval/approval.js'; useTempProjectBase(); const mockHookService = { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), emit: () => Effect.succeed(undefined), emitDecision: () => Effect.succeed(null), reloadUserHooks: () => Effect.succeed(undefined), - attachSessionHooks: () => Effect.succeed(undefined), - disableHook: () => Effect.succeed(undefined), - enableHook: () => Effect.succeed(undefined), - disposeSession: () => Effect.succeed(undefined), - disposeProject: () => Effect.succeed(undefined), }; const mockApprovalWaitService = { diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index 4ae1238f..ae851e95 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -105,12 +105,8 @@ const MockSkillLayer = Layer.succeed(SkillService, { const MockMcpLayer = Layer.succeed(McpService, { syncConnections: () => Effect.void, - connectServers: () => Effect.void, - disconnectServers: () => Effect.void, - getServerToolNames: () => [], - disconnectAll: () => Effect.void, - status: () => Effect.succeed([]), listProjectMcpTools: () => [], + status: () => Effect.succeed([]), } as any); const MockMemoryLayer = Layer.succeed(MemoryService, { diff --git a/packages/codingcode/test/server/index.test.ts b/packages/codingcode/test/server/index.test.ts index 1b60f8d8..b6dab99f 100644 --- a/packages/codingcode/test/server/index.test.ts +++ b/packages/codingcode/test/server/index.test.ts @@ -52,12 +52,8 @@ const MockSkillLayer = Layer.succeed(SkillService, { const MockMcpLayer = Layer.succeed(McpService, { syncConnections: () => Effect.void, - connectServers: () => Effect.void, - disconnectServers: () => Effect.void, - getServerToolNames: () => [], - disconnectAll: () => Effect.void, - status: () => Effect.succeed([]), listProjectMcpTools: () => [], + status: () => Effect.succeed([]), } as any); const MockMemoryLayer = Layer.succeed(MemoryService, { diff --git a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts index 4bc4cc91..196f4f5c 100644 --- a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts +++ b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts @@ -16,16 +16,9 @@ import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase(); const mockHookService = { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), emit: () => Effect.succeed(undefined), emitDecision: () => Effect.succeed(null), reloadUserHooks: () => Effect.succeed(undefined), - attachSessionHooks: () => Effect.succeed(undefined), - disableHook: () => Effect.succeed(undefined), - enableHook: () => Effect.succeed(undefined), - disposeSession: () => Effect.succeed(undefined), - disposeProject: () => Effect.succeed(undefined), } as any; const mockApprovalWaitService = { diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts index 61d37d30..a037a829 100644 --- a/packages/codingcode/test/server/plan-file-route.test.ts +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { mkdirSync, writeFileSync, utimesSync } from 'fs'; import { join, resolve } from 'path'; @@ -18,9 +18,8 @@ import { MemoryService } from '../../src/memory/port.js'; import { SchedulerService } from '../../src/scheduler/port.js'; import { ContextService } from '../../src/context/port.js'; import { CheckpointService } from '../../src/checkpoint/port.js'; -import { encodeProjectPath, setProjectBaseDir } from '../../src/core/path.js'; -import { mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; +import { encodeProjectPath, getProjectBaseDir } from '../../src/core/path.js'; +import { useTempHome } from '../helpers/temp-home.js'; import { HookLayer } from '../../src/hooks/hooks.js'; import { ApprovalWaitLayer } from '../../src/approval/wait.js'; import { ApprovalLayer } from '../../src/approval/approval.js'; @@ -107,12 +106,8 @@ const MockSkillLayer = Layer.succeed(SkillService, { const MockMcpLayer = Layer.succeed(McpService, { syncConnections: () => Effect.void, - connectServers: () => Effect.void, - disconnectServers: () => Effect.void, - getServerToolNames: () => [], - disconnectAll: () => Effect.void, - status: () => Effect.succeed([]), listProjectMcpTools: () => [], + status: () => Effect.succeed([]), } as any); const MockMemoryLayer = Layer.succeed(MemoryService, { @@ -171,21 +166,16 @@ const TestLayer = Layer.mergeAll( MockCheckpointLayer ); -let tempBase = ''; +// home 指向临时目录 ⇒ getProjectBaseDir() = /.codingcode/project +useTempHome('codingcode-plan-route-'); + let plansDir = ''; beforeEach(() => { - tempBase = mkdtempSync(join(tmpdir(), 'codingcode-plan-route-')); // The route resolves the query cwd to an absolute path first // (core/path.resolveCwd -> path.resolve), then encodes it. - plansDir = join(tempBase, encodeProjectPath(resolve('/tmp/test'))); + plansDir = join(getProjectBaseDir(), encodeProjectPath(resolve('/tmp/test'))); mkdirSync(plansDir, { recursive: true }); - setProjectBaseDir(tempBase); -}); - -afterEach(() => { - setProjectBaseDir(undefined); - rmSync(tempBase, { recursive: true, force: true }); }); describe('GET /api/sessions/:id/plan', () => { diff --git a/packages/codingcode/test/session/disk-setters.test.ts b/packages/codingcode/test/session/disk-setters.test.ts index dc87d664..bd08cc9d 100644 --- a/packages/codingcode/test/session/disk-setters.test.ts +++ b/packages/codingcode/test/session/disk-setters.test.ts @@ -13,23 +13,15 @@ import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); const mockHookService = { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), emit: () => Effect.succeed(undefined), emitDecision: () => Effect.succeed(null), reloadUserHooks: () => Effect.succeed(undefined), - attachSessionHooks: () => Effect.succeed(undefined), - disableHook: () => Effect.succeed(undefined), - enableHook: () => Effect.succeed(undefined), - disposeSession: () => Effect.succeed(undefined), - disposeProject: () => Effect.succeed(undefined), }; const mockMcpService = { syncConnections: () => Effect.succeed(undefined), - connectServers: () => Effect.succeed(undefined), listProjectMcpTools: () => [], - disposeSession: () => Effect.succeed(undefined), + status: () => Effect.succeed([]), } as any; const mockRulesService = { diff --git a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts index be4bf91a..2183a952 100644 --- a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts +++ b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer } from 'effect'; -import { existsSync, mkdtempSync, readdirSync, rmSync } from 'fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; +import { useTempHome } from '../helpers/temp-home.js'; import { AgentLayer } from '../../src/agent/agent.js'; import { ToolEnvLayer } from '../../src/agent/tool-env.js'; import { AgentService } from '../../src/agent/port.js'; @@ -21,7 +22,12 @@ import { McpService } from '../../src/mcp/port.js'; import { SubagentRunnerService } from '../../src/subagent/port.js'; import { TodoService } from '../../src/todo/port.js'; import { readHistory } from '../../src/session/file-ops.js'; -import { encodeProjectPath, normalizePath, setProjectBaseDir, computePaths } from '../../src/core/path.js'; +import { + encodeProjectPath, + normalizePath, + getProjectBaseDir, + computePaths, +} from '../../src/core/path.js'; import type { Message } from '../../src/contracts/types.js'; import type { LLMClient } from '../../src/contracts/provider.js'; import type { FrameBody } from '../../src/contracts/frame.js'; @@ -75,12 +81,9 @@ const McpMock = Layer.succeed(McpService, { } as any); const HookMock = Layer.succeed(HookService, { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), emit: () => Effect.succeed(undefined), emitDecision: () => Effect.succeed(null), reloadUserHooks: () => Effect.succeed(undefined), - disposeSession: () => Effect.void, } as any); const TodoMock = Layer.succeed(TodoService, { read: () => [], write: () => {}, reset: () => {} } as any); @@ -171,17 +174,18 @@ function drainStream(stream: AsyncGenerator): Effect.Effect { + useTempHome('codingcode-test-e2e-home-'); + let projectBase: string; let cwd: string; beforeEach(() => { - projectBase = mkdtempSync(join(tmpdir(), 'codingcode-test-e2e-')); - setProjectBaseDir(projectBase); + projectBase = getProjectBaseDir(); + mkdirSync(projectBase, { recursive: true }); cwd = mkdtempSync(join(tmpdir(), 'codingcode-test-cwd-')); }); afterEach(() => { - if (existsSync(projectBase)) rmSync(projectBase, { recursive: true, force: true }); if (existsSync(cwd)) rmSync(cwd, { recursive: true, force: true }); }); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index f8f1a8f8..5eaad5b9 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -2,25 +2,14 @@ import { expect, it, describe, beforeEach, vi } from 'vitest'; import { Effect, Layer } from 'effect'; import { dispatchAgentTool } from '../../src/tools/domains/subagent/dispatch.js'; import { HookService } from '../../src/hooks/port.js'; -import { McpService } from '../../src/mcp/port.js'; import { SubagentRunnerService } from '../../src/subagent/port.js'; import type { ToolExecCtx } from '../../src/contracts/tool.js'; import type { FrameBody } from '../../src/contracts/frame.js'; const mockHooks = { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), emit: vi.fn(() => Effect.succeed(undefined)), emitDecision: vi.fn(() => Effect.succeed(null)), reloadUserHooks: () => Effect.succeed(undefined), - disposeSession: vi.fn(() => Effect.succeed(undefined)), -}; - -const mockMcp = { - connectServers: () => Effect.void, - syncConnections: () => Effect.void, - listProjectMcpTools: () => [], - disposeSession: vi.fn(() => Effect.succeed(undefined)), }; const mockRunner = { @@ -39,7 +28,6 @@ function makeRunStream(): AsyncGenerator { function makeLayers() { return Layer.mergeAll( Layer.succeed(HookService, mockHooks as any), - Layer.succeed(McpService, mockMcp as any), Layer.succeed(SubagentRunnerService, mockRunner as any) ); } @@ -121,7 +109,7 @@ describe('dispatch_agent (runner-based subagent spawn)', () => { } }); - it('case 5: emits spawn.after and disposes the child session on completion', async () => { + it('case 5: emits spawn.after and complete carrying the child session id', async () => { await runTool( { agent: 'build', prompt: 'go' }, { projectPath: '/test', sessionId: 'parent-1' } @@ -131,7 +119,43 @@ describe('dispatch_agent (runner-based subagent spawn)', () => { 'agent.subagent.spawn.after', expect.objectContaining({ childSessionId: 'child-1', profile: 'build' }) ); - expect(mockHooks.disposeSession).toHaveBeenCalledWith('child-1'); - expect(mockMcp.disposeSession).toHaveBeenCalledWith('child-1'); + expect(mockHooks.emit).toHaveBeenCalledWith( + 'agent.subagent.complete', + expect.objectContaining({ childSessionId: 'child-1', status: 'done' }) + ); + }); + + it('case 6: a stream that ends with error fails the tool and skips complete', async () => { + mockRunner.runSubagent.mockReturnValueOnce( + Effect.succeed({ + stream: (async function* () { + yield { + family: 'transition', + transition: { to: 'end', reason: 'error', error: { message: 'boom' } }, + }; + })() as AsyncGenerator, + sessionId: 'child-2', + }) as any + ); + + const outcome = await Effect.runPromise( + Effect.either( + dispatchAgentTool + .execute( + { agent: 'build', prompt: 'go' }, + { projectPath: '/test', sessionId: 'parent-1' } + ) + .pipe(Effect.provide(makeLayers())) + ) + ); + + expect(outcome._tag).toBe('Left'); + if (outcome._tag === 'Left') { + expect(String((outcome.left as any).message)).toContain('Subagent failed: boom'); + } + expect(mockHooks.emit).not.toHaveBeenCalledWith( + 'agent.subagent.complete', + expect.anything() + ); }); }); diff --git a/packages/codingcode/test/tools/executor-concurrency.test.ts b/packages/codingcode/test/tools/executor-concurrency.test.ts new file mode 100644 index 00000000..d02e6414 --- /dev/null +++ b/packages/codingcode/test/tools/executor-concurrency.test.ts @@ -0,0 +1,430 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Duration, Effect, Layer } from 'effect'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { ToolExecutorLayer } from '../../src/tools/tools.js'; +import { ToolExecutorService } from '../../src/tools/port.js'; +import { HookService } from '../../src/hooks/port.js'; +import { TodoService } from '../../src/todo/port.js'; +import { McpService } from '../../src/mcp/port.js'; +import { SubagentRunnerService } from '../../src/subagent/port.js'; +import { TOOLS_BY_NAME, createToolCatalog } from '../../src/tools/catalog.js'; +import type { ToolCall, TodoItem } from '../../src/contracts/types.js'; +import type { ToolResult } from '../../src/contracts/tool.js'; +import type { McpToolSpec } from '../../src/contracts/mcp.js'; +import type { HookPoint } from '../../src/contracts/hooks.js'; +import type { FrameBody } from '../../src/contracts/frame.js'; + +// 桩 hook 在工具执行窗口内 sleep,把窗口拉宽到足以用「区间是否相交」判定并发/串行 +const WINDOW_MS = 150; + +interface Interval { + name: string; + callId: string; + start: number; + end: number; +} + +interface Harness { + intervals: Interval[]; + hookPoints: string[]; + todoStore: Map; + onAfter?: (toolName: string, afterIndex: number) => void; + clear: () => void; + layers: Layer.Layer; +} + +function makeHarness(): Harness { + const h: Harness = { + intervals: [], + hookPoints: [], + todoStore: new Map(), + clear: () => { + h.intervals.length = 0; + h.hookPoints.length = 0; + h.onAfter = undefined; + }, + layers: undefined as unknown as Layer.Layer, + }; + + const starts = new Map(); + let afterIndex = 0; + + const hooks = { + emit: (point: HookPoint, payload: Record) => + Effect.gen(function* () { + h.hookPoints.push(point); + const callId = String(payload.callId ?? ''); + const toolName = String(payload.toolName ?? ''); + if (point === 'tool.execute.before') { + starts.set(callId, Date.now()); + yield* Effect.sleep(Duration.millis(WINDOW_MS)); + } else if (point === 'tool.execute.after') { + h.intervals.push({ + name: toolName, + callId, + start: starts.get(callId) ?? Date.now(), + end: Date.now(), + }); + h.onAfter?.(toolName, afterIndex++); + } + }), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + }; + + const todo = { + read: (sessionId: string) => h.todoStore.get(sessionId) ?? [], + write: (sessionId: string, plan: TodoItem[]) => { + h.todoStore.set(sessionId, plan); + }, + reset: () => { + h.todoStore.clear(); + }, + }; + + const mcp = { + syncConnections: () => Effect.succeed(undefined), + listProjectMcpTools: () => [], + status: () => Effect.succeed([]), + }; + + const runner = { + runSubagent: () => + Effect.succeed({ stream: makeSubagentStream(200), sessionId: 'child-1' }), + }; + + h.layers = Layer.mergeAll( + Layer.succeed(HookService, hooks as any), + Layer.succeed(TodoService, todo as any), + Layer.succeed(McpService, mcp as any), + Layer.succeed(SubagentRunnerService, runner as any) + ); + return h; +} + +function makeSubagentStream(delayMs: number): AsyncGenerator { + return (async function* () { + await new Promise((r) => setTimeout(r, delayMs)); + yield { family: 'event', event: { type: 'text_delta', text: 'child-done' } }; + yield { family: 'transition', transition: { to: 'end', reason: 'done' } }; + })(); +} + +function tc(id: string, name: string, args: Record): ToolCall { + return { id, name, arguments: args }; +} + +function runBatch( + calls: ToolCall[], + ctx: { projectPath: string; sessionId?: string; signal?: AbortSignal }, + h: Harness, + mcpSpecs: McpToolSpec[] = [] +): Promise { + const program = Effect.gen(function* () { + const exec = yield* ToolExecutorService; + // 只把内置名交给 catalog(去重);MCP 工具名(含 `:`)由 spec 侧注册 + const builtinNames = [...new Set(calls.map((c) => c.name).filter((n) => TOOLS_BY_NAME.has(n)))]; + const catalog = yield* exec.prepare(builtinNames, mcpSpecs); + return yield* exec.executeBatch(calls, ctx.sessionId, { + projectPath: ctx.projectPath, + signal: ctx.signal, + toolLookup: catalog.lookup, + }); + }); + return Effect.runPromise( + program.pipe(Effect.provide(ToolExecutorLayer), Effect.provide(h.layers)) + ); +} + +function okOutput(r: ToolResult | undefined): string { + if (!r) throw new Error('missing tool result'); + if (r.status !== 'ok') throw new Error(`expected ok, got ${r.status}`); + return r.output; +} + +function intervalFor(h: Harness, callId: string): Interval { + const found = h.intervals.find((i) => i.callId === callId); + if (!found) throw new Error(`no execution interval recorded for callId=${callId}`); + return found; +} + +// 严格相交:端点相等不算重叠(串行时后一个的 start 可等于前一个的 end) +const overlaps = (a: Interval, b: Interval): boolean => a.start < b.end && b.start < a.end; + +describe('executeBatch 保序波次调度', () => { + let dir: string; + let h: Harness; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'cc-concurrency-')); + h = makeHarness(); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('只读工具同批并发:三个 read_file 的执行区间两两重叠', async () => { + await writeFile(join(dir, 'a.txt'), 'A'); + await writeFile(join(dir, 'b.txt'), 'B'); + await writeFile(join(dir, 'c.txt'), 'C'); + + const results = await runBatch( + [ + tc('r1', 'read_file', { path: 'a.txt' }), + tc('r2', 'read_file', { path: 'b.txt' }), + tc('r3', 'read_file', { path: 'c.txt' }), + ], + { projectPath: dir }, + h + ); + + expect(results.map((r) => r.status)).toEqual(['ok', 'ok', 'ok']); + const i1 = intervalFor(h, 'r1'); + const i2 = intervalFor(h, 'r2'); + const i3 = intervalFor(h, 'r3'); + expect(overlaps(i1, i2)).toBe(true); + expect(overlaps(i1, i3)).toBe(true); + expect(overlaps(i2, i3)).toBe(true); + }); + + it('同文件两次 edit_file:区间不相交且两处编辑都生效(旧行为会静默丢一次)', async () => { + const file = join(dir, 'f.txt'); + await writeFile(file, 'AAA\nBBB\n'); + + const results = await runBatch( + [ + tc('e1', 'edit_file', { path: 'f.txt', old_string: 'AAA', new_string: 'XXX' }), + tc('e2', 'edit_file', { path: 'f.txt', old_string: 'BBB', new_string: 'YYY' }), + ], + { projectPath: dir }, + h + ); + + expect(results.map((r) => r.status)).toEqual(['ok', 'ok']); + expect(overlaps(intervalFor(h, 'e1'), intervalFor(h, 'e2'))).toBe(false); + + const content = await readFile(file, 'utf-8'); + expect(content).toContain('XXX'); + expect(content).toContain('YYY'); + expect(content).not.toContain('AAA'); + expect(content).not.toContain('BBB'); + }); + + it('结果按声明顺序回填:命令与只读混批不再跨组重排', async () => { + await writeFile(join(dir, 'a.txt'), 'A'); + + const results = await runBatch( + [ + tc('c1', 'execute_command', { command: 'echo hi' }), + tc('r1', 'read_file', { path: 'a.txt' }), + ], + { projectPath: dir }, + h + ); + + // 旧行为:分组后 concat 成 [read, cmd],与声明的 [cmd, read] 相反 + expect(results.map((r) => r.id)).toEqual(['c1', 'r1']); + expect(results.map((r) => r.status)).toEqual(['ok', 'ok']); + }); + + it('写类工具与读零重叠,且读的可见性由声明顺序确定', async () => { + const file = join(dir, 's.txt'); + + for (let i = 0; i < 5; i++) { + await writeFile(file, 'BEFORE'); + h.clear(); + const before = await runBatch( + [ + tc('r', 'read_file', { path: 's.txt' }), + tc('w', 'write_file', { path: 's.txt', content: 'AFTER' }), + ], + { projectPath: dir }, + h + ); + expect(okOutput(before[0])).toContain('BEFORE'); + expect(overlaps(intervalFor(h, 'r'), intervalFor(h, 'w'))).toBe(false); + + await writeFile(file, 'BEFORE'); + h.clear(); + const after = await runBatch( + [ + tc('w', 'write_file', { path: 's.txt', content: 'AFTER' }), + tc('r', 'read_file', { path: 's.txt' }), + ], + { projectPath: dir }, + h + ); + expect(okOutput(after[1])).toContain('AFTER'); + expect(overlaps(intervalFor(h, 'r'), intervalFor(h, 'w'))).toBe(false); + } + }); + + it('读—写—读切成三波:第二个读确定读到编辑后内容', async () => { + const file = join(dir, 't.txt'); + await writeFile(file, 'OLD\n'); + + const results = await runBatch( + [ + tc('r1', 'read_file', { path: 't.txt' }), + tc('e1', 'edit_file', { path: 't.txt', old_string: 'OLD', new_string: 'NEW' }), + tc('r2', 'read_file', { path: 't.txt' }), + ], + { projectPath: dir }, + h + ); + + expect(okOutput(results[0])).toContain('OLD'); + expect(okOutput(results[2])).toContain('NEW'); + expect(overlaps(intervalFor(h, 'r1'), intervalFor(h, 'e1'))).toBe(false); + expect(overlaps(intervalFor(h, 'e1'), intervalFor(h, 'r2'))).toBe(false); + expect(overlaps(intervalFor(h, 'r1'), intervalFor(h, 'r2'))).toBe(false); + }); + + it('MCP 工具缺省(无 readOnlyHint)独占一波,声明 true 时与只读工具同波', async () => { + await writeFile(join(dir, 'a.txt'), 'A'); + const calls = [ + tc('r1', 'read_file', { path: 'a.txt' }), + tc('m1', 'srv:slow', {}), + ]; + const makeSpec = (readOnlyHint: boolean): McpToolSpec => ({ + server: 'srv', + name: 'slow', + description: 'slow tool', + inputSchema: {}, + readOnlyHint, + execute: () => Effect.sleep(Duration.millis(200)).pipe(Effect.as('mcp-ok')), + }); + + h.clear(); + const serial = await runBatch(calls, { projectPath: dir }, h, [makeSpec(false)]); + expect(serial.map((r) => r.status)).toEqual(['ok', 'ok']); + expect(okOutput(serial[1])).toBe('mcp-ok'); + expect(overlaps(intervalFor(h, 'r1'), intervalFor(h, 'm1'))).toBe(false); + + h.clear(); + const parallel = await runBatch(calls, { projectPath: dir }, h, [makeSpec(true)]); + expect(parallel.map((r) => r.status)).toEqual(['ok', 'ok']); + expect(overlaps(intervalFor(h, 'r1'), intervalFor(h, 'm1'))).toBe(true); + }); + + it('[read_file, todo_write] 同波并发,控制态写入生效且结果保序', async () => { + await writeFile(join(dir, 'a.txt'), 'A'); + + const results = await runBatch( + [ + tc('r1', 'read_file', { path: 'a.txt' }), + tc('t1', 'todo_write', { + plan: [{ step: 'step one', status: 'pending' }], + }), + ], + { projectPath: dir, sessionId: 'sid-1' }, + h + ); + + // todo_write 只写进程内 Map,不触碰工作树 ⇒ 与只读工具同波 + expect(overlaps(intervalFor(h, 'r1'), intervalFor(h, 't1'))).toBe(true); + expect(results.map((r) => r.id)).toEqual(['r1', 't1']); + expect(okOutput(results[1])).toBe('pending=1 in_progress=0 completed=0'); + expect(h.todoStore.get('sid-1')?.[0]?.step).toBe('step one'); + }); + + it('[dispatch_agent, write_file] 零重叠,且委派与写入的先后由声明顺序决定', async () => { + const results = await runBatch( + [ + tc('d1', 'dispatch_agent', { agent: 'build', prompt: 'go' }), + tc('w1', 'write_file', { path: 'z.txt', content: 'z' }), + ], + { projectPath: dir, sessionId: 'sid-1' }, + h + ); + + expect(okOutput(results[0])).toBe('child-done'); + expect(okOutput(results[1])).toContain('File written'); + const d1 = intervalFor(h, 'd1'); + const w1 = intervalFor(h, 'w1'); + expect(overlaps(d1, w1)).toBe(false); + expect(d1.end).toBeLessThanOrEqual(w1.start); + + h.clear(); + const reversed = await runBatch( + [ + tc('w2', 'write_file', { path: 'z2.txt', content: 'z' }), + tc('d2', 'dispatch_agent', { agent: 'build', prompt: 'go' }), + ], + { projectPath: dir, sessionId: 'sid-1' }, + h + ); + expect(reversed.map((r) => r.id)).toEqual(['w2', 'd2']); + expect(intervalFor(h, 'w2').end).toBeLessThanOrEqual(intervalFor(h, 'd2').start); + }); + + it('批前已 abort:全部 denied,且没有任何工具启动', async () => { + await writeFile(join(dir, 'a.txt'), 'A'); + const controller = new AbortController(); + controller.abort(); + + const results = await runBatch( + [ + tc('r1', 'read_file', { path: 'a.txt' }), + tc('w1', 'write_file', { path: 'w.txt', content: 'x' }), + tc('r2', 'read_file', { path: 'a.txt' }), + ], + { projectPath: dir, signal: controller.signal }, + h + ); + + expect(results.map((r) => r.status)).toEqual(['denied', 'denied', 'denied']); + expect(h.hookPoints.filter((p) => p === 'tool.execute.before')).toHaveLength(0); + }); + + it('执行中途 abort:后续工具在各自开始前被拒(两条车道判定时机一致)', async () => { + await writeFile(join(dir, 'a.txt'), 'A'); + const controller = new AbortController(); + // 第一个工具完成后才 abort,故它自身不受影响;后续工具须在开始前发现自己被中止 + h.onAfter = (_name, index) => { + if (index === 0) controller.abort(); + }; + + const results = await runBatch( + [ + tc('r1', 'read_file', { path: 'a.txt' }), + tc('w1', 'write_file', { path: 'w.txt', content: 'x' }), + tc('r2', 'read_file', { path: 'a.txt' }), + ], + { projectPath: dir, signal: controller.signal }, + h + ); + + expect(results.map((r) => r.status)).toEqual(['ok', 'denied', 'denied']); + expect(h.hookPoints.filter((p) => p === 'tool.execute.before')).toHaveLength(1); + }); + + it('11 个内置工具的 concurrencySafe 与分类表逐项一致,且无未覆盖工具', () => { + const expected: Record = { + read_file: true, + search_code: true, + search_files: true, + fetch_url: true, + web_search: true, + todo_write: true, + write_file: false, + edit_file: false, + execute_command: false, + dispatch_agent: false, + submit_plan: false, + }; + + const { lookup } = createToolCatalog(Object.keys(expected)); + for (const [name, safe] of Object.entries(expected)) { + expect(lookup(name)?.concurrencySafe, name).toBe(safe); + } + expect([...TOOLS_BY_NAME.keys()].sort()).toEqual(Object.keys(expected).sort()); + + // 未声明的构造出的 runner 收敛为 false(fail-closed) + expect(lookup('read_file')?.concurrencySafe).toBe(true); + expect(lookup('nope')).toBeUndefined(); + }); +}); diff --git a/packages/desktop/src/settings/HooksPanel.tsx b/packages/desktop/src/settings/HooksPanel.tsx index b0bc7177..bec08c2a 100644 --- a/packages/desktop/src/settings/HooksPanel.tsx +++ b/packages/desktop/src/settings/HooksPanel.tsx @@ -36,26 +36,10 @@ const HOOK_GROUPS: HookGroup[] = [ { name: 'tool.execute.before', description: '工具执行前触发', type: 'decision' }, { name: 'tool.execute.after', description: '工具执行成功后触发', type: 'observer' }, { name: 'tool.execute.error', description: '工具执行失败后触发', type: 'observer' }, - { name: 'tool.execute.denied', description: '工具被拒绝执行后触发', type: 'observer' }, { name: 'tool.approval.pre', description: '工具审批请求前触发', type: 'decision' }, { name: 'tool.approval.post', description: '工具审批完成后触发', type: 'observer' }, ], }, - { - label: 'LLM 请求', - points: [ - { name: 'llm.request.before', description: '向模型发送请求前触发', type: 'decision' }, - { name: 'llm.response.after', description: '收到模型响应后触发', type: 'observer' }, - { name: 'llm.response.error', description: '模型响应出错时触发', type: 'observer' }, - ], - }, - { - label: '会话', - points: [ - { name: 'session.save.before', description: '保存会话前触发', type: 'observer' }, - { name: 'session.save.after', description: '保存会话后触发', type: 'observer' }, - ], - }, { label: 'Agent 轮次', points: [ diff --git a/packages/infra/package.json b/packages/infra/package.json index 76715a1a..3adb1e6c 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -5,10 +5,13 @@ "main": "./src/config.ts", "exports": { "./config": "./src/config.ts", - "./logger": "./src/logger.ts" + "./logger": "./src/logger.ts", + "./paths": "./src/paths.ts", + "./yaml-store": "./src/yaml-store.ts" }, "dependencies": { "pino": "^9.6.0", - "pino-pretty": "^13.0.0" + "pino-pretty": "^13.0.0", + "yaml": "^2.9.0" } } diff --git a/packages/infra/src/config.ts b/packages/infra/src/config.ts index c4a4fb56..7d124d59 100644 --- a/packages/infra/src/config.ts +++ b/packages/infra/src/config.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { resolve, dirname } from 'path'; -import { homedir } from 'os'; +import { join, dirname } from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { getGlobalDir } from './paths.js'; export interface ContextConfig { /** Model for context compaction. Empty string falls back to main session LLM. @@ -140,7 +140,7 @@ export function loadConfig(configPath?: string): AppConfig { } export function getUserConfigPath(): string { - return resolve(homedir(), '.codingcode', 'config.yaml'); + return join(getGlobalDir(), 'config.yaml'); } export function ensureUserConfig(): void { diff --git a/packages/infra/src/logger.ts b/packages/infra/src/logger.ts index 26b5941a..a5de68a5 100644 --- a/packages/infra/src/logger.ts +++ b/packages/infra/src/logger.ts @@ -1,7 +1,7 @@ import pino from 'pino'; import { join } from 'path'; -import { homedir } from 'os'; import { mkdirSync } from 'fs'; +import { getGlobalDir } from './paths.js'; export type Logger = pino.Logger; @@ -15,7 +15,7 @@ export function createLogger(level = process.env.LOG_LEVEL ?? 'info'): Logger { if (!isDev) { // 生产模式:使用 pino.destination 同步写入文件,不依赖 worker 线程 // 这样可以被 esbuild 打包为单文件,无需额外 node_modules - const logDir = join(homedir(), '.codingcode', 'logs'); + const logDir = join(getGlobalDir(), 'logs'); try { mkdirSync(logDir, { recursive: true }); } catch {} diff --git a/packages/infra/src/paths.ts b/packages/infra/src/paths.ts new file mode 100644 index 00000000..6a665fa8 --- /dev/null +++ b/packages/infra/src/paths.ts @@ -0,0 +1,8 @@ +import { homedir } from 'os'; +import { join } from 'path'; + +export const CODINGCODE_DIRNAME = '.codingcode'; + +export function getGlobalDir(): string { + return join(homedir(), CODINGCODE_DIRNAME); +} diff --git a/packages/infra/src/yaml-store.ts b/packages/infra/src/yaml-store.ts new file mode 100644 index 00000000..0b22d422 --- /dev/null +++ b/packages/infra/src/yaml-store.ts @@ -0,0 +1,70 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; + +export interface NamedListFile { + readonly fileName: string; + readonly key: string; +} + +/** 丢掉值为 undefined 的键,避免下层未显式写的字段被擦掉 */ +function definedOnly(obj: T): Partial { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial; +} + +/** 该目录下实际生效的文件;都不存在时给出默认写入位置(`.yaml` 优先) */ +export function yamlStorePath(dir: string, file: NamedListFile): string { + const candidates = [join(dir, `${file.fileName}.yaml`), join(dir, `${file.fileName}.yml`)]; + return candidates.find((p) => existsSync(p)) ?? candidates[0]!; +} + +/** 读顶层键下的列表;文件不存在或内容非法 → `[]` */ +export function readNamedList(dir: string, file: NamedListFile): T[] { + const p = yamlStorePath(dir, file); + if (!existsSync(p)) return []; + try { + const parsed = parseYaml(readFileSync(p, 'utf8')) as Record | null; + return parsed?.[file.key] ?? []; + } catch { + return []; + } +} + +/** 写回顶层键,目录/文件不存在则建,文件里其它顶层键原样保留 */ +export function writeNamedList(dir: string, file: NamedListFile, items: T[]): void { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = yamlStorePath(dir, file); + let existing: Record = {}; + if (existsSync(p)) { + existing = (parseYaml(readFileSync(p, 'utf8')) as Record) ?? {}; + } + existing[file.key] = items; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +/** 按 name 做字段级合并:后一层只覆盖它显式写出的字段,其余继承前一层 */ +export function mergeNamed(base: T[], override: T[]): T[] { + const map = new Map(); + for (const item of base) map.set(item.name, { ...item }); + for (const item of override) { + const prev = map.get(item.name); + map.set(item.name, prev ? { ...prev, ...definedOnly(item) } : { ...item }); + } + return Array.from(map.values()); +} + +/** 只改一条的若干字段;该 name 不存在时按 `{ name, ...patch }` 追加 */ +export function patchNamed( + dir: string, + file: NamedListFile, + name: string, + patch: Partial +): void { + const items = readNamedList(dir, file); + const idx = items.findIndex((item) => item?.name === name); + // 追加分支:`T` 的类型收窄只有在拿到完整条目后才成立,故此处由调用方保证 + // patch 足以构成一条合法条目(与旧实现 `push({ name, enabled })` 同义) + if (idx === -1) items.push({ name, ...patch } as T); + else items[idx] = { ...items[idx]!, ...patch }; + writeNamedList(dir, file, items); +} diff --git a/packages/infra/test/yaml-store.test.ts b/packages/infra/test/yaml-store.test.ts new file mode 100644 index 00000000..f4f962aa --- /dev/null +++ b/packages/infra/test/yaml-store.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { parse as parseYaml } from 'yaml'; +import { + mergeNamed, + patchNamed, + readNamedList, + writeNamedList, + yamlStorePath, + type NamedListFile, +} from '../src/yaml-store.js'; + +interface Item { + name: string; + enabled?: boolean; + command?: string; +} + +const FILE: NamedListFile = { fileName: 'widgets', key: 'widgets' }; + +let dir: string; + +function storePath(ext = 'yaml'): string { + return join(dir, `widgets.${ext}`); +} + +function readRawYaml(): Record { + return parseYaml(readFileSync(storePath(), 'utf8')) as Record; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'codingcode-test-yaml-store-')); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('yamlStorePath', () => { + it('.yaml 优先,缺失时才退回 .yml', () => { + expect(yamlStorePath(dir, FILE)).toBe(storePath('yaml')); + + writeFileSync(storePath('yml'), 'widgets: []\n'); + expect(yamlStorePath(dir, FILE)).toBe(storePath('yml')); + + writeFileSync(storePath('yaml'), 'widgets: []\n'); + expect(yamlStorePath(dir, FILE)).toBe(storePath('yaml')); + }); +}); + +describe('readNamedList', () => { + it('文件不存在 → []', () => { + expect(readNamedList(dir, FILE)).toEqual([]); + }); + + it('YAML 非法 → [](不让坏文件炸掉整条链路)', () => { + writeFileSync(storePath(), 'widgets: [1, 2\n'); + expect(readNamedList(dir, FILE)).toEqual([]); + }); + + it('顶层键缺失 → []', () => { + writeFileSync(storePath(), 'otherKey: 1\n'); + expect(readNamedList(dir, FILE)).toEqual([]); + }); + + it('从 .yml 也能读', () => { + writeFileSync(storePath('yml'), 'widgets:\n - name: from-yml\n'); + expect(readNamedList(dir, FILE)).toEqual([{ name: 'from-yml' }]); + }); +}); + +describe('writeNamedList', () => { + it('目录不存在则自建', () => { + const nested = join(dir, 'a', 'b'); + writeNamedList(nested, FILE, [{ name: 'x' }]); + expect((parseYaml(readFileSync(join(nested, 'widgets.yaml'), 'utf8')) as any).widgets).toEqual([ + { name: 'x' }, + ]); + }); + + it('覆盖同名列表,但保留文件里其它顶层键', () => { + writeFileSync(storePath(), 'otherKey: keep-me\nwidgets: []\n'); + writeNamedList(dir, FILE, [{ name: 'new' }]); + const raw = readRawYaml(); + expect(raw.otherKey).toBe('keep-me'); + expect(raw.widgets).toEqual([{ name: 'new' }]); + }); +}); + +describe('mergeNamed', () => { + it('按 name 做字段级合并:未写出的字段继承前一层', () => { + const base: Item[] = [{ name: 'a', enabled: true, command: 'x' }]; + const override: Item[] = [{ name: 'a', command: 'y' }]; + expect(mergeNamed(base, override)).toEqual([{ name: 'a', enabled: true, command: 'y' }]); + }); + + it('显式 undefined 不擦掉前一层已有的值', () => { + const base: Item[] = [{ name: 'a', enabled: true, command: 'x' }]; + const override = [{ name: 'a', enabled: undefined, command: undefined }] as Item[]; + expect(mergeNamed(base, override)).toEqual([{ name: 'a', enabled: true, command: 'x' }]); + }); + + it('新 name 追加在后,且不动原数组', () => { + const base: Item[] = [{ name: 'a', enabled: true }]; + const override: Item[] = [{ name: 'b' }]; + expect(mergeNamed(base, override)).toEqual([{ name: 'a', enabled: true }, { name: 'b' }]); + expect(base).toEqual([{ name: 'a', enabled: true }]); + }); +}); + +describe('patchNamed', () => { + it('name 不存在 → 追加 { name, ...patch },并建出目录/文件', () => { + const nested = join(dir, 'created'); + patchNamed(nested, FILE, 'fresh', { enabled: false }); + expect(readNamedList(nested, FILE)).toEqual([{ name: 'fresh', enabled: false }]); + }); + + it('name 存在 → 只改 patch 里的字段,其它字段原样保留', () => { + writeNamedList(dir, FILE, [{ name: 'a', enabled: true, command: 'x' }]); + patchNamed(dir, FILE, 'a', { enabled: false }); + expect(readNamedList(dir, FILE)).toEqual([{ name: 'a', enabled: false, command: 'x' }]); + }); + + it('打 patch 不动同文件里的其它顶层键', () => { + mkdirSync(dir, { recursive: true }); + writeFileSync(storePath(), 'otherKey: keep-me\nwidgets:\n - name: a\n enabled: true\n'); + patchNamed(dir, FILE, 'a', { enabled: false }); + expect(readRawYaml().otherKey).toBe('keep-me'); + }); +}); diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts index 4370d6b9..bb4e8729 100644 --- a/packages/tui/src/types.ts +++ b/packages/tui/src/types.ts @@ -26,11 +26,9 @@ export interface PanelItem { export interface McpServerStatus { name: string; - connected: boolean; enabled: boolean; toolCount: number; transport: 'stdio' | 'http'; - reconnectAttempts: number; } export interface SkillStatus { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1f0029b..ae7e7565 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: pino-pretty: specifier: ^13.0.0 version: 13.1.3 + yaml: + specifier: ^2.9.0 + version: 2.9.0 packages/tui: dependencies: