From 120ca17401fa5b07869e435a48027f8ec5d0f0b6 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 8 Oct 2026 01:42:46 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E6=B8=85=E7=90=86contract=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 37 +- CLAUDE.md | 37 +- packages/codingcode/package.json | 21 +- packages/codingcode/src/agent/agent.ts | 854 ++++++++++-------- packages/codingcode/src/agent/port.ts | 16 +- packages/codingcode/src/agent/profile.ts | 31 +- packages/codingcode/src/agent/prompt.ts | 4 +- packages/codingcode/src/agent/tool-env.ts | 3 +- packages/codingcode/src/approval/approval.ts | 39 +- .../codingcode/src/approval/confirmation.ts | 5 +- packages/codingcode/src/approval/port.ts | 10 +- .../codingcode/src/approval/rule-engine.ts | 2 +- .../codingcode/src/approval/tool-policy.ts | 29 + packages/codingcode/src/approval/types.ts | 2 +- packages/codingcode/src/approval/wait-port.ts | 5 +- packages/codingcode/src/approval/wait.ts | 7 +- .../codingcode/src/checkpoint/checkpoint.ts | 7 +- packages/codingcode/src/checkpoint/port.ts | 30 +- .../codingcode/src/checkpoint/project-lock.ts | 6 +- .../codingcode/src/checkpoint/shadow-git.ts | 6 +- packages/codingcode/src/context/context.ts | 58 +- packages/codingcode/src/context/port.ts | 12 +- packages/codingcode/src/context/tokens.ts | 2 +- packages/codingcode/src/contracts/hooks.ts | 33 - packages/codingcode/src/contracts/paths.ts | 11 - .../codingcode/src/contracts/permission.ts | 18 - packages/codingcode/src/contracts/profile.ts | 11 - packages/codingcode/src/contracts/tool.ts | 28 - packages/codingcode/src/contracts/types.ts | 38 - packages/codingcode/src/core/path.ts | 13 + packages/codingcode/src/hooks/config.ts | 7 +- packages/codingcode/src/hooks/executor.ts | 3 +- packages/codingcode/src/hooks/hooks.ts | 35 +- packages/codingcode/src/hooks/port.ts | 7 +- packages/codingcode/src/hooks/types.ts | 38 +- packages/codingcode/src/infra/models.ts | 17 +- packages/codingcode/src/layer.ts | 21 +- packages/codingcode/src/llm/llm.ts | 16 +- packages/codingcode/src/llm/port.ts | 14 +- .../codingcode/src/llm/providers/deepseek.ts | 6 +- .../codingcode/src/llm/providers/openai.ts | 6 +- .../codingcode/src/llm/providers/shared.ts | 2 +- .../{contracts/provider.ts => llm/types.ts} | 43 +- packages/codingcode/src/mcp/client.ts | 2 +- packages/codingcode/src/mcp/config.ts | 6 +- packages/codingcode/src/mcp/mcp.ts | 10 +- packages/codingcode/src/mcp/port.ts | 2 +- .../src/{contracts/mcp.ts => mcp/types.ts} | 0 packages/codingcode/src/memory/memory.ts | 9 +- packages/codingcode/src/memory/port.ts | 6 +- packages/codingcode/src/rules/rules.ts | 7 +- packages/codingcode/src/scheduler/port.ts | 11 +- .../codingcode/src/scheduler/scheduler.ts | 13 +- packages/codingcode/src/scheduler/store.ts | 2 +- .../automation.ts => scheduler/types.ts} | 4 - .../src/{contracts => server}/frame-io.ts | 11 +- packages/codingcode/src/server/handler.ts | 4 +- .../error.ts => server/http-error.ts} | 0 .../src/server/routes/automations.ts | 4 +- .../codingcode/src/server/routes/hooks.ts | 4 +- packages/codingcode/src/server/routes/mcp.ts | 4 +- .../codingcode/src/server/routes/sessions.ts | 19 +- .../codingcode/src/server/routes/skills.ts | 4 +- packages/codingcode/src/session/file-ops.ts | 26 +- packages/codingcode/src/session/mailbox.ts | 7 +- packages/codingcode/src/session/paths.ts | 17 +- packages/codingcode/src/session/port.ts | 86 +- packages/codingcode/src/session/session.ts | 33 +- .../session.ts => session/types.ts} | 56 +- packages/codingcode/src/sink/port.ts | 7 +- packages/codingcode/src/sink/sink.ts | 2 +- .../src/{contracts/frame.ts => sink/types.ts} | 16 +- packages/codingcode/src/skills/loader.ts | 2 +- packages/codingcode/src/skills/port.ts | 2 +- packages/codingcode/src/skills/skills.ts | 9 +- .../{contracts/skill.ts => skills/types.ts} | 0 packages/codingcode/src/subagent/port.ts | 16 +- packages/codingcode/src/subagent/registry.ts | 105 ++- packages/codingcode/src/subagent/subagent.ts | 2 +- packages/codingcode/src/todo/port.ts | 2 +- packages/codingcode/src/todo/todo.ts | 25 +- packages/codingcode/src/todo/types.ts | 6 + packages/codingcode/src/tools/catalog.ts | 15 +- .../src/tools/domains/self/todo-write.ts | 2 +- .../src/tools/domains/subagent/spawn.ts | 52 +- .../src/tools/domains/subagent/submit-plan.ts | 5 +- .../src/tools/domains/subagent/wait.ts | 18 +- packages/codingcode/src/tools/port.ts | 20 +- packages/codingcode/src/tools/registry.ts | 2 +- packages/codingcode/src/tools/tools.ts | 72 +- packages/codingcode/src/tools/types.ts | 40 +- packages/codingcode/test/agent/abort.test.ts | 2 +- packages/codingcode/test/agent/agent.test.ts | 12 +- .../test/agent/context-compressed.test.ts | 9 +- .../test/agent/hooks-deps-type.test.ts | 9 +- .../test/agent/loop-options.test.ts | 14 +- .../test/agent/render-skill-block.test.ts | 2 +- .../test/agent/skill-mention-persist.test.ts | 7 +- .../test/architecture/boundaries.test.ts | 166 ++-- .../test/checkpoint/checkpoint-diff.test.ts | 1 - .../test/checkpoint/checkpoint-undo.test.ts | 1 - .../checkpoint/turn-title-removal.test.ts | 4 +- .../test/context/budget-integration.test.ts | 8 +- .../test/context/compressor/behavior.test.ts | 9 +- .../test/context/memory-buffer.test.ts | 21 +- .../test/context/subagent-result-role.test.ts | 19 +- .../context/transcript-path-agreement.test.ts | 30 - .../codingcode/test/core/error-code.test.ts | 2 +- .../codingcode/test/core/frame-io.test.ts | 4 +- .../test/core/frame-protocol.test.ts | 5 +- .../codingcode/test/helpers/agent-harness.ts | 66 +- .../codingcode/test/helpers/project-base.ts | 7 +- .../test/hooks/config-merge.test.ts | 7 +- .../codingcode/test/hooks/decision.test.ts | 61 +- .../test/hooks/points-coverage.test.ts | 54 +- .../codingcode/test/hooks/registry.test.ts | 8 +- packages/codingcode/test/infra/models.test.ts | 6 +- .../test/llm/deepseek-provider.test.ts | 2 +- .../test/llm/openai-provider.test.ts | 9 +- .../codingcode/test/mcp/config-merge.test.ts | 4 +- packages/codingcode/test/mcp/service.test.ts | 5 +- .../codingcode/test/memory/extractor.test.ts | 2 +- packages/codingcode/test/memory/index.test.ts | 29 +- .../test/plan/allowed-tools.test.ts | 12 +- .../test/plan/gate-pipeline.test.ts | 9 +- .../test/plan/is-plan-profile.test.ts | 11 +- packages/codingcode/test/plan/policy.test.ts | 2 +- .../codingcode/test/scheduler/store.test.ts | 2 +- .../security/plan-profile-restart.test.ts | 26 +- .../codingcode/test/self/todo/service.test.ts | 2 +- .../codingcode/test/session/filter-ui.test.ts | 2 +- packages/codingcode/test/session/fork.test.ts | 2 +- .../test/session/prompt-estimate.test.ts | 2 +- .../test/session/store-compact-usage.test.ts | 2 +- .../test/session/store-diff-rebuild.test.ts | 2 +- .../test/session/store-rollback-usage.test.ts | 2 +- .../test/session/ui-history-rollback.test.ts | 2 +- .../test/session/view-assembly.test.ts | 2 +- packages/codingcode/test/sink/sink.test.ts | 19 +- packages/codingcode/test/skills/index.test.ts | 8 +- .../codingcode/test/subagent/registry.test.ts | 24 +- .../test/subagent/runner-service.test.ts | 5 +- .../test/subagent/runner-wiring.test.ts | 31 +- .../codingcode/test/tools/catalog.test.ts | 2 +- .../test/tools/executor-concurrency.test.ts | 30 +- .../test/types/type-collapse.test.ts | 4 +- 146 files changed, 1864 insertions(+), 1281 deletions(-) create mode 100644 packages/codingcode/src/approval/tool-policy.ts delete mode 100644 packages/codingcode/src/contracts/hooks.ts delete mode 100644 packages/codingcode/src/contracts/paths.ts delete mode 100644 packages/codingcode/src/contracts/permission.ts delete mode 100644 packages/codingcode/src/contracts/profile.ts delete mode 100644 packages/codingcode/src/contracts/tool.ts delete mode 100644 packages/codingcode/src/contracts/types.ts rename packages/codingcode/src/{contracts/provider.ts => llm/types.ts} (66%) rename packages/codingcode/src/{contracts/mcp.ts => mcp/types.ts} (100%) rename packages/codingcode/src/{contracts/automation.ts => scheduler/types.ts} (92%) rename packages/codingcode/src/{contracts => server}/frame-io.ts (68%) rename packages/codingcode/src/{contracts/error.ts => server/http-error.ts} (100%) rename packages/codingcode/src/{contracts/session.ts => session/types.ts} (77%) rename packages/codingcode/src/{contracts/frame.ts => sink/types.ts} (87%) rename packages/codingcode/src/{contracts/skill.ts => skills/types.ts} (100%) create mode 100644 packages/codingcode/src/todo/types.ts delete mode 100644 packages/codingcode/test/context/transcript-path-agreement.test.ts diff --git a/AGENTS.md b/AGENTS.md index 0271de79..1e1bbc7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,45 +33,16 @@ Coding Code 是 AI 编程助手。 - `rules`:全局 / 项目级规则装载 - `server`:HTTP / SSE 入口 - `client`:HTTP 客户端(`AgentClient` 的实现) -- `core`:通用件(`error` / `result` / `path`),不指向任何功能模块 -- `contracts`:跨领域共享契约 +- `core`:通用件(`error` / `result` / `path`:路径归一化与工作区数据目录),不指向任何功能模块 +- 类型归属:类型跟拥有者走——仅本模块用的进本模块 `types.ts`;跨模块一律 `import type` 指向对方的 `types.ts` - `layer.ts`:组合根,全量装配 -## 架构要求 - -**依赖倒置**:所有非叶子模块利用 `port.ts`(宽契约;agent 自持的装配端口也在 `agent/port.ts`)声明自己需要的接口和类型定义,使调用者不需要依赖实现方;只允许依赖下层模块。 - -**分层与允许依赖**: - -| 层 | 落点 | 允许依赖 | -|---|---|---| -| L0 通用件 | `core/` | node 内置 + 同目录 | -| L1 共享契约 | `contracts/` | `core/` + 同目录 + 第三方(type-only) | -| L1' 端口契约 | 各 `xxx/port.ts`(含 `agent/port.ts` 的装配端口) | `core/` + `contracts/` | -| L2 实现 | `tools/`、`hooks/`、`session/`、`approval/`、`llm/`、`mcp/`、`context/` … | L0 + L1 | -| L3 组合根 | `layer.ts`、`agent/tool-env.ts` | 全部 | - -**架构边界硬规则**(由 `packages/codingcode/test/architecture/boundaries.test.ts` 静态断言,共 29 项): - -- **R1** 契约不得 import 实现:`contracts/` 与 `**/port.ts` 的相对 import 只能落在 `core/`、`contracts/` 或同目录 -- **R2** 实现不得依赖消费者模块:agent 自持的装配端口 `ToolEnvPort` 只在 `agent/` 内部出现 -- **R3** `core/` 零内部依赖:不引用 `core/` 之外的任何 src 模块 -- **R4** 一个概念只允许一处类型定义,canonical 落点为 `contracts/` -- **准入**:`core/` 的 import 只能是 node 内置与同目录;`contracts/` 只引用 `core/`、同目录与第三方 -- **可解析**:`src/**` 的每条相对 import 都必须能在仓库内找到落点 - -**类型落点判据**(先判归属,再判引用面): - -- 判据一 —— 有无领域归属:不指向任何功能模块的(错误基类、结果容器、路径运算)→ `core/`;指向某功能模块的 → 判据二 -- 判据二 —— 引用面,**只作用于领域件**:仅 1 个 src 领域引用 → 回该领域**已有**的归属文件;只出现在某调用方接口签名里 → 内联进调用方;≥2 个 src 领域,或 ≥1 个跨包 → `contracts/` - -**机制形状例外**:`z.ZodTypeAny`、SDK client、Effect 的 R 通道类型必须留在叶子模块,不得进 `contracts/`。契约只暴露窄的纯数据描述——MCP 契约返回 `McpToolSpec`,`z.fromJSONSchema` 的转换由拥有机制的 `tools/catalog.ts` 自己做。 - ## 开发规则 - 禁止用户当前轮未明确要求就主动修改仓库中任何内容,包括源代码、配置文件、文档等 - 禁止用户当前轮未明确要求就主动进行 reset、commit、push 等相关会影响 git 历史或者当前仓库代码的操作,仅用户显式要求进行某类操作才能进行;仅允许 `git diff`、`git log` 等无副作用的操作可以自主进行 - 禁止未在用户指示下补充测试,当开发任务完成后,给用户报告完成程度,由用户决定针对哪些部分写测试 +- 禁止只是修改代码格式的无效修改,污染diff内容 - 禁止将工具执行细节泄漏到 agent 编排层及其他模块,agent 只依赖端口契约,不得 import 工具实现 - 禁止将传输协议细节(HTTP / SSE)泄漏到 agent 核心及其他模块,agent 不得依赖 `server/`、`client/` - 不允许假设“这是未来需要扩展的”,所以现在就不做,应该贴合用户的实际要求 @@ -99,7 +70,7 @@ Coding Code 是 AI 编程助手。 3. 存在性 / 导出断言——断言文件存在、类 / 函数 / layer 已导出、方法存在。真断裂时编译与上层用例会同时失败,此类用例不提供额外信息。 4. 常量钉死——断言常量等于它自身的字面量。需要守护的是行为,不是常量的副本。 5. 测试内重新实现逻辑——在测试里重写一遍待测算法再断言自己写的副本,只证明写法一致,发现不了实现缺陷。 -6. 源码文本 / 结构扫描——用字符串或正则解析源码断言导入、布局或写法。这属于静态分析:类型与导入可解析性由 `tsc` 保证,无需重复;架构分层等 `tsc` 无法表达的约束,改用 ESLint 规则或 AST 解析,而不是正则匹配源码文本(现有 `test/architecture/boundaries.test.ts`、`test/hooks/points-coverage.test.ts` 应按此方向迁移)。 +6. 源码文本 / 结构扫描——用字符串或正则解析源码断言导入、布局或写法。这属于静态分析:类型与导入可解析性由 `tsc` 保证,无需重复;架构分层等 `tsc` 无法表达的约束,改用 ESLint 规则或 AST 解析(`test/architecture/boundaries.test.ts`、`test/hooks/points-coverage.test.ts` 已按此实现)。 7. 占位 / 恒真断言——`expect(true).toBe(true)`、只 `toBeDefined()` 不校验值、以及无论如何都会通过的断言。 8. 重复用例——与已有用例覆盖同一行为的副本;发现重复应合并,不要并存。 diff --git a/CLAUDE.md b/CLAUDE.md index 0271de79..262318cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,46 +33,17 @@ Coding Code 是 AI 编程助手。 - `rules`:全局 / 项目级规则装载 - `server`:HTTP / SSE 入口 - `client`:HTTP 客户端(`AgentClient` 的实现) -- `core`:通用件(`error` / `result` / `path`),不指向任何功能模块 -- `contracts`:跨领域共享契约 +- `core`:通用件(`error` / `result` / `path`:路径归一化与工作区数据目录),不指向任何功能模块 +- 类型归属:类型跟拥有者走——仅本模块用的进本模块 `types.ts`;跨模块一律 `import type` 指向对方的 `types.ts` - `layer.ts`:组合根,全量装配 -## 架构要求 - -**依赖倒置**:所有非叶子模块利用 `port.ts`(宽契约;agent 自持的装配端口也在 `agent/port.ts`)声明自己需要的接口和类型定义,使调用者不需要依赖实现方;只允许依赖下层模块。 - -**分层与允许依赖**: - -| 层 | 落点 | 允许依赖 | -|---|---|---| -| L0 通用件 | `core/` | node 内置 + 同目录 | -| L1 共享契约 | `contracts/` | `core/` + 同目录 + 第三方(type-only) | -| L1' 端口契约 | 各 `xxx/port.ts`(含 `agent/port.ts` 的装配端口) | `core/` + `contracts/` | -| L2 实现 | `tools/`、`hooks/`、`session/`、`approval/`、`llm/`、`mcp/`、`context/` … | L0 + L1 | -| L3 组合根 | `layer.ts`、`agent/tool-env.ts` | 全部 | - -**架构边界硬规则**(由 `packages/codingcode/test/architecture/boundaries.test.ts` 静态断言,共 29 项): - -- **R1** 契约不得 import 实现:`contracts/` 与 `**/port.ts` 的相对 import 只能落在 `core/`、`contracts/` 或同目录 -- **R2** 实现不得依赖消费者模块:agent 自持的装配端口 `ToolEnvPort` 只在 `agent/` 内部出现 -- **R3** `core/` 零内部依赖:不引用 `core/` 之外的任何 src 模块 -- **R4** 一个概念只允许一处类型定义,canonical 落点为 `contracts/` -- **准入**:`core/` 的 import 只能是 node 内置与同目录;`contracts/` 只引用 `core/`、同目录与第三方 -- **可解析**:`src/**` 的每条相对 import 都必须能在仓库内找到落点 - -**类型落点判据**(先判归属,再判引用面): - -- 判据一 —— 有无领域归属:不指向任何功能模块的(错误基类、结果容器、路径运算)→ `core/`;指向某功能模块的 → 判据二 -- 判据二 —— 引用面,**只作用于领域件**:仅 1 个 src 领域引用 → 回该领域**已有**的归属文件;只出现在某调用方接口签名里 → 内联进调用方;≥2 个 src 领域,或 ≥1 个跨包 → `contracts/` - -**机制形状例外**:`z.ZodTypeAny`、SDK client、Effect 的 R 通道类型必须留在叶子模块,不得进 `contracts/`。契约只暴露窄的纯数据描述——MCP 契约返回 `McpToolSpec`,`z.fromJSONSchema` 的转换由拥有机制的 `tools/catalog.ts` 自己做。 - ## 开发规则 - 禁止用户当前轮未明确要求就主动修改仓库中任何内容,包括源代码、配置文件、文档等 - 禁止用户当前轮未明确要求就主动进行 reset、commit、push 等相关会影响 git 历史或者当前仓库代码的操作,仅用户显式要求进行某类操作才能进行;仅允许 `git diff`、`git log` 等无副作用的操作可以自主进行 - 禁止未在用户指示下补充测试,当开发任务完成后,给用户报告完成程度,由用户决定针对哪些部分写测试 - 禁止将工具执行细节泄漏到 agent 编排层及其他模块,agent 只依赖端口契约,不得 import 工具实现 +- 禁止只是修改代码格式的无效修改,污染diff内容 - 禁止将传输协议细节(HTTP / SSE)泄漏到 agent 核心及其他模块,agent 不得依赖 `server/`、`client/` - 不允许假设“这是未来需要扩展的”,所以现在就不做,应该贴合用户的实际要求 - 不允许总是有阶段性计划,分阶段完成很容易导致过程产生一堆没用的死代码 @@ -99,7 +70,7 @@ Coding Code 是 AI 编程助手。 3. 存在性 / 导出断言——断言文件存在、类 / 函数 / layer 已导出、方法存在。真断裂时编译与上层用例会同时失败,此类用例不提供额外信息。 4. 常量钉死——断言常量等于它自身的字面量。需要守护的是行为,不是常量的副本。 5. 测试内重新实现逻辑——在测试里重写一遍待测算法再断言自己写的副本,只证明写法一致,发现不了实现缺陷。 -6. 源码文本 / 结构扫描——用字符串或正则解析源码断言导入、布局或写法。这属于静态分析:类型与导入可解析性由 `tsc` 保证,无需重复;架构分层等 `tsc` 无法表达的约束,改用 ESLint 规则或 AST 解析,而不是正则匹配源码文本(现有 `test/architecture/boundaries.test.ts`、`test/hooks/points-coverage.test.ts` 应按此方向迁移)。 +6. 源码文本 / 结构扫描——用字符串或正则解析源码断言导入、布局或写法。这属于静态分析:类型与导入可解析性由 `tsc` 保证,无需重复;架构分层等 `tsc` 无法表达的约束,改用 ESLint 规则或 AST 解析(`test/architecture/boundaries.test.ts`、`test/hooks/points-coverage.test.ts` 已按此实现)。 7. 占位 / 恒真断言——`expect(true).toBe(true)`、只 `toBeDefined()` 不校验值、以及无论如何都会通过的断言。 8. 重复用例——与已有用例覆盖同一行为的副本;发现重复应合并,不要并存。 diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 24b05501..5a0879b1 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -11,20 +11,15 @@ "./server": "./src/server/index.ts", "./approval/types": "./src/approval/types.ts", "./agent/profile": "./src/agent/profile.ts", - "./contracts/frame": "./src/contracts/frame.ts", - "./contracts/frame-io": "./src/contracts/frame-io.ts", + "./sink/types": "./src/sink/types.ts", + "./llm/types": "./src/llm/types.ts", + "./todo/types": "./src/todo/types.ts", + "./session/types": "./src/session/types.ts", + "./mcp/types": "./src/mcp/types.ts", + "./scheduler/types": "./src/scheduler/types.ts", + "./skills/types": "./src/skills/types.ts", + "./tools/types": "./src/tools/types.ts", "./core/error": "./src/core/error.ts", - "./contracts/error": "./src/contracts/error.ts", - "./contracts/types": "./src/contracts/types.ts", - "./contracts/permission": "./src/contracts/permission.ts", - "./contracts/profile": "./src/contracts/profile.ts", - "./contracts/hooks": "./src/contracts/hooks.ts", - "./contracts/session": "./src/contracts/session.ts", - "./contracts/provider": "./src/contracts/provider.ts", - "./contracts/mcp": "./src/contracts/mcp.ts", - "./contracts/automation": "./src/contracts/automation.ts", - "./contracts/skill": "./src/contracts/skill.ts", - "./contracts/tool": "./src/contracts/tool.ts", "./checkpoint/types": "./src/checkpoint/types.ts", "./session/port": "./src/session/port.ts", "./hooks/types": "./src/hooks/types.ts" diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index db05503e..f1f0b0d8 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -3,7 +3,7 @@ import { AgentError } from '../core/error.js'; import { Result } from '../core/result.js'; import { AgentService, ToolEnvPort } from './port.js'; import type { RunTurnOptions, ToolEnv } from './port.js'; -import { ApprovalService } from '../approval/port.js'; +import { ApprovalService, getToolNames } from '../approval/port.js'; import { CheckpointService } from '../checkpoint/port.js'; import { ContextService } from '../context/port.js'; import { EventSinkService } from '../sink/port.js'; @@ -18,15 +18,22 @@ import { SkillService } from '../skills/port.js'; import { TodoService } from '../todo/port.js'; import { ToolExecutorService } from '../tools/port.js'; import { buildSystemPrompt, renderSkillBlock } from './prompt.js'; -import type { EndTransition, FrameBody, FrameError, ResponseMeta, ToolOutcome } from '../contracts/frame.js'; -import { isTurnEnd } from '../contracts/frame.js'; -import type { SessionRef } from '../contracts/session.js'; -import type { ToolCatalog, ToolResult } from '../contracts/tool.js'; -import type { ToolCall } from '../contracts/types.js'; +import type { + EndTransition, + FrameBody, + FrameError, + ResponseMeta, + ToolOutcome, +} from '../sink/types.js'; +import { isTurnEnd } from '../sink/types.js'; +import type { SessionRef } from '../session/types.js'; +import type { PermissionMode } from '../session/types.js'; +import type { ToolCatalog, ToolResult } from '../tools/types.js'; +import type { ToolCall } from '../llm/types.js'; import { loadConfig } from '../infra/config.js'; import { createLogger } from '../infra/logger.js'; import { normalizePath } from '../core/path.js'; -import { resolveProfile, getToolNames } from './profile.js'; +import { resolveProfile } from './profile.js'; function toolOutcomeOf(result: ToolResult): ToolOutcome { return result.status === 'denied' @@ -34,7 +41,6 @@ function toolOutcomeOf(result: ToolResult): ToolOutcome { : { status: result.status, output: result.output }; } import type { AgentProfile } from './profile.js'; -import type { PermissionMode } from '../contracts/permission.js'; const logger = createLogger(); @@ -42,389 +48,527 @@ function toFrameError(e: AgentError): FrameError { return { message: e.message, code: e.code }; } -export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { - const session = yield* SessionService; - const executor = yield* ToolExecutorService; - const checkpoint = yield* CheckpointService; - const hooks = yield* HookService; - const approval = yield* ApprovalService; - const skills = yield* SkillService; - const mcp = yield* McpService; - const context = yield* ContextService; - const sink = yield* EventSinkService; - const mailbox = yield* MailboxService; - const memory = yield* MemoryService; - const llm = yield* LLMService; - const rules = yield* RulesService; - const todo = yield* TodoService; - const toolEnvPort = yield* ToolEnvPort; - const cfg = loadConfig(); - const maxSteps = cfg.maxSteps ?? 250; - const maxStopContinuations = cfg.maxStopContinuations ?? 3; - - const flushMemoryInBackground = (sessionId: string, model: string, cwd: string) => - Effect.forkDaemon( - memory.flushSessionToMemory(sessionId, model, cwd).pipe( - Effect.catchAllCause((cause) => - Effect.sync(() => logger.error('memory flush failed:', cause)) - ) - ) - ); - - const runTurn = (input: string, opts: RunTurnOptions) => - Effect.gen(function* () { - const normalizedCwd = normalizePath(opts.cwd); - - yield* rules.evictProjectRules(normalizedCwd); - yield* hooks.reloadUserHooks(normalizedCwd); - yield* hooks.emit('agent.turn.start', { sessionId: '', projectPath: normalizedCwd }); - yield* mcp.syncConnections(normalizedCwd); - - let sessionId = opts.sessionId; - let parentSessionId = opts.parentSessionId; - const model = opts.model; - if (!sessionId) { - if (!opts.activeProfile || !opts.permissionMode) { - return yield* Effect.fail( - new AgentError('CONFIG_MISSING', 'new session requires activeProfile and permissionMode') +export const AgentLayer = Layer.effect( + AgentService, + Effect.gen(function* () { + const session = yield* SessionService; + const executor = yield* ToolExecutorService; + const checkpoint = yield* CheckpointService; + const hooks = yield* HookService; + const approval = yield* ApprovalService; + const skills = yield* SkillService; + const mcp = yield* McpService; + const context = yield* ContextService; + const sink = yield* EventSinkService; + const mailbox = yield* MailboxService; + const memory = yield* MemoryService; + const llm = yield* LLMService; + const rules = yield* RulesService; + const todo = yield* TodoService; + const toolEnvPort = yield* ToolEnvPort; + const cfg = loadConfig(); + const maxSteps = cfg.maxSteps ?? 250; + const maxStopContinuations = cfg.maxStopContinuations ?? 3; + + const flushMemoryInBackground = (sessionId: string, model: string, cwd: string) => + Effect.forkDaemon( + memory + .flushSessionToMemory(sessionId, model, cwd) + .pipe( + Effect.catchAllCause((cause) => + Effect.sync(() => logger.error('memory flush failed:', cause)) + ) + ) + ); + + const runTurn = (input: string, opts: RunTurnOptions) => + Effect.gen(function* () { + const normalizedCwd = normalizePath(opts.cwd); + + yield* rules.evictProjectRules(normalizedCwd); + yield* hooks.reloadUserHooks(normalizedCwd); + yield* hooks.emit('agent.turn.start', { sessionId: '', projectPath: normalizedCwd }); + yield* mcp.syncConnections(normalizedCwd); + + let sessionId = opts.sessionId; + let parentSessionId = opts.parentSessionId; + const model = opts.model; + if (!sessionId) { + if (!opts.activeProfile || !opts.permissionMode) { + return yield* Effect.fail( + new AgentError( + 'CONFIG_MISSING', + 'new session requires activeProfile and permissionMode' + ) + ); + } + const created = yield* session.create( + normalizedCwd, + { + model, + title: input, + activeProfile: opts.activeProfile, + permissionMode: opts.permissionMode, + }, + { parentSessionId, agentName: opts.agentName } ); + sessionId = created.sessionId; + parentSessionId = created.parentSessionId; } - const created = yield* session.create( - normalizedCwd, - { - model, - title: input, - activeProfile: opts.activeProfile, - permissionMode: opts.permissionMode, - }, - { parentSessionId, agentName: opts.agentName } - ); - sessionId = created.sessionId; - parentSessionId = created.parentSessionId; - } - const state = yield* session.load(normalizedCwd, sessionId, parentSessionId); + const state = yield* session.load(normalizedCwd, sessionId, parentSessionId); - const profileName = opts.activeProfile ?? state.activeProfile; - const effectivePerm = opts.permissionMode ?? state.permissionMode; - if (opts.permissionMode) { - yield* session.setPermissionMode(normalizedCwd, sessionId, opts.permissionMode, parentSessionId); - } - if (opts.activeProfile) { - yield* session.setActiveProfile(normalizedCwd, sessionId, opts.activeProfile, parentSessionId); - } + const profileName = opts.activeProfile ?? state.activeProfile; + const effectivePerm = opts.permissionMode ?? state.permissionMode; + if (opts.permissionMode) { + yield* session.setPermissionMode( + normalizedCwd, + sessionId, + opts.permissionMode, + parentSessionId + ); + } + if (opts.activeProfile) { + yield* session.setActiveProfile( + normalizedCwd, + sessionId, + opts.activeProfile, + parentSessionId + ); + } - state.memorySnapshot = yield* memory.loadMemoryForPrompt(state.cwd); + state.memorySnapshot = yield* memory.loadMemoryForPrompt(state.cwd); - const profile: AgentProfile | undefined = profileName ? resolveProfile(profileName) : undefined; + const profile: AgentProfile | undefined = profileName + ? resolveProfile(profileName) + : undefined; - const mcpTools = yield* mcp.listProjectMcpTools(normalizedCwd); - const catalog = yield* executor.prepare(getToolNames(profile), mcpTools); + const mcpTools = yield* mcp.listProjectMcpTools(normalizedCwd); + const catalog = yield* executor.prepare(getToolNames(profileName), mcpTools); - const toolEnv = yield* toolEnvPort.getToolEnv(); + const toolEnv = yield* toolEnvPort.getToolEnv(); - const turnId = (yield* session.recordUser(state, input)).turnId; + const turnId = (yield* session.recordUser(state, input)).turnId; - // 用户显式 @ 的 skill:按 path 回查权威数据,正文拼块后作为同回合的第二条 user 事件 - if (opts.skills?.length) { - const all = yield* skills.getAll(state.cwd); - const chosen = all.filter((s) => opts.skills!.some((m) => m.path === s.skillPath)); - if (chosen.length) { - const entries = yield* Effect.forEach(chosen, (s) => - skills.readContent(s.skillPath).pipe(Effect.map((body) => ({ skill: s, body }))) - ); - yield* session.recordSystem(state, renderSkillBlock(entries)); + // 用户显式 @ 的 skill:按 path 回查权威数据,正文拼块后作为同回合的第二条 user 事件 + if (opts.skills?.length) { + const all = yield* skills.getAll(state.cwd); + const chosen = all.filter((s) => opts.skills!.some((m) => m.path === s.skillPath)); + if (chosen.length) { + const entries = yield* Effect.forEach(chosen, (s) => + skills.readContent(s.skillPath).pipe(Effect.map((body) => ({ skill: s, body }))) + ); + yield* session.recordSystem(state, renderSkillBlock(entries)); + } } - } - // checkpoint baseline - yield* checkpoint.snapshotBaseline(state.cwd, sessionId, turnId); - - // get rules text - const rulesText = yield* rules.getAllRules(state.cwd); - - // run agent loop:出站队列挂在 sink 上,本回合是它的唯一读者 - const q = yield* sink.attach(sessionId); - const emit = (body: FrameBody) => Effect.runSync(sink.emit(sessionId, body)); - const stream = runAgentLoop( - { - state, model, profile, catalog, systemPrompt: opts.systemPrompt, - toolEnv, - abortSignal: opts.signal, rulesText, - sid: sessionId, projectPath: state.cwd, permissionMode: effectivePerm, - }, - { q, emit, onEnd: () => Effect.runSync(sink.detach(sessionId)) } - ); + // checkpoint baseline + yield* checkpoint.snapshotBaseline(state.cwd, sessionId, turnId); - return { stream, sessionId }; - }); - - function runAgentLoop(opts: { - state: any; model: string; profile: AgentProfile | undefined; - abortSignal: AbortSignal | undefined; - catalog: ToolCatalog; - toolEnv: ToolEnv; - rulesText: string; - sid: string; projectPath: string; permissionMode: PermissionMode; - systemPrompt?: string; - }, out: { - q: Queue.Queue; - emit: (body: FrameBody) => void; - onEnd: () => void; - }): AsyncGenerator { - const program = agentLoopInternal(opts, out.emit); - - return (async function* () { - const fiber = Effect.runFork(opts.toolEnv.provide(program)); - if (opts.abortSignal) { - opts.abortSignal.addEventListener('abort', () => { - Effect.runFork(Fiber.interrupt(fiber)); - }, { once: true }); - if (opts.abortSignal.aborted) Effect.runFork(Fiber.interrupt(fiber)); - } + // get rules text + const rulesText = yield* rules.getAllRules(state.cwd); - try { - const stream = Stream.fromQueue(out.q).pipe( - Stream.takeUntil((body: FrameBody) => isTurnEnd(body)) + // run agent loop:出站队列挂在 sink 上,本回合是它的唯一读者 + const q = yield* sink.attach(sessionId); + const emit = (body: FrameBody) => Effect.runSync(sink.emit(sessionId, body)); + const stream = runAgentLoop( + { + state, + model, + profile, + catalog, + systemPrompt: opts.systemPrompt, + toolEnv, + abortSignal: opts.signal, + rulesText, + sid: sessionId, + projectPath: state.cwd, + permissionMode: effectivePerm, + }, + { q, emit, onEnd: () => Effect.runSync(sink.detach(sessionId)) } ); - for await (const body of Stream.toAsyncIterable(stream) as AsyncIterable) { - yield body; - } - } finally { - out.onEnd(); - } - })(); - } - - function agentLoopInternal(opts: { - state: any; model: string; profile: AgentProfile | undefined; - abortSignal: AbortSignal | undefined; - catalog: ToolCatalog; - rulesText: string; - sid: string; projectPath: string; permissionMode: PermissionMode; - systemPrompt?: string; - }, emit: (body: FrameBody) => void): Effect.Effect, AgentError> { - const { state, model, profile, abortSignal, catalog, rulesText, sid, projectPath, permissionMode } = opts; - const { tools, lookup: toolLookup } = catalog; - - let ended = false; - let deliveryPhase: 'currentTurn' | 'nextTurn' = 'currentTurn'; - const offerEnd = (transition: EndTransition) => - Effect.sync(() => { - if (ended) return; - ended = true; - emit({ family: 'transition', transition }); - }); - return Effect.gen(function* () { - const basePrompt = buildSystemPrompt({ - cwd: projectPath, - platform: process.platform, - shell: process.env.SHELL || process.env.ComSpec || 'bash', - rules: rulesText, - profileSystemPrompt: opts.systemPrompt ?? profile?.systemPrompt, + return { stream, sessionId }; }); - const memoryBlock = state.memorySnapshot; - const memorySection = memoryBlock ? `## Session Memory\n\n${memoryBlock}` : ''; - const system = [basePrompt, memorySection].filter(Boolean).join('\n\n'); - - let stopContinuations = 0; - const effectiveMaxStopContinuations = maxStopContinuations; + function runAgentLoop( + opts: { + state: any; + model: string; + profile: AgentProfile | undefined; + abortSignal: AbortSignal | undefined; + catalog: ToolCatalog; + toolEnv: ToolEnv; + rulesText: string; + sid: string; + projectPath: string; + permissionMode: PermissionMode; + systemPrompt?: string; + }, + out: { + q: Queue.Queue; + emit: (body: FrameBody) => void; + onEnd: () => void; + } + ): AsyncGenerator { + const program = agentLoopInternal(opts, out.emit); + + return (async function* () { + const fiber = Effect.runFork(opts.toolEnv.provide(program)); + if (opts.abortSignal) { + opts.abortSignal.addEventListener( + 'abort', + () => { + Effect.runFork(Fiber.interrupt(fiber)); + }, + { once: true } + ); + if (opts.abortSignal.aborted) Effect.runFork(Fiber.interrupt(fiber)); + } - let lastResult: Result | null = null; + try { + const stream = Stream.fromQueue(out.q).pipe( + Stream.takeUntil((body: FrameBody) => isTurnEnd(body)) + ); + for await (const body of Stream.toAsyncIterable(stream) as AsyncIterable) { + yield body; + } + } finally { + out.onEnd(); + } + })(); + } + + function agentLoopInternal( + opts: { + state: any; + model: string; + profile: AgentProfile | undefined; + abortSignal: AbortSignal | undefined; + catalog: ToolCatalog; + rulesText: string; + sid: string; + projectPath: string; + permissionMode: PermissionMode; + systemPrompt?: string; + }, + emit: (body: FrameBody) => void + ): Effect.Effect, AgentError> { + const { + state, + model, + profile, + abortSignal, + catalog, + rulesText, + sid, + projectPath, + permissionMode, + } = opts; + const { tools } = catalog; + + let ended = false; + let deliveryPhase: 'currentTurn' | 'nextTurn' = 'currentTurn'; + const offerEnd = (transition: EndTransition) => + Effect.sync(() => { + if (ended) return; + ended = true; + emit({ family: 'transition', transition }); + }); + + return Effect.gen(function* () { + const basePrompt = buildSystemPrompt({ + cwd: projectPath, + platform: process.platform, + shell: process.env.SHELL || process.env.ComSpec || 'bash', + rules: rulesText, + profileSystemPrompt: opts.systemPrompt ?? profile?.systemPrompt, + }); + + const memoryBlock = state.memorySnapshot; + const memorySection = memoryBlock ? `## Session Memory\n\n${memoryBlock}` : ''; + const system = [basePrompt, memorySection].filter(Boolean).join('\n\n'); + + let stopContinuations = 0; + const effectiveMaxStopContinuations = maxStopContinuations; + + let lastResult: Result | null = null; + + yield* hooks.emit('agent.turn.start', { sessionId: sid, projectPath }); + emit({ 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, + projectPath, + }); - yield* hooks.emit('agent.turn.start', { sessionId: sid, projectPath }); - emit({ family: 'transition', transition: { to: 'start', turnId: state.currentTurnId } }); + if (step === 0) { + emit({ family: 'transition', transition: { to: 'executing' } }); + } - for (let step = 0; step < maxSteps; step++) { - yield* hooks.emitDecision('agent.step.before', { sessionId: sid, step: step + 1, projectPath }); + const sessionRef: SessionRef = { + cwd: state.cwd, + sessionId: state.sessionId, + parentSessionId: state.parentSessionId, + currentTurnId: state.currentTurnId, + }; + + const mayDrain = deliveryPhase === 'currentTurn' && step > 0; + let drainedCount = 0; + if (mayDrain) { + for (const item of yield* mailbox.drain(state.sessionId)) { + const ev = yield* session.recordSubagentResult(state, item); + yield* context.absorb(sessionRef, [ev]); + drainedCount++; + } + } - if (step === 0) { - emit({ family: 'transition', transition: { to: 'executing' } }); - } + const history = yield* Effect.either(context.getHistory(sessionRef, model)); + if (Either.isLeft(history)) { + yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(history.left) }); + yield* hooks.emit('agent.turn.end', { + sessionId: sid, + turnId: state.currentTurnId, + status: 'error', + projectPath, + }); + return Result.err(history.left); + } - const sessionRef: SessionRef = { - cwd: state.cwd, - sessionId: state.sessionId, - parentSessionId: state.parentSessionId, - currentTurnId: state.currentTurnId, - }; - - const mayDrain = deliveryPhase === 'currentTurn' && step > 0; - let drainedCount = 0; - if (mayDrain) { - for (const item of yield* mailbox.drain(state.sessionId)) { - const ev = yield* session.recordSubagentResult(state, item); - yield* context.absorb(sessionRef, [ev]); - drainedCount++; + const llmMessages = [...history.right]; + + let content = ''; + const toolCalls: ToolCall[] = []; + let responded: ResponseMeta = {}; + + const streamed = yield* Effect.either( + Effect.tryPromise({ + try: async () => { + for await (const part of llm.completeStream( + { messages: llmMessages, system, tools, maxSteps: 1 }, + model, + abortSignal + )) { + if (abortSignal?.aborted) break; + if (part.type === 'text') { + content += part.text; + emit({ family: 'event', event: { type: 'text_delta', text: part.text } }); + } else if (part.type === 'tool_call') { + toolCalls.push({ id: part.id, name: part.name, arguments: part.arguments }); + emit({ + family: 'event', + event: { + type: 'tool_call', + id: part.id, + name: part.name, + args: part.arguments, + }, + }); + } else { + responded = part.usage ? { usage: part.usage } : {}; + emit({ + family: 'transition', + transition: { to: 'executing', responded }, + }); + } + } + }, + catch: (e) => (e instanceof AgentError ? e : new AgentError('LLM_FAILED', String(e))), + }) + ); + 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', + projectPath, + }); + return Result.err(streamed.left); } - } - const history = yield* Effect.either(context.getHistory(sessionRef, model)); - if (Either.isLeft(history)) { - yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(history.left) }); - yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error', projectPath }); - return Result.err(history.left); - } + if (toolCalls.length === 0) { + if (content.trim() === '' && !abortSignal?.aborted) { + const detail = + drainedCount > 0 ? ` after delivering ${drainedCount} subagent result(s)` : ''; + const emptyErr = new AgentError( + 'EMPTY_RESPONSE', + `model returned an empty response${detail}` + ); + yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(emptyErr) }); + yield* hooks.emit('agent.turn.end', { + sessionId: sid, + turnId: state.currentTurnId, + status: 'error', + projectPath, + }); + yield* flushMemoryInBackground(state.sessionId, model, state.cwd); + return Result.err(emptyErr); + } - const llmMessages = [...history.right]; - - let content = ''; - const toolCalls: ToolCall[] = []; - let responded: ResponseMeta = {}; - - const streamed = yield* Effect.either(Effect.tryPromise({ - try: async () => { - for await (const part of llm.completeStream({ messages: llmMessages, system, tools, maxSteps: 1 }, model, abortSignal)) { - if (abortSignal?.aborted) break; - if (part.type === 'text') { - content += part.text; - emit({ family: 'event', event: { type: 'text_delta', text: part.text } }); - } else if (part.type === 'tool_call') { - toolCalls.push({ id: part.id, name: part.name, arguments: part.arguments }); - emit({ - family: 'event', - event: { type: 'tool_call', id: part.id, name: part.name, args: part.arguments }, - }); - } else { - responded = part.usage ? { usage: part.usage } : {}; - emit({ - family: 'transition', - transition: { to: 'executing', responded }, + const assistantEv = yield* session.recordAssistant(state, content, [], responded.usage); + yield* context.absorb(sessionRef, [assistantEv]); + deliveryPhase = 'nextTurn'; + 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', + projectPath, }); + yield* flushMemoryInBackground(state.sessionId, model, state.cwd); + return Result.err(loopErr); } + stopContinuations++; + const injection = stopDecision.injection ?? '(continue)'; + const systemEv = yield* session.recordSystem(state, injection); + yield* context.absorb(sessionRef, [systemEv]); + continue; } - }, - catch: (e) => (e instanceof AgentError ? e : new AgentError('LLM_FAILED', String(e))), - })); - 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', projectPath }); - return Result.err(streamed.left); - } - if (toolCalls.length === 0) { - if (content.trim() === '' && !abortSignal?.aborted) { - const detail = drainedCount > 0 ? ` after delivering ${drainedCount} subagent result(s)` : ''; - const emptyErr = new AgentError('EMPTY_RESPONSE', `model returned an empty response${detail}`); - yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(emptyErr) }); - yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error', projectPath }); - yield* flushMemoryInBackground(state.sessionId, model, state.cwd); - return Result.err(emptyErr); + yield* offerEnd({ to: 'end', reason: 'done' }); + lastResult = Result.ok(content); + yield* hooks.emit('agent.turn.end', { + sessionId: sid, + turnId: state.currentTurnId, + status: 'done', + projectPath, + }); + break; } - const assistantEv = yield* session.recordAssistant(state, content, [], responded.usage); - yield* context.absorb(sessionRef, [assistantEv]); - deliveryPhase = 'nextTurn'; - 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', projectPath }); - yield* flushMemoryInBackground(state.sessionId, model, state.cwd); - return Result.err(loopErr); + const assistantToolEv = yield* session.recordAssistant( + state, + content, + toolCalls, + responded.usage + ); + yield* context.absorb(sessionRef, [assistantToolEv]); + + const approvedCalls: any[] = []; + const deniedResults: any[] = []; + for (const tc of toolCalls) { + const decision = yield* approval.evaluate({ + tool: tc.name, + input: tc.arguments ?? {}, + callId: tc.id, + sessionId: state.sessionId, + projectPath, + permissionMode, + profile: profile?.name, + }); + if (decision.type === 'deny') { + deniedResults.push({ + status: 'denied', + id: tc.id, + name: tc.name, + reason: decision.reason, + }); + } else { + approvedCalls.push(tc); } - stopContinuations++; - const injection = stopDecision.injection ?? '(continue)'; - const systemEv = yield* session.recordSystem(state, injection); - yield* context.absorb(sessionRef, [systemEv]); - continue; } - yield* offerEnd({ to: 'end', reason: 'done' }); - lastResult = Result.ok(content); - yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'done', projectPath }); - break; - } - - const assistantToolEv = yield* session.recordAssistant(state, content, toolCalls, responded.usage); - yield* context.absorb(sessionRef, [assistantToolEv]); - - const approvedCalls: any[] = []; - const deniedResults: any[] = []; - for (const tc of toolCalls) { - const decision = yield* approval.evaluate({ - tool: tc.name, - input: tc.arguments ?? {}, - callId: tc.id, - sessionId: state.sessionId, - projectPath, - permissionMode, - profile: profile?.name, - }); - if (decision.type === 'deny') { - deniedResults.push({ status: 'denied', id: tc.id, name: tc.name, reason: decision.reason }); - } else { - approvedCalls.push(tc); + const approvedResults = + approvedCalls.length > 0 + ? yield* catalog.executeBatch(approvedCalls, state.sessionId, { + turnId: state.currentTurnId, + projectPath, + signal: abortSignal, + activeProfile: profile?.name, + model, + }) + : []; + + const resultsById = new Map(); + for (const r of approvedResults) resultsById.set(r.id, r); + for (const r of deniedResults) resultsById.set(r.id, r); + const allResults = toolCalls.map((tc) => resultsById.get(tc.id)); + + let todoPrinted = false; + for (const r of allResults) { + const resultOut = r.status === 'denied' ? '' : r.output; + const toolEv = yield* session.recordToolResult(state, r.name, r.id, resultOut); + yield* context.absorb(sessionRef, [toolEv]); + const outcome = toolOutcomeOf(r); + const todos = + !todoPrinted && r.status === 'ok' && r.name === 'todo_write' + ? todo.read(sid) + : undefined; + if (todos) todoPrinted = true; + emit({ + family: 'event', + event: { + type: 'tool_result', + id: r.id, + name: r.name, + outcome, + ...(todos ? { todos } : {}), + }, + }); } } - const approvedResults = approvedCalls.length > 0 - ? yield* executor.executeBatch(approvedCalls, state.sessionId, { - turnId: state.currentTurnId, projectPath, signal: abortSignal, toolLookup, - activeProfile: profile?.name, - model, - }) - : []; - - const resultsById = new Map(); - for (const r of approvedResults) resultsById.set(r.id, r); - for (const r of deniedResults) resultsById.set(r.id, r); - const allResults = toolCalls.map((tc) => resultsById.get(tc.id)); - - let todoPrinted = false; - for (const r of allResults) { - const resultOut = r.status === 'denied' ? '' : r.output; - const toolEv = yield* session.recordToolResult(state, r.name, r.id, resultOut); - yield* context.absorb(sessionRef, [toolEv]); - const outcome = toolOutcomeOf(r); - const todos = !todoPrinted && r.status === 'ok' && r.name === 'todo_write' - ? todo.read(sid) - : undefined; - if (todos) todoPrinted = true; - emit({ - family: 'event', - event: { - type: 'tool_result', id: r.id, name: r.name, outcome, - ...(todos ? { todos } : {}), - }, - }); - } - } + yield* checkpoint.snapshotFinal(projectPath, state.sessionId, state.currentTurnId); + yield* flushMemoryInBackground(state.sessionId, model, state.cwd); + + if (lastResult) return lastResult; + + const maxErr = AgentError.maxStepsReached(maxSteps); + yield* offerEnd({ to: 'end', reason: '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', + projectPath: opts.projectPath, + }) + .pipe(Effect.ignore); + }) + ), + Effect.ensuring( + Effect.gen(function* () { + yield* offerEnd({ + to: 'end', + reason: 'error', + error: { message: 'agent terminated without end frame', code: 'AGENT_TERMINATED' }, + }); + yield* checkpoint + .snapshotFinal(opts.projectPath, opts.sid, opts.state.currentTurnId) + .pipe(Effect.ignore); + yield* flushMemoryInBackground(opts.state.sessionId, opts.model, opts.projectPath); + }) + ) + ); + } - yield* checkpoint.snapshotFinal(projectPath, state.sessionId, state.currentTurnId); - yield* flushMemoryInBackground(state.sessionId, model, state.cwd); - - if (lastResult) return lastResult; - - const maxErr = AgentError.maxStepsReached(maxSteps); - yield* offerEnd({ to: 'end', reason: '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', projectPath: opts.projectPath }).pipe(Effect.ignore); - }) - ), - Effect.ensuring( - Effect.gen(function* () { - yield* offerEnd({ - to: 'end', - reason: 'error', - error: { message: 'agent terminated without end frame', code: 'AGENT_TERMINATED' }, - }); - yield* checkpoint.snapshotFinal(opts.projectPath, opts.sid, opts.state.currentTurnId).pipe(Effect.ignore); - yield* flushMemoryInBackground(opts.state.sessionId, opts.model, opts.projectPath); - }) - ) - ); - } - - return { runTurn }; -})); + return { runTurn }; + }) +); diff --git a/packages/codingcode/src/agent/port.ts b/packages/codingcode/src/agent/port.ts index a03b912d..24170650 100644 --- a/packages/codingcode/src/agent/port.ts +++ b/packages/codingcode/src/agent/port.ts @@ -1,8 +1,9 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { FrameBody } from '../contracts/frame.js'; -import type { ProfileName } from '../contracts/types.js'; -import type { PermissionMode } from '../contracts/permission.js'; +import type { FrameBody } from '../sink/types.js'; +import type { ProfileName } from '../session/types.js'; +import type { PermissionMode } from '../session/types.js'; + import type { AgentError } from '../core/error.js'; export interface RunTurnOptions { @@ -37,6 +38,9 @@ export interface ToolEnv { provide(effect: Effect.Effect): Effect.Effect; } -export class ToolEnvPort extends Context.Tag('AgentToolEnvPort'); -}>() {} +export class ToolEnvPort extends Context.Tag('AgentToolEnvPort')< + ToolEnvPort, + { + getToolEnv(): Effect.Effect; + } +>() {} diff --git a/packages/codingcode/src/agent/profile.ts b/packages/codingcode/src/agent/profile.ts index cd8bd094..1064c171 100644 --- a/packages/codingcode/src/agent/profile.ts +++ b/packages/codingcode/src/agent/profile.ts @@ -1,15 +1,10 @@ -import type { ProfileName } from '../contracts/types.js'; +import { PLAN_PROFILE_NAME, BUILD_PROFILE_NAME, type ProfileName } from '../session/types.js'; export interface AgentProfile { name: ProfileName; systemPrompt?: string; } -import { PLAN_ALLOWED_TOOLS } from '../contracts/permission.js'; - -export const PLAN_PROFILE_NAME = 'plan' as const; -export const BUILD_PROFILE_NAME = 'build' as const; - export const BUILD_PROMPT = `You are a coding assistant —an AI agent that helps users with software engineering tasks. ## How you work @@ -115,26 +110,6 @@ export const BUILD_PROFILE: AgentProfile = { systemPrompt: BUILD_PROMPT, }; -export const PLAN_TOOL_NAMES: readonly string[] = [...PLAN_ALLOWED_TOOLS]; - -export const BUILD_TOOL_NAMES: readonly string[] = [ - 'read_file', - 'write_file', - 'edit_file', - 'execute_command', - 'search_code', - 'search_files', - 'fetch_url', - 'web_search', - 'todo_write', - 'spawn_agent', - 'wait_agent', -]; - -export function isPlanProfile(p: { name: string } | null | undefined): boolean { - return p?.name === PLAN_PROFILE_NAME; -} - export function isAgentProfileName(name: string): name is ProfileName { return name === PLAN_PROFILE_NAME || name === BUILD_PROFILE_NAME; } @@ -142,7 +117,3 @@ export function isAgentProfileName(name: string): name is ProfileName { export function resolveProfile(name: ProfileName): AgentProfile { return name === PLAN_PROFILE_NAME ? PLAN_PROFILE : BUILD_PROFILE; } - -export function getToolNames(profile: AgentProfile | undefined): readonly string[] { - return isPlanProfile(profile) ? PLAN_TOOL_NAMES : BUILD_TOOL_NAMES; -} diff --git a/packages/codingcode/src/agent/prompt.ts b/packages/codingcode/src/agent/prompt.ts index 9c86eb58..921879cf 100644 --- a/packages/codingcode/src/agent/prompt.ts +++ b/packages/codingcode/src/agent/prompt.ts @@ -1,6 +1,6 @@ import { BUILD_PROMPT } from './profile.js'; -import type { Skill } from '../contracts/skill.js'; -import { SUBAGENT_RESULT_PREFIX } from '../contracts/session.js'; +import type { Skill } from '../skills/types.js'; +import { SUBAGENT_RESULT_PREFIX } from '../session/types.js'; interface SystemPromptOptions { cwd: string; diff --git a/packages/codingcode/src/agent/tool-env.ts b/packages/codingcode/src/agent/tool-env.ts index 59e0d0b3..c0d92edb 100644 --- a/packages/codingcode/src/agent/tool-env.ts +++ b/packages/codingcode/src/agent/tool-env.ts @@ -8,8 +8,7 @@ export const ToolEnvLayer = Layer.effect( Effect.succeed({ getToolEnv: (): Effect.Effect => Effect.gen(function* () { - - const ctx = yield* (Effect.context() as Effect.Effect>); + const ctx = yield* Effect.context() as Effect.Effect>; const env: ToolEnv = { provide: (effect: Effect.Effect): Effect.Effect => Effect.provide(effect, ctx) as Effect.Effect, diff --git a/packages/codingcode/src/approval/approval.ts b/packages/codingcode/src/approval/approval.ts index b07d04f8..fa3006d5 100644 --- a/packages/codingcode/src/approval/approval.ts +++ b/packages/codingcode/src/approval/approval.ts @@ -1,16 +1,16 @@ import { Layer, Effect } from 'effect'; import { HookService } from '../hooks/port.js'; import type { HookShape } from '../hooks/port.js'; -import type { ApprovalDecision, PermissionMode } from '../contracts/permission.js'; +import type { PermissionMode } from '../session/types.js'; import type { PermissionRule, ToolCallRequest } from './types.js'; -import type { ProfileName } from '../contracts/types.js'; -import { PLAN_ALLOWED_TOOLS } from '../contracts/permission.js'; +import type { ProfileName } from '../session/types.js'; +import { PLAN_ALLOWED_TOOLS } from './tool-policy.js'; import { createRuleEngine, type RuleEngine } from './rule-engine.js'; import { userConfirmAsync } from './confirmation.js'; import { ApprovalWaitService } from './wait-port.js'; import { EventSinkService } from '../sink/port.js'; import { ApprovalService } from './port.js'; -import type { ApprovalRequest } from './port.js'; +import type { ApprovalDecision, ApprovalRequest } from './port.js'; const DANGEROUS_TOOL_NAMES = ['execute_command']; @@ -114,7 +114,13 @@ export function runPipeline( ); if (modeResult) { layers.push(LAYER_NAMES[1]); - const final = yield* recordAuditAndReturn(hooks, request, modeResult, layers, opts.projectPath); + const final = yield* recordAuditAndReturn( + hooks, + request, + modeResult, + layers, + opts.projectPath + ); return final; } } @@ -141,12 +147,24 @@ export function runPipeline( reason: hookResult.reason ?? 'Denied by PreToolUse hook', source: 'hook', }; - const final = yield* recordAuditAndReturn(hooks, request, result, layers, opts.projectPath); + 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, opts.projectPath); + const final = yield* recordAuditAndReturn( + hooks, + request, + result, + layers, + opts.projectPath + ); return final; } const nextRequest: ToolCallRequest = { ...request }; @@ -192,7 +210,9 @@ export function runPipeline( }); } -export const ApprovalLayer = Layer.effect(ApprovalService, Effect.gen(function* () { +export const ApprovalLayer = Layer.effect( + ApprovalService, + Effect.gen(function* () { const hooks = yield* HookService; const sink = yield* EventSinkService; const approvalWait = yield* ApprovalWaitService; @@ -225,4 +245,5 @@ export const ApprovalLayer = Layer.effect(ApprovalService, Effect.gen(function* Effect.provideService(ApprovalWaitService, approvalWait) ), }; -})); + }) +); diff --git a/packages/codingcode/src/approval/confirmation.ts b/packages/codingcode/src/approval/confirmation.ts index b8dc9732..121b1896 100644 --- a/packages/codingcode/src/approval/confirmation.ts +++ b/packages/codingcode/src/approval/confirmation.ts @@ -20,7 +20,10 @@ export function userConfirmAsync( const sink = yield* EventSinkService; const id = callId; - yield* sink.emit(sessionId, { family: 'event', event: { type: 'approval_request', id, tool, args } }); + yield* sink.emit(sessionId, { + family: 'event', + event: { type: 'approval_request', id, tool, args }, + }); return yield* waitSvc.waitForConfirm(id, sessionId); }); diff --git a/packages/codingcode/src/approval/port.ts b/packages/codingcode/src/approval/port.ts index 83f3184a..21a7f67e 100644 --- a/packages/codingcode/src/approval/port.ts +++ b/packages/codingcode/src/approval/port.ts @@ -1,9 +1,15 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { ProfileName } from '../contracts/types.js'; -import type { PermissionMode, ApprovalDecision } from '../contracts/permission.js'; +import type { ProfileName } from '../session/types.js'; +import type { PermissionMode } from '../session/types.js'; import type { ToolCallRequest } from './types.js'; +export * from './tool-policy.js'; + +export type ApprovalDecision = + | { type: 'deny'; reason: string; source: string } + | { type: 'allow'; source: string }; + /** 审批请求:工具调用 + 审批上下文。 */ export interface ApprovalRequest extends ToolCallRequest { sessionId: string; diff --git a/packages/codingcode/src/approval/rule-engine.ts b/packages/codingcode/src/approval/rule-engine.ts index 4509ee25..82a23409 100644 --- a/packages/codingcode/src/approval/rule-engine.ts +++ b/packages/codingcode/src/approval/rule-engine.ts @@ -1,4 +1,4 @@ -import type { ApprovalDecision } from '../contracts/permission.js'; +import type { ApprovalDecision } from './port.js'; import type { PermissionRule, RuleAction } from './types.js'; /** diff --git a/packages/codingcode/src/approval/tool-policy.ts b/packages/codingcode/src/approval/tool-policy.ts new file mode 100644 index 00000000..b6a7f1c8 --- /dev/null +++ b/packages/codingcode/src/approval/tool-policy.ts @@ -0,0 +1,29 @@ +import { isPlanProfile, type ProfileName } from '../session/types.js'; + +export const PLAN_TOOL_NAMES: readonly string[] = [ + 'read_file', + 'search_files', + 'search_code', + 'fetch_url', + 'submit_plan', +]; + +export const BUILD_TOOL_NAMES: readonly string[] = [ + 'read_file', + 'write_file', + 'edit_file', + 'execute_command', + 'search_code', + 'search_files', + 'fetch_url', + 'web_search', + 'todo_write', + 'spawn_agent', + 'wait_agent', +]; + +export const PLAN_ALLOWED_TOOLS: ReadonlySet = new Set(PLAN_TOOL_NAMES); + +export function getToolNames(name: ProfileName | undefined): readonly string[] { + return isPlanProfile(name) ? PLAN_TOOL_NAMES : BUILD_TOOL_NAMES; +} diff --git a/packages/codingcode/src/approval/types.ts b/packages/codingcode/src/approval/types.ts index aed50c04..a071fcba 100644 --- a/packages/codingcode/src/approval/types.ts +++ b/packages/codingcode/src/approval/types.ts @@ -1,4 +1,4 @@ -import { PERMISSION_MODES, type PermissionMode } from '../contracts/permission.js'; +import { PERMISSION_MODES, type PermissionMode } from '../session/types.js'; export function isPermissionMode(value: unknown): value is PermissionMode { return typeof value === 'string' && (PERMISSION_MODES as readonly string[]).includes(value); diff --git a/packages/codingcode/src/approval/wait-port.ts b/packages/codingcode/src/approval/wait-port.ts index cf2e21ff..715eb6a6 100644 --- a/packages/codingcode/src/approval/wait-port.ts +++ b/packages/codingcode/src/approval/wait-port.ts @@ -9,4 +9,7 @@ export interface ApprovalWaitShape { cancelPendingFor(sessionId: string): Effect.Effect; } -export class ApprovalWaitService extends Context.Tag('ApprovalWait')() {} +export class ApprovalWaitService extends Context.Tag('ApprovalWait')< + ApprovalWaitService, + ApprovalWaitShape +>() {} diff --git a/packages/codingcode/src/approval/wait.ts b/packages/codingcode/src/approval/wait.ts index f2845fe6..9dd0be94 100644 --- a/packages/codingcode/src/approval/wait.ts +++ b/packages/codingcode/src/approval/wait.ts @@ -7,7 +7,9 @@ interface PendingEntry { sessionId: string; } -export const ApprovalWaitLayer = Layer.effect(ApprovalWaitService, Effect.sync(() => { +export const ApprovalWaitLayer = Layer.effect( + ApprovalWaitService, + Effect.sync(() => { const pendingConfirmations = new Map(); return { @@ -43,4 +45,5 @@ export const ApprovalWaitLayer = Layer.effect(ApprovalWaitService, Effect.sync(( return cleared; }), }; -})); + }) +); diff --git a/packages/codingcode/src/checkpoint/checkpoint.ts b/packages/codingcode/src/checkpoint/checkpoint.ts index 1a0e50cf..e2967241 100644 --- a/packages/codingcode/src/checkpoint/checkpoint.ts +++ b/packages/codingcode/src/checkpoint/checkpoint.ts @@ -10,7 +10,9 @@ import { CheckpointService } from './port.js'; // ---- Effect Service ---- -export const CheckpointLayer = Layer.effect(CheckpointService, Effect.gen(function* () { +export const CheckpointLayer = Layer.effect( + CheckpointService, + Effect.gen(function* () { const shadowGitByProject = new ProjectCache(10); const lockByProject = new ProjectCache(10); @@ -180,4 +182,5 @@ export const CheckpointLayer = Layer.effect(CheckpointService, Effect.gen(functi return executeRollback(plan, selectedFiles, sg, lockFor(projectPath)); }), }; -})); + }) +); diff --git a/packages/codingcode/src/checkpoint/port.ts b/packages/codingcode/src/checkpoint/port.ts index f712fc5b..04e23db4 100644 --- a/packages/codingcode/src/checkpoint/port.ts +++ b/packages/codingcode/src/checkpoint/port.ts @@ -5,10 +5,30 @@ import type { CheckpointDiff, CodeRollbackResult, RollbackPreviewDiff } from './ export interface CheckpointShape { snapshotBaseline(projectPath: string, sessionId: string, turnId: number): Effect.Effect; snapshotFinal(projectPath: string, sessionId: string, turnId: number): Effect.Effect; - getCheckpointDiff(projectPath: string, sessionId: string, turnId?: number): Effect.Effect; - revertCheckpointFiles(projectPath: string, sessionId: string, turnId: number | undefined, files: string[]): Effect.Effect; - previewRollbackDiff(projectPath: string, sessionId: string, throughTurnId: number): Effect.Effect; - rollbackCodeToTurn(projectPath: string, sessionId: string, throughTurnId: number): Effect.Effect; + getCheckpointDiff( + projectPath: string, + sessionId: string, + turnId?: number + ): Effect.Effect; + revertCheckpointFiles( + projectPath: string, + sessionId: string, + turnId: number | undefined, + files: string[] + ): Effect.Effect; + previewRollbackDiff( + projectPath: string, + sessionId: string, + throughTurnId: number + ): Effect.Effect; + rollbackCodeToTurn( + projectPath: string, + sessionId: string, + throughTurnId: number + ): Effect.Effect; } -export class CheckpointService extends Context.Tag('Checkpoint')() {} +export class CheckpointService extends Context.Tag('Checkpoint')< + CheckpointService, + CheckpointShape +>() {} diff --git a/packages/codingcode/src/checkpoint/project-lock.ts b/packages/codingcode/src/checkpoint/project-lock.ts index a8afa8e2..7ccd6347 100644 --- a/packages/codingcode/src/checkpoint/project-lock.ts +++ b/packages/codingcode/src/checkpoint/project-lock.ts @@ -1,15 +1,13 @@ import { openSync, closeSync, unlinkSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; -import { getGlobalDir, normalizePath, encodeProjectPath } from '../core/path.js'; -import { PROJECTS_DIRNAME } from '../contracts/paths.js'; +import { projectDataDir } from '../core/path.js'; export class ProjectLock { private readonly lockPath: string; private locked = false; constructor(projectPath: string) { - const encoded = encodeProjectPath(normalizePath(projectPath)); - this.lockPath = join(getGlobalDir(), PROJECTS_DIRNAME, encoded, 'checkpoint', 'repo.lock'); + this.lockPath = join(projectDataDir(projectPath), 'checkpoint', 'repo.lock'); } lock(): void { diff --git a/packages/codingcode/src/checkpoint/shadow-git.ts b/packages/codingcode/src/checkpoint/shadow-git.ts index 92f991d9..c07224cd 100644 --- a/packages/codingcode/src/checkpoint/shadow-git.ts +++ b/packages/codingcode/src/checkpoint/shadow-git.ts @@ -1,8 +1,7 @@ import { spawnSync } from 'child_process'; import { existsSync, mkdirSync, statSync, writeFileSync } from 'fs'; import { join } from 'path'; -import { getGlobalDir, normalizePath, encodeProjectPath } from '../core/path.js'; -import { PROJECTS_DIRNAME } from '../contracts/paths.js'; +import { normalizePath, projectDataDir } from '../core/path.js'; const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null'; @@ -29,8 +28,7 @@ export class ShadowGit { constructor(projectPath: string) { this.projectPath = normalizePath(projectPath); - const encoded = encodeProjectPath(this.projectPath); - this.gitDir = join(getGlobalDir(), PROJECTS_DIRNAME, encoded, 'checkpoint', 'repo.git'); + this.gitDir = join(projectDataDir(this.projectPath), 'checkpoint', 'repo.git'); } init(): void { diff --git a/packages/codingcode/src/context/context.ts b/packages/codingcode/src/context/context.ts index 339a31bc..a14e86b7 100644 --- a/packages/codingcode/src/context/context.ts +++ b/packages/codingcode/src/context/context.ts @@ -1,36 +1,29 @@ import { Layer, Effect } from 'effect'; import { randomUUID } from 'crypto'; -import { join } from 'path'; -import { getGlobalDir, encodeProjectPath } from '../core/path.js'; -import { - PROJECTS_DIRNAME, - SESSIONS_DIRNAME, - SUBAGENTS_DIRNAME, - TRANSCRIPT_SUFFIX, -} from '../contracts/paths.js'; import { loadConfig } from '../infra/config.js'; -import type { Message } from '../contracts/types.js'; +import { transcriptPathOf } from '../session/paths.js'; +import type { Message } from '../llm/types.js'; +import type { + SessionEvent, + AssistantEvent, + ToolResultEvent, + CompactEvent, + SummaryEvent, +} from '../session/types.js'; +import type { SessionRef } from '../session/types.js'; import { SessionService } from '../session/port.js'; import { estimateTokens, estimateMessageTokens } from './tokens.js'; import { LLMService } from '../llm/port.js'; import { contextWindowOf } from '../infra/models.js'; import { COMPACTION_SYSTEM_PROMPT } from './compaction-prompt.js'; -import type { SessionEvent, AssistantEvent, ToolResultEvent, CompactEvent, SummaryEvent, SessionRef } from '../contracts/session.js'; + import { AgentError } from '../core/error.js'; import { ContextService } from './port.js'; import type { CompressResult } from './port.js'; import { EventSinkService } from '../sink/port.js'; export function transcriptPathFor(ref: SessionRef): string { - const sessionsDir = join( - getGlobalDir(), - PROJECTS_DIRNAME, - encodeProjectPath(ref.cwd), - SESSIONS_DIRNAME - ); - return ref.parentSessionId - ? join(sessionsDir, ref.parentSessionId, SUBAGENTS_DIRNAME, `${ref.sessionId}${TRANSCRIPT_SUFFIX}`) - : join(sessionsDir, `${ref.sessionId}${TRANSCRIPT_SUFFIX}`); + return transcriptPathOf(ref.cwd, ref.sessionId, ref.parentSessionId); } const COMPACTABLE_TOOLS = new Set([ @@ -207,7 +200,9 @@ interface ContextBuffer { compactedTurnIds: Set; } -export const ContextLayer = Layer.effect(ContextService, Effect.gen(function* () { +export const ContextLayer = Layer.effect( + ContextService, + Effect.gen(function* () { const session = yield* SessionService; const llm = yield* LLMService; const sink = yield* EventSinkService; @@ -273,17 +268,15 @@ export const ContextLayer = Layer.effect(ContextService, Effect.gen(function* () yield* applyOldTurnCompact(buf); }); - const tryCompaction = ( - buf: ContextBuffer, - model: string - ): Effect.Effect => + const tryCompaction = (buf: ContextBuffer, model: string): Effect.Effect => Effect.gen(function* () { const endTurn = buf.turnId - KEEP_RECENT_TURNS - 1; if (endTurn < 1) return 0; const inRange = buf.events.filter((ev) => { if (ev.type === 'session_meta') return false; - if ('turnId' in ev && (ev as any).turnId >= 1 && (ev as any).turnId <= endTurn) return true; + if ('turnId' in ev && (ev as any).turnId >= 1 && (ev as any).turnId <= endTurn) + return true; return false; }); if (inRange.length === 0) return 0; @@ -320,7 +313,12 @@ export const ContextLayer = Layer.effect(ContextService, Effect.gen(function* () // 就地更新:被摘要的 turn 移出可见集,摘要追加到末尾(与重读盘后的顺序一致) buf.events = buf.events.filter( - (ev) => !('turnId' in ev && (ev as any).turnId >= startTurnId && (ev as any).turnId <= endTurnId) + (ev) => + !( + 'turnId' in ev && + (ev as any).turnId >= startTurnId && + (ev as any).turnId <= endTurnId + ) ); buf.events.push(summaryEvent); @@ -394,7 +392,10 @@ export const ContextLayer = Layer.effect(ContextService, Effect.gen(function* () // 压缩判定与压缩帧都归 context,agent 不参与 yield* sink.emit(ref.sessionId, { family: 'transition', transition: { to: 'compress' } }); yield* summarizeToFit(buf, contextWindow, model); - yield* sink.emit(ref.sessionId, { family: 'transition', transition: { to: 'executing' } }); + yield* sink.emit(ref.sessionId, { + family: 'transition', + transition: { to: 'executing' }, + }); } return buildContextMessages(buf.events, buf.compactedTurnIds); }); @@ -435,4 +436,5 @@ export const ContextLayer = Layer.effect(ContextService, Effect.gen(function* () compact, dispose, }; -})); + }) +); diff --git a/packages/codingcode/src/context/port.ts b/packages/codingcode/src/context/port.ts index 4a80a2fa..13a4f8fe 100644 --- a/packages/codingcode/src/context/port.ts +++ b/packages/codingcode/src/context/port.ts @@ -1,7 +1,9 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { Message } from '../contracts/types.js'; -import type { SessionRef, SessionEvent } from '../contracts/session.js'; +import type { Message } from '../llm/types.js'; +import type { SessionRef } from '../session/types.js'; +import type { SessionEvent } from '../session/types.js'; + import type { AgentError } from '../core/error.js'; export interface CompressResult { @@ -16,7 +18,11 @@ export interface ContextShape { /** 把本回合自己写进 transcript 的事件并入内存态(零 IO) */ absorb(ref: SessionRef, events: readonly SessionEvent[]): Effect.Effect; /** 手动压缩入口(HTTP /compact),不受阈值限制 */ - compact(ref: SessionRef, model: string, usage?: number): Effect.Effect; + compact( + ref: SessionRef, + model: string, + usage?: number + ): Effect.Effect; /** 会话删除时丢弃缓存 */ dispose(sessionId: string): Effect.Effect; } diff --git a/packages/codingcode/src/context/tokens.ts b/packages/codingcode/src/context/tokens.ts index bfe729fb..cb8373fe 100644 --- a/packages/codingcode/src/context/tokens.ts +++ b/packages/codingcode/src/context/tokens.ts @@ -1,4 +1,4 @@ -import type { Message } from '../contracts/types.js'; +import type { Message } from '../llm/types.js'; export function estimateMessageTokens(m: Message): number { let tokens = estimateTokensForContent(m.content ?? ''); diff --git a/packages/codingcode/src/contracts/hooks.ts b/packages/codingcode/src/contracts/hooks.ts deleted file mode 100644 index 41aea350..00000000 --- a/packages/codingcode/src/contracts/hooks.ts +++ /dev/null @@ -1,33 +0,0 @@ -export type HookPoint = - | 'tool.execute.before' - | 'tool.execute.after' - | 'tool.execute.error' - | 'tool.approval.pre' - | 'tool.approval.post' - | 'agent.turn.start' - | 'agent.step.before' - | 'agent.turn.stop' - | 'agent.turn.end' - | 'agent.subagent.spawn.before' - | 'agent.subagent.spawn.after' - | 'agent.subagent.complete'; - -export interface HookDecision { - decision?: 'allow' | 'deny' | 'ask' | 'continue'; - reason?: string; - injection?: string; - modifiedInput?: Record; -} - -export interface UserHookConfig { - name: string; - description?: string; - point: HookPoint; - type: 'observer' | 'decision'; - command: string; - args?: string[]; - env?: Record; - priority?: number; - /** 开关:false 表示禁用。缺省(undefined)等同启用 */ - enabled?: boolean; -} diff --git a/packages/codingcode/src/contracts/paths.ts b/packages/codingcode/src/contracts/paths.ts deleted file mode 100644 index c720e48d..00000000 --- a/packages/codingcode/src/contracts/paths.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** `<~/.codingcode>/project` —— 所有工作区的数据根。 */ -export const PROJECTS_DIRNAME = 'project'; - -/** 工作区数据根下存放会话转录的目录名。 */ -export const SESSIONS_DIRNAME = 'sessions'; - -/** 子代理转录所在的子目录名(位于其父会话目录下)。 */ -export const SUBAGENTS_DIRNAME = 'subagents'; - -/** 转录文件后缀。 */ -export const TRANSCRIPT_SUFFIX = '.jsonl'; diff --git a/packages/codingcode/src/contracts/permission.ts b/packages/codingcode/src/contracts/permission.ts deleted file mode 100644 index 990985ef..00000000 --- a/packages/codingcode/src/contracts/permission.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const PERMISSION_MODES = [ - 'askBeforeExec', - 'bypass', -] as const; - -export type PermissionMode = (typeof PERMISSION_MODES)[number]; - -export const PLAN_ALLOWED_TOOLS: ReadonlySet = new Set([ - 'read_file', - 'search_files', - 'search_code', - 'fetch_url', - 'submit_plan', -]); - -export type ApprovalDecision = - | { type: 'deny'; reason: string; source: string } - | { type: 'allow'; source: string }; diff --git a/packages/codingcode/src/contracts/profile.ts b/packages/codingcode/src/contracts/profile.ts deleted file mode 100644 index 066337b5..00000000 --- a/packages/codingcode/src/contracts/profile.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ProfileName } from './types.js'; - -export interface AvailableProfile { - name: ProfileName; - description: string; -} - -export const AVAILABLE_PROFILES: AvailableProfile[] = [ - { name: 'plan', description: 'Planning agent' }, - { name: 'build', description: 'Build agent' }, -]; diff --git a/packages/codingcode/src/contracts/tool.ts b/packages/codingcode/src/contracts/tool.ts deleted file mode 100644 index 582c0e9b..00000000 --- a/packages/codingcode/src/contracts/tool.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Effect } from 'effect'; -import type { AgentError } from '../core/error.js'; -import type { ToolOutcome } from './frame.js'; -import type { ToolDescription, ProfileName } from './types.js'; - -export interface ToolExecCtx { - signal?: AbortSignal; - sessionId?: string; - projectPath?: string; - activeProfile?: ProfileName; - model: string; -} - -export type ToolResult = { readonly id: string; readonly name: string } & ToolOutcome; - -export interface ToolRunner { - readonly name: string; - readonly concurrencySafe: boolean; - parse(args: unknown): unknown; - execute(args: unknown, ctx?: ToolExecCtx): Effect.Effect; -} - -export type ToolLookup = (name: string) => ToolRunner | undefined; - -export interface ToolCatalog { - tools: ToolDescription[]; - lookup: ToolLookup; -} diff --git a/packages/codingcode/src/contracts/types.ts b/packages/codingcode/src/contracts/types.ts deleted file mode 100644 index a99c4585..00000000 --- a/packages/codingcode/src/contracts/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -export type ProfileName = 'plan' | 'build'; - -export interface TokenUsage { - prompt: number; - completion: number; - total: number; -} - -export type TodoStatus = 'pending' | 'in_progress' | 'completed'; - -export interface TodoItem { - step: string; - status: TodoStatus; -} - -export interface ToolDescription { - name: string; - description: string; - parameters: Record; -} - -export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; - -export interface Message { - role: MessageRole; - content: string; - tool_calls?: ToolCall[]; - tool_call_id?: string; - tool_name?: string; - name?: string; - usage?: TokenUsage; -} - -export interface ToolCall { - id: string; - name: string; - arguments: Record; -} diff --git a/packages/codingcode/src/core/path.ts b/packages/codingcode/src/core/path.ts index c81e79b8..6ff0d805 100644 --- a/packages/codingcode/src/core/path.ts +++ b/packages/codingcode/src/core/path.ts @@ -3,10 +3,23 @@ import { join } from 'path'; export const CODINGCODE_DIRNAME = '.codingcode'; +/** 工作区数据的公共根所在目录名。 */ +const PROJECTS_DIRNAME = 'project'; + export function getGlobalDir(): string { return join(homedir(), CODINGCODE_DIRNAME); } +/** 工作区数据的公共根:`/project`。 */ +export function projectRootDir(): string { + return join(getGlobalDir(), PROJECTS_DIRNAME); +} + +/** 某工作区在数据根下的私有目录。 */ +export function projectDataDir(cwd: string): string { + return join(projectRootDir(), encodeProjectPath(normalizePath(cwd))); +} + export function normalizePath(p: string): string { let s = p.replaceAll('\\', '/'); s = s.replace(/^\/([a-zA-Z])\//, (_, letter: string) => `${letter.toLowerCase()}:/`); diff --git a/packages/codingcode/src/hooks/config.ts b/packages/codingcode/src/hooks/config.ts index 643f14a0..97822476 100644 --- a/packages/codingcode/src/hooks/config.ts +++ b/packages/codingcode/src/hooks/config.ts @@ -7,14 +7,17 @@ import { type NamedListFile, } from '../infra/yaml-store.js'; import { getGlobalDir, CODINGCODE_DIRNAME } from '../core/path.js'; -import type { UserHookConfig } from '../contracts/hooks.js'; +import type { UserHookConfig } from './types.js'; const HOOKS_FILE: NamedListFile = { fileName: 'hooks', key: 'hooks' }; type RawHookConfig = Partial & { name: string }; export function loadHookConfigs(projectRoot: string): UserHookConfig[] { - return readNamedList(join(projectRoot, CODINGCODE_DIRNAME), HOOKS_FILE) as UserHookConfig[]; + return readNamedList( + join(projectRoot, CODINGCODE_DIRNAME), + HOOKS_FILE + ) as UserHookConfig[]; } export function writeHookConfigs(projectRoot: string, hooks: UserHookConfig[]): void { diff --git a/packages/codingcode/src/hooks/executor.ts b/packages/codingcode/src/hooks/executor.ts index 8e809ae8..8a2b726e 100644 --- a/packages/codingcode/src/hooks/executor.ts +++ b/packages/codingcode/src/hooks/executor.ts @@ -1,4 +1,5 @@ import { spawn } from 'child_process'; +import type { HookCommandOutput } from './types.js'; /** hook 子进程的超时上限 */ const HOOK_TIMEOUT_MS = 30000; @@ -54,7 +55,7 @@ export async function executeHookCommand( export async function executeDecisionHookCommand( config: HookRunConfig, payload: Record -): Promise | null> { +): Promise { const result = await runHook(config, payload, true).catch(() => null); if (!result || result.code !== 0) return null; try { diff --git a/packages/codingcode/src/hooks/hooks.ts b/packages/codingcode/src/hooks/hooks.ts index 7d2ccba7..aff7a636 100644 --- a/packages/codingcode/src/hooks/hooks.ts +++ b/packages/codingcode/src/hooks/hooks.ts @@ -3,12 +3,33 @@ import { resolveHookConfigs } from './config.js'; import { executeHookCommand, executeDecisionHookCommand } from './executor.js'; import { createLogger } from '../infra/logger.js'; import { HookService } from './port.js'; -import type { HookPoint, HookDecision } from '../contracts/hooks.js'; +import type { HookPoint, HookDecision, HookCommandOutput } from './types.js'; import type { ObserverHandler, DecisionHandler, HandlerEntry, ProjectPath } from './types.js'; const logger = createLogger(); -export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { +const DECISION_VALUES = new Set(['allow', 'deny', 'ask', 'continue']); + +/** 外部进程输出 → 决策语义:只认已知键,其余丢弃。 */ +function toHookDecision(output: HookCommandOutput): HookDecision { + const decision = + typeof output.decision === 'string' && DECISION_VALUES.has(output.decision) + ? (output.decision as HookDecision['decision']) + : undefined; + return { + decision, + reason: typeof output.reason === 'string' ? output.reason : undefined, + injection: typeof output.injection === 'string' ? output.injection : undefined, + modifiedInput: + output.modifiedInput && typeof output.modifiedInput === 'object' + ? (output.modifiedInput as Record) + : undefined, + }; +} + +export const HookLayer = Layer.effect( + HookService, + Effect.gen(function* () { const hooksByProject = new Map>(); /** 某个项目在某个 hook 点上生效的 handler,按 priority 升序 */ @@ -25,7 +46,9 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { 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))) + Effect.catchAll((e) => + Effect.sync(() => logger.error(`hook emit error [${point}]:`, e)) + ) ); } }) as Effect.Effect; @@ -64,7 +87,8 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { }).pipe(Effect.ignore); const decisionHandler: DecisionHandler = async (payload) => { try { - return (await executeDecisionHookCommand(hc, payload)) as HookDecision | null; + const output = await executeDecisionHookCommand(hc, payload); + return output ? toHookDecision(output) : null; } catch (e) { logger.error(`user decision hook ${hookName} error:`, e); return null; @@ -82,4 +106,5 @@ export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { hooksByProject.set(projectPath, projectMap); }), }; -})); + }) +); diff --git a/packages/codingcode/src/hooks/port.ts b/packages/codingcode/src/hooks/port.ts index 4e688d45..011c3001 100644 --- a/packages/codingcode/src/hooks/port.ts +++ b/packages/codingcode/src/hooks/port.ts @@ -1,10 +1,13 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { HookPoint, HookDecision } from '../contracts/hooks.js'; +import type { HookPoint, HookDecision } from './types.js'; export interface HookShape { emit(point: HookPoint, payload: Record): Effect.Effect; - emitDecision(point: HookPoint, payload: Record): Effect.Effect; + emitDecision( + point: HookPoint, + payload: Record + ): Effect.Effect; reloadUserHooks(projectPath: string): Effect.Effect; } diff --git a/packages/codingcode/src/hooks/types.ts b/packages/codingcode/src/hooks/types.ts index 7e507f1b..f4b305b5 100644 --- a/packages/codingcode/src/hooks/types.ts +++ b/packages/codingcode/src/hooks/types.ts @@ -1,5 +1,41 @@ import type { Effect } from 'effect'; -import type { HookDecision } from '../contracts/hooks.js'; + +export type HookPoint = + | 'tool.execute.before' + | 'tool.execute.after' + | 'tool.execute.error' + | 'tool.approval.pre' + | 'tool.approval.post' + | 'agent.turn.start' + | 'agent.step.before' + | 'agent.turn.stop' + | 'agent.turn.end' + | 'agent.subagent.spawn.before' + | 'agent.subagent.spawn.after' + | 'agent.subagent.complete'; + +export interface HookDecision { + decision?: 'allow' | 'deny' | 'ask' | 'continue'; + reason?: string; + injection?: string; + modifiedInput?: Record; +} + +/** hook 子进程 stdout 解析出的原始对象;映射成 HookDecision 收在 hooks.ts 一处。 */ +export type HookCommandOutput = Record; + +export interface UserHookConfig { + name: string; + description?: string; + point: HookPoint; + type: 'observer' | 'decision'; + command: string; + args?: string[]; + env?: Record; + priority?: number; + /** 开关:false 表示禁用。缺省(undefined)等同启用 */ + enabled?: boolean; +} /** 观察者:只被通知,返回值被忽略 */ export type ObserverHandler = (payload: Record) => Effect.Effect; diff --git a/packages/codingcode/src/infra/models.ts b/packages/codingcode/src/infra/models.ts index a7eff021..9750b171 100644 --- a/packages/codingcode/src/infra/models.ts +++ b/packages/codingcode/src/infra/models.ts @@ -1,9 +1,20 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { AgentError } from '../core/error.js'; -import type { SelectableModel } from '../contracts/provider.js'; import { loadConfig, updateActiveModel } from './config.js'; +/** 模型清单里一条可被会话选中的模型。 */ +export interface SelectableModel { + id: string; + provider: string; + driver: string; + name: string; + model: string; + base_url: string; + api_key_env: string; + context_window: number; +} + export interface ModelDescriptor { id: string; name: string; @@ -82,9 +93,7 @@ export function findModel(target: string): SelectableModel | null { export function activeModel(): SelectableModel | null { const cfg = loadConfig().activeModel; if (!cfg) return null; - return ( - listModels().find((m) => m.model === cfg.model && m.api_key_env === cfg.apiKeyEnv) ?? null - ); + return listModels().find((m) => m.model === cfg.model && m.api_key_env === cfg.apiKeyEnv) ?? null; } export function activeModelId(): string { diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts index 4ceb8c3c..3835a579 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -22,7 +22,13 @@ import { SchedulerLayer } from './scheduler/scheduler.js'; // base layers const InfraLayer = Layer.mergeAll( - HookLayer, RulesLayer, SkillLayer, McpLayer, EventSinkLayer, ApprovalWaitLayer, TodoLayer, + HookLayer, + RulesLayer, + SkillLayer, + McpLayer, + EventSinkLayer, + ApprovalWaitLayer, + TodoLayer ); const ApprovalWithDeps = ApprovalLayer.pipe( @@ -38,8 +44,15 @@ const MemoryWithDeps = MemoryLayer.pipe(Layer.provide(LlmLayer)); // agent 直接消费的宽服务集合 const AgentServiceLayers = Layer.mergeAll( - InfraLayer, SessionLayer, MailboxLayer, ToolExecutorWithDeps, ApprovalWithDeps, - ContextWithDeps, MemoryWithDeps, CheckpointLayer, LlmLayer, + InfraLayer, + SessionLayer, + MailboxLayer, + ToolExecutorWithDeps, + ApprovalWithDeps, + ContextWithDeps, + MemoryWithDeps, + CheckpointLayer, + LlmLayer ); // agent with deps @@ -71,7 +84,7 @@ export const AppLayer = Layer.mergeAll( SubagentWithDeps, SubagentRunRegistryWithDeps, SchedulerLayer, - EventSinkLayer, + EventSinkLayer ); export const createAppRuntime = () => ManagedRuntime.make(AppLayer); diff --git a/packages/codingcode/src/llm/llm.ts b/packages/codingcode/src/llm/llm.ts index 1e1b8d30..22fd064b 100644 --- a/packages/codingcode/src/llm/llm.ts +++ b/packages/codingcode/src/llm/llm.ts @@ -1,9 +1,9 @@ import { Effect, Layer } from 'effect'; import { AgentError } from '../core/error.js'; -import type { LLMClient, LLMRequest, LLMResponse, LLMStreamPart, SelectableModel } from '../contracts/provider.js'; +import type { LLMClient, LLMRequest, LLMResponse, LLMStreamPart } from './types.js'; import { OpenAIProvider } from './providers/openai.js'; import { DeepSeekProvider } from './providers/deepseek.js'; -import { activeModel, activeModelError, findModel } from '../infra/models.js'; +import { activeModel, activeModelError, findModel, type SelectableModel } from '../infra/models.js'; import { LLMService } from './port.js'; function entryFor(model: string): Effect.Effect { @@ -80,7 +80,11 @@ async function runOrThrow(eff: Effect.Effect): Promise { } export const LlmLayer = Layer.succeed(LLMService, { - complete(req: LLMRequest, model: string, signal?: AbortSignal): Effect.Effect { + complete( + req: LLMRequest, + model: string, + signal?: AbortSignal + ): Effect.Effect { return Effect.gen(function* () { const entry = yield* entryFor(model); const client = yield* clientFor(entry); @@ -88,7 +92,11 @@ export const LlmLayer = Layer.succeed(LLMService, { }); }, - completeStream(req: LLMRequest, model: string, signal?: AbortSignal): AsyncIterable { + completeStream( + req: LLMRequest, + model: string, + signal?: AbortSignal + ): AsyncIterable { return (async function* () { const entry = await runOrThrow(entryFor(model)); const client = await runOrThrow(clientFor(entry)); diff --git a/packages/codingcode/src/llm/port.ts b/packages/codingcode/src/llm/port.ts index 9120e986..032ab37a 100644 --- a/packages/codingcode/src/llm/port.ts +++ b/packages/codingcode/src/llm/port.ts @@ -1,11 +1,19 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; import type { AgentError } from '../core/error.js'; -import type { LLMRequest, LLMResponse, LLMStreamPart } from '../contracts/provider.js'; +import type { LLMRequest, LLMResponse, LLMStreamPart } from './types.js'; export interface LLMShape { - complete(req: LLMRequest, model: string, signal?: AbortSignal): Effect.Effect; - completeStream(req: LLMRequest, model: string, signal?: AbortSignal): AsyncIterable; + complete( + req: LLMRequest, + model: string, + signal?: AbortSignal + ): Effect.Effect; + completeStream( + req: LLMRequest, + model: string, + signal?: AbortSignal + ): AsyncIterable; } export class LLMService extends Context.Tag('LLM')() {} diff --git a/packages/codingcode/src/llm/providers/deepseek.ts b/packages/codingcode/src/llm/providers/deepseek.ts index 82cba781..607b47fc 100644 --- a/packages/codingcode/src/llm/providers/deepseek.ts +++ b/packages/codingcode/src/llm/providers/deepseek.ts @@ -3,9 +3,9 @@ import type { LanguageModelV3 } from '@ai-sdk/provider'; import { Effect } from 'effect'; import type { AgentError } from '../../core/error.js'; import { mapLlmError } from '../errors.js'; -import type { LLMClient } from '../../contracts/provider.js'; -import type { LLMRequest, LLMResponse, LLMStreamPart } from '../../contracts/provider.js'; -import type { SelectableModel } from '../../contracts/provider.js'; +import type { LLMClient, LLMRequest, LLMResponse, LLMStreamPart } from '../types.js'; +import type { SelectableModel } from '../../infra/models.js'; + import { convertMessages, convertTools, toTokenUsage } from './shared.js'; export class DeepSeekProvider implements LLMClient { diff --git a/packages/codingcode/src/llm/providers/openai.ts b/packages/codingcode/src/llm/providers/openai.ts index 425e901a..ed16c17d 100644 --- a/packages/codingcode/src/llm/providers/openai.ts +++ b/packages/codingcode/src/llm/providers/openai.ts @@ -3,9 +3,9 @@ import type { LanguageModelV3 } from '@ai-sdk/provider'; import { Effect } from 'effect'; import type { AgentError } from '../../core/error.js'; import { mapLlmError } from '../errors.js'; -import type { LLMClient } from '../../contracts/provider.js'; -import type { LLMRequest, LLMResponse, LLMStreamPart } from '../../contracts/provider.js'; -import type { SelectableModel } from '../../contracts/provider.js'; +import type { LLMClient, LLMRequest, LLMResponse, LLMStreamPart } from '../types.js'; +import type { SelectableModel } from '../../infra/models.js'; + import { convertMessages, convertTools, toTokenUsage } from './shared.js'; export class OpenAIProvider implements LLMClient { diff --git a/packages/codingcode/src/llm/providers/shared.ts b/packages/codingcode/src/llm/providers/shared.ts index df63b7c9..834ed889 100644 --- a/packages/codingcode/src/llm/providers/shared.ts +++ b/packages/codingcode/src/llm/providers/shared.ts @@ -1,5 +1,5 @@ import { jsonSchema, type LanguageModelUsage, type ModelMessage } from 'ai'; -import type { TokenUsage } from '../../contracts/types.js'; +import type { TokenUsage } from '../types.js'; export function convertMessages( messages: Array<{ role: string; content: string; tool_calls?: unknown[]; tool_call_id?: string }> diff --git a/packages/codingcode/src/contracts/provider.ts b/packages/codingcode/src/llm/types.ts similarity index 66% rename from packages/codingcode/src/contracts/provider.ts rename to packages/codingcode/src/llm/types.ts index 17707b64..2ec0d70d 100644 --- a/packages/codingcode/src/contracts/provider.ts +++ b/packages/codingcode/src/llm/types.ts @@ -1,7 +1,37 @@ import type { Effect } from 'effect'; -import type { Message, ToolCall, ToolDescription, TokenUsage } from './types.js'; import type { AgentError } from '../core/error.js'; +export interface TokenUsage { + prompt: number; + completion: number; + total: number; +} + +export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; + +export interface ToolCall { + id: string; + name: string; + arguments: Record; +} + +export interface Message { + role: MessageRole; + content: string; + tool_calls?: ToolCall[]; + tool_call_id?: string; + tool_name?: string; + name?: string; + usage?: TokenUsage; +} + +/** 请求里"给模型看的"工具描述。 */ +export interface ToolDescription { + name: string; + description: string; + parameters: Record; +} + export interface LLMRequest { messages: Message[]; system?: string; @@ -40,14 +70,3 @@ export interface LLMClient { completeStream(req: LLMRequest, signal?: AbortSignal): AsyncIterable; readonly modelInfo: ModelInfo; } - -export interface SelectableModel { - id: string; - provider: string; - driver: string; - name: string; - model: string; - base_url: string; - api_key_env: string; - context_window: number; -} diff --git a/packages/codingcode/src/mcp/client.ts b/packages/codingcode/src/mcp/client.ts index c0e3af15..b7f6aa8a 100644 --- a/packages/codingcode/src/mcp/client.ts +++ b/packages/codingcode/src/mcp/client.ts @@ -2,7 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { Effect, TSemaphore, STM } from 'effect'; -import type { McpServerConfig } from '../contracts/mcp.js'; +import type { McpServerConfig } from './types.js'; /** callTool 的失败类型;导出仅为满足 d.ts 生成(模块外无 import) */ export class McpError extends Error { diff --git a/packages/codingcode/src/mcp/config.ts b/packages/codingcode/src/mcp/config.ts index 22109c7f..d6420080 100644 --- a/packages/codingcode/src/mcp/config.ts +++ b/packages/codingcode/src/mcp/config.ts @@ -7,7 +7,7 @@ import { type NamedListFile, } from '../infra/yaml-store.js'; import { getGlobalDir, CODINGCODE_DIRNAME } from '../core/path.js'; -import type { McpServerConfig } from '../contracts/mcp.js'; +import type { McpServerConfig } from './types.js'; /** mcp 的落盘形状:`/.codingcode/mcp.yaml` 的 `servers:` */ const MCP_FILE: NamedListFile = { fileName: 'mcp', key: 'servers' }; @@ -61,5 +61,7 @@ export function setProjectMcpServerEnabled( name: string, enabled: boolean ): void { - patchNamed(join(projectRoot, CODINGCODE_DIRNAME), MCP_FILE, name, { enabled }); + patchNamed(join(projectRoot, CODINGCODE_DIRNAME), MCP_FILE, name, { + enabled, + }); } diff --git a/packages/codingcode/src/mcp/mcp.ts b/packages/codingcode/src/mcp/mcp.ts index 27254441..85b5710c 100644 --- a/packages/codingcode/src/mcp/mcp.ts +++ b/packages/codingcode/src/mcp/mcp.ts @@ -2,7 +2,7 @@ import { Effect, Layer } from 'effect'; import { resolveMcpConfig } from './config.js'; import { McpClient } from './client.js'; import { McpService } from './port.js'; -import type { McpServerConfig, McpToolSpec } from '../contracts/mcp.js'; +import type { McpServerConfig, McpToolSpec } from './types.js'; import { createLogger } from '../infra/logger.js'; import { AgentError } from '../core/error.js'; @@ -23,7 +23,9 @@ interface ServerEntry { type ProjectPath = string; type ServerName = string; -export const McpLayer = Layer.effect(McpService, Effect.sync(() => { +export const McpLayer = Layer.effect( + McpService, + Effect.sync(() => { const clientsByProject = new Map>(); function getProjectClients(projectPath: string): Map { @@ -136,8 +138,8 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { return specs; }), }; - } -)); + }) +); function mcpToolToSpec( serverName: string, diff --git a/packages/codingcode/src/mcp/port.ts b/packages/codingcode/src/mcp/port.ts index 0a1059e4..9820ce93 100644 --- a/packages/codingcode/src/mcp/port.ts +++ b/packages/codingcode/src/mcp/port.ts @@ -1,6 +1,6 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { McpToolSpec } from '../contracts/mcp.js'; +import type { McpToolSpec } from './types.js'; export interface McpShape { syncConnections(projectPath: string): Effect.Effect; diff --git a/packages/codingcode/src/contracts/mcp.ts b/packages/codingcode/src/mcp/types.ts similarity index 100% rename from packages/codingcode/src/contracts/mcp.ts rename to packages/codingcode/src/mcp/types.ts diff --git a/packages/codingcode/src/memory/memory.ts b/packages/codingcode/src/memory/memory.ts index af38f71f..35bfb40c 100644 --- a/packages/codingcode/src/memory/memory.ts +++ b/packages/codingcode/src/memory/memory.ts @@ -1,6 +1,6 @@ import { Layer, Effect } from 'effect'; import { readTranscript } from '../session/file-ops.js'; -import type { SessionEvent } from '../contracts/session.js'; +import type { SessionEvent } from '../session/types.js'; import { readMemoryFile, resolveMemoryPath, @@ -19,7 +19,9 @@ const NOT_WRITTEN = { written: false, bytes: 0 } as const; const logger = createLogger(); -export const MemoryLayer = Layer.effect(MemoryService, Effect.gen(function* () { +export const MemoryLayer = Layer.effect( + MemoryService, + Effect.gen(function* () { const llm = yield* LLMService; let _runtimeEnabled: boolean | null = null; @@ -134,4 +136,5 @@ export const MemoryLayer = Layer.effect(MemoryService, Effect.gen(function* () { return { written: true, bytes: Buffer.byteLength(truncated, 'utf-8') }; }).pipe(Effect.catchAllCause(() => Effect.succeed({ ...NOT_WRITTEN }))), }; -})); + }) +); diff --git a/packages/codingcode/src/memory/port.ts b/packages/codingcode/src/memory/port.ts index e990c188..c66ff599 100644 --- a/packages/codingcode/src/memory/port.ts +++ b/packages/codingcode/src/memory/port.ts @@ -5,7 +5,11 @@ export interface MemoryShape { getMemoryEnabled(): Effect.Effect; setMemoryEnabled(v: boolean): Effect.Effect; loadMemoryForPrompt(cwd: string): Effect.Effect; - flushSessionToMemory(sessionId: string, model: string, sessionCwd: string): Effect.Effect<{ written: boolean; bytes: number }>; + flushSessionToMemory( + sessionId: string, + model: string, + sessionCwd: string + ): Effect.Effect<{ written: boolean; bytes: number }>; } export class MemoryService extends Context.Tag('Memory')() {} diff --git a/packages/codingcode/src/rules/rules.ts b/packages/codingcode/src/rules/rules.ts index efec4dba..54e2f6ad 100644 --- a/packages/codingcode/src/rules/rules.ts +++ b/packages/codingcode/src/rules/rules.ts @@ -14,7 +14,9 @@ function getProjectRulesPath(projectPath?: string): string { return path.join(projectPath ?? process.cwd(), 'AGENTS.md'); } -export const RulesLayer = Layer.effect(RulesService, Effect.sync(() => { +export const RulesLayer = Layer.effect( + RulesService, + Effect.sync(() => { let _globalRules: string | null = null; const _projectRulesCache = new Map(); const _allRulesCache = new Map(); @@ -68,4 +70,5 @@ export const RulesLayer = Layer.effect(RulesService, Effect.sync(() => { _allRulesCache.delete(projectPath); }), }; -})); + }) +); diff --git a/packages/codingcode/src/scheduler/port.ts b/packages/codingcode/src/scheduler/port.ts index 068f1a7b..c16e80de 100644 --- a/packages/codingcode/src/scheduler/port.ts +++ b/packages/codingcode/src/scheduler/port.ts @@ -1,6 +1,10 @@ import { Context } from 'effect'; import type { ManagedRuntime } from 'effect'; -import type { Automation, CreateAutomationInput, UpdateAutomationInput } from '../contracts/automation.js'; +import type { + Automation, + CreateAutomationInput, + UpdateAutomationInput, +} from './types.js'; export interface SchedulerShape { setRuntime(rt: ManagedRuntime.ManagedRuntime): void; @@ -13,4 +17,7 @@ export interface SchedulerShape { stopAll(): void; } -export class SchedulerService extends Context.Tag('Scheduler')() {} +export class SchedulerService extends Context.Tag('Scheduler')< + SchedulerService, + SchedulerShape +>() {} diff --git a/packages/codingcode/src/scheduler/scheduler.ts b/packages/codingcode/src/scheduler/scheduler.ts index 4ae87c8b..ccaeccc9 100644 --- a/packages/codingcode/src/scheduler/scheduler.ts +++ b/packages/codingcode/src/scheduler/scheduler.ts @@ -2,7 +2,11 @@ import { Layer, Effect, ManagedRuntime } from 'effect'; import { CronJob } from 'cron'; import { randomUUID } from 'crypto'; import { createLogger } from '../infra/logger.js'; -import type { Automation, CreateAutomationInput, UpdateAutomationInput } from '../contracts/automation.js'; +import type { + Automation, + CreateAutomationInput, + UpdateAutomationInput, +} from './types.js'; import { readAutomations, writeAutomations } from './store.js'; import { AgentService } from '../agent/port.js'; import { AgentError } from '../core/error.js'; @@ -13,7 +17,9 @@ const logger = createLogger(); const TIMEOUT_MS = 5 * 60 * 1000; -export const SchedulerLayer = Layer.effect(SchedulerService, Effect.sync(() => { +export const SchedulerLayer = Layer.effect( + SchedulerService, + Effect.sync(() => { const jobs = new Map(); let _rt: ManagedRuntime.ManagedRuntime | null = null; @@ -220,4 +226,5 @@ export const SchedulerLayer = Layer.effect(SchedulerService, Effect.sync(() => { jobs.clear(); }, }; -})); + }) +); diff --git a/packages/codingcode/src/scheduler/store.ts b/packages/codingcode/src/scheduler/store.ts index ade1fac7..5da1d64c 100644 --- a/packages/codingcode/src/scheduler/store.ts +++ b/packages/codingcode/src/scheduler/store.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { dirname, join } from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { getGlobalDir } from '../core/path.js'; -import type { Automation } from '../contracts/automation.js'; +import type { Automation } from './types.js'; interface AutomationsFile { automations: Automation[]; diff --git a/packages/codingcode/src/contracts/automation.ts b/packages/codingcode/src/scheduler/types.ts similarity index 92% rename from packages/codingcode/src/contracts/automation.ts rename to packages/codingcode/src/scheduler/types.ts index 83ba0cd8..ef3be549 100644 --- a/packages/codingcode/src/contracts/automation.ts +++ b/packages/codingcode/src/scheduler/types.ts @@ -35,7 +35,3 @@ export interface UpdateAutomationInput { enabled?: boolean; runOnce?: boolean; } - -export interface RunAutomationResult { - sessionId: string; -} diff --git a/packages/codingcode/src/contracts/frame-io.ts b/packages/codingcode/src/server/frame-io.ts similarity index 68% rename from packages/codingcode/src/contracts/frame-io.ts rename to packages/codingcode/src/server/frame-io.ts index 5d6783ba..4788ebd8 100644 --- a/packages/codingcode/src/contracts/frame-io.ts +++ b/packages/codingcode/src/server/frame-io.ts @@ -1,4 +1,13 @@ -import type { Frame, FrameBody } from './frame.js'; +import type { FrameBody } from '../sink/types.js'; + +/** SSE 帧封套:出站帧的唯一序号来源。 */ +export interface Envelope { + readonly sessionId: string; + readonly turnId: number | null; + readonly seq: number; +} + +export type Frame = Envelope & FrameBody; export interface FrameAssembler { stamp(body: FrameBody): Frame; diff --git a/packages/codingcode/src/server/handler.ts b/packages/codingcode/src/server/handler.ts index c6857b98..36b7c63f 100644 --- a/packages/codingcode/src/server/handler.ts +++ b/packages/codingcode/src/server/handler.ts @@ -2,8 +2,8 @@ import type { Context } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { ApprovalWaitService } from '../approval/wait-port.js'; import { AgentError } from '../core/error.js'; -import type { FrameBody } from '../contracts/frame.js'; -import { createFrameAssembler, encodeFrame } from '../contracts/frame-io.js'; +import type { FrameBody } from '../sink/types.js'; +import { createFrameAssembler, encodeFrame } from './frame-io.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; diff --git a/packages/codingcode/src/contracts/error.ts b/packages/codingcode/src/server/http-error.ts similarity index 100% rename from packages/codingcode/src/contracts/error.ts rename to packages/codingcode/src/server/http-error.ts diff --git a/packages/codingcode/src/server/routes/automations.ts b/packages/codingcode/src/server/routes/automations.ts index ec482ae5..2cba62d7 100644 --- a/packages/codingcode/src/server/routes/automations.ts +++ b/packages/codingcode/src/server/routes/automations.ts @@ -2,8 +2,8 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { SchedulerService } from '../../scheduler/port.js'; import { errorBody, errorResponse } from '../util.js'; -import { NotFoundError } from '../../contracts/error.js'; -import type { CreateAutomationInput, UpdateAutomationInput } from '../../contracts/automation.js'; +import { NotFoundError } from '../http-error.js'; +import type { CreateAutomationInput, UpdateAutomationInput } from '../../scheduler/types.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; diff --git a/packages/codingcode/src/server/routes/hooks.ts b/packages/codingcode/src/server/routes/hooks.ts index 3796b9aa..bff19b21 100644 --- a/packages/codingcode/src/server/routes/hooks.ts +++ b/packages/codingcode/src/server/routes/hooks.ts @@ -1,7 +1,7 @@ import type { Hono } from 'hono'; import { isGlobalCwd, resolveCwd } from '../cwd.js'; -import { AlreadyExistsError, NotFoundError } from '../../contracts/error.js'; -import type { UserHookConfig } from '../../contracts/hooks.js'; +import { AlreadyExistsError, NotFoundError } from '../http-error.js'; +import type { UserHookConfig } from '../../hooks/types.js'; import { loadHookConfigs, writeHookConfigs, diff --git a/packages/codingcode/src/server/routes/mcp.ts b/packages/codingcode/src/server/routes/mcp.ts index e6210b41..49bcc346 100644 --- a/packages/codingcode/src/server/routes/mcp.ts +++ b/packages/codingcode/src/server/routes/mcp.ts @@ -1,7 +1,7 @@ import type { Hono } from 'hono'; import { isGlobalCwd, resolveCwd } from '../cwd.js'; -import { AlreadyExistsError, NotFoundError } from '../../contracts/error.js'; -import type { McpServerConfig } from '../../contracts/mcp.js'; +import { AlreadyExistsError, NotFoundError } from '../http-error.js'; +import type { McpServerConfig } from '../../mcp/types.js'; import { loadMcpConfig, writeMcpConfig, diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 1e7e06d7..496fb776 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -2,8 +2,10 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; -import type { SessionStoreState } from '../../contracts/session.js'; -import type { ProfileName } from '../../contracts/types.js'; +import type { SessionStoreState } from '../../session/types.js'; +import type { ProfileName } from '../../session/types.js'; +import type { PermissionMode } from '../../session/types.js'; + import { SessionService } from '../../session/port.js'; import { computePaths } from '../../session/paths.js'; import { ContextService } from '../../context/port.js'; @@ -13,12 +15,10 @@ import { CheckpointService } from '../../checkpoint/port.js'; import { activeModelId, setGlobalActive } from '../../infra/models.js'; import { errorBody, errorResponse } from '../util.js'; import { resolveCwd, resolveWorkspaceCwd } from '../cwd.js'; -import { getGlobalDir, encodeProjectPath } from '../../core/path.js'; -import { PROJECTS_DIRNAME } from '../../contracts/paths.js'; -import { AVAILABLE_PROFILES } from '../../contracts/profile.js'; +import { projectDataDir } from '../../core/path.js'; +import { AVAILABLE_PROFILES } from '../../session/types.js'; import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; -import type { PermissionMode } from '../../contracts/permission.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -60,7 +60,10 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { model: string; }; if (!isAgentProfileName(body.activeProfile)) { - return c.json(errorBody('CONFIG_INVALID', `Invalid activeProfile: ${body.activeProfile}`), 400); + return c.json( + errorBody('CONFIG_INVALID', `Invalid activeProfile: ${body.activeProfile}`), + 400 + ); } if (!isPermissionMode(body.permissionMode)) { return c.json( @@ -173,7 +176,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { // ---- Plan file: read the current plan document for a session ---- router.get('/api/sessions/:id/plan', async (c) => { const cwd = resolveCwd(c.req.query('cwd')); - const planDir = join(getGlobalDir(), PROJECTS_DIRNAME, encodeProjectPath(cwd)); + const planDir = projectDataDir(cwd); if (!existsSync(planDir)) { return c.json({ content: '', diff --git a/packages/codingcode/src/server/routes/skills.ts b/packages/codingcode/src/server/routes/skills.ts index 58db6820..aff6f3b6 100644 --- a/packages/codingcode/src/server/routes/skills.ts +++ b/packages/codingcode/src/server/routes/skills.ts @@ -42,7 +42,9 @@ export function registerSkillsSettingsRoutes(router: Hono, rt: ManagedRt): void return c.json( skills.map((s) => ({ ...s, - source: globalDirPaths.has(dirname(s.skillPath)) ? ('global' as const) : ('project' as const), + source: globalDirPaths.has(dirname(s.skillPath)) + ? ('global' as const) + : ('project' as const), })) ); }); diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index f14a9495..65071ebf 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -14,18 +14,22 @@ import { renameSync, } from 'fs'; import { join, dirname } from 'path'; -import { getGlobalDir } from '../core/path.js'; -import { PROJECTS_DIRNAME, SESSIONS_DIRNAME } from '../contracts/paths.js'; -import { transcriptPathOf } from './paths.js'; -import type { SessionEvent, SessionMetaEvent, SessionSummary } from '../contracts/session.js'; -import type { TokenUsage } from '../contracts/types.js'; +import { getGlobalDir, projectRootDir } from '../core/path.js'; +import { transcriptPathOf, SESSIONS_DIRNAME } from './paths.js'; +import type { SessionEvent, SessionMetaEvent } from './types.js'; +import type { SessionSummary } from './types.js'; +import type { TokenUsage } from '../llm/types.js'; /** 首行最大读取字节数:会话头远小于此值。 */ const HEAD_BYTES = 8192; /** 尾部扫描初始窗口,命中不到时按 4 倍扩大。 */ const TAIL_WINDOW_BYTES = 16 * 1024; -export function sessionJsonlPathFromCwd(cwd: string, sessionId: string, parentSessionId?: string): string { +export function sessionJsonlPathFromCwd( + cwd: string, + sessionId: string, + parentSessionId?: string +): string { return transcriptPathOf(cwd, sessionId, parentSessionId); } @@ -99,7 +103,10 @@ function readWindow(transcriptPath: string, start: number, end: number): Session } /** 从尾部按窗口 4 倍扩大读取,直到 visit 判定可返回或已覆盖整个文件。 */ -function scanTail(transcriptPath: string, visit: (events: SessionEvent[]) => T | undefined): T | undefined { +function scanTail( + transcriptPath: string, + visit: (events: SessionEvent[]) => T | undefined +): T | undefined { const size = statSync(transcriptPath).size; let window = TAIL_WINDOW_BYTES; for (;;) { @@ -144,7 +151,7 @@ export function readLastUsage(transcriptPath: string): TokenUsage | undefined { export function listSessions(encodedProjectPath?: string): SessionSummary[] { const results: SessionSummary[] = []; - const projectBase = join(getGlobalDir(), PROJECTS_DIRNAME); + const projectBase = projectRootDir(); const encodedDirs = encodedProjectPath ? [encodedProjectPath] : existsSync(projectBase) @@ -160,8 +167,7 @@ export function listSessions(encodedProjectPath?: string): SessionSummary[] { let updatedAt = meta.createdAt; try { updatedAt = statSync(jsonlPath).mtime.toISOString(); - } catch { - } + } catch {} results.push({ ...meta, updatedAt, usage: readLastUsage(jsonlPath) }); } } diff --git a/packages/codingcode/src/session/mailbox.ts b/packages/codingcode/src/session/mailbox.ts index a3d44ca4..c3a08198 100644 --- a/packages/codingcode/src/session/mailbox.ts +++ b/packages/codingcode/src/session/mailbox.ts @@ -1,5 +1,5 @@ import { Chunk, Context, Effect, Layer, Queue } from 'effect'; -import type { SubagentResultEvent } from '../contracts/session.js'; +import type { SubagentResultEvent } from './types.js'; /** 入站暂存条目。当前唯一生产者是子代理终态 —— 不预留其它变体 */ export type MailboxItem = SubagentResultEvent; @@ -42,7 +42,10 @@ export const MailboxLayer = Layer.scoped( ? Queue.takeAll(q).pipe(Effect.map(Chunk.toReadonlyArray)) : Effect.succeed>([]); }, - dispose: (sessionId) => Effect.sync(() => { queues.delete(sessionId); }), + dispose: (sessionId) => + Effect.sync(() => { + queues.delete(sessionId); + }), }; }) ); diff --git a/packages/codingcode/src/session/paths.ts b/packages/codingcode/src/session/paths.ts index 7ce36674..cf5ce9cf 100644 --- a/packages/codingcode/src/session/paths.ts +++ b/packages/codingcode/src/session/paths.ts @@ -1,12 +1,11 @@ import { join } from 'path'; -import { getGlobalDir, normalizePath, encodeProjectPath } from '../core/path.js'; -import { - PROJECTS_DIRNAME, - SESSIONS_DIRNAME, - SUBAGENTS_DIRNAME, - TRANSCRIPT_SUFFIX, -} from '../contracts/paths.js'; +import { normalizePath, encodeProjectPath, projectDataDir } from '../core/path.js'; +/** 会话转录在项目数据目录下的布局。 */ +export const SESSIONS_DIRNAME = 'sessions'; + +const SUBAGENTS_DIRNAME = 'subagents'; +const TRANSCRIPT_SUFFIX = '.jsonl'; export interface SessionPaths { sessionId: string; @@ -15,10 +14,6 @@ export interface SessionPaths { transcriptPath: string; } -export function projectDataDir(cwd: string): string { - return join(getGlobalDir(), PROJECTS_DIRNAME, encodeProjectPath(normalizePath(cwd))); -} - export function projectSessionsDir(cwd: string): string { return join(projectDataDir(cwd), SESSIONS_DIRNAME); } diff --git a/packages/codingcode/src/session/port.ts b/packages/codingcode/src/session/port.ts index a33d7232..f65cb3b1 100644 --- a/packages/codingcode/src/session/port.ts +++ b/packages/codingcode/src/session/port.ts @@ -1,13 +1,36 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; import type { AgentError } from '../core/error.js'; -import type { AssistantEvent, RollbackEvent, SessionCreateOptions, SessionEvent, SessionSummary, SessionStoreState, SubagentResultEvent, SummaryEvent, ToolResultEvent, UITurn, UserEvent } from '../contracts/session.js'; -import type { TokenUsage, ProfileName } from '../contracts/types.js'; -import type { PermissionMode } from '../contracts/permission.js'; +import type { + AssistantEvent, + RollbackEvent, + SessionEvent, + SubagentResultEvent, + SummaryEvent, + ToolResultEvent, + UserEvent, +} from './types.js'; +import type { + SessionCreateOptions, + SessionSummary, + SessionStoreState, +} from './types.js'; +import type { UITurn } from './types.js'; +import type { TokenUsage } from '../llm/types.js'; +import type { ProfileName } from './types.js'; +import type { PermissionMode } from './types.js'; export interface SessionShape { - create(cwd: string, options: SessionCreateOptions, opts?: { parentSessionId?: string; agentName?: string }): Effect.Effect; - load(cwd: string, sessionId: string, parentSessionId?: string): Effect.Effect; + create( + cwd: string, + options: SessionCreateOptions, + opts?: { parentSessionId?: string; agentName?: string } + ): Effect.Effect; + load( + cwd: string, + sessionId: string, + parentSessionId?: string + ): Effect.Effect; deleteSession(sessionId: string, cwd: string): Effect.Effect; forkSession(state: SessionStoreState, atTurnId: number): Effect.Effect; renameSession(state: SessionStoreState, text: string): Effect.Effect; @@ -15,17 +38,54 @@ export interface SessionShape { readHistory(state: SessionStoreState): Effect.Effect; recordUser(state: SessionStoreState, content: string): Effect.Effect; recordSystem(state: SessionStoreState, content: string): Effect.Effect; - recordAssistant(state: SessionStoreState, content: string, toolCalls: AssistantEvent['toolCalls'], usage?: TokenUsage): Effect.Effect; - recordToolResult(state: SessionStoreState, toolName: string, toolCallId: string, output: string): Effect.Effect; - recordSubagentResult(state: SessionStoreState, result: { sessionId: string; agentName: string; content: string }): Effect.Effect; - appendSummary(state: SessionStoreState, summaryText: string, startTurnId: number, endTurnId: number): Effect.Effect; - rollbackToTurn(state: SessionStoreState, throughTurnId: number, reason: string): Effect.Effect; + recordAssistant( + state: SessionStoreState, + content: string, + toolCalls: AssistantEvent['toolCalls'], + usage?: TokenUsage + ): Effect.Effect; + recordToolResult( + state: SessionStoreState, + toolName: string, + toolCallId: string, + output: string + ): Effect.Effect; + recordSubagentResult( + state: SessionStoreState, + result: { sessionId: string; agentName: string; content: string } + ): Effect.Effect; + appendSummary( + state: SessionStoreState, + summaryText: string, + startTurnId: number, + endTurnId: number + ): Effect.Effect; + rollbackToTurn( + state: SessionStoreState, + throughTurnId: number, + reason: string + ): Effect.Effect; readEvents(transcriptPath: string): Effect.Effect; appendEvent(transcriptPath: string, event: SessionEvent): Effect.Effect; readUITurns(sessionId: string, cwd: string): Effect.Effect; - setPermissionMode(cwd: string, sessionId: string, mode: PermissionMode, parentSessionId?: string): Effect.Effect; - setActiveProfile(cwd: string, sessionId: string, profile: ProfileName, parentSessionId?: string): Effect.Effect; - setModel(cwd: string, sessionId: string, model: string, parentSessionId?: string): Effect.Effect; + setPermissionMode( + cwd: string, + sessionId: string, + mode: PermissionMode, + parentSessionId?: string + ): Effect.Effect; + setActiveProfile( + cwd: string, + sessionId: string, + profile: ProfileName, + parentSessionId?: string + ): Effect.Effect; + setModel( + cwd: string, + sessionId: string, + model: string, + parentSessionId?: string + ): Effect.Effect; } export class SessionService extends Context.Tag('Session')() {} diff --git a/packages/codingcode/src/session/session.ts b/packages/codingcode/src/session/session.ts index 6cfd58dd..778995da 100644 --- a/packages/codingcode/src/session/session.ts +++ b/packages/codingcode/src/session/session.ts @@ -5,9 +5,23 @@ import { join, dirname } from 'path'; import { AgentError } from '../core/error.js'; import { encodeProjectPath } from '../core/path.js'; import { computePaths } from './paths.js'; -import type { SessionMetaEvent, UserEvent, AssistantEvent, ToolResultEvent, SubagentResultEvent, SummaryEvent, RollbackEvent, SessionEvent, SessionStoreState, SessionSummary, CompactEvent, UITurn } from '../contracts/session.js'; -import type { TokenUsage, ProfileName } from '../contracts/types.js'; -import type { PermissionMode } from '../contracts/permission.js'; +import type { + SessionMetaEvent, + UserEvent, + AssistantEvent, + ToolResultEvent, + SubagentResultEvent, + SummaryEvent, + RollbackEvent, + SessionEvent, + CompactEvent, +} from './types.js'; +import type { SessionStoreState, SessionSummary } from './types.js'; +import type { UITurn } from './types.js'; +import type { TokenUsage } from '../llm/types.js'; +import type { ProfileName } from './types.js'; +import type { PermissionMode } from './types.js'; + import { SessionService } from './port.js'; import { ensureDirs, @@ -79,7 +93,7 @@ export function sessionEventsToTurns(events: SessionEvent[]): UITurn[] { for (const event of events) { if (event.type === 'session_meta') continue; if (event.type === 'compact' || event.type === 'rollback') continue; - if (event.type === 'subagent_result') continue; // 结果进模型上下文,不占用户视野 + if (event.type === 'subagent_result') continue; // 结果进模型上下文,不占用户视野 if (event.type === 'summary') { let turn = turnsMap.get(event.endTurnId); @@ -195,7 +209,11 @@ export const SessionLayer = Layer.effect( : new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e), }); - const load = (cwd: string, sessionId: string, parentSessionId?: string): Effect.Effect => + const load = ( + cwd: string, + sessionId: string, + parentSessionId?: string + ): Effect.Effect => Effect.try({ try: () => { assertResumeWorkspace(cwd, sessionId, parentSessionId); @@ -469,10 +487,7 @@ export const SessionLayer = Layer.effect( catch: (e) => new AgentError('SESSION_IO_ERROR', `Failed to read transcript ${transcriptPath}`, e), }), - appendEvent: ( - transcriptPath: string, - event: SessionEvent - ): Effect.Effect => + appendEvent: (transcriptPath: string, event: SessionEvent): Effect.Effect => Effect.try({ try: () => appendLine(transcriptPath, event), catch: (e) => diff --git a/packages/codingcode/src/contracts/session.ts b/packages/codingcode/src/session/types.ts similarity index 77% rename from packages/codingcode/src/contracts/session.ts rename to packages/codingcode/src/session/types.ts index bf9ef0b9..b08d55ca 100644 --- a/packages/codingcode/src/contracts/session.ts +++ b/packages/codingcode/src/session/types.ts @@ -1,5 +1,29 @@ -import type { ProfileName, TokenUsage, ToolCall } from './types.js'; -import type { PermissionMode } from './permission.js'; +import type { TokenUsage, ToolCall } from '../llm/types.js'; + +export const PLAN_PROFILE_NAME = 'plan' as const; +export const BUILD_PROFILE_NAME = 'build' as const; + +export const PROFILE_NAMES = [PLAN_PROFILE_NAME, BUILD_PROFILE_NAME] as const; + +export type ProfileName = (typeof PROFILE_NAMES)[number]; + +export function isPlanProfile(name: string | null | undefined): boolean { + return name === PLAN_PROFILE_NAME; +} + +export interface AvailableProfile { + name: ProfileName; + description: string; +} + +export const AVAILABLE_PROFILES: AvailableProfile[] = [ + { name: PLAN_PROFILE_NAME, description: 'Planning agent' }, + { name: BUILD_PROFILE_NAME, description: 'Build agent' }, +]; + +export const PERMISSION_MODES = ['askBeforeExec', 'bypass'] as const; + +export type PermissionMode = (typeof PERMISSION_MODES)[number]; export interface SessionMetaEvent { type: 'session_meta'; @@ -88,6 +112,20 @@ export interface SessionStoreState extends SessionMetaEvent { usage: TokenUsage | undefined; } +export interface SessionCreateOptions { + model: string; + title?: string; + activeProfile: ProfileName; + permissionMode: PermissionMode; +} + +export interface SessionRef { + cwd: string; + sessionId: string; + parentSessionId?: string; + currentTurnId: number; +} + export type UITurnItem = | { id: string; type: 'message'; role: 'user' | 'assistant'; content: string; partial?: boolean } | { @@ -124,17 +162,3 @@ export interface UITurn { items: UITurnItem[]; status: 'running' | 'completed' | 'error'; } - -export interface SessionCreateOptions { - model: string; - title?: string; - activeProfile: ProfileName; - permissionMode: PermissionMode; -} - -export interface SessionRef { - cwd: string; - sessionId: string; - parentSessionId?: string; - currentTurnId: number; -} diff --git a/packages/codingcode/src/sink/port.ts b/packages/codingcode/src/sink/port.ts index 7cb235a3..972f6eb0 100644 --- a/packages/codingcode/src/sink/port.ts +++ b/packages/codingcode/src/sink/port.ts @@ -1,6 +1,6 @@ import { Context } from 'effect'; import type { Effect, Queue } from 'effect'; -import type { FrameBody } from '../contracts/frame.js'; +import type { FrameBody } from './types.js'; export interface EventSinkShape { /** 建队列并挂载为该会话的出站队列;调用者即这条帧流的唯一读者。覆盖式:每次调用都换新队列 */ @@ -11,4 +11,7 @@ export interface EventSinkShape { emit(sessionId: string, body: FrameBody): Effect.Effect; } -export class EventSinkService extends Context.Tag('EventSink')() {} +export class EventSinkService extends Context.Tag('EventSink')< + EventSinkService, + EventSinkShape +>() {} diff --git a/packages/codingcode/src/sink/sink.ts b/packages/codingcode/src/sink/sink.ts index c6afcffd..2d92ba8c 100644 --- a/packages/codingcode/src/sink/sink.ts +++ b/packages/codingcode/src/sink/sink.ts @@ -1,5 +1,5 @@ import { Effect, Layer, Queue } from 'effect'; -import type { FrameBody } from '../contracts/frame.js'; +import type { FrameBody } from './types.js'; import { EventSinkService } from './port.js'; export const EventSinkLayer = Layer.effect( diff --git a/packages/codingcode/src/contracts/frame.ts b/packages/codingcode/src/sink/types.ts similarity index 87% rename from packages/codingcode/src/contracts/frame.ts rename to packages/codingcode/src/sink/types.ts index 386cbcb0..c14e4c04 100644 --- a/packages/codingcode/src/contracts/frame.ts +++ b/packages/codingcode/src/sink/types.ts @@ -1,6 +1,5 @@ -import type { TodoItem, TokenUsage } from './types.js'; - -export type EndReason = 'done' | 'error' | 'maxSteps' | 'aborted'; +import type { TodoItem } from '../todo/types.js'; +import type { TokenUsage } from '../llm/types.js'; export interface FrameError { readonly message: string; @@ -11,12 +10,6 @@ export interface ResponseMeta { readonly usage?: TokenUsage; } -export interface Envelope { - readonly sessionId: string; - readonly turnId: number | null; - readonly seq: number; -} - export type Transition = | { readonly to: 'start'; readonly turnId: number } | { @@ -53,13 +46,13 @@ export type RuntimeEvent = readonly id: string; readonly tool: string; readonly args: Readonly>; - } + } | { readonly type: 'subagent_event'; readonly sessionId: string; readonly agentName: string; readonly status: 'spawned' | 'completed' | 'failed'; - }; + }; export interface Fatal { readonly message: string; @@ -71,7 +64,6 @@ export type FrameBody = | { readonly family: 'event'; readonly event: RuntimeEvent } | { readonly family: 'fatal'; readonly fatal: Fatal }; -export type Frame = Envelope & FrameBody; export type EndTransition = Extract; export function isTurnEnd( diff --git a/packages/codingcode/src/skills/loader.ts b/packages/codingcode/src/skills/loader.ts index f4dc3199..6c142b44 100644 --- a/packages/codingcode/src/skills/loader.ts +++ b/packages/codingcode/src/skills/loader.ts @@ -1,5 +1,5 @@ import { basename, resolve } from 'path'; -import type { Skill } from '../contracts/skill.js'; +import type { Skill } from './types.js'; import { readSkillFrontMatter } from './source.js'; export function loadSkill(dirPath: string): Skill | null { diff --git a/packages/codingcode/src/skills/port.ts b/packages/codingcode/src/skills/port.ts index c41a0b25..ec6d9b0e 100644 --- a/packages/codingcode/src/skills/port.ts +++ b/packages/codingcode/src/skills/port.ts @@ -1,6 +1,6 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { Skill } from '../contracts/skill.js'; +import type { Skill } from './types.js'; export interface SkillShape { getAll(projectPath: string): Effect.Effect; diff --git a/packages/codingcode/src/skills/skills.ts b/packages/codingcode/src/skills/skills.ts index 69803879..2eb570fa 100644 --- a/packages/codingcode/src/skills/skills.ts +++ b/packages/codingcode/src/skills/skills.ts @@ -1,10 +1,12 @@ import { Layer, Effect } from 'effect'; import { discoverSkillDirs, readSkillBody } from './source.js'; import { loadSkill } from './loader.js'; -import type { Skill } from '../contracts/skill.js'; +import type { Skill } from './types.js'; import { SkillService } from './port.js'; -export const SkillLayer = Layer.effect(SkillService, Effect.gen(function* () { +export const SkillLayer = Layer.effect( + SkillService, + Effect.gen(function* () { const cachedByProject = new Map(); function readAll(projectPath: string): Skill[] { @@ -25,4 +27,5 @@ export const SkillLayer = Layer.effect(SkillService, Effect.gen(function* () { readContent: (skillPath: string) => Effect.sync(() => readSkillBody(skillPath)), }; -})); + }) +); diff --git a/packages/codingcode/src/contracts/skill.ts b/packages/codingcode/src/skills/types.ts similarity index 100% rename from packages/codingcode/src/contracts/skill.ts rename to packages/codingcode/src/skills/types.ts diff --git a/packages/codingcode/src/subagent/port.ts b/packages/codingcode/src/subagent/port.ts index a5895527..3e9ab957 100644 --- a/packages/codingcode/src/subagent/port.ts +++ b/packages/codingcode/src/subagent/port.ts @@ -1,6 +1,6 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { FrameBody } from '../contracts/frame.js'; +import type { FrameBody } from '../sink/types.js'; import type { AgentError } from '../core/error.js'; import type { Result } from '../core/result.js'; @@ -8,8 +8,8 @@ export interface RunSubagentOptions { sessionId?: string; cwd: string; signal?: AbortSignal; - activeProfile?: import('../contracts/types.js').ProfileName; - permissionMode?: import('../contracts/permission.js').PermissionMode; + activeProfile?: import('../session/types.js').ProfileName; + permissionMode?: import('../session/types.js').PermissionMode; model: string; systemPrompt?: string; parentSessionId?: string; @@ -17,7 +17,10 @@ export interface RunSubagentOptions { } export interface SubagentRunnerShape { - runSubagent(input: string, opts: RunSubagentOptions): Effect.Effect< + runSubagent( + input: string, + opts: RunSubagentOptions + ): Effect.Effect< { stream: AsyncGenerator, unknown>; sessionId: string; @@ -27,4 +30,7 @@ export interface SubagentRunnerShape { >; } -export class SubagentRunnerService extends Context.Tag('SubagentRunner')() {} +export class SubagentRunnerService extends Context.Tag('SubagentRunner')< + SubagentRunnerService, + SubagentRunnerShape +>() {} diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts index 47d75bfd..8a7e5aa2 100644 --- a/packages/codingcode/src/subagent/registry.ts +++ b/packages/codingcode/src/subagent/registry.ts @@ -1,11 +1,12 @@ import { Context, Effect, Fiber, Layer, Option, Stream, SubscriptionRef } from 'effect'; import { AgentError } from '../core/error.js'; -import { isTurnEnd } from '../contracts/frame.js'; -import type { EndTransition, FrameBody } from '../contracts/frame.js'; -import type { ProfileName } from '../contracts/types.js'; +import { isTurnEnd } from '../sink/types.js'; +import type { EndTransition, FrameBody } from '../sink/types.js'; +import type { ProfileName } from '../session/types.js'; +import { SUBAGENT_RESULT_PREFIX } from '../session/types.js'; import { estimateTokensForContent } from '../context/tokens.js'; import { loadConfig } from '../infra/config.js'; -import { SUBAGENT_RESULT_PREFIX } from '../contracts/session.js'; + import { MailboxService } from '../session/mailbox.js'; import { EventSinkService } from '../sink/port.js'; import { HookService } from '../hooks/port.js'; @@ -70,9 +71,10 @@ function truncateToTokens(text: string, budget: number): string { } function renderResult(run: SubagentRun, outcome: { end: EndTransition; content: string }): string { - const body = outcome.end.reason === 'done' - ? outcome.content - : `${outcome.content}\nThe subagent did not finish. Spawn it again if the task is still needed.`; + const body = + outcome.end.reason === 'done' + ? outcome.content + : `${outcome.content}\nThe subagent did not finish. Spawn it again if the task is still needed.`; return [ SUBAGENT_RESULT_PREFIX, `Task name: ${run.parentSessionId}`, @@ -83,8 +85,11 @@ function renderResult(run: SubagentRun, outcome: { end: EndTransition; content: } /** 非 done 的终态统一归一成一条 error 帧,形状仍是帧契约的 end */ -const failedEnd = (message: string): EndTransition => - ({ to: 'end', reason: 'error', error: { message, code: 'SUBAGENT_FAILED' } }); +const failedEnd = (message: string): EndTransition => ({ + to: 'end', + reason: 'error', + error: { message, code: 'SUBAGENT_FAILED' }, +}); /** 帧流的唯一读者:攒 text_delta 取最终输出,认 isTurnEnd 拿终态,其余帧丢弃 */ const consume = (stream: AsyncGenerator) => @@ -98,18 +103,21 @@ const consume = (stream: AsyncGenerator) => } if (isTurnEnd(body)) { const end = body.transition; - if (end.reason === 'done') return { end, content: content || '(subagent completed without output)' }; + if (end.reason === 'done') + return { end, content: content || '(subagent completed without output)' }; if (end.reason === 'error') return { end, content: end.error.message }; - const message = end.reason === 'maxSteps' - ? 'subagent exhausted its step budget before finishing' - : 'subagent was aborted'; + const message = + end.reason === 'maxSteps' + ? 'subagent exhausted its step budget before finishing' + : 'subagent was aborted'; return { end: failedEnd(message), content: message }; } } const message = 'subagent stream ended without a terminal frame'; return { end: failedEnd(message), content: message }; }, - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', e instanceof Error ? e.message : String(e)), + catch: (e) => + new AgentError('TOOL_EXECUTION_FAILED', e instanceof Error ? e.message : String(e)), }); export const SubagentRunRegistryLayer = Layer.scoped( @@ -119,16 +127,19 @@ export const SubagentRunRegistryLayer = Layer.scoped( const runner = yield* SubagentRunnerService; const sink = yield* EventSinkService; const hooks = yield* HookService; - const runs = new Map(); // 键 = 子会话 sessionId;parentSessionId 只是条目上的字段 + const runs = new Map(); // 键 = 子会话 sessionId;parentSessionId 只是条目上的字段 /** 帧直投父会话的出站队列:EventSink 的键就是收件人会话,不需要任何回调透传 */ const emitSubagent = ( - parentSessionId: string, sessionId: string, agentName: string, - status: 'spawned' | 'completed' | 'failed', - ) => sink.emit(parentSessionId, { - family: 'event', - event: { type: 'subagent_event', sessionId, agentName, status }, - }); + parentSessionId: string, + sessionId: string, + agentName: string, + status: 'spawned' | 'completed' | 'failed' + ) => + sink.emit(parentSessionId, { + family: 'event', + event: { type: 'subagent_event', sessionId, agentName, status }, + }); /** 同步读当前值:SubscriptionRef 的 get 随时可读、读不走 */ const currentStatus = (run: SubagentRun): SubagentRunStatus => @@ -146,21 +157,26 @@ export const SubagentRunRegistryLayer = Layer.scoped( const drainRun = (run: SubagentRun, stream: AsyncGenerator) => Effect.gen(function* () { const settled = yield* Effect.either(consume(stream)); - const outcome = settled._tag === 'Right' - ? settled.right - : { end: failedEnd(settled.left.message), content: settled.left.message }; + const outcome = + settled._tag === 'Right' + ? settled.right + : { end: failedEnd(settled.left.message), content: settled.left.message }; - yield* mailbox.offer(run.parentSessionId, { - type: 'subagent_result', - sessionId: run.sessionId, - agentName: run.agentName, - content: renderResult(run, outcome), - }).pipe(Effect.ignore); + yield* mailbox + .offer(run.parentSessionId, { + type: 'subagent_result', + sessionId: run.sessionId, + agentName: run.agentName, + content: renderResult(run, outcome), + }) + .pipe(Effect.ignore); yield* SubscriptionRef.set(run.status, { kind: 'ended', end: outcome.end }); yield* emitSubagent( - run.parentSessionId, run.sessionId, run.agentName, - outcome.end.reason === 'done' ? 'completed' : 'failed', + run.parentSessionId, + run.sessionId, + run.agentName, + outcome.end.reason === 'done' ? 'completed' : 'failed' ); yield* hooks.emit('agent.subagent.complete', { projectPath: run.parentCwd, @@ -181,7 +197,7 @@ export const SubagentRunRegistryLayer = Layer.scoped( const abort = new AbortController(); const { stream, sessionId } = yield* runner.runSubagent(opts.prompt, { cwd: opts.parentCwd, - signal: abort.signal, // 子代理自己的 signal,与父回合无关 + signal: abort.signal, // 子代理自己的 signal,与父回合无关 activeProfile: opts.parentProfile, permissionMode: 'bypass', parentSessionId: opts.parentSessionId, @@ -192,8 +208,12 @@ export const SubagentRunRegistryLayer = Layer.scoped( const status = yield* SubscriptionRef.make({ kind: 'running' }); const run: SubagentRun = { - sessionId, parentSessionId: opts.parentSessionId, parentCwd: opts.parentCwd, - agentName: opts.agentName, status, abort, + sessionId, + parentSessionId: opts.parentSessionId, + parentCwd: opts.parentCwd, + agentName: opts.agentName, + status, + abort, }; runs.set(sessionId, run); yield* emitSubagent(opts.parentSessionId, sessionId, opts.agentName, 'spawned'); @@ -206,14 +226,19 @@ export const SubagentRunRegistryLayer = Layer.scoped( Effect.gen(function* () { const run = runs.get(sessionId); if (!run) { - return yield* Effect.fail(new AgentError('TOOL_EXECUTION_FAILED', `Unknown subagent: ${sessionId}`)); + return yield* Effect.fail( + new AgentError('TOOL_EXECUTION_FAILED', `Unknown subagent: ${sessionId}`) + ); } // 等状态通道的后续值;changes 的首次发射即当前值 ⇒ 已终态的 run 立即返回 const settled = run.status.changes.pipe( - Stream.filterMap((s) => s.kind === 'ended' ? Option.some(s.end) : Option.none()), + Stream.filterMap((s) => + s.kind === 'ended' ? Option.some(s.end) : Option.none() + ), Stream.runHead, - Effect.map((opt): WaitOutcome => - Option.isNone(opt) ? 'timeout' : (opt.value.reason === 'done' ? 'completed' : 'failed') + Effect.map( + (opt): WaitOutcome => + Option.isNone(opt) ? 'timeout' : opt.value.reason === 'done' ? 'completed' : 'failed' ) ); @@ -228,7 +253,7 @@ export const SubagentRunRegistryLayer = Layer.scoped( let stopped = 0; for (const run of runs.values()) { if (run.parentSessionId !== parentSessionId) continue; - if (run.abort.signal.aborted) continue; // 已请求过停止,不重复计数 + if (run.abort.signal.aborted) continue; // 已请求过停止,不重复计数 if (currentStatus(run).kind !== 'running') continue; run.abort.abort(); stopped++; diff --git a/packages/codingcode/src/subagent/subagent.ts b/packages/codingcode/src/subagent/subagent.ts index 1150c490..4d238391 100644 --- a/packages/codingcode/src/subagent/subagent.ts +++ b/packages/codingcode/src/subagent/subagent.ts @@ -2,7 +2,7 @@ import { Layer, Effect } from 'effect'; import { SubagentRunnerService } from './port.js'; import type { RunSubagentOptions } from './port.js'; import { AgentService } from '../agent/port.js'; -import type { FrameBody } from '../contracts/frame.js'; +import type { FrameBody } from '../sink/types.js'; import type { Result } from '../core/result.js'; export const SubagentRunnerLayer = Layer.effect( diff --git a/packages/codingcode/src/todo/port.ts b/packages/codingcode/src/todo/port.ts index c05a9d00..5b0540a7 100644 --- a/packages/codingcode/src/todo/port.ts +++ b/packages/codingcode/src/todo/port.ts @@ -1,5 +1,5 @@ import { Context } from 'effect'; -import type { TodoItem } from '../contracts/types.js'; +import type { TodoItem } from './types.js'; export interface TodoCounts { pending: number; diff --git a/packages/codingcode/src/todo/todo.ts b/packages/codingcode/src/todo/todo.ts index e1e8836b..3cb2b0c2 100644 --- a/packages/codingcode/src/todo/todo.ts +++ b/packages/codingcode/src/todo/todo.ts @@ -1,12 +1,19 @@ import { Layer, Effect } from 'effect'; import { TodoService } from './port.js'; -import type { TodoItem } from '../contracts/types.js'; +import type { TodoItem } from './types.js'; -export const TodoLayer = Layer.effect(TodoService, Effect.sync(() => { - const store = new Map(); - return { - read: (sessionId: string) => store.get(sessionId) ?? [], - write: (sessionId: string, plan: TodoItem[]) => { store.set(sessionId, plan); }, - reset: () => { store.clear(); }, - }; -})); +export const TodoLayer = Layer.effect( + TodoService, + Effect.sync(() => { + const store = new Map(); + return { + read: (sessionId: string) => store.get(sessionId) ?? [], + write: (sessionId: string, plan: TodoItem[]) => { + store.set(sessionId, plan); + }, + reset: () => { + store.clear(); + }, + }; + }) +); diff --git a/packages/codingcode/src/todo/types.ts b/packages/codingcode/src/todo/types.ts new file mode 100644 index 00000000..bce3dd9b --- /dev/null +++ b/packages/codingcode/src/todo/types.ts @@ -0,0 +1,6 @@ +export type TodoStatus = 'pending' | 'in_progress' | 'completed'; + +export interface TodoItem { + step: string; + status: TodoStatus; +} diff --git a/packages/codingcode/src/tools/catalog.ts b/packages/codingcode/src/tools/catalog.ts index 324d11e3..7e262a43 100644 --- a/packages/codingcode/src/tools/catalog.ts +++ b/packages/codingcode/src/tools/catalog.ts @@ -1,9 +1,8 @@ import { z } from 'zod'; import type { Effect } from 'effect'; -import type { ToolDefinition } from './types.js'; -import type { ToolDescription } from '../contracts/types.js'; -import type { ToolExecCtx, ToolLookup } from '../contracts/tool.js'; -import type { McpToolSpec } from '../contracts/mcp.js'; +import type { ToolDefinition, ToolExecCtx, ToolRunner } from './types.js'; +import type { ToolDescription } from '../llm/types.js'; +import type { McpToolSpec } from '../mcp/types.js'; import type { AgentError } from '../core/error.js'; import { ToolRegistry } from './registry.js'; import { readFileTool } from './domains/fs/read.js'; @@ -46,10 +45,16 @@ function specToDefinition(spec: McpToolSpec): ToolDefinition { }; } +/** 装配产物:给模型看的描述 + 内部查找(不对外暴露)。 */ +export interface ToolCatalogSource { + readonly tools: ToolDescription[]; + readonly lookup: (name: string) => ToolRunner | undefined; +} + export function createToolCatalog( toolNames: readonly string[], mcpTools: McpToolSpec[] = [] -): { tools: ToolDescription[]; lookup: ToolLookup } { +): ToolCatalogSource { const registry = new ToolRegistry(); for (const name of toolNames) { const definition = TOOLS_BY_NAME.get(name); diff --git a/packages/codingcode/src/tools/domains/self/todo-write.ts b/packages/codingcode/src/tools/domains/self/todo-write.ts index 7a7ca581..c0bfc728 100644 --- a/packages/codingcode/src/tools/domains/self/todo-write.ts +++ b/packages/codingcode/src/tools/domains/self/todo-write.ts @@ -8,7 +8,7 @@ import { TODO_MAX_ITEMS, TODO_MAX_STEP_LEN, } from '../../../todo/port.js'; -import type { TodoItem } from '../../../contracts/types.js'; +import type { TodoItem } from '../../../todo/types.js'; const todoSchema = z.object({ plan: z diff --git a/packages/codingcode/src/tools/domains/subagent/spawn.ts b/packages/codingcode/src/tools/domains/subagent/spawn.ts index 2e175e67..237c25a5 100644 --- a/packages/codingcode/src/tools/domains/subagent/spawn.ts +++ b/packages/codingcode/src/tools/domains/subagent/spawn.ts @@ -10,21 +10,37 @@ export const spawnAgentTool: ToolDefinition Effect.gen(function* () { const hooks = yield* HookService; const registry = yield* SubagentRunRegistryService; const { agentName, prompt, model, systemPrompt } = args as { - agentName: string; prompt: string; model?: string; systemPrompt?: string; + agentName: string; + prompt: string; + model?: string; + systemPrompt?: string; }; const projectPath = ctx?.projectPath || process.cwd(); @@ -35,26 +51,38 @@ export const spawnAgentTool: ToolDefinition = { name: 'wait_agent', concurrencySafe: true, description: - 'Wait until a subagent reaches a terminal state. Returns completed, failed or timeout. ' - + 'The subagent final output is appended to this conversation automatically — never use this tool to fetch text, ' - + 'and only wait when the result blocks your next step. Do not poll with short timeouts.', + 'Wait until a subagent reaches a terminal state. Returns completed, failed or timeout. ' + + 'The subagent final output is appended to this conversation automatically — never use this tool to fetch text, ' + + 'and only wait when the result blocks your next step. Do not poll with short timeouts.', parameters: z.object({ sessionId: z.string().min(1).describe('subagent session id returned by spawn_agent'), - timeoutMs: z.number().int().positive().optional().describe('upper bound for this wait, clamped to [10000, 3600000]; default 30000'), + timeoutMs: z + .number() + .int() + .positive() + .optional() + .describe('upper bound for this wait, clamped to [10000, 3600000]; default 30000'), }), execute: (args, _ctx) => Effect.gen(function* () { const registry = yield* SubagentRunRegistryService; const { sessionId, timeoutMs } = args as { sessionId: string; timeoutMs?: number }; - const clamped = Math.min(Math.max(timeoutMs ?? SUBAGENT_WAIT_DEFAULT_MS, SUBAGENT_WAIT_MIN_MS), SUBAGENT_WAIT_MAX_MS); + const clamped = Math.min( + Math.max(timeoutMs ?? SUBAGENT_WAIT_DEFAULT_MS, SUBAGENT_WAIT_MIN_MS), + SUBAGENT_WAIT_MAX_MS + ); const outcome = yield* registry.wait(sessionId, clamped); return outcome; }), diff --git a/packages/codingcode/src/tools/port.ts b/packages/codingcode/src/tools/port.ts index 98f7173c..f76cac04 100644 --- a/packages/codingcode/src/tools/port.ts +++ b/packages/codingcode/src/tools/port.ts @@ -1,20 +1,14 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { ToolCall, ProfileName } from '../contracts/types.js'; -import type { McpToolSpec } from '../contracts/mcp.js'; -import type { ToolCatalog, ToolLookup, ToolResult } from '../contracts/tool.js'; +import type { McpToolSpec } from '../mcp/types.js'; +import type { ToolCatalog } from './types.js'; export interface ToolExecutorShape { - // 按工具名 + 调用方提供的 MCP 工具装配出本轮可用的工具集 + /** 按工具名 + 调用方提供的 MCP 工具装配出本轮可用的工具集 */ prepare(toolNames: readonly string[], mcpTools?: McpToolSpec[]): Effect.Effect; - executeBatch(toolCalls: ToolCall[], sessionId: string | undefined, opts: { - turnId?: number; - projectPath?: string; - signal?: AbortSignal; - toolLookup?: ToolLookup; - activeProfile?: ProfileName; - model: string; - }): Effect.Effect; } -export class ToolExecutorService extends Context.Tag('ToolExecutor')() {} +export class ToolExecutorService extends Context.Tag('ToolExecutor')< + ToolExecutorService, + ToolExecutorShape +>() {} diff --git a/packages/codingcode/src/tools/registry.ts b/packages/codingcode/src/tools/registry.ts index adb69f55..1f27e7a7 100644 --- a/packages/codingcode/src/tools/registry.ts +++ b/packages/codingcode/src/tools/registry.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { ToolDescription } from '../contracts/types.js'; +import type { ToolDescription } from '../llm/types.js'; import type { ToolDefinition } from './types.js'; import { canonicalizeSchema } from './utils/canonicalize-schema.js'; diff --git a/packages/codingcode/src/tools/tools.ts b/packages/codingcode/src/tools/tools.ts index 9ec7a24c..8c64c1ed 100644 --- a/packages/codingcode/src/tools/tools.ts +++ b/packages/codingcode/src/tools/tools.ts @@ -1,13 +1,18 @@ import { Layer, Effect } from 'effect'; import { AgentError } from '../core/error.js'; import { HookService } from '../hooks/port.js'; -import type { ToolCall, ProfileName } from '../contracts/types.js'; -import type { McpToolSpec } from '../contracts/mcp.js'; -import type { ToolCatalog, ToolLookup, ToolResult } from '../contracts/tool.js'; +import type { ToolCall } from '../llm/types.js'; +import type { ProfileName } from '../session/types.js'; +import type { McpToolSpec } from '../mcp/types.js'; +import type { ToolCatalog, ToolExecOpts, ToolResult, ToolRunner } from './types.js'; import { ToolExecutorService } from './port.js'; import { createToolCatalog } from './catalog.js'; -export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(function* () { +type Lookup = (name: string) => ToolRunner | undefined; + +export const ToolExecutorLayer = Layer.effect( + ToolExecutorService, + Effect.gen(function* () { const hooks = yield* HookService; function execute( @@ -19,13 +24,13 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu turnId?: number; projectPath?: string; callId?: string; - toolLookup?: ToolLookup; activeProfile?: ProfileName; model: string; - } + }, + lookup: Lookup ): Effect.Effect<{ output: string }, AgentError> { return Effect.gen(function* () { - const tool = opts.toolLookup?.(name); + const tool = lookup(name); if (!tool) return yield* Effect.fail(AgentError.toolNotFound(name)); const finalArgs = args as Record; @@ -52,7 +57,6 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu model: opts.model, }; - let toolEffect = tool.execute(parsedArgs, ctx); if (opts.signal) { @@ -99,16 +103,10 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu function execSingle( tc: ToolCall, sessionId: string | undefined, - opts: { - turnId?: number; - projectPath?: string; - signal?: AbortSignal; - toolLookup?: ToolLookup; - activeProfile?: ProfileName; - model: string; - } + opts: ToolExecOpts, + lookup: Lookup ): Effect.Effect { - return execute(tc.name, tc.arguments ?? {}, { sessionId, callId: tc.id, ...opts }).pipe( + return execute(tc.name, tc.arguments ?? {}, { sessionId, callId: tc.id, ...opts }, lookup).pipe( Effect.matchEffect({ onSuccess: (result: any): Effect.Effect => Effect.succeed({ @@ -147,11 +145,11 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu ); } - function splitWaves(toolCalls: ToolCall[], toolLookup?: ToolLookup): ToolCall[][] { + function splitWaves(toolCalls: ToolCall[], lookup: Lookup): ToolCall[][] { const waves: ToolCall[][] = []; let current: ToolCall[] | undefined; for (const tc of toolCalls) { - if (toolLookup?.(tc.name)?.concurrencySafe) { + if (lookup(tc.name)?.concurrencySafe) { if (!current) { current = []; waves.push(current); @@ -165,20 +163,13 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu return waves; } - function executeBatch( + function runBatch( toolCalls: ToolCall[], sessionId: string | undefined, - opts: { - turnId?: number; - projectPath?: string; - signal?: AbortSignal; - toolLookup?: ToolLookup; - activeProfile?: ProfileName; - model: string; - } + opts: ToolExecOpts, + lookup: Lookup ): Effect.Effect { return Effect.gen(function* () { - const runTool = (tc: ToolCall): Effect.Effect => Effect.suspend(() => opts.signal?.aborted @@ -188,11 +179,11 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu name: tc.name, reason: 'aborted', }) - : execSingle(tc, sessionId, opts) + : execSingle(tc, sessionId, opts, lookup) ); const waveResults = yield* Effect.forEach( - splitWaves(toolCalls, opts.toolLookup), + splitWaves(toolCalls, lookup), (wave) => Effect.forEach(wave, runTool, { concurrency: 'unbounded' }), { concurrency: 1 } ); @@ -203,9 +194,20 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu }); } - function prepare(toolNames: readonly string[], mcpTools: McpToolSpec[] = []): Effect.Effect { - return Effect.sync(() => createToolCatalog(toolNames, mcpTools)); + function prepare( + toolNames: readonly string[], + mcpTools: McpToolSpec[] = [] + ): Effect.Effect { + return Effect.sync(() => { + const source = createToolCatalog(toolNames, mcpTools); + return { + tools: source.tools, + executeBatch: (toolCalls, sessionId, opts) => + runBatch(toolCalls, sessionId, opts, source.lookup), + }; + }); } - return { prepare, executeBatch }; -})); + return { prepare }; + }) +); diff --git a/packages/codingcode/src/tools/types.ts b/packages/codingcode/src/tools/types.ts index a5f51705..c6d27808 100644 --- a/packages/codingcode/src/tools/types.ts +++ b/packages/codingcode/src/tools/types.ts @@ -1,7 +1,9 @@ import { z } from 'zod'; import { Effect } from 'effect'; -import { AgentError } from '../core/error.js'; -import type { ToolExecCtx } from '../contracts/tool.js'; +import type { AgentError } from '../core/error.js'; +import type { ToolCall, ToolDescription } from '../llm/types.js'; +import type { ProfileName } from '../session/types.js'; +import type { ToolOutcome } from '../sink/types.js'; export interface ToolDefinition { name: string; @@ -11,3 +13,37 @@ export interface ToolDefinition { parameters: z.ZodTypeAny; execute: (args: unknown, ctx?: ToolExecCtx) => Effect.Effect; } + +export interface ToolExecCtx { + signal?: AbortSignal; + sessionId?: string; + projectPath?: string; + activeProfile?: ProfileName; + model: string; +} + +export type ToolResult = { readonly id: string; readonly name: string } & ToolOutcome; + +export interface ToolRunner { + readonly name: string; + readonly concurrencySafe: boolean; + parse(args: unknown): unknown; + execute(args: unknown, ctx?: ToolExecCtx): Effect.Effect; +} + +export interface ToolExecOpts { + turnId?: number; + projectPath?: string; + signal?: AbortSignal; + activeProfile?: ProfileName; + model: string; +} + +export interface ToolCatalog { + tools: ToolDescription[]; + executeBatch( + toolCalls: ToolCall[], + sessionId: string | undefined, + opts: ToolExecOpts + ): Effect.Effect; +} diff --git a/packages/codingcode/test/agent/abort.test.ts b/packages/codingcode/test/agent/abort.test.ts index c6ab72d2..d29fba0d 100644 --- a/packages/codingcode/test/agent/abort.test.ts +++ b/packages/codingcode/test/agent/abort.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { makeState, runAgentTurn, textDeltas } from '../helpers/agent-harness.js'; -import type { EndTransition, FrameBody } from '../../src/contracts/frame.js'; +import type { EndTransition, FrameBody } from '../../src/sink/types.js'; vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index f93b3981..9dfab4b5 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -34,7 +34,9 @@ function makeCapturingLlm(parts: () => AsyncIterable) { describe('agent runTurn loop', () => { it('should yield text chunks from LLM stream', async () => { - const llm = makeCapturingLlm(() => llmStream(pText('Hello'), pText(' '), pText('world'), pEnd())); + const llm = makeCapturingLlm(() => + llmStream(pText('Hello'), pText(' '), pText('world'), pEnd()) + ); const { events } = await runAgentTurn( { llm, state: mockState }, { sessionId: 'test-sid', cwd: '/tmp' } @@ -52,9 +54,7 @@ describe('agent runTurn loop', () => { expect(texts(events)).toHaveLength(0); expect(endReason(events)).toBe('error'); - const end = events.find( - (b) => b.family === 'transition' && b.transition.to === 'end' - ) as any; + const end = events.find((b) => b.family === 'transition' && b.transition.to === 'end') as any; expect(end.transition.error.code).toBe('EMPTY_RESPONSE'); }); @@ -122,7 +122,9 @@ describe('agent runTurn loop', () => { it('should end with maxSteps and emit a single turn.end hook when maxSteps is exhausted', async () => { // LLM always requests a tool call → the loop never reaches a natural stop. const llm = { - completeStream: vi.fn(() => llmStream(pText('calling tool'), pToolCall('tc1', 'read_file', { path: 'x' }), pEnd())), + completeStream: vi.fn(() => + llmStream(pText('calling tool'), pToolCall('tc1', 'read_file', { path: 'x' }), pEnd()) + ), modelInfo: { maxTokens: 1000 }, } as any; const turnEndCalls: any[] = []; diff --git a/packages/codingcode/test/agent/context-compressed.test.ts b/packages/codingcode/test/agent/context-compressed.test.ts index 12fd0166..864b805a 100644 --- a/packages/codingcode/test/agent/context-compressed.test.ts +++ b/packages/codingcode/test/agent/context-compressed.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { makeState, runAgentTurn, llmStream, pText, pEnd, hasCompress } from '../helpers/agent-harness.js'; +import { + makeState, + runAgentTurn, + llmStream, + pText, + pEnd, + hasCompress, +} from '../helpers/agent-harness.js'; function makePlainLlm(content = 'ok') { return { diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index 01056ba7..fa4447ae 100644 --- a/packages/codingcode/test/agent/hooks-deps-type.test.ts +++ b/packages/codingcode/test/agent/hooks-deps-type.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; -import { makeState, runAgentTurn, llmStream, pText, pEnd, endReason } from '../helpers/agent-harness.js'; +import { + makeState, + runAgentTurn, + llmStream, + pText, + pEnd, + endReason, +} from '../helpers/agent-harness.js'; vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ diff --git a/packages/codingcode/test/agent/loop-options.test.ts b/packages/codingcode/test/agent/loop-options.test.ts index 949eaa7e..8c8d1921 100644 --- a/packages/codingcode/test/agent/loop-options.test.ts +++ b/packages/codingcode/test/agent/loop-options.test.ts @@ -1,6 +1,13 @@ import { expect, it, describe, vi } from 'vitest'; import { Effect } from 'effect'; -import { makeState, runAgentTurn, llmStream, pText, pEnd, endReason } from '../helpers/agent-harness.js'; +import { + makeState, + runAgentTurn, + llmStream, + pText, + pEnd, + endReason, +} from '../helpers/agent-harness.js'; vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ @@ -40,10 +47,7 @@ describe('agent runTurn loop options', () => { it('should emit turn hooks agent.turn.start / agent.turn.end after stopping', async () => { const llm = makeCapturingLlm(); const hooks = mockHooks(); - await runAgentTurn( - { llm, state: mockState, hooks }, - { sessionId: 'test-sid', cwd: '/tmp' } - ); + await runAgentTurn({ llm, state: mockState, hooks }, { sessionId: 'test-sid', cwd: '/tmp' }); expect(hooks.emit).toHaveBeenCalledWith( 'agent.turn.start', diff --git a/packages/codingcode/test/agent/render-skill-block.test.ts b/packages/codingcode/test/agent/render-skill-block.test.ts index a56be7a4..81d0a0af 100644 --- a/packages/codingcode/test/agent/render-skill-block.test.ts +++ b/packages/codingcode/test/agent/render-skill-block.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { renderSkillBlock } from '../../src/agent/prompt.js'; -import type { Skill } from '../../src/contracts/skill.js'; +import type { Skill } from '../../src/skills/types.js'; function skill(name: string, skillPath: string): Skill { return { name, description: `${name} description`, skillPath }; diff --git a/packages/codingcode/test/agent/skill-mention-persist.test.ts b/packages/codingcode/test/agent/skill-mention-persist.test.ts index cdc485ac..be1faa30 100644 --- a/packages/codingcode/test/agent/skill-mention-persist.test.ts +++ b/packages/codingcode/test/agent/skill-mention-persist.test.ts @@ -10,7 +10,7 @@ import { SkillService } from '../../src/skills/port.js'; import { normalizePath } from '../../src/core/path.js'; import { computePaths } from '../../src/session/paths.js'; import { readHistory } from '../../src/session/file-ops.js'; -import type { UserEvent } from '../../src/contracts/session.js'; +import type { UserEvent } from '../../src/session/types.js'; import { runAgentTurn, llmStream, pText, pEnd } from '../helpers/agent-harness.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -60,7 +60,10 @@ describe('agent runTurn with an explicit @ skill', () => { const { sessionId } = await runAgentTurn( { - llm: { completeStream: () => llmStream(pText('ok'), pEnd()), modelInfo: { maxTokens: 1000 } }, + llm: { + completeStream: () => llmStream(pText('ok'), pEnd()), + modelInfo: { maxTokens: 1000 }, + }, sessionLayer: SessionLayer, skillLayer: SkillLayer, }, diff --git a/packages/codingcode/test/architecture/boundaries.test.ts b/packages/codingcode/test/architecture/boundaries.test.ts index 997929b5..eca504c5 100644 --- a/packages/codingcode/test/architecture/boundaries.test.ts +++ b/packages/codingcode/test/architecture/boundaries.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; // 架构边界回归:把 issue 里的 R1–R4 固化成断言,防止再次出现"半倒置"。 // 扫描根是两个包:core 引擎(packages/codingcode/src)与接入层(packages/sdk/src)。 @@ -26,13 +27,47 @@ function listTs(dir: string): string[] { const CORE_FILES = listTs(SRC); const FILES = [...CORE_FILES, ...listTs(SDK_SRC)]; -/** 抽取文件里的模块说明符(静态 import 与动态 import) */ -function specifiersOf(file: string): string[] { - const src = readFileSync(file, 'utf8'); - const specs: string[] = []; - for (const m of src.matchAll(/\bfrom\s+['"]([^'"]+)['"]/g)) specs.push(m[1]!); - for (const m of src.matchAll(/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) specs.push(m[1]!); - return specs; +export interface ModuleRef { + spec: string; + /** 整条 import 都是类型导入(`import type` 或全部具名 specifier 带 type) */ + typeOnly: boolean; +} + +/** 用编译器 API 抽取模块说明符:静态 import / export-from / 动态 import */ +function moduleRefsOf(file: string): ModuleRef[] { + const sf = ts.createSourceFile( + file, + readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const refs: ModuleRef[] = []; + const push = (spec: string, typeOnly: boolean) => refs.push({ spec, typeOnly }); + + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + const clause = node.importClause; + let typeOnly = clause?.isTypeOnly ?? false; + const bindings = clause?.namedBindings; + if (!typeOnly && bindings && ts.isNamedImports(bindings)) { + typeOnly = bindings.elements.length > 0 && bindings.elements.every((e) => e.isTypeOnly); + } + push(node.moduleSpecifier.text, typeOnly); + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier) { + push((node.moduleSpecifier as ts.StringLiteral).text, node.isTypeOnly); + } else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]!) + ) { + push((node.arguments[0] as ts.StringLiteral).text, false); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return refs; } /** 相对说明符 → 仓库内绝对路径(去 .js);第三方返回 null */ @@ -47,22 +82,20 @@ const relSdk = (abs: string) => norm(relative(SDK_SRC, abs)); const relPkg = (abs: string) => abs.startsWith(norm(SDK_SRC) + '/') ? `sdk/src/${relSdk(abs)}` : `codingcode/src/${relSrc(abs)}`; const isCore = (abs: string) => abs.startsWith(norm(join(SRC, 'core')) + '/'); -const isContracts = (abs: string) => abs.startsWith(norm(join(SRC, 'contracts')) + '/'); /** 契约文件:每个特性目录的 port.ts(宽 Tag,agent 自持的装配端口也在 agent/port.ts) */ const CONTRACT_FILES = FILES.filter((f) => f.endsWith('/port.ts')); describe('R1 契约不得 import 实现', () => { - it('所有 port.ts 的跨模块引用只有 core/、contracts/ 与同目录类型', () => { + it('port.ts 跨模块引用只有 core/、同目录,或对方的 types.ts(type-only)', () => { const violations: string[] = []; for (const file of CONTRACT_FILES) { - for (const spec of specifiersOf(file)) { + for (const { spec, typeOnly } of moduleRefsOf(file)) { const target = resolveSpec(file, spec); if (target === null) continue; // 第三方库 - const sameDir = dirname(target) === dirname(file); - if (!isCore(target) && !isContracts(target) && !sameDir) { - violations.push(`${relPkg(file)} → ${spec}`); - } + if (dirname(target) === dirname(file) || isCore(target)) continue; + if (!typeOnly) violations.push(`${relPkg(file)} → ${spec} (runtime import)`); + else if (!target.endsWith('/types')) violations.push(`${relPkg(file)} → ${spec} (非 types.ts)`); } } expect(violations).toEqual([]); @@ -71,14 +104,10 @@ describe('R1 契约不得 import 实现', () => { describe('R2 实现不得依赖消费者模块', () => { it('agent 自持的装配端口只在 agent/ 内部出现', () => { - const hits = FILES.filter((f) => - /\bToolEnvPort\b/.test(readFileSync(f, 'utf8')) - ).map(relSrc).sort(); - expect(hits).toEqual([ - 'agent/agent.ts', - 'agent/port.ts', - 'agent/tool-env.ts', - ]); + const hits = FILES.filter((f) => /\bToolEnvPort\b/.test(readFileSync(f, 'utf8'))) + .map(relSrc) + .sort(); + expect(hits).toEqual(['agent/agent.ts', 'agent/port.ts', 'agent/tool-env.ts']); }); }); @@ -86,7 +115,7 @@ describe('R3 core 零内部依赖', () => { it('core/ 不引用 core/ 之外的任何 src 模块', () => { const violations: string[] = []; for (const file of FILES.filter((f) => isCore(f))) { - for (const spec of specifiersOf(file)) { + for (const { spec } of moduleRefsOf(file)) { const target = resolveSpec(file, spec); if (target === null) continue; // 第三方 / 其他 workspace 包 if (!isCore(target)) violations.push(`${relPkg(file)} → ${spec}`); @@ -98,14 +127,24 @@ describe('R3 core 零内部依赖', () => { /** core/ 的准入:只放不指向任何功能模块的通用件 */ const NODE_BUILTINS = new Set([ - 'os', 'path', 'fs', 'url', 'crypto', 'util', 'stream', 'events', 'buffer', 'child_process', 'process', + 'os', + 'path', + 'fs', + 'url', + 'crypto', + 'util', + 'stream', + 'events', + 'buffer', + 'child_process', + 'process', ]); describe('core/ 准入:只承载通用件', () => { it('core/ 的 import 只能是 node 内置与同目录相对路径', () => { const violations: string[] = []; for (const file of FILES.filter((f) => isCore(f))) { - for (const spec of specifiersOf(file)) { + for (const { spec } of moduleRefsOf(file)) { if (spec.startsWith('.')) { const target = resolveSpec(file, spec)!; if (dirname(target) !== dirname(file)) violations.push(`${relPkg(file)} → ${spec}`); @@ -120,49 +159,40 @@ describe('core/ 准入:只承载通用件', () => { }); }); -describe('contracts/ 准入:不得依赖领域实现', () => { - it('contracts/ 的文件只引用 core/、同目录与第三方', () => { - const violations: string[] = []; - for (const file of FILES.filter((f) => isContracts(f))) { - for (const spec of specifiersOf(file)) { - const target = resolveSpec(file, spec); - if (target === null) continue; - const sameDir = dirname(target) === dirname(file); - if (!isCore(target) && !sameDir) violations.push(`${relPkg(file)} → ${spec}`); - } - } - expect(violations).toEqual([]); - }); -}); - describe('R4 一个概念只允许一处类型定义', () => { const CANONICAL: Record = { ApprovalRequest: 'approval/port.ts', - TokenUsage: 'contracts/types.ts', - TodoItem: 'contracts/types.ts', - ProfileName: 'contracts/types.ts', - UITurn: 'contracts/session.ts', - UITurnItem: 'contracts/session.ts', - SessionEvent: 'contracts/session.ts', - SessionStoreState: 'contracts/session.ts', - SessionCreateOptions: 'contracts/session.ts', - ToolOutcome: 'contracts/frame.ts', - ToolCatalog: 'contracts/tool.ts', - ToolResult: 'contracts/tool.ts', - ToolRunner: 'contracts/tool.ts', - ToolLookup: 'contracts/tool.ts', - LLMClient: 'contracts/provider.ts', - SelectableModel: 'contracts/provider.ts', - PermissionMode: 'contracts/permission.ts', - ApprovalDecision: 'contracts/permission.ts', - HookPoint: 'contracts/hooks.ts', - HookDecision: 'contracts/hooks.ts', - Skill: 'contracts/skill.ts', - McpServerConfig: 'contracts/mcp.ts', - Automation: 'contracts/automation.ts', - AutomationSandbox: 'contracts/automation.ts', - CreateAutomationInput: 'contracts/automation.ts', - UpdateAutomationInput: 'contracts/automation.ts', + ApprovalDecision: 'approval/port.ts', + TokenUsage: 'llm/types.ts', + Message: 'llm/types.ts', + ToolCall: 'llm/types.ts', + ToolDescription: 'llm/types.ts', + LLMClient: 'llm/types.ts', + TodoItem: 'todo/types.ts', + ProfileName: 'session/types.ts', + PermissionMode: 'session/types.ts', + UITurn: 'session/types.ts', + UITurnItem: 'session/types.ts', + SessionEvent: 'session/types.ts', + SessionRef: 'session/types.ts', + SessionStoreState: 'session/types.ts', + SessionCreateOptions: 'session/types.ts', + ToolOutcome: 'sink/types.ts', + FrameBody: 'sink/types.ts', + Envelope: 'server/frame-io.ts', + ToolCatalog: 'tools/types.ts', + ToolResult: 'tools/types.ts', + ToolRunner: 'tools/types.ts', + SelectableModel: 'infra/models.ts', + HookPoint: 'hooks/types.ts', + HookDecision: 'hooks/types.ts', + Skill: 'skills/types.ts', + McpServerConfig: 'mcp/types.ts', + McpToolSpec: 'mcp/types.ts', + Automation: 'scheduler/types.ts', + AutomationSandbox: 'scheduler/types.ts', + CreateAutomationInput: 'scheduler/types.ts', + UpdateAutomationInput: 'scheduler/types.ts', }; it.each(Object.entries(CANONICAL))('%s 只在 %s 声明一次', (name, expected) => { @@ -170,7 +200,9 @@ describe('R4 一个概念只允许一处类型定义', () => { `^export\\s+(?:declare\\s+)?(?:abstract\\s+)?(?:interface|type|class)\\s+${name}\\b`, 'm' ); - const owners = CORE_FILES.filter((f) => re.test(readFileSync(f, 'utf8'))).map(relSrc).sort(); + const owners = CORE_FILES.filter((f) => re.test(readFileSync(f, 'utf8'))) + .map(relSrc) + .sort(); expect(owners).toEqual([norm(expected)]); }); }); @@ -179,7 +211,7 @@ describe('相对 import 必须可解析', () => { it('每条相对 import 都能在仓库内找到落点(覆盖 layer.ts 等测试不加载的模块)', () => { const missing: string[] = []; for (const file of FILES) { - for (const spec of specifiersOf(file)) { + for (const { spec } of moduleRefsOf(file)) { const target = resolveSpec(file, spec); if (target === null) continue; const ok = ['.ts', '.tsx', '/index.ts', '/index.tsx'].some((ext) => diff --git a/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts b/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts index 4e09da0f..6aeb604f 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts @@ -16,4 +16,3 @@ describe('toGitPath', () => { expect(result).toContain('file.ts'); }); }); - diff --git a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts index fceda626..0c9ac118 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts @@ -45,4 +45,3 @@ describe('toGitPath preserves original casing for git paths', () => { expect(toGitPath(projectPath, gitPath)).toBe('src/Main.ts'); }); }); - diff --git a/packages/codingcode/test/checkpoint/turn-title-removal.test.ts b/packages/codingcode/test/checkpoint/turn-title-removal.test.ts index b38cbb3e..1b61e60d 100644 --- a/packages/codingcode/test/checkpoint/turn-title-removal.test.ts +++ b/packages/codingcode/test/checkpoint/turn-title-removal.test.ts @@ -20,7 +20,7 @@ describe('checkpoint turn title removal', () => { writeFileSync(join(projectPath, 'before.txt'), 'before', 'utf8'); try { - const checkpoints = await Effect.runPromise( + const checkpoints = (await Effect.runPromise( Effect.gen(function* () { const checkpoint = yield* CheckpointService; yield* checkpoint.snapshotBaseline(projectPath, sessionId, 1); @@ -28,7 +28,7 @@ describe('checkpoint turn title removal', () => { yield* checkpoint.snapshotFinal(projectPath, sessionId, 1); return yield* checkpoint.getCheckpointDiff(projectPath, sessionId); }).pipe(Effect.provide(CheckpointLayer) as any) - ) as { turnId: number; files: Array<{ path: string }> }; + )) as { turnId: number; files: Array<{ path: string }> }; expect(checkpoints.turnId).toBe(1); expect(checkpoints.files.map((f) => f.path)).toEqual([ diff --git a/packages/codingcode/test/context/budget-integration.test.ts b/packages/codingcode/test/context/budget-integration.test.ts index 191870f5..917f15bf 100644 --- a/packages/codingcode/test/context/budget-integration.test.ts +++ b/packages/codingcode/test/context/budget-integration.test.ts @@ -7,7 +7,7 @@ import { ContextService } from '../../src/context/port.js'; import type { ContextShape } from '../../src/context/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { LLMService } from '../../src/llm/port.js'; -import type { SessionRef } from '../../src/contracts/session.js'; +import type { SessionRef } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; import { ContextLayer, transcriptPathFor } from '../../src/context/context.js'; import { EventSinkLayer } from '../../src/sink/sink.js'; @@ -94,7 +94,11 @@ describe('getHistory integration', () => { }); it('returns an empty message list when the transcript is empty', async () => { - const emptyRef: SessionRef = { cwd: CWD, sessionId: `${ref.sessionId}-empty`, currentTurnId: 1 }; + const emptyRef: SessionRef = { + cwd: CWD, + sessionId: `${ref.sessionId}-empty`, + currentTurnId: 1, + }; const emptyPath = transcriptPathFor(emptyRef); writeFileSync(emptyPath, '', 'utf8'); const ctx = await getCtxService(); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index 2dab56ca..c95f2092 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -8,8 +8,13 @@ import type { ContextShape } from '../../../src/context/port.js'; import { SessionService } from '../../../src/session/port.js'; import { SessionLayer } from '../../../src/session/session.js'; import { LLMService } from '../../../src/llm/port.js'; -import type { SessionEvent, SessionRef, SummaryEvent } from '../../../src/contracts/session.js'; -import { filterForContext, buildContextMessages, transcriptPathFor } from '../../../src/context/context.js'; +import type { SessionEvent, SummaryEvent } from '../../../src/session/types.js'; +import type { SessionRef } from '../../../src/session/types.js'; +import { + filterForContext, + buildContextMessages, + transcriptPathFor, +} from '../../../src/context/context.js'; import { readHistory } from '../../../src/session/file-ops.js'; import { estimateTokens } from '../../../src/context/tokens.js'; import { useTempProjectBase } from '../../helpers/project-base.js'; diff --git a/packages/codingcode/test/context/memory-buffer.test.ts b/packages/codingcode/test/context/memory-buffer.test.ts index dbc47063..8e840ee0 100644 --- a/packages/codingcode/test/context/memory-buffer.test.ts +++ b/packages/codingcode/test/context/memory-buffer.test.ts @@ -6,7 +6,8 @@ import { ContextLayer } from '../../src/context/context.js'; import { SessionService } from '../../src/session/port.js'; import { LLMService } from '../../src/llm/port.js'; import { EventSinkLayer } from '../../src/sink/sink.js'; -import type { SessionEvent, SessionRef } from '../../src/contracts/session.js'; +import type { SessionEvent } from '../../src/session/types.js'; +import type { SessionRef } from '../../src/session/types.js'; // 上下文窗口钉死成可控值(与其余 context 用例一致) const windowState = vi.hoisted(() => ({ value: 128000 })); @@ -14,7 +15,8 @@ vi.mock('../../src/infra/models.js', () => ({ contextWindowOf: () => windowState.value, })); -const SUMMARY = '## Compacted History\n\n### Goal\nx\n\n### Instructions\ny\n\n### Discoveries\nz\n\n### Accomplished\nw\n\n### Relevant Files\nf'; +const SUMMARY = + '## Compacted History\n\n### Goal\nx\n\n### Instructions\ny\n\n### Discoveries\nz\n\n### Accomplished\nw\n\n### Relevant Files\nf'; /** 计数 + 内存中的假 transcript:把「读盘」变成可断言的数字 */ function makeCountingSession(seed: SessionEvent[]) { @@ -96,7 +98,9 @@ describe('context memory buffer', () => { const ctx = await getCtx(counting); await Effect.runPromise(ctx.getHistory(REF, 'test-model')); - await Effect.runPromise(ctx.absorb(REF, [{ type: 'assistant', turnId: 1, content: 'r1', toolCalls: [] }])); + await Effect.runPromise( + ctx.absorb(REF, [{ type: 'assistant', turnId: 1, content: 'r1', toolCalls: [] }]) + ); const messages = await Effect.runPromise(ctx.getHistory(REF, 'test-model')); expect(messages.some((m) => m.content === 'r1')).toBe(true); @@ -120,12 +124,19 @@ describe('context memory buffer', () => { const big = 'X'.repeat(8000); const counting = makeCountingSession([ ...seedEvents(), - { type: 'assistant', turnId: 1, content: 'r1', toolCalls: [{ id: 'tc1', name: 'bash', arguments: {} }] }, + { + type: 'assistant', + turnId: 1, + content: 'r1', + toolCalls: [{ id: 'tc1', name: 'bash', arguments: {} }], + }, { type: 'tool_result', turnId: 1, toolName: 'bash', toolCallId: 'tc1', output: big }, ]); const ctx = await getCtx(counting); - const messages = await Effect.runPromise(ctx.getHistory({ ...REF, currentTurnId: 3 }, 'test-model')); + const messages = await Effect.runPromise( + ctx.getHistory({ ...REF, currentTurnId: 3 }, 'test-model') + ); expect(messages.some((m) => m.name === 'compacted_history')).toBe(true); expect(counting.reads.count).toBe(1); diff --git a/packages/codingcode/test/context/subagent-result-role.test.ts b/packages/codingcode/test/context/subagent-result-role.test.ts index 1dfe3d1e..c05acd38 100644 --- a/packages/codingcode/test/context/subagent-result-role.test.ts +++ b/packages/codingcode/test/context/subagent-result-role.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect } from 'vitest'; import { buildContextMessages } from '../../src/context/context.js'; -import { SUBAGENT_RESULT_PREFIX } from '../../src/contracts/session.js'; -import type { SessionEvent } from '../../src/contracts/session.js'; -import type { Message } from '../../src/contracts/types.js'; +import { SUBAGENT_RESULT_PREFIX, type SessionEvent } from '../../src/session/types.js'; +import type { Message } from '../../src/llm/types.js'; function build(events: unknown[]): Message[] { return buildContextMessages(events as SessionEvent[]); @@ -28,7 +27,12 @@ describe('subagent_result 的入模形态', () => { toolCallId: 'tc1', output: 'spawned build (child-1)', }, - { type: 'subagent_result', sessionId: 'child-1', agentName: 'build', content: resultContent('hello') }, + { + type: 'subagent_result', + sessionId: 'child-1', + agentName: 'build', + content: resultContent('hello'), + }, ]); const last = messages[messages.length - 1]!; @@ -52,7 +56,12 @@ describe('subagent_result 的入模形态', () => { toolCallId: 'tc1', output: 'completed', }, - { type: 'subagent_result', sessionId: 'child-1', agentName: 'build', content: resultContent('done') }, + { + type: 'subagent_result', + sessionId: 'child-1', + agentName: 'build', + content: resultContent('done'), + }, ]); expect(messages.length).toBeGreaterThan(0); diff --git a/packages/codingcode/test/context/transcript-path-agreement.test.ts b/packages/codingcode/test/context/transcript-path-agreement.test.ts deleted file mode 100644 index 1ea7b470..00000000 --- a/packages/codingcode/test/context/transcript-path-agreement.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { randomUUID } from 'crypto'; -import { computePaths } from '../../src/session/paths.js'; -import { transcriptPathFor } from '../../src/context/context.js'; -import type { SessionRef } from '../../src/contracts/session.js'; - -/** - * context 与 session 各自「用 core/path 的纯函数格式化入参、再用 contracts 常量拼接」。 - * 两处实现必须产出同一个字符串,否则 context 会去读一个不存在的文件。 - * 这条断言就是防漂移的护栏。 - */ -describe('会话转录路径:context 与 session 各自拼接的结果必须一致', () => { - const cases: Array<{ cwd: string; parentSessionId?: string }> = [ - { cwd: 'c:/Users/me/proj' }, - { cwd: '/home/me/proj' }, - { cwd: 'C:\\Users\\me\\my proj' }, - { cwd: '/home/me/proj', parentSessionId: 'parent-fixed-id' }, - ]; - - for (const { cwd, parentSessionId } of cases) { - it(`cwd=${cwd} parent=${parentSessionId ?? '(none)'}`, () => { - const sessionId = randomUUID(); - const ref: SessionRef = { cwd, sessionId, parentSessionId, currentTurnId: 0 }; - - expect(transcriptPathFor(ref)).toBe( - computePaths(cwd, sessionId, parentSessionId).transcriptPath - ); - }); - } -}); diff --git a/packages/codingcode/test/core/error-code.test.ts b/packages/codingcode/test/core/error-code.test.ts index 6d9d04fe..a26a5edc 100644 --- a/packages/codingcode/test/core/error-code.test.ts +++ b/packages/codingcode/test/core/error-code.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { AgentError } from '../../src/core/error.js'; -import { NotFoundError, AlreadyExistsError } from '../../src/contracts/error.js'; +import { NotFoundError, AlreadyExistsError } from '../../src/server/http-error.js'; describe('NotFoundError has code+httpStatus', () => { it('code is NOT_FOUND and httpStatus returns 404', () => { diff --git a/packages/codingcode/test/core/frame-io.test.ts b/packages/codingcode/test/core/frame-io.test.ts index 64ab3e35..64b1872b 100644 --- a/packages/codingcode/test/core/frame-io.test.ts +++ b/packages/codingcode/test/core/frame-io.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { createFrameAssembler } from '../../src/contracts/frame-io.js'; -import type { FrameBody } from '../../src/contracts/frame.js'; +import { createFrameAssembler } from '../../src/server/frame-io.js'; +import type { FrameBody } from '../../src/sink/types.js'; // ---- body builders ---- diff --git a/packages/codingcode/test/core/frame-protocol.test.ts b/packages/codingcode/test/core/frame-protocol.test.ts index 4da491d1..1594572b 100644 --- a/packages/codingcode/test/core/frame-protocol.test.ts +++ b/packages/codingcode/test/core/frame-protocol.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { isTurnEnd } from '../../src/contracts/frame.js'; -import type { Frame, FrameBody } from '../../src/contracts/frame.js'; +import { isTurnEnd } from '../../src/sink/types.js'; +import type { FrameBody } from '../../src/sink/types.js'; +import type { Frame } from '../../src/server/frame-io.js'; const turnId = 1; diff --git a/packages/codingcode/test/helpers/agent-harness.ts b/packages/codingcode/test/helpers/agent-harness.ts index 803dba30..2889d3c2 100644 --- a/packages/codingcode/test/helpers/agent-harness.ts +++ b/packages/codingcode/test/helpers/agent-harness.ts @@ -19,10 +19,9 @@ import { SkillService } from '../../src/skills/port.js'; import { SubagentRunnerService } from '../../src/subagent/port.js'; import { TodoService } from '../../src/todo/port.js'; import { ToolExecutorService } from '../../src/tools/port.js'; -import type { FrameBody, RuntimeEvent, Transition } from '../../src/contracts/frame.js'; -import type { TokenUsage } from '../../src/contracts/types.js'; -import type { LLMStreamPart } from '../../src/contracts/provider.js'; -import type { SessionStoreState, SessionRef } from '../../src/contracts/session.js'; +import type { FrameBody, RuntimeEvent, Transition } from '../../src/sink/types.js'; +import type { TokenUsage, LLMStreamPart } from '../../src/llm/types.js'; +import type { SessionStoreState, SessionRef } from '../../src/session/types.js'; // ---- LLM 部件构造器 ---- @@ -104,7 +103,9 @@ export function endReason(events: readonly FrameBody[]): TransitionOf<'end'>['re return endOf(events)?.reason; } -export function fatalOf(events: readonly FrameBody[]): { message: string; code: string } | undefined { +export function fatalOf( + events: readonly FrameBody[] +): { message: string; code: string } | undefined { for (const b of events) { if (b.family === 'fatal') return b.fatal; } @@ -120,7 +121,11 @@ export function hasCompress(events: readonly FrameBody[]): boolean { export interface HarnessMocks { llm: { - completeStream: (params: any, model: string, signal?: AbortSignal) => AsyncIterable; + completeStream: ( + params: any, + model: string, + signal?: AbortSignal + ) => AsyncIterable; modelInfo: { maxTokens: number }; }; state?: Partial; @@ -213,20 +218,25 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { emit: () => Effect.succeed(undefined), emitDecision: () => Effect.succeed(null), }; - const executor = - mocks.executor ?? - ({ - prepare: (names: readonly string[], mcpTools: any[] = []) => Effect.succeed(createToolCatalog(names, mcpTools)), - executeBatch: (calls: any[]) => - Effect.succeed( - calls.map((c: any) => ({ - status: 'ok' as const, - id: c.id, - name: c.name, - output: '', - })) - ), - } as any); + const batch = + mocks.executor?.executeBatch ?? + ((calls: any[]) => + Effect.succeed( + calls.map((c: any) => ({ + status: 'ok' as const, + id: c.id, + name: c.name, + output: '', + })) + )); + const executor = { + prepare: (names: readonly string[], mcpTools: any[] = []) => + Effect.succeed({ + tools: createToolCatalog(names, mcpTools).tools, + executeBatch: (calls: any[], sessionId?: string, opts?: any) => + batch(calls, sessionId, opts), + }), + } as any; const session: Record = { load: (_cwd: string, sid: string) => Effect.succeed({ ...state, sessionId: sid }), @@ -257,10 +267,18 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { return { getHistory: (ref: SessionRef) => Effect.gen(function* () { - const shouldCompact = mocks.contextWillCompact ? yield* Effect.promise(mocks.contextWillCompact) : false; + const shouldCompact = mocks.contextWillCompact + ? yield* Effect.promise(mocks.contextWillCompact) + : false; if (shouldCompact) { - yield* sink.emit(ref.sessionId, { family: 'transition', transition: { to: 'compress' } }); - yield* sink.emit(ref.sessionId, { family: 'transition', transition: { to: 'executing' } }); + yield* sink.emit(ref.sessionId, { + family: 'transition', + transition: { to: 'compress' }, + }); + yield* sink.emit(ref.sessionId, { + family: 'transition', + transition: { to: 'executing' }, + }); } return mocks.contextAssemble ? yield* Effect.promise(mocks.contextAssemble) @@ -323,7 +341,7 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { mcpLayer, Layer.succeed(SubagentRunnerService, {} as any), // ToolEnvPort:把上面的具体服务适配成 agent 所需的工具执行期注入能力(同 layer.ts) - ToolEnvLayer, + ToolEnvLayer ); return services; } diff --git a/packages/codingcode/test/helpers/project-base.ts b/packages/codingcode/test/helpers/project-base.ts index 7ac9fb99..5a0b4a68 100644 --- a/packages/codingcode/test/helpers/project-base.ts +++ b/packages/codingcode/test/helpers/project-base.ts @@ -1,8 +1,6 @@ import { mkdirSync } from 'fs'; -import { join } from 'path'; import { beforeEach } from 'vitest'; -import { getGlobalDir } from '../../src/core/path.js'; -import { PROJECTS_DIRNAME } from '../../src/contracts/paths.js'; +import { projectRootDir } from '../../src/core/path.js'; import { useTempHome } from './temp-home.js'; export interface TempProjectBase { @@ -11,10 +9,9 @@ export interface TempProjectBase { /** 工作区数据的公共根:`~/.codingcode/project`。 */ export function projectBaseDir(): string { - return join(getGlobalDir(), PROJECTS_DIRNAME); + return projectRootDir(); } - export function useTempProjectBase(prefix = 'codingcode-test-project-base-'): TempProjectBase { useTempHome(prefix); beforeEach(() => { diff --git a/packages/codingcode/test/hooks/config-merge.test.ts b/packages/codingcode/test/hooks/config-merge.test.ts index d65f3caf..90ad0ed1 100644 --- a/packages/codingcode/test/hooks/config-merge.test.ts +++ b/packages/codingcode/test/hooks/config-merge.test.ts @@ -12,7 +12,7 @@ import { setGlobalHookEnabled, setProjectHookEnabled, } from '../../src/hooks/config.js'; -import type { UserHookConfig } from '../../src/contracts/hooks.js'; +import type { UserHookConfig } from '../../src/hooks/types.js'; import { useTempHome, setFakeHome } from '../helpers/temp-home.js'; let projectDir: string; @@ -49,7 +49,10 @@ describe('Hooks config merge', () => { }); it('merges global and project by name, project wins', () => { - writeGlobalHookConfigs([hook('global-hook', 'global-cmd'), hook('shared-hook', 'global-shared')]); + writeGlobalHookConfigs([ + hook('global-hook', 'global-cmd'), + hook('shared-hook', 'global-shared'), + ]); writeHookConfigs(projectDir, [ hook('shared-hook', 'project-shared'), hook('project-hook', 'project-cmd'), diff --git a/packages/codingcode/test/hooks/decision.test.ts b/packages/codingcode/test/hooks/decision.test.ts index 2aa9a8f2..87e46c11 100644 --- a/packages/codingcode/test/hooks/decision.test.ts +++ b/packages/codingcode/test/hooks/decision.test.ts @@ -93,9 +93,27 @@ describe('HookService.emitDecision(YAML 定义的决策 hook)', () => { 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 }, + { + 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"}' }); @@ -113,8 +131,20 @@ describe('HookService.emitDecision(YAML 定义的决策 hook)', () => { 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 }, + { + 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"}' }); @@ -132,8 +162,20 @@ describe('HookService.emitDecision(YAML 定义的决策 hook)', () => { 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 }, + { + 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"}' }); @@ -193,7 +235,10 @@ describe('HookService.emitDecision(YAML 定义的决策 hook)', () => { Effect.gen(function* () { const hooks = yield* HookService; yield* hooks.reloadUserHooks(testDir); - return yield* hooks.emitDecision('agent.turn.stop', { projectPath: testDir, content: 'hi' }); + return yield* hooks.emitDecision('agent.turn.stop', { + projectPath: testDir, + content: 'hi', + }); }) ); diff --git a/packages/codingcode/test/hooks/points-coverage.test.ts b/packages/codingcode/test/hooks/points-coverage.test.ts index f4117441..8ba05ef0 100644 --- a/packages/codingcode/test/hooks/points-coverage.test.ts +++ b/packages/codingcode/test/hooks/points-coverage.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest'; import { readFileSync, readdirSync } from 'fs'; import { join, resolve } from 'path'; +import ts from 'typescript'; const SRC = resolve(__dirname, '../../src'); +const HOOK_END_TYPES = join(SRC, 'hooks', 'types.ts'); function listTsFiles(dir: string): string[] { const out: string[] = []; @@ -14,21 +16,53 @@ function listTsFiles(dir: string): string[] { return out; } -/** 契约里声明的 HookPoint 联合成员 */ +function parse(file: string): ts.SourceFile { + return ts.createSourceFile( + file, + readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); +} + +/** hooks/types.ts 里声明的 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]!); + const sf = parse(HOOK_END_TYPES); + for (const stmt of sf.statements) { + if ( + ts.isTypeAliasDeclaration(stmt) && + stmt.name.text === 'HookPoint' && + ts.isUnionTypeNode(stmt.type) + ) { + return stmt.type.types.flatMap((t) => + ts.isLiteralTypeNode(t) && ts.isStringLiteral(t.literal) ? [t.literal.text] : [] + ); + } + } + return []; } -/** 生产代码里真正 emit 的点 */ +/** 生产代码里真正 emit 的点:`hooks.emit('x')` / `hooks.emitDecision('x')` */ 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]!); - } + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + if ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'hooks' && + (callee.name.text === 'emit' || callee.name.text === 'emitDecision') + ) { + const arg = node.arguments[0]; + if (arg && ts.isStringLiteral(arg)) found.add(arg.text); + } + } + ts.forEachChild(node, visit); + }; + visit(parse(file)); } return found; } @@ -46,7 +80,7 @@ describe('HookPoint 契约与触发点一致', () => { expect(undeclared).toEqual([]); }); - it('契约本身非空(防止上面两条正则失配后静默通过)', () => { + 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 ac054dd2..92e68e35 100644 --- a/packages/codingcode/test/hooks/registry.test.ts +++ b/packages/codingcode/test/hooks/registry.test.ts @@ -96,7 +96,9 @@ describe('HookService.emit(YAML 定义的观察者)', () => { }); it('enabled: false 的 hook 不注册', async () => { - writeHooksYaml([{ name: 'off', point: 'tool.execute.before', command: 'cmd-off', enabled: false }]); + writeHooksYaml([ + { name: 'off', point: 'tool.execute.before', command: 'cmd-off', enabled: false }, + ]); await runWithLayer( Effect.gen(function* () { @@ -219,9 +221,7 @@ describe('HookService.emit(YAML 定义的观察者)', () => { }); it('只在全局层定义的 hook 在项目层没写任何东西时照常生效', async () => { - writeGlobalHooksYaml([ - { name: 'g', point: 'tool.execute.before', command: 'cmd-global' }, - ]); + writeGlobalHooksYaml([{ name: 'g', point: 'tool.execute.before', command: 'cmd-global' }]); await runWithLayer( Effect.gen(function* () { diff --git a/packages/codingcode/test/infra/models.test.ts b/packages/codingcode/test/infra/models.test.ts index bf36ffc0..5728b61d 100644 --- a/packages/codingcode/test/infra/models.test.ts +++ b/packages/codingcode/test/infra/models.test.ts @@ -84,11 +84,7 @@ describe('catalog - listModels / findModel', () => { const { listModels } = await import('../../src/infra/models.js'); const ids = listModels().map((m) => m.id); - expect(ids).toEqual([ - 'model-x@provider-a', - 'model-y@provider-a', - 'model-x@provider-b', - ]); + expect(ids).toEqual(['model-x@provider-a', 'model-y@provider-a', 'model-x@provider-b']); }); it('prefers the exact compound id over a bare-name match', async () => { diff --git a/packages/codingcode/test/llm/deepseek-provider.test.ts b/packages/codingcode/test/llm/deepseek-provider.test.ts index 4a3bbb4d..3845cae2 100644 --- a/packages/codingcode/test/llm/deepseek-provider.test.ts +++ b/packages/codingcode/test/llm/deepseek-provider.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { LLMStreamPart } from '../../src/contracts/provider.js'; +import type { LLMStreamPart } from '../../src/llm/types.js'; const streamText = vi.fn(); const stepCountIs = vi.fn((count: number) => ({ count })); diff --git a/packages/codingcode/test/llm/openai-provider.test.ts b/packages/codingcode/test/llm/openai-provider.test.ts index 0f7b5fc9..8c6e8b89 100644 --- a/packages/codingcode/test/llm/openai-provider.test.ts +++ b/packages/codingcode/test/llm/openai-provider.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { LLMStreamPart } from '../../src/contracts/provider.js'; +import type { LLMStreamPart } from '../../src/llm/types.js'; const generateText = vi.fn(); const streamText = vi.fn(); @@ -106,7 +106,12 @@ describe('OpenAIProvider completeStream', () => { streamText.mockReturnValue({ fullStream: (async function* () { yield { type: 'text-delta', text: 'reading' }; - yield { type: 'tool-call', toolCallId: 'tc-1', toolName: 'read_file', input: { path: 'a.ts' } }; + yield { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'read_file', + input: { path: 'a.ts' }, + }; yield { type: 'finish', totalUsage: USAGE }; })(), }); diff --git a/packages/codingcode/test/mcp/config-merge.test.ts b/packages/codingcode/test/mcp/config-merge.test.ts index 787fc370..9fcef653 100644 --- a/packages/codingcode/test/mcp/config-merge.test.ts +++ b/packages/codingcode/test/mcp/config-merge.test.ts @@ -62,7 +62,9 @@ describe('MCP config merge', () => { }); it('project layer only overrides the fields it declares', () => { - writeGlobalMcpConfig([{ name: 'shared', command: 'global-cmd', args: ['--a'], concurrency: 5 }]); + writeGlobalMcpConfig([ + { name: 'shared', command: 'global-cmd', args: ['--a'], concurrency: 5 }, + ]); writeMcpConfig(projectDir, [{ name: 'shared', command: 'project-cmd' }]); const merged = resolveMcpConfig(projectDir); diff --git a/packages/codingcode/test/mcp/service.test.ts b/packages/codingcode/test/mcp/service.test.ts index a1c630cf..c1fb5697 100644 --- a/packages/codingcode/test/mcp/service.test.ts +++ b/packages/codingcode/test/mcp/service.test.ts @@ -167,10 +167,7 @@ describe('McpService(syncConnections / listProjectMcpTools / status)', () => }); it('建连失败的 server 被跳过,不影响同批其它 server', async () => { - mockConfigs = [ - { ...cfg('bad', [tool('x')]), _mockFail: true }, - cfg('good', [tool('ok')]), - ]; + mockConfigs = [{ ...cfg('bad', [tool('x')]), _mockFail: true }, cfg('good', [tool('ok')])]; await run( Effect.gen(function* () { diff --git a/packages/codingcode/test/memory/extractor.test.ts b/packages/codingcode/test/memory/extractor.test.ts index 81780e59..8c19d909 100644 --- a/packages/codingcode/test/memory/extractor.test.ts +++ b/packages/codingcode/test/memory/extractor.test.ts @@ -3,7 +3,7 @@ import { Effect } from 'effect'; import { extractMemory } from '../../src/memory/extractor.js'; import { AgentError } from '../../src/core/error.js'; import type { LLMShape } from '../../src/llm/port.js'; -import type { LLMRequest } from '../../src/contracts/provider.js'; +import type { LLMRequest } from '../../src/llm/types.js'; const TEST_MODEL = 'demo-model@demo'; diff --git a/packages/codingcode/test/memory/index.test.ts b/packages/codingcode/test/memory/index.test.ts index a647e1eb..d3705344 100644 --- a/packages/codingcode/test/memory/index.test.ts +++ b/packages/codingcode/test/memory/index.test.ts @@ -155,9 +155,7 @@ describe('flushSessionToMemory', () => { it('gracefully handles an LLM failure', async () => { await enableConfig(); const { readTranscript } = await import('../../src/session/file-ops.js'); - vi.mocked(readTranscript).mockImplementation(() => [ - { type: 'user', content: 'hello' }, - ] as any); + vi.mocked(readTranscript).mockImplementation(() => [{ type: 'user', content: 'hello' }] as any); mockLlm.complete.mockImplementation(() => Effect.fail(new AgentError('LLM_FAILED', 'llm unavailable')) ); @@ -169,10 +167,13 @@ describe('flushSessionToMemory', () => { await enableConfig(); writeMemory('### 旧主题\n- 旧内容'); const { readTranscript } = await import('../../src/session/file-ops.js'); - vi.mocked(readTranscript).mockImplementation(() => [ - { type: 'user', content: '记住新架构决策' }, - { type: 'assistant', content: '好的' }, - ] as any); + vi.mocked(readTranscript).mockImplementation( + () => + [ + { type: 'user', content: '记住新架构决策' }, + { type: 'assistant', content: '好的' }, + ] as any + ); setLlmResponse('### 项目\n- 新的架构决策'); const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); @@ -187,9 +188,7 @@ describe('flushSessionToMemory', () => { await enableConfig(); writeMemory('### 旧主题\n- 旧内容'); const { readTranscript } = await import('../../src/session/file-ops.js'); - vi.mocked(readTranscript).mockImplementation(() => [ - { type: 'user', content: 'hello' }, - ] as any); + vi.mocked(readTranscript).mockImplementation(() => [{ type: 'user', content: 'hello' }] as any); setLlmResponse(''); const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); @@ -202,9 +201,9 @@ describe('flushSessionToMemory', () => { await enableConfig(); writeMemory('### 主题\n- 不变的内容'); const { readTranscript } = await import('../../src/session/file-ops.js'); - vi.mocked(readTranscript).mockImplementation(() => [ - { type: 'user', content: '无新信息' }, - ] as any); + vi.mocked(readTranscript).mockImplementation( + () => [{ type: 'user', content: '无新信息' }] as any + ); setLlmResponse('### 主题\n- 不变的内容'); const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); @@ -216,9 +215,7 @@ describe('flushSessionToMemory', () => { await enableConfig(); writeMemory('### 旧主题\n- 旧内容'); const { readTranscript } = await import('../../src/session/file-ops.js'); - vi.mocked(readTranscript).mockImplementation(() => [ - { type: 'user', content: 'hello' }, - ] as any); + vi.mocked(readTranscript).mockImplementation(() => [{ type: 'user', content: 'hello' }] as any); setLlmResponse('### 自动\n- 新记忆', () => { writeMemory('### 手动\n- 用户并发编辑'); }); diff --git a/packages/codingcode/test/plan/allowed-tools.test.ts b/packages/codingcode/test/plan/allowed-tools.test.ts index e1303286..a386173c 100644 --- a/packages/codingcode/test/plan/allowed-tools.test.ts +++ b/packages/codingcode/test/plan/allowed-tools.test.ts @@ -1,15 +1,13 @@ import { describe, it, expect } from 'vitest'; import { - PLAN_PROFILE, - BUILD_PROFILE, - getToolNames, - PLAN_TOOL_NAMES, BUILD_TOOL_NAMES, -} from '../../src/agent/profile.js'; + PLAN_TOOL_NAMES, + getToolNames, +} from '../../src/approval/tool-policy.js'; describe('getToolNames (profile tool name list)', () => { it('plan profile includes submit_plan and excludes write tools', () => { - const names = getToolNames(PLAN_PROFILE); + const names = getToolNames('plan'); expect(names).toContain('submit_plan'); expect(names).toContain('read_file'); expect(names).not.toContain('write_file'); @@ -17,7 +15,7 @@ describe('getToolNames (profile tool name list)', () => { }); it('build profile includes write tools and excludes submit_plan', () => { - const names = getToolNames(BUILD_PROFILE); + const names = getToolNames('build'); expect(names).toContain('write_file'); expect(names).toContain('execute_command'); expect(names).not.toContain('submit_plan'); diff --git a/packages/codingcode/test/plan/gate-pipeline.test.ts b/packages/codingcode/test/plan/gate-pipeline.test.ts index 6b08ea96..54e3d583 100644 --- a/packages/codingcode/test/plan/gate-pipeline.test.ts +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -8,7 +8,7 @@ import { createRuleEngine } from '../../src/approval/rule-engine.js'; import { HookService } from '../../src/hooks/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { EventSinkService } from '../../src/sink/port.js'; -import type { ProfileName } from '../../src/contracts/types.js'; +import type { ProfileName } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase(); @@ -37,7 +37,12 @@ function makeMockEventSink() { emit: (sessionId: string, body: any) => Effect.sync(() => { if (body?.family === 'event' && body.event?.type === 'approval_request') { - capturedApproval = { sessionId, id: body.event.id, tool: body.event.tool, args: body.event.args }; + capturedApproval = { + sessionId, + id: body.event.id, + tool: body.event.tool, + args: body.event.args, + }; } }), }; diff --git a/packages/codingcode/test/plan/is-plan-profile.test.ts b/packages/codingcode/test/plan/is-plan-profile.test.ts index 6633c3d5..438c55ee 100644 --- a/packages/codingcode/test/plan/is-plan-profile.test.ts +++ b/packages/codingcode/test/plan/is-plan-profile.test.ts @@ -1,22 +1,21 @@ import { describe, it, expect } from 'vitest'; -import { isPlanProfile } from '../../src/agent/profile.js'; +import { isPlanProfile } from '../../src/session/types.js'; describe('isPlanProfile', () => { - it('returns true for a profile named "plan"', () => { - expect(isPlanProfile({ name: 'plan' })).toBe(true); + it('returns true for "plan"', () => { + expect(isPlanProfile('plan')).toBe(true); }); it('returns false for "build"', () => { - expect(isPlanProfile({ name: 'build' })).toBe(false); + expect(isPlanProfile('build')).toBe(false); }); it('returns false for an arbitrary profile name', () => { - expect(isPlanProfile({ name: 'custom' })).toBe(false); + expect(isPlanProfile('custom')).toBe(false); }); it('returns false for null/undefined', () => { expect(isPlanProfile(null)).toBe(false); expect(isPlanProfile(undefined)).toBe(false); }); - }); diff --git a/packages/codingcode/test/plan/policy.test.ts b/packages/codingcode/test/plan/policy.test.ts index 6c38c4eb..a2b52b08 100644 --- a/packages/codingcode/test/plan/policy.test.ts +++ b/packages/codingcode/test/plan/policy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { PLAN_ALLOWED_TOOLS } from '../../src/contracts/permission.js'; +import { PLAN_ALLOWED_TOOLS } from '../../src/approval/tool-policy.js'; describe('PLAN_ALLOWED_TOOLS', () => { it('does not expose write tools', () => { diff --git a/packages/codingcode/test/scheduler/store.test.ts b/packages/codingcode/test/scheduler/store.test.ts index bc49f98c..f25ec4ab 100644 --- a/packages/codingcode/test/scheduler/store.test.ts +++ b/packages/codingcode/test/scheduler/store.test.ts @@ -3,7 +3,7 @@ import { existsSync, rmSync, mkdirSync, writeFileSync } from 'fs'; import { resolve, join } from 'path'; import { tmpdir } from 'os'; import { readAutomations, writeAutomations } from '../../src/scheduler/store.js'; -import type { Automation } from '../../src/contracts/automation.js'; +import type { Automation } from '../../src/scheduler/types.js'; const testDir = resolve(tmpdir(), 'codingcode-test-scheduler-store'); const testFile = join(testDir, 'automations.yaml'); diff --git a/packages/codingcode/test/security/plan-profile-restart.test.ts b/packages/codingcode/test/security/plan-profile-restart.test.ts index 8bdd8560..a3eca6a6 100644 --- a/packages/codingcode/test/security/plan-profile-restart.test.ts +++ b/packages/codingcode/test/security/plan-profile-restart.test.ts @@ -9,7 +9,7 @@ import { HookService } from '../../src/hooks/port.js'; import { ApprovalService } from '../../src/approval/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { EventSinkLayer } from '../../src/sink/sink.js'; -import type { ProfileName } from '../../src/contracts/types.js'; +import type { ProfileName } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; import { ApprovalLayer } from '../../src/approval/approval.js'; @@ -81,11 +81,7 @@ describe('plan profile security boundary (permission-mode, disk-persisted profil rmSync(cwd, { recursive: true, force: true }); }); - async function evaluateAsProfile( - tool: string, - input: any, - profile: ProfileName - ): Promise { + async function evaluateAsProfile(tool: string, input: any, profile: ProfileName): Promise { return rt.runPromise( Effect.gen(function* () { const approval = yield* ApprovalService; @@ -102,7 +98,11 @@ describe('plan profile security boundary (permission-mode, disk-persisted profil } it('plan profile: write_file is denied by permission-mode', async () => { - const decision = await evaluateAsProfile('write_file', { path: '/tmp/x', content: 'foo' }, 'plan'); + const decision = await evaluateAsProfile( + 'write_file', + { path: '/tmp/x', content: 'foo' }, + 'plan' + ); expect(decision.type).toBe('deny'); expect(decision.reason).toMatch(/plan profile/i); expect(decision.source).toBe('permission-mode'); @@ -116,7 +116,11 @@ describe('plan profile security boundary (permission-mode, disk-persisted profil }); it('plan profile: submit_plan is allowed by the plan allow-list', async () => { - const decision: any = await evaluateAsProfile('submit_plan', { plan_content: 'do things' }, 'plan'); + const decision: any = await evaluateAsProfile( + 'submit_plan', + { plan_content: 'do things' }, + 'plan' + ); expect(decision.type).toBe('allow'); expect(decision.source).toBe('permission-mode'); }); @@ -136,7 +140,11 @@ describe('plan profile security boundary (permission-mode, disk-persisted profil }); it('build profile: write_file is not denied by plan permission-mode', async () => { - const decision: any = await evaluateAsProfile('write_file', { path: '/tmp/x', content: 'foo' }, 'build'); + const decision: any = await evaluateAsProfile( + 'write_file', + { path: '/tmp/x', content: 'foo' }, + 'build' + ); if (decision.type === 'deny') { expect(decision.source).not.toBe('permission-mode'); expect(decision.reason).not.toMatch(/plan profile/i); diff --git a/packages/codingcode/test/self/todo/service.test.ts b/packages/codingcode/test/self/todo/service.test.ts index 05837607..97ef8d8d 100644 --- a/packages/codingcode/test/self/todo/service.test.ts +++ b/packages/codingcode/test/self/todo/service.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { Effect } from 'effect'; import { TodoService, countByStatus } from '../../../src/todo/port.js'; -import type { TodoItem } from '../../../src/contracts/types.js'; +import type { TodoItem } from '../../../src/todo/types.js'; import { TodoLayer } from '../../../src/todo/todo.js'; describe('TodoService', () => { diff --git a/packages/codingcode/test/session/filter-ui.test.ts b/packages/codingcode/test/session/filter-ui.test.ts index f844edd0..99d98c44 100644 --- a/packages/codingcode/test/session/filter-ui.test.ts +++ b/packages/codingcode/test/session/filter-ui.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import type { SessionEvent } from '../../src/contracts/session.js'; +import type { SessionEvent } from '../../src/session/types.js'; import { filterForUI, sessionEventsToTurns } from '../../src/session/session.js'; function makeBaseEvents(extra: SessionEvent[] = []): SessionEvent[] { diff --git a/packages/codingcode/test/session/fork.test.ts b/packages/codingcode/test/session/fork.test.ts index 30cdfdd7..6c22bbcc 100644 --- a/packages/codingcode/test/session/fork.test.ts +++ b/packages/codingcode/test/session/fork.test.ts @@ -7,7 +7,7 @@ import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { filterForContext, buildContextMessages } from '../../src/context/context.js'; import { readHistory } from '../../src/session/file-ops.js'; -import type { SessionMetaEvent, SessionEvent } from '../../src/contracts/session.js'; +import type { SessionMetaEvent, SessionEvent } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; import { computePaths } from '../../src/session/paths.js'; diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index ecec3f19..3ff262ab 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -11,7 +11,7 @@ import { readHistory, readLastUsage, readSessionMeta } from '../../src/session/f import { estimateTokensForContent } from '../../src/context/tokens.js'; import { encodeProjectPath } from '../../src/core/path.js'; import { computePaths } from '../../src/session/paths.js'; -import type { SessionStoreState } from '../../src/contracts/session.js'; +import type { SessionStoreState } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); diff --git a/packages/codingcode/test/session/store-compact-usage.test.ts b/packages/codingcode/test/session/store-compact-usage.test.ts index d4d45d33..9d40948a 100644 --- a/packages/codingcode/test/session/store-compact-usage.test.ts +++ b/packages/codingcode/test/session/store-compact-usage.test.ts @@ -7,7 +7,7 @@ import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/session/paths.js'; -import type { SessionStoreState } from '../../src/contracts/session.js'; +import type { SessionStoreState } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); diff --git a/packages/codingcode/test/session/store-diff-rebuild.test.ts b/packages/codingcode/test/session/store-diff-rebuild.test.ts index d188d7ea..09c9678a 100644 --- a/packages/codingcode/test/session/store-diff-rebuild.test.ts +++ b/packages/codingcode/test/session/store-diff-rebuild.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import type { SessionEvent } from '../../src/contracts/session.js'; +import type { SessionEvent } from '../../src/session/types.js'; import { sessionEventsToTurns } from '../../src/session/session.js'; describe('sessionEventsToTurns', () => { diff --git a/packages/codingcode/test/session/store-rollback-usage.test.ts b/packages/codingcode/test/session/store-rollback-usage.test.ts index 324f4766..d8f247b3 100644 --- a/packages/codingcode/test/session/store-rollback-usage.test.ts +++ b/packages/codingcode/test/session/store-rollback-usage.test.ts @@ -7,7 +7,7 @@ import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/session/paths.js'; -import type { SessionStoreState } from '../../src/contracts/session.js'; +import type { SessionStoreState } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); diff --git a/packages/codingcode/test/session/ui-history-rollback.test.ts b/packages/codingcode/test/session/ui-history-rollback.test.ts index b5e1588b..37ebd8c7 100644 --- a/packages/codingcode/test/session/ui-history-rollback.test.ts +++ b/packages/codingcode/test/session/ui-history-rollback.test.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'crypto'; import { filterForContext, buildContextMessages } from '../../src/context/context.js'; import { readHistory } from '../../src/session/file-ops.js'; import { filterForUI } from '../../src/session/session.js'; -import type { SessionEvent } from '../../src/contracts/session.js'; +import type { SessionEvent } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); diff --git a/packages/codingcode/test/session/view-assembly.test.ts b/packages/codingcode/test/session/view-assembly.test.ts index eaa1086d..f7d9f393 100644 --- a/packages/codingcode/test/session/view-assembly.test.ts +++ b/packages/codingcode/test/session/view-assembly.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { filterForContext, buildContextMessages } from '../../src/context/context.js'; -import type { SessionEvent } from '../../src/contracts/session.js'; +import type { SessionEvent } from '../../src/session/types.js'; function toMessages(events: SessionEvent[]) { const { visible, compactedTurnIds } = filterForContext(events); diff --git a/packages/codingcode/test/sink/sink.test.ts b/packages/codingcode/test/sink/sink.test.ts index f2b43091..5ca1a8d2 100644 --- a/packages/codingcode/test/sink/sink.test.ts +++ b/packages/codingcode/test/sink/sink.test.ts @@ -2,12 +2,15 @@ import { describe, it, expect } from 'vitest'; import { Effect, Queue } from 'effect'; import { EventSinkService } from '../../src/sink/port.js'; import { EventSinkLayer } from '../../src/sink/sink.js'; -import type { FrameBody } from '../../src/contracts/frame.js'; +import type { FrameBody } from '../../src/sink/types.js'; const run = (eff: Effect.Effect): Promise => Effect.runPromise(eff.pipe(Effect.provide(EventSinkLayer))); -const textDelta = (t: string): FrameBody => ({ family: 'event', event: { type: 'text_delta', text: t } }); +const textDelta = (t: string): FrameBody => ({ + family: 'event', + event: { type: 'text_delta', text: t }, +}); const toolCall = (id: string): FrameBody => ({ family: 'event', event: { type: 'tool_call', id, name: 'read_file', args: {} }, @@ -56,10 +59,14 @@ describe('EventSink', () => { }); it('未挂载的会话 emit 是静默丢弃', async () => { - await expect(run(Effect.gen(function* () { - const sink = yield* EventSinkService; - yield* sink.emit('never-attached', textDelta('x')); - }))).resolves.toBeUndefined(); + await expect( + run( + Effect.gen(function* () { + const sink = yield* EventSinkService; + yield* sink.emit('never-attached', textDelta('x')); + }) + ) + ).resolves.toBeUndefined(); }); it('同一队列内先入先出:投递顺序 == 取出顺序', async () => { diff --git a/packages/codingcode/test/skills/index.test.ts b/packages/codingcode/test/skills/index.test.ts index 43333745..a2865e6d 100644 --- a/packages/codingcode/test/skills/index.test.ts +++ b/packages/codingcode/test/skills/index.test.ts @@ -21,9 +21,7 @@ const runWithSkill = (f: (skill: SkillSvc) => Effect.Effect): A => ); /** Run multiple operations against the same SkillService instance (shared cache). */ -const runWithSharedSkill = ( - ...ops: Array<(skill: SkillSvc) => Effect.Effect> -): A[] => +const runWithSharedSkill = (...ops: Array<(skill: SkillSvc) => Effect.Effect>): A[] => Effect.runSync( Effect.gen(function* () { const skill = yield* SkillService; @@ -98,9 +96,7 @@ Test the skill system. writeFileSync(join(skillDir, 'scripts', 'run.sh'), 'secret script'); writeFileSync(join(skillDir, 'assets', 'image.bin'), Buffer.from([0, 1, 2, 3])); - const skill = runWithSkill((s) => s.getAll(TEST_ROOT)).find( - (s) => s.name === 'metadata-only' - ); + const skill = runWithSkill((s) => s.getAll(TEST_ROOT)).find((s) => s.name === 'metadata-only'); expect(skill).toEqual({ name: 'metadata-only', diff --git a/packages/codingcode/test/subagent/registry.test.ts b/packages/codingcode/test/subagent/registry.test.ts index e20340c9..ef8eec9a 100644 --- a/packages/codingcode/test/subagent/registry.test.ts +++ b/packages/codingcode/test/subagent/registry.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect } from 'vitest'; import { Effect, Layer, Queue } from 'effect'; -import { SubagentRunRegistryLayer, SubagentRunRegistryService } from '../../src/subagent/registry.js'; +import { + SubagentRunRegistryLayer, + SubagentRunRegistryService, +} from '../../src/subagent/registry.js'; import { SubagentRunnerService } from '../../src/subagent/port.js'; import { MailboxLayer, MailboxService } from '../../src/session/mailbox.js'; import { EventSinkService } from '../../src/sink/port.js'; import { HookService } from '../../src/hooks/port.js'; -import type { FrameBody } from '../../src/contracts/frame.js'; +import type { FrameBody } from '../../src/sink/types.js'; const spawnOpts = { prompt: 'do a thing', @@ -76,9 +79,7 @@ function makeHarness(makeStream: () => AsyncGenerator) { } const run = (layers: Layer.Layer, eff: Effect.Effect): Promise => - Effect.runPromise( - eff.pipe(Effect.provide(layers)) as unknown as Effect.Effect - ); + Effect.runPromise(eff.pipe(Effect.provide(layers)) as unknown as Effect.Effect); describe('subagent run registry', () => { it('spawn 立即返回句柄,不等子代理结束', async () => { @@ -108,7 +109,12 @@ describe('subagent run registry', () => { expect(emitted[0]?.sessionId).toBe('parent-1'); expect(emitted[0]?.body).toMatchObject({ family: 'event', - event: { type: 'subagent_event', sessionId: 'child-1', agentName: 'build', status: 'spawned' }, + event: { + type: 'subagent_event', + sessionId: 'child-1', + agentName: 'build', + status: 'spawned', + }, }); }); @@ -126,7 +132,11 @@ describe('subagent run registry', () => { }) ); expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ type: 'subagent_result', sessionId: 'child-1', agentName: 'build' }); + expect(result[0]).toMatchObject({ + type: 'subagent_result', + sessionId: 'child-1', + agentName: 'build', + }); expect(result[0]!.content).toContain('hello'); expect(result[0]!.content).toContain('Message Type: FINAL_ANSWER'); diff --git a/packages/codingcode/test/subagent/runner-service.test.ts b/packages/codingcode/test/subagent/runner-service.test.ts index c7d2b223..b7e5717a 100644 --- a/packages/codingcode/test/subagent/runner-service.test.ts +++ b/packages/codingcode/test/subagent/runner-service.test.ts @@ -51,7 +51,10 @@ describe('SubagentRunnerService', () => { ( Effect.gen(function* () { const runner = yield* SubagentRunnerService; - const { stream, sessionId } = yield* runner.runSubagent('go', { cwd: '/test', model: 'm' }); + const { stream, sessionId } = yield* runner.runSubagent('go', { + cwd: '/test', + model: 'm', + }); expect(sessionId).toBe('child-1'); // Consume the async generator outside the Effect generator return yield* Effect.async((resume) => { diff --git a/packages/codingcode/test/subagent/runner-wiring.test.ts b/packages/codingcode/test/subagent/runner-wiring.test.ts index 75b23314..4ba87772 100644 --- a/packages/codingcode/test/subagent/runner-wiring.test.ts +++ b/packages/codingcode/test/subagent/runner-wiring.test.ts @@ -29,10 +29,11 @@ import { encodeProjectPath, normalizePath } from '../../src/core/path.js'; import { computePaths } from '../../src/session/paths.js'; import { transcriptPathFor } from '../../src/context/context.js'; import { projectBaseDir } from '../helpers/project-base.js'; -import type { SessionMetaEvent, SessionRef } from '../../src/contracts/session.js'; -import type { Message } from '../../src/contracts/types.js'; -import type { LLMRequest } from '../../src/contracts/provider.js'; -import type { FrameBody } from '../../src/contracts/frame.js'; +import type { SessionMetaEvent } from '../../src/session/types.js'; +import type { SessionRef } from '../../src/session/types.js'; +import type { Message, LLMRequest } from '../../src/llm/types.js'; + +import type { FrameBody } from '../../src/sink/types.js'; const CHILD_MODEL = 'child-model'; const PARENT_MODEL = 'parent-model'; @@ -115,13 +116,16 @@ const HookMock = Layer.succeed(HookService, { reloadUserHooks: () => Effect.succeed(undefined), } as any); -const TodoMock = Layer.succeed(TodoService, { read: () => [], write: () => {}, reset: () => {} } as any); +const TodoMock = Layer.succeed(TodoService, { + read: () => [], + write: () => {}, + reset: () => {}, +} as any); const AgentDeps = Layer.mergeAll( SessionLayer, Layer.succeed(ToolExecutorService, { - executeBatch: () => Effect.succeed([]), - prepare: () => Effect.succeed({ tools: [], lookup: () => undefined }), + prepare: () => Effect.succeed({ tools: [], executeBatch: () => Effect.succeed([]) }), } as any), Layer.succeed(CheckpointService, { snapshotBaseline: () => Effect.void, @@ -161,7 +165,14 @@ const AgentWired = AgentLayer.pipe(Layer.provide(AgentDeps as any)); // 真实 SubagentRunnerLayer:这一层到 agent.runTurn 的参数折叠正是阶段一要修的地方 const SubagentWired = SubagentRunnerLayer.pipe(Layer.provide(AgentWired)); -const Runtime = Layer.mergeAll(AgentWired, SubagentWired, SessionLayer, HookMock, McpMock, TodoMock); +const Runtime = Layer.mergeAll( + AgentWired, + SubagentWired, + SessionLayer, + HookMock, + McpMock, + TodoMock +); function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(Runtime as any)) as any); @@ -246,9 +257,7 @@ describe('subagent runner wiring (child session mounts under the parent)', () => const reloaded = await run( Effect.gen(function* () { const session = yield* SessionService; - const ok = yield* Effect.either( - session.load(normalizePath(cwd), childId, parentId) - ); + const ok = yield* Effect.either(session.load(normalizePath(cwd), childId, parentId)); const withoutParent = yield* Effect.either(session.load(normalizePath(cwd), childId)); return { ok: ok._tag === 'Right' ? ok.right : null, withoutParent }; }) diff --git a/packages/codingcode/test/tools/catalog.test.ts b/packages/codingcode/test/tools/catalog.test.ts index 3488406e..803d3988 100644 --- a/packages/codingcode/test/tools/catalog.test.ts +++ b/packages/codingcode/test/tools/catalog.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { Effect } from 'effect'; import { createToolCatalog } from '../../src/tools/catalog.js'; -import type { McpToolSpec } from '../../src/contracts/mcp.js'; +import type { McpToolSpec } from '../../src/mcp/types.js'; const BUILD_NAMES = [ 'read_file', diff --git a/packages/codingcode/test/tools/executor-concurrency.test.ts b/packages/codingcode/test/tools/executor-concurrency.test.ts index ebc2f0dc..694592d6 100644 --- a/packages/codingcode/test/tools/executor-concurrency.test.ts +++ b/packages/codingcode/test/tools/executor-concurrency.test.ts @@ -11,11 +11,12 @@ import { McpService } from '../../src/mcp/port.js'; import { SubagentRunnerService } from '../../src/subagent/port.js'; import { SubagentRunRegistryService } from '../../src/subagent/registry.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'; +import type { ToolCall } from '../../src/llm/types.js'; +import type { TodoItem } from '../../src/todo/types.js'; +import type { ToolResult } from '../../src/tools/types.js'; +import type { McpToolSpec } from '../../src/mcp/types.js'; +import type { HookPoint } from '../../src/hooks/types.js'; +import type { FrameBody } from '../../src/sink/types.js'; // 桩 hook 在工具执行窗口内 sleep,把窗口拉宽到足以用「区间是否相交」判定并发/串行 const WINDOW_MS = 150; @@ -92,8 +93,7 @@ function makeHarness(): Harness { }; const runner = { - runSubagent: () => - Effect.succeed({ stream: makeSubagentStream(200), sessionId: 'child-1' }), + runSubagent: () => Effect.succeed({ stream: makeSubagentStream(200), sessionId: 'child-1' }), }; // spawn_agent 消费注册表(生产装配在 AppLayer,这里只提供最小桩) @@ -141,10 +141,9 @@ function runBatch( // 只把内置名交给 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, { + return yield* catalog.executeBatch(calls, ctx.sessionId, { projectPath: ctx.projectPath, signal: ctx.signal, - toolLookup: catalog.lookup, activeProfile: ctx.activeProfile, model: ctx.model, }); @@ -301,10 +300,7 @@ describe('executeBatch 保序波次调度', () => { 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 calls = [tc('r1', 'read_file', { path: 'a.txt' }), tc('m1', 'srv:slow', {})]; const makeSpec = (readOnlyHint: boolean): McpToolSpec => ({ server: 'srv', name: 'slow', @@ -315,13 +311,17 @@ describe('executeBatch 保序波次调度', () => { }); h.clear(); - const serial = await runBatch(calls, { projectPath: dir, model: 'test-model' }, h, [makeSpec(false)]); + const serial = await runBatch(calls, { projectPath: dir, model: 'test-model' }, 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, model: 'test-model' }, h, [makeSpec(true)]); + const parallel = await runBatch(calls, { projectPath: dir, model: 'test-model' }, h, [ + makeSpec(true), + ]); expect(parallel.map((r) => r.status)).toEqual(['ok', 'ok']); expect(overlaps(intervalFor(h, 'r1'), intervalFor(h, 'm1'))).toBe(true); }); diff --git a/packages/codingcode/test/types/type-collapse.test.ts b/packages/codingcode/test/types/type-collapse.test.ts index 0f4dee8f..2ea1afcf 100644 --- a/packages/codingcode/test/types/type-collapse.test.ts +++ b/packages/codingcode/test/types/type-collapse.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'vitest'; -import type { UITurn, UITurnItem } from '../../src/contracts/session.js'; -import type { TodoItem } from '../../src/contracts/types.js'; +import type { UITurn, UITurnItem } from '../../src/session/types.js'; +import type { TodoItem } from '../../src/todo/types.js'; // 本文件只做编译期类型约束(tsc 执行 @ts-expect-error 校验),不含运行时断言。 describe('类型收口', () => { From c83ca0ea182b200c36a55fa03e5456238ac86577 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 8 Oct 2026 13:39:48 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E6=94=B6=E6=95=9BcreateRunWithLayer?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=94=B9=E7=AB=AF=E5=8F=A3=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/configuration.md | 6 +- packages/codingcode/src/agent/agent.ts | 2 +- packages/codingcode/src/cli.ts | 27 +- packages/codingcode/src/core/error.ts | 6 +- packages/codingcode/src/infra/config.ts | 6 - packages/codingcode/src/server/handler.ts | 4 +- packages/codingcode/src/server/http-error.ts | 12 + packages/codingcode/src/server/index.ts | 18 +- .../codingcode/src/server/port-discovery.ts | 26 -- .../codingcode/src/server/routes/approval.ts | 22 +- .../src/server/routes/automations.ts | 115 ++--- .../codingcode/src/server/routes/memory.ts | 10 +- .../codingcode/src/server/routes/messages.ts | 28 +- .../codingcode/src/server/routes/sessions.ts | 417 +++++++----------- .../codingcode/src/server/routes/skills.ts | 6 +- .../codingcode/src/server/routes/subagents.ts | 28 +- packages/codingcode/src/server/util.ts | 46 +- .../codingcode/src/tools/domains/fs/edit.ts | 4 +- .../codingcode/src/tools/domains/fs/glob.ts | 2 +- .../codingcode/src/tools/domains/fs/grep.ts | 4 +- .../codingcode/src/tools/domains/fs/read.ts | 2 +- .../codingcode/src/tools/domains/fs/write.ts | 4 +- .../codingcode/src/tools/domains/web/fetch.ts | 4 +- .../src/tools/domains/web/search.ts | 2 +- packages/codingcode/test/agent/abort.test.ts | 1 - .../test/agent/agent-concurrent.test.ts | 1 - .../test/agent/agent-todo-event.test.ts | 1 - packages/codingcode/test/agent/agent.test.ts | 1 - .../test/agent/hooks-deps-type.test.ts | 1 - .../test/agent/loop-options.test.ts | 1 - .../test/agent/memory-snapshot.test.ts | 1 - .../codingcode/test/agent/stop-hook.test.ts | 1 - .../test/agent/submit-plan-turn-end.test.ts | 1 - .../test/context/append-turn-end.test.ts | 1 - packages/codingcode/test/orchestrate.test.ts | 1 - .../create-session-active-profile.test.ts | 2 + .../messages-fork-permission-mode.test.ts | 2 + .../test/server/plan-file-route.test.ts | 4 + .../test/server/session-title-route.test.ts | 2 + .../test/server/subagents-route.test.ts | 8 +- packages/codingcode/test/server/util.test.ts | 39 +- 41 files changed, 350 insertions(+), 519 deletions(-) delete mode 100644 packages/codingcode/src/server/port-discovery.ts diff --git a/docs/configuration.md b/docs/configuration.md index 4ddbed5b..8a1d9f1c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,9 +24,6 @@ Coding Code 的核心哲学是所有行为都可配置。本文档详细介绍 ### 完整配置项 ```yaml -server: - port: 8080 # HTTP 服务端口 - maxSteps: 200 # Agent 最大步数 maxStopContinuations: 2 # 最大停止续行次数 @@ -47,7 +44,6 @@ memory: | 字段 | 默认值 | 说明 | |------|--------|------| -| `server.port` | `8080` | HTTP 服务监听端口 | | `maxSteps` | `200` | 单次 Agent 执行的最大步数限制 | | `maxStopContinuations` | `2` | Agent 停止后最大续行次数 | | `activeModel` | 无(可选) | 覆盖 models.json 中的默认模型,不设置则使用 models.json 配置 | @@ -56,6 +52,8 @@ memory: | `memory.model` | `''` | 记忆提取使用的模型,空字符串回退到主模型 | | `memory.promptMaxBytes` | `8192` | 注入 system prompt 的记忆内容最大字节数 | +> **HTTP 端口不可配置**。服务启动时监听端口 `0`,由操作系统原子地分配一个空闲端口,避免多实例或“先探测再绑定”之间的竞态。实际端口通过 stdout 的 `CODINGCODE_SERVER_READY:` 上报给拉起方(Desktop、SDK 等)。 + --- ## models.json diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index f1f0b0d8..e0eae9da 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -34,13 +34,13 @@ import { loadConfig } from '../infra/config.js'; import { createLogger } from '../infra/logger.js'; import { normalizePath } from '../core/path.js'; import { resolveProfile } from './profile.js'; +import type { AgentProfile } from './profile.js'; function toolOutcomeOf(result: ToolResult): ToolOutcome { return result.status === 'denied' ? { status: 'denied', reason: result.reason } : { status: result.status, output: result.output }; } -import type { AgentProfile } from './profile.js'; const logger = createLogger(); diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index f03cc158..821eaf68 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -1,33 +1,44 @@ import { Effect } from 'effect'; import { mkdirSync } from 'fs'; import { serve } from '@hono/node-server'; +import type { Hono } from 'hono'; import { createServer } from './server/index.js'; import { createAppRuntime } from './layer.js'; -import { loadConfig, ensureUserConfig } from './infra/config.js'; +import { ensureUserConfig } from './infra/config.js'; import { tempCwd } from './server/cwd.js'; -import { findAvailablePort } from './server/port-discovery.js'; import { AgentError } from './core/error.js'; import { SchedulerService } from './scheduler/port.js'; +function listen(app: Hono): Promise { + return new Promise((resolve, reject) => { + const server = serve({ fetch: app.fetch, port: 0 }); + server.on('error', reject); + server.on('listening', () => { + server.removeAllListeners('error'); + const address = server.address(); + if (typeof address === 'object' && address !== null) { + resolve(address.port); + } else { + reject(new Error('Server is listening on a pipe, not a TCP port')); + } + }); + }); +} + async function main() { ensureUserConfig(); mkdirSync(tempCwd(), { recursive: true }); - const config = loadConfig(); - - const basePort = config.server.port; const rt = createAppRuntime(); const program = Effect.gen(function* () { - const port = yield* Effect.tryPromise(() => findAvailablePort(basePort)); - // Initialize scheduler with the shared runtime const scheduler = yield* SchedulerService; scheduler.setRuntime(rt); scheduler.initialize(); const app = yield* Effect.tryPromise(() => createServer(rt)); - serve({ fetch: app.fetch, port }); + const port = yield* Effect.tryPromise(() => listen(app)); console.log(`CODINGCODE_SERVER_READY:${port}`); }); diff --git a/packages/codingcode/src/core/error.ts b/packages/codingcode/src/core/error.ts index b3459d39..1ce75d31 100644 --- a/packages/codingcode/src/core/error.ts +++ b/packages/codingcode/src/core/error.ts @@ -1,4 +1,4 @@ -export type ErrorCode = +type ErrorCode = | 'LLM_TIMEOUT' | 'LLM_RATE_LIMITED' | 'LLM_FAILED' @@ -6,7 +6,6 @@ export type ErrorCode = | 'TOOL_NOT_FOUND' | 'TOOL_NOT_ALLOWED' | 'TOOL_EXECUTION_FAILED' - | 'PATH_NOT_ALLOWED' | 'MAX_STEPS_REACHED' | 'CONFIG_MISSING' | 'CONFIG_INVALID' @@ -47,9 +46,6 @@ export class AgentError extends Error { static toolExecutionFailed(name: string, e: unknown) { return new AgentError('TOOL_EXECUTION_FAILED', `Tool "${name}" failed: ${String(e)}`, e); } - static pathNotAllowed(path: string) { - return new AgentError('PATH_NOT_ALLOWED', `Path "${path}" is outside allowed scope`); - } static maxStepsReached(max: number) { return new AgentError('MAX_STEPS_REACHED', `Max steps (${max}) reached`); } diff --git a/packages/codingcode/src/infra/config.ts b/packages/codingcode/src/infra/config.ts index 7876b490..7aa6c8ab 100644 --- a/packages/codingcode/src/infra/config.ts +++ b/packages/codingcode/src/infra/config.ts @@ -23,9 +23,6 @@ export interface SubagentConfig { } export interface AppConfig { - server: { - port: number; - }; maxSteps: number; maxStopContinuations: number; activeModel?: ActiveModelConfig; @@ -51,9 +48,6 @@ export const DEFAULT_SUBAGENT: SubagentConfig = { }; export const DEFAULT_CONFIG: AppConfig = { - server: { - port: 8080, - }, maxSteps: 200, maxStopContinuations: 2, activeProfile: 'build', diff --git a/packages/codingcode/src/server/handler.ts b/packages/codingcode/src/server/handler.ts index 36b7c63f..407ae6d2 100644 --- a/packages/codingcode/src/server/handler.ts +++ b/packages/codingcode/src/server/handler.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { ApprovalWaitService } from '../approval/wait-port.js'; -import { AgentError } from '../core/error.js'; +import { isHttpError } from './http-error.js'; import type { FrameBody } from '../sink/types.js'; import { createFrameAssembler, encodeFrame } from './frame-io.js'; @@ -39,7 +39,7 @@ export function createSseHandler(rt: ManagedRt) { family: 'fatal', fatal: { message: e instanceof Error ? e.message : String(e), - code: e instanceof AgentError ? e.code : 'INTERNAL_ERROR', + code: isHttpError(e) ? e.code : 'INTERNAL_ERROR', }, }); } finally { diff --git a/packages/codingcode/src/server/http-error.ts b/packages/codingcode/src/server/http-error.ts index 0f435604..2eb9648c 100644 --- a/packages/codingcode/src/server/http-error.ts +++ b/packages/codingcode/src/server/http-error.ts @@ -19,3 +19,15 @@ export class NotFoundError extends Error { return 404; } } + +/** 自带 HTTP 语义的错误:`code` 决定响应体,`httpStatus()` 决定状态码 */ +export interface HttpError extends Error { + readonly code: string; + httpStatus(): number; +} + +export function isHttpError(err: unknown): err is HttpError { + if (typeof err !== 'object' || err === null) return false; + const e = err as { code?: unknown; httpStatus?: unknown }; + return typeof e.code === 'string' && typeof e.httpStatus === 'function'; +} diff --git a/packages/codingcode/src/server/index.ts b/packages/codingcode/src/server/index.ts index a9be2c48..c6cfed91 100644 --- a/packages/codingcode/src/server/index.ts +++ b/packages/codingcode/src/server/index.ts @@ -8,28 +8,14 @@ import { registerApprovalRoutes } from './routes/approval.js'; import { registerSettingsRoutes } from './routes/settings.js'; import { registerAutomationsRoutes } from './routes/automations.js'; import { registerSubagentsRoutes } from './routes/subagents.js'; -import { AgentError } from '../core/error.js'; +import { registerErrorHandler } from './util.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; export async function createServer(rt: ManagedRt): Promise { const app = new Hono(); - app.onError((err, c) => { - if (err instanceof AgentError) { - return c.json({ error: { code: err.code, message: err.message } }, err.httpStatus() as any); - } - if ( - err && - typeof (err as { code?: unknown }).code === 'string' && - typeof (err as { httpStatus?: unknown }).httpStatus === 'function' - ) { - const e = err as unknown as { code: string; message: string; httpStatus: () => number }; - return c.json({ error: { code: e.code, message: e.message } }, e.httpStatus() as any); - } - console.error('[500 INTERNAL_ERROR]', err); - return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }, 500); - }); + registerErrorHandler(app); app.use( '*', diff --git a/packages/codingcode/src/server/port-discovery.ts b/packages/codingcode/src/server/port-discovery.ts deleted file mode 100644 index c2e581de..00000000 --- a/packages/codingcode/src/server/port-discovery.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createServer as createNetServer } from 'net'; - -function isPortFree(port: number): Promise { - return new Promise((resolve, reject) => { - const server = createNetServer(); - server.once('error', (err: any) => { - if (err.code === 'EADDRINUSE') { - resolve(false); - } else { - reject(err); - } - }); - server.once('listening', () => { - server.close(); - resolve(true); - }); - server.listen(port); - }); -} - -export async function findAvailablePort(startPort: number): Promise { - for (let port = startPort; port < startPort + 100; port++) { - if (await isPortFree(port)) return port; - } - throw new Error(`No available port in range [${startPort}, ${startPort + 100})`); -} diff --git a/packages/codingcode/src/server/routes/approval.ts b/packages/codingcode/src/server/routes/approval.ts index aa10ec96..8bb24881 100644 --- a/packages/codingcode/src/server/routes/approval.ts +++ b/packages/codingcode/src/server/routes/approval.ts @@ -2,11 +2,13 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { ApprovalWaitService } from '../../approval/wait-port.js'; import { parseApprovalResponse } from '../../approval/confirmation.js'; -import { errorResponse } from '../util.js'; +import { createRunWithLayer } from '../util.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; export function registerApprovalRoutes(router: Hono, rt: ManagedRt): void { + const runWithLayer = createRunWithLayer(rt); + router.post('/api/sessions/:sessionId/approval/:id', async (c) => { const id = c.req.param('id'); const sessionId = c.req.param('sessionId'); @@ -15,25 +17,13 @@ export function registerApprovalRoutes(router: Hono, rt: ManagedRt): void { }; const response = typeof body.response === 'string' ? body.response : ''; - const result = await rt.runPromise( + const resolved = await runWithLayer( Effect.gen(function* () { const svc = yield* ApprovalWaitService; return yield* svc.resolveConfirm(id, sessionId, parseApprovalResponse(response)); - }).pipe( - Effect.catchAllDefect((defect) => - Effect.fail(new Error(`Unexpected error: ${String(defect)}`)) - ), - Effect.match({ - onSuccess: (a) => ({ ok: true as const, value: a }), - onFailure: (e) => ({ ok: false as const, error: e }), - }) - ) + }) ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - return c.json({ ok: result.value }); + return c.json({ ok: resolved }); }); } diff --git a/packages/codingcode/src/server/routes/automations.ts b/packages/codingcode/src/server/routes/automations.ts index 2cba62d7..302ec877 100644 --- a/packages/codingcode/src/server/routes/automations.ts +++ b/packages/codingcode/src/server/routes/automations.ts @@ -1,35 +1,24 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { SchedulerService } from '../../scheduler/port.js'; -import { errorBody, errorResponse } from '../util.js'; +import { createRunWithLayer, errorBody } from '../util.js'; import { NotFoundError } from '../http-error.js'; import type { CreateAutomationInput, UpdateAutomationInput } from '../../scheduler/types.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; export function registerAutomationsRoutes(router: Hono, rt: ManagedRt): void { + const runWithLayer = createRunWithLayer(rt); + router.get('/api/automations', async (c) => { - const result = await rt.runPromise( - Effect.gen(function* () { - const scheduler = yield* SchedulerService; - return scheduler.list(); - }).pipe( - Effect.catchAllDefect((defect) => - Effect.fail(new Error(`Unexpected error: ${String(defect)}`)) - ), - Effect.match({ - onSuccess: (a) => ({ ok: true as const, value: a }), - onFailure: (e) => ({ ok: false as const, error: e }), + return c.json( + await runWithLayer( + Effect.gen(function* () { + const scheduler = yield* SchedulerService; + return scheduler.list(); }) ) ); - - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - - return c.json(result.value); }); router.post('/api/automations', async (c) => { @@ -42,117 +31,63 @@ export function registerAutomationsRoutes(router: Hono, rt: ManagedRt): void { ); } - const result = await rt.runPromise( + const created = await runWithLayer( Effect.gen(function* () { const scheduler = yield* SchedulerService; return scheduler.add(body); - }).pipe( - Effect.catchAllDefect((defect) => - Effect.fail(new Error(`Unexpected error: ${String(defect)}`)) - ), - Effect.match({ - onSuccess: (a) => ({ ok: true as const, value: a }), - onFailure: (e) => ({ ok: false as const, error: e }), - }) - ) + }) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - - return c.json(result.value, 201); + return c.json(created, 201); }); router.patch('/api/automations/:id', async (c) => { const id = c.req.param('id'); const body = (await c.req.json()) as UpdateAutomationInput; - const result = await rt.runPromise( + const updated = await runWithLayer( Effect.gen(function* () { const scheduler = yield* SchedulerService; - const updated = scheduler.update(id, body); - if (!updated) { + const result = scheduler.update(id, body); + if (!result) { return yield* Effect.fail(new NotFoundError(`Automation '${id}' not found`)); } - return updated; - }).pipe( - Effect.catchAllDefect((defect) => - Effect.fail(new Error(`Unexpected error: ${String(defect)}`)) - ), - Effect.match({ - onSuccess: (a) => ({ ok: true as const, value: a }), - onFailure: (e) => ({ ok: false as const, error: e }), - }) - ) + return result; + }) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - - return c.json(result.value); + return c.json(updated); }); router.delete('/api/automations/:id', async (c) => { const id = c.req.param('id'); - const result = await rt.runPromise( + const removed = await runWithLayer( Effect.gen(function* () { const scheduler = yield* SchedulerService; - const removed = scheduler.remove(id); - if (!removed) { + if (!scheduler.remove(id)) { return yield* Effect.fail(new NotFoundError(`Automation '${id}' not found`)); } return { ok: true }; - }).pipe( - Effect.catchAllDefect((defect) => - Effect.fail(new Error(`Unexpected error: ${String(defect)}`)) - ), - Effect.match({ - onSuccess: (a) => ({ ok: true as const, value: a }), - onFailure: (e) => ({ ok: false as const, error: e }), - }) - ) + }) ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - - return c.json(result.value); + return c.json(removed); }); router.post('/api/automations/:id/run', async (c) => { const id = c.req.param('id'); - const result = await rt.runPromise( + const sessionId = await runWithLayer( Effect.gen(function* () { const scheduler = yield* SchedulerService; - const sessionId = yield* Effect.tryPromise({ + return yield* Effect.tryPromise({ try: () => scheduler.runOnce(id), catch: () => new NotFoundError(`Automation '${id}' not found or execution failed`), }); - return { sessionId }; - }).pipe( - Effect.catchAllDefect((defect) => - Effect.fail(new Error(`Unexpected error: ${String(defect)}`)) - ), - Effect.match({ - onSuccess: (a) => ({ ok: true as const, value: a }), - onFailure: (e) => ({ ok: false as const, error: e }), - }) - ) + }) ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - - return c.json(result.value); + return c.json({ sessionId }); }); } diff --git a/packages/codingcode/src/server/routes/memory.ts b/packages/codingcode/src/server/routes/memory.ts index 1624e778..6f1d765c 100644 --- a/packages/codingcode/src/server/routes/memory.ts +++ b/packages/codingcode/src/server/routes/memory.ts @@ -3,10 +3,13 @@ import { Effect, ManagedRuntime } from 'effect'; import { MemoryService } from '../../memory/port.js'; import { getMemoryConfig } from '../../memory/config.js'; import { updateMemoryModel } from '../../infra/config.js'; +import { createRunWithLayer } from '../util.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; export function registerMemorySettingsRoutes(router: Hono, rt: ManagedRt): void { + const runWithLayer = createRunWithLayer(rt); + router.get('/api/settings/memory/config', (c) => { const cfg = getMemoryConfig(); return c.json({ @@ -17,15 +20,10 @@ export function registerMemorySettingsRoutes(router: Hono, rt: ManagedRt): void router.post('/api/settings/memory/enabled', async (c) => { const body = (await c.req.json()) as { enabled: boolean }; - await rt.runPromise( + const enabled = await runWithLayer( Effect.gen(function* () { const m = yield* MemoryService; yield* m.setMemoryEnabled(body.enabled); - }) - ); - const enabled = await rt.runPromise( - Effect.gen(function* () { - const m = yield* MemoryService; return yield* m.getMemoryEnabled(); }) ); diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index d7ef63ff..6498cff3 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -5,16 +5,17 @@ import { resolveWorkspaceCwd } from '../cwd.js'; import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; import { loadConfig } from '../../infra/config.js'; -import { errorBody, errorResponse } from '../util.js'; +import { createRunWithLayer, errorBody } from '../util.js'; import { createSseHandler } from '../handler.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void { + const runWithLayer = createRunWithLayer(rt); const sseHandler = createSseHandler(rt); router.post('/api/sessions/:id/messages', async (c) => { - let sessionId = c.req.param('id'); + const sessionId = c.req.param('id'); const { input, cwd, model, skills } = await c.req.json<{ input: string; cwd: string; @@ -44,36 +45,21 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void { : 'askBeforeExec'; } - const result = await rt.runPromise( + const { stream, sessionId: actualSid } = (await runWithLayer( Effect.gen(function* () { const agent = yield* AgentService; return yield* agent.runTurn(input, { sessionId: isNew ? undefined : sessionId, ...runOpts, }); - }).pipe( - Effect.catchAllDefect((defect) => - Effect.fail(new Error(`Unexpected error: ${String(defect)}`)) - ), - Effect.match({ - onSuccess: (a) => ({ ok: true as const, value: a }), - onFailure: (e) => ({ ok: false as const, error: e }), - }) - ) - ); - - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - const { stream, sessionId: actualSid } = result.value as any; - sessionId = actualSid; + }) + )) as any; return sseHandler( async function* () { yield* stream; }, - { sessionId } + { sessionId: actualSid } )(c); }); } diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 496fb776..2a5998e9 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -13,7 +13,7 @@ import { MailboxService } from '../../session/mailbox.js'; import { estimatePromptTokensFrom } from '../../context/context.js'; import { CheckpointService } from '../../checkpoint/port.js'; import { activeModelId, setGlobalActive } from '../../infra/models.js'; -import { errorBody, errorResponse } from '../util.js'; +import { createRunWithLayer, errorBody } from '../util.js'; import { resolveCwd, resolveWorkspaceCwd } from '../cwd.js'; import { projectDataDir } from '../../core/path.js'; import { AVAILABLE_PROFILES } from '../../session/types.js'; @@ -23,33 +23,18 @@ import { isPermissionMode } from '../../approval/types.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { - const runWithLayer = async (eff: Effect.Effect) => { - return rt.runPromise( - eff.pipe( - Effect.catchAllDefect((defect) => - Effect.fail(new Error(`Unexpected error: ${String(defect)}`)) - ), - Effect.match({ - onSuccess: (a) => ({ ok: true as const, value: a }), - onFailure: (e) => ({ ok: false as const, error: e }), - }) - ) - ); - }; + const runWithLayer = createRunWithLayer(rt); router.get('/api/sessions', async (c) => { const cwd = resolveCwd(c.req.query('cwd')); - const result = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - return yield* session.listSessions(cwd); - }) as any + return c.json( + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.listSessions(cwd); + }) as any + ) ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - return c.json(result.value); }); router.post('/api/sessions', async (c) => { @@ -76,67 +61,56 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { } // 建会话即建立工作区:目录必须存在 const normalizedCwd = resolveWorkspaceCwd(body.cwd); - const result = await runWithLayer( + const state = (await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.create(normalizedCwd, { + return yield* session.create(normalizedCwd, { model: body.model, activeProfile: body.activeProfile, permissionMode: body.permissionMode, }); - return state; }) as any - ); - if (!result.ok) { - const { status, body: resp } = errorResponse(result.error); - return c.json(resp, status as any); - } - return c.json({ sessionId: (result.value as SessionStoreState).sessionId }); + )) as SessionStoreState; + return c.json({ sessionId: state.sessionId }); }); router.post('/api/sessions/:id/resume', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string }; const normalizedCwd = resolveCwd(body.cwd); - const result = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(normalizedCwd, sessionId); - return yield* session.readHistory(state); - }) as any + return c.json( + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(normalizedCwd, sessionId); + return yield* session.readHistory(state); + }) as any + ) ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - return c.json(result.value); }); router.post('/api/sessions/:id/compact', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string; model?: string }; const normalizedCwd = resolveCwd(body.cwd); - const result = await runWithLayer( - Effect.gen(function* () { - const context = yield* ContextService; - const session = yield* SessionService; - const state = yield* session.load(normalizedCwd, sessionId); - return yield* context.compact( - { - cwd: state.cwd, - sessionId: state.sessionId, - parentSessionId: state.parentSessionId, - currentTurnId: state.currentTurnId, - }, - body.model ?? '' - ); - }) + return c.json( + await runWithLayer( + Effect.gen(function* () { + const context = yield* ContextService; + const session = yield* SessionService; + const state = yield* session.load(normalizedCwd, sessionId); + return yield* context.compact( + { + cwd: state.cwd, + sessionId: state.sessionId, + parentSessionId: state.parentSessionId, + currentTurnId: state.currentTurnId, + }, + body.model ?? '' + ); + }) + ) ); - if (!result.ok) { - const { status, body: resp } = errorResponse(result.error); - return c.json(resp, status as any); - } - return c.json(result.value); }); router.delete('/api/sessions/:id', async (c) => { @@ -160,17 +134,14 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); if (!cwd) return c.json(errorBody('CONFIG_MISSING', 'cwd required'), 400); - const result = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - return yield* session.readUITurns(sessionId, cwd); - }) as any + return c.json( + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.readUITurns(sessionId, cwd); + }) as any + ) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json(result.value); }); // ---- Plan file: read the current plan document for a session ---- @@ -219,7 +190,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.get('/api/sessions/:id/profile', async (c) => { const sessionId = c.req.param('id'); const cwd = resolveCwd(c.req.query('cwd')); - const result = await runWithLayer( + const value = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); @@ -229,12 +200,8 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { }; }) ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } return c.json({ - ...result.value, + ...value, cwd, available: AVAILABLE_PROFILES, }); @@ -248,40 +215,34 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { if (!isAgentProfileName(activeProfile)) { return c.json(errorBody('CONFIG_INVALID', `Invalid activeProfile: ${activeProfile}`), 400); } - const result = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - yield* session.setActiveProfile(cwd, sessionId, activeProfile); - const state = yield* session.load(cwd, sessionId); - return { - activeProfile: state.activeProfile, - permissionMode: state.permissionMode, - }; - }) + return c.json( + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setActiveProfile(cwd, sessionId, activeProfile); + const state = yield* session.load(cwd, sessionId); + return { + activeProfile: state.activeProfile, + permissionMode: state.permissionMode, + }; + }) + ) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json(result.value); }); router.get('/api/sessions/:id/permission-mode', async (c) => { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); if (!cwd) return c.json({ mode: 'askBeforeExec' }); - const result = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - return { mode: state.permissionMode }; - }) as any + return c.json( + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + return { mode: state.permissionMode }; + }) as any + ) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json(result.value); }); router.put('/api/sessions/:id/permission-mode', async (c) => { @@ -291,18 +252,15 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { if (!isPermissionMode(mode)) { return c.json(errorBody('CONFIG_INVALID', `Invalid permissionMode: ${mode}`), 400); } - const setResult = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - yield* session.setPermissionMode(cwd, sessionId, mode); - return { ok: true }; - }) as any + return c.json( + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setPermissionMode(cwd, sessionId, mode); + return { ok: true }; + }) as any + ) ); - if (!setResult.ok) { - const { status, body: errBody } = errorResponse(setResult.error); - return c.json(errBody, status as any); - } - return c.json({ ok: true }); }); // ---- Model switching ---- @@ -314,28 +272,20 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { if (!model) return c.json(errorBody('CONFIG_MISSING', 'model required'), 400); if (sessionId === '_' || !sessionId) { - try { - setGlobalActive(model); - } catch (e) { - const { status, body: errBody } = errorResponse(e); - return c.json(errBody, status as any); - } + setGlobalActive(model); return c.json({ ok: true, activeId: activeModelId() }); } const cwd = resolveCwd(body.cwd); - const result = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - yield* session.setModel(cwd, sessionId, model); - return { ok: true }; - }) as any + return c.json( + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setModel(cwd, sessionId, model); + return { ok: true }; + }) as any + ) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json(result.value); }); router.put('/api/sessions/:id/title', async (c) => { @@ -344,56 +294,47 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const title = body.title?.replace(/\n/g, ' ').trim(); if (!title) return c.json(errorBody('CONFIG_MISSING', 'title required'), 400); const cwd = resolveCwd(body.cwd); - const result = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - yield* session.renameSession(state, title); - return { ok: true }; - }) as any + return c.json( + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + yield* session.renameSession(state, title); + return { ok: true }; + }) as any + ) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json(result.value); }); router.get('/api/sessions/:id/checkpoints/latest/diff', async (c) => { const sessionId = c.req.param('id'); const cwd = resolveCwd(c.req.query('cwd')); - const result = await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.getCheckpointDiff(cwd, sessionId); - }) + return c.json( + await runWithLayer( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.getCheckpointDiff(cwd, sessionId); + }) + ) ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - return c.json(result.value); }); router.get('/api/sessions/:id/checkpoints/:turnId/diff', async (c) => { const sessionId = c.req.param('id'); const turnId = parseInt(c.req.param('turnId'), 10); const cwd = resolveCwd(c.req.query('cwd')); - const result = await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.getCheckpointDiff( - cwd, - sessionId, - isNaN(turnId) ? undefined : turnId - ); - }) + return c.json( + await runWithLayer( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.getCheckpointDiff( + cwd, + sessionId, + isNaN(turnId) ? undefined : turnId + ); + }) + ) ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - return c.json(result.value); }); router.post('/api/sessions/:id/checkpoints/latest/revert-file', async (c) => { @@ -406,11 +347,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { return yield* checkpoint.revertCheckpointFiles(cwd, sessionId, undefined, [body.file]); }) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json({ ok: true, result: result.value }); + return c.json({ ok: true, result }); }); router.post('/api/sessions/:id/checkpoints/latest/revert-files', async (c) => { @@ -423,28 +360,21 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { return yield* checkpoint.revertCheckpointFiles(cwd, sessionId, undefined, body.files); }) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json({ ok: true, result: result.value }); + return c.json({ ok: true, result }); }); router.get('/api/sessions/:id/rollback-preview', async (c) => { const sessionId = c.req.param('id'); const cwd = resolveCwd(c.req.query('cwd')); const throughTurnId = parseInt(c.req.query('throughTurnId') ?? '0', 10); - const result = await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.previewRollbackDiff(cwd, sessionId, throughTurnId); - }) + return c.json( + await runWithLayer( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.previewRollbackDiff(cwd, sessionId, throughTurnId); + }) + ) ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - return c.json(result.value); }); router.post('/api/sessions/:id/rollback-code-to-turn', async (c) => { @@ -457,63 +387,57 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { return yield* checkpoint.rollbackCodeToTurn(cwd, sessionId, body.throughTurnId); }) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json({ ok: true, result: result.value }); + return c.json({ ok: true, result }); }); router.post('/api/sessions/:id/rollback-context', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string; throughTurnId: number }; const cwd = resolveCwd(body.cwd); - const result = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); - const turns = yield* session.readUITurns(sessionId, cwd); - const promptEstimate = estimatePromptTokensFrom(yield* session.readHistory(state)); - const usage = state.usage; - return { ok: true, turns, promptEstimate, usage }; - }) as any + return c.json( + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); + const turns = yield* session.readUITurns(sessionId, cwd); + const promptEstimate = estimatePromptTokensFrom(yield* session.readHistory(state)); + const usage = state.usage; + return { ok: true, turns, promptEstimate, usage }; + }) as any + ) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json(result.value); }); router.post('/api/sessions/:id/rollback-both-to-turn', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string; throughTurnId: number }; const cwd = resolveCwd(body.cwd); - const result = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const checkpoint = yield* CheckpointService; - const codeResult = yield* checkpoint.rollbackCodeToTurn(cwd, sessionId, body.throughTurnId); - const state = yield* session.load(cwd, sessionId); - yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); - const turns = yield* session.readUITurns(sessionId, cwd); - const promptEstimate = estimatePromptTokensFrom(yield* session.readHistory(state)); - const usage = state.usage; - return { - ok: true, - turns, - codeResult, - promptEstimate, - usage, - }; - }) as any + return c.json( + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + const checkpoint = yield* CheckpointService; + const codeResult = yield* checkpoint.rollbackCodeToTurn( + cwd, + sessionId, + body.throughTurnId + ); + const state = yield* session.load(cwd, sessionId); + yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); + const turns = yield* session.readUITurns(sessionId, cwd); + const promptEstimate = estimatePromptTokensFrom(yield* session.readHistory(state)); + const usage = state.usage; + return { + ok: true, + turns, + codeResult, + promptEstimate, + usage, + }; + }) as any + ) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json(result.value); }); router.post('/api/sessions/:id/fork', async (c) => { @@ -521,21 +445,18 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const body = (await c.req.json()) as { cwd: string; atTurnId?: number }; const cwd = resolveCwd(body.cwd); const atTurnId = body.atTurnId ?? 0; - const result = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - const newSessionId = yield* session.forkSession(state, atTurnId); - const turns = yield* session.readUITurns(newSessionId, cwd); - const newJsonlPath = computePaths(cwd, newSessionId).transcriptPath; - const promptEstimate = estimatePromptTokensFrom(yield* session.readEvents(newJsonlPath)); - return { sessionId: newSessionId, turns, promptEstimate }; - }) as any + return c.json( + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + const newSessionId = yield* session.forkSession(state, atTurnId); + const turns = yield* session.readUITurns(newSessionId, cwd); + const newJsonlPath = computePaths(cwd, newSessionId).transcriptPath; + const promptEstimate = estimatePromptTokensFrom(yield* session.readEvents(newJsonlPath)); + return { sessionId: newSessionId, turns, promptEstimate }; + }) as any + ) ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json(result.value); }); } diff --git a/packages/codingcode/src/server/routes/skills.ts b/packages/codingcode/src/server/routes/skills.ts index aff6f3b6..3244e024 100644 --- a/packages/codingcode/src/server/routes/skills.ts +++ b/packages/codingcode/src/server/routes/skills.ts @@ -15,13 +15,12 @@ export function registerSkillsSettingsRoutes(router: Hono, rt: ManagedRt): void const rawCwd = c.req.query('cwd'); if (isGlobalCwd(rawCwd)) { const cwd = resolveCwd(rawCwd); - const result = await runWithLayer( + const skills = await runWithLayer( Effect.gen(function* () { const skill = yield* SkillService; return yield* skill.getAll(cwd); }) ); - const skills = result.ok ? result.value : []; return c.json( skills.map((s) => ({ ...s, @@ -31,13 +30,12 @@ export function registerSkillsSettingsRoutes(router: Hono, rt: ManagedRt): void } const cwd = resolveCwd(rawCwd); const globalDirPaths = new Set(discoverGlobalSkillDirs().map((d) => d.dirPath)); - const result = await runWithLayer( + const skills = await runWithLayer( Effect.gen(function* () { const skill = yield* SkillService; return yield* skill.getAll(cwd); }) ); - const skills = result.ok ? result.value : []; // 按 SKILL.md 所在目录判定来源;同名同时存在于全局与项目时,两条各自标注真实来源 return c.json( skills.map((s) => ({ diff --git a/packages/codingcode/src/server/routes/subagents.ts b/packages/codingcode/src/server/routes/subagents.ts index 1c322361..9a9039fa 100644 --- a/packages/codingcode/src/server/routes/subagents.ts +++ b/packages/codingcode/src/server/routes/subagents.ts @@ -1,34 +1,24 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { SubagentRunRegistryService } from '../../subagent/registry.js'; -import { errorResponse } from '../util.js'; +import { createRunWithLayer } from '../util.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; export function registerSubagentsRoutes(router: Hono, rt: ManagedRt): void { + const runWithLayer = createRunWithLayer(rt); + router.post('/api/sessions/:id/subagents/stop', async (c) => { const sessionId = c.req.param('id'); - const result = await rt.runPromise( - Effect.gen(function* () { - const registry = yield* SubagentRunRegistryService; - return yield* registry.stopAll(sessionId); - }).pipe( - Effect.catchAllDefect((defect) => - Effect.fail(new Error(`Unexpected error: ${String(defect)}`)) - ), - Effect.match({ - onSuccess: (stopped) => ({ ok: true as const, value: { stopped } }), - onFailure: (e) => ({ ok: false as const, error: e }), + return c.json( + await runWithLayer( + Effect.gen(function* () { + const registry = yield* SubagentRunRegistryService; + const stopped = yield* registry.stopAll(sessionId); + return { stopped }; }) ) ); - - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - - return c.json(result.value); }); } diff --git a/packages/codingcode/src/server/util.ts b/packages/codingcode/src/server/util.ts index 73e9e5f3..7ba2ce45 100644 --- a/packages/codingcode/src/server/util.ts +++ b/packages/codingcode/src/server/util.ts @@ -1,35 +1,33 @@ -import { Effect, ManagedRuntime } from 'effect'; -import { AgentError } from '../core/error.js'; +import type { Hono } from 'hono'; +import { Cause, Effect, Exit, ManagedRuntime } from 'effect'; +import { isHttpError } from './http-error.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; -export type Result = { ok: true; value: A } | { ok: false; error: E }; +function toError(u: unknown): Error { + if (u instanceof Error) return u; + return new Error(typeof u === 'string' ? u : `Unexpected error: ${String(u)}`); +} + export function createRunWithLayer(rt: ManagedRt) { - return async function runWithLayer(eff: Effect.Effect): Promise> { - return rt.runPromise( - eff.pipe( - Effect.catchAllDefect((defect) => - Effect.fail( - new AgentError('SESSION_IO_ERROR' as any, `Unexpected error: ${String(defect)}`, defect) - ) - ), - Effect.match({ - onSuccess: (a) => ({ ok: true as const, value: a }), - onFailure: (e) => ({ ok: false as const, error: e as E }), - }) - ) - ) as Promise>; + return async function runWithLayer(eff: Effect.Effect): Promise { + const exit = await rt.runPromiseExit(eff); + if (Exit.isSuccess(exit)) return exit.value; + throw toError(Cause.squash(exit.cause)); }; } -/** 错误信封的唯一构造点:`{ error: { code, message } }` */ export function errorBody(code: string, message: string) { return { error: { code, message } }; } -export function errorResponse(err: unknown) { - if (err instanceof AgentError) { +/** 错误值 → HTTP 状态码 + 响应体。**唯一映射点**,只由 `onError` 调用。 */ +export function errorResponse(err: unknown): { + status: number; + body: { error: { code: string; message: string } }; +} { + if (isHttpError(err)) { return { status: err.httpStatus(), body: errorBody(err.code, err.message) }; } return { @@ -37,3 +35,11 @@ export function errorResponse(err: unknown) { body: errorBody('INTERNAL_ERROR', err instanceof Error ? err.message : 'Internal server error'), }; } + +export function registerErrorHandler(app: Hono): void { + app.onError((err, c) => { + const { status, body } = errorResponse(err); + if (status >= 500) console.error(`[${status} ${body.error.code}]`, err); + return c.json(body, status as any); + }); +} diff --git a/packages/codingcode/src/tools/domains/fs/edit.ts b/packages/codingcode/src/tools/domains/fs/edit.ts index c878cfae..2455860d 100644 --- a/packages/codingcode/src/tools/domains/fs/edit.ts +++ b/packages/codingcode/src/tools/domains/fs/edit.ts @@ -28,7 +28,7 @@ export const editFileTool: ToolDefinition = { const filePath = resolve(ctx?.projectPath ?? process.cwd(), path); const content = yield* Effect.tryPromise({ try: () => readFile(filePath, 'utf-8'), - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', String(e), e), + catch: (e) => AgentError.toolExecutionFailed('edit_file', e), }); let idx = 0; @@ -53,7 +53,7 @@ export const editFileTool: ToolDefinition = { content.slice(0, lastIdx) + new_string + content.slice(lastIdx + old_string.length); yield* Effect.tryPromise({ try: () => writeFile(filePath, newContent), - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', String(e), e), + catch: (e) => AgentError.toolExecutionFailed('edit_file', e), }); const newLines = newContent.split('\n').length; diff --git a/packages/codingcode/src/tools/domains/fs/glob.ts b/packages/codingcode/src/tools/domains/fs/glob.ts index d691f028..0e7cb1c5 100644 --- a/packages/codingcode/src/tools/domains/fs/glob.ts +++ b/packages/codingcode/src/tools/domains/fs/glob.ts @@ -43,7 +43,7 @@ export const globTool: ToolDefinition = { absolute: true, onlyFiles: true, }), - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', String(e), e), + catch: (e) => AgentError.toolExecutionFailed('search_files', e), }); const truncated = files.slice(0, max_results); diff --git a/packages/codingcode/src/tools/domains/fs/grep.ts b/packages/codingcode/src/tools/domains/fs/grep.ts index 93166d60..70b8ca8b 100644 --- a/packages/codingcode/src/tools/domains/fs/grep.ts +++ b/packages/codingcode/src/tools/domains/fs/grep.ts @@ -37,7 +37,7 @@ export const searchTool: ToolDefinition = { ignore: ['node_modules/**', 'dist/**', '.git/**', '*.lockb', '*.lock', '*.min.js'], absolute: true, }), - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', String(e), e), + catch: (e) => AgentError.toolExecutionFailed('search_code', e), }); const filesToScan = files.slice(0, 200); @@ -49,7 +49,7 @@ export const searchTool: ToolDefinition = { const contentResult = yield* Effect.either( Effect.tryPromise({ try: () => readFile(file, 'utf-8'), - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', String(e), e), + catch: (e) => AgentError.toolExecutionFailed('search_code', e), }) ); if (contentResult._tag === 'Left') continue; diff --git a/packages/codingcode/src/tools/domains/fs/read.ts b/packages/codingcode/src/tools/domains/fs/read.ts index c3aff2f5..f6e77f53 100644 --- a/packages/codingcode/src/tools/domains/fs/read.ts +++ b/packages/codingcode/src/tools/domains/fs/read.ts @@ -26,7 +26,7 @@ export const readFileTool: ToolDefinition = { const filePath = resolve(ctx?.projectPath ?? process.cwd(), path); const content = yield* Effect.tryPromise({ try: () => readFile(filePath, 'utf-8'), - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', String(e), e), + catch: (e) => AgentError.toolExecutionFailed('read_file', e), }); const lines = content.split('\n'); const start = Math.max(0, offset - 1); diff --git a/packages/codingcode/src/tools/domains/fs/write.ts b/packages/codingcode/src/tools/domains/fs/write.ts index 93e0b6e2..d1837c1d 100644 --- a/packages/codingcode/src/tools/domains/fs/write.ts +++ b/packages/codingcode/src/tools/domains/fs/write.ts @@ -21,11 +21,11 @@ export const writeFileTool: ToolDefinition = { const filePath = resolve(base, path); yield* Effect.tryPromise({ try: () => mkdir(dirname(filePath), { recursive: true }), - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', String(e), e), + catch: (e) => AgentError.toolExecutionFailed('write_file', e), }); yield* Effect.tryPromise({ try: () => writeFile(filePath, content), - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', String(e), e), + catch: (e) => AgentError.toolExecutionFailed('write_file', e), }); const relPath = relative(base, filePath) || '.'; return `File written: ${relPath} (${content.split('\n').length} lines, ${content.length} bytes)`; diff --git a/packages/codingcode/src/tools/domains/web/fetch.ts b/packages/codingcode/src/tools/domains/web/fetch.ts index 6631795e..280db1ae 100644 --- a/packages/codingcode/src/tools/domains/web/fetch.ts +++ b/packages/codingcode/src/tools/domains/web/fetch.ts @@ -34,7 +34,7 @@ export const webFetchTool: ToolDefinition = { }, redirect: 'follow', }), - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', String(e), e), + catch: (e) => AgentError.toolExecutionFailed('fetch_url', e), }); if (!response.ok) { @@ -44,7 +44,7 @@ export const webFetchTool: ToolDefinition = { const contentType = response.headers.get('content-type') || ''; const text = yield* Effect.tryPromise({ try: () => response.text(), - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', String(e), e), + catch: (e) => AgentError.toolExecutionFailed('fetch_url', e), }); const truncated = text.length > max_length diff --git a/packages/codingcode/src/tools/domains/web/search.ts b/packages/codingcode/src/tools/domains/web/search.ts index f281b81c..ed3b2182 100644 --- a/packages/codingcode/src/tools/domains/web/search.ts +++ b/packages/codingcode/src/tools/domains/web/search.ts @@ -173,7 +173,7 @@ export const webSearchTool: ToolDefinition = { const engineResult = yield* Effect.either( Effect.tryPromise({ try: () => engine(query, max_results, controller.signal), - catch: (e) => new AgentError('TOOL_EXECUTION_FAILED', String(e), e), + catch: (e) => AgentError.toolExecutionFailed('web_search', e), }) ); diff --git a/packages/codingcode/test/agent/abort.test.ts b/packages/codingcode/test/agent/abort.test.ts index d29fba0d..9cd81e67 100644 --- a/packages/codingcode/test/agent/abort.test.ts +++ b/packages/codingcode/test/agent/abort.test.ts @@ -8,7 +8,6 @@ vi.mock('../../src/infra/config.js', () => ({ maxStopContinuations: 2, context: { compactionModel: '' }, memory: { enabled: false }, - server: { port: 8080 }, }), })); diff --git a/packages/codingcode/test/agent/agent-concurrent.test.ts b/packages/codingcode/test/agent/agent-concurrent.test.ts index 3161c054..e6109cae 100644 --- a/packages/codingcode/test/agent/agent-concurrent.test.ts +++ b/packages/codingcode/test/agent/agent-concurrent.test.ts @@ -23,7 +23,6 @@ vi.mock('../../src/infra/config.js', () => ({ maxBytes: 16384, promptMaxBytes: 8192, }, - server: { port: 8080 }, }), })); diff --git a/packages/codingcode/test/agent/agent-todo-event.test.ts b/packages/codingcode/test/agent/agent-todo-event.test.ts index 3061240b..65cb8adb 100644 --- a/packages/codingcode/test/agent/agent-todo-event.test.ts +++ b/packages/codingcode/test/agent/agent-todo-event.test.ts @@ -24,7 +24,6 @@ vi.mock('../../src/infra/config.js', () => ({ maxBytes: 16384, promptMaxBytes: 8192, }, - server: { port: 8080 }, }), })); diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index 9dfab4b5..5162e0de 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -18,7 +18,6 @@ vi.mock('../../src/infra/config.js', () => ({ maxStopContinuations: 2, context: { compactionModel: '' }, memory: { enabled: false }, - server: { port: 8080 }, }), })); diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index fa4447ae..a3b4191e 100644 --- a/packages/codingcode/test/agent/hooks-deps-type.test.ts +++ b/packages/codingcode/test/agent/hooks-deps-type.test.ts @@ -22,7 +22,6 @@ vi.mock('../../src/infra/config.js', () => ({ maxBytes: 16384, promptMaxBytes: 8192, }, - server: { port: 8080 }, }), })); diff --git a/packages/codingcode/test/agent/loop-options.test.ts b/packages/codingcode/test/agent/loop-options.test.ts index 8c8d1921..0b12f811 100644 --- a/packages/codingcode/test/agent/loop-options.test.ts +++ b/packages/codingcode/test/agent/loop-options.test.ts @@ -22,7 +22,6 @@ vi.mock('../../src/infra/config.js', () => ({ maxBytes: 16384, promptMaxBytes: 8192, }, - server: { port: 8080 }, }), })); diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index 14e2f0d3..7fdabb6d 100644 --- a/packages/codingcode/test/agent/memory-snapshot.test.ts +++ b/packages/codingcode/test/agent/memory-snapshot.test.ts @@ -14,7 +14,6 @@ vi.mock('../../src/infra/config.js', () => ({ maxBytes: 16384, promptMaxBytes: 8192, }, - server: { port: 8080 }, }), })); diff --git a/packages/codingcode/test/agent/stop-hook.test.ts b/packages/codingcode/test/agent/stop-hook.test.ts index 127e64b8..4b217c63 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.test.ts @@ -23,7 +23,6 @@ vi.mock('../../src/infra/config.js', () => ({ maxBytes: 16384, promptMaxBytes: 8192, }, - server: { port: 8080 }, }), })); diff --git a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts index 45460a2f..f4e460f3 100644 --- a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts +++ b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts @@ -21,7 +21,6 @@ vi.mock('../../src/infra/config.js', () => ({ maxBytes: 16384, promptMaxBytes: 8192, }, - server: { port: 8080 }, }), })); diff --git a/packages/codingcode/test/context/append-turn-end.test.ts b/packages/codingcode/test/context/append-turn-end.test.ts index e7ee9459..8606289f 100644 --- a/packages/codingcode/test/context/append-turn-end.test.ts +++ b/packages/codingcode/test/context/append-turn-end.test.ts @@ -16,7 +16,6 @@ vi.mock('../../src/infra/config.js', () => ({ maxBytes: 16384, promptMaxBytes: 8192, }, - server: { port: 8080 }, }), })); diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index b37f2865..35672049 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -15,7 +15,6 @@ vi.mock('../src/infra/config.js', () => ({ maxStopContinuations: 2, context: { compactionModel: '' }, memory: { enabled: false }, - server: { port: 8080 }, }), })); diff --git a/packages/codingcode/test/server/create-session-active-profile.test.ts b/packages/codingcode/test/server/create-session-active-profile.test.ts index 0f988946..76645d98 100644 --- a/packages/codingcode/test/server/create-session-active-profile.test.ts +++ b/packages/codingcode/test/server/create-session-active-profile.test.ts @@ -6,6 +6,7 @@ import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; +import { registerErrorHandler } from '../../src/server/util.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -20,6 +21,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { mkdirSync(cwd, { recursive: true }); rt = ManagedRuntime.make(SessionLayer as any); app = new Hono(); + registerErrorHandler(app); registerSessionsRoutes(app, rt); }); 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 c34a2446..96937b56 100644 --- a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts +++ b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { registerMessagesRoutes } from '../../src/server/routes/messages.js'; +import { registerErrorHandler } from '../../src/server/util.js'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { HookService } from '../../src/hooks/port.js'; @@ -84,6 +85,7 @@ describe('POST /api/sessions/:id/messages — reads permissionMode from disk', ( loadedPermissionModes.length = 0; app = new Hono(); + registerErrorHandler(app); registerMessagesRoutes(app, rt); }); diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts index ce1ac554..db1d4e6b 100644 --- a/packages/codingcode/test/server/plan-file-route.test.ts +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -7,6 +7,7 @@ import { mkdirSync, writeFileSync, utimesSync } from 'fs'; import { join, resolve } from 'path'; import { Hono } from 'hono'; import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; +import { registerErrorHandler } from '../../src/server/util.js'; import { SessionService } from '../../src/session/port.js'; import { LLMService } from '../../src/llm/port.js'; import { ApprovalService } from '../../src/approval/port.js'; @@ -152,6 +153,7 @@ describe('GET /api/sessions/:id/plan', () => { it('returns exists:false with empty content when no .md file is present', async () => { const rt = ManagedRuntime.make(TestLayer as any); const app = new Hono(); + registerErrorHandler(app); registerSessionsRoutes(app, rt); const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); expect(res.status).toBe(200); @@ -179,6 +181,7 @@ describe('GET /api/sessions/:id/plan', () => { const rt = ManagedRuntime.make(TestLayer as any); const app = new Hono(); + registerErrorHandler(app); registerSessionsRoutes(app, rt); const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); expect(res.status).toBe(200); @@ -199,6 +202,7 @@ describe('GET /api/sessions/:id/plan', () => { const rt = ManagedRuntime.make(TestLayer as any); const app = new Hono(); + registerErrorHandler(app); registerSessionsRoutes(app, rt); const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); const body = (await res.json()) as { content: string; exists: boolean }; diff --git a/packages/codingcode/test/server/session-title-route.test.ts b/packages/codingcode/test/server/session-title-route.test.ts index 79228804..c9261656 100644 --- a/packages/codingcode/test/server/session-title-route.test.ts +++ b/packages/codingcode/test/server/session-title-route.test.ts @@ -6,6 +6,7 @@ import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; +import { registerErrorHandler } from '../../src/server/util.js'; import { computePaths } from '../../src/session/paths.js'; import { readSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -22,6 +23,7 @@ describe('PUT /api/sessions/:id/title', () => { mkdirSync(cwd, { recursive: true }); rt = ManagedRuntime.make(SessionLayer as any); app = new Hono(); + registerErrorHandler(app); registerSessionsRoutes(app, rt); }); diff --git a/packages/codingcode/test/server/subagents-route.test.ts b/packages/codingcode/test/server/subagents-route.test.ts index d3572e07..14bd78e4 100644 --- a/packages/codingcode/test/server/subagents-route.test.ts +++ b/packages/codingcode/test/server/subagents-route.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; -import { Effect, Layer } from 'effect'; +import { Effect, Layer, ManagedRuntime } from 'effect'; import { registerSubagentsRoutes } from '../../src/server/routes/subagents.js'; +import { registerErrorHandler } from '../../src/server/util.js'; import { SubagentRunRegistryService } from '../../src/subagent/registry.js'; function makeApp(stopAll: (sessionId: string) => Effect.Effect) { const layer = Layer.succeed(SubagentRunRegistryService, { stopAll } as any); - const rt = { - runPromise: (eff: any) => Effect.runPromise(eff.pipe(Effect.provide(layer))), - } as any; + const rt = ManagedRuntime.make(layer); const app = new Hono(); + registerErrorHandler(app); registerSubagentsRoutes(app, rt); return app; } diff --git a/packages/codingcode/test/server/util.test.ts b/packages/codingcode/test/server/util.test.ts index ca9e92ef..3abb86c0 100644 --- a/packages/codingcode/test/server/util.test.ts +++ b/packages/codingcode/test/server/util.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { errorResponse } from '../../src/server/util.js'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { createRunWithLayer, errorResponse } from '../../src/server/util.js'; +import { AlreadyExistsError, NotFoundError } from '../../src/server/http-error.js'; import { AgentError } from '../../src/core/error.js'; describe('server/util', () => { @@ -45,5 +47,40 @@ describe('server/util', () => { const resp = errorResponse(err); expect(resp.status).toBe(429); }); + + it('returns 404 for NotFoundError(自带 code + httpStatus,不必是 AgentError)', () => { + const resp = errorResponse(new NotFoundError("Automation 'x' not found")); + expect(resp.status).toBe(404); + expect(resp.body.error.code).toBe('NOT_FOUND'); + expect(resp.body.error.message).toBe("Automation 'x' not found"); + }); + + it('returns 409 for AlreadyExistsError', () => { + const resp = errorResponse(new AlreadyExistsError("Hook 'x' already exists")); + expect(resp.status).toBe(409); + expect(resp.body.error.code).toBe('ALREADY_EXISTS'); + }); + }); + + describe('createRunWithLayer', () => { + const runWithLayer = createRunWithLayer(ManagedRuntime.make(Layer.empty as any) as any); + + it('返回成功值', async () => { + await expect(runWithLayer(Effect.succeed(1))).resolves.toBe(1); + }); + + it('正常失败原样抛出(http 语义交给 onError)', async () => { + await expect(runWithLayer(Effect.fail(new NotFoundError('nope')))).rejects.toBeInstanceOf( + NotFoundError + ); + }); + + it('把非 Error 的失败归一成 Error —— Hono onError 只接 instanceof Error', async () => { + await expect(runWithLayer(Effect.fail('boom'))).rejects.toBeInstanceOf(Error); + }); + + it('defect 同样归一成 Error,不逃出边界', async () => { + await expect(runWithLayer(Effect.die('die'))).rejects.toBeInstanceOf(Error); + }); }); }); From cb8aec947d365e39282ac19a38ba275cbd303176 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 8 Oct 2026 16:59:52 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E5=B0=86=20HTTP=20=E5=B1=82=E4=BB=8E=20Hon?= =?UTF-8?q?o=20=E8=BF=81=E7=A7=BB=E5=88=B0=20@effect/platform=EF=BC=8C?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E6=94=B9=E7=94=B1=20HttpRouter=20=E6=89=BF?= =?UTF-8?q?=E6=8B=85=EF=BC=8Chandler=20=E6=94=B9=E4=B8=BA=20Effect=20?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- package.json | 4 +- packages/codingcode/package.json | 3 +- packages/codingcode/src/cli.ts | 86 +- packages/codingcode/src/core/error.ts | 22 +- packages/codingcode/src/server/app.ts | 47 ++ packages/codingcode/src/server/handler.ts | 166 ++-- packages/codingcode/src/server/http-error.ts | 111 ++- packages/codingcode/src/server/index.ts | 49 +- .../codingcode/src/server/routes/agent.ts | 83 +- .../codingcode/src/server/routes/approval.ts | 37 +- .../src/server/routes/automations.ts | 168 ++-- .../codingcode/src/server/routes/hooks.ts | 266 ++++--- packages/codingcode/src/server/routes/mcp.ts | 266 ++++--- .../codingcode/src/server/routes/memory.ts | 56 +- .../codingcode/src/server/routes/messages.ts | 108 +-- .../codingcode/src/server/routes/models.ts | 14 +- .../codingcode/src/server/routes/sessions.ts | 735 ++++++++---------- .../codingcode/src/server/routes/settings.ts | 32 +- .../codingcode/src/server/routes/skills.ts | 67 +- .../codingcode/src/server/routes/subagents.ts | 32 +- packages/codingcode/src/server/util.ts | 45 -- .../codingcode/test/core/error-code.test.ts | 27 - packages/codingcode/test/core/error.test.ts | 48 +- .../create-session-active-profile.test.ts | 10 +- packages/codingcode/test/server/cwd.test.ts | 3 +- .../codingcode/test/server/http-error.test.ts | 119 +++ .../messages-fork-permission-mode.test.ts | 10 +- .../test/server/plan-file-route.test.ts | 16 +- .../server/request-body-validation.test.ts | 128 +++ .../test/server/router-registration.test.ts | 101 +-- .../test/server/session-title-route.test.ts | 10 +- packages/codingcode/test/server/sse.test.ts | 119 +++ .../test/server/subagents-route.test.ts | 16 +- packages/codingcode/test/server/util.test.ts | 86 -- pnpm-lock.yaml | 450 ++++++++++- pnpm-workspace.yaml | 7 +- 37 files changed, 2095 insertions(+), 1454 deletions(-) create mode 100644 packages/codingcode/src/server/app.ts delete mode 100644 packages/codingcode/src/server/util.ts delete mode 100644 packages/codingcode/test/core/error-code.test.ts create mode 100644 packages/codingcode/test/server/http-error.test.ts create mode 100644 packages/codingcode/test/server/request-body-validation.test.ts create mode 100644 packages/codingcode/test/server/sse.test.ts delete mode 100644 packages/codingcode/test/server/util.test.ts diff --git a/README.md b/README.md index 61ce8e78..444b5e4f 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ for await (const frame of clients.agent.sendMessage('帮我写一个快排', { | 运行时 | Node.js (tsx) | | DI / 错误追踪 | Effect TS 3.x | | LLM SDK | Vercel AI SDK v6 + @ai-sdk/deepseek + @ai-sdk/openai | -| HTTP 框架 | Hono 4.x | +| HTTP 框架 | @effect/platform(HttpRouter + NodeHttpServer) | | Desktop | Electron 35 + React 19 + Zustand 5 | | MCP | @modelcontextprotocol/sdk 1.29.x | | 校验 | Zod 4.x | diff --git a/package.json b/package.json index 501b2778..50ac9676 100644 --- a/package.json +++ b/package.json @@ -26,13 +26,13 @@ "dependencies": { "@ai-sdk/deepseek": "^2.0.35", "@ai-sdk/openai": "^3.0.63", - "@hono/node-server": "^2.0.2", + "@effect/platform": "^0.96.0", + "@effect/platform-node": "^0.96.0", "@modelcontextprotocol/sdk": "^1.29.0", "@types/react": "^19.2.14", "ai": "^6.0.180", "effect": "^3.21.2", "globby": "^14.1.0", - "hono": "^4.12.19", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "react": "^19.2.6", diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 5a0879b1..8fe4c437 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -29,10 +29,11 @@ "@ai-sdk/openai": "^3.0.63", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", + "@effect/platform": "0.96.0", + "@effect/platform-node": "0.96.0", "ai": "^6.0.180", "cron": "^3.5.0", "effect": "^3.21.2", - "hono": "^4.12.19", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.9.0", diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index 821eaf68..b3059636 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -1,49 +1,28 @@ -import { Effect } from 'effect'; +import { createServer as createNodeServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import * as HttpServer from '@effect/platform/HttpServer'; +import * as NodeHttpServer from '@effect/platform-node/NodeHttpServer'; +import { Effect, Layer } from 'effect'; import { mkdirSync } from 'fs'; -import { serve } from '@hono/node-server'; -import type { Hono } from 'hono'; -import { createServer } from './server/index.js'; +import { corsMiddleware, createHttpApp } from './server/app.js'; import { createAppRuntime } from './layer.js'; import { ensureUserConfig } from './infra/config.js'; import { tempCwd } from './server/cwd.js'; import { AgentError } from './core/error.js'; import { SchedulerService } from './scheduler/port.js'; -function listen(app: Hono): Promise { - return new Promise((resolve, reject) => { - const server = serve({ fetch: app.fetch, port: 0 }); - server.on('error', reject); - server.on('listening', () => { - server.removeAllListeners('error'); - const address = server.address(); - if (typeof address === 'object' && address !== null) { - resolve(address.port); - } else { - reject(new Error('Server is listening on a pipe, not a TCP port')); - } - }); - }); -} - async function main() { ensureUserConfig(); mkdirSync(tempCwd(), { recursive: true }); const rt = createAppRuntime(); - const program = Effect.gen(function* () { - // Initialize scheduler with the shared runtime - const scheduler = yield* SchedulerService; - scheduler.setRuntime(rt); - scheduler.initialize(); - - const app = yield* Effect.tryPromise(() => createServer(rt)); - const port = yield* Effect.tryPromise(() => listen(app)); - console.log(`CODINGCODE_SERVER_READY:${port}`); - }); - - const result = await rt.runPromise( - program.pipe( + const init = await rt.runPromise( + Effect.gen(function* () { + const scheduler = yield* SchedulerService; + scheduler.setRuntime(rt); + scheduler.initialize(); + }).pipe( Effect.match({ onSuccess: () => ({ type: 'ok' as const }), onFailure: (err: unknown) => ({ type: 'err' as const, err }), @@ -51,8 +30,8 @@ async function main() { ) ); - if (result.type === 'err') { - const err = result.err; + if (init.type === 'err') { + const err = init.err; if (err instanceof AgentError) { console.error(`Error [${err.code}]: ${err.message}`); process.exit(err.code === 'CONFIG_MISSING' ? 78 : 64); @@ -60,6 +39,43 @@ async function main() { console.error('Internal error:', err); process.exit(1); } + + const httpApp = await Effect.runPromise(createHttpApp()); + + const nodeServer = createNodeServer(); + + nodeServer.once('error', (err) => { + console.error('Server failed to start:', err); + process.exit(1); + }); + nodeServer.on('listening', () => { + const address = nodeServer.address() as AddressInfo | null; + if (address && typeof address === 'object') { + console.log(`CODINGCODE_SERVER_READY:${address.port}`); + } else { + console.error('Server is listening on a pipe, not a TCP port'); + process.exit(1); + } + }); + + const ServerLive = HttpServer.serve(corsMiddleware)(httpApp).pipe( + Layer.provide(NodeHttpServer.layer(() => nodeServer, { port: 0 })) + ); + + const shutdown = async () => { + nodeServer.close(); + await rt.dispose(); + process.exit(0); + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + + try { + await rt.runPromise(Layer.launch(ServerLive)); + } catch (err) { + console.error('Server stopped unexpectedly:', err); + process.exit(1); + } } main(); diff --git a/packages/codingcode/src/core/error.ts b/packages/codingcode/src/core/error.ts index 1ce75d31..d01aa451 100644 --- a/packages/codingcode/src/core/error.ts +++ b/packages/codingcode/src/core/error.ts @@ -1,4 +1,4 @@ -type ErrorCode = +export type ErrorCode = | 'LLM_TIMEOUT' | 'LLM_RATE_LIMITED' | 'LLM_FAILED' @@ -18,6 +18,8 @@ type ErrorCode = | 'SESSION_IO_ERROR'; export class AgentError extends Error { + readonly _tag = 'AgentError'; + constructor( public readonly code: ErrorCode, message: string, @@ -63,22 +65,4 @@ export class AgentError extends Error { { sessionId, expectedCwd } ); } - - httpStatus(): number { - switch (this.code) { - case 'CONFIG_MISSING': - case 'CONFIG_INVALID': - return 400; - case 'SESSION_NOT_FOUND': - return 404; - case 'SESSION_WORKSPACE_MISMATCH': - return 409; - case 'TOOL_NOT_ALLOWED': - return 403; - case 'LLM_RATE_LIMITED': - return 429; - default: - return 500; - } - } } diff --git a/packages/codingcode/src/server/app.ts b/packages/codingcode/src/server/app.ts new file mode 100644 index 00000000..35318e07 --- /dev/null +++ b/packages/codingcode/src/server/app.ts @@ -0,0 +1,47 @@ +import * as HttpMiddleware from '@effect/platform/HttpMiddleware'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import { Cause, Effect } from 'effect'; +import { toHttpServerResponse } from './http-error.js'; +import { json, type Handler } from './handler.js'; +import { addSessionsRoutes } from './routes/sessions.js'; +import { addMessagesRoutes } from './routes/messages.js'; +import { addModelsRoutes } from './routes/models.js'; +import { addApprovalRoutes } from './routes/approval.js'; +import { addSettingsRoutes } from './routes/settings.js'; +import { addAutomationsRoutes } from './routes/automations.js'; +import { addSubagentsRoutes } from './routes/subagents.js'; + +export const corsMiddleware = HttpMiddleware.cors({ + allowedOrigins: ['*'], + allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], +}); + +const health: Handler = Effect.sync(() => json({ status: 'ok' })); + +export const buildRouter = (): HttpRouter.HttpRouter => + HttpRouter.empty.pipe( + HttpRouter.get('/api/health', health), + addSessionsRoutes, + addMessagesRoutes, + addModelsRoutes, + addApprovalRoutes, + addSettingsRoutes, + addAutomationsRoutes, + addSubagentsRoutes + ); + + +export const createHttpApp = () => + HttpRouter.toHttpApp(buildRouter()).pipe( + Effect.map((app) => + app.pipe( + Effect.catchAllCause((cause) => { + const err = Cause.squash(cause); + const response = toHttpServerResponse(err); + if (response.status >= 500) console.error(`[${response.status}]`, err); + return Effect.succeed(response); + }) + ) + ) + ); diff --git a/packages/codingcode/src/server/handler.ts b/packages/codingcode/src/server/handler.ts index 407ae6d2..d31aa353 100644 --- a/packages/codingcode/src/server/handler.ts +++ b/packages/codingcode/src/server/handler.ts @@ -1,65 +1,121 @@ -import type { Context } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import * as HttpServerRequest from '@effect/platform/HttpServerRequest'; +import * as HttpServerResponse from '@effect/platform/HttpServerResponse'; +import { Effect, Stream } from 'effect'; +import { AgentError } from '../core/error.js'; import { ApprovalWaitService } from '../approval/wait-port.js'; -import { isHttpError } from './http-error.js'; import type { FrameBody } from '../sink/types.js'; import { createFrameAssembler, encodeFrame } from './frame-io.js'; +import { errorCodeOf, isAppError, type AppError } from './http-error.js'; -type ManagedRt = ManagedRuntime.ManagedRuntime; +export type Handler = HttpRouter.Route.Handler; -export function createSseHandler(rt: ManagedRt) { - return function sseHandler( - createGenerator: () => AsyncGenerator, - opts?: { sessionId?: string; onDone?: () => void } - ): (c: Context) => Promise { - return async (c) => { - const sessionId = opts?.sessionId ?? c.req.param('id') ?? 'default'; - const stream = new ReadableStream({ - async start(controller) { - const assembler = createFrameAssembler({ sessionId }); - const emit = (body: FrameBody) => { - const frame = assembler.stamp(body); - controller.enqueue(new TextEncoder().encode(`data: ${encodeFrame(frame)}\n\n`)); - }; +export type Router = HttpRouter.HttpRouter; - const waitService = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalWaitService; - }) - ); +export const json = (body: unknown, status?: number): HttpServerResponse.HttpServerResponse => + HttpServerResponse.unsafeJson(body, status === undefined ? undefined : { status }); - try { - const generator = createGenerator(); +export const query: Effect.Effect< + Readonly>, + never, + HttpServerRequest.ParsedSearchParams +> = Effect.map(HttpServerRequest.ParsedSearchParams, (params) => { + const out: Record = {}; + for (const [key, value] of Object.entries(params)) { + out[key] = Array.isArray(value) ? (value[0] ?? '') : value; + } + return out; +}); - for await (const body of generator) { - emit(body); - } - } catch (e) { - emit({ - family: 'fatal', - fatal: { - message: e instanceof Error ? e.message : String(e), - code: isHttpError(e) ? e.code : 'INTERNAL_ERROR', - }, - }); - } finally { - Effect.runSync(waitService.cancelPendingFor(sessionId)); - opts?.onDone?.(); - } - controller.close(); - }, - }); +/** 路径参数(`/api/sessions/:id` 里的 id)。 */ +export const pathParams: Effect.Effect< + Readonly>, + never, + HttpRouter.RouteContext +> = HttpRouter.params; - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }, - }); - }; - }; +/** 平台请求 → web Request。给需要原生 signal / web API 的路由用;转换失败属于框架级缺陷。 */ +export const webRequest: Effect.Effect = + Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) => + HttpServerRequest.toWeb(request).pipe(Effect.orDie) + ); + +/** body 不是合法 JSON = 请求体问题,落 400,而不是让 JSON.parse 抛成 500。 */ +const asAppError = (cause: unknown): AgentError => + new AgentError( + 'CONFIG_INVALID', + `Malformed JSON body: ${cause instanceof Error ? cause.message : String(cause)}` + ); + +function parseJson(source: Effect.Effect): Effect.Effect { + return source.pipe(Effect.mapError(asAppError)) as Effect.Effect; } + +/** 读 JSON body(空 body / 坏 JSON → 400 CONFIG_INVALID)。 */ +export const readJson = (): Effect.Effect => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + return yield* parseJson(request.json); + }); + +/** 读 JSON body,读不到就给 `{}`(对齐旧代码的 `c.req.json().catch(() => ({}))`)。 */ +export const readJsonOrEmpty = (): Effect.Effect => + readJson().pipe(Effect.catchAll(() => Effect.succeed({} as T))); + +/** 从已经拿到的 web Request 上读 JSON body。 */ +export const readJsonFrom = (web: Request): Effect.Effect => + parseJson( + Effect.tryPromise({ + try: () => web.json() as Promise, + catch: (cause) => cause, + }) + ); + +/** + * SSE 响应:把领域帧流编码成 `data: \n\n`,流结束(或被打断)时取消该会话的挂起审批。 + * + * 旧实现手写 ReadableStream + `Effect.runSync` 收尾;这里交给 Stream,收尾走 finalizer, + * 客户端中途断开也会执行。 + */ +export const sseResponse = ( + frames: Stream.Stream, + opts: { sessionId: string; onDone?: () => void } +): Effect.Effect => + Effect.gen(function* () { + const wait = yield* ApprovalWaitService; + const assembler = createFrameAssembler({ sessionId: opts.sessionId }); + const encoder = new TextEncoder(); + const body = frames.pipe( + Stream.map((frame) => encoder.encode(`data: ${encodeFrame(assembler.stamp(frame))}\n\n`)), + Stream.ensuring( + Effect.sync(() => { + Effect.runSync(wait.cancelPendingFor(opts.sessionId)); + opts.onDone?.(); + }) + ) + ); + return HttpServerResponse.stream(body, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }, + }); + }); + +/** + * 把 AsyncGenerator 的抛出物转成**一帧 fatal**(不中断流)——对齐旧 SSE 行为: + * 回合失败是「最后一条消息」,不是连接级错误。 + */ +export const frameStream = (gen: AsyncGenerator): Stream.Stream => + Stream.fromAsyncIterable(gen, (cause) => cause).pipe( + Stream.catchAll((cause) => + Stream.make({ + family: 'fatal' as const, + fatal: { + message: cause instanceof Error ? cause.message : String(cause), + code: isAppError(cause) ? errorCodeOf(cause) : 'INTERNAL_ERROR', + }, + }) + ) + ); diff --git a/packages/codingcode/src/server/http-error.ts b/packages/codingcode/src/server/http-error.ts index 2eb9648c..a22dedfd 100644 --- a/packages/codingcode/src/server/http-error.ts +++ b/packages/codingcode/src/server/http-error.ts @@ -1,33 +1,108 @@ +import * as HttpServerResponse from '@effect/platform/HttpServerResponse'; +import { Match } from 'effect'; +import { AgentError, type ErrorCode } from '../core/error.js'; + +const DOMAIN_STATUS: Record = { + LLM_TIMEOUT: 500, + LLM_RATE_LIMITED: 429, + LLM_FAILED: 500, + CONTEXT_OVERFLOW: 500, + TOOL_NOT_FOUND: 500, + TOOL_NOT_ALLOWED: 403, + TOOL_EXECUTION_FAILED: 500, + MAX_STEPS_REACHED: 500, + CONFIG_MISSING: 400, + CONFIG_INVALID: 400, + SESSION_CORRUPTED: 500, + SESSION_NOT_FOUND: 404, + SESSION_WORKSPACE_MISMATCH: 409, + AGENT_ABORTED: 500, + AGENT_LOOP_DETECTED: 500, + EMPTY_RESPONSE: 500, + SESSION_IO_ERROR: 500, +}; + +export class NotFoundError extends Error { + readonly _tag = 'NotFoundError'; + constructor(message: string) { + super(message); + this.name = 'NotFoundError'; + } +} + export class AlreadyExistsError extends Error { - readonly code = 'ALREADY_EXISTS'; + readonly _tag = 'AlreadyExistsError'; constructor(message: string) { super(message); this.name = 'AlreadyExistsError'; } - httpStatus(): 409 { - return 409; - } } -export class NotFoundError extends Error { - readonly code = 'NOT_FOUND'; +export class InvalidInputError extends Error { + readonly _tag = 'InvalidInputError'; constructor(message: string) { super(message); - this.name = 'NotFoundError'; - } - httpStatus(): 404 { - return 404; + this.name = 'InvalidInputError'; } } -/** 自带 HTTP 语义的错误:`code` 决定响应体,`httpStatus()` 决定状态码 */ -export interface HttpError extends Error { - readonly code: string; - httpStatus(): number; +export type HttpError = NotFoundError | AlreadyExistsError | InvalidInputError; +export type AppError = HttpError | AgentError; + +export const isHttpError = (u: unknown): u is HttpError => + u instanceof NotFoundError || u instanceof AlreadyExistsError || u instanceof InvalidInputError; + +export const isAppError = (u: unknown): u is AppError => isHttpError(u) || u instanceof AgentError; + +/** 错误 → 响应体里的 code。 */ +export const errorCodeOf = Match.type().pipe( + Match.tag('NotFoundError', () => 'NOT_FOUND'), + Match.tag('AlreadyExistsError', () => 'ALREADY_EXISTS'), + Match.tag('InvalidInputError', () => 'CONFIG_INVALID'), + Match.tag('AgentError', (e) => e.code), + Match.exhaustive +); + +/** 错误 → HTTP 状态码 */ +export const statusOf = Match.type().pipe( + Match.tag('NotFoundError', () => 404), + Match.tag('AlreadyExistsError', () => 409), + Match.tag('InvalidInputError', () => 400), + Match.tag('AgentError', (e) => DOMAIN_STATUS[e.code]), + Match.exhaustive +); + +export interface ErrorPayload { + readonly status: number; + readonly body: { readonly error: { readonly code: string; readonly message: string } }; +} + +export function errorBody(code: string, message: string) { + return { error: { code, message } }; +} + +export const isRouteNotFound = (u: unknown): boolean => + typeof u === 'object' && u !== null && (u as { _tag?: unknown })._tag === 'RouteNotFound'; + +/** 任意抛出物 → 状态码 + 响应体。非 AppError 一律 500,并保留原文以免丢信息。 */ +export function errorResponse(err: unknown): ErrorPayload { + if (isRouteNotFound(err)) { + return { status: 404, body: errorBody('NOT_FOUND', 'Route not found') }; + } + if (isAppError(err)) { + return { status: statusOf(err), body: errorBody(errorCodeOf(err), err.message) }; + } + return { + status: 500, + body: errorBody( + 'INTERNAL_ERROR', + err instanceof Error ? err.message : `Unexpected error: ${String(err)}` + ), + }; } -export function isHttpError(err: unknown): err is HttpError { - if (typeof err !== 'object' || err === null) return false; - const e = err as { code?: unknown; httpStatus?: unknown }; - return typeof e.code === 'string' && typeof e.httpStatus === 'function'; +/** platform 侧出口:错误 → HttpServerResponse */ +export function toHttpServerResponse(err: unknown): HttpServerResponse.HttpServerResponse { + const { status, body } = errorResponse(err); + return HttpServerResponse.unsafeJson(body, { status }); } diff --git a/packages/codingcode/src/server/index.ts b/packages/codingcode/src/server/index.ts index c6cfed91..e0f93014 100644 --- a/packages/codingcode/src/server/index.ts +++ b/packages/codingcode/src/server/index.ts @@ -1,40 +1,23 @@ -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; +import * as HttpApp from '@effect/platform/HttpApp'; import type { ManagedRuntime } from 'effect'; -import { registerSessionsRoutes } from './routes/sessions.js'; -import { registerMessagesRoutes } from './routes/messages.js'; -import { registerModelsRoutes } from './routes/models.js'; -import { registerApprovalRoutes } from './routes/approval.js'; -import { registerSettingsRoutes } from './routes/settings.js'; -import { registerAutomationsRoutes } from './routes/automations.js'; -import { registerSubagentsRoutes } from './routes/subagents.js'; -import { registerErrorHandler } from './util.js'; +import { Effect } from 'effect'; +import { corsMiddleware, createHttpApp } from './app.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; -export async function createServer(rt: ManagedRt): Promise { - const app = new Hono(); - - registerErrorHandler(app); - - app.use( - '*', - cors({ - origin: '*', - allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'Authorization'], - }) - ); - - app.get('/api/health', (c) => c.json({ status: 'ok' })); +export interface ServerApp { + request(input: string | Request, init?: RequestInit): Promise; +} - registerSessionsRoutes(app, rt); - registerMessagesRoutes(app, rt); - registerModelsRoutes(app, rt); - registerApprovalRoutes(app, rt); - registerSettingsRoutes(app, rt); - registerAutomationsRoutes(app, rt); - registerSubagentsRoutes(app, rt); +export async function createServer(rt: ManagedRt): Promise { + const app = await Effect.runPromise(createHttpApp()); + const runtime = await rt.runtime(); + const handler = HttpApp.toWebHandlerRuntime(runtime)(app, corsMiddleware); - return app; + return { + request: (input, init) => + handler( + typeof input === 'string' ? new Request(new URL(input, 'http://localhost'), init) : input + ), + }; } diff --git a/packages/codingcode/src/server/routes/agent.ts b/packages/codingcode/src/server/routes/agent.ts index 0e9c0000..fe203c09 100644 --- a/packages/codingcode/src/server/routes/agent.ts +++ b/packages/codingcode/src/server/routes/agent.ts @@ -1,4 +1,5 @@ -import type { Hono } from 'hono'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import { Effect } from 'effect'; import { loadConfig, updateMaxSteps, @@ -9,9 +10,9 @@ import { } from '../../infra/config.js'; import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; -import { errorBody } from '../util.js'; +import { AgentError } from '../../core/error.js'; +import { json, readJson, type Handler, type Router } from '../handler.js'; -/** config.yaml 的 agent 段;交互/权限模式非法时回落到 build / default */ function readAgentConfig() { const cfg = loadConfig(); return { @@ -22,43 +23,43 @@ function readAgentConfig() { }; } -export function registerAgentSettingsRoutes(router: Hono): void { - // ---- Agent config ---- - router.get('/api/settings/agent/config', (c) => { - return c.json(readAgentConfig()); - }); +const getConfig: Handler = Effect.sync(() => json(readAgentConfig())); - router.post('/api/settings/agent/config', async (c) => { - const body = (await c.req.json()) as { - maxSteps?: number; - maxStopContinuations?: number; - activeProfile?: string; - permissionMode?: string; - }; - if (body.activeProfile !== undefined && !isAgentProfileName(body.activeProfile)) { - return c.json( - errorBody('CONFIG_INVALID', `Invalid activeProfile: ${body.activeProfile}`), - 400 - ); - } - if (body.permissionMode !== undefined && !isPermissionMode(body.permissionMode)) { - return c.json( - errorBody('CONFIG_INVALID', `Invalid permissionMode: ${body.permissionMode}`), - 400 - ); - } - if (body.maxSteps !== undefined) updateMaxSteps(body.maxSteps); - if (body.maxStopContinuations !== undefined) - updateMaxStopContinuations(body.maxStopContinuations); - if (body.activeProfile !== undefined) updateActiveProfile(body.activeProfile); - if (body.permissionMode !== undefined) updatePermissionMode(body.permissionMode); - return c.json(readAgentConfig()); - }); +const putConfig: Handler = Effect.gen(function* () { + const body = yield* readJson<{ + maxSteps?: number; + maxStopContinuations?: number; + activeProfile?: string; + permissionMode?: string; + }>(); - // ---- Context config ---- - router.post('/api/settings/context/compaction-model', async (c) => { - const body = (await c.req.json()) as { compactionModel: string }; - updateContextCompactionModel(body.compactionModel); - return c.json({ compactionModel: body.compactionModel }); - }); -} + if (body.activeProfile !== undefined && !isAgentProfileName(body.activeProfile)) { + return yield* Effect.fail( + new AgentError('CONFIG_INVALID', `Invalid activeProfile: ${body.activeProfile}`) + ); + } + if (body.permissionMode !== undefined && !isPermissionMode(body.permissionMode)) { + return yield* Effect.fail( + new AgentError('CONFIG_INVALID', `Invalid permissionMode: ${body.permissionMode}`) + ); + } + + if (body.maxSteps !== undefined) updateMaxSteps(body.maxSteps); + if (body.maxStopContinuations !== undefined) updateMaxStopContinuations(body.maxStopContinuations); + if (body.activeProfile !== undefined) updateActiveProfile(body.activeProfile); + if (body.permissionMode !== undefined) updatePermissionMode(body.permissionMode); + return json(readAgentConfig()); +}); + +const setCompactionModel: Handler = Effect.gen(function* () { + const body = yield* readJson<{ compactionModel: string }>(); + updateContextCompactionModel(body.compactionModel); + return json({ compactionModel: body.compactionModel }); +}); + +export const addAgentSettingsRoutes = (router: Router): Router => + router.pipe( + HttpRouter.get('/api/settings/agent/config', getConfig), + HttpRouter.post('/api/settings/agent/config', putConfig), + HttpRouter.post('/api/settings/context/compaction-model', setCompactionModel) + ); diff --git a/packages/codingcode/src/server/routes/approval.ts b/packages/codingcode/src/server/routes/approval.ts index 8bb24881..abf55675 100644 --- a/packages/codingcode/src/server/routes/approval.ts +++ b/packages/codingcode/src/server/routes/approval.ts @@ -1,29 +1,18 @@ -import type { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import { Effect } from 'effect'; import { ApprovalWaitService } from '../../approval/wait-port.js'; import { parseApprovalResponse } from '../../approval/confirmation.js'; -import { createRunWithLayer } from '../util.js'; +import { json, pathParams, readJsonOrEmpty, type Handler, type Router } from '../handler.js'; -type ManagedRt = ManagedRuntime.ManagedRuntime; +const resolve: Handler = Effect.gen(function* () { + const { id, sessionId } = yield* pathParams; + const body = yield* readJsonOrEmpty<{ response?: string }>(); + const response = typeof body.response === 'string' ? body.response : ''; -export function registerApprovalRoutes(router: Hono, rt: ManagedRt): void { - const runWithLayer = createRunWithLayer(rt); + const svc = yield* ApprovalWaitService; + const resolved = yield* svc.resolveConfirm(id ?? '', sessionId ?? '', parseApprovalResponse(response)); + return json({ ok: resolved }); +}); - router.post('/api/sessions/:sessionId/approval/:id', async (c) => { - const id = c.req.param('id'); - const sessionId = c.req.param('sessionId'); - const body = (await c.req.json().catch(() => ({}))) as { - response?: string; - }; - const response = typeof body.response === 'string' ? body.response : ''; - - const resolved = await runWithLayer( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - return yield* svc.resolveConfirm(id, sessionId, parseApprovalResponse(response)); - }) - ); - - return c.json({ ok: resolved }); - }); -} +export const addApprovalRoutes = (router: Router): Router => + router.pipe(HttpRouter.post('/api/sessions/:sessionId/approval/:id', resolve)); diff --git a/packages/codingcode/src/server/routes/automations.ts b/packages/codingcode/src/server/routes/automations.ts index 302ec877..53323fd2 100644 --- a/packages/codingcode/src/server/routes/automations.ts +++ b/packages/codingcode/src/server/routes/automations.ts @@ -1,93 +1,87 @@ -import type { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import { Effect } from 'effect'; import { SchedulerService } from '../../scheduler/port.js'; -import { createRunWithLayer, errorBody } from '../util.js'; import { NotFoundError } from '../http-error.js'; -import type { CreateAutomationInput, UpdateAutomationInput } from '../../scheduler/types.js'; - -type ManagedRt = ManagedRuntime.ManagedRuntime; - -export function registerAutomationsRoutes(router: Hono, rt: ManagedRt): void { - const runWithLayer = createRunWithLayer(rt); +import { AgentError } from '../../core/error.js'; +import { json, pathParams, readJson, type Handler, type Router } from '../handler.js'; +import type { AutomationSandbox, CreateAutomationInput } from '../../scheduler/types.js'; + +type AutomationBody = { + name?: string; + description?: string; + cron?: string; + timezone?: string; + sandbox?: AutomationSandbox; + projectCwd?: string; + runOnce?: boolean; + enabled?: boolean; +}; + +/** PATCH 只能改既有自动化的字段,不接受 projectCwd */ +type AutomationPatchBody = Omit; + +/** 校验必填项后构造 CreateAutomationInput;缺字段返回 null,由调用方落 400。不用 `as` 断言未验证 body。 */ +function toCreateInput(body: AutomationBody): CreateAutomationInput | null { + const { name, description, cron, projectCwd } = body; + if (!name || !description || !cron || !projectCwd) return null; + return { ...body, name, description, cron, projectCwd }; +} - router.get('/api/automations', async (c) => { - return c.json( - await runWithLayer( - Effect.gen(function* () { - const scheduler = yield* SchedulerService; - return scheduler.list(); - }) +const listAutomations: Handler = Effect.gen(function* () { + const scheduler = yield* SchedulerService; + return json(scheduler.list()); +}); + +const createAutomation: Handler = Effect.gen(function* () { + const body = yield* readJson(); + const input = toCreateInput(body); + if (!input) { + return yield* Effect.fail( + new AgentError( + 'CONFIG_MISSING', + 'Missing required fields: name, description, cron, projectCwd' ) ); + } + const scheduler = yield* SchedulerService; + return json(scheduler.add(input), 201); +}); + +const patchAutomation: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson(); + const scheduler = yield* SchedulerService; + const updated = scheduler.update(id ?? '', body); + if (!updated) { + return yield* Effect.fail(new NotFoundError(`Automation '${id}' not found`)); + } + return json(updated); +}); + +const deleteAutomation: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const scheduler = yield* SchedulerService; + if (!scheduler.remove(id ?? '')) { + return yield* Effect.fail(new NotFoundError(`Automation '${id}' not found`)); + } + return json({ ok: true }); +}); + +const runAutomation: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const scheduler = yield* SchedulerService; + const sessionId = yield* Effect.tryPromise({ + try: () => scheduler.runOnce(id ?? ''), + catch: () => new NotFoundError(`Automation '${id}' not found or execution failed`), }); - - router.post('/api/automations', async (c) => { - const body = (await c.req.json()) as CreateAutomationInput; - - if (!body.name || !body.description || !body.cron || !body.projectCwd) { - return c.json( - errorBody('CONFIG_MISSING', 'Missing required fields: name, description, cron, projectCwd'), - 400 - ); - } - - const created = await runWithLayer( - Effect.gen(function* () { - const scheduler = yield* SchedulerService; - return scheduler.add(body); - }) - ); - - return c.json(created, 201); - }); - - router.patch('/api/automations/:id', async (c) => { - const id = c.req.param('id'); - const body = (await c.req.json()) as UpdateAutomationInput; - - const updated = await runWithLayer( - Effect.gen(function* () { - const scheduler = yield* SchedulerService; - const result = scheduler.update(id, body); - if (!result) { - return yield* Effect.fail(new NotFoundError(`Automation '${id}' not found`)); - } - return result; - }) - ); - - return c.json(updated); - }); - - router.delete('/api/automations/:id', async (c) => { - const id = c.req.param('id'); - - const removed = await runWithLayer( - Effect.gen(function* () { - const scheduler = yield* SchedulerService; - if (!scheduler.remove(id)) { - return yield* Effect.fail(new NotFoundError(`Automation '${id}' not found`)); - } - return { ok: true }; - }) - ); - - return c.json(removed); - }); - - router.post('/api/automations/:id/run', async (c) => { - const id = c.req.param('id'); - - const sessionId = await runWithLayer( - Effect.gen(function* () { - const scheduler = yield* SchedulerService; - return yield* Effect.tryPromise({ - try: () => scheduler.runOnce(id), - catch: () => new NotFoundError(`Automation '${id}' not found or execution failed`), - }); - }) - ); - - return c.json({ sessionId }); - }); -} + return json({ sessionId }); +}); + +export const addAutomationsRoutes = (router: Router): Router => + router.pipe( + HttpRouter.get('/api/automations', listAutomations), + HttpRouter.post('/api/automations', createAutomation), + HttpRouter.patch('/api/automations/:id', patchAutomation), + HttpRouter.del('/api/automations/:id', deleteAutomation), + HttpRouter.post('/api/automations/:id/run', runAutomation) + ); diff --git a/packages/codingcode/src/server/routes/hooks.ts b/packages/codingcode/src/server/routes/hooks.ts index bff19b21..6e4120e3 100644 --- a/packages/codingcode/src/server/routes/hooks.ts +++ b/packages/codingcode/src/server/routes/hooks.ts @@ -1,7 +1,10 @@ -import type { Hono } from 'hono'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import { Effect } from 'effect'; import { isGlobalCwd, resolveCwd } from '../cwd.js'; -import { AlreadyExistsError, NotFoundError } from '../http-error.js'; -import type { UserHookConfig } from '../../hooks/types.js'; +import { AlreadyExistsError, NotFoundError, type AppError } from '../http-error.js'; +import { AgentError } from '../../core/error.js'; +import { json, pathParams, query, readJson, type Handler, type Router } from '../handler.js'; +import type { HookPoint, UserHookConfig } from '../../hooks/types.js'; import { loadHookConfigs, writeHookConfigs, @@ -12,127 +15,168 @@ import { setProjectHookEnabled, } from '../../hooks/config.js'; -function hooksCreate(cwd: string, hook: UserHookConfig): void { - const hooks = loadHookConfigs(cwd); - if (hooks.some((h) => h.name === hook.name)) { - throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); - } - hooks.push(hook); - writeHookConfigs(cwd, hooks); -} +type HookBody = { + name?: string; + description?: string; + point?: HookPoint; + type?: 'observer' | 'decision'; + command?: string; + args?: string[]; + env?: Record; + priority?: number; + enabled?: boolean; +}; -function hooksUpdate(cwd: string, name: string, hook: UserHookConfig): void { - const hooks = loadHookConfigs(cwd); - const idx = hooks.findIndex((h) => h.name === name); - if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); - if (hook.name !== name && hooks.some((h) => h.name === hook.name)) { - throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); - } - hooks[idx] = hook; - writeHookConfigs(cwd, hooks); +function toHookConfig(body: HookBody): UserHookConfig | null { + const { name, point, type, command } = body; + if (!name || !point || !type || !command) return null; + return { ...body, name, point, type, command }; } -function hooksDelete(cwd: string, name: string): void { - const hooks = loadHookConfigs(cwd); - if (!hooks.some((h) => h.name === name)) { - throw new NotFoundError(`Hook '${name}' not found in project config`); - } - writeHookConfigs( - cwd, - hooks.filter((h) => h.name !== name) - ); -} +const MISSING_FIELDS = 'Missing required fields: name, point, type, command'; -export function registerHooksSettingsRoutes(router: Hono): void { - router.get('/api/settings/hooks', (c) => { - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - return c.json( - loadGlobalHookConfigs().map((h) => ({ - ...h, - enabled: h.enabled !== false, - source: 'global' as const, - })) - ); +const hooksCreate = (cwd: string, hook: UserHookConfig): Effect.Effect => + Effect.gen(function* () { + const hooks = loadHookConfigs(cwd); + if (hooks.some((h) => h.name === hook.name)) { + return yield* Effect.fail(new AlreadyExistsError(`Hook '${hook.name}' already exists`)); } - const cwd = resolveCwd(rawCwd); - const globalHooks = loadGlobalHookConfigs(); - const projectHooks = loadHookConfigs(cwd); - const globalNames = new Set(globalHooks.map((h) => h.name)); - const projectNames = new Set(projectHooks.map((h) => h.name)); - const merged = resolveHookConfigs(cwd); - return c.json( - merged.map((h) => { - const isFromProject = projectNames.has(h.name); - const isFromGlobal = globalNames.has(h.name); - const hasProjectOverride = isFromProject && isFromGlobal; - return { - ...h, - enabled: h.enabled !== false, - source: isFromProject ? 'project' : 'global', - hasProjectOverride, - }; - }) - ); + hooks.push(hook); + writeHookConfigs(cwd, hooks); }); - router.post('/api/settings/hooks', async (c) => { - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as UserHookConfig; - if (isGlobalCwd(rawCwd)) { - const hooks = loadGlobalHookConfigs(); - if (hooks.some((h) => h.name === body.name)) { - throw new AlreadyExistsError(`Hook '${body.name}' already exists`); - } - hooks.push(body); - writeGlobalHookConfigs(hooks); - } else { - hooksCreate(resolveCwd(rawCwd), body); +const hooksUpdate = ( + cwd: string, + name: string, + hook: UserHookConfig +): Effect.Effect => + Effect.gen(function* () { + const hooks = loadHookConfigs(cwd); + const idx = hooks.findIndex((h) => h.name === name); + if (idx === -1) return yield* Effect.fail(new NotFoundError(`Hook '${name}' not found`)); + if (hook.name !== name && hooks.some((h) => h.name === hook.name)) { + return yield* Effect.fail(new AlreadyExistsError(`Hook '${hook.name}' already exists`)); } - return c.json({ ok: true }); + hooks[idx] = hook; + writeHookConfigs(cwd, hooks); }); - router.put('/api/settings/hooks/:name', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as UserHookConfig; - if (isGlobalCwd(rawCwd)) { - const hooks = loadGlobalHookConfigs(); - const idx = hooks.findIndex((h) => h.name === name); - if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); - if (body.name !== name && hooks.some((h) => h.name === body.name)) { - throw new AlreadyExistsError(`Hook '${body.name}' already exists`); - } - hooks[idx] = body; - writeGlobalHookConfigs(hooks); - } else { - hooksUpdate(resolveCwd(rawCwd), name, body); +const hooksDelete = (cwd: string, name: string): Effect.Effect => + Effect.gen(function* () { + const hooks = loadHookConfigs(cwd); + if (!hooks.some((h) => h.name === name)) { + return yield* Effect.fail(new NotFoundError(`Hook '${name}' not found in project config`)); } - return c.json({ ok: true }); + writeHookConfigs( + cwd, + hooks.filter((h) => h.name !== name) + ); }); - router.delete('/api/settings/hooks/:name', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - const hooks = loadGlobalHookConfigs().filter((h) => h.name !== name); - writeGlobalHookConfigs(hooks); - } else { - hooksDelete(resolveCwd(rawCwd), name); +const listHooks: Handler = Effect.gen(function* () { + const { cwd: rawCwd } = yield* query; + if (isGlobalCwd(rawCwd)) { + return json( + loadGlobalHookConfigs().map((h) => ({ + ...h, + enabled: h.enabled !== false, + source: 'global' as const, + })) + ); + } + + const cwd = resolveCwd(rawCwd); + const globalHooks = loadGlobalHookConfigs(); + const projectHooks = loadHookConfigs(cwd); + const globalNames = new Set(globalHooks.map((h) => h.name)); + const projectNames = new Set(projectHooks.map((h) => h.name)); + return json( + resolveHookConfigs(cwd).map((h) => { + const isFromProject = projectNames.has(h.name); + const isFromGlobal = globalNames.has(h.name); + return { + ...h, + enabled: h.enabled !== false, + source: isFromProject ? 'project' : 'global', + hasProjectOverride: isFromProject && isFromGlobal, + }; + }) + ); +}); + +const createHook: Handler = Effect.gen(function* () { + const { cwd: rawCwd } = yield* query; + const body = yield* readJson(); + const hook = toHookConfig(body); + if (!hook) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_FIELDS)); + + if (isGlobalCwd(rawCwd)) { + const hooks = loadGlobalHookConfigs(); + if (hooks.some((h) => h.name === hook.name)) { + return yield* Effect.fail(new AlreadyExistsError(`Hook '${hook.name}' already exists`)); } - return c.json({ ok: true }); - }); + hooks.push(hook); + writeGlobalHookConfigs(hooks); + } else { + yield* hooksCreate(resolveCwd(rawCwd), hook); + } + return json({ ok: true }); +}); + +const updateHook: Handler = Effect.gen(function* () { + const { name } = yield* pathParams; + const { cwd: rawCwd } = yield* query; + const body = yield* readJson(); + const hook = toHookConfig(body); + if (!hook) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_FIELDS)); - // 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 hooks.yaml - router.post('/api/settings/hooks/:name/enabled', async (c) => { - const name = c.req.param('name'); - const body = (await c.req.json()) as { enabled: boolean }; - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - setGlobalHookEnabled(name, body.enabled); - } else { - setProjectHookEnabled(resolveCwd(rawCwd), name, body.enabled); + const target = name ?? ''; + if (isGlobalCwd(rawCwd)) { + const hooks = loadGlobalHookConfigs(); + const idx = hooks.findIndex((h) => h.name === target); + if (idx === -1) return yield* Effect.fail(new NotFoundError(`Hook '${target}' not found`)); + if (hook.name !== target && hooks.some((h) => h.name === hook.name)) { + return yield* Effect.fail(new AlreadyExistsError(`Hook '${hook.name}' already exists`)); } - return c.json({ ok: true }); - }); -} + hooks[idx] = hook; + writeGlobalHookConfigs(hooks); + } else { + yield* hooksUpdate(resolveCwd(rawCwd), target, hook); + } + return json({ ok: true }); +}); + +const deleteHook: Handler = Effect.gen(function* () { + const { name } = yield* pathParams; + const { cwd: rawCwd } = yield* query; + const target = name ?? ''; + if (isGlobalCwd(rawCwd)) { + writeGlobalHookConfigs(loadGlobalHookConfigs().filter((h) => h.name !== target)); + } else { + yield* hooksDelete(resolveCwd(rawCwd), target); + } + return json({ ok: true }); +}); + +// 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 hooks.yaml +const setHookEnabled: Handler = Effect.gen(function* () { + const { name } = yield* pathParams; + const body = yield* readJson<{ enabled: boolean }>(); + const { cwd: rawCwd } = yield* query; + const target = name ?? ''; + if (isGlobalCwd(rawCwd)) { + setGlobalHookEnabled(target, body.enabled); + } else { + setProjectHookEnabled(resolveCwd(rawCwd), target, body.enabled); + } + return json({ ok: true }); +}); + +export const addHooksSettingsRoutes = (router: Router): Router => + router.pipe( + HttpRouter.get('/api/settings/hooks', listHooks), + HttpRouter.post('/api/settings/hooks', createHook), + HttpRouter.put('/api/settings/hooks/:name', updateHook), + HttpRouter.del('/api/settings/hooks/:name', deleteHook), + HttpRouter.post('/api/settings/hooks/:name/enabled', setHookEnabled) + ); diff --git a/packages/codingcode/src/server/routes/mcp.ts b/packages/codingcode/src/server/routes/mcp.ts index 49bcc346..6ebca2e5 100644 --- a/packages/codingcode/src/server/routes/mcp.ts +++ b/packages/codingcode/src/server/routes/mcp.ts @@ -1,6 +1,9 @@ -import type { Hono } from 'hono'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import { Effect } from 'effect'; import { isGlobalCwd, resolveCwd } from '../cwd.js'; -import { AlreadyExistsError, NotFoundError } from '../http-error.js'; +import { AlreadyExistsError, NotFoundError, type AppError } from '../http-error.js'; +import { AgentError } from '../../core/error.js'; +import { json, pathParams, query, readJson, type Handler, type Router } from '../handler.js'; import type { McpServerConfig } from '../../mcp/types.js'; import { loadMcpConfig, @@ -12,127 +15,170 @@ import { setProjectMcpServerEnabled, } from '../../mcp/config.js'; -function mcpCreateServer(cwd: string, server: McpServerConfig): void { - const servers = loadMcpConfig(cwd); - if (servers.some((s) => s.name === server.name)) { - throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); - } - servers.push(server); - writeMcpConfig(cwd, servers); -} +/** POST / PUT body 的线上形状:字段一律可选——请求体是未验证输入,必填项由 toMcpServerConfig 显式校验 */ +type McpServerBody = { + name?: string; + enabled?: boolean; + command?: string; + args?: string[]; + env?: Record; + url?: string; + headers?: Record; + concurrency?: number; + autoReconnect?: boolean; +}; -function mcpUpdateServer(cwd: string, name: string, server: McpServerConfig): void { - const servers = loadMcpConfig(cwd); - const idx = servers.findIndex((s) => s.name === name); - if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); - if (server.name !== name && servers.some((s) => s.name === server.name)) { - throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); - } - servers[idx] = server; - writeMcpConfig(cwd, servers); +/** 校验必填项后构造领域对象;缺 name 返回 null,由调用方落 400。不用 `as` 把未验证 body 断言成 McpServerConfig。 */ +function toMcpServerConfig(body: McpServerBody): McpServerConfig | null { + const { name } = body; + if (!name) return null; + return { ...body, name }; } -function mcpDeleteServer(cwd: string, name: string): void { - const servers = loadMcpConfig(cwd); - if (!servers.some((s) => s.name === name)) { - throw new NotFoundError(`MCP server '${name}' not found in project config`); - } - writeMcpConfig( - cwd, - servers.filter((s) => s.name !== name) - ); -} +const MISSING_NAME = 'Missing required field: name'; -export function registerMcpSettingsRoutes(router: Hono): void { - router.get('/api/settings/mcp', async (c) => { - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - return c.json( - loadGlobalMcpConfig().map((s) => ({ - ...s, - enabled: s.enabled !== false, - source: 'global' as const, - })) - ); +const mcpCreateServer = (cwd: string, server: McpServerConfig): Effect.Effect => + Effect.gen(function* () { + const servers = loadMcpConfig(cwd); + if (servers.some((s) => s.name === server.name)) { + return yield* Effect.fail(new AlreadyExistsError(`MCP server '${server.name}' already exists`)); } - const cwd = resolveCwd(rawCwd); - const globalServers = loadGlobalMcpConfig(); - const projectServers = loadMcpConfig(cwd); - const globalNames = new Set(globalServers.map((s) => s.name)); - const projectNames = new Set(projectServers.map((s) => s.name)); - const merged = resolveMcpConfig(cwd); - return c.json( - merged.map((s) => { - const isFromProject = projectNames.has(s.name); - const isFromGlobal = globalNames.has(s.name); - const hasProjectOverride = isFromProject && isFromGlobal; - return { - ...s, - enabled: s.enabled !== false, - source: isFromProject ? 'project' : 'global', - hasProjectOverride, - }; - }) - ); + servers.push(server); + writeMcpConfig(cwd, servers); }); - router.post('/api/settings/mcp', async (c) => { - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as McpServerConfig; - if (isGlobalCwd(rawCwd)) { - const servers = loadGlobalMcpConfig(); - if (servers.some((s) => s.name === body.name)) { - throw new AlreadyExistsError(`MCP server '${body.name}' already exists`); - } - servers.push(body); - writeGlobalMcpConfig(servers); - } else { - mcpCreateServer(resolveCwd(rawCwd), body); +const mcpUpdateServer = ( + cwd: string, + name: string, + server: McpServerConfig +): Effect.Effect => + Effect.gen(function* () { + const servers = loadMcpConfig(cwd); + const idx = servers.findIndex((s) => s.name === name); + if (idx === -1) return yield* Effect.fail(new NotFoundError(`MCP server '${name}' not found`)); + if (server.name !== name && servers.some((s) => s.name === server.name)) { + return yield* Effect.fail(new AlreadyExistsError(`MCP server '${server.name}' already exists`)); } - return c.json({ ok: true }); + servers[idx] = server; + writeMcpConfig(cwd, servers); }); - router.put('/api/settings/mcp/:name', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as McpServerConfig; - if (isGlobalCwd(rawCwd)) { - const servers = loadGlobalMcpConfig(); - const idx = servers.findIndex((s) => s.name === name); - if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); - if (body.name !== name && servers.some((s) => s.name === body.name)) { - throw new AlreadyExistsError(`MCP server '${body.name}' already exists`); - } - servers[idx] = body; - writeGlobalMcpConfig(servers); - } else { - mcpUpdateServer(resolveCwd(rawCwd), name, body); +const mcpDeleteServer = (cwd: string, name: string): Effect.Effect => + Effect.gen(function* () { + const servers = loadMcpConfig(cwd); + if (!servers.some((s) => s.name === name)) { + return yield* Effect.fail(new NotFoundError(`MCP server '${name}' not found in project config`)); } - return c.json({ ok: true }); + writeMcpConfig( + cwd, + servers.filter((s) => s.name !== name) + ); }); - router.delete('/api/settings/mcp/:name', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - const servers = loadGlobalMcpConfig().filter((s) => s.name !== name); - writeGlobalMcpConfig(servers); - } else { - mcpDeleteServer(resolveCwd(rawCwd), name); +const listServers: Handler = Effect.gen(function* () { + const { cwd: rawCwd } = yield* query; + if (isGlobalCwd(rawCwd)) { + return json( + loadGlobalMcpConfig().map((s) => ({ + ...s, + enabled: s.enabled !== false, + source: 'global' as const, + })) + ); + } + + const cwd = resolveCwd(rawCwd); + const globalServers = loadGlobalMcpConfig(); + const projectServers = loadMcpConfig(cwd); + const globalNames = new Set(globalServers.map((s) => s.name)); + const projectNames = new Set(projectServers.map((s) => s.name)); + return json( + resolveMcpConfig(cwd).map((s) => { + const isFromProject = projectNames.has(s.name); + const isFromGlobal = globalNames.has(s.name); + return { + ...s, + enabled: s.enabled !== false, + source: isFromProject ? 'project' : 'global', + hasProjectOverride: isFromProject && isFromGlobal, + }; + }) + ); +}); + +const createServer: Handler = Effect.gen(function* () { + const { cwd: rawCwd } = yield* query; + const body = yield* readJson(); + const server = toMcpServerConfig(body); + if (!server) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_NAME)); + + if (isGlobalCwd(rawCwd)) { + const servers = loadGlobalMcpConfig(); + if (servers.some((s) => s.name === server.name)) { + return yield* Effect.fail(new AlreadyExistsError(`MCP server '${server.name}' already exists`)); } - return c.json({ ok: true }); - }); + servers.push(server); + writeGlobalMcpConfig(servers); + } else { + yield* mcpCreateServer(resolveCwd(rawCwd), server); + } + return json({ ok: true }); +}); + +const updateServer: Handler = Effect.gen(function* () { + const { name } = yield* pathParams; + const { cwd: rawCwd } = yield* query; + const body = yield* readJson(); + const server = toMcpServerConfig(body); + if (!server) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_NAME)); - // 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 mcp.yaml - router.post('/api/settings/mcp/:name/enabled', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as { enabled: boolean }; - if (isGlobalCwd(rawCwd)) { - setGlobalMcpServerEnabled(name, body.enabled); - } else { - setProjectMcpServerEnabled(resolveCwd(rawCwd), name, body.enabled); + const target = name ?? ''; + if (isGlobalCwd(rawCwd)) { + const servers = loadGlobalMcpConfig(); + const idx = servers.findIndex((s) => s.name === target); + if (idx === -1) return yield* Effect.fail(new NotFoundError(`MCP server '${target}' not found`)); + if (server.name !== target && servers.some((s) => s.name === server.name)) { + return yield* Effect.fail(new AlreadyExistsError(`MCP server '${server.name}' already exists`)); } - return c.json({ ok: true }); - }); -} + servers[idx] = server; + writeGlobalMcpConfig(servers); + } else { + yield* mcpUpdateServer(resolveCwd(rawCwd), target, server); + } + return json({ ok: true }); +}); + +const deleteServer: Handler = Effect.gen(function* () { + const { name } = yield* pathParams; + const { cwd: rawCwd } = yield* query; + const target = name ?? ''; + if (isGlobalCwd(rawCwd)) { + writeGlobalMcpConfig(loadGlobalMcpConfig().filter((s) => s.name !== target)); + } else { + yield* mcpDeleteServer(resolveCwd(rawCwd), target); + } + return json({ ok: true }); +}); + +// 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 mcp.yaml +const setServerEnabled: Handler = Effect.gen(function* () { + const { name } = yield* pathParams; + const { cwd: rawCwd } = yield* query; + const body = yield* readJson<{ enabled: boolean }>(); + const target = name ?? ''; + if (isGlobalCwd(rawCwd)) { + setGlobalMcpServerEnabled(target, body.enabled); + } else { + setProjectMcpServerEnabled(resolveCwd(rawCwd), target, body.enabled); + } + return json({ ok: true }); +}); + +export const addMcpSettingsRoutes = (router: Router): Router => + router.pipe( + HttpRouter.get('/api/settings/mcp', listServers), + HttpRouter.post('/api/settings/mcp', createServer), + HttpRouter.put('/api/settings/mcp/:name', updateServer), + HttpRouter.del('/api/settings/mcp/:name', deleteServer), + HttpRouter.post('/api/settings/mcp/:name/enabled', setServerEnabled) + ); diff --git a/packages/codingcode/src/server/routes/memory.ts b/packages/codingcode/src/server/routes/memory.ts index 6f1d765c..d0375cad 100644 --- a/packages/codingcode/src/server/routes/memory.ts +++ b/packages/codingcode/src/server/routes/memory.ts @@ -1,38 +1,32 @@ -import type { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import { Effect } from 'effect'; import { MemoryService } from '../../memory/port.js'; import { getMemoryConfig } from '../../memory/config.js'; import { updateMemoryModel } from '../../infra/config.js'; -import { createRunWithLayer } from '../util.js'; +import { json, readJson, type Handler, type Router } from '../handler.js'; -type ManagedRt = ManagedRuntime.ManagedRuntime; +const readConfig: Handler = Effect.sync(() => { + const cfg = getMemoryConfig(); + return json({ enabled: cfg.enabled, model: cfg.model }); +}); -export function registerMemorySettingsRoutes(router: Hono, rt: ManagedRt): void { - const runWithLayer = createRunWithLayer(rt); +const setEnabled: Handler = Effect.gen(function* () { + const body = yield* readJson<{ enabled: boolean }>(); + const memory = yield* MemoryService; + yield* memory.setMemoryEnabled(body.enabled); + const enabled = yield* memory.getMemoryEnabled(); + return json({ enabled }); +}); - router.get('/api/settings/memory/config', (c) => { - const cfg = getMemoryConfig(); - return c.json({ - enabled: cfg.enabled, - model: cfg.model, - }); - }); +const setModel: Handler = Effect.gen(function* () { + const body = yield* readJson<{ model: string }>(); + updateMemoryModel(body.model); + return json({ model: body.model }); +}); - router.post('/api/settings/memory/enabled', async (c) => { - const body = (await c.req.json()) as { enabled: boolean }; - const enabled = await runWithLayer( - Effect.gen(function* () { - const m = yield* MemoryService; - yield* m.setMemoryEnabled(body.enabled); - return yield* m.getMemoryEnabled(); - }) - ); - return c.json({ enabled }); - }); - - router.post('/api/settings/memory/model', async (c) => { - const body = (await c.req.json()) as { model: string }; - updateMemoryModel(body.model); - return c.json({ model: body.model }); - }); -} +export const addMemorySettingsRoutes = (router: Router): Router => + router.pipe( + HttpRouter.get('/api/settings/memory/config', readConfig), + HttpRouter.post('/api/settings/memory/enabled', setEnabled), + HttpRouter.post('/api/settings/memory/model', setModel) + ); diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index 6498cff3..9d8aa106 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -1,65 +1,65 @@ -import type { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import { Effect } from 'effect'; import { AgentService } from '../../agent/port.js'; import { resolveWorkspaceCwd } from '../cwd.js'; import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; import { loadConfig } from '../../infra/config.js'; -import { createRunWithLayer, errorBody } from '../util.js'; -import { createSseHandler } from '../handler.js'; +import { AgentError } from '../../core/error.js'; +import { + frameStream, + pathParams, + readJsonFrom, + sseResponse, + webRequest, + type Handler, + type Router, +} from '../handler.js'; -type ManagedRt = ManagedRuntime.ManagedRuntime; +/** POST body 的线上形状:模型是回合的必要输入,缺失即拒绝,不在 agent 层兜底成空串。 */ +type MessageBody = { + input: string; + cwd: string; + model?: string; + skills?: Array<{ name: string; path: string }>; +}; -export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void { - const runWithLayer = createRunWithLayer(rt); - const sseHandler = createSseHandler(rt); +const sendMessage: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const web = yield* webRequest; + const body = yield* readJsonFrom(web); - router.post('/api/sessions/:id/messages', async (c) => { - const sessionId = c.req.param('id'); - const { input, cwd, model, skills } = await c.req.json<{ - input: string; - cwd: string; - model?: string; - skills?: Array<{ name: string; path: string }>; - }>(); - // 模型是回合的必要输入,缺失即拒绝,不允许在 agent 层兜底成空串 - if (!model?.trim()) { - return c.json(errorBody('CONFIG_MISSING', 'model is required'), 400); - } - // 工作区目录必须存在,否则拒绝(不允许带着不存在的路径开回合) - const normalizedCwd = resolveWorkspaceCwd(cwd); + if (!body.model?.trim()) { + return yield* Effect.fail(new AgentError('CONFIG_MISSING', 'model is required')); + } + // 工作区目录必须存在,否则拒绝(不允许带着不存在的路径开回合) + const cwd = resolveWorkspaceCwd(body.cwd); - const isNew = sessionId === '_' || !sessionId; - const runOpts: any = { - cwd: normalizedCwd, - signal: c.req.raw.signal, - model, - skills, - }; - if (isNew) { - // 新会话的交互/权限模式取自 config.yaml;会话一旦建立就以会话头为准 - const cfg = loadConfig(); - runOpts.activeProfile = isAgentProfileName(cfg.activeProfile) ? cfg.activeProfile : 'build'; - runOpts.permissionMode = isPermissionMode(cfg.permissionMode) - ? cfg.permissionMode - : 'askBeforeExec'; - } + const sessionId = id ?? ''; + const isNew = sessionId === '_' || !sessionId; + const runOpts: Record = { + cwd, + signal: web.signal, + model: body.model, + skills: body.skills, + }; + if (isNew) { + // 新会话的交互/权限模式取自 config.yaml;会话一旦建立就以会话头为准 + const cfg = loadConfig(); + runOpts.activeProfile = isAgentProfileName(cfg.activeProfile) ? cfg.activeProfile : 'build'; + runOpts.permissionMode = isPermissionMode(cfg.permissionMode) + ? cfg.permissionMode + : 'askBeforeExec'; + } - const { stream, sessionId: actualSid } = (await runWithLayer( - Effect.gen(function* () { - const agent = yield* AgentService; - return yield* agent.runTurn(input, { - sessionId: isNew ? undefined : sessionId, - ...runOpts, - }); - }) - )) as any; + const agent = yield* AgentService; + const { stream, sessionId: actualSid } = yield* agent.runTurn(body.input, { + sessionId: isNew ? undefined : sessionId, + ...runOpts, + } as never); - return sseHandler( - async function* () { - yield* stream; - }, - { sessionId: actualSid } - )(c); - }); -} + return yield* sseResponse(frameStream(stream), { sessionId: actualSid }); +}); + +export const addMessagesRoutes = (router: Router): Router => + router.pipe(HttpRouter.post('/api/sessions/:id/messages', sendMessage)); diff --git a/packages/codingcode/src/server/routes/models.ts b/packages/codingcode/src/server/routes/models.ts index a8852c58..26e98a0a 100644 --- a/packages/codingcode/src/server/routes/models.ts +++ b/packages/codingcode/src/server/routes/models.ts @@ -1,11 +1,9 @@ -import type { Hono } from 'hono'; -import type { ManagedRuntime } from 'effect'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import { Effect } from 'effect'; import { activeModelId, listModels } from '../../infra/models.js'; +import { json, type Handler, type Router } from '../handler.js'; -type ManagedRt = ManagedRuntime.ManagedRuntime; +const list: Handler = Effect.sync(() => json({ models: listModels(), activeId: activeModelId() })); -export function registerModelsRoutes(router: Hono, _rt: ManagedRt): void { - router.get('/api/models', (c) => { - return c.json({ models: listModels(), activeId: activeModelId() }); - }); -} +export const addModelsRoutes = (router: Router): Router => + router.pipe(HttpRouter.get('/api/models', list)); diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 2a5998e9..8e7b861a 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -1,5 +1,5 @@ -import type { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import { Effect } from 'effect'; import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; import type { SessionStoreState } from '../../session/types.js'; @@ -13,450 +13,361 @@ import { MailboxService } from '../../session/mailbox.js'; import { estimatePromptTokensFrom } from '../../context/context.js'; import { CheckpointService } from '../../checkpoint/port.js'; import { activeModelId, setGlobalActive } from '../../infra/models.js'; -import { createRunWithLayer, errorBody } from '../util.js'; import { resolveCwd, resolveWorkspaceCwd } from '../cwd.js'; import { projectDataDir } from '../../core/path.js'; import { AVAILABLE_PROFILES } from '../../session/types.js'; import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; +import { AgentError } from '../../core/error.js'; +import { json, pathParams, query, readJson, type Handler, type Router } from '../handler.js'; +import type { AppError } from '../http-error.js'; -type ManagedRt = ManagedRuntime.ManagedRuntime; +// ---- 会话列表 / 建立 / 恢复 ---- -export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { - const runWithLayer = createRunWithLayer(rt); +const listSessions: Handler = Effect.gen(function* () { + const { cwd: rawCwd } = yield* query; + const session = yield* SessionService; + return json(yield* session.listSessions(resolveCwd(rawCwd))); +}); - router.get('/api/sessions', async (c) => { - const cwd = resolveCwd(c.req.query('cwd')); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - return yield* session.listSessions(cwd); - }) as any - ) +const createSession: Handler = Effect.gen(function* () { + const body = yield* readJson<{ + cwd: string; + activeProfile: ProfileName; + permissionMode: PermissionMode; + model: string; + }>(); + + if (!isAgentProfileName(body.activeProfile)) { + return yield* Effect.fail( + new AgentError('CONFIG_INVALID', `Invalid activeProfile: ${body.activeProfile}`) ); - }); + } + if (!isPermissionMode(body.permissionMode)) { + return yield* Effect.fail( + new AgentError('CONFIG_INVALID', `Invalid permissionMode: ${body.permissionMode}`) + ); + } + if (!body.model) { + return yield* Effect.fail(new AgentError('CONFIG_MISSING', 'model required')); + } - router.post('/api/sessions', async (c) => { - const body = (await c.req.json()) as { - cwd: string; - activeProfile: ProfileName; - permissionMode: PermissionMode; - model: string; - }; - if (!isAgentProfileName(body.activeProfile)) { - return c.json( - errorBody('CONFIG_INVALID', `Invalid activeProfile: ${body.activeProfile}`), - 400 - ); - } - if (!isPermissionMode(body.permissionMode)) { - return c.json( - errorBody('CONFIG_INVALID', `Invalid permissionMode: ${body.permissionMode}`), - 400 - ); - } - if (!body.model) { - return c.json(errorBody('CONFIG_MISSING', 'model required'), 400); - } - // 建会话即建立工作区:目录必须存在 - const normalizedCwd = resolveWorkspaceCwd(body.cwd); - const state = (await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - return yield* session.create(normalizedCwd, { - model: body.model, - activeProfile: body.activeProfile, - permissionMode: body.permissionMode, - }); - }) as any - )) as SessionStoreState; - return c.json({ sessionId: state.sessionId }); - }); + // 建会话即建立工作区:目录必须存在 + const cwd = resolveWorkspaceCwd(body.cwd); + const session = yield* SessionService; + const state = (yield* session.create(cwd, { + model: body.model, + activeProfile: body.activeProfile, + permissionMode: body.permissionMode, + })) as SessionStoreState; + return json({ sessionId: state.sessionId }); +}); - router.post('/api/sessions/:id/resume', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string }; - const normalizedCwd = resolveCwd(body.cwd); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(normalizedCwd, sessionId); - return yield* session.readHistory(state); - }) as any - ) - ); - }); +const resumeSession: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd: string }>(); + const cwd = resolveCwd(body.cwd); + const session = yield* SessionService; + const state = yield* session.load(cwd, id ?? ''); + return json(yield* session.readHistory(state)); +}); - router.post('/api/sessions/:id/compact', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string; model?: string }; - const normalizedCwd = resolveCwd(body.cwd); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const context = yield* ContextService; - const session = yield* SessionService; - const state = yield* session.load(normalizedCwd, sessionId); - return yield* context.compact( - { - cwd: state.cwd, - sessionId: state.sessionId, - parentSessionId: state.parentSessionId, - currentTurnId: state.currentTurnId, - }, - body.model ?? '' - ); - }) - ) - ); - }); +const compactSession: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd: string; model?: string }>(); + const cwd = resolveCwd(body.cwd); + const context = yield* ContextService; + const session = yield* SessionService; + const state = yield* session.load(cwd, id ?? ''); + return json( + yield* context.compact( + { + cwd: state.cwd, + sessionId: state.sessionId, + parentSessionId: state.parentSessionId, + currentTurnId: state.currentTurnId, + }, + body.model ?? '' + ) + ); +}); - router.delete('/api/sessions/:id', async (c) => { - const sessionId = c.req.param('id'); - const cwd = c.req.query('cwd'); - if (!cwd) return c.json(errorBody('CONFIG_MISSING', 'cwd required'), 400); - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const context = yield* ContextService; - const mailbox = yield* MailboxService; - yield* session.deleteSession(sessionId, cwd); - yield* context.dispose(sessionId); - yield* mailbox.dispose(sessionId); - }) as any - ); - return c.json({ ok: true }); - }); +const deleteSession: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const { cwd } = yield* query; + if (!cwd) return yield* Effect.fail(new AgentError('CONFIG_MISSING', 'cwd required')); - router.get('/api/sessions/:id/history', async (c) => { - const sessionId = c.req.param('id'); - const cwd = c.req.query('cwd'); - if (!cwd) return c.json(errorBody('CONFIG_MISSING', 'cwd required'), 400); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - return yield* session.readUITurns(sessionId, cwd); - }) as any - ) - ); - }); + const session = yield* SessionService; + const context = yield* ContextService; + const mailbox = yield* MailboxService; + yield* session.deleteSession(id ?? '', cwd); + yield* context.dispose(id ?? ''); + yield* mailbox.dispose(id ?? ''); + return json({ ok: true }); +}); - // ---- Plan file: read the current plan document for a session ---- - router.get('/api/sessions/:id/plan', async (c) => { - const cwd = resolveCwd(c.req.query('cwd')); - const planDir = projectDataDir(cwd); - if (!existsSync(planDir)) { - return c.json({ - content: '', - path: '', - directory: planDir, - exists: false, - }); - } - let latest: { path: string; mtime: number } | null = null; - for (const name of readdirSync(planDir)) { - if (!name.endsWith('.md')) continue; - const full = join(planDir, name); - const mtime = statSync(full).mtimeMs; - if (latest === null || mtime > latest.mtime) { - latest = { path: full, mtime }; - } - } - if (latest === null) { - return c.json({ - content: '', - path: '', - directory: planDir, - exists: false, - }); - } - try { - const content = readFileSync(latest.path, 'utf8'); - return c.json({ - content, - path: latest.path, - directory: planDir, - exists: true, - }); - } catch (e) { - return c.json(errorBody('SESSION_IO_ERROR', `Failed to read plan: ${String(e)}`), 500); - } - }); +const sessionHistory: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const { cwd } = yield* query; + if (!cwd) return yield* Effect.fail(new AgentError('CONFIG_MISSING', 'cwd required')); + const session = yield* SessionService; + return json(yield* session.readUITurns(id ?? '', cwd)); +}); - // ---- Agent profile switching ---- - router.get('/api/sessions/:id/profile', async (c) => { - const sessionId = c.req.param('id'); - const cwd = resolveCwd(c.req.query('cwd')); - const value = await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - return { - activeProfile: state.activeProfile, - permissionMode: state.permissionMode, - }; - }) - ); - return c.json({ - ...value, - cwd, - available: AVAILABLE_PROFILES, - }); - }); +// ---- Plan file: read the current plan document for a session ---- - router.post('/api/sessions/:id/profile', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd?: string; activeProfile: ProfileName }; - const cwd = resolveCwd(body.cwd); - const activeProfile = body.activeProfile; - if (!isAgentProfileName(activeProfile)) { - return c.json(errorBody('CONFIG_INVALID', `Invalid activeProfile: ${activeProfile}`), 400); +const readLatestPlan = (planDir: string): { path: string; content: string } | null => { + if (!existsSync(planDir)) return null; + let latest: { path: string; mtime: number } | null = null; + for (const name of readdirSync(planDir)) { + if (!name.endsWith('.md')) continue; + const full = join(planDir, name); + const mtime = statSync(full).mtimeMs; + if (latest === null || mtime > latest.mtime) { + latest = { path: full, mtime }; } - return c.json( - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - yield* session.setActiveProfile(cwd, sessionId, activeProfile); - const state = yield* session.load(cwd, sessionId); - return { - activeProfile: state.activeProfile, - permissionMode: state.permissionMode, - }; - }) - ) - ); - }); + } + return latest === null ? null : { path: latest.path, content: readFileSync(latest.path, 'utf8') }; +}; - router.get('/api/sessions/:id/permission-mode', async (c) => { - const sessionId = c.req.param('id'); - const cwd = c.req.query('cwd'); - if (!cwd) return c.json({ mode: 'askBeforeExec' }); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - return { mode: state.permissionMode }; - }) as any - ) - ); - }); +const readPlan: Handler = Effect.gen(function* () { + const { cwd: rawCwd } = yield* query; + const planDir = projectDataDir(resolveCwd(rawCwd)); + const missing = json({ content: '', path: '', directory: planDir, exists: false }); - router.put('/api/sessions/:id/permission-mode', async (c) => { - const sessionId = c.req.param('id'); - const { cwd, mode } = await c.req.json<{ cwd: string; mode: PermissionMode }>(); - if (!cwd) return c.json(errorBody('CONFIG_MISSING', 'cwd required'), 400); - if (!isPermissionMode(mode)) { - return c.json(errorBody('CONFIG_INVALID', `Invalid permissionMode: ${mode}`), 400); - } - return c.json( - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - yield* session.setPermissionMode(cwd, sessionId, mode); - return { ok: true }; - }) as any - ) - ); + const latest = yield* Effect.try({ + try: () => readLatestPlan(planDir), + catch: (e) => new AgentError('SESSION_IO_ERROR', `Failed to read plan: ${String(e)}`), }); - // ---- Model switching ---- - // :id 为 '_' 时切全局默认模型(写 config.yaml);否则只写该会话头文件 - router.put('/api/sessions/:id/model', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd?: string; model?: string }; - const model = body.model?.trim(); - if (!model) return c.json(errorBody('CONFIG_MISSING', 'model required'), 400); - - if (sessionId === '_' || !sessionId) { - setGlobalActive(model); - return c.json({ ok: true, activeId: activeModelId() }); - } + if (latest === null) return missing; + return json({ content: latest.content, path: latest.path, directory: planDir, exists: true }); +}); - const cwd = resolveCwd(body.cwd); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - yield* session.setModel(cwd, sessionId, model); - return { ok: true }; - }) as any - ) - ); - }); +// ---- Agent profile switching ---- - router.put('/api/sessions/:id/title', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd?: string; title?: string }; - const title = body.title?.replace(/\n/g, ' ').trim(); - if (!title) return c.json(errorBody('CONFIG_MISSING', 'title required'), 400); - const cwd = resolveCwd(body.cwd); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - yield* session.renameSession(state, title); - return { ok: true }; - }) as any - ) - ); +const getProfile: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const cwd = resolveCwd((yield* query).cwd); + const session = yield* SessionService; + const state = yield* session.load(cwd, id ?? ''); + return json({ + activeProfile: state.activeProfile, + permissionMode: state.permissionMode, + cwd, + available: AVAILABLE_PROFILES, }); +}); - router.get('/api/sessions/:id/checkpoints/latest/diff', async (c) => { - const sessionId = c.req.param('id'); - const cwd = resolveCwd(c.req.query('cwd')); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.getCheckpointDiff(cwd, sessionId); - }) - ) +const setProfile: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd?: string; activeProfile: ProfileName }>(); + const cwd = resolveCwd(body.cwd); + const activeProfile = body.activeProfile; + if (!isAgentProfileName(activeProfile)) { + return yield* Effect.fail( + new AgentError('CONFIG_INVALID', `Invalid activeProfile: ${activeProfile}`) ); - }); + } - router.get('/api/sessions/:id/checkpoints/:turnId/diff', async (c) => { - const sessionId = c.req.param('id'); - const turnId = parseInt(c.req.param('turnId'), 10); - const cwd = resolveCwd(c.req.query('cwd')); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.getCheckpointDiff( - cwd, - sessionId, - isNaN(turnId) ? undefined : turnId - ); - }) - ) - ); + const session = yield* SessionService; + yield* session.setActiveProfile(cwd, id ?? '', activeProfile); + const state = yield* session.load(cwd, id ?? ''); + return json({ + activeProfile: state.activeProfile, + permissionMode: state.permissionMode, }); +}); - router.post('/api/sessions/:id/checkpoints/latest/revert-file', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string; file: string }; - const cwd = resolveCwd(body.cwd); - const result = await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.revertCheckpointFiles(cwd, sessionId, undefined, [body.file]); - }) - ); - return c.json({ ok: true, result }); - }); +const getPermissionMode: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const { cwd } = yield* query; + if (!cwd) return json({ mode: 'askBeforeExec' }); + const session = yield* SessionService; + const state = yield* session.load(cwd, id ?? ''); + return json({ mode: state.permissionMode }); +}); - router.post('/api/sessions/:id/checkpoints/latest/revert-files', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string; files: string[] }; - const cwd = resolveCwd(body.cwd); - const result = await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.revertCheckpointFiles(cwd, sessionId, undefined, body.files); - }) - ); - return c.json({ ok: true, result }); - }); +const setPermissionMode: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const { cwd, mode } = yield* readJson<{ cwd: string; mode: PermissionMode }>(); + if (!cwd) return yield* Effect.fail(new AgentError('CONFIG_MISSING', 'cwd required')); + if (!isPermissionMode(mode)) { + return yield* Effect.fail(new AgentError('CONFIG_INVALID', `Invalid permissionMode: ${mode}`)); + } + const session = yield* SessionService; + yield* session.setPermissionMode(cwd, id ?? '', mode); + return json({ ok: true }); +}); - router.get('/api/sessions/:id/rollback-preview', async (c) => { - const sessionId = c.req.param('id'); - const cwd = resolveCwd(c.req.query('cwd')); - const throughTurnId = parseInt(c.req.query('throughTurnId') ?? '0', 10); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.previewRollbackDiff(cwd, sessionId, throughTurnId); - }) - ) - ); - }); +// ---- Model switching ---- +// :id 为 '_' 时切全局默认模型(写 config.yaml);否则只写该会话头文件 - router.post('/api/sessions/:id/rollback-code-to-turn', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string; throughTurnId: number }; - const cwd = resolveCwd(body.cwd); - const result = await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.rollbackCodeToTurn(cwd, sessionId, body.throughTurnId); - }) - ); - return c.json({ ok: true, result }); - }); +const setModel: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd?: string; model?: string }>(); + const model = body.model?.trim(); + if (!model) return yield* Effect.fail(new AgentError('CONFIG_MISSING', 'model required')); - router.post('/api/sessions/:id/rollback-context', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string; throughTurnId: number }; - const cwd = resolveCwd(body.cwd); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); - const turns = yield* session.readUITurns(sessionId, cwd); - const promptEstimate = estimatePromptTokensFrom(yield* session.readHistory(state)); - const usage = state.usage; - return { ok: true, turns, promptEstimate, usage }; - }) as any - ) - ); - }); + const sessionId = id ?? ''; + if (sessionId === '_' || !sessionId) { + setGlobalActive(model); + return json({ ok: true, activeId: activeModelId() }); + } - router.post('/api/sessions/:id/rollback-both-to-turn', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string; throughTurnId: number }; - const cwd = resolveCwd(body.cwd); - return c.json( - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const checkpoint = yield* CheckpointService; - const codeResult = yield* checkpoint.rollbackCodeToTurn( - cwd, - sessionId, - body.throughTurnId - ); - const state = yield* session.load(cwd, sessionId); - yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); - const turns = yield* session.readUITurns(sessionId, cwd); - const promptEstimate = estimatePromptTokensFrom(yield* session.readHistory(state)); - const usage = state.usage; - return { - ok: true, - turns, - codeResult, - promptEstimate, - usage, - }; - }) as any - ) - ); - }); + const session = yield* SessionService; + yield* session.setModel(resolveCwd(body.cwd), sessionId, model); + return json({ ok: true }); +}); - router.post('/api/sessions/:id/fork', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string; atTurnId?: number }; - const cwd = resolveCwd(body.cwd); - const atTurnId = body.atTurnId ?? 0; - return c.json( - await runWithLayer( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - const newSessionId = yield* session.forkSession(state, atTurnId); - const turns = yield* session.readUITurns(newSessionId, cwd); - const newJsonlPath = computePaths(cwd, newSessionId).transcriptPath; - const promptEstimate = estimatePromptTokensFrom(yield* session.readEvents(newJsonlPath)); - return { sessionId: newSessionId, turns, promptEstimate }; - }) as any - ) - ); +const setTitle: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd?: string; title?: string }>(); + const title = body.title?.replace(/\n/g, ' ').trim(); + if (!title) return yield* Effect.fail(new AgentError('CONFIG_MISSING', 'title required')); + + const cwd = resolveCwd(body.cwd); + const session = yield* SessionService; + const state = yield* session.load(cwd, id ?? ''); + yield* session.renameSession(state, title); + return json({ ok: true }); +}); + +// ---- Checkpoints / rollback ---- + +const latestCheckpointDiff: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const cwd = resolveCwd((yield* query).cwd); + const checkpoint = yield* CheckpointService; + return json(yield* checkpoint.getCheckpointDiff(cwd, id ?? '')); +}); + +const turnCheckpointDiff: Handler = Effect.gen(function* () { + const { id, turnId: rawTurnId } = yield* pathParams; + const cwd = resolveCwd((yield* query).cwd); + const turnId = parseInt(rawTurnId ?? '', 10); + const checkpoint = yield* CheckpointService; + return json( + yield* checkpoint.getCheckpointDiff(cwd, id ?? '', isNaN(turnId) ? undefined : turnId) + ); +}); + +const revertFile: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd: string; file: string }>(); + const checkpoint = yield* CheckpointService; + const result = yield* checkpoint.revertCheckpointFiles(resolveCwd(body.cwd), id ?? '', undefined, [ + body.file, + ]); + return json({ ok: true, result }); +}); + +const revertFiles: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd: string; files: string[] }>(); + const checkpoint = yield* CheckpointService; + const result = yield* checkpoint.revertCheckpointFiles( + resolveCwd(body.cwd), + id ?? '', + undefined, + body.files + ); + return json({ ok: true, result }); +}); + +const rollbackPreview: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const params = yield* query; + const cwd = resolveCwd(params.cwd); + const throughTurnId = parseInt(params.throughTurnId ?? '0', 10); + const checkpoint = yield* CheckpointService; + return json(yield* checkpoint.previewRollbackDiff(cwd, id ?? '', throughTurnId)); +}); + +const rollbackCode: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd: string; throughTurnId: number }>(); + const checkpoint = yield* CheckpointService; + const result = yield* checkpoint.rollbackCodeToTurn( + resolveCwd(body.cwd), + id ?? '', + body.throughTurnId + ); + return json({ ok: true, result }); +}); + +/** 回滚 context(+ 可选代码)后回吐新状态:turns / promptEstimate / usage 都要重算。 */ +const rollbackState = ( + cwd: string, + sessionId: string, + throughTurnId: number, + codeResult?: unknown +): Effect.Effect => + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + yield* session.rollbackToTurn(state, throughTurnId, 'user rollback'); + const turns = yield* session.readUITurns(sessionId, cwd); + const promptEstimate = estimatePromptTokensFrom(yield* session.readHistory(state)); + return { ok: true, turns, promptEstimate, usage: state.usage, ...(codeResult ? { codeResult } : {}) }; }); -} + +const rollbackContext: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd: string; throughTurnId: number }>(); + return json(yield* rollbackState(resolveCwd(body.cwd), id ?? '', body.throughTurnId)); +}); + +const rollbackBoth: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd: string; throughTurnId: number }>(); + const cwd = resolveCwd(body.cwd); + const sessionId = id ?? ''; + const checkpoint = yield* CheckpointService; + const codeResult = yield* checkpoint.rollbackCodeToTurn(cwd, sessionId, body.throughTurnId); + return json(yield* rollbackState(cwd, sessionId, body.throughTurnId, codeResult)); +}); + +const forkSession: Handler = Effect.gen(function* () { + const { id } = yield* pathParams; + const body = yield* readJson<{ cwd: string; atTurnId?: number }>(); + const cwd = resolveCwd(body.cwd); + const session = yield* SessionService; + const state = yield* session.load(cwd, id ?? ''); + const newSessionId = yield* session.forkSession(state, body.atTurnId ?? 0); + const turns = yield* session.readUITurns(newSessionId, cwd); + const newJsonlPath = computePaths(cwd, newSessionId).transcriptPath; + const promptEstimate = estimatePromptTokensFrom(yield* session.readEvents(newJsonlPath)); + return json({ sessionId: newSessionId, turns, promptEstimate }); +}); + +export const addSessionsRoutes = (router: Router): Router => { + // 22 条路由,分两段 pipe —— 单个 pipe 的泛型重载上限是 20。 + const withCore = router.pipe( + HttpRouter.get('/api/sessions', listSessions), + HttpRouter.post('/api/sessions', createSession), + HttpRouter.post('/api/sessions/:id/resume', resumeSession), + HttpRouter.post('/api/sessions/:id/compact', compactSession), + HttpRouter.del('/api/sessions/:id', deleteSession), + HttpRouter.get('/api/sessions/:id/history', sessionHistory), + HttpRouter.get('/api/sessions/:id/plan', readPlan), + HttpRouter.get('/api/sessions/:id/profile', getProfile), + HttpRouter.post('/api/sessions/:id/profile', setProfile), + HttpRouter.get('/api/sessions/:id/permission-mode', getPermissionMode), + HttpRouter.put('/api/sessions/:id/permission-mode', setPermissionMode) + ); + + return withCore.pipe( + HttpRouter.put('/api/sessions/:id/model', setModel), + HttpRouter.put('/api/sessions/:id/title', setTitle), + HttpRouter.get('/api/sessions/:id/checkpoints/latest/diff', latestCheckpointDiff), + HttpRouter.get('/api/sessions/:id/checkpoints/:turnId/diff', turnCheckpointDiff), + HttpRouter.post('/api/sessions/:id/checkpoints/latest/revert-file', revertFile), + HttpRouter.post('/api/sessions/:id/checkpoints/latest/revert-files', revertFiles), + HttpRouter.get('/api/sessions/:id/rollback-preview', rollbackPreview), + HttpRouter.post('/api/sessions/:id/rollback-code-to-turn', rollbackCode), + HttpRouter.post('/api/sessions/:id/rollback-context', rollbackContext), + HttpRouter.post('/api/sessions/:id/rollback-both-to-turn', rollbackBoth), + HttpRouter.post('/api/sessions/:id/fork', forkSession) + ); +}; diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index 54ab0328..d7f2274f 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -1,17 +1,17 @@ -import type { Hono } from 'hono'; -import type { ManagedRuntime } from 'effect'; -import { registerMemorySettingsRoutes } from './memory.js'; -import { registerAgentSettingsRoutes } from './agent.js'; -import { registerHooksSettingsRoutes } from './hooks.js'; -import { registerMcpSettingsRoutes } from './mcp.js'; -import { registerSkillsSettingsRoutes } from './skills.js'; +import { pipe } from 'effect'; +import type { Router } from '../handler.js'; +import { addMemorySettingsRoutes } from './memory.js'; +import { addAgentSettingsRoutes } from './agent.js'; +import { addHooksSettingsRoutes } from './hooks.js'; +import { addMcpSettingsRoutes } from './mcp.js'; +import { addSkillsSettingsRoutes } from './skills.js'; -type ManagedRt = ManagedRuntime.ManagedRuntime; - -export function registerSettingsRoutes(router: Hono, rt: ManagedRt): void { - registerMemorySettingsRoutes(router, rt); - registerAgentSettingsRoutes(router); - registerHooksSettingsRoutes(router); - registerMcpSettingsRoutes(router); - registerSkillsSettingsRoutes(router, rt); -} +export const addSettingsRoutes = (router: Router): Router => + pipe( + router, + addMemorySettingsRoutes, + addAgentSettingsRoutes, + addHooksSettingsRoutes, + addMcpSettingsRoutes, + addSkillsSettingsRoutes + ); diff --git a/packages/codingcode/src/server/routes/skills.ts b/packages/codingcode/src/server/routes/skills.ts index 3244e024..0dc232f3 100644 --- a/packages/codingcode/src/server/routes/skills.ts +++ b/packages/codingcode/src/server/routes/skills.ts @@ -1,49 +1,32 @@ -import type { Hono } from 'hono'; +import * as HttpRouter from '@effect/platform/HttpRouter'; import { dirname } from 'path'; -import { Effect, ManagedRuntime } from 'effect'; +import { Effect } from 'effect'; import { SkillService } from '../../skills/port.js'; import { isGlobalCwd, resolveCwd } from '../cwd.js'; import { discoverGlobalSkillDirs } from '../../skills/source.js'; -import { createRunWithLayer } from '../util.js'; +import { json, query, type Handler, type Router } from '../handler.js'; -type ManagedRt = ManagedRuntime.ManagedRuntime; +const listSkills: Handler = Effect.gen(function* () { + const { cwd: rawCwd } = yield* query; + const skill = yield* SkillService; -export function registerSkillsSettingsRoutes(router: Hono, rt: ManagedRt): void { - const runWithLayer = createRunWithLayer(rt); + if (isGlobalCwd(rawCwd)) { + const skills = yield* skill.getAll(resolveCwd(rawCwd)); + return json(skills.map((s) => ({ ...s, source: 'global' as const }))); + } - router.get('/api/settings/skills', async (c) => { - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - const cwd = resolveCwd(rawCwd); - const skills = await runWithLayer( - Effect.gen(function* () { - const skill = yield* SkillService; - return yield* skill.getAll(cwd); - }) - ); - return c.json( - skills.map((s) => ({ - ...s, - source: 'global' as const, - })) - ); - } - const cwd = resolveCwd(rawCwd); - const globalDirPaths = new Set(discoverGlobalSkillDirs().map((d) => d.dirPath)); - const skills = await runWithLayer( - Effect.gen(function* () { - const skill = yield* SkillService; - return yield* skill.getAll(cwd); - }) - ); - // 按 SKILL.md 所在目录判定来源;同名同时存在于全局与项目时,两条各自标注真实来源 - return c.json( - skills.map((s) => ({ - ...s, - source: globalDirPaths.has(dirname(s.skillPath)) - ? ('global' as const) - : ('project' as const), - })) - ); - }); -} + const cwd = resolveCwd(rawCwd); + const globalDirPaths = new Set(discoverGlobalSkillDirs().map((d) => d.dirPath)); + const skills = yield* skill.getAll(cwd); + return json( + skills.map((s) => ({ + ...s, + source: globalDirPaths.has(dirname(s.skillPath)) + ? ('global' as const) + : ('project' as const), + })) + ); +}); + +export const addSkillsSettingsRoutes = (router: Router): Router => + router.pipe(HttpRouter.get('/api/settings/skills', listSkills)); diff --git a/packages/codingcode/src/server/routes/subagents.ts b/packages/codingcode/src/server/routes/subagents.ts index 9a9039fa..57ae5c3b 100644 --- a/packages/codingcode/src/server/routes/subagents.ts +++ b/packages/codingcode/src/server/routes/subagents.ts @@ -1,24 +1,14 @@ -import type { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; +import * as HttpRouter from '@effect/platform/HttpRouter'; +import { Effect } from 'effect'; import { SubagentRunRegistryService } from '../../subagent/registry.js'; -import { createRunWithLayer } from '../util.js'; +import { json, pathParams, type Handler, type Router } from '../handler.js'; -type ManagedRt = ManagedRuntime.ManagedRuntime; +const stopAll: Handler = Effect.gen(function* () { + const { id: sessionId } = yield* pathParams; + const registry = yield* SubagentRunRegistryService; + const stopped = yield* registry.stopAll(sessionId ?? ''); + return json({ stopped }); +}); -export function registerSubagentsRoutes(router: Hono, rt: ManagedRt): void { - const runWithLayer = createRunWithLayer(rt); - - router.post('/api/sessions/:id/subagents/stop', async (c) => { - const sessionId = c.req.param('id'); - - return c.json( - await runWithLayer( - Effect.gen(function* () { - const registry = yield* SubagentRunRegistryService; - const stopped = yield* registry.stopAll(sessionId); - return { stopped }; - }) - ) - ); - }); -} +export const addSubagentsRoutes = (router: Router): Router => + router.pipe(HttpRouter.post('/api/sessions/:id/subagents/stop', stopAll)); diff --git a/packages/codingcode/src/server/util.ts b/packages/codingcode/src/server/util.ts deleted file mode 100644 index 7ba2ce45..00000000 --- a/packages/codingcode/src/server/util.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Hono } from 'hono'; -import { Cause, Effect, Exit, ManagedRuntime } from 'effect'; -import { isHttpError } from './http-error.js'; - -type ManagedRt = ManagedRuntime.ManagedRuntime; - -function toError(u: unknown): Error { - if (u instanceof Error) return u; - return new Error(typeof u === 'string' ? u : `Unexpected error: ${String(u)}`); -} - - -export function createRunWithLayer(rt: ManagedRt) { - return async function runWithLayer(eff: Effect.Effect): Promise { - const exit = await rt.runPromiseExit(eff); - if (Exit.isSuccess(exit)) return exit.value; - throw toError(Cause.squash(exit.cause)); - }; -} - -export function errorBody(code: string, message: string) { - return { error: { code, message } }; -} - -/** 错误值 → HTTP 状态码 + 响应体。**唯一映射点**,只由 `onError` 调用。 */ -export function errorResponse(err: unknown): { - status: number; - body: { error: { code: string; message: string } }; -} { - if (isHttpError(err)) { - return { status: err.httpStatus(), body: errorBody(err.code, err.message) }; - } - return { - status: 500, - body: errorBody('INTERNAL_ERROR', err instanceof Error ? err.message : 'Internal server error'), - }; -} - -export function registerErrorHandler(app: Hono): void { - app.onError((err, c) => { - const { status, body } = errorResponse(err); - if (status >= 500) console.error(`[${status} ${body.error.code}]`, err); - return c.json(body, status as any); - }); -} diff --git a/packages/codingcode/test/core/error-code.test.ts b/packages/codingcode/test/core/error-code.test.ts deleted file mode 100644 index a26a5edc..00000000 --- a/packages/codingcode/test/core/error-code.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { AgentError } from '../../src/core/error.js'; -import { NotFoundError, AlreadyExistsError } from '../../src/server/http-error.js'; - -describe('NotFoundError has code+httpStatus', () => { - it('code is NOT_FOUND and httpStatus returns 404', () => { - const err = new NotFoundError('missing'); - expect(err.code).toBe('NOT_FOUND'); - expect(err.httpStatus()).toBe(404); - }); -}); - -describe('AlreadyExistsError has code+httpStatus', () => { - it('code is ALREADY_EXISTS and httpStatus returns 409', () => { - const err = new AlreadyExistsError('exists'); - expect(err.code).toBe('ALREADY_EXISTS'); - expect(err.httpStatus()).toBe(409); - }); -}); - -describe('AgentError unchanged', () => { - it('sessionNotFound still returns SESSION_NOT_FOUND with 404', () => { - const err = AgentError.sessionNotFound('abc'); - expect(err.code).toBe('SESSION_NOT_FOUND'); - expect(err.httpStatus()).toBe(404); - }); -}); diff --git a/packages/codingcode/test/core/error.test.ts b/packages/codingcode/test/core/error.test.ts index 8aa5bf5b..653003fe 100644 --- a/packages/codingcode/test/core/error.test.ts +++ b/packages/codingcode/test/core/error.test.ts @@ -1,39 +1,25 @@ import { describe, it, expect } from 'vitest'; import { AgentError } from '../../src/core/error.js'; -describe('AgentError.httpStatus', () => { - it('returns 400 for CONFIG_MISSING', () => { +// core 只负责「领域错误码 + 消息」,HTTP 状态码的映射在 server 层(见 test/server/http-error.test.ts) +describe('AgentError', () => { + it('带上领域码与可判别 tag', () => { const err = AgentError.configMissing('missing'); - expect(err.httpStatus()).toBe(400); + expect(err.code).toBe('CONFIG_MISSING'); + expect(err._tag).toBe('AgentError'); + expect(err.name).toBe('AgentError'); + expect(err.message).toBe('[CONFIG_MISSING] missing'); }); - it('returns 400 for CONFIG_INVALID', () => { - const err = new AgentError('CONFIG_INVALID', 'invalid'); - expect(err.httpStatus()).toBe(400); - }); - - it('returns 404 for SESSION_NOT_FOUND', () => { - const err = AgentError.sessionNotFound('abc'); - expect(err.httpStatus()).toBe(404); - }); - - it('returns 409 for SESSION_WORKSPACE_MISMATCH', () => { - const err = AgentError.sessionWorkspaceMismatch('abc', '/foo'); - expect(err.httpStatus()).toBe(409); - }); - - it('returns 403 for TOOL_NOT_ALLOWED', () => { - const err = AgentError.toolNotAllowed('write_file'); - expect(err.httpStatus()).toBe(403); - }); - - it('returns 429 for LLM_RATE_LIMITED', () => { - const err = new AgentError('LLM_RATE_LIMITED', 'rate limited'); - expect(err.httpStatus()).toBe(429); - }); - - it('returns 500 for unknown codes', () => { - const err = new AgentError('LLM_TIMEOUT', 'timeout'); - expect(err.httpStatus()).toBe(500); + it('工厂方法产出对应领域码', () => { + expect(AgentError.sessionNotFound('abc').code).toBe('SESSION_NOT_FOUND'); + expect(AgentError.sessionWorkspaceMismatch('abc', '/foo').code).toBe( + 'SESSION_WORKSPACE_MISMATCH' + ); + expect(AgentError.toolNotAllowed('write_file').code).toBe('TOOL_NOT_ALLOWED'); + expect(AgentError.toolExecutionFailed('read_file', new Error('boom')).code).toBe( + 'TOOL_EXECUTION_FAILED' + ); + expect(new AgentError('LLM_RATE_LIMITED', 'rate limited').code).toBe('LLM_RATE_LIMITED'); }); }); diff --git a/packages/codingcode/test/server/create-session-active-profile.test.ts b/packages/codingcode/test/server/create-session-active-profile.test.ts index 76645d98..b1d8ed46 100644 --- a/packages/codingcode/test/server/create-session-active-profile.test.ts +++ b/packages/codingcode/test/server/create-session-active-profile.test.ts @@ -1,12 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, ManagedRuntime } from 'effect'; -import { Hono } from 'hono'; import { mkdirSync } from 'fs'; import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; -import { registerErrorHandler } from '../../src/server/util.js'; +import { createServer, type ServerApp } from '../../src/server/index.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -14,15 +12,13 @@ const base = useTempProjectBase(); describe('POST /api/sessions — atomic mode + permissionMode + model', () => { let cwd: string; let rt: ManagedRuntime.ManagedRuntime; - let app: Hono; + let app: ServerApp; beforeEach(async () => { cwd = join(base.dir, 'create-session-active-profile'); mkdirSync(cwd, { recursive: true }); rt = ManagedRuntime.make(SessionLayer as any); - app = new Hono(); - registerErrorHandler(app); - registerSessionsRoutes(app, rt); + app = await createServer(rt); }); afterEach(async () => { diff --git a/packages/codingcode/test/server/cwd.test.ts b/packages/codingcode/test/server/cwd.test.ts index 9a70a430..36323a0c 100644 --- a/packages/codingcode/test/server/cwd.test.ts +++ b/packages/codingcode/test/server/cwd.test.ts @@ -4,6 +4,7 @@ import { join, resolve } from 'path'; import { tmpdir } from 'os'; import { isGlobalCwd, tempCwd, resolveCwd, resolveWorkspaceCwd } from '../../src/server/cwd.js'; import { AgentError } from '../../src/core/error.js'; +import { statusOf } from '../../src/server/http-error.js'; describe('server/cwd:请求入口的工作区解析', () => { it('isGlobalCwd treats missing, empty and "global" as global', () => { @@ -42,6 +43,6 @@ describe('server/cwd:请求入口的工作区解析', () => { } expect(caught).toBeInstanceOf(AgentError); expect((caught as AgentError).code).toBe('CONFIG_INVALID'); - expect((caught as AgentError).httpStatus()).toBe(400); + expect(statusOf(caught as AgentError)).toBe(400); }); }); diff --git a/packages/codingcode/test/server/http-error.test.ts b/packages/codingcode/test/server/http-error.test.ts new file mode 100644 index 00000000..25991c51 --- /dev/null +++ b/packages/codingcode/test/server/http-error.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { AgentError, type ErrorCode } from '../../src/core/error.js'; +import { + AlreadyExistsError, + InvalidInputError, + NotFoundError, + errorCodeOf, + errorResponse, + statusOf, +} from '../../src/server/http-error.js'; + +describe('领域错误码 → 状态码(server 层唯一映射点)', () => { + it('CONFIG_* → 400', () => { + expect(statusOf(AgentError.configMissing('missing'))).toBe(400); + expect(statusOf(new AgentError('CONFIG_INVALID', 'invalid'))).toBe(400); + }); + + it('SESSION_NOT_FOUND → 404', () => { + expect(statusOf(AgentError.sessionNotFound('abc'))).toBe(404); + }); + + it('SESSION_WORKSPACE_MISMATCH → 409', () => { + expect(statusOf(AgentError.sessionWorkspaceMismatch('abc', '/foo'))).toBe(409); + }); + + it('TOOL_NOT_ALLOWED → 403', () => { + expect(statusOf(AgentError.toolNotAllowed('write_file'))).toBe(403); + }); + + it('LLM_RATE_LIMITED → 429', () => { + expect(statusOf(new AgentError('LLM_RATE_LIMITED', 'rate limited'))).toBe(429); + }); + + it('其余 code 一律 500', () => { + expect(statusOf(new AgentError('LLM_TIMEOUT', 'timeout'))).toBe(500); + expect(statusOf(new AgentError('TOOL_NOT_FOUND', 'nope'))).toBe(500); + }); + + it('表覆盖全部 ErrorCode —— 新增 code 不补表会编译失败', () => { + const all: readonly ErrorCode[] = [ + 'LLM_TIMEOUT', + 'LLM_RATE_LIMITED', + 'LLM_FAILED', + 'CONTEXT_OVERFLOW', + 'TOOL_NOT_FOUND', + 'TOOL_NOT_ALLOWED', + 'TOOL_EXECUTION_FAILED', + 'MAX_STEPS_REACHED', + 'CONFIG_MISSING', + 'CONFIG_INVALID', + 'SESSION_CORRUPTED', + 'SESSION_NOT_FOUND', + 'SESSION_WORKSPACE_MISMATCH', + 'AGENT_ABORTED', + 'AGENT_LOOP_DETECTED', + 'EMPTY_RESPONSE', + 'SESSION_IO_ERROR', + ]; + for (const code of all) { + expect(typeof statusOf(new AgentError(code, 'x'))).toBe('number'); + } + }); +}); + +describe('server 自带的 HttpError', () => { + it('NotFoundError → 404 / NOT_FOUND', () => { + const err = new NotFoundError("Hook 'x' not found"); + expect(statusOf(err)).toBe(404); + expect(errorCodeOf(err)).toBe('NOT_FOUND'); + }); + + it('AlreadyExistsError → 409 / ALREADY_EXISTS', () => { + const err = new AlreadyExistsError("Hook 'x' already exists"); + expect(statusOf(err)).toBe(409); + expect(errorCodeOf(err)).toBe('ALREADY_EXISTS'); + }); + + it('InvalidInputError → 400 / CONFIG_INVALID', () => { + const err = new InvalidInputError('bad body'); + expect(statusOf(err)).toBe(400); + expect(errorCodeOf(err)).toBe('CONFIG_INVALID'); + }); +}); + +describe('errorResponse', () => { + it('AppError 走穷尽映射', () => { + const { status, body } = errorResponse(AgentError.sessionNotFound('abc')); + expect(status).toBe(404); + expect(body.error.code).toBe('SESSION_NOT_FOUND'); + expect(body.error.message).toBe('[SESSION_NOT_FOUND] Session "abc" not found'); + }); + + it('未识别的 Error → 500,保留原文', () => { + const { status, body } = errorResponse(new Error('boom')); + expect(status).toBe(500); + expect(body.error.code).toBe('INTERNAL_ERROR'); + expect(body.error.message).toBe('boom'); + }); + + it('非 Error 抛出物 → 500,不丢信息', () => { + const { status, body } = errorResponse({ weird: true }); + expect(status).toBe(500); + expect(body.error.message).toContain('[object Object]'); + }); + + it('字符串抛出物 → 500,带 Unexpected error 前缀', () => { + const { status, body } = errorResponse('raw string'); + expect(status).toBe(500); + expect(body.error.code).toBe('INTERNAL_ERROR'); + expect(body.error.message).toBe('Unexpected error: raw string'); + }); + + // 路由没匹配上不是领域错误、也不是缺陷:必须落 404,不能被「非 AppError 一律 500」吞掉 + it('RouteNotFound → 404,而不是 500', () => { + const { status, body } = errorResponse({ _tag: 'RouteNotFound' }); + expect(status).toBe(404); + expect(body.error.code).toBe('NOT_FOUND'); + }); +}); 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 96937b56..b17842f1 100644 --- a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts +++ b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts @@ -1,11 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { Hono } from 'hono'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { registerMessagesRoutes } from '../../src/server/routes/messages.js'; -import { registerErrorHandler } from '../../src/server/util.js'; +import { createServer, type ServerApp } from '../../src/server/index.js'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { HookService } from '../../src/hooks/port.js'; @@ -59,7 +57,7 @@ describe('POST /api/sessions/:id/messages — reads permissionMode from disk', ( let cwd: string; let sessionId: string; let rt: ManagedRuntime.ManagedRuntime; - let app: Hono; + let app: ServerApp; beforeEach(async () => { cwd = mkdtempSync(join(tmpdir(), 'codingcode-msg-fork-')); @@ -84,9 +82,7 @@ describe('POST /api/sessions/:id/messages — reads permissionMode from disk', ( loadedPermissionModes.length = 0; - app = new Hono(); - registerErrorHandler(app); - registerMessagesRoutes(app, rt); + app = await createServer(rt); }); afterEach(async () => { diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts index db1d4e6b..82e7756c 100644 --- a/packages/codingcode/test/server/plan-file-route.test.ts +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -5,9 +5,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { mkdirSync, writeFileSync, utimesSync } from 'fs'; import { join, resolve } from 'path'; -import { Hono } from 'hono'; -import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; -import { registerErrorHandler } from '../../src/server/util.js'; +import { createServer } from '../../src/server/index.js'; import { SessionService } from '../../src/session/port.js'; import { LLMService } from '../../src/llm/port.js'; import { ApprovalService } from '../../src/approval/port.js'; @@ -152,9 +150,7 @@ beforeEach(() => { describe('GET /api/sessions/:id/plan', () => { it('returns exists:false with empty content when no .md file is present', async () => { const rt = ManagedRuntime.make(TestLayer as any); - const app = new Hono(); - registerErrorHandler(app); - registerSessionsRoutes(app, rt); + const app = await createServer(rt); const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); expect(res.status).toBe(200); const body = (await res.json()) as { @@ -180,9 +176,7 @@ describe('GET /api/sessions/:id/plan', () => { utimesSync(newPath, newerDate, newerDate); const rt = ManagedRuntime.make(TestLayer as any); - const app = new Hono(); - registerErrorHandler(app); - registerSessionsRoutes(app, rt); + const app = await createServer(rt); const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); expect(res.status).toBe(200); const body = (await res.json()) as { @@ -201,9 +195,7 @@ describe('GET /api/sessions/:id/plan', () => { writeFileSync(join(plansDir, 'notes.txt'), 'should be ignored', 'utf8'); const rt = ManagedRuntime.make(TestLayer as any); - const app = new Hono(); - registerErrorHandler(app); - registerSessionsRoutes(app, rt); + const app = await createServer(rt); const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); const body = (await res.json()) as { content: string; exists: boolean }; expect(body.exists).toBe(true); diff --git a/packages/codingcode/test/server/request-body-validation.test.ts b/packages/codingcode/test/server/request-body-validation.test.ts new file mode 100644 index 00000000..8bcb7760 --- /dev/null +++ b/packages/codingcode/test/server/request-body-validation.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + * + * 请求体边界:body 是未验证输入 —— 必填项缺失必须在落盘前就被拦成 400。 + * 这几条路由此前没有任何测试,而它们会把 body 直接写进 hooks.yaml / mcp.yaml。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Layer, ManagedRuntime } from 'effect'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { parse as parseYaml } from 'yaml'; +import { createServer, type ServerApp } from '../../src/server/index.js'; +import { useTempHome } from '../helpers/temp-home.js'; + +// 全局层落在临时 home,避免写坏开发机的 ~/.codingcode +useTempHome('codingcode-test-body-'); + +let projectDir: string; +// 校验失败在任何 Effect 之前就返回,故运行时可以是空壳 —— 只有走到「校验通过」的 +// 分支才会去 context 里取 SchedulerService。 +let app: ServerApp; + +beforeEach(async () => { + projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-body-project-')); + app = await createServer(ManagedRuntime.make(Layer.empty as any)); +}); + +afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); +}); + +function send(target: ServerApp, method: string, path: string, body: unknown) { + return target.request(path, { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +const yamlOf = (file: string): any => + parseYaml(readFileSync(join(projectDir, '.codingcode', file), 'utf8')); + +const HOOK = { + name: 'h', + point: 'tool.execute.before', + type: 'observer', + command: 'echo hi', +}; + +describe('POST /api/settings/hooks —— 必填项', () => { + it('空 body 落 400 CONFIG_MISSING,且不写任何文件', async () => { + const res = await send(app, 'POST', `/api/settings/hooks?cwd=${projectDir}`, {}); + expect(res.status).toBe(400); + expect((await res.json()).error.code).toBe('CONFIG_MISSING'); + expect(existsSync(join(projectDir, '.codingcode', 'hooks.yaml'))).toBe(false); + }); + + it('只缺 point 同样拒绝(body 断言不再把半截对象当完整 hook)', async () => { + const { point: _point, ...withoutPoint } = HOOK; + const res = await send( + app, + 'POST', + `/api/settings/hooks?cwd=${projectDir}`, + withoutPoint + ); + expect(res.status).toBe(400); + expect(existsSync(join(projectDir, '.codingcode', 'hooks.yaml'))).toBe(false); + }); + + it('四个必填项齐全时写入 yaml', async () => { + const res = await send(app, 'POST', `/api/settings/hooks?cwd=${projectDir}`, HOOK); + expect(res.status).toBe(200); + const hooks = yamlOf('hooks.yaml').hooks; + expect(hooks).toHaveLength(1); + expect(hooks[0]).toMatchObject(HOOK); + }); +}); + +describe('PUT /api/settings/hooks/:name —— 必填项', () => { + it('空 body 落 400,不进入查找流程', async () => { + const res = await send( + app, + 'PUT', + `/api/settings/hooks/h?cwd=${projectDir}`, + { name: 'h' } + ); + expect(res.status).toBe(400); + expect((await res.json()).error.code).toBe('CONFIG_MISSING'); + }); +}); + +describe('POST /api/settings/mcp —— name 必填', () => { + it('空 body 落 400 CONFIG_MISSING', async () => { + const res = await send(app, 'POST', `/api/settings/mcp?cwd=${projectDir}`, { + command: 'npx', + }); + expect(res.status).toBe(400); + expect((await res.json()).error.code).toBe('CONFIG_MISSING'); + expect(existsSync(join(projectDir, '.codingcode', 'mcp.yaml'))).toBe(false); + }); + + it('只有 name 也能成立(stdio/http 的其余字段本就可选)', async () => { + const res = await send(app, 'POST', `/api/settings/mcp?cwd=${projectDir}`, { + name: 'srv', + url: 'https://example.com/mcp', + }); + expect(res.status).toBe(200); + expect(yamlOf('mcp.yaml').servers).toMatchObject([{ name: 'srv' }]); + }); +}); + +describe('POST /api/automations —— 四个必填项', () => { + it('空 body 落 400 CONFIG_MISSING', async () => { + const res = await send(app, 'POST', '/api/automations', {}); + expect(res.status).toBe(400); + expect((await res.json()).error.code).toBe('CONFIG_MISSING'); + }); + + it('只缺 projectCwd 也拒绝', async () => { + const res = await send(app, 'POST', '/api/automations', { + name: 'a', + description: 'd', + cron: '0 9 * * *', + }); + expect(res.status).toBe(400); + }); +}); diff --git a/packages/codingcode/test/server/router-registration.test.ts b/packages/codingcode/test/server/router-registration.test.ts index f1a1fe7e..cbfe0467 100644 --- a/packages/codingcode/test/server/router-registration.test.ts +++ b/packages/codingcode/test/server/router-registration.test.ts @@ -1,74 +1,37 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const state = vi.hoisted(() => ({ - honoInstances: 0, - registrations: [] as Array<{ name: string; router: unknown; runtime: unknown }>, -})); - -vi.mock('hono', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Hono: class extends actual.Hono { - constructor(...args: ConstructorParameters) { - super(...args); - state.honoInstances += 1; - } - }, - }; -}); - -function register(name: string) { - return (router: unknown, runtime: unknown) => { - state.registrations.push({ name, router, runtime }); - }; -} - -vi.mock('../../src/server/routes/sessions.js', () => ({ - registerSessionsRoutes: register('sessions'), -})); -vi.mock('../../src/server/routes/messages.js', () => ({ - registerMessagesRoutes: register('messages'), -})); -vi.mock('../../src/server/routes/models.js', () => ({ - registerModelsRoutes: register('models'), -})); -vi.mock('../../src/server/routes/approval.js', () => ({ - registerApprovalRoutes: register('approval'), -})); -vi.mock('../../src/server/routes/settings.js', () => ({ - registerSettingsRoutes: register('settings'), -})); -vi.mock('../../src/server/routes/automations.js', () => ({ - registerAutomationsRoutes: register('automations'), -})); -vi.mock('../../src/server/routes/subagents.js', () => ({ - registerSubagentsRoutes: register('subagents'), -})); - -import { createServer } from '../../src/server/index.js'; - -describe('server route registration', () => { - beforeEach(() => { - state.honoInstances = 0; - state.registrations.length = 0; +import { describe, expect, it } from 'vitest'; +import { Chunk } from 'effect'; +import { buildRouter } from '../../src/server/app.js'; + +/** + * 路由表结构。这些约束以前靠「mock 掉各 register* 函数、数 Hono 实例」来间接保证, + * 现在路由表本身是个值,可以直接断言。 + */ +const routes = Chunk.toReadonlyArray(buildRouter().routes); +const keys = routes.map((route) => `${route.method} ${route.path}`); + +describe('服务器路由表', () => { + it('同一个 METHOD + path 只注册一次', () => { + expect(new Set(keys).size).toBe(keys.length); }); - it('registers every route group on the single server router', async () => { - const runtime = {} as never; - const app = await createServer(runtime); + it('七组路由都挂在同一张表上', () => { + const registered = new Set(keys); + for (const expected of [ + 'GET /api/health', + 'GET /api/sessions', + 'POST /api/sessions/:id/messages', + 'GET /api/models', + 'POST /api/sessions/:sessionId/approval/:id', + 'GET /api/settings/agent/config', + 'GET /api/automations', + 'POST /api/sessions/:id/subagents/stop', + ]) { + expect(registered.has(expected), `缺少路由 ${expected}`).toBe(true); + } + }); - expect(state.honoInstances).toBe(1); - expect(state.registrations.map(({ name }) => name)).toEqual([ - 'sessions', - 'messages', - 'models', - 'approval', - 'settings', - 'automations', - 'subagents', - ]); - expect(state.registrations.every(({ router }) => router === app)).toBe(true); - expect(state.registrations.every(({ runtime: value }) => value === runtime)).toBe(true); + // 兜底通配会把「没这条路由」变成「有路由但返回别的东西」,404 就再也发不出来 + it('没有兜底通配路由', () => { + expect(routes.some((route) => route.path === '*')).toBe(false); }); }); diff --git a/packages/codingcode/test/server/session-title-route.test.ts b/packages/codingcode/test/server/session-title-route.test.ts index c9261656..cc6e176f 100644 --- a/packages/codingcode/test/server/session-title-route.test.ts +++ b/packages/codingcode/test/server/session-title-route.test.ts @@ -1,12 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, ManagedRuntime } from 'effect'; -import { Hono } from 'hono'; import { mkdirSync } from 'fs'; import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; -import { registerErrorHandler } from '../../src/server/util.js'; +import { createServer, type ServerApp } from '../../src/server/index.js'; import { computePaths } from '../../src/session/paths.js'; import { readSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -16,15 +14,13 @@ const base = useTempProjectBase(); describe('PUT /api/sessions/:id/title', () => { let cwd: string; let rt: ManagedRuntime.ManagedRuntime; - let app: Hono; + let app: ServerApp; beforeEach(async () => { cwd = join(base.dir, 'session-title-route'); mkdirSync(cwd, { recursive: true }); rt = ManagedRuntime.make(SessionLayer as any); - app = new Hono(); - registerErrorHandler(app); - registerSessionsRoutes(app, rt); + app = await createServer(rt); }); afterEach(async () => { diff --git a/packages/codingcode/test/server/sse.test.ts b/packages/codingcode/test/server/sse.test.ts new file mode 100644 index 00000000..64f9330a --- /dev/null +++ b/packages/codingcode/test/server/sse.test.ts @@ -0,0 +1,119 @@ +/** + * @vitest-environment node + * + * SSE 契约:帧封套编码 + 「生成器抛出物落成一帧 fatal,而不是断流」。 + * + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { createServer, type ServerApp } from '../../src/server/index.js'; +import { AgentService } from '../../src/agent/port.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.js'; +import { AgentError } from '../../src/core/error.js'; +import type { FrameBody } from '../../src/sink/types.js'; + +const TEXT_FRAME: FrameBody = { family: 'event', event: { type: 'text_delta', text: 'hello' } }; + +let cwd: string; +let cancelled: string[] = []; + +function makeServer(frames: () => AsyncGenerator): Promise { + const layer = Layer.mergeAll( + Layer.succeed(AgentService, { + runTurn: (_input: string, opts: { sessionId?: string }) => + Effect.succeed({ + stream: frames(), + sessionId: opts.sessionId ?? 's1', + }), + } as any), + Layer.succeed(ApprovalWaitService, { + cancelPendingFor: (sessionId: string) => + Effect.sync(() => { + cancelled.push(sessionId); + return 0; + }), + resolveConfirm: () => Effect.succeed(false), + waitForConfirm: () => Effect.dieMessage('not implemented'), + } as any) + ); + return createServer(ManagedRuntime.make(layer)); +} + +const send = (app: ServerApp, sessionId = 's1') => + app.request(`/api/sessions/${sessionId}/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ input: 'hi', cwd, model: 'm' }), + }); + +/** 把 SSE body 拆成逐帧的 JSON。 */ +const decode = (body: string) => + body + .split('\n\n') + .filter((chunk) => chunk.trim().length > 0) + .map((chunk) => JSON.parse(chunk.replace(/^data: /, '').trim())); + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'codingcode-sse-')); + cancelled = []; +}); + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); +}); + +describe('SSE /api/sessions/:id/messages', () => { + it('应答 text/event-stream,帧带封套(sessionId / seq)', async () => { + const app = await makeServer(async function* () { + yield TEXT_FRAME; + }); + const res = await send(app); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/event-stream'); + + const frames = decode(await res.text()); + expect(frames).toHaveLength(1); + expect(frames[0]).toMatchObject({ + sessionId: 's1', + seq: 1, + family: 'event', + event: { type: 'text_delta', text: 'hello' }, + }); + }); + + it('生成器抛出物落成一帧 fatal,前序帧照常下发', async () => { + const app = await makeServer(async function* () { + yield TEXT_FRAME; + throw new Error('boom'); + }); + + const frames = decode(await (await send(app)).text()); + expect(frames).toHaveLength(2); + expect(frames[0].family).toBe('event'); + expect(frames[1]).toMatchObject({ + seq: 2, + family: 'fatal', + fatal: { message: 'boom', code: 'INTERNAL_ERROR' }, + }); + }); + + it('领域错误(AgentError)带出自己的 code —— 不是笼统的 INTERNAL_ERROR', async () => { + const app = await makeServer(async function* () { + throw new AgentError('SESSION_CORRUPTED', 'nope'); + }); + + const frames = decode(await (await send(app)).text()); + expect(frames[0].fatal.code).toBe('SESSION_CORRUPTED'); + }); + + it('流结束时取消该会话的挂起审批', async () => { + // eslint-disable-next-line require-yield -- 空流正是本用例要覆盖的形状 + const app = await makeServer(async function* () {}); + await (await send(app, 'sess-9')).text(); + expect(cancelled).toEqual(['sess-9']); + }); +}); diff --git a/packages/codingcode/test/server/subagents-route.test.ts b/packages/codingcode/test/server/subagents-route.test.ts index 14bd78e4..ba851158 100644 --- a/packages/codingcode/test/server/subagents-route.test.ts +++ b/packages/codingcode/test/server/subagents-route.test.ts @@ -1,22 +1,16 @@ import { describe, it, expect } from 'vitest'; -import { Hono } from 'hono'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { registerSubagentsRoutes } from '../../src/server/routes/subagents.js'; -import { registerErrorHandler } from '../../src/server/util.js'; +import { createServer } from '../../src/server/index.js'; import { SubagentRunRegistryService } from '../../src/subagent/registry.js'; -function makeApp(stopAll: (sessionId: string) => Effect.Effect) { +async function makeApp(stopAll: (sessionId: string) => Effect.Effect) { const layer = Layer.succeed(SubagentRunRegistryService, { stopAll } as any); - const rt = ManagedRuntime.make(layer); - const app = new Hono(); - registerErrorHandler(app); - registerSubagentsRoutes(app, rt); - return app; + return createServer(ManagedRuntime.make(layer)); } describe('POST /api/sessions/:id/subagents/stop', () => { it('返回停掉的数量', async () => { - const app = makeApp(() => Effect.succeed(3)); + const app = await makeApp(() => Effect.succeed(3)); const res = await app.request('/api/sessions/s1/subagents/stop', { method: 'POST' }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ stopped: 3 }); @@ -24,7 +18,7 @@ describe('POST /api/sessions/:id/subagents/stop', () => { it('把路径里的 sessionId 原样透给注册表', async () => { const seen: string[] = []; - const app = makeApp((sessionId) => + const app = await makeApp((sessionId) => Effect.sync(() => { seen.push(sessionId); return 0; diff --git a/packages/codingcode/test/server/util.test.ts b/packages/codingcode/test/server/util.test.ts deleted file mode 100644 index 3abb86c0..00000000 --- a/packages/codingcode/test/server/util.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; -import { createRunWithLayer, errorResponse } from '../../src/server/util.js'; -import { AlreadyExistsError, NotFoundError } from '../../src/server/http-error.js'; -import { AgentError } from '../../src/core/error.js'; - -describe('server/util', () => { - describe('errorResponse', () => { - it('returns AgentError status for known codes', () => { - const err = AgentError.sessionNotFound('abc'); - const resp = errorResponse(err); - expect(resp.status).toBe(404); - expect(resp.body.error.code).toBe('SESSION_NOT_FOUND'); - expect(resp.body.error.message).toBe('[SESSION_NOT_FOUND] Session "abc" not found'); - }); - - it('returns 500 for unknown errors with original message', () => { - const resp = errorResponse(new Error('boom')); - expect(resp.status).toBe(500); - expect(resp.body.error.code).toBe('INTERNAL_ERROR'); - expect(resp.body.error.message).toBe('boom'); - }); - - it('returns 500 with fallback for non-Error throw', () => { - const resp = errorResponse('raw string'); - expect(resp.status).toBe(500); - expect(resp.body.error.code).toBe('INTERNAL_ERROR'); - expect(resp.body.error.message).toBe('Internal server error'); - }); - - it('returns 400 for CONFIG_MISSING', () => { - const err = AgentError.configMissing('missing'); - const resp = errorResponse(err); - expect(resp.status).toBe(400); - expect(resp.body.error.message).toBe('[CONFIG_MISSING] missing'); - }); - - it('returns 403 for TOOL_NOT_ALLOWED', () => { - const err = AgentError.toolNotAllowed('write_file'); - const resp = errorResponse(err); - expect(resp.status).toBe(403); - expect(resp.body.error.message).toBe('[TOOL_NOT_ALLOWED] Tool "write_file" is not allowed'); - }); - - it('returns 429 for LLM_RATE_LIMITED', () => { - const err = new AgentError('LLM_RATE_LIMITED', 'rate limited'); - const resp = errorResponse(err); - expect(resp.status).toBe(429); - }); - - it('returns 404 for NotFoundError(自带 code + httpStatus,不必是 AgentError)', () => { - const resp = errorResponse(new NotFoundError("Automation 'x' not found")); - expect(resp.status).toBe(404); - expect(resp.body.error.code).toBe('NOT_FOUND'); - expect(resp.body.error.message).toBe("Automation 'x' not found"); - }); - - it('returns 409 for AlreadyExistsError', () => { - const resp = errorResponse(new AlreadyExistsError("Hook 'x' already exists")); - expect(resp.status).toBe(409); - expect(resp.body.error.code).toBe('ALREADY_EXISTS'); - }); - }); - - describe('createRunWithLayer', () => { - const runWithLayer = createRunWithLayer(ManagedRuntime.make(Layer.empty as any) as any); - - it('返回成功值', async () => { - await expect(runWithLayer(Effect.succeed(1))).resolves.toBe(1); - }); - - it('正常失败原样抛出(http 语义交给 onError)', async () => { - await expect(runWithLayer(Effect.fail(new NotFoundError('nope')))).rejects.toBeInstanceOf( - NotFoundError - ); - }); - - it('把非 Error 的失败归一成 Error —— Hono onError 只接 instanceof Error', async () => { - await expect(runWithLayer(Effect.fail('boom'))).rejects.toBeInstanceOf(Error); - }); - - it('defect 同样归一成 Error,不逃出边界', async () => { - await expect(runWithLayer(Effect.die('die'))).rejects.toBeInstanceOf(Error); - }); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0a17125..491fa817 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,9 +19,12 @@ importers: '@ai-sdk/openai': specifier: ^3.0.63 version: 3.0.67(zod@4.4.3) - '@hono/node-server': - specifier: ^2.0.2 - version: 2.0.4(hono@4.12.23) + '@effect/platform': + specifier: ^0.96.0 + version: 0.96.0(effect@3.21.2) + '@effect/platform-node': + specifier: ^0.96.0 + version: 0.96.0(@effect/cluster@0.48.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/workflow@0.9.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2) '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.4.3) @@ -37,15 +40,12 @@ importers: globby: specifier: ^14.1.0 version: 14.1.0 - hono: - specifier: ^4.12.19 - version: 4.12.23 pino: specifier: ^9.6.0 version: 9.6.0 pino-pretty: specifier: ^13.0.0 - version: 13.1.3 + version: 13.2.0 react: specifier: ^19.2.6 version: 19.2.7 @@ -104,6 +104,12 @@ importers: '@ai-sdk/provider-utils': specifier: ^4.0.0 version: 4.0.27(zod@4.4.3) + '@effect/platform': + specifier: 0.96.0 + version: 0.96.0(effect@3.21.2) + '@effect/platform-node': + specifier: 0.96.0 + version: 0.96.0(@effect/cluster@0.48.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/workflow@0.9.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2) ai: specifier: ^6.0.180 version: 6.0.194(zod@4.4.3) @@ -113,15 +119,12 @@ importers: effect: specifier: ^3.21.2 version: 3.21.2 - hono: - specifier: ^4.12.19 - version: 4.12.23 pino: specifier: ^9.6.0 version: 9.6.0 pino-pretty: specifier: ^13.0.0 - version: 13.1.3 + version: 13.2.0 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -401,6 +404,71 @@ packages: resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} + '@effect/cluster@0.48.0': + resolution: {integrity: sha512-C3fpuQH2vdZQMTp8VsaDRNhBh1ZTtVMU/w/NtOpXQE/mx4gnVEbDOrIcIiiidqu2+0M+i6NfVQI8wJThop3UtQ==} + peerDependencies: + '@effect/platform': ^0.90.2 + '@effect/rpc': ^0.69.0 + '@effect/sql': ^0.44.1 + '@effect/workflow': ^0.9.0 + effect: ^3.17.7 + + '@effect/experimental@0.54.4': + resolution: {integrity: sha512-rOgeydJEyDkm0nP84DH7FXSJZheXXFukvfMYAgYIvCE5PbuEEtr9MDsBo05hxWJxMqoNrMQln/sCr3QaSS/GBw==} + peerDependencies: + '@effect/platform': ^0.90.0 + effect: ^3.17.6 + ioredis: ^5 + lmdb: ^3 + peerDependenciesMeta: + ioredis: + optional: true + lmdb: + optional: true + + '@effect/platform-node-shared@0.49.0': + resolution: {integrity: sha512-6ufPQUtofYW+jsADRI4Pa4sMY+kc0dcoXWpH1ozH/bD6I5c2au1n/wDffnLoXMeHGYSpt/54Dd7WOqqNcOdXlg==} + peerDependencies: + '@effect/cluster': ^0.48.0 + '@effect/platform': ^0.90.2 + '@effect/rpc': ^0.69.0 + '@effect/sql': ^0.44.1 + effect: ^3.17.7 + + '@effect/platform-node@0.96.0': + resolution: {integrity: sha512-9v6UJnSiQGq90gYPdakcLjkyX951ZODLwtkZgXjdKwjvcpx5C1Feq+LDsSifF3aOg1NgamwAGYDKi00JQxK6Cg==} + peerDependencies: + '@effect/cluster': ^0.48.0 + '@effect/platform': ^0.90.2 + '@effect/rpc': ^0.69.0 + '@effect/sql': ^0.44.1 + effect: ^3.17.7 + + '@effect/platform@0.96.0': + resolution: {integrity: sha512-U7PLhkVzg7zzrgFvyWATOzD6reL87KG/fcdOxgLWBQ/J5CCU6qdPAVG+0o6o+IxcsLoqGwxs+rFxaFzrdtDV1A==} + peerDependencies: + effect: ^3.21.0 + + '@effect/rpc@0.69.0': + resolution: {integrity: sha512-iHVf7OxIblLVRlKlZLe40UTsSwBzpQAGXLOHKqSzTRnVgetbNx/UjhIJmQJxua2p9WWsLAJUpNOlTBhtuXcUAw==} + peerDependencies: + '@effect/platform': ^0.90.2 + effect: ^3.17.7 + + '@effect/sql@0.44.1': + resolution: {integrity: sha512-h8/WdEG+dswVcN6QjAZRFT7AJ8g3wHMQFR9b8ul14idqXuBcPZxx9OFSsUT3tOTtUKmRMrlU8iMiUq0LIsm5SQ==} + peerDependencies: + '@effect/experimental': ^0.54.4 + '@effect/platform': ^0.90.0 + effect: ^3.17.6 + + '@effect/workflow@0.9.0': + resolution: {integrity: sha512-Id9kO3UQT3CL+sVUDR0ZppI3U0WvamU04KY+Dqzf5VaYP90Y1oLAp4/h1N9sctx2mzcY96l35yDICAluxUlK6A==} + peerDependencies: + '@effect/platform': ^0.90.2 + '@effect/rpc': ^0.69.0 + effect: ^3.17.7 + '@electron/asar@3.2.7': resolution: {integrity: sha512-8FaSCAIiZGYFWyjeevPQt+0e9xCK9YmJ2Rjg5SXgdsXon6cRnU0Yxnbe6CvJbQn26baifur2Y2G5EBayRIsjyg==} engines: {node: '>=10.12.0'} @@ -941,12 +1009,6 @@ packages: peerDependencies: hono: ^4 - '@hono/node-server@2.0.4': - resolution: {integrity: sha512-Ut3y0dMMPWy6bZ2kVfx25EOVbZlm15dhF4mOsezMlhpNHy+4MkU1qN9Y6lnruYi4wPmFzimGX2X7LF/FwHli4A==} - engines: {node: '>=20'} - peerDependencies: - hono: ^4 - '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1032,6 +1094,36 @@ packages: '@cfworker/json-schema': optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1056,6 +1148,92 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2372,6 +2550,9 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3154,6 +3335,11 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -3232,6 +3418,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@1.12.1: + resolution: {integrity: sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==} + + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} @@ -3258,9 +3454,16 @@ packages: node-addon-api@1.7.2: resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + node-addon-api@7.0.0: + resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} + node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp@9.0.0: resolution: {integrity: sha512-Ma6p4s+XCTPxCuAMrOA/IJRmVy16R8Sdhtwl4PrCr7IBlj4cPawF0vg/l7nOT1jPbuNS7lIRJpBSvVsXwEZuzw==} engines: {node: ^12.22 || ^14.13 || >=16} @@ -3429,8 +3632,8 @@ packages: pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - pino-pretty@13.1.3: - resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + pino-pretty@13.2.0: + resolution: {integrity: sha512-jjghD1ML+Di/WxjJDz6j6Y4FT7hIXOlb8h27AyJ+ohtAXJXH4PS/xkv7si+RCCIW59HMgZsMKTDgzdr0RhuRrg==} hasBin: true pino-std-serializers@7.1.0: @@ -3787,6 +3990,9 @@ packages: sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + sonic-boom@5.0.1: + resolution: {integrity: sha512-I1E9tHuKe6SdS/Uab4bl2o1ZUBm2SCTDFF9eQ66ehyVcFIBixfecy9DlwXCtnRF8CUzPw7Q4Dn6JQE/8iVl/FQ==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4074,6 +4280,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -4219,6 +4429,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -4501,6 +4723,75 @@ snapshots: ajv: 6.15.0 ajv-keywords: 3.5.2(ajv@6.15.0) + '@effect/cluster@0.48.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/workflow@0.9.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(effect@3.21.2)': + dependencies: + '@effect/platform': 0.96.0(effect@3.21.2) + '@effect/rpc': 0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2) + '@effect/sql': 0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2) + '@effect/workflow': 0.9.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2) + effect: 3.21.2 + + '@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2)': + dependencies: + '@effect/platform': 0.96.0(effect@3.21.2) + effect: 3.21.2 + uuid: 11.1.1 + + '@effect/platform-node-shared@0.49.0(@effect/cluster@0.48.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/workflow@0.9.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2)': + dependencies: + '@effect/cluster': 0.48.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/workflow@0.9.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(effect@3.21.2) + '@effect/platform': 0.96.0(effect@3.21.2) + '@effect/rpc': 0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2) + '@effect/sql': 0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2) + '@parcel/watcher': 2.6.0 + effect: 3.21.2 + multipasta: 0.2.8 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@0.96.0(@effect/cluster@0.48.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/workflow@0.9.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2)': + dependencies: + '@effect/cluster': 0.48.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/workflow@0.9.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(effect@3.21.2) + '@effect/platform': 0.96.0(effect@3.21.2) + '@effect/platform-node-shared': 0.49.0(@effect/cluster@0.48.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/workflow@0.9.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2) + '@effect/rpc': 0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2) + '@effect/sql': 0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2) + effect: 3.21.2 + mime: 3.0.0 + undici: 7.27.2 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform@0.96.0(effect@3.21.2)': + dependencies: + effect: 3.21.2 + find-my-way-ts: 0.1.6 + msgpackr: 1.12.1 + multipasta: 0.2.8 + + '@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2)': + dependencies: + '@effect/platform': 0.96.0(effect@3.21.2) + effect: 3.21.2 + + '@effect/sql@0.44.1(@effect/experimental@0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2)': + dependencies: + '@effect/experimental': 0.54.4(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2) + '@effect/platform': 0.96.0(effect@3.21.2) + '@opentelemetry/semantic-conventions': 1.43.0 + effect: 3.21.2 + uuid: 11.1.1 + + '@effect/workflow@0.9.0(@effect/platform@0.96.0(effect@3.21.2))(@effect/rpc@0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2))(effect@3.21.2)': + dependencies: + '@effect/platform': 0.96.0(effect@3.21.2) + '@effect/rpc': 0.69.0(@effect/platform@0.96.0(effect@3.21.2))(effect@3.21.2) + effect: 3.21.2 + '@electron/asar@3.2.7': dependencies: commander: 5.0.0 @@ -4843,10 +5134,6 @@ snapshots: dependencies: hono: 4.12.23 - '@hono/node-server@2.0.4(hono@4.12.23)': - dependencies: - hono: 4.12.23 - '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -4956,6 +5243,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4980,6 +5285,64 @@ snapshots: '@opentelemetry/api@1.9.1': {} + '@opentelemetry/semantic-conventions@1.43.0': {} + + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.0.0 + picomatch: 4.0.4 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + '@pkgjs/parseargs@0.11.0': optional: true @@ -6486,6 +6849,8 @@ snapshots: transitivePeerDependencies: - supports-color + find-my-way-ts@0.1.6: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -7540,6 +7905,8 @@ snapshots: mime@2.6.0: {} + mime@3.0.0: {} + mimic-fn@2.1.0: {} mimic-response@1.0.1: {} @@ -7609,6 +7976,24 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@1.12.1: + optionalDependencies: + msgpackr-extract: 3.0.4 + + multipasta@0.2.8: {} + mute-stream@0.0.8: {} nanoid@3.3.12: {} @@ -7626,10 +8011,17 @@ snapshots: node-addon-api@1.7.2: optional: true + node-addon-api@7.0.0: {} + node-api-version@0.2.1: dependencies: semver: 7.8.1 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp@9.0.0: dependencies: env-paths: 2.2.0 @@ -7794,7 +8186,7 @@ snapshots: dependencies: split2: 4.2.0 - pino-pretty@13.1.3: + pino-pretty@13.2.0: dependencies: colorette: 2.0.20 dateformat: 4.6.3 @@ -7807,7 +8199,7 @@ snapshots: pino-abstract-transport: 3.0.0 pump: 3.0.4 secure-json-parse: 4.1.0 - sonic-boom: 4.2.1 + sonic-boom: 5.0.1 strip-json-comments: 5.0.3 pino-std-serializers@7.1.0: {} @@ -8246,6 +8638,10 @@ snapshots: dependencies: atomic-sleep: 1.0.0 + sonic-boom@5.0.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -8534,6 +8930,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.1: {} + vary@1.1.2: {} verror@1.10.1: @@ -8693,6 +9091,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.22.0: {} + xml-name-validator@5.0.0: {} xmlbuilder@15.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d932804f..8097dd84 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,10 +5,7 @@ overrides: vite: ^6.4.2 allowBuilds: + '@parcel/watcher': false electron: true esbuild: true - -settings: - onlyBuiltDependencies: - - electron - - esbuild + msgpackr-extract: false From 7e08a3efebee08f98e1717f43e4e7fa2df762602 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 8 Oct 2026 19:13:47 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E5=B0=86core=E6=A8=A1=E5=9D=97=E6=94=B9?= =?UTF-8?q?=E5=90=8D=E6=88=90util?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/codingcode/src/agent/agent.ts | 6 +- packages/codingcode/src/agent/port.ts | 2 +- .../codingcode/src/checkpoint/checkpoint.ts | 2 +- .../codingcode/src/checkpoint/project-lock.ts | 2 +- .../codingcode/src/checkpoint/shadow-git.ts | 2 +- packages/codingcode/src/checkpoint/utils.ts | 2 +- packages/codingcode/src/cli.ts | 2 +- packages/codingcode/src/context/context.ts | 2 +- packages/codingcode/src/context/port.ts | 2 +- packages/codingcode/src/hooks/config.ts | 2 +- packages/codingcode/src/infra/config.ts | 2 +- packages/codingcode/src/infra/logger.ts | 2 +- packages/codingcode/src/infra/models.ts | 2 +- packages/codingcode/src/layer.ts | 1 - packages/codingcode/src/llm/errors.ts | 2 +- packages/codingcode/src/llm/llm.ts | 2 +- packages/codingcode/src/llm/port.ts | 2 +- .../codingcode/src/llm/providers/deepseek.ts | 2 +- .../codingcode/src/llm/providers/openai.ts | 2 +- packages/codingcode/src/llm/types.ts | 2 +- packages/codingcode/src/mcp/config.ts | 2 +- packages/codingcode/src/mcp/mcp.ts | 2 +- packages/codingcode/src/mcp/types.ts | 2 +- packages/codingcode/src/memory/storage.ts | 2 +- packages/codingcode/src/rules/rules.ts | 2 +- .../codingcode/src/scheduler/scheduler.ts | 2 - packages/codingcode/src/scheduler/store.ts | 2 +- packages/codingcode/src/server/cwd.ts | 4 +- packages/codingcode/src/server/handler.ts | 2 +- packages/codingcode/src/server/http-error.ts | 2 +- .../codingcode/src/server/routes/agent.ts | 2 +- .../src/server/routes/automations.ts | 2 +- .../codingcode/src/server/routes/hooks.ts | 2 +- packages/codingcode/src/server/routes/mcp.ts | 2 +- .../codingcode/src/server/routes/messages.ts | 3 +- .../codingcode/src/server/routes/sessions.ts | 4 +- packages/codingcode/src/session/file-ops.ts | 2 +- packages/codingcode/src/session/paths.ts | 2 +- packages/codingcode/src/session/port.ts | 2 +- packages/codingcode/src/session/session.ts | 4 +- packages/codingcode/src/session/types.ts | 5 +- packages/codingcode/src/skills/source.ts | 2 +- packages/codingcode/src/subagent/port.ts | 4 +- packages/codingcode/src/subagent/registry.ts | 2 +- packages/codingcode/src/subagent/subagent.ts | 2 +- packages/codingcode/src/tools/catalog.ts | 2 +- .../codingcode/src/tools/domains/bash/exec.ts | 2 +- .../codingcode/src/tools/domains/fs/edit.ts | 2 +- .../codingcode/src/tools/domains/fs/glob.ts | 2 +- .../codingcode/src/tools/domains/fs/grep.ts | 2 +- .../codingcode/src/tools/domains/fs/read.ts | 2 +- .../codingcode/src/tools/domains/fs/write.ts | 2 +- .../src/tools/domains/self/todo-write.ts | 2 +- .../src/tools/domains/subagent/spawn.ts | 2 +- .../src/tools/domains/subagent/submit-plan.ts | 4 +- .../codingcode/src/tools/domains/web/fetch.ts | 2 +- .../src/tools/domains/web/search.ts | 2 +- packages/codingcode/src/tools/tools.ts | 35 ++++--- packages/codingcode/src/tools/types.ts | 14 ++- .../codingcode/src/{core => util}/error.ts | 0 .../codingcode/src/{core => util}/path.ts | 0 .../codingcode/src/{core => util}/result.ts | 0 .../test/agent/skill-mention-persist.test.ts | 2 +- .../test/architecture/boundaries.test.ts | 22 ++--- .../checkpoint/turn-title-removal.test.ts | 2 +- .../codingcode/test/helpers/project-base.ts | 2 +- packages/codingcode/test/helpers/temp-home.ts | 2 +- .../codingcode/test/llm/error-mapping.test.ts | 2 +- .../codingcode/test/memory/extractor.test.ts | 2 +- packages/codingcode/test/memory/index.test.ts | 2 +- packages/codingcode/test/server/cwd.test.ts | 2 +- .../codingcode/test/server/http-error.test.ts | 2 +- .../test/server/plan-file-route.test.ts | 2 +- packages/codingcode/test/server/sse.test.ts | 2 +- .../test/session/compute-paths.test.ts | 2 +- .../codingcode/test/session/io-error.test.ts | 2 +- .../test/session/load-create.test.ts | 2 +- .../test/session/prompt-estimate.test.ts | 2 +- .../test/session/record-user-title.test.ts | 2 +- .../test/subagent/runner-wiring.test.ts | 4 +- .../tools/domains/bash/exec-error.test.ts | 2 +- .../tools/executor-ctx-projection.test.ts | 95 +++++++++++++++++++ .../test/types/tool-exec-shapes.test.ts | 51 ++++++++++ .../test/{core => util}/error.test.ts | 4 +- .../test/{core => util}/frame-io.test.ts | 0 .../{core => util}/frame-protocol.test.ts | 0 .../test/{core => util}/path.test.ts | 6 +- packages/desktop/src/hooks/useAgent.ts | 2 +- packages/desktop/src/lib/core-api.ts | 2 +- .../desktop/test/profile-indicator.test.tsx | 5 +- packages/sdk/src/types.ts | 1 - 91 files changed, 269 insertions(+), 137 deletions(-) rename packages/codingcode/src/{core => util}/error.ts (100%) rename packages/codingcode/src/{core => util}/path.ts (100%) rename packages/codingcode/src/{core => util}/result.ts (100%) create mode 100644 packages/codingcode/test/tools/executor-ctx-projection.test.ts create mode 100644 packages/codingcode/test/types/tool-exec-shapes.test.ts rename packages/codingcode/test/{core => util}/error.test.ts (89%) rename packages/codingcode/test/{core => util}/frame-io.test.ts (100%) rename packages/codingcode/test/{core => util}/frame-protocol.test.ts (100%) rename packages/codingcode/test/{core => util}/path.test.ts (93%) diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index e0eae9da..926be58c 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -1,6 +1,6 @@ import { Effect, Either, Queue, Stream, Fiber, Layer } from 'effect'; -import { AgentError } from '../core/error.js'; -import { Result } from '../core/result.js'; +import { AgentError } from '../util/error.js'; +import { Result } from '../util/result.js'; import { AgentService, ToolEnvPort } from './port.js'; import type { RunTurnOptions, ToolEnv } from './port.js'; import { ApprovalService, getToolNames } from '../approval/port.js'; @@ -32,7 +32,7 @@ import type { ToolCatalog, ToolResult } from '../tools/types.js'; import type { ToolCall } from '../llm/types.js'; import { loadConfig } from '../infra/config.js'; import { createLogger } from '../infra/logger.js'; -import { normalizePath } from '../core/path.js'; +import { normalizePath } from '../util/path.js'; import { resolveProfile } from './profile.js'; import type { AgentProfile } from './profile.js'; diff --git a/packages/codingcode/src/agent/port.ts b/packages/codingcode/src/agent/port.ts index 24170650..215f2019 100644 --- a/packages/codingcode/src/agent/port.ts +++ b/packages/codingcode/src/agent/port.ts @@ -4,7 +4,7 @@ import type { FrameBody } from '../sink/types.js'; import type { ProfileName } from '../session/types.js'; import type { PermissionMode } from '../session/types.js'; -import type { AgentError } from '../core/error.js'; +import type { AgentError } from '../util/error.js'; export interface RunTurnOptions { sessionId?: string; diff --git a/packages/codingcode/src/checkpoint/checkpoint.ts b/packages/codingcode/src/checkpoint/checkpoint.ts index e2967241..24eabb03 100644 --- a/packages/codingcode/src/checkpoint/checkpoint.ts +++ b/packages/codingcode/src/checkpoint/checkpoint.ts @@ -2,7 +2,7 @@ import { Layer, Effect } from 'effect'; import { resolve } from 'path'; import { ShadowGit } from './shadow-git.js'; import { ProjectLock } from './project-lock.js'; -import { normalizePath } from '../core/path.js'; +import { normalizePath } from '../util/path.js'; import { commitMsg, toGitPath, ProjectCache } from './utils.js'; import { getCompletedTurnsFor, getTurnRestorePlan, getRollbackToTurnPlan } from './turn-query.js'; import { emptyRollbackResult, executeRollback } from './rollback-engine.js'; diff --git a/packages/codingcode/src/checkpoint/project-lock.ts b/packages/codingcode/src/checkpoint/project-lock.ts index 7ccd6347..56c9c4ef 100644 --- a/packages/codingcode/src/checkpoint/project-lock.ts +++ b/packages/codingcode/src/checkpoint/project-lock.ts @@ -1,6 +1,6 @@ import { openSync, closeSync, unlinkSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; -import { projectDataDir } from '../core/path.js'; +import { projectDataDir } from '../util/path.js'; export class ProjectLock { private readonly lockPath: string; diff --git a/packages/codingcode/src/checkpoint/shadow-git.ts b/packages/codingcode/src/checkpoint/shadow-git.ts index c07224cd..e079f469 100644 --- a/packages/codingcode/src/checkpoint/shadow-git.ts +++ b/packages/codingcode/src/checkpoint/shadow-git.ts @@ -1,7 +1,7 @@ import { spawnSync } from 'child_process'; import { existsSync, mkdirSync, statSync, writeFileSync } from 'fs'; import { join } from 'path'; -import { normalizePath, projectDataDir } from '../core/path.js'; +import { normalizePath, projectDataDir } from '../util/path.js'; const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null'; diff --git a/packages/codingcode/src/checkpoint/utils.ts b/packages/codingcode/src/checkpoint/utils.ts index a2229484..250b8371 100644 --- a/packages/codingcode/src/checkpoint/utils.ts +++ b/packages/codingcode/src/checkpoint/utils.ts @@ -1,7 +1,7 @@ import { createHash } from 'crypto'; import { readFileSync } from 'fs'; import { resolve } from 'path'; -import { normalizePath } from '../core/path.js'; +import { normalizePath } from '../util/path.js'; export function shortSid(sessionId: string): string { return createHash('sha256').update(sessionId).digest('hex').slice(0, 8); diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index b3059636..9b88b150 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -8,7 +8,7 @@ import { corsMiddleware, createHttpApp } from './server/app.js'; import { createAppRuntime } from './layer.js'; import { ensureUserConfig } from './infra/config.js'; import { tempCwd } from './server/cwd.js'; -import { AgentError } from './core/error.js'; +import { AgentError } from './util/error.js'; import { SchedulerService } from './scheduler/port.js'; async function main() { diff --git a/packages/codingcode/src/context/context.ts b/packages/codingcode/src/context/context.ts index a14e86b7..f8d19484 100644 --- a/packages/codingcode/src/context/context.ts +++ b/packages/codingcode/src/context/context.ts @@ -17,7 +17,7 @@ import { LLMService } from '../llm/port.js'; import { contextWindowOf } from '../infra/models.js'; import { COMPACTION_SYSTEM_PROMPT } from './compaction-prompt.js'; -import { AgentError } from '../core/error.js'; +import { AgentError } from '../util/error.js'; import { ContextService } from './port.js'; import type { CompressResult } from './port.js'; import { EventSinkService } from '../sink/port.js'; diff --git a/packages/codingcode/src/context/port.ts b/packages/codingcode/src/context/port.ts index 13a4f8fe..e472cd8d 100644 --- a/packages/codingcode/src/context/port.ts +++ b/packages/codingcode/src/context/port.ts @@ -4,7 +4,7 @@ import type { Message } from '../llm/types.js'; import type { SessionRef } from '../session/types.js'; import type { SessionEvent } from '../session/types.js'; -import type { AgentError } from '../core/error.js'; +import type { AgentError } from '../util/error.js'; export interface CompressResult { didCompress: boolean; diff --git a/packages/codingcode/src/hooks/config.ts b/packages/codingcode/src/hooks/config.ts index 97822476..8120c6c7 100644 --- a/packages/codingcode/src/hooks/config.ts +++ b/packages/codingcode/src/hooks/config.ts @@ -6,7 +6,7 @@ import { writeNamedList, type NamedListFile, } from '../infra/yaml-store.js'; -import { getGlobalDir, CODINGCODE_DIRNAME } from '../core/path.js'; +import { getGlobalDir, CODINGCODE_DIRNAME } from '../util/path.js'; import type { UserHookConfig } from './types.js'; const HOOKS_FILE: NamedListFile = { fileName: 'hooks', key: 'hooks' }; diff --git a/packages/codingcode/src/infra/config.ts b/packages/codingcode/src/infra/config.ts index 7aa6c8ab..0bd05e4b 100644 --- a/packages/codingcode/src/infra/config.ts +++ b/packages/codingcode/src/infra/config.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import { getGlobalDir } from '../core/path.js'; +import { getGlobalDir } from '../util/path.js'; export interface ContextConfig { compactionModel: string; diff --git a/packages/codingcode/src/infra/logger.ts b/packages/codingcode/src/infra/logger.ts index 1bb73610..057dac88 100644 --- a/packages/codingcode/src/infra/logger.ts +++ b/packages/codingcode/src/infra/logger.ts @@ -1,7 +1,7 @@ import pino from 'pino'; import { join } from 'path'; import { mkdirSync } from 'fs'; -import { getGlobalDir } from '../core/path.js'; +import { getGlobalDir } from '../util/path.js'; export type Logger = pino.Logger; diff --git a/packages/codingcode/src/infra/models.ts b/packages/codingcode/src/infra/models.ts index 9750b171..a2337e5f 100644 --- a/packages/codingcode/src/infra/models.ts +++ b/packages/codingcode/src/infra/models.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; -import { AgentError } from '../core/error.js'; +import { AgentError } from '../util/error.js'; import { loadConfig, updateActiveModel } from './config.js'; /** 模型清单里一条可被会话选中的模型。 */ diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts index 3835a579..2e072057 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -65,7 +65,6 @@ const SubagentWithDeps = SubagentRunnerLayer.pipe(Layer.provide(AgentWithDeps)); // 运行注册表:要 runner 起子代理、要 mailbox 投递终态、要 sink 发 subagent_event 帧、 // 要 hooks 在子代理终态时触发 agent.subagent.complete。 -// 不依赖 SessionLayer —— 它不写盘,写盘由父回合循环在 drain 点做。 const SubagentRunRegistryWithDeps = SubagentRunRegistryLayer.pipe( Layer.provide(Layer.mergeAll(SubagentWithDeps, MailboxLayer, EventSinkLayer, HookLayer)) ); diff --git a/packages/codingcode/src/llm/errors.ts b/packages/codingcode/src/llm/errors.ts index 2dfdcd6f..853f0fff 100644 --- a/packages/codingcode/src/llm/errors.ts +++ b/packages/codingcode/src/llm/errors.ts @@ -1,4 +1,4 @@ -import { AgentError } from '../core/error.js'; +import { AgentError } from '../util/error.js'; export const LLMErrors = { timeout: (timeoutMs: number) => AgentError.llmTimeout(), diff --git a/packages/codingcode/src/llm/llm.ts b/packages/codingcode/src/llm/llm.ts index 22fd064b..c5c1142d 100644 --- a/packages/codingcode/src/llm/llm.ts +++ b/packages/codingcode/src/llm/llm.ts @@ -1,5 +1,5 @@ import { Effect, Layer } from 'effect'; -import { AgentError } from '../core/error.js'; +import { AgentError } from '../util/error.js'; import type { LLMClient, LLMRequest, LLMResponse, LLMStreamPart } from './types.js'; import { OpenAIProvider } from './providers/openai.js'; import { DeepSeekProvider } from './providers/deepseek.js'; diff --git a/packages/codingcode/src/llm/port.ts b/packages/codingcode/src/llm/port.ts index 032ab37a..18fb8d59 100644 --- a/packages/codingcode/src/llm/port.ts +++ b/packages/codingcode/src/llm/port.ts @@ -1,6 +1,6 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { AgentError } from '../core/error.js'; +import type { AgentError } from '../util/error.js'; import type { LLMRequest, LLMResponse, LLMStreamPart } from './types.js'; export interface LLMShape { diff --git a/packages/codingcode/src/llm/providers/deepseek.ts b/packages/codingcode/src/llm/providers/deepseek.ts index 607b47fc..4ed455db 100644 --- a/packages/codingcode/src/llm/providers/deepseek.ts +++ b/packages/codingcode/src/llm/providers/deepseek.ts @@ -1,7 +1,7 @@ import { generateText, streamText, stepCountIs } from 'ai'; import type { LanguageModelV3 } from '@ai-sdk/provider'; import { Effect } from 'effect'; -import type { AgentError } from '../../core/error.js'; +import type { AgentError } from '../../util/error.js'; import { mapLlmError } from '../errors.js'; import type { LLMClient, LLMRequest, LLMResponse, LLMStreamPart } from '../types.js'; import type { SelectableModel } from '../../infra/models.js'; diff --git a/packages/codingcode/src/llm/providers/openai.ts b/packages/codingcode/src/llm/providers/openai.ts index ed16c17d..b629fceb 100644 --- a/packages/codingcode/src/llm/providers/openai.ts +++ b/packages/codingcode/src/llm/providers/openai.ts @@ -1,7 +1,7 @@ import { generateText, streamText, stepCountIs } from 'ai'; import type { LanguageModelV3 } from '@ai-sdk/provider'; import { Effect } from 'effect'; -import type { AgentError } from '../../core/error.js'; +import type { AgentError } from '../../util/error.js'; import { mapLlmError } from '../errors.js'; import type { LLMClient, LLMRequest, LLMResponse, LLMStreamPart } from '../types.js'; import type { SelectableModel } from '../../infra/models.js'; diff --git a/packages/codingcode/src/llm/types.ts b/packages/codingcode/src/llm/types.ts index 2ec0d70d..963b466f 100644 --- a/packages/codingcode/src/llm/types.ts +++ b/packages/codingcode/src/llm/types.ts @@ -1,5 +1,5 @@ import type { Effect } from 'effect'; -import type { AgentError } from '../core/error.js'; +import type { AgentError } from '../util/error.js'; export interface TokenUsage { prompt: number; diff --git a/packages/codingcode/src/mcp/config.ts b/packages/codingcode/src/mcp/config.ts index d6420080..024f8a4e 100644 --- a/packages/codingcode/src/mcp/config.ts +++ b/packages/codingcode/src/mcp/config.ts @@ -6,7 +6,7 @@ import { writeNamedList, type NamedListFile, } from '../infra/yaml-store.js'; -import { getGlobalDir, CODINGCODE_DIRNAME } from '../core/path.js'; +import { getGlobalDir, CODINGCODE_DIRNAME } from '../util/path.js'; import type { McpServerConfig } from './types.js'; /** mcp 的落盘形状:`/.codingcode/mcp.yaml` 的 `servers:` */ diff --git a/packages/codingcode/src/mcp/mcp.ts b/packages/codingcode/src/mcp/mcp.ts index 85b5710c..a85d4080 100644 --- a/packages/codingcode/src/mcp/mcp.ts +++ b/packages/codingcode/src/mcp/mcp.ts @@ -4,7 +4,7 @@ import { McpClient } from './client.js'; import { McpService } from './port.js'; import type { McpServerConfig, McpToolSpec } from './types.js'; import { createLogger } from '../infra/logger.js'; -import { AgentError } from '../core/error.js'; +import { AgentError } from '../util/error.js'; const logger = createLogger(); diff --git a/packages/codingcode/src/mcp/types.ts b/packages/codingcode/src/mcp/types.ts index cf06188d..b591edd5 100644 --- a/packages/codingcode/src/mcp/types.ts +++ b/packages/codingcode/src/mcp/types.ts @@ -1,5 +1,5 @@ import type { Effect } from 'effect'; -import type { AgentError } from '../core/error.js'; +import type { AgentError } from '../util/error.js'; export interface McpServerConfig { name: string; diff --git a/packages/codingcode/src/memory/storage.ts b/packages/codingcode/src/memory/storage.ts index fece7c41..33f7b290 100644 --- a/packages/codingcode/src/memory/storage.ts +++ b/packages/codingcode/src/memory/storage.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { CODINGCODE_DIRNAME } from '../core/path.js'; +import { CODINGCODE_DIRNAME } from '../util/path.js'; export function resolveMemoryPath(cwd: string): string { return path.join(cwd, CODINGCODE_DIRNAME, 'memory.md'); diff --git a/packages/codingcode/src/rules/rules.ts b/packages/codingcode/src/rules/rules.ts index 54e2f6ad..6310f811 100644 --- a/packages/codingcode/src/rules/rules.ts +++ b/packages/codingcode/src/rules/rules.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { Layer, Effect } from 'effect'; import { RulesService } from './port.js'; -import { getGlobalDir } from '../core/path.js'; +import { getGlobalDir } from '../util/path.js'; // ── Paths ── diff --git a/packages/codingcode/src/scheduler/scheduler.ts b/packages/codingcode/src/scheduler/scheduler.ts index ccaeccc9..e30e6f5c 100644 --- a/packages/codingcode/src/scheduler/scheduler.ts +++ b/packages/codingcode/src/scheduler/scheduler.ts @@ -9,7 +9,6 @@ import type { } from './types.js'; import { readAutomations, writeAutomations } from './store.js'; import { AgentService } from '../agent/port.js'; -import { AgentError } from '../core/error.js'; import { activeModelId } from '../infra/models.js'; import { SchedulerService } from './port.js'; @@ -186,7 +185,6 @@ export const SchedulerLayer = Layer.effect( return yield* agent.runTurn(auto.description, { cwd: auto.projectCwd, signal: controller.signal, - // 自动化没有独立的模型配置,统一用 config.yaml 的活动模型 model: activeModelId(), activeProfile: 'build', permissionMode: 'bypass', diff --git a/packages/codingcode/src/scheduler/store.ts b/packages/codingcode/src/scheduler/store.ts index 5da1d64c..2e13df12 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 { dirname, join } from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import { getGlobalDir } from '../core/path.js'; +import { getGlobalDir } from '../util/path.js'; import type { Automation } from './types.js'; interface AutomationsFile { diff --git a/packages/codingcode/src/server/cwd.ts b/packages/codingcode/src/server/cwd.ts index 2da3c239..5b4c51fa 100644 --- a/packages/codingcode/src/server/cwd.ts +++ b/packages/codingcode/src/server/cwd.ts @@ -1,7 +1,7 @@ import { existsSync } from 'fs'; import { join, resolve } from 'path'; -import { AgentError } from '../core/error.js'; -import { getGlobalDir } from '../core/path.js'; +import { AgentError } from '../util/error.js'; +import { getGlobalDir } from '../util/path.js'; export function isGlobalCwd(raw: string | undefined): boolean { return !raw || raw === '' || raw === 'global'; diff --git a/packages/codingcode/src/server/handler.ts b/packages/codingcode/src/server/handler.ts index d31aa353..744a247f 100644 --- a/packages/codingcode/src/server/handler.ts +++ b/packages/codingcode/src/server/handler.ts @@ -2,7 +2,7 @@ import * as HttpRouter from '@effect/platform/HttpRouter'; import * as HttpServerRequest from '@effect/platform/HttpServerRequest'; import * as HttpServerResponse from '@effect/platform/HttpServerResponse'; import { Effect, Stream } from 'effect'; -import { AgentError } from '../core/error.js'; +import { AgentError } from '../util/error.js'; import { ApprovalWaitService } from '../approval/wait-port.js'; import type { FrameBody } from '../sink/types.js'; import { createFrameAssembler, encodeFrame } from './frame-io.js'; diff --git a/packages/codingcode/src/server/http-error.ts b/packages/codingcode/src/server/http-error.ts index a22dedfd..03d93de2 100644 --- a/packages/codingcode/src/server/http-error.ts +++ b/packages/codingcode/src/server/http-error.ts @@ -1,6 +1,6 @@ import * as HttpServerResponse from '@effect/platform/HttpServerResponse'; import { Match } from 'effect'; -import { AgentError, type ErrorCode } from '../core/error.js'; +import { AgentError, type ErrorCode } from '../util/error.js'; const DOMAIN_STATUS: Record = { LLM_TIMEOUT: 500, diff --git a/packages/codingcode/src/server/routes/agent.ts b/packages/codingcode/src/server/routes/agent.ts index fe203c09..c4a91e68 100644 --- a/packages/codingcode/src/server/routes/agent.ts +++ b/packages/codingcode/src/server/routes/agent.ts @@ -10,7 +10,7 @@ import { } from '../../infra/config.js'; import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; -import { AgentError } from '../../core/error.js'; +import { AgentError } from '../../util/error.js'; import { json, readJson, type Handler, type Router } from '../handler.js'; function readAgentConfig() { diff --git a/packages/codingcode/src/server/routes/automations.ts b/packages/codingcode/src/server/routes/automations.ts index 53323fd2..cce6f057 100644 --- a/packages/codingcode/src/server/routes/automations.ts +++ b/packages/codingcode/src/server/routes/automations.ts @@ -2,7 +2,7 @@ import * as HttpRouter from '@effect/platform/HttpRouter'; import { Effect } from 'effect'; import { SchedulerService } from '../../scheduler/port.js'; import { NotFoundError } from '../http-error.js'; -import { AgentError } from '../../core/error.js'; +import { AgentError } from '../../util/error.js'; import { json, pathParams, readJson, type Handler, type Router } from '../handler.js'; import type { AutomationSandbox, CreateAutomationInput } from '../../scheduler/types.js'; diff --git a/packages/codingcode/src/server/routes/hooks.ts b/packages/codingcode/src/server/routes/hooks.ts index 6e4120e3..ee5d800e 100644 --- a/packages/codingcode/src/server/routes/hooks.ts +++ b/packages/codingcode/src/server/routes/hooks.ts @@ -2,7 +2,7 @@ import * as HttpRouter from '@effect/platform/HttpRouter'; import { Effect } from 'effect'; import { isGlobalCwd, resolveCwd } from '../cwd.js'; import { AlreadyExistsError, NotFoundError, type AppError } from '../http-error.js'; -import { AgentError } from '../../core/error.js'; +import { AgentError } from '../../util/error.js'; import { json, pathParams, query, readJson, type Handler, type Router } from '../handler.js'; import type { HookPoint, UserHookConfig } from '../../hooks/types.js'; import { diff --git a/packages/codingcode/src/server/routes/mcp.ts b/packages/codingcode/src/server/routes/mcp.ts index 6ebca2e5..d29d13b0 100644 --- a/packages/codingcode/src/server/routes/mcp.ts +++ b/packages/codingcode/src/server/routes/mcp.ts @@ -2,7 +2,7 @@ import * as HttpRouter from '@effect/platform/HttpRouter'; import { Effect } from 'effect'; import { isGlobalCwd, resolveCwd } from '../cwd.js'; import { AlreadyExistsError, NotFoundError, type AppError } from '../http-error.js'; -import { AgentError } from '../../core/error.js'; +import { AgentError } from '../../util/error.js'; import { json, pathParams, query, readJson, type Handler, type Router } from '../handler.js'; import type { McpServerConfig } from '../../mcp/types.js'; import { diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index 9d8aa106..bf0b0373 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -5,7 +5,7 @@ import { resolveWorkspaceCwd } from '../cwd.js'; import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; import { loadConfig } from '../../infra/config.js'; -import { AgentError } from '../../core/error.js'; +import { AgentError } from '../../util/error.js'; import { frameStream, pathParams, @@ -44,7 +44,6 @@ const sendMessage: Handler = Effect.gen(function* () { skills: body.skills, }; if (isNew) { - // 新会话的交互/权限模式取自 config.yaml;会话一旦建立就以会话头为准 const cfg = loadConfig(); runOpts.activeProfile = isAgentProfileName(cfg.activeProfile) ? cfg.activeProfile : 'build'; runOpts.permissionMode = isPermissionMode(cfg.permissionMode) diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 8e7b861a..db0fda1e 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -14,11 +14,11 @@ import { estimatePromptTokensFrom } from '../../context/context.js'; import { CheckpointService } from '../../checkpoint/port.js'; import { activeModelId, setGlobalActive } from '../../infra/models.js'; import { resolveCwd, resolveWorkspaceCwd } from '../cwd.js'; -import { projectDataDir } from '../../core/path.js'; +import { projectDataDir } from '../../util/path.js'; import { AVAILABLE_PROFILES } from '../../session/types.js'; import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; -import { AgentError } from '../../core/error.js'; +import { AgentError } from '../../util/error.js'; import { json, pathParams, query, readJson, type Handler, type Router } from '../handler.js'; import type { AppError } from '../http-error.js'; diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index 65071ebf..4c44336c 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -14,7 +14,7 @@ import { renameSync, } from 'fs'; import { join, dirname } from 'path'; -import { getGlobalDir, projectRootDir } from '../core/path.js'; +import { getGlobalDir, projectRootDir } from '../util/path.js'; import { transcriptPathOf, SESSIONS_DIRNAME } from './paths.js'; import type { SessionEvent, SessionMetaEvent } from './types.js'; import type { SessionSummary } from './types.js'; diff --git a/packages/codingcode/src/session/paths.ts b/packages/codingcode/src/session/paths.ts index cf5ce9cf..c8838c5f 100644 --- a/packages/codingcode/src/session/paths.ts +++ b/packages/codingcode/src/session/paths.ts @@ -1,5 +1,5 @@ import { join } from 'path'; -import { normalizePath, encodeProjectPath, projectDataDir } from '../core/path.js'; +import { normalizePath, encodeProjectPath, projectDataDir } from '../util/path.js'; /** 会话转录在项目数据目录下的布局。 */ export const SESSIONS_DIRNAME = 'sessions'; diff --git a/packages/codingcode/src/session/port.ts b/packages/codingcode/src/session/port.ts index f65cb3b1..7f300d09 100644 --- a/packages/codingcode/src/session/port.ts +++ b/packages/codingcode/src/session/port.ts @@ -1,6 +1,6 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { AgentError } from '../core/error.js'; +import type { AgentError } from '../util/error.js'; import type { AssistantEvent, RollbackEvent, diff --git a/packages/codingcode/src/session/session.ts b/packages/codingcode/src/session/session.ts index 778995da..583d0f57 100644 --- a/packages/codingcode/src/session/session.ts +++ b/packages/codingcode/src/session/session.ts @@ -2,8 +2,8 @@ import { Effect, Layer } from 'effect'; import { randomUUID } from 'crypto'; import { existsSync } from 'fs'; import { join, dirname } from 'path'; -import { AgentError } from '../core/error.js'; -import { encodeProjectPath } from '../core/path.js'; +import { AgentError } from '../util/error.js'; +import { encodeProjectPath } from '../util/path.js'; import { computePaths } from './paths.js'; import type { SessionMetaEvent, diff --git a/packages/codingcode/src/session/types.ts b/packages/codingcode/src/session/types.ts index b08d55ca..4446dc09 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -13,12 +13,11 @@ export function isPlanProfile(name: string | null | undefined): boolean { export interface AvailableProfile { name: ProfileName; - description: string; } export const AVAILABLE_PROFILES: AvailableProfile[] = [ - { name: PLAN_PROFILE_NAME, description: 'Planning agent' }, - { name: BUILD_PROFILE_NAME, description: 'Build agent' }, + { name: PLAN_PROFILE_NAME }, + { name: BUILD_PROFILE_NAME }, ]; export const PERMISSION_MODES = ['askBeforeExec', 'bypass'] as const; diff --git a/packages/codingcode/src/skills/source.ts b/packages/codingcode/src/skills/source.ts index ac275fc7..7fdd0c71 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 { parse as parseYaml } from 'yaml'; -import { getGlobalDir, CODINGCODE_DIRNAME } from '../core/path.js'; +import { getGlobalDir, CODINGCODE_DIRNAME } from '../util/path.js'; interface SkillFrontMatter { name?: string; diff --git a/packages/codingcode/src/subagent/port.ts b/packages/codingcode/src/subagent/port.ts index 3e9ab957..65ac0c92 100644 --- a/packages/codingcode/src/subagent/port.ts +++ b/packages/codingcode/src/subagent/port.ts @@ -1,8 +1,8 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; import type { FrameBody } from '../sink/types.js'; -import type { AgentError } from '../core/error.js'; -import type { Result } from '../core/result.js'; +import type { AgentError } from '../util/error.js'; +import type { Result } from '../util/result.js'; export interface RunSubagentOptions { sessionId?: string; diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts index 8a7e5aa2..a19f6056 100644 --- a/packages/codingcode/src/subagent/registry.ts +++ b/packages/codingcode/src/subagent/registry.ts @@ -1,5 +1,5 @@ import { Context, Effect, Fiber, Layer, Option, Stream, SubscriptionRef } from 'effect'; -import { AgentError } from '../core/error.js'; +import { AgentError } from '../util/error.js'; import { isTurnEnd } from '../sink/types.js'; import type { EndTransition, FrameBody } from '../sink/types.js'; import type { ProfileName } from '../session/types.js'; diff --git a/packages/codingcode/src/subagent/subagent.ts b/packages/codingcode/src/subagent/subagent.ts index 4d238391..a33ceaf3 100644 --- a/packages/codingcode/src/subagent/subagent.ts +++ b/packages/codingcode/src/subagent/subagent.ts @@ -3,7 +3,7 @@ import { SubagentRunnerService } from './port.js'; import type { RunSubagentOptions } from './port.js'; import { AgentService } from '../agent/port.js'; import type { FrameBody } from '../sink/types.js'; -import type { Result } from '../core/result.js'; +import type { Result } from '../util/result.js'; export const SubagentRunnerLayer = Layer.effect( SubagentRunnerService, diff --git a/packages/codingcode/src/tools/catalog.ts b/packages/codingcode/src/tools/catalog.ts index 7e262a43..cd6ad682 100644 --- a/packages/codingcode/src/tools/catalog.ts +++ b/packages/codingcode/src/tools/catalog.ts @@ -3,7 +3,7 @@ import type { Effect } from 'effect'; import type { ToolDefinition, ToolExecCtx, ToolRunner } from './types.js'; import type { ToolDescription } from '../llm/types.js'; import type { McpToolSpec } from '../mcp/types.js'; -import type { AgentError } from '../core/error.js'; +import type { AgentError } from '../util/error.js'; import { ToolRegistry } from './registry.js'; import { readFileTool } from './domains/fs/read.js'; import { writeFileTool } from './domains/fs/write.js'; diff --git a/packages/codingcode/src/tools/domains/bash/exec.ts b/packages/codingcode/src/tools/domains/bash/exec.ts index eb627a14..f452b257 100644 --- a/packages/codingcode/src/tools/domains/bash/exec.ts +++ b/packages/codingcode/src/tools/domains/bash/exec.ts @@ -3,7 +3,7 @@ import { spawn } from 'child_process'; import { Effect } from 'effect'; import type { ToolDefinition } from '../../types.js'; -import { AgentError } from '../../../core/error.js'; +import { AgentError } from '../../../util/error.js'; export const bashTool: ToolDefinition = { name: 'execute_command', diff --git a/packages/codingcode/src/tools/domains/fs/edit.ts b/packages/codingcode/src/tools/domains/fs/edit.ts index 2455860d..3a6a45d5 100644 --- a/packages/codingcode/src/tools/domains/fs/edit.ts +++ b/packages/codingcode/src/tools/domains/fs/edit.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { readFile, writeFile } from 'fs/promises'; import { resolve } from 'path'; import { Effect } from 'effect'; -import { AgentError } from '../../../core/error.js'; +import { AgentError } from '../../../util/error.js'; import type { ToolDefinition } from '../../types.js'; export const editFileTool: ToolDefinition = { diff --git a/packages/codingcode/src/tools/domains/fs/glob.ts b/packages/codingcode/src/tools/domains/fs/glob.ts index 0e7cb1c5..6d7dc098 100644 --- a/packages/codingcode/src/tools/domains/fs/glob.ts +++ b/packages/codingcode/src/tools/domains/fs/glob.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { globby } from 'globby'; import { relative, resolve } from 'path'; import { Effect } from 'effect'; -import { AgentError } from '../../../core/error.js'; +import { AgentError } from '../../../util/error.js'; import type { ToolDefinition } from '../../types.js'; export const globTool: ToolDefinition = { diff --git a/packages/codingcode/src/tools/domains/fs/grep.ts b/packages/codingcode/src/tools/domains/fs/grep.ts index 70b8ca8b..1345d606 100644 --- a/packages/codingcode/src/tools/domains/fs/grep.ts +++ b/packages/codingcode/src/tools/domains/fs/grep.ts @@ -3,7 +3,7 @@ import { globby } from 'globby'; import { readFile } from 'fs/promises'; import { relative } from 'path'; import { Effect } from 'effect'; -import { AgentError } from '../../../core/error.js'; +import { AgentError } from '../../../util/error.js'; import type { ToolDefinition } from '../../types.js'; export const searchTool: ToolDefinition = { diff --git a/packages/codingcode/src/tools/domains/fs/read.ts b/packages/codingcode/src/tools/domains/fs/read.ts index f6e77f53..3fd8c0fe 100644 --- a/packages/codingcode/src/tools/domains/fs/read.ts +++ b/packages/codingcode/src/tools/domains/fs/read.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { readFile } from 'fs/promises'; import { resolve } from 'path'; import { Effect } from 'effect'; -import { AgentError } from '../../../core/error.js'; +import { AgentError } from '../../../util/error.js'; import type { ToolDefinition } from '../../types.js'; export const readFileTool: ToolDefinition = { diff --git a/packages/codingcode/src/tools/domains/fs/write.ts b/packages/codingcode/src/tools/domains/fs/write.ts index d1837c1d..d9c5c458 100644 --- a/packages/codingcode/src/tools/domains/fs/write.ts +++ b/packages/codingcode/src/tools/domains/fs/write.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { writeFile, mkdir } from 'fs/promises'; import { dirname, relative, resolve } from 'path'; import { Effect } from 'effect'; -import { AgentError } from '../../../core/error.js'; +import { AgentError } from '../../../util/error.js'; import type { ToolDefinition } from '../../types.js'; export const writeFileTool: ToolDefinition = { diff --git a/packages/codingcode/src/tools/domains/self/todo-write.ts b/packages/codingcode/src/tools/domains/self/todo-write.ts index c0bfc728..4d0659be 100644 --- a/packages/codingcode/src/tools/domains/self/todo-write.ts +++ b/packages/codingcode/src/tools/domains/self/todo-write.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { Effect } from 'effect'; -import { AgentError } from '../../../core/error.js'; +import { AgentError } from '../../../util/error.js'; import type { ToolDefinition } from '../../types.js'; import { TodoService, diff --git a/packages/codingcode/src/tools/domains/subagent/spawn.ts b/packages/codingcode/src/tools/domains/subagent/spawn.ts index 237c25a5..2b3bc723 100644 --- a/packages/codingcode/src/tools/domains/subagent/spawn.ts +++ b/packages/codingcode/src/tools/domains/subagent/spawn.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { Effect } from 'effect'; -import { AgentError } from '../../../core/error.js'; +import { AgentError } from '../../../util/error.js'; import { findModel } from '../../../infra/models.js'; import type { ToolDefinition } from '../../types.js'; import { HookService } from '../../../hooks/port.js'; diff --git a/packages/codingcode/src/tools/domains/subagent/submit-plan.ts b/packages/codingcode/src/tools/domains/subagent/submit-plan.ts index d9dfa927..66980660 100644 --- a/packages/codingcode/src/tools/domains/subagent/submit-plan.ts +++ b/packages/codingcode/src/tools/domains/subagent/submit-plan.ts @@ -2,9 +2,9 @@ import { z } from 'zod'; import { Effect } from 'effect'; import { join } from 'path'; import { writeFileSync, mkdirSync } from 'fs'; -import { AgentError } from '../../../core/error.js'; +import { AgentError } from '../../../util/error.js'; import type { ToolDefinition } from '../../types.js'; -import { projectDataDir } from '../../../core/path.js'; +import { projectDataDir } from '../../../util/path.js'; import { createLogger } from '../../../infra/logger.js'; const logger = createLogger(); diff --git a/packages/codingcode/src/tools/domains/web/fetch.ts b/packages/codingcode/src/tools/domains/web/fetch.ts index 280db1ae..62633f5a 100644 --- a/packages/codingcode/src/tools/domains/web/fetch.ts +++ b/packages/codingcode/src/tools/domains/web/fetch.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { Effect } from 'effect'; -import { AgentError } from '../../../core/error.js'; +import { AgentError } from '../../../util/error.js'; import type { ToolDefinition } from '../../types.js'; export const webFetchTool: ToolDefinition = { diff --git a/packages/codingcode/src/tools/domains/web/search.ts b/packages/codingcode/src/tools/domains/web/search.ts index ed3b2182..4665f66b 100644 --- a/packages/codingcode/src/tools/domains/web/search.ts +++ b/packages/codingcode/src/tools/domains/web/search.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { Effect } from 'effect'; -import { AgentError } from '../../../core/error.js'; +import { AgentError } from '../../../util/error.js'; import type { ToolDefinition } from '../../types.js'; interface SearchResult { diff --git a/packages/codingcode/src/tools/tools.ts b/packages/codingcode/src/tools/tools.ts index 8c64c1ed..9283c8c5 100644 --- a/packages/codingcode/src/tools/tools.ts +++ b/packages/codingcode/src/tools/tools.ts @@ -1,15 +1,26 @@ import { Layer, Effect } from 'effect'; -import { AgentError } from '../core/error.js'; +import { AgentError } from '../util/error.js'; import { HookService } from '../hooks/port.js'; import type { ToolCall } from '../llm/types.js'; -import type { ProfileName } from '../session/types.js'; import type { McpToolSpec } from '../mcp/types.js'; -import type { ToolCatalog, ToolExecOpts, ToolResult, ToolRunner } from './types.js'; +import type { + ToolCatalog, + ToolExecCall, + ToolExecCtx, + ToolExecOpts, + ToolResult, + ToolRunner, +} from './types.js'; import { ToolExecutorService } from './port.js'; import { createToolCatalog } from './catalog.js'; type Lookup = (name: string) => ToolRunner | undefined; +function toToolCtx(opts: ToolExecCall): ToolExecCtx { + const { turnId: _turnId, callId: _callId, ...ctx } = opts; + return ctx; +} + export const ToolExecutorLayer = Layer.effect( ToolExecutorService, Effect.gen(function* () { @@ -18,15 +29,7 @@ export const ToolExecutorLayer = Layer.effect( function execute( name: string, args: unknown, - opts: { - signal?: AbortSignal; - sessionId?: string; - turnId?: number; - projectPath?: string; - callId?: string; - activeProfile?: ProfileName; - model: string; - }, + opts: ToolExecCall, lookup: Lookup ): Effect.Effect<{ output: string }, AgentError> { return Effect.gen(function* () { @@ -49,13 +52,7 @@ export const ToolExecutorLayer = Layer.effect( const start = Date.now(); // Execute tool — now returns Effect directly - const ctx = { - signal: opts.signal, - sessionId: opts.sessionId, - projectPath: opts.projectPath, - activeProfile: opts.activeProfile, - model: opts.model, - }; + const ctx = toToolCtx(opts); let toolEffect = tool.execute(parsedArgs, ctx); diff --git a/packages/codingcode/src/tools/types.ts b/packages/codingcode/src/tools/types.ts index c6d27808..317e6500 100644 --- a/packages/codingcode/src/tools/types.ts +++ b/packages/codingcode/src/tools/types.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { Effect } from 'effect'; -import type { AgentError } from '../core/error.js'; +import type { AgentError } from '../util/error.js'; import type { ToolCall, ToolDescription } from '../llm/types.js'; import type { ProfileName } from '../session/types.js'; import type { ToolOutcome } from '../sink/types.js'; @@ -14,6 +14,7 @@ export interface ToolDefinition { execute: (args: unknown, ctx?: ToolExecCtx) => Effect.Effect; } + export interface ToolExecCtx { signal?: AbortSignal; sessionId?: string; @@ -31,13 +32,10 @@ export interface ToolRunner { execute(args: unknown, ctx?: ToolExecCtx): Effect.Effect; } -export interface ToolExecOpts { - turnId?: number; - projectPath?: string; - signal?: AbortSignal; - activeProfile?: ProfileName; - model: string; -} + +export type ToolExecOpts = Omit & { turnId?: number }; + +export type ToolExecCall = ToolExecOpts & { sessionId?: string; callId?: string }; export interface ToolCatalog { tools: ToolDescription[]; diff --git a/packages/codingcode/src/core/error.ts b/packages/codingcode/src/util/error.ts similarity index 100% rename from packages/codingcode/src/core/error.ts rename to packages/codingcode/src/util/error.ts diff --git a/packages/codingcode/src/core/path.ts b/packages/codingcode/src/util/path.ts similarity index 100% rename from packages/codingcode/src/core/path.ts rename to packages/codingcode/src/util/path.ts diff --git a/packages/codingcode/src/core/result.ts b/packages/codingcode/src/util/result.ts similarity index 100% rename from packages/codingcode/src/core/result.ts rename to packages/codingcode/src/util/result.ts diff --git a/packages/codingcode/test/agent/skill-mention-persist.test.ts b/packages/codingcode/test/agent/skill-mention-persist.test.ts index be1faa30..9b9914c1 100644 --- a/packages/codingcode/test/agent/skill-mention-persist.test.ts +++ b/packages/codingcode/test/agent/skill-mention-persist.test.ts @@ -7,7 +7,7 @@ import { SessionLayer } from '../../src/session/session.js'; import { SessionService } from '../../src/session/port.js'; import { SkillLayer } from '../../src/skills/skills.js'; import { SkillService } from '../../src/skills/port.js'; -import { normalizePath } from '../../src/core/path.js'; +import { normalizePath } from '../../src/util/path.js'; import { computePaths } from '../../src/session/paths.js'; import { readHistory } from '../../src/session/file-ops.js'; import type { UserEvent } from '../../src/session/types.js'; diff --git a/packages/codingcode/test/architecture/boundaries.test.ts b/packages/codingcode/test/architecture/boundaries.test.ts index eca504c5..ac045a6b 100644 --- a/packages/codingcode/test/architecture/boundaries.test.ts +++ b/packages/codingcode/test/architecture/boundaries.test.ts @@ -81,19 +81,19 @@ const relSdk = (abs: string) => norm(relative(SDK_SRC, abs)); /** 违规信息用:按所属包给出可读路径 */ const relPkg = (abs: string) => abs.startsWith(norm(SDK_SRC) + '/') ? `sdk/src/${relSdk(abs)}` : `codingcode/src/${relSrc(abs)}`; -const isCore = (abs: string) => abs.startsWith(norm(join(SRC, 'core')) + '/'); +const isUtil = (abs: string) => abs.startsWith(norm(join(SRC, 'util')) + '/'); /** 契约文件:每个特性目录的 port.ts(宽 Tag,agent 自持的装配端口也在 agent/port.ts) */ const CONTRACT_FILES = FILES.filter((f) => f.endsWith('/port.ts')); describe('R1 契约不得 import 实现', () => { - it('port.ts 跨模块引用只有 core/、同目录,或对方的 types.ts(type-only)', () => { + it('port.ts 跨模块引用只有 util/、同目录,或对方的 types.ts(type-only)', () => { const violations: string[] = []; for (const file of CONTRACT_FILES) { for (const { spec, typeOnly } of moduleRefsOf(file)) { const target = resolveSpec(file, spec); if (target === null) continue; // 第三方库 - if (dirname(target) === dirname(file) || isCore(target)) continue; + if (dirname(target) === dirname(file) || isUtil(target)) continue; if (!typeOnly) violations.push(`${relPkg(file)} → ${spec} (runtime import)`); else if (!target.endsWith('/types')) violations.push(`${relPkg(file)} → ${spec} (非 types.ts)`); } @@ -111,21 +111,21 @@ describe('R2 实现不得依赖消费者模块', () => { }); }); -describe('R3 core 零内部依赖', () => { - it('core/ 不引用 core/ 之外的任何 src 模块', () => { +describe('R3 util 零内部依赖', () => { + it('util/ 不引用 util/ 之外的任何 src 模块', () => { const violations: string[] = []; - for (const file of FILES.filter((f) => isCore(f))) { + for (const file of FILES.filter((f) => isUtil(f))) { for (const { spec } of moduleRefsOf(file)) { const target = resolveSpec(file, spec); if (target === null) continue; // 第三方 / 其他 workspace 包 - if (!isCore(target)) violations.push(`${relPkg(file)} → ${spec}`); + if (!isUtil(target)) violations.push(`${relPkg(file)} → ${spec}`); } } expect(violations).toEqual([]); }); }); -/** core/ 的准入:只放不指向任何功能模块的通用件 */ +/** util/ 的准入:只放不指向任何功能模块的通用件 */ const NODE_BUILTINS = new Set([ 'os', 'path', @@ -140,10 +140,10 @@ const NODE_BUILTINS = new Set([ 'process', ]); -describe('core/ 准入:只承载通用件', () => { - it('core/ 的 import 只能是 node 内置与同目录相对路径', () => { +describe('util/ 准入:只承载通用件', () => { + it('util/ 的 import 只能是 node 内置与同目录相对路径', () => { const violations: string[] = []; - for (const file of FILES.filter((f) => isCore(f))) { + for (const file of FILES.filter((f) => isUtil(f))) { for (const { spec } of moduleRefsOf(file)) { if (spec.startsWith('.')) { const target = resolveSpec(file, spec)!; diff --git a/packages/codingcode/test/checkpoint/turn-title-removal.test.ts b/packages/codingcode/test/checkpoint/turn-title-removal.test.ts index 1b61e60d..11276ffc 100644 --- a/packages/codingcode/test/checkpoint/turn-title-removal.test.ts +++ b/packages/codingcode/test/checkpoint/turn-title-removal.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'os'; import { randomUUID, createHash } from 'crypto'; import { CheckpointService } from '../../src/checkpoint/port.js'; import { ShadowGit } from '../../src/checkpoint/shadow-git.js'; -import { normalizePath } from '../../src/core/path.js'; +import { normalizePath } from '../../src/util/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; import { CheckpointLayer } from '../../src/checkpoint/checkpoint.js'; diff --git a/packages/codingcode/test/helpers/project-base.ts b/packages/codingcode/test/helpers/project-base.ts index 5a0b4a68..13ec868f 100644 --- a/packages/codingcode/test/helpers/project-base.ts +++ b/packages/codingcode/test/helpers/project-base.ts @@ -1,6 +1,6 @@ import { mkdirSync } from 'fs'; import { beforeEach } from 'vitest'; -import { projectRootDir } from '../../src/core/path.js'; +import { projectRootDir } from '../../src/util/path.js'; import { useTempHome } from './temp-home.js'; export interface TempProjectBase { diff --git a/packages/codingcode/test/helpers/temp-home.ts b/packages/codingcode/test/helpers/temp-home.ts index 0c142f4b..59e04f76 100644 --- a/packages/codingcode/test/helpers/temp-home.ts +++ b/packages/codingcode/test/helpers/temp-home.ts @@ -16,7 +16,7 @@ export interface TempHome { * 全局配置目录 = `join(home, '.codingcode')`;未必已存在,写配置的代码会自行创建。 * * 这里刻意写字面量而不引用源码常量:本助手模拟的是「OS 提供的 home 布局」, - * 常量若被改错,正需要在这一侧显性同步才能暴露出来(另有 `test/core/path.test.ts` 钉住字面量)。 + * 常量若被改错,正需要在这一侧显性同步才能暴露出来(另有 `test/util/path.test.ts` 钉住字面量)。 */ readonly configDir: string; } diff --git a/packages/codingcode/test/llm/error-mapping.test.ts b/packages/codingcode/test/llm/error-mapping.test.ts index 7c195cad..02595426 100644 --- a/packages/codingcode/test/llm/error-mapping.test.ts +++ b/packages/codingcode/test/llm/error-mapping.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { mapLlmError } from '../../src/llm/errors.js'; -import { AgentError } from '../../src/core/error.js'; +import { AgentError } from '../../src/util/error.js'; describe('mapLlmError', () => { it('should map 413 status to CONTEXT_OVERFLOW', () => { diff --git a/packages/codingcode/test/memory/extractor.test.ts b/packages/codingcode/test/memory/extractor.test.ts index 8c19d909..3ebbc2c7 100644 --- a/packages/codingcode/test/memory/extractor.test.ts +++ b/packages/codingcode/test/memory/extractor.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; import { extractMemory } from '../../src/memory/extractor.js'; -import { AgentError } from '../../src/core/error.js'; +import { AgentError } from '../../src/util/error.js'; import type { LLMShape } from '../../src/llm/port.js'; import type { LLMRequest } from '../../src/llm/types.js'; diff --git a/packages/codingcode/test/memory/index.test.ts b/packages/codingcode/test/memory/index.test.ts index d3705344..bbce3a8c 100644 --- a/packages/codingcode/test/memory/index.test.ts +++ b/packages/codingcode/test/memory/index.test.ts @@ -6,7 +6,7 @@ import * as os from 'node:os'; import { MemoryService, type MemoryShape } from '../../src/memory/port.js'; import { LLMService } from '../../src/llm/port.js'; import { MemoryLayer } from '../../src/memory/memory.js'; -import { AgentError } from '../../src/core/error.js'; +import { AgentError } from '../../src/util/error.js'; const tmpDir = path.join(os.tmpdir(), 'memory-index-test'); const memFile = path.join(tmpDir, '.codingcode', 'memory.md'); diff --git a/packages/codingcode/test/server/cwd.test.ts b/packages/codingcode/test/server/cwd.test.ts index 36323a0c..5d018bed 100644 --- a/packages/codingcode/test/server/cwd.test.ts +++ b/packages/codingcode/test/server/cwd.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'fs'; import { join, resolve } from 'path'; import { tmpdir } from 'os'; import { isGlobalCwd, tempCwd, resolveCwd, resolveWorkspaceCwd } from '../../src/server/cwd.js'; -import { AgentError } from '../../src/core/error.js'; +import { AgentError } from '../../src/util/error.js'; import { statusOf } from '../../src/server/http-error.js'; describe('server/cwd:请求入口的工作区解析', () => { diff --git a/packages/codingcode/test/server/http-error.test.ts b/packages/codingcode/test/server/http-error.test.ts index 25991c51..26fe52c8 100644 --- a/packages/codingcode/test/server/http-error.test.ts +++ b/packages/codingcode/test/server/http-error.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { AgentError, type ErrorCode } from '../../src/core/error.js'; +import { AgentError, type ErrorCode } from '../../src/util/error.js'; import { AlreadyExistsError, InvalidInputError, diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts index 82e7756c..57703114 100644 --- a/packages/codingcode/test/server/plan-file-route.test.ts +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -17,7 +17,7 @@ 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 } from '../../src/core/path.js'; +import { encodeProjectPath } from '../../src/util/path.js'; import { useTempHome } from '../helpers/temp-home.js'; import { projectBaseDir } from '../helpers/project-base.js'; import { HookLayer } from '../../src/hooks/hooks.js'; diff --git a/packages/codingcode/test/server/sse.test.ts b/packages/codingcode/test/server/sse.test.ts index 64f9330a..27cbd6cb 100644 --- a/packages/codingcode/test/server/sse.test.ts +++ b/packages/codingcode/test/server/sse.test.ts @@ -12,7 +12,7 @@ import { join } from 'path'; import { createServer, type ServerApp } from '../../src/server/index.js'; import { AgentService } from '../../src/agent/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; -import { AgentError } from '../../src/core/error.js'; +import { AgentError } from '../../src/util/error.js'; import type { FrameBody } from '../../src/sink/types.js'; const TEXT_FRAME: FrameBody = { family: 'event', event: { type: 'text_delta', text: 'hello' } }; diff --git a/packages/codingcode/test/session/compute-paths.test.ts b/packages/codingcode/test/session/compute-paths.test.ts index 5bde6490..0e4f1fd8 100644 --- a/packages/codingcode/test/session/compute-paths.test.ts +++ b/packages/codingcode/test/session/compute-paths.test.ts @@ -7,7 +7,7 @@ import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { computePaths, projectSessionsDir } from '../../src/session/paths.js'; import { sessionJsonlPathFromCwd } from '../../src/session/file-ops.js'; -import { normalizePath, encodeProjectPath } from '../../src/core/path.js'; +import { normalizePath, encodeProjectPath } from '../../src/util/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); diff --git a/packages/codingcode/test/session/io-error.test.ts b/packages/codingcode/test/session/io-error.test.ts index 4f02f19a..89761653 100644 --- a/packages/codingcode/test/session/io-error.test.ts +++ b/packages/codingcode/test/session/io-error.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { AgentError } from '../../src/core/error.js'; +import { AgentError } from '../../src/util/error.js'; import * as fs from 'fs'; vi.mock('fs', async (importOriginal) => ({ diff --git a/packages/codingcode/test/session/load-create.test.ts b/packages/codingcode/test/session/load-create.test.ts index edd03c2f..726cdfaa 100644 --- a/packages/codingcode/test/session/load-create.test.ts +++ b/packages/codingcode/test/session/load-create.test.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { encodeProjectPath } from '../../src/core/path.js'; +import { encodeProjectPath } from '../../src/util/path.js'; import { computePaths } from '../../src/session/paths.js'; import { readSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index 3ff262ab..398159a1 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -9,7 +9,7 @@ import { SessionLayer } from '../../src/session/session.js'; import { estimatePromptTokensFrom } from '../../src/context/context.js'; import { readHistory, readLastUsage, readSessionMeta } from '../../src/session/file-ops.js'; import { estimateTokensForContent } from '../../src/context/tokens.js'; -import { encodeProjectPath } from '../../src/core/path.js'; +import { encodeProjectPath } from '../../src/util/path.js'; import { computePaths } from '../../src/session/paths.js'; import type { SessionStoreState } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; diff --git a/packages/codingcode/test/session/record-user-title.test.ts b/packages/codingcode/test/session/record-user-title.test.ts index dad2a9a3..89ae8d51 100644 --- a/packages/codingcode/test/session/record-user-title.test.ts +++ b/packages/codingcode/test/session/record-user-title.test.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { encodeProjectPath } from '../../src/core/path.js'; +import { encodeProjectPath } from '../../src/util/path.js'; import { computePaths } from '../../src/session/paths.js'; import { readSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; diff --git a/packages/codingcode/test/subagent/runner-wiring.test.ts b/packages/codingcode/test/subagent/runner-wiring.test.ts index 4ba87772..9e7a75ff 100644 --- a/packages/codingcode/test/subagent/runner-wiring.test.ts +++ b/packages/codingcode/test/subagent/runner-wiring.test.ts @@ -14,7 +14,7 @@ import { ContextService } from '../../src/context/port.js'; import { EventSinkLayer } from '../../src/sink/sink.js'; import { MailboxLayer } from '../../src/session/mailbox.js'; import { LLMService } from '../../src/llm/port.js'; -import { AgentError } from '../../src/core/error.js'; +import { AgentError } from '../../src/util/error.js'; import { MemoryService } from '../../src/memory/port.js'; import { RulesService } from '../../src/rules/port.js'; import { SkillService } from '../../src/skills/port.js'; @@ -25,7 +25,7 @@ import { HookService } from '../../src/hooks/port.js'; import { McpService } from '../../src/mcp/port.js'; import { TodoService } from '../../src/todo/port.js'; import { readHistory } from '../../src/session/file-ops.js'; -import { encodeProjectPath, normalizePath } from '../../src/core/path.js'; +import { encodeProjectPath, normalizePath } from '../../src/util/path.js'; import { computePaths } from '../../src/session/paths.js'; import { transcriptPathFor } from '../../src/context/context.js'; import { projectBaseDir } from '../helpers/project-base.js'; diff --git a/packages/codingcode/test/tools/domains/bash/exec-error.test.ts b/packages/codingcode/test/tools/domains/bash/exec-error.test.ts index 357f83be..2558e6d3 100644 --- a/packages/codingcode/test/tools/domains/bash/exec-error.test.ts +++ b/packages/codingcode/test/tools/domains/bash/exec-error.test.ts @@ -15,7 +15,7 @@ vi.mock('child_process', () => ({ })); const { bashTool } = await import('../../../../src/tools/domains/bash/exec.js'); -const { AgentError } = await import('../../../../src/core/error.js'); +const { AgentError } = await import('../../../../src/util/error.js'); describe('tools/domains/bash exec error', () => { it('fails with AgentError when spawn emits error', async () => { diff --git a/packages/codingcode/test/tools/executor-ctx-projection.test.ts b/packages/codingcode/test/tools/executor-ctx-projection.test.ts new file mode 100644 index 00000000..34676fe2 --- /dev/null +++ b/packages/codingcode/test/tools/executor-ctx-projection.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { z } from 'zod'; +import { Effect, Layer } from 'effect'; +import { ToolExecutorLayer } from '../../src/tools/tools.js'; +import { ToolExecutorService } from '../../src/tools/port.js'; +import { HookService } from '../../src/hooks/port.js'; +import { TOOLS_BY_NAME } from '../../src/tools/catalog.js'; +import type { ToolDefinition, ToolExecCtx, ToolExecOpts } from '../../src/tools/types.js'; + +/** + * 探针工具:临时挂进内置表,从真实执行路径(executeBatch → execute → 投影) + * 里把「工具实际收到的那份 ctx」抓出来。 + * + * 投影是 ToolExecOpts/ToolExecCall → ToolExecCtx 的收窄口子,这里钉住它的 + * 两条契约:可见字段一个不少,编排私有字段一个不漏。 + */ +const PROBE = '__ctx_probe'; + +let captured: ToolExecCtx | undefined; + +const probeTool: ToolDefinition = { + name: PROBE, + description: '探针:记录收到的 ctx', + parameters: z.object({}), + execute: (_args, ctx) => { + captured = ctx; + return Effect.succeed('ok'); + }, +}; + +function hooksStub() { + return { + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + }; +} + +function runProbe(opts: ToolExecOpts, sessionId: string | undefined): Promise { + const program = Effect.gen(function* () { + const exec = yield* ToolExecutorService; + const catalog = yield* exec.prepare([PROBE]); + yield* catalog.executeBatch([{ id: 'c1', name: PROBE, arguments: {} }], sessionId, opts); + }); + // prepare 的 catalog 只依赖 HookService,其余服务与探针无关 + const live = ToolExecutorLayer.pipe(Layer.provide(Layer.succeed(HookService, hooksStub() as any))); + return Effect.runPromise(Effect.asVoid(program).pipe(Effect.provide(live))); +} + +describe('工具执行上下文投影', () => { + beforeEach(() => { + captured = undefined; + TOOLS_BY_NAME.set(PROBE, probeTool); + }); + + afterEach(() => { + TOOLS_BY_NAME.delete(PROBE); + }); + + it('可见字段一个不少:sessionId 由位置参数并入,其余从 opts 流过', async () => { + const signal = new AbortController().signal; + await runProbe({ turnId: 7, projectPath: '/proj', signal, activeProfile: 'plan', model: 'm-1' }, 'sess-1'); + + expect(captured).toBeDefined(); + // 精确断言键集合:既防漏抄,也防多塞 + expect(Object.keys(captured!).sort()).toEqual([ + 'activeProfile', + 'model', + 'projectPath', + 'sessionId', + 'signal', + ]); + expect(captured!.sessionId).toBe('sess-1'); + expect(captured!.projectPath).toBe('/proj'); + expect(captured!.activeProfile).toBe('plan'); + expect(captured!.model).toBe('m-1'); + expect(captured!.signal).toBe(signal); + }); + + it('编排私有字段一个不漏:turnId / callId 到不了工具', async () => { + await runProbe({ turnId: 7, projectPath: '/proj', model: 'm-1' }, 'sess-1'); + + expect(captured).toBeDefined(); + expect('turnId' in captured!).toBe(false); + expect('callId' in captured!).toBe(false); + }); + + it('sessionId 缺省时不伪造该字段', async () => { + await runProbe({ projectPath: '/proj', model: 'm-1' }, undefined); + + // 键仍在(位置参数是显式传的),值为 undefined —— 与旧的手写投影行为一致 + expect(captured!.sessionId).toBeUndefined(); + expect(captured!.model).toBe('m-1'); + }); +}); diff --git a/packages/codingcode/test/types/tool-exec-shapes.test.ts b/packages/codingcode/test/types/tool-exec-shapes.test.ts new file mode 100644 index 00000000..ca73f66e --- /dev/null +++ b/packages/codingcode/test/types/tool-exec-shapes.test.ts @@ -0,0 +1,51 @@ +import { describe, it } from 'vitest'; +import type { ToolExecCall, ToolExecCtx, ToolExecOpts } from '../../src/tools/types.js'; + +// 本文件只做编译期类型约束(tsc 执行 @ts-expect-error 校验),不含运行时断言。 + +/** ToolExecCtx 的键若有一个没进 ToolExecCall,投影就会漏字段 */ +type UnprojectedCtxKey = Exclude; +type CtxFullyProjected = [UnprojectedCtxKey] extends [never] ? true : false; + +describe('工具执行参数的形状收口', () => { + it('ToolExecOpts 由 ToolExecCtx 派生:换掉 sessionId、加上 turnId', () => { + const opts: ToolExecOpts = { + turnId: 3, + projectPath: '/p', + signal: new AbortController().signal, + activeProfile: 'plan', + model: 'm', + }; + void opts; + + // 派生关系:ToolExecCtx 改字段而 ToolExecOpts 没跟上时,这里编译不过 + const asCtx: Omit = opts; + void asCtx; + }); + + it('ToolExecCtx 的每个字段都进了投影链(ToolExecCall)', () => { + // 若哪天把 ToolExecOpts/ToolExecCall 改回手写接口并漏了字段,这里会变成 false + const fullyProjected: CtxFullyProjected = true; + void fullyProjected; + }); + + it('sessionId 不进 ToolExecOpts —— 它是 executeBatch 的位置参数', () => { + // @ts-expect-error sessionId 由 executeBatch 位置参数传,不放进 opts + const bad: ToolExecOpts = { model: 'm', sessionId: 's-1' }; + void bad; + }); + + it('编排私有字段不外泄到工具可见的 ctx', () => { + // @ts-expect-error turnId 只进 hook payload,不属于 ToolExecCtx + const badTurn: ToolExecCtx = { model: 'm', turnId: 1 }; + void badTurn; + // @ts-expect-error callId 只进 hook payload,不属于 ToolExecCtx + const badCall: ToolExecCtx = { model: 'm', callId: 'c-1' }; + void badCall; + }); + + it('ToolExecCall = ToolExecOpts + 批次级 sessionId / callId', () => { + const call: ToolExecCall = { model: 'm', turnId: 1, sessionId: 's', callId: 'c' }; + void call; + }); +}); diff --git a/packages/codingcode/test/core/error.test.ts b/packages/codingcode/test/util/error.test.ts similarity index 89% rename from packages/codingcode/test/core/error.test.ts rename to packages/codingcode/test/util/error.test.ts index 653003fe..ea518640 100644 --- a/packages/codingcode/test/core/error.test.ts +++ b/packages/codingcode/test/util/error.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { AgentError } from '../../src/core/error.js'; +import { AgentError } from '../../src/util/error.js'; -// core 只负责「领域错误码 + 消息」,HTTP 状态码的映射在 server 层(见 test/server/http-error.test.ts) +// util 只负责「领域错误码 + 消息」,HTTP 状态码的映射在 server 层(见 test/server/http-error.test.ts) describe('AgentError', () => { it('带上领域码与可判别 tag', () => { const err = AgentError.configMissing('missing'); diff --git a/packages/codingcode/test/core/frame-io.test.ts b/packages/codingcode/test/util/frame-io.test.ts similarity index 100% rename from packages/codingcode/test/core/frame-io.test.ts rename to packages/codingcode/test/util/frame-io.test.ts diff --git a/packages/codingcode/test/core/frame-protocol.test.ts b/packages/codingcode/test/util/frame-protocol.test.ts similarity index 100% rename from packages/codingcode/test/core/frame-protocol.test.ts rename to packages/codingcode/test/util/frame-protocol.test.ts diff --git a/packages/codingcode/test/core/path.test.ts b/packages/codingcode/test/util/path.test.ts similarity index 93% rename from packages/codingcode/test/core/path.test.ts rename to packages/codingcode/test/util/path.test.ts index d0454d59..ff8a01fd 100644 --- a/packages/codingcode/test/core/path.test.ts +++ b/packages/codingcode/test/util/path.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest'; import { join } from 'path'; import { homedir, tmpdir } from 'os'; -import { normalizePath, encodeProjectPath, getGlobalDir } from '../../src/core/path.js'; +import { normalizePath, encodeProjectPath, getGlobalDir } from '../../src/util/path.js'; import { ShadowGit } from '../../src/checkpoint/shadow-git.js'; import { setFakeHome, restoreHome } from '../helpers/temp-home.js'; -describe('core/path:纯格式化函数', () => { +describe('util/path:纯格式化函数', () => { it('normalizePath unifies Windows path variants', () => { expect(normalizePath('C:\\Users\\proj')).toBe('c:/Users/proj'); expect(normalizePath('/c/Users/proj')).toBe('c:/Users/proj'); @@ -36,7 +36,7 @@ describe('core/path:纯格式化函数', () => { }); }); -describe('core/path:全局目录', () => { +describe('util/path:全局目录', () => { it('全局目录落在用户目录下', () => { expect(getGlobalDir()).toBe(join(homedir(), '.codingcode')); }); diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index 07864429..910e1931 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -561,7 +561,7 @@ export type SessionProfileSnapshot = { activeProfile: ProfileName; permissionMode: PermissionMode; cwd: string; - available: Array<{ name: string; description: string }>; + available: Array<{ name: string }>; }; export type PlanFileSnapshot = { diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index d6987b9d..511a60d6 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -117,7 +117,7 @@ export type SessionProfileInfo = { activeProfile: ProfileName; permissionMode: PermissionMode; cwd: string; - available: Array<{ name: string; description: string }>; + available: Array<{ name: string }>; }; export function getSessionProfile(sessionId: string, cwd: string): Promise { diff --git a/packages/desktop/test/profile-indicator.test.tsx b/packages/desktop/test/profile-indicator.test.tsx index 7c60cf6b..43e6b200 100644 --- a/packages/desktop/test/profile-indicator.test.tsx +++ b/packages/desktop/test/profile-indicator.test.tsx @@ -30,10 +30,7 @@ const baseProfile = { activeProfile: 'build' as const, permissionMode: 'askBeforeExec' as const, cwd: '/tmp', - available: [ - { name: 'plan', description: 'plan agent' }, - { name: 'build', description: 'build agent' }, - ], + available: [{ name: 'plan' }, { name: 'build' }], }; describe('ProfileIndicator (with live session)', () => { diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index dc9b4c36..c0cd54be 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -4,7 +4,6 @@ export type PermissionMode = 'askBeforeExec' | 'bypass'; export interface AvailableProfile { name: ProfileName; - description: string; } export type AvailableProfiles = AvailableProfile[]; From e14d4ff943ee09e1bef8add0ba49555f06708b15 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 8 Oct 2026 19:41:43 +0800 Subject: [PATCH 5/7] =?UTF-8?q?permissionmode=E5=92=8Cprofilename=E6=94=B9?= =?UTF-8?q?=E6=88=90=E5=B8=B8=E9=87=8F=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/codingcode/src/agent/agent.ts | 2 +- packages/codingcode/src/agent/port.ts | 3 +-- packages/codingcode/src/agent/profile.ts | 2 +- packages/codingcode/src/approval/approval.ts | 17 ++++++++----- packages/codingcode/src/approval/port.ts | 3 +-- .../codingcode/src/approval/tool-policy.ts | 2 +- packages/codingcode/src/approval/types.ts | 2 +- packages/codingcode/src/infra/config.ts | 5 ++-- .../codingcode/src/scheduler/scheduler.ts | 9 ++++--- .../codingcode/src/server/routes/agent.ts | 7 ++++-- .../codingcode/src/server/routes/messages.ts | 7 ++++-- .../codingcode/src/server/routes/sessions.ts | 19 +++++++++----- packages/codingcode/src/session/port.ts | 3 +-- packages/codingcode/src/session/session.ts | 3 +-- packages/codingcode/src/session/types.ts | 25 +------------------ packages/codingcode/src/subagent/port.ts | 5 ++-- packages/codingcode/src/subagent/registry.ts | 4 +-- packages/codingcode/src/subagent/subagent.ts | 3 ++- packages/codingcode/src/tools/types.ts | 2 +- packages/codingcode/src/util/enums.ts | 17 +++++++++++++ .../test/architecture/boundaries.test.ts | 4 +-- .../test/plan/gate-pipeline.test.ts | 2 +- .../test/plan/is-plan-profile.test.ts | 2 +- .../security/plan-profile-restart.test.ts | 2 +- packages/desktop/src/hooks/useAgent.ts | 1 - packages/desktop/src/lib/core-api.ts | 1 - .../desktop/test/profile-indicator.test.tsx | 1 - packages/sdk/src/contracts.ts | 5 +--- packages/sdk/src/index.ts | 2 -- packages/sdk/src/types.ts | 6 ----- 30 files changed, 82 insertions(+), 84 deletions(-) create mode 100644 packages/codingcode/src/util/enums.ts diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 926be58c..4e330c17 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -27,7 +27,7 @@ import type { } from '../sink/types.js'; import { isTurnEnd } from '../sink/types.js'; import type { SessionRef } from '../session/types.js'; -import type { PermissionMode } from '../session/types.js'; +import type { PermissionMode } from '../util/enums.js'; import type { ToolCatalog, ToolResult } from '../tools/types.js'; import type { ToolCall } from '../llm/types.js'; import { loadConfig } from '../infra/config.js'; diff --git a/packages/codingcode/src/agent/port.ts b/packages/codingcode/src/agent/port.ts index 215f2019..e12247f4 100644 --- a/packages/codingcode/src/agent/port.ts +++ b/packages/codingcode/src/agent/port.ts @@ -1,8 +1,7 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; import type { FrameBody } from '../sink/types.js'; -import type { ProfileName } from '../session/types.js'; -import type { PermissionMode } from '../session/types.js'; +import type { ProfileName, PermissionMode } from '../util/enums.js'; import type { AgentError } from '../util/error.js'; diff --git a/packages/codingcode/src/agent/profile.ts b/packages/codingcode/src/agent/profile.ts index 1064c171..88cdd3ae 100644 --- a/packages/codingcode/src/agent/profile.ts +++ b/packages/codingcode/src/agent/profile.ts @@ -1,4 +1,4 @@ -import { PLAN_PROFILE_NAME, BUILD_PROFILE_NAME, type ProfileName } from '../session/types.js'; +import { PLAN_PROFILE_NAME, BUILD_PROFILE_NAME, type ProfileName } from '../util/enums.js'; export interface AgentProfile { name: ProfileName; diff --git a/packages/codingcode/src/approval/approval.ts b/packages/codingcode/src/approval/approval.ts index fa3006d5..6ac8133b 100644 --- a/packages/codingcode/src/approval/approval.ts +++ b/packages/codingcode/src/approval/approval.ts @@ -1,9 +1,14 @@ import { Layer, Effect } from 'effect'; import { HookService } from '../hooks/port.js'; import type { HookShape } from '../hooks/port.js'; -import type { PermissionMode } from '../session/types.js'; +import { + ASK_BEFORE_EXEC_PERMISSION_MODE, + BYPASS_PERMISSION_MODE, + PLAN_PROFILE_NAME, + type PermissionMode, + type ProfileName, +} from '../util/enums.js'; import type { PermissionRule, ToolCallRequest } from './types.js'; -import type { ProfileName } from '../session/types.js'; import { PLAN_ALLOWED_TOOLS } from './tool-policy.js'; import { createRuleEngine, type RuleEngine } from './rule-engine.js'; import { userConfirmAsync } from './confirmation.js'; @@ -40,7 +45,7 @@ function applyPermissionMode( profile: ProfileName | undefined, destructiveTools: Set ): ApprovalDecision | null { - if (profile === 'plan') { + if (profile === PLAN_PROFILE_NAME) { if (PLAN_ALLOWED_TOOLS.has(tool)) { return { type: 'allow', source: 'permission-mode' }; } @@ -52,10 +57,10 @@ function applyPermissionMode( } switch (mode) { - case 'bypass': + case BYPASS_PERMISSION_MODE: return { type: 'allow', source: 'permission-mode' }; - case 'askBeforeExec': + case ASK_BEFORE_EXEC_PERMISSION_MODE: if (!destructiveTools.has(tool)) { return { type: 'allow', source: 'permission-mode' }; } @@ -231,7 +236,7 @@ export const ApprovalLayer = Layer.effect( { ruleEngine, destructiveTools, - permissionMode: request.permissionMode ?? 'askBeforeExec', + permissionMode: request.permissionMode ?? ASK_BEFORE_EXEC_PERMISSION_MODE, profile: request.profile, onAlways: (rule) => ruleEngine.addRule(rule), onNever: (rule) => ruleEngine.addRule(rule), diff --git a/packages/codingcode/src/approval/port.ts b/packages/codingcode/src/approval/port.ts index 21a7f67e..0fd2620f 100644 --- a/packages/codingcode/src/approval/port.ts +++ b/packages/codingcode/src/approval/port.ts @@ -1,7 +1,6 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { ProfileName } from '../session/types.js'; -import type { PermissionMode } from '../session/types.js'; +import type { ProfileName, PermissionMode } from '../util/enums.js'; import type { ToolCallRequest } from './types.js'; export * from './tool-policy.js'; diff --git a/packages/codingcode/src/approval/tool-policy.ts b/packages/codingcode/src/approval/tool-policy.ts index b6a7f1c8..144873bb 100644 --- a/packages/codingcode/src/approval/tool-policy.ts +++ b/packages/codingcode/src/approval/tool-policy.ts @@ -1,4 +1,4 @@ -import { isPlanProfile, type ProfileName } from '../session/types.js'; +import { isPlanProfile, type ProfileName } from '../util/enums.js'; export const PLAN_TOOL_NAMES: readonly string[] = [ 'read_file', diff --git a/packages/codingcode/src/approval/types.ts b/packages/codingcode/src/approval/types.ts index a071fcba..9687a3fc 100644 --- a/packages/codingcode/src/approval/types.ts +++ b/packages/codingcode/src/approval/types.ts @@ -1,4 +1,4 @@ -import { PERMISSION_MODES, type PermissionMode } from '../session/types.js'; +import { PERMISSION_MODES, type PermissionMode } from '../util/enums.js'; export function isPermissionMode(value: unknown): value is PermissionMode { return typeof value === 'string' && (PERMISSION_MODES as readonly string[]).includes(value); diff --git a/packages/codingcode/src/infra/config.ts b/packages/codingcode/src/infra/config.ts index 0bd05e4b..50672cae 100644 --- a/packages/codingcode/src/infra/config.ts +++ b/packages/codingcode/src/infra/config.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { getGlobalDir } from '../util/path.js'; +import { ASK_BEFORE_EXEC_PERMISSION_MODE, BUILD_PROFILE_NAME } from '../util/enums.js'; export interface ContextConfig { compactionModel: string; @@ -50,8 +51,8 @@ export const DEFAULT_SUBAGENT: SubagentConfig = { export const DEFAULT_CONFIG: AppConfig = { maxSteps: 200, maxStopContinuations: 2, - activeProfile: 'build', - permissionMode: 'askBeforeExec', + activeProfile: BUILD_PROFILE_NAME, + permissionMode: ASK_BEFORE_EXEC_PERMISSION_MODE, context: DEFAULT_CONTEXT, memory: DEFAULT_MEMORY, subagent: DEFAULT_SUBAGENT, diff --git a/packages/codingcode/src/scheduler/scheduler.ts b/packages/codingcode/src/scheduler/scheduler.ts index e30e6f5c..34b83aa7 100644 --- a/packages/codingcode/src/scheduler/scheduler.ts +++ b/packages/codingcode/src/scheduler/scheduler.ts @@ -11,6 +11,7 @@ import { readAutomations, writeAutomations } from './store.js'; import { AgentService } from '../agent/port.js'; import { activeModelId } from '../infra/models.js'; import { SchedulerService } from './port.js'; +import { BYPASS_PERMISSION_MODE, BUILD_PROFILE_NAME } from '../util/enums.js'; const logger = createLogger(); @@ -54,8 +55,8 @@ export const SchedulerLayer = Layer.effect( signal: controller.signal, // 自动化没有独立的模型配置,统一用 config.yaml 的活动模型 model: activeModelId(), - activeProfile: 'build', - permissionMode: 'bypass', + activeProfile: BUILD_PROFILE_NAME, + permissionMode: BYPASS_PERMISSION_MODE, }); }) ); @@ -186,8 +187,8 @@ export const SchedulerLayer = Layer.effect( cwd: auto.projectCwd, signal: controller.signal, model: activeModelId(), - activeProfile: 'build', - permissionMode: 'bypass', + activeProfile: BUILD_PROFILE_NAME, + permissionMode: BYPASS_PERMISSION_MODE, }); }) ); diff --git a/packages/codingcode/src/server/routes/agent.ts b/packages/codingcode/src/server/routes/agent.ts index c4a91e68..a9a85c5e 100644 --- a/packages/codingcode/src/server/routes/agent.ts +++ b/packages/codingcode/src/server/routes/agent.ts @@ -12,14 +12,17 @@ import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; import { AgentError } from '../../util/error.js'; import { json, readJson, type Handler, type Router } from '../handler.js'; +import { ASK_BEFORE_EXEC_PERMISSION_MODE, BUILD_PROFILE_NAME } from '../../util/enums.js'; function readAgentConfig() { const cfg = loadConfig(); return { maxSteps: cfg.maxSteps, maxStopContinuations: cfg.maxStopContinuations, - activeProfile: isAgentProfileName(cfg.activeProfile) ? cfg.activeProfile : 'build', - permissionMode: isPermissionMode(cfg.permissionMode) ? cfg.permissionMode : 'askBeforeExec', + activeProfile: isAgentProfileName(cfg.activeProfile) ? cfg.activeProfile : BUILD_PROFILE_NAME, + permissionMode: isPermissionMode(cfg.permissionMode) + ? cfg.permissionMode + : ASK_BEFORE_EXEC_PERMISSION_MODE, }; } diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index bf0b0373..776492d9 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -6,6 +6,7 @@ import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; import { loadConfig } from '../../infra/config.js'; import { AgentError } from '../../util/error.js'; +import { ASK_BEFORE_EXEC_PERMISSION_MODE, BUILD_PROFILE_NAME } from '../../util/enums.js'; import { frameStream, pathParams, @@ -45,10 +46,12 @@ const sendMessage: Handler = Effect.gen(function* () { }; if (isNew) { const cfg = loadConfig(); - runOpts.activeProfile = isAgentProfileName(cfg.activeProfile) ? cfg.activeProfile : 'build'; + runOpts.activeProfile = isAgentProfileName(cfg.activeProfile) + ? cfg.activeProfile + : BUILD_PROFILE_NAME; runOpts.permissionMode = isPermissionMode(cfg.permissionMode) ? cfg.permissionMode - : 'askBeforeExec'; + : ASK_BEFORE_EXEC_PERMISSION_MODE; } const agent = yield* AgentService; diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index db0fda1e..e76e199b 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -3,8 +3,11 @@ import { Effect } from 'effect'; import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; import type { SessionStoreState } from '../../session/types.js'; -import type { ProfileName } from '../../session/types.js'; -import type { PermissionMode } from '../../session/types.js'; +import { + ASK_BEFORE_EXEC_PERMISSION_MODE, + type PermissionMode, + type ProfileName, +} from '../../util/enums.js'; import { SessionService } from '../../session/port.js'; import { computePaths } from '../../session/paths.js'; @@ -15,7 +18,6 @@ import { CheckpointService } from '../../checkpoint/port.js'; import { activeModelId, setGlobalActive } from '../../infra/models.js'; import { resolveCwd, resolveWorkspaceCwd } from '../cwd.js'; import { projectDataDir } from '../../util/path.js'; -import { AVAILABLE_PROFILES } from '../../session/types.js'; import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; import { AgentError } from '../../util/error.js'; @@ -155,7 +157,6 @@ const getProfile: Handler = Effect.gen(function* () { activeProfile: state.activeProfile, permissionMode: state.permissionMode, cwd, - available: AVAILABLE_PROFILES, }); }); @@ -182,7 +183,7 @@ const setProfile: Handler = Effect.gen(function* () { const getPermissionMode: Handler = Effect.gen(function* () { const { id } = yield* pathParams; const { cwd } = yield* query; - if (!cwd) return json({ mode: 'askBeforeExec' }); + if (!cwd) return json({ mode: ASK_BEFORE_EXEC_PERMISSION_MODE }); const session = yield* SessionService; const state = yield* session.load(cwd, id ?? ''); return json({ mode: state.permissionMode }); @@ -309,7 +310,13 @@ const rollbackState = ( yield* session.rollbackToTurn(state, throughTurnId, 'user rollback'); const turns = yield* session.readUITurns(sessionId, cwd); const promptEstimate = estimatePromptTokensFrom(yield* session.readHistory(state)); - return { ok: true, turns, promptEstimate, usage: state.usage, ...(codeResult ? { codeResult } : {}) }; + return { + ok: true, + turns, + promptEstimate, + usage: state.usage, + ...(codeResult ? { codeResult } : {}), + }; }); const rollbackContext: Handler = Effect.gen(function* () { diff --git a/packages/codingcode/src/session/port.ts b/packages/codingcode/src/session/port.ts index 7f300d09..b99c32e1 100644 --- a/packages/codingcode/src/session/port.ts +++ b/packages/codingcode/src/session/port.ts @@ -17,8 +17,7 @@ import type { } from './types.js'; import type { UITurn } from './types.js'; import type { TokenUsage } from '../llm/types.js'; -import type { ProfileName } from './types.js'; -import type { PermissionMode } from './types.js'; +import type { ProfileName, PermissionMode } from '../util/enums.js'; export interface SessionShape { create( diff --git a/packages/codingcode/src/session/session.ts b/packages/codingcode/src/session/session.ts index 583d0f57..44350a68 100644 --- a/packages/codingcode/src/session/session.ts +++ b/packages/codingcode/src/session/session.ts @@ -19,8 +19,7 @@ import type { import type { SessionStoreState, SessionSummary } from './types.js'; import type { UITurn } from './types.js'; import type { TokenUsage } from '../llm/types.js'; -import type { ProfileName } from './types.js'; -import type { PermissionMode } from './types.js'; +import type { ProfileName, PermissionMode } from '../util/enums.js'; import { SessionService } from './port.js'; import { diff --git a/packages/codingcode/src/session/types.ts b/packages/codingcode/src/session/types.ts index 4446dc09..726d6f1b 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -1,28 +1,5 @@ import type { TokenUsage, ToolCall } from '../llm/types.js'; - -export const PLAN_PROFILE_NAME = 'plan' as const; -export const BUILD_PROFILE_NAME = 'build' as const; - -export const PROFILE_NAMES = [PLAN_PROFILE_NAME, BUILD_PROFILE_NAME] as const; - -export type ProfileName = (typeof PROFILE_NAMES)[number]; - -export function isPlanProfile(name: string | null | undefined): boolean { - return name === PLAN_PROFILE_NAME; -} - -export interface AvailableProfile { - name: ProfileName; -} - -export const AVAILABLE_PROFILES: AvailableProfile[] = [ - { name: PLAN_PROFILE_NAME }, - { name: BUILD_PROFILE_NAME }, -]; - -export const PERMISSION_MODES = ['askBeforeExec', 'bypass'] as const; - -export type PermissionMode = (typeof PERMISSION_MODES)[number]; +import type { ProfileName, PermissionMode } from '../util/enums.js'; export interface SessionMetaEvent { type: 'session_meta'; diff --git a/packages/codingcode/src/subagent/port.ts b/packages/codingcode/src/subagent/port.ts index 65ac0c92..59ea348d 100644 --- a/packages/codingcode/src/subagent/port.ts +++ b/packages/codingcode/src/subagent/port.ts @@ -3,13 +3,14 @@ import type { Effect } from 'effect'; import type { FrameBody } from '../sink/types.js'; import type { AgentError } from '../util/error.js'; import type { Result } from '../util/result.js'; +import type { ProfileName, PermissionMode } from '../util/enums.js'; export interface RunSubagentOptions { sessionId?: string; cwd: string; signal?: AbortSignal; - activeProfile?: import('../session/types.js').ProfileName; - permissionMode?: import('../session/types.js').PermissionMode; + activeProfile?: ProfileName; + permissionMode?: PermissionMode; model: string; systemPrompt?: string; parentSessionId?: string; diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts index a19f6056..0dc383b8 100644 --- a/packages/codingcode/src/subagent/registry.ts +++ b/packages/codingcode/src/subagent/registry.ts @@ -2,7 +2,7 @@ import { Context, Effect, Fiber, Layer, Option, Stream, SubscriptionRef } from ' import { AgentError } from '../util/error.js'; import { isTurnEnd } from '../sink/types.js'; import type { EndTransition, FrameBody } from '../sink/types.js'; -import type { ProfileName } from '../session/types.js'; +import { BYPASS_PERMISSION_MODE, type ProfileName } from '../util/enums.js'; import { SUBAGENT_RESULT_PREFIX } from '../session/types.js'; import { estimateTokensForContent } from '../context/tokens.js'; import { loadConfig } from '../infra/config.js'; @@ -199,7 +199,7 @@ export const SubagentRunRegistryLayer = Layer.scoped( cwd: opts.parentCwd, signal: abort.signal, // 子代理自己的 signal,与父回合无关 activeProfile: opts.parentProfile, - permissionMode: 'bypass', + permissionMode: BYPASS_PERMISSION_MODE, parentSessionId: opts.parentSessionId, agentName: opts.agentName, model: opts.model, diff --git a/packages/codingcode/src/subagent/subagent.ts b/packages/codingcode/src/subagent/subagent.ts index a33ceaf3..ea2b2508 100644 --- a/packages/codingcode/src/subagent/subagent.ts +++ b/packages/codingcode/src/subagent/subagent.ts @@ -4,6 +4,7 @@ import type { RunSubagentOptions } from './port.js'; import { AgentService } from '../agent/port.js'; import type { FrameBody } from '../sink/types.js'; import type { Result } from '../util/result.js'; +import { BYPASS_PERMISSION_MODE } from '../util/enums.js'; export const SubagentRunnerLayer = Layer.effect( SubagentRunnerService, @@ -18,7 +19,7 @@ export const SubagentRunnerLayer = Layer.effect( signal: opts.signal, activeProfile: opts.activeProfile, // 子代理不经审批:调用点未给定时固定 bypass,避免继承父会话的审批链路 - permissionMode: opts.permissionMode ?? 'bypass', + permissionMode: opts.permissionMode ?? BYPASS_PERMISSION_MODE, model: opts.model, parentSessionId: opts.parentSessionId, agentName: opts.agentName, diff --git a/packages/codingcode/src/tools/types.ts b/packages/codingcode/src/tools/types.ts index 317e6500..8101f38f 100644 --- a/packages/codingcode/src/tools/types.ts +++ b/packages/codingcode/src/tools/types.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { Effect } from 'effect'; import type { AgentError } from '../util/error.js'; import type { ToolCall, ToolDescription } from '../llm/types.js'; -import type { ProfileName } from '../session/types.js'; +import type { ProfileName } from '../util/enums.js'; import type { ToolOutcome } from '../sink/types.js'; export interface ToolDefinition { diff --git a/packages/codingcode/src/util/enums.ts b/packages/codingcode/src/util/enums.ts new file mode 100644 index 00000000..567ad8fc --- /dev/null +++ b/packages/codingcode/src/util/enums.ts @@ -0,0 +1,17 @@ +export const PLAN_PROFILE_NAME = 'plan' as const; +export const BUILD_PROFILE_NAME = 'build' as const; + +export const PROFILE_NAMES = [PLAN_PROFILE_NAME, BUILD_PROFILE_NAME] as const; + +export type ProfileName = (typeof PROFILE_NAMES)[number]; + +export function isPlanProfile(name: string | null | undefined): boolean { + return name === PLAN_PROFILE_NAME; +} + +export const ASK_BEFORE_EXEC_PERMISSION_MODE = 'askBeforeExec' as const; +export const BYPASS_PERMISSION_MODE = 'bypass' as const; + +export const PERMISSION_MODES = [ASK_BEFORE_EXEC_PERMISSION_MODE, BYPASS_PERMISSION_MODE] as const; + +export type PermissionMode = (typeof PERMISSION_MODES)[number]; diff --git a/packages/codingcode/test/architecture/boundaries.test.ts b/packages/codingcode/test/architecture/boundaries.test.ts index ac045a6b..60bec960 100644 --- a/packages/codingcode/test/architecture/boundaries.test.ts +++ b/packages/codingcode/test/architecture/boundaries.test.ts @@ -169,8 +169,8 @@ describe('R4 一个概念只允许一处类型定义', () => { ToolDescription: 'llm/types.ts', LLMClient: 'llm/types.ts', TodoItem: 'todo/types.ts', - ProfileName: 'session/types.ts', - PermissionMode: 'session/types.ts', + ProfileName: 'util/enums.ts', + PermissionMode: 'util/enums.ts', UITurn: 'session/types.ts', UITurnItem: 'session/types.ts', SessionEvent: 'session/types.ts', diff --git a/packages/codingcode/test/plan/gate-pipeline.test.ts b/packages/codingcode/test/plan/gate-pipeline.test.ts index 54e3d583..6657a702 100644 --- a/packages/codingcode/test/plan/gate-pipeline.test.ts +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -8,7 +8,7 @@ import { createRuleEngine } from '../../src/approval/rule-engine.js'; import { HookService } from '../../src/hooks/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { EventSinkService } from '../../src/sink/port.js'; -import type { ProfileName } from '../../src/session/types.js'; +import type { ProfileName } from '../../src/util/enums.js'; import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase(); diff --git a/packages/codingcode/test/plan/is-plan-profile.test.ts b/packages/codingcode/test/plan/is-plan-profile.test.ts index 438c55ee..3e48c77b 100644 --- a/packages/codingcode/test/plan/is-plan-profile.test.ts +++ b/packages/codingcode/test/plan/is-plan-profile.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isPlanProfile } from '../../src/session/types.js'; +import { isPlanProfile } from '../../src/util/enums.js'; describe('isPlanProfile', () => { it('returns true for "plan"', () => { diff --git a/packages/codingcode/test/security/plan-profile-restart.test.ts b/packages/codingcode/test/security/plan-profile-restart.test.ts index a3eca6a6..6bd8bc72 100644 --- a/packages/codingcode/test/security/plan-profile-restart.test.ts +++ b/packages/codingcode/test/security/plan-profile-restart.test.ts @@ -9,7 +9,7 @@ import { HookService } from '../../src/hooks/port.js'; import { ApprovalService } from '../../src/approval/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { EventSinkLayer } from '../../src/sink/sink.js'; -import type { ProfileName } from '../../src/session/types.js'; +import type { ProfileName } from '../../src/util/enums.js'; import { useTempProjectBase } from '../helpers/project-base.js'; import { ApprovalLayer } from '../../src/approval/approval.js'; diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index 910e1931..68314496 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -561,7 +561,6 @@ export type SessionProfileSnapshot = { activeProfile: ProfileName; permissionMode: PermissionMode; cwd: string; - available: Array<{ name: string }>; }; export type PlanFileSnapshot = { diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index 511a60d6..1418f4dd 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -117,7 +117,6 @@ export type SessionProfileInfo = { activeProfile: ProfileName; permissionMode: PermissionMode; cwd: string; - available: Array<{ name: string }>; }; export function getSessionProfile(sessionId: string, cwd: string): Promise { diff --git a/packages/desktop/test/profile-indicator.test.tsx b/packages/desktop/test/profile-indicator.test.tsx index 43e6b200..f839a35e 100644 --- a/packages/desktop/test/profile-indicator.test.tsx +++ b/packages/desktop/test/profile-indicator.test.tsx @@ -30,7 +30,6 @@ const baseProfile = { activeProfile: 'build' as const, permissionMode: 'askBeforeExec' as const, cwd: '/tmp', - available: [{ name: 'plan' }, { name: 'build' }], }; describe('ProfileIndicator (with live session)', () => { diff --git a/packages/sdk/src/contracts.ts b/packages/sdk/src/contracts.ts index 3a94d462..a4bfb6bf 100644 --- a/packages/sdk/src/contracts.ts +++ b/packages/sdk/src/contracts.ts @@ -13,16 +13,13 @@ import type { UpdateAutomationInput, UserHookConfig, } from './dto.js'; -import type { AvailableProfile, PermissionMode, ProfileName, TokenUsage } from './types.js'; +import type { PermissionMode, ProfileName, TokenUsage } from './types.js'; import type { Frame } from './protocol.js'; -export type AvailableProfiles = AvailableProfile[]; - export interface SessionProfileInfo { activeProfile: ProfileName; permissionMode: PermissionMode; cwd: string; - available: AvailableProfiles; } export interface SessionPlanFile { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 24da5770..6785d11f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -51,8 +51,6 @@ export type { } from './dto.js'; export type { - AvailableProfile, - AvailableProfiles, PermissionMode, ProfileName, TodoItem, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index c0cd54be..20bdc7f0 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -2,12 +2,6 @@ export type ProfileName = 'plan' | 'build'; export type PermissionMode = 'askBeforeExec' | 'bypass'; -export interface AvailableProfile { - name: ProfileName; -} - -export type AvailableProfiles = AvailableProfile[]; - export interface TokenUsage { prompt: number; completion: number; From 693103d530de2f81d451d051ebcee7799be43401 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 8 Oct 2026 20:10:14 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E6=81=A2=E5=A4=8Dexecutebatch=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/codingcode/src/agent/agent.ts | 21 ++++++++++++------- packages/codingcode/src/tools/catalog.ts | 11 ++-------- packages/codingcode/src/tools/port.ts | 10 ++++++++- packages/codingcode/src/tools/tools.ts | 17 ++++++--------- packages/codingcode/src/tools/types.ts | 9 +++----- .../codingcode/test/helpers/agent-harness.ts | 9 ++++---- .../test/subagent/runner-wiring.test.ts | 3 ++- .../test/tools/executor-concurrency.test.ts | 4 ++-- .../tools/executor-ctx-projection.test.ts | 7 ++++++- 9 files changed, 48 insertions(+), 43 deletions(-) diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 4e330c17..a9cfc257 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -265,7 +265,7 @@ export const AgentLayer = Layer.effect( projectPath, permissionMode, } = opts; - const { tools } = catalog; + const { tools, lookup: toolLookup } = catalog; let ended = false; let deliveryPhase: 'currentTurn' | 'nextTurn' = 'currentTurn'; @@ -486,13 +486,18 @@ export const AgentLayer = Layer.effect( const approvedResults = approvedCalls.length > 0 - ? yield* catalog.executeBatch(approvedCalls, state.sessionId, { - turnId: state.currentTurnId, - projectPath, - signal: abortSignal, - activeProfile: profile?.name, - model, - }) + ? yield* executor.executeBatch( + approvedCalls, + state.sessionId, + { + turnId: state.currentTurnId, + projectPath, + signal: abortSignal, + activeProfile: profile?.name, + model, + }, + toolLookup + ) : []; const resultsById = new Map(); diff --git a/packages/codingcode/src/tools/catalog.ts b/packages/codingcode/src/tools/catalog.ts index cd6ad682..8dcd232d 100644 --- a/packages/codingcode/src/tools/catalog.ts +++ b/packages/codingcode/src/tools/catalog.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; import type { Effect } from 'effect'; -import type { ToolDefinition, ToolExecCtx, ToolRunner } from './types.js'; -import type { ToolDescription } from '../llm/types.js'; +import type { ToolDefinition, ToolExecCtx, ToolCatalog } from './types.js'; import type { McpToolSpec } from '../mcp/types.js'; import type { AgentError } from '../util/error.js'; import { ToolRegistry } from './registry.js'; @@ -45,16 +44,10 @@ function specToDefinition(spec: McpToolSpec): ToolDefinition { }; } -/** 装配产物:给模型看的描述 + 内部查找(不对外暴露)。 */ -export interface ToolCatalogSource { - readonly tools: ToolDescription[]; - readonly lookup: (name: string) => ToolRunner | undefined; -} - export function createToolCatalog( toolNames: readonly string[], mcpTools: McpToolSpec[] = [] -): ToolCatalogSource { +): ToolCatalog { const registry = new ToolRegistry(); for (const name of toolNames) { const definition = TOOLS_BY_NAME.get(name); diff --git a/packages/codingcode/src/tools/port.ts b/packages/codingcode/src/tools/port.ts index f76cac04..dbf6e621 100644 --- a/packages/codingcode/src/tools/port.ts +++ b/packages/codingcode/src/tools/port.ts @@ -1,11 +1,19 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; import type { McpToolSpec } from '../mcp/types.js'; -import type { ToolCatalog } from './types.js'; +import type { ToolCall } from '../llm/types.js'; +import type { ToolCatalog, ToolExecOpts, ToolLookup, ToolResult } from './types.js'; export interface ToolExecutorShape { /** 按工具名 + 调用方提供的 MCP 工具装配出本轮可用的工具集 */ prepare(toolNames: readonly string[], mcpTools?: McpToolSpec[]): Effect.Effect; + + executeBatch( + toolCalls: ToolCall[], + sessionId: string | undefined, + opts: ToolExecOpts, + toolLookup: ToolLookup + ): Effect.Effect; } export class ToolExecutorService extends Context.Tag('ToolExecutor')< diff --git a/packages/codingcode/src/tools/tools.ts b/packages/codingcode/src/tools/tools.ts index 9283c8c5..1d60aa45 100644 --- a/packages/codingcode/src/tools/tools.ts +++ b/packages/codingcode/src/tools/tools.ts @@ -8,6 +8,7 @@ import type { ToolExecCall, ToolExecCtx, ToolExecOpts, + ToolLookup, ToolResult, ToolRunner, } from './types.js'; @@ -160,12 +161,13 @@ export const ToolExecutorLayer = Layer.effect( return waves; } - function runBatch( + function executeBatch( toolCalls: ToolCall[], sessionId: string | undefined, opts: ToolExecOpts, - lookup: Lookup + toolLookup: ToolLookup ): Effect.Effect { + const lookup: Lookup = toolLookup; return Effect.gen(function* () { const runTool = (tc: ToolCall): Effect.Effect => Effect.suspend(() => @@ -195,16 +197,9 @@ export const ToolExecutorLayer = Layer.effect( toolNames: readonly string[], mcpTools: McpToolSpec[] = [] ): Effect.Effect { - return Effect.sync(() => { - const source = createToolCatalog(toolNames, mcpTools); - return { - tools: source.tools, - executeBatch: (toolCalls, sessionId, opts) => - runBatch(toolCalls, sessionId, opts, source.lookup), - }; - }); + return Effect.sync(() => createToolCatalog(toolNames, mcpTools)); } - return { prepare }; + return { prepare, executeBatch }; }) ); diff --git a/packages/codingcode/src/tools/types.ts b/packages/codingcode/src/tools/types.ts index 8101f38f..e756443a 100644 --- a/packages/codingcode/src/tools/types.ts +++ b/packages/codingcode/src/tools/types.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { Effect } from 'effect'; import type { AgentError } from '../util/error.js'; -import type { ToolCall, ToolDescription } from '../llm/types.js'; +import type { ToolDescription } from '../llm/types.js'; import type { ProfileName } from '../util/enums.js'; import type { ToolOutcome } from '../sink/types.js'; @@ -32,6 +32,7 @@ export interface ToolRunner { execute(args: unknown, ctx?: ToolExecCtx): Effect.Effect; } +export type ToolLookup = (name: string) => ToolRunner | undefined; export type ToolExecOpts = Omit & { turnId?: number }; @@ -39,9 +40,5 @@ export type ToolExecCall = ToolExecOpts & { sessionId?: string; callId?: string export interface ToolCatalog { tools: ToolDescription[]; - executeBatch( - toolCalls: ToolCall[], - sessionId: string | undefined, - opts: ToolExecOpts - ): Effect.Effect; + lookup: ToolLookup; } diff --git a/packages/codingcode/test/helpers/agent-harness.ts b/packages/codingcode/test/helpers/agent-harness.ts index 2889d3c2..5e550e4e 100644 --- a/packages/codingcode/test/helpers/agent-harness.ts +++ b/packages/codingcode/test/helpers/agent-harness.ts @@ -231,11 +231,12 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { )); const executor = { prepare: (names: readonly string[], mcpTools: any[] = []) => - Effect.succeed({ - tools: createToolCatalog(names, mcpTools).tools, - executeBatch: (calls: any[], sessionId?: string, opts?: any) => - batch(calls, sessionId, opts), + Effect.sync(() => { + const { tools, lookup } = createToolCatalog(names, mcpTools); + return { tools, lookup }; }), + executeBatch: (calls: any[], sessionId?: string, opts?: any) => + batch(calls, sessionId, opts), } as any; const session: Record = { diff --git a/packages/codingcode/test/subagent/runner-wiring.test.ts b/packages/codingcode/test/subagent/runner-wiring.test.ts index 9e7a75ff..34108e80 100644 --- a/packages/codingcode/test/subagent/runner-wiring.test.ts +++ b/packages/codingcode/test/subagent/runner-wiring.test.ts @@ -125,7 +125,8 @@ const TodoMock = Layer.succeed(TodoService, { const AgentDeps = Layer.mergeAll( SessionLayer, Layer.succeed(ToolExecutorService, { - prepare: () => Effect.succeed({ tools: [], executeBatch: () => Effect.succeed([]) }), + prepare: () => Effect.succeed({ tools: [], lookup: () => undefined }), + executeBatch: () => Effect.succeed([]), } as any), Layer.succeed(CheckpointService, { snapshotBaseline: () => Effect.void, diff --git a/packages/codingcode/test/tools/executor-concurrency.test.ts b/packages/codingcode/test/tools/executor-concurrency.test.ts index 694592d6..7168fe08 100644 --- a/packages/codingcode/test/tools/executor-concurrency.test.ts +++ b/packages/codingcode/test/tools/executor-concurrency.test.ts @@ -141,12 +141,12 @@ function runBatch( // 只把内置名交给 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* catalog.executeBatch(calls, ctx.sessionId, { + return yield* exec.executeBatch(calls, ctx.sessionId, { projectPath: ctx.projectPath, signal: ctx.signal, activeProfile: ctx.activeProfile, model: ctx.model, - }); + }, catalog.lookup); }); return Effect.runPromise( program.pipe(Effect.provide(ToolExecutorLayer), Effect.provide(h.layers)) diff --git a/packages/codingcode/test/tools/executor-ctx-projection.test.ts b/packages/codingcode/test/tools/executor-ctx-projection.test.ts index 34676fe2..abd9e2d9 100644 --- a/packages/codingcode/test/tools/executor-ctx-projection.test.ts +++ b/packages/codingcode/test/tools/executor-ctx-projection.test.ts @@ -40,7 +40,12 @@ function runProbe(opts: ToolExecOpts, sessionId: string | undefined): Promise Date: Thu, 8 Oct 2026 20:22:49 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=94=B9=E5=9B=9E=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/server/routes/automations.ts | 23 ++++--------------- .../codingcode/src/server/routes/hooks.ts | 20 ++++------------ packages/codingcode/src/server/routes/mcp.ts | 19 +++------------ 3 files changed, 12 insertions(+), 50 deletions(-) diff --git a/packages/codingcode/src/server/routes/automations.ts b/packages/codingcode/src/server/routes/automations.ts index cce6f057..ecc6e9f0 100644 --- a/packages/codingcode/src/server/routes/automations.ts +++ b/packages/codingcode/src/server/routes/automations.ts @@ -4,24 +4,10 @@ import { SchedulerService } from '../../scheduler/port.js'; import { NotFoundError } from '../http-error.js'; import { AgentError } from '../../util/error.js'; import { json, pathParams, readJson, type Handler, type Router } from '../handler.js'; -import type { AutomationSandbox, CreateAutomationInput } from '../../scheduler/types.js'; - -type AutomationBody = { - name?: string; - description?: string; - cron?: string; - timezone?: string; - sandbox?: AutomationSandbox; - projectCwd?: string; - runOnce?: boolean; - enabled?: boolean; -}; - -/** PATCH 只能改既有自动化的字段,不接受 projectCwd */ -type AutomationPatchBody = Omit; +import type { CreateAutomationInput, UpdateAutomationInput } from '../../scheduler/types.js'; /** 校验必填项后构造 CreateAutomationInput;缺字段返回 null,由调用方落 400。不用 `as` 断言未验证 body。 */ -function toCreateInput(body: AutomationBody): CreateAutomationInput | null { +function toCreateInput(body: Partial): CreateAutomationInput | null { const { name, description, cron, projectCwd } = body; if (!name || !description || !cron || !projectCwd) return null; return { ...body, name, description, cron, projectCwd }; @@ -33,7 +19,7 @@ const listAutomations: Handler = Effect.gen(function* () { }); const createAutomation: Handler = Effect.gen(function* () { - const body = yield* readJson(); + const body = yield* readJson>(); const input = toCreateInput(body); if (!input) { return yield* Effect.fail( @@ -49,7 +35,8 @@ const createAutomation: Handler = Effect.gen(function* () { const patchAutomation: Handler = Effect.gen(function* () { const { id } = yield* pathParams; - const body = yield* readJson(); + // PATCH 只能改既有自动化的字段(UpdateAutomationInput 不含 projectCwd) + const body = yield* readJson(); const scheduler = yield* SchedulerService; const updated = scheduler.update(id ?? '', body); if (!updated) { diff --git a/packages/codingcode/src/server/routes/hooks.ts b/packages/codingcode/src/server/routes/hooks.ts index ee5d800e..8d65368b 100644 --- a/packages/codingcode/src/server/routes/hooks.ts +++ b/packages/codingcode/src/server/routes/hooks.ts @@ -4,7 +4,7 @@ import { isGlobalCwd, resolveCwd } from '../cwd.js'; import { AlreadyExistsError, NotFoundError, type AppError } from '../http-error.js'; import { AgentError } from '../../util/error.js'; import { json, pathParams, query, readJson, type Handler, type Router } from '../handler.js'; -import type { HookPoint, UserHookConfig } from '../../hooks/types.js'; +import type { UserHookConfig } from '../../hooks/types.js'; import { loadHookConfigs, writeHookConfigs, @@ -15,19 +15,7 @@ import { setProjectHookEnabled, } from '../../hooks/config.js'; -type HookBody = { - name?: string; - description?: string; - point?: HookPoint; - type?: 'observer' | 'decision'; - command?: string; - args?: string[]; - env?: Record; - priority?: number; - enabled?: boolean; -}; - -function toHookConfig(body: HookBody): UserHookConfig | null { +function toHookConfig(body: Partial): UserHookConfig | null { const { name, point, type, command } = body; if (!name || !point || !type || !command) return null; return { ...body, name, point, type, command }; @@ -106,7 +94,7 @@ const listHooks: Handler = Effect.gen(function* () { const createHook: Handler = Effect.gen(function* () { const { cwd: rawCwd } = yield* query; - const body = yield* readJson(); + const body = yield* readJson>(); const hook = toHookConfig(body); if (!hook) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_FIELDS)); @@ -126,7 +114,7 @@ const createHook: Handler = Effect.gen(function* () { const updateHook: Handler = Effect.gen(function* () { const { name } = yield* pathParams; const { cwd: rawCwd } = yield* query; - const body = yield* readJson(); + const body = yield* readJson>(); const hook = toHookConfig(body); if (!hook) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_FIELDS)); diff --git a/packages/codingcode/src/server/routes/mcp.ts b/packages/codingcode/src/server/routes/mcp.ts index d29d13b0..0ed96c67 100644 --- a/packages/codingcode/src/server/routes/mcp.ts +++ b/packages/codingcode/src/server/routes/mcp.ts @@ -15,21 +15,8 @@ import { setProjectMcpServerEnabled, } from '../../mcp/config.js'; -/** POST / PUT body 的线上形状:字段一律可选——请求体是未验证输入,必填项由 toMcpServerConfig 显式校验 */ -type McpServerBody = { - name?: string; - enabled?: boolean; - command?: string; - args?: string[]; - env?: Record; - url?: string; - headers?: Record; - concurrency?: number; - autoReconnect?: boolean; -}; - /** 校验必填项后构造领域对象;缺 name 返回 null,由调用方落 400。不用 `as` 把未验证 body 断言成 McpServerConfig。 */ -function toMcpServerConfig(body: McpServerBody): McpServerConfig | null { +function toMcpServerConfig(body: Partial): McpServerConfig | null { const { name } = body; if (!name) return null; return { ...body, name }; @@ -108,7 +95,7 @@ const listServers: Handler = Effect.gen(function* () { const createServer: Handler = Effect.gen(function* () { const { cwd: rawCwd } = yield* query; - const body = yield* readJson(); + const body = yield* readJson>(); const server = toMcpServerConfig(body); if (!server) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_NAME)); @@ -128,7 +115,7 @@ const createServer: Handler = Effect.gen(function* () { const updateServer: Handler = Effect.gen(function* () { const { name } = yield* pathParams; const { cwd: rawCwd } = yield* query; - const body = yield* readJson(); + const body = yield* readJson>(); const server = toMcpServerConfig(body); if (!server) return yield* Effect.fail(new AgentError('CONFIG_MISSING', MISSING_NAME));