From 90c255269f2fd82c0fddcc2ca207db84662c3e1e Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 3 Oct 2026 00:42:09 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AD=90=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=E8=B0=83=E7=94=A8=E9=94=99=E8=AF=AF=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E8=B0=83=E6=95=B4client=E8=87=B3sdk=EF=BC=8C?= =?UTF-8?q?context=E7=AD=89=E5=A2=9E=E5=8A=A0effect=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=8C=85=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- CLAUDE.md | 2 +- CONTRIBUTING.md | 10 +- README.md | 10 +- docs/tools.md | 10 +- package.json | 2 +- packages/codingcode/package.json | 10 +- packages/codingcode/src/agent/agent.ts | 83 +-- packages/codingcode/src/agent/port.ts | 6 +- packages/codingcode/src/agent/profile.ts | 11 +- packages/codingcode/src/approval/approval.ts | 4 +- packages/codingcode/src/cli.ts | 2 +- packages/codingcode/src/client/http/index.ts | 30 - packages/codingcode/src/context/context.ts | 375 ++++++------ packages/codingcode/src/context/port.ts | 9 +- .../types.ts => contracts/automation.ts} | 12 +- packages/codingcode/src/contracts/error.ts | 11 - packages/codingcode/src/contracts/frame-io.ts | 102 +--- packages/codingcode/src/contracts/mcp.ts | 6 - .../codingcode/src/contracts/permission.ts | 2 +- packages/codingcode/src/contracts/profile.ts | 11 + packages/codingcode/src/contracts/session.ts | 31 +- packages/codingcode/src/contracts/tool.ts | 6 +- packages/codingcode/src/core/path.ts | 13 +- packages/codingcode/src/hooks/config.ts | 2 +- packages/codingcode/src/hooks/hooks.ts | 2 +- .../src => codingcode/src/infra}/config.ts | 26 +- .../src => codingcode/src/infra}/logger.ts | 5 +- packages/codingcode/src/infra/models.ts | 110 ++++ .../src/infra}/yaml-store.ts | 0 packages/codingcode/src/llm/llm-resolver.ts | 19 - packages/codingcode/src/llm/llm.ts | 342 +++-------- packages/codingcode/src/llm/port.ts | 14 +- packages/codingcode/src/mcp/client.ts | 4 - packages/codingcode/src/mcp/config.ts | 2 +- packages/codingcode/src/mcp/mcp.ts | 16 +- packages/codingcode/src/mcp/port.ts | 3 +- packages/codingcode/src/memory/config.ts | 2 +- packages/codingcode/src/memory/extractor.ts | 18 +- packages/codingcode/src/memory/memory.ts | 141 ++--- packages/codingcode/src/memory/port.ts | 10 +- packages/codingcode/src/rules/port.ts | 5 +- packages/codingcode/src/rules/rules.ts | 26 +- packages/codingcode/src/scheduler/port.ts | 2 +- .../codingcode/src/scheduler/scheduler.ts | 9 +- packages/codingcode/src/scheduler/store.ts | 2 +- packages/codingcode/src/server/index.ts | 2 +- .../codingcode/src/server/routes/agent.ts | 64 +++ .../src/server/routes/automations.ts | 9 +- .../codingcode/src/server/routes/hooks.ts | 138 +++++ packages/codingcode/src/server/routes/mcp.ts | 138 +++++ .../codingcode/src/server/routes/memory.ts | 40 ++ .../codingcode/src/server/routes/messages.ts | 20 +- .../codingcode/src/server/routes/models.ts | 35 +- .../codingcode/src/server/routes/sessions.ts | 84 ++- .../codingcode/src/server/routes/settings.ts | 420 +------------- .../codingcode/src/server/routes/skills.ts | 56 ++ packages/codingcode/src/server/util.ts | 14 +- packages/codingcode/src/session/file-ops.ts | 212 +++---- packages/codingcode/src/session/port.ts | 15 +- packages/codingcode/src/session/session.ts | 236 +++----- packages/codingcode/src/subagent/port.ts | 3 +- packages/codingcode/src/subagent/subagent.ts | 6 +- .../src/tools/domains/subagent/dispatch.ts | 38 +- .../src/tools/domains/subagent/submit-plan.ts | 2 +- packages/codingcode/src/tools/port.ts | 6 +- packages/codingcode/src/tools/tools.ts | 50 +- packages/codingcode/test/agent/abort.test.ts | 2 +- .../test/agent/agent-cache-stability.test.ts | 2 +- .../test/agent/agent-concurrent.test.ts | 2 +- .../test/agent/agent-todo-event.test.ts | 2 +- packages/codingcode/test/agent/agent.test.ts | 2 +- .../test/agent/hooks-deps-type.test.ts | 2 +- .../test/agent/loop-options.test.ts | 2 +- .../test/agent/memory-snapshot.test.ts | 2 +- .../codingcode/test/agent/stop-hook.test.ts | 2 +- .../test/agent/submit-plan-turn-end.test.ts | 2 +- .../codingcode/test/approval/pipeline.test.ts | 6 +- .../test/architecture/boundaries.test.ts | 32 +- .../test/context/append-turn-end.test.ts | 2 +- .../test/context/budget-integration.test.ts | 38 +- .../test/context/compressor/behavior.test.ts | 120 ++-- .../context/compressor/llm-resolver.test.ts | 106 ---- .../codingcode/test/context/organizer.test.ts | 12 +- packages/codingcode/test/core/error.test.ts | 21 - .../codingcode/test/core/frame-io.test.ts | 93 +-- packages/codingcode/test/core/path.test.ts | 5 +- .../codingcode/test/helpers/agent-harness.ts | 44 +- .../test/infra}/config.test.ts | 2 +- .../test/infra}/logger.test.ts | 6 +- packages/codingcode/test/infra/models.test.ts | 230 ++++++++ .../test/infra}/yaml-store.test.ts | 2 +- packages/codingcode/test/llm/factory.test.ts | 223 -------- packages/codingcode/test/mcp/service.test.ts | 16 - .../codingcode/test/memory/config.test.ts | 4 +- packages/codingcode/test/memory/index.test.ts | 127 ++--- .../test/memory/llm-resolver.test.ts | 90 --- packages/codingcode/test/orchestrate.test.ts | 2 +- .../test/plan/gate-pipeline.test.ts | 12 +- .../test/prompts/system-prompt.test.ts | 8 + .../codingcode/test/scheduler/store.test.ts | 2 +- .../security/plan-profile-restart.test.ts | 17 +- .../test/server/compact-route.test.ts | 121 +--- .../create-session-active-profile.test.ts | 50 +- packages/codingcode/test/server/index.test.ts | 8 +- .../messages-fork-permission-mode.test.ts | 19 +- .../test/server/plan-file-route.test.ts | 22 +- .../test/session/compute-paths.test.ts | 12 +- .../session/create-active-profile.test.ts | 21 +- .../session/create-session-profile.test.ts | 2 +- .../test/session/disk-setters.test.ts | 29 +- .../test/session/facade-surface.test.ts | 1 + .../codingcode/test/session/filter-ui.test.ts | 4 +- packages/codingcode/test/session/fork.test.ts | 145 ++--- .../test/session/index-write-error.test.ts | 102 ---- .../test/session/index-write-sync.test.ts | 100 ---- .../codingcode/test/session/io-error.test.ts | 54 +- .../test/session/load-create.test.ts | 91 ++- .../test/session/load-restore-profile.test.ts | 23 +- .../test/session/parent-session-id.test.ts | 26 +- .../test/session/prompt-estimate.test.ts | 96 ++-- .../record-tool-result-persist.test.ts | 4 +- .../codingcode/test/session/rollback.test.ts | 23 +- .../test/session/session-jsonl-path.test.ts | 10 +- .../test/session/store-compact-usage.test.ts | 55 +- .../test/session/store-diff-rebuild.test.ts | 6 +- .../test/session/store-rollback-usage.test.ts | 63 +-- .../test/session/types-export.test.ts | 8 +- .../test/session/ui-history-rollback.test.ts | 48 +- .../test/session/update-index-dedup.test.ts | 124 ---- .../test/session/view-assembly.test.ts | 14 +- .../test/subagent/dispatch-end-to-end.test.ts | 53 +- .../subagent/dispatch-production-path.test.ts | 243 ++++++++ .../codingcode/test/subagent/dispatch.test.ts | 142 +++-- .../test/subagent/runner-service.test.ts | 2 +- .../test/subagent/runner-wiring.test.ts | 287 ++++++++++ .../domains/bash/bash-project-path.test.ts | 2 +- .../domains/fs/tool-project-path.test.ts | 2 +- .../test/tools/executor-concurrency.test.ts | 40 +- packages/codingcode/test/tools/todo.test.ts | 4 +- packages/codingcode/test/tsconfig.json | 2 +- .../test/types/type-collapse.test.ts | 5 - packages/codingcode/tsconfig.json | 3 +- packages/desktop/electron.vite.config.ts | 11 - .../desktop/electron/core/child-process.ts | 2 + packages/desktop/package.json | 3 +- packages/desktop/shared/types.ts | 1 + packages/desktop/src/agent/AgentWorkspace.tsx | 110 ++-- packages/desktop/src/agent/AutomationForm.tsx | 15 +- .../desktop/src/agent/AutomationPanel.tsx | 4 +- packages/desktop/src/agent/MessageStream.tsx | 1 + .../desktop/src/agent/ProfileIndicator.tsx | 34 +- packages/desktop/src/hooks/useAgent.ts | 73 ++- packages/desktop/src/lib/api.ts | 13 +- packages/desktop/src/lib/core-api.ts | 161 ++---- packages/desktop/src/lib/frame-reducer.ts | 2 +- packages/desktop/src/settings/HooksPanel.tsx | 2 +- packages/desktop/src/settings/McpPanel.tsx | 8 +- packages/desktop/src/stores/agent.store.ts | 118 ++-- .../desktop/test/agent-mode-config.test.ts | 91 +++ .../desktop/test/approval-panel-plan.test.tsx | 2 +- .../desktop/test/approval-policy-sync.test.ts | 38 -- .../desktop/test/compact-usage-reset.test.ts | 4 +- .../desktop/test/fork-button-portal.test.tsx | 2 +- packages/desktop/test/global-store.test.ts | 74 ++- .../test/message-stream-scroll.test.tsx | 2 +- .../test/performance-optimization.test.ts | 19 +- .../plan-profile-hide-perm-switch.test.tsx | 4 +- .../desktop/test/profile-indicator.test.tsx | 81 ++- .../desktop/test/rollback-usage-reset.test.ts | 2 +- .../test/sidebar-thread-switch.test.tsx | 3 +- packages/desktop/test/thread-delete.test.ts | 4 +- .../desktop/test/todo-panel-state.test.ts | 2 +- packages/desktop/tsconfig.json | 2 +- packages/infra/package.json | 17 - packages/infra/src/paths.ts | 8 - packages/sdk/package.json | 9 + .../src/client => sdk/src}/contracts.ts | 76 ++- packages/sdk/src/decode.ts | 102 ++++ packages/sdk/src/dto.ts | 175 ++++++ packages/sdk/src/error.ts | 11 + .../client => sdk/src}/http/agent-runtime.ts | 19 +- packages/sdk/src/http/automations.ts | 30 + .../src/client => sdk/src}/http/models.ts | 6 +- .../src/client => sdk/src}/http/request.ts | 38 +- .../src/client => sdk/src}/http/sessions.ts | 14 +- .../src/client => sdk/src}/http/settings.ts | 10 +- packages/sdk/src/index.ts | 92 +++ packages/sdk/src/protocol.ts | 67 +++ .../{codingcode/src/client => sdk/src}/sse.ts | 0 packages/sdk/src/types.ts | 23 + .../client => sdk/test}/contracts.test.ts | 22 +- .../test}/get-session-plan.test.ts | 3 +- .../test}/http-session-contracts.test.ts | 4 +- .../test}/http/agent-runtime.test.ts | 8 +- .../client => sdk/test}/http/sessions.test.ts | 4 +- packages/sdk/test/tsconfig.json | 12 + packages/{infra => sdk}/tsconfig.json | 4 +- pnpm-lock.yaml | 535 +----------------- tsconfig.json | 3 +- 200 files changed, 4061 insertions(+), 4533 deletions(-) delete mode 100644 packages/codingcode/src/client/http/index.ts rename packages/codingcode/src/{scheduler/types.ts => contracts/automation.ts} (73%) create mode 100644 packages/codingcode/src/contracts/profile.ts rename packages/{infra/src => codingcode/src/infra}/config.ts (86%) rename packages/{infra/src => codingcode/src/infra}/logger.ts (77%) create mode 100644 packages/codingcode/src/infra/models.ts rename packages/{infra/src => codingcode/src/infra}/yaml-store.ts (100%) delete mode 100644 packages/codingcode/src/llm/llm-resolver.ts create mode 100644 packages/codingcode/src/server/routes/agent.ts create mode 100644 packages/codingcode/src/server/routes/hooks.ts create mode 100644 packages/codingcode/src/server/routes/mcp.ts create mode 100644 packages/codingcode/src/server/routes/memory.ts create mode 100644 packages/codingcode/src/server/routes/skills.ts delete mode 100644 packages/codingcode/test/context/compressor/llm-resolver.test.ts rename packages/{infra/test => codingcode/test/infra}/config.test.ts (99%) rename packages/{infra/test => codingcode/test/infra}/logger.test.ts (92%) create mode 100644 packages/codingcode/test/infra/models.test.ts rename packages/{infra/test => codingcode/test/infra}/yaml-store.test.ts (99%) delete mode 100644 packages/codingcode/test/llm/factory.test.ts delete mode 100644 packages/codingcode/test/memory/llm-resolver.test.ts delete mode 100644 packages/codingcode/test/session/index-write-error.test.ts delete mode 100644 packages/codingcode/test/session/index-write-sync.test.ts delete mode 100644 packages/codingcode/test/session/update-index-dedup.test.ts create mode 100644 packages/codingcode/test/subagent/dispatch-production-path.test.ts create mode 100644 packages/codingcode/test/subagent/runner-wiring.test.ts create mode 100644 packages/desktop/test/agent-mode-config.test.ts delete mode 100644 packages/desktop/test/approval-policy-sync.test.ts delete mode 100644 packages/infra/package.json delete mode 100644 packages/infra/src/paths.ts create mode 100644 packages/sdk/package.json rename packages/{codingcode/src/client => sdk/src}/contracts.ts (66%) create mode 100644 packages/sdk/src/decode.ts create mode 100644 packages/sdk/src/dto.ts create mode 100644 packages/sdk/src/error.ts rename packages/{codingcode/src/client => sdk/src}/http/agent-runtime.ts (61%) create mode 100644 packages/sdk/src/http/automations.ts rename packages/{codingcode/src/client => sdk/src}/http/models.ts (70%) rename packages/{codingcode/src/client => sdk/src}/http/request.ts (60%) rename packages/{codingcode/src/client => sdk/src}/http/sessions.ts (87%) rename packages/{codingcode/src/client => sdk/src}/http/settings.ts (91%) create mode 100644 packages/sdk/src/index.ts create mode 100644 packages/sdk/src/protocol.ts rename packages/{codingcode/src/client => sdk/src}/sse.ts (100%) create mode 100644 packages/sdk/src/types.ts rename packages/{codingcode/test/client => sdk/test}/contracts.test.ts (74%) rename packages/{codingcode/test/client => sdk/test}/get-session-plan.test.ts (86%) rename packages/{codingcode/test/client => sdk/test}/http-session-contracts.test.ts (95%) rename packages/{codingcode/test/client => sdk/test}/http/agent-runtime.test.ts (94%) rename packages/{codingcode/test/client => sdk/test}/http/sessions.test.ts (84%) create mode 100644 packages/sdk/test/tsconfig.json rename packages/{infra => sdk}/tsconfig.json (63%) diff --git a/AGENTS.md b/AGENTS.md index 46574489..2df137bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,6 @@ Coding Code 是 AI 编程助手。 | 包 | 目录 | 职责 | |---|---|---| | `@codingcode/core` | `packages/codingcode` | 核心引擎:agent loop 与全部编排能力 | -| `@codingcode/infra` | `packages/infra` | 基础设施:配置加载、日志、禁用项存储 | | `@codingcode/desktop` | `packages/desktop` | 桌面端(Electron + React) | `packages/sdk`、`packages/web` 目前只有空 `src/`,尚无实现。 @@ -19,6 +18,7 @@ Coding Code 是 AI 编程助手。 - `agent`:本项目核心,手写 ReAct loop 与编排;不持有 Session、不感知传输协议 - `tools`:工具系统,`domains/` 下分 fs / bash / web / self / subagent 五个域 - `llm`:模型调用与 provider 适配 +- `infra`:应用配置(`config.yaml`)、模型清单(`models.json`)、YAML 存取、日志 - `mcp`:Model Context Protocol 集成 - `context`:上下文预算与压缩 - `memory`:跨会话长期记忆 diff --git a/CLAUDE.md b/CLAUDE.md index 46574489..2df137bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,6 @@ Coding Code 是 AI 编程助手。 | 包 | 目录 | 职责 | |---|---|---| | `@codingcode/core` | `packages/codingcode` | 核心引擎:agent loop 与全部编排能力 | -| `@codingcode/infra` | `packages/infra` | 基础设施:配置加载、日志、禁用项存储 | | `@codingcode/desktop` | `packages/desktop` | 桌面端(Electron + React) | `packages/sdk`、`packages/web` 目前只有空 `src/`,尚无实现。 @@ -19,6 +18,7 @@ Coding Code 是 AI 编程助手。 - `agent`:本项目核心,手写 ReAct loop 与编排;不持有 Session、不感知传输协议 - `tools`:工具系统,`domains/` 下分 fs / bash / web / self / subagent 五个域 - `llm`:模型调用与 provider 适配 +- `infra`:应用配置(`config.yaml`)、模型清单(`models.json`)、YAML 存取、日志 - `mcp`:Model Context Protocol 集成 - `context`:上下文预算与压缩 - `memory`:跨会话长期记忆 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77c94078..aa4764cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,8 @@ coding-code/ ├── packages/ │ ├── codingcode/src/ # @codingcode/core — 核心引擎 │ │ ├── agent/ # ReAct Loop(纯引擎,无副作用) -│ │ ├── llm/ # LLM 客户端工厂(多厂商) +│ │ ├── llm/ # LLM 调用通道(多厂商) +│ │ ├── infra/ # 应用配置、模型清单、YAML 存取、日志 │ │ ├── mcp/ # MCP 服务集成 │ │ ├── context/ # 上下文管理 + 自动压缩 │ │ ├── session/ # JSONL 会话持久化 @@ -46,10 +47,9 @@ coding-code/ │ │ ├── server/ # Hono HTTP 服务 + SSE │ │ ├── cli.ts # CLI 入口 │ │ └── layer.ts # Effect Layer 入口 -│ ├── desktop/ # @codingcode/desktop — Electron 桌面应用 -│ │ ├── electron/ # 主进程(IPC、文件服务、Git 服务) -│ │ └── src/ # React 前端(Agent UI、设置面板) -│ └── infra/src/ # @codingcode/infra — 基础设施 +│ └── desktop/ # @codingcode/desktop — Electron 桌面应用 +│ ├── electron/ # 主进程(IPC、文件服务、Git 服务) +│ └── src/ # React 前端(Agent UI、设置面板) ├── config/ # 模型配置 │ └── models.json # 模型/厂商目录 ├── docs/ # 项目文档 diff --git a/README.md b/README.md index 565d5cbe..61ce8e78 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ pnpm start ### SDK 调用示例 ```typescript -import { createHttpClients } from '@codingcode/core/client'; +import { createHttpClients } from '@codingcode/sdk'; const clients = createHttpClients('http://localhost:8080'); @@ -73,6 +73,7 @@ for await (const frame of clients.agent.sendMessage('帮我写一个快排', { ┌──────────────────────────────────────────────────────────┐ │ 客户端层 │ │ @codingcode/desktop (Electron) │ +│ @codingcode/sdk(AgentClient 契约 + HTTP/SSE 实现) │ └──────────────────────────┬───────────────────────────────┘ │ HTTP / SSE(AgentClient 接口) ┌──────────────────────────┴───────────────────────────────┐ @@ -80,12 +81,7 @@ for await (const frame of clients.agent.sendMessage('帮我写一个快排', { │ @codingcode/core │ │ ReAct Loop · 工具 · MCP · 上下文 · 记忆 · Checkpoint │ │ 钩子 · 子智能体 · 技能 · 审批 · 会话 · 调度 │ -└──────────────────────────┬───────────────────────────────┘ - │ -┌──────────────────────────┴───────────────────────────────┐ -│ 基础设施层 │ -│ @codingcode/infra │ -│ 配置加载 · 日志 · 共享类型 │ +│ 模型清单 · 应用配置 · YAML 存取 · 日志 · 共享类型 │ └──────────────────────────────────────────────────────────┘ ``` diff --git a/docs/tools.md b/docs/tools.md index 190c53c2..80187628 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -102,7 +102,7 @@ interface ToolVisibilityPolicy { | 层级 | 名称 | 逻辑 | |------|------|------| | 1 | **RuleEngine** | 规则引擎匹配,支持 glob 模式匹配工具名和参数,按优先级排序 | -| 2 | **PermissionMode** | 权限模式驱动的自动放行:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行,涵盖只读与编辑工具)、`default`(不自动放行,继续下一层)。只读工具不再有无条件的独立白名单层;`plan` Profile 由 `agent/profile.ts` 中的 `planProfileGateHook` 在下一层强制,不在此层处理 | +| 2 | **PermissionMode** | 权限模式驱动的自动放行:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行,涵盖只读与编辑工具)、`ask`(不自动放行,继续下一层)。只读工具不再有无条件的独立白名单层;`plan` Profile 由 `agent/profile.ts` 中的 `planProfileGateHook` 在下一层强制,不在此层处理 | | 3 | **HookPreToolUse** | 钩子决策,可返回 allow/deny/ask/continue,支持 `modifiedInput` 修改参数 | | 4 | **UserConfirmation** | 异步用户确认,支持 allow/deny/always/never 四种响应,always/never 会持久化为规则 | | 5 | **AuditLog** | 每一层决策后记录审计日志,通过 `tool.approval.post` 钩子发出 | @@ -126,17 +126,13 @@ interface ToolVisibilityPolicy { ### 权限模式 ```typescript -type PermissionMode = 'default' | 'acceptEdits' | 'bypass'; +type PermissionMode = 'ask' | 'acceptEdits' | 'bypass'; ``` -- `default`:不自动放行任何工具(含只读工具),全部逐层审批 +- `ask`:不自动放行任何工具(含只读工具),全部逐层审批 - `acceptEdits`:非破坏性工具自动放行(涵盖只读工具与编辑类工具),破坏性工具仍需确认 - `bypass`:全部放行,跳过所有审批(慎用) -> 原独立的 `ReadonlyWhitelist` 层(在 `default` 下也无条件放行只读工具)已废弃,其语义并入 `PermissionMode` 的自动放行判定:`acceptEdits` 视只读工具为非破坏性工具自动放行,`default` 不再自动放行。 - -> `plan` 不再是 `PermissionMode` 的成员。plan Profile 通过 `AgentProfile.name === 'plan'` 结构化识别,由 `agent/profile.ts` 的 `planProfileGateHook` 和 `PLAN_PROFILE_ALLOWED_TOOLS` 共同限制工具。因只读白名单层已删除,`dispatch_agent` 等在 plan 下不再被流水线上层提前放行,统一由 plan gate 拦截。 - ### OS 级沙箱(预留) `packages/codingcode/src/sandbox/` 目前是 stub 实现(`SandboxService` 为空类),尚未集成实际的沙箱运行时。审批流水线已提供基本安全保障,OS 级沙箱将在未来版本中实现。 diff --git a/package.json b/package.json index bdd6f9ca..501b2778 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "packageManager": "pnpm@11.3.0", "type": "module", "workspaces": [ - "packages/infra", "packages/codingcode", + "packages/sdk", "packages/desktop" ], "scripts": { diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index f79d9fac..24b05501 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -8,19 +8,21 @@ }, "exports": { ".": "./src/layer.ts", - "./client": "./src/client/http/index.ts", "./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", "./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", @@ -36,7 +38,9 @@ "cron": "^3.5.0", "effect": "^3.21.2", "hono": "^4.12.19", - "zod": "^4.4.3", - "@codingcode/infra": "workspace:*" + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", + "yaml": "^2.9.0", + "zod": "^4.4.3" } } diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 4669fc1e..f526cdd9 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -7,7 +7,7 @@ import { ApprovalService } from '../approval/port.js'; import { CheckpointService } from '../checkpoint/port.js'; import { ContextService } from '../context/port.js'; import { HookService } from '../hooks/port.js'; -import { LLMFactoryService } from '../llm/port.js'; +import { LLMService } from '../llm/port.js'; import { McpService } from '../mcp/port.js'; import { MemoryService } from '../memory/port.js'; import { RulesService } from '../rules/port.js'; @@ -20,8 +20,8 @@ import type { FrameBody, FrameError, ResponseMeta, ToolOutcome, Transition } fro import { isTurnEnd } from '../contracts/frame.js'; import type { ToolCatalog, ToolResult } from '../contracts/tool.js'; import type { ToolCall } from '../contracts/types.js'; -import { loadConfig } from '@codingcode/infra/config'; -import { createLogger } from '@codingcode/infra/logger'; +import { loadConfig } from '../infra/config.js'; +import { createLogger } from '../infra/logger.js'; import { normalizePath, computePaths } from '../core/path.js'; import { resolveProfile, getToolNames } from './profile.js'; @@ -49,7 +49,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const mcp = yield* McpService; const context = yield* ContextService; const memory = yield* MemoryService; - const llmFactory = yield* LLMFactoryService; + const llm = yield* LLMService; const rules = yield* RulesService; const todo = yield* TodoService; const toolEnvPort = yield* ToolEnvPort; @@ -57,45 +57,59 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { 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); - rules.evictProjectRules(normalizedCwd); + 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; - const llm = yield* llmFactory.getLLMClient(); + 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 model = opts.model ?? llm.modelInfo.model; - const created = yield* session.create(normalizedCwd, { - model, - activeProfile: opts.activeProfile, - permissionMode: opts.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 state = yield* session.load(normalizedCwd, sessionId); + const state = yield* session.load(normalizedCwd, sessionId, parentSessionId); - // restore session profile/permission from the frontend request, falling back to persisted values const profileName = opts.activeProfile ?? state.activeProfile; const effectivePerm = opts.permissionMode ?? state.permissionMode; if (opts.permissionMode) { - yield* session.setPermissionMode(normalizedCwd, sessionId, opts.permissionMode); + yield* session.setPermissionMode(normalizedCwd, sessionId, opts.permissionMode, parentSessionId); } if (opts.activeProfile) { - yield* session.setActiveProfile(normalizedCwd, sessionId, opts.activeProfile); + yield* session.setActiveProfile(normalizedCwd, sessionId, opts.activeProfile, parentSessionId); } - state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd); + state.memorySnapshot = yield* memory.loadMemoryForPrompt(state.cwd); const profile: AgentProfile | undefined = profileName ? resolveProfile(profileName) : undefined; @@ -112,11 +126,11 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { yield* checkpoint.snapshotBaseline(state.cwd, sessionId, turnId); // get rules text - const rulesText = rules.getAllRules(state.cwd); + const rulesText = yield* rules.getAllRules(state.cwd); // run agent loop const stream = runAgentLoop({ - state, llm, profile, catalog, + state, model, profile, catalog, systemPrompt: opts.systemPrompt, toolEnv, abortSignal: opts.signal, rulesText, sid: sessionId, projectPath: state.cwd, permissionMode: effectivePerm, @@ -126,16 +140,16 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { }); function runAgentLoop(opts: { - state: any; llm: any; profile: AgentProfile | undefined; + state: any; model: string; profile: AgentProfile | undefined; abortSignal: AbortSignal | undefined; catalog: ToolCatalog; toolEnv: ToolEnv; rulesText: string; sid: string; projectPath: string; permissionMode: PermissionMode; + systemPrompt?: string; }): AsyncGenerator { const q = Effect.runSync(Queue.unbounded()); - // agentLoopInternal 只经闭包引用服务,不消费任何 Tag;工具执行所需的服务由 toolEnv 注入 const program = agentLoopInternal(opts, q); return (async function* () { @@ -157,13 +171,14 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { } function agentLoopInternal(opts: { - state: any; llm: any; profile: AgentProfile | undefined; + state: any; model: string; profile: AgentProfile | undefined; abortSignal: AbortSignal | undefined; catalog: ToolCatalog; rulesText: string; sid: string; projectPath: string; permissionMode: PermissionMode; + systemPrompt?: string; }, q: Queue.Queue): Effect.Effect, AgentError> { - const { state, llm, profile, abortSignal, catalog, rulesText, sid, projectPath, permissionMode } = opts; + const { state, model, profile, abortSignal, catalog, rulesText, sid, projectPath, permissionMode } = opts; const { tools, lookup: toolLookup } = catalog; let ended = false; @@ -180,7 +195,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { platform: process.platform, shell: process.env.SHELL || process.env.ComSpec || 'bash', rules: rulesText, - profileSystemPrompt: profile?.systemPrompt, + profileSystemPrompt: opts.systemPrompt ?? profile?.systemPrompt, }); const memoryBlock = state.memorySnapshot; @@ -204,10 +219,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const transcriptPath = computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath; - const willCompact = yield* Effect.either(Effect.tryPromise({ - try: () => context.willCompact(transcriptPath, llm.modelInfo.maxTokens), - catch: (e) => new AgentError('LLM_FAILED', String(e)), - })); + const willCompact = yield* Effect.either(context.willCompact(transcriptPath, model)); if (Either.isLeft(willCompact)) { yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(willCompact.left) }); yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error', projectPath }); @@ -217,10 +229,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { yield* q.offer({ family: 'transition', transition: { to: 'compress' } }); } - const assembled = yield* Effect.either(Effect.tryPromise({ - try: () => context.assemblePayload(transcriptPath, llm.modelInfo.maxTokens, llm), - catch: (e) => new AgentError('LLM_FAILED', String(e)), - })); + const assembled = yield* Effect.either(context.assemblePayload(transcriptPath, model)); if (Either.isLeft(assembled)) { yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(assembled.left) }); yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error', projectPath }); @@ -238,7 +247,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const streamed = yield* Effect.either(Effect.tryPromise({ try: async () => { - for await (const part of llm.completeStream({ messages: llmMessages, system, tools, maxSteps: 1 }, abortSignal)) { + 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; @@ -275,7 +284,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { 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 }); - memory.flushSessionToMemory(state.sessionId, llm, state.cwd).catch((e) => logger.error('memory flush failed:', e)); + yield* flushMemoryInBackground(state.sessionId, model, state.cwd); return Result.err(loopErr); } stopContinuations++; @@ -314,6 +323,8 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const approvedResults = approvedCalls.length > 0 ? yield* executor.executeBatch(approvedCalls, state.sessionId, { turnId: state.currentTurnId, projectPath, signal: abortSignal, toolLookup, + activeProfile: profile?.name, + model, }) : []; @@ -342,7 +353,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { } yield* checkpoint.snapshotFinal(projectPath, state.sessionId, state.currentTurnId); - memory.flushSessionToMemory(state.sessionId, llm, state.cwd).catch((e) => logger.error('memory flush failed:', e)); + yield* flushMemoryInBackground(state.sessionId, model, state.cwd); if (lastResult) return lastResult; @@ -366,7 +377,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { error: { message: 'agent terminated without end frame', code: 'AGENT_TERMINATED' }, }); yield* checkpoint.snapshotFinal(opts.projectPath, opts.sid, opts.state.currentTurnId).pipe(Effect.ignore); - memory.flushSessionToMemory(opts.state.sessionId, opts.llm, opts.projectPath).catch((e) => logger.error('memory flush failed:', e)); + yield* flushMemoryInBackground(opts.state.sessionId, opts.model, opts.projectPath); }) ) ); diff --git a/packages/codingcode/src/agent/port.ts b/packages/codingcode/src/agent/port.ts index 74fd6658..fbb5930d 100644 --- a/packages/codingcode/src/agent/port.ts +++ b/packages/codingcode/src/agent/port.ts @@ -10,8 +10,11 @@ export interface RunTurnOptions { cwd: string; signal?: AbortSignal; permissionMode?: PermissionMode; - model?: string; + model: string; activeProfile?: ProfileName; + parentSessionId?: string; + agentName?: string; + systemPrompt?: string; } export interface AgentShape { @@ -29,7 +32,6 @@ export interface AgentShape { export class AgentService extends Context.Tag('AgentService')() {} -// agent 自持的装配端口:工具执行期注入能力,不离开 agent/ export interface ToolEnv { provide(effect: Effect.Effect): Effect.Effect; } diff --git a/packages/codingcode/src/agent/profile.ts b/packages/codingcode/src/agent/profile.ts index 948a9dd8..e12e9b21 100644 --- a/packages/codingcode/src/agent/profile.ts +++ b/packages/codingcode/src/agent/profile.ts @@ -27,7 +27,7 @@ export const BUILD_PROMPT = `You are a coding assistant —an AI agent that help 7. For complex or broad tasks (understanding a whole module, cross-file analysis, comprehensive search): a. Briefly assess the task scope using your own reasoning —do not use tools for exploration at this stage, as that would consume your limited context window. b. If you can clearly handle it without extensive file reading or searching, proceed yourself. - c. Otherwise, delegate the discovery task with dispatch_agent when a runtime-configured subagent is available. + c. Otherwise delegate the exploration with dispatch_agent: give the subagent a short agentName and a self-contained prompt. The subagent shares your working directory, so keep the delegated write set disjoint from your own. ## Using your tools - **Prefer dedicated tools over shell commands.** Use read_file instead of cat, edit_file instead of sed, search_code instead of grep. Dedicated tools give the user better visibility into your work. @@ -142,15 +142,6 @@ export function resolveProfile(name: ProfileName): AgentProfile { return name === PLAN_PROFILE_NAME ? PLAN_PROFILE : BUILD_PROFILE; } -export function resolveSubagentProfile(name: string): AgentProfile | undefined { - return isAgentProfileName(name) ? resolveProfile(name) : undefined; -} - export function getToolNames(profile: AgentProfile | undefined): readonly string[] { return isPlanProfile(profile) ? PLAN_TOOL_NAMES : BUILD_TOOL_NAMES; } - -export const AVAILABLE_PROFILES: Array<{ name: ProfileName; description: string }> = [ - { name: PLAN_PROFILE_NAME, description: 'Planning agent' }, - { name: BUILD_PROFILE_NAME, description: 'Build agent' }, -]; diff --git a/packages/codingcode/src/approval/approval.ts b/packages/codingcode/src/approval/approval.ts index 44bd84d1..01eeccc1 100644 --- a/packages/codingcode/src/approval/approval.ts +++ b/packages/codingcode/src/approval/approval.ts @@ -60,7 +60,7 @@ function applyPermissionMode( } return null; - case 'default': + case 'ask': default: return null; } @@ -222,7 +222,7 @@ export const ApprovalLayer = Layer.effect(ApprovalService, Effect.gen(function* { ruleEngine, destructiveTools, - permissionMode: request.permissionMode ?? 'default', + permissionMode: request.permissionMode ?? 'ask', profile: request.profile, onAlways: (rule) => ruleEngine.addRule(rule), onNever: (rule) => ruleEngine.addRule(rule), diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index 1361e744..398ac9af 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -2,7 +2,7 @@ import { Effect } from 'effect'; import { serve } from '@hono/node-server'; import { createServer } from './server/index.js'; import { createAppRuntime } from './layer.js'; -import { loadConfig, ensureUserConfig } from '@codingcode/infra/config'; +import { loadConfig, ensureUserConfig } from './infra/config.js'; import { ensureTempCwd } from './core/path.js'; import { findAvailablePort } from './server/port-discovery.js'; import { AgentError } from './core/error.js'; diff --git a/packages/codingcode/src/client/http/index.ts b/packages/codingcode/src/client/http/index.ts deleted file mode 100644 index a3394fad..00000000 --- a/packages/codingcode/src/client/http/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createRequestHelpers } from './request.js'; -import { createHttpAgentClient } from './agent-runtime.js'; -import { createHttpSessionClient } from './sessions.js'; -import { createHttpModelClient } from './models.js'; -import { createHttpSettingsClient } from './settings.js'; -import type { - AgentRuntimeClient, - SessionClient, - ModelClient, - SettingsClient, -} from '../contracts.js'; - -export type { AgentRuntimeClient, SessionClient, ModelClient, SettingsClient }; - -export interface HttpClients { - agent: AgentRuntimeClient; - sessions: SessionClient; - models: ModelClient; - settings: SettingsClient; -} - -export function createHttpClients(baseUrl: string): HttpClients { - const request = createRequestHelpers(baseUrl); - return { - agent: createHttpAgentClient(baseUrl, request), - sessions: createHttpSessionClient(request), - models: createHttpModelClient(request), - settings: createHttpSettingsClient(request), - }; -} diff --git a/packages/codingcode/src/context/context.ts b/packages/codingcode/src/context/context.ts index 88ddea82..77fd881c 100644 --- a/packages/codingcode/src/context/context.ts +++ b/packages/codingcode/src/context/context.ts @@ -1,15 +1,14 @@ import { Layer, Effect } from 'effect'; import { randomUUID } from 'crypto'; -import { readFileSync, existsSync } from 'fs'; -import { loadConfig } from '@codingcode/infra/config'; +import { loadConfig } from '../infra/config.js'; import type { Message } from '../contracts/types.js'; import { SessionService } from '../session/port.js'; import { estimateTokens, estimateMessageTokens } from './tokens.js'; -import { resolveLLM } from '../llm/llm-resolver.js'; -import { LLMFactoryService } from '../llm/port.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 } from '../contracts/session.js'; -import type { LLMClient } from '../contracts/provider.js'; +import { AgentError } from '../core/error.js'; import { ContextService } from './port.js'; import type { CompressResult } from './port.js'; @@ -30,8 +29,6 @@ const COMPACTION_THRESHOLD = 0.85; const KEEP_RECENT_TURNS = 1; const MAX_AUTO_COMPACT_PASSES = 3; -// --- Internal: visibility computation for LLM context --- - function applyVisibilityEvents(events: SessionEvent[]): { hiddenTurnIds: Set; hiddenOpUuids: Set; @@ -69,7 +66,6 @@ function applyVisibilityEvents(events: SessionEvent[]): { return { hiddenTurnIds, hiddenOpUuids, compactedTurnIds }; } -/** Filter events for LLM context building: hide summary-covered turns, apply rollback */ export function filterForContext(events: SessionEvent[]): { visible: SessionEvent[]; compactedTurnIds: Set; @@ -86,7 +82,6 @@ export function filterForContext(events: SessionEvent[]): { return { visible, compactedTurnIds }; } -/** Format filtered events as LLM messages, with micro-compaction for compacted turns */ export function buildContextMessages( events: SessionEvent[], compactedTurnIds?: Set @@ -174,7 +169,6 @@ export function buildContextMessages( return filtered; } -/** Estimate prompt tokens for a filtered event stream */ export function estimatePromptTokensFrom(events: SessionEvent[]): number { const { visible, compactedTurnIds } = filterForContext(events); return estimateTokens(buildContextMessages(visible, compactedTurnIds)); @@ -189,178 +183,148 @@ interface PayloadState { export const ContextLayer = Layer.effect(ContextService, Effect.gen(function* () { const session = yield* SessionService; - const factory = yield* LLMFactoryService; - - const readState = (transcriptPath: string): PayloadState => { - const jsonlPath = transcriptPath; - let currentTurnId = 0; - const idxPath = transcriptPath.replace('.jsonl', '.index.json'); - if (existsSync(idxPath)) { - try { - const idx = JSON.parse(readFileSync(idxPath, 'utf8')); - currentTurnId = idx?.currentTurnId ?? 0; - } catch {} - } - const { visible, compactedTurnIds } = filterForContext(session.readEvents(jsonlPath)); - return { jsonlPath, currentTurnId, visible, compactedTurnIds }; - }; + const llm = yield* LLMService; + + const readState = (transcriptPath: string): Effect.Effect => + Effect.gen(function* () { + const jsonlPath = transcriptPath; + const events = yield* session.readEvents(jsonlPath); + let currentTurnId = 0; + for (const ev of events) { + if ('turnId' in ev && typeof ev.turnId === 'number' && ev.turnId > currentTurnId) { + currentTurnId = ev.turnId; + } + } + const { visible, compactedTurnIds } = filterForContext(events); + return { jsonlPath, currentTurnId, visible, compactedTurnIds }; + }); const estimateFor = (s: PayloadState): number => estimateTokens(buildContextMessages(s.visible, s.compactedTurnIds)); - // 微压缩:确定性截断旧 turn 的长工具输出;是否需要压缩由本模块内部判断 - function runMicroCompact(s: PayloadState, contextWindow: number): PayloadState { - if (estimateFor(s) <= contextWindow * MICRO_COMPACT_THRESHOLD) return s; - if (applyOldTurnCompact(s.visible, s.currentTurnId, s.jsonlPath)) { - return readState(s.jsonlPath); - } - return s; - } - - function applyOldTurnCompact( + const applyOldTurnCompact = ( events: SessionEvent[], currentTurnId: number, jsonlPath: string - ): boolean { - const compactedTurnIds = new Set(); - for (const ev of events) { - if (ev.type === 'compact') { - for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { - compactedTurnIds.add(t); + ): Effect.Effect => + Effect.gen(function* () { + const compactedTurnIds = new Set(); + for (const ev of events) { + if (ev.type === 'compact') { + for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { + compactedTurnIds.add(t); + } } } - } - const oldResults: ToolResultEvent[] = []; - for (const ev of events) { - if (ev.type !== 'tool_result') continue; - if (ev.turnId >= currentTurnId - 1) continue; - if (compactedTurnIds.has(ev.turnId)) continue; - if (!COMPACTABLE_TOOLS.has(ev.toolName.toLowerCase())) continue; - if (ev.output.length <= MICRO_COMPACT_MIN_CHARS) continue; - oldResults.push(ev); - } - - if (oldResults.length === 0) return false; - - const turnIds = [...new Set(oldResults.map((ev) => ev.turnId))].sort((a, b) => a - b); - const startTurnId = turnIds[0]!; - const endTurnId = turnIds[turnIds.length - 1]!; + const oldResults: ToolResultEvent[] = []; + for (const ev of events) { + if (ev.type !== 'tool_result') continue; + if (ev.turnId >= currentTurnId - 1) continue; + if (compactedTurnIds.has(ev.turnId)) continue; + if (!COMPACTABLE_TOOLS.has(ev.toolName.toLowerCase())) continue; + if (ev.output.length <= MICRO_COMPACT_MIN_CHARS) continue; + oldResults.push(ev); + } - const compactEvent: CompactEvent = { - type: 'compact', - uuid: randomUUID(), - startTurnId, - endTurnId, - }; - session.appendEvent(jsonlPath, compactEvent); - return true; - } + if (oldResults.length === 0) return false; - // LLM 摘要压缩(老 turn → summary 事件),失败返回 0 释放量 - async function tryCompaction(s: PayloadState, llm: LLMClient | null): Promise { - const endTurn = s.currentTurnId - KEEP_RECENT_TURNS - 1; - if (endTurn < 1) return 0; + const turnIds = [...new Set(oldResults.map((ev) => ev.turnId))].sort((a, b) => a - b); + const startTurnId = turnIds[0]!; + const endTurnId = turnIds[turnIds.length - 1]!; - const inRange = s.visible.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; - return false; + const compactEvent: CompactEvent = { + type: 'compact', + uuid: randomUUID(), + startTurnId, + endTurnId, + }; + yield* session.appendEvent(jsonlPath, compactEvent); + return true; }); - if (inRange.length === 0) return 0; - const targetEvents = getIncrementalEvents(inRange); - if (targetEvents.length === 0) return 0; + const runMicroCompact = ( + s: PayloadState, + contextWindow: number + ): Effect.Effect => + Effect.gen(function* () { + if (estimateFor(s) <= contextWindow * MICRO_COMPACT_THRESHOLD) return s; + const applied = yield* applyOldTurnCompact(s.visible, s.currentTurnId, s.jsonlPath); + if (applied) { + return yield* readState(s.jsonlPath); + } + return s; + }); - const msgs = buildContextMessages(targetEvents, s.compactedTurnIds); - const totalTokens = estimateTokens(msgs); + const tryCompaction = ( + s: PayloadState, + model: string + ): Effect.Effect => + Effect.gen(function* () { + const endTurn = s.currentTurnId - KEEP_RECENT_TURNS - 1; + if (endTurn < 1) return 0; + + const inRange = s.visible.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; + return false; + }); + if (inRange.length === 0) return 0; - let compactionLlm = await Effect.runPromise( - resolveLLM(loadConfig().context.compactionModel, llm).pipe( - Effect.provideService(LLMFactoryService, factory) - ) - ); - if (compactionLlm && compactionLlm.modelInfo.maxTokens < totalTokens + 25000) { - compactionLlm = llm; - } + const targetEvents = getIncrementalEvents(inRange); + if (targetEvents.length === 0) return 0; - const summary = await callLLMForCompaction(msgs, compactionLlm); - if (!summary) return 0; - - const turnIds = targetEvents - .filter((e) => 'turnId' in e) - .map((e) => (e as any).turnId as number); - const startTurnId = Math.min(...turnIds); - const endTurnId = Math.max(...turnIds); - - const summaryEvent: SummaryEvent = { - type: 'summary', - uuid: randomUUID(), - startTurnId, - endTurnId, - summaryText: summary, - }; - session.appendEvent(s.jsonlPath, summaryEvent); - - const summaryMsg: Message = { role: 'system', name: 'compacted_history', content: summary }; - return Math.max(0, totalTokens - estimateMessageTokens(summaryMsg)); - } + const msgs = buildContextMessages(targetEvents, s.compactedTurnIds); + const totalTokens = estimateTokens(msgs); - function needsCompaction(s: PayloadState, contextWindow: number): boolean { - return estimateFor(s) > contextWindow * COMPACTION_THRESHOLD; - } + const configured = loadConfig().context.compactionModel?.trim(); + let compactionModel = configured || model; + if (contextWindowOf(compactionModel) < totalTokens + 25000) { + compactionModel = model; + } - async function summarizeToFit( - s: PayloadState, - contextWindow: number, - llm: LLMClient | null - ): Promise<{ state: PayloadState; released: number }> { - let cur = s; - let releasedTotal = 0; - for (let i = 0; i < MAX_AUTO_COMPACT_PASSES; i++) { - if (!needsCompaction(cur, contextWindow)) break; - const released = await tryCompaction(cur, llm); - if (released <= 0) break; - releasedTotal += released; - cur = readState(cur.jsonlPath); - } - return { state: cur, released: releasedTotal }; - } + const summary = yield* callLLMForCompaction(msgs, compactionModel); + if (!summary) return 0; + + const turnIds = targetEvents + .filter((e) => 'turnId' in e) + .map((e) => (e as any).turnId as number); + const startTurnId = Math.min(...turnIds); + const endTurnId = Math.max(...turnIds); + + const summaryEvent: SummaryEvent = { + type: 'summary', + uuid: randomUUID(), + startTurnId, + endTurnId, + summaryText: summary, + }; + yield* session.appendEvent(s.jsonlPath, summaryEvent); + + const summaryMsg: Message = { role: 'system', name: 'compacted_history', content: summary }; + return Math.max(0, totalTokens - estimateMessageTokens(summaryMsg)); + }); - const willCompact = async ( - transcriptPath: string, - contextWindow: number - ): Promise => { - const s = runMicroCompact(readState(transcriptPath), contextWindow); - return needsCompaction(s, contextWindow); - }; + const needsCompaction = (s: PayloadState, contextWindow: number): boolean => + estimateFor(s) > contextWindow * COMPACTION_THRESHOLD; - const assemblePayload = async ( - transcriptPath: string, + const summarizeToFit = ( + s: PayloadState, contextWindow: number, - llm: LLMClient | null - ): Promise => { - let s = readState(transcriptPath); - s = runMicroCompact(s, contextWindow); - const { state } = await summarizeToFit(s, contextWindow, llm); - return buildContextMessages(state.visible, state.compactedTurnIds); - }; - - const compactWithLLM = async ( - transcriptPath: string, - modelMaxTokens: number, - llm: LLMClient | null, - usage?: number - ): Promise => { - let s = runMicroCompact(readState(transcriptPath), modelMaxTokens); - const preEstimate = usage ?? estimateFor(s); - const released = await tryCompaction(s, llm); - if (released <= 0) { - return { didCompress: false, released: 0, promptEstimate: preEstimate }; - } - s = readState(transcriptPath); - return { didCompress: true, released, promptEstimate: estimateFor(s) }; - }; + model: string + ): Effect.Effect<{ state: PayloadState; released: number }, AgentError> => + Effect.gen(function* () { + let cur = s; + let releasedTotal = 0; + for (let i = 0; i < MAX_AUTO_COMPACT_PASSES; i++) { + if (!needsCompaction(cur, contextWindow)) break; + const released = yield* tryCompaction(cur, model); + if (released <= 0) break; + releasedTotal += released; + cur = yield* readState(cur.jsonlPath); + } + return { state: cur, released: releasedTotal }; + }); function getIncrementalEvents(inRange: SessionEvent[]): SessionEvent[] { const existingSummary = [...inRange] @@ -373,47 +337,76 @@ export const ContextLayer = Layer.effect(ContextService, Effect.gen(function* () return inRange.filter((e) => 'turnId' in e && (e as any).turnId > lastTurn); } - async function callLLMForCompaction( + const callLLMForCompaction = ( transcript: Message[], - fallbackLlm: LLMClient | null - ): Promise { - const llm = await Effect.runPromise( - resolveLLM(loadConfig().context.compactionModel, fallbackLlm).pipe( - Effect.provideService(LLMFactoryService, factory) - ) - ); - if (!llm) return null; - - const transcriptText = transcript - .map( - (m) => - `[${m.role}${(m as any).tool_name ? ':' + (m as any).tool_name : ''}]\n${m.content}` - ) - .join('\n\n'); - - const system = COMPACTION_SYSTEM_PROMPT; - - const userMsg: Message = { - role: 'user', - content: `Compact the following conversation transcript into the sections above:\n\n${transcriptText}`, - }; - - try { - const result = await Effect.runPromise( - llm.complete({ messages: [userMsg], system }).pipe(Effect.either) - ); + model: string + ): Effect.Effect => + Effect.gen(function* () { + const transcriptText = transcript + .map( + (m) => + `[${m.role}${(m as any).tool_name ? ':' + (m as any).tool_name : ''}]\n${m.content}` + ) + .join('\n\n'); + + const system = COMPACTION_SYSTEM_PROMPT; + + const userMsg: Message = { + role: 'user', + content: `Compact the following conversation transcript into the sections above:\n\n${transcriptText}`, + }; + + const result = yield* llm + .complete({ messages: [userMsg], system }, model) + .pipe(Effect.either); if (result._tag === 'Left') return null; return extractSummary(result.right.content.trim()); - } catch { - return null; - } - } + }).pipe(Effect.catchAllCause(() => Effect.succeed(null))); function extractSummary(raw: string): string { const m = raw.match(/([\s\S]*?)<\/summary>/); return (m?.[1] ?? raw).trim(); } + const willCompact = ( + transcriptPath: string, + model: string + ): Effect.Effect => + Effect.gen(function* () { + const contextWindow = contextWindowOf(model); + const s = yield* runMicroCompact(yield* readState(transcriptPath), contextWindow); + return needsCompaction(s, contextWindow); + }); + + const assemblePayload = ( + transcriptPath: string, + model: string + ): Effect.Effect => + Effect.gen(function* () { + const contextWindow = contextWindowOf(model); + let s = yield* readState(transcriptPath); + s = yield* runMicroCompact(s, contextWindow); + const { state } = yield* summarizeToFit(s, contextWindow, model); + return buildContextMessages(state.visible, state.compactedTurnIds); + }); + + const compactWithLLM = ( + transcriptPath: string, + model: string, + usage?: number + ): Effect.Effect => + Effect.gen(function* () { + const contextWindow = contextWindowOf(model); + let s = yield* runMicroCompact(yield* readState(transcriptPath), contextWindow); + const preEstimate = usage ?? estimateFor(s); + const released = yield* tryCompaction(s, model); + if (released <= 0) { + return { didCompress: false, released: 0, promptEstimate: preEstimate }; + } + s = yield* readState(transcriptPath); + return { didCompress: true, released, promptEstimate: estimateFor(s) }; + }); + return { willCompact, assemblePayload, diff --git a/packages/codingcode/src/context/port.ts b/packages/codingcode/src/context/port.ts index d42168a4..34013d60 100644 --- a/packages/codingcode/src/context/port.ts +++ b/packages/codingcode/src/context/port.ts @@ -1,6 +1,7 @@ import { Context } from 'effect'; +import type { Effect } from 'effect'; import type { Message } from '../contracts/types.js'; -import type { LLMClient } from '../contracts/provider.js'; +import type { AgentError } from '../core/error.js'; export interface CompressResult { didCompress: boolean; @@ -9,9 +10,9 @@ export interface CompressResult { } export interface ContextShape { - willCompact(transcriptPath: string, contextWindow: number): Promise; - assemblePayload(transcriptPath: string, contextWindow: number, llm: LLMClient | null): Promise; - compactWithLLM(transcriptPath: string, modelMaxTokens: number, llm: LLMClient | null, usage?: number): Promise; + willCompact(transcriptPath: string, model: string): Effect.Effect; + assemblePayload(transcriptPath: string, model: string): Effect.Effect; + compactWithLLM(transcriptPath: string, model: string, usage?: number): Effect.Effect; } export class ContextService extends Context.Tag('Context')() {} diff --git a/packages/codingcode/src/scheduler/types.ts b/packages/codingcode/src/contracts/automation.ts similarity index 73% rename from packages/codingcode/src/scheduler/types.ts rename to packages/codingcode/src/contracts/automation.ts index cd56c5af..83ba0cd8 100644 --- a/packages/codingcode/src/scheduler/types.ts +++ b/packages/codingcode/src/contracts/automation.ts @@ -4,7 +4,7 @@ export interface Automation { description: string; cron: string; timezone: string; - sandbox: 'readonly' | 'workspace-write'; + sandbox: AutomationSandbox; enabled: boolean; projectCwd: string; runOnce: boolean; @@ -14,12 +14,14 @@ export interface Automation { lastSessionId: string | null; } +export type AutomationSandbox = 'readonly' | 'workspace-write'; + export interface CreateAutomationInput { name: string; description: string; cron: string; timezone?: string; - sandbox?: 'readonly' | 'workspace-write'; + sandbox?: AutomationSandbox; projectCwd: string; runOnce?: boolean; } @@ -29,7 +31,11 @@ export interface UpdateAutomationInput { description?: string; cron?: string; timezone?: string; - sandbox?: 'readonly' | 'workspace-write'; + sandbox?: AutomationSandbox; enabled?: boolean; runOnce?: boolean; } + +export interface RunAutomationResult { + sessionId: string; +} diff --git a/packages/codingcode/src/contracts/error.ts b/packages/codingcode/src/contracts/error.ts index 3dcf8109..0f435604 100644 --- a/packages/codingcode/src/contracts/error.ts +++ b/packages/codingcode/src/contracts/error.ts @@ -9,17 +9,6 @@ export class AlreadyExistsError extends Error { } } -export class ApiError extends Error { - constructor( - public readonly status: number, - public readonly path: string, - public readonly body?: { code: string; message: string } - ) { - super(body?.message ?? `HTTP ${status}: ${path}`); - this.name = 'ApiError'; - } -} - export class NotFoundError extends Error { readonly code = 'NOT_FOUND'; constructor(message: string) { diff --git a/packages/codingcode/src/contracts/frame-io.ts b/packages/codingcode/src/contracts/frame-io.ts index 891a1863..5d6783ba 100644 --- a/packages/codingcode/src/contracts/frame-io.ts +++ b/packages/codingcode/src/contracts/frame-io.ts @@ -1,4 +1,4 @@ -import type { EndReason, Frame, FrameBody, FrameError, ToolOutcome } from './frame.js'; +import type { Frame, FrameBody } from './frame.js'; export interface FrameAssembler { stamp(body: FrameBody): Frame; @@ -23,103 +23,3 @@ export function createFrameAssembler(opts: { readonly sessionId: string }): Fram export function encodeFrame(frame: Frame): string { return JSON.stringify(frame); } - -export type DecodeFailureReason = - | 'shape' - | 'unknown-family' - | 'unknown-transition' - | 'unknown-event'; - -export type DecodeResult = - | { readonly ok: true; readonly frame: Frame } - | { readonly ok: false; readonly reason: DecodeFailureReason; readonly raw: unknown }; - -const TRANSITIONS = new Set(['start', 'executing', 'compress', 'end']); -const EVENTS = new Set(['text_delta', 'tool_call', 'tool_result', 'approval_request']); -const END_REASONS = new Set(['done', 'error', 'maxSteps', 'aborted']); -const OUTCOMES = new Set(['ok', 'error', 'denied']); - -function isRecord(v: unknown): v is Record { - return typeof v === 'object' && v !== null; -} - -function isError(v: unknown): v is FrameError { - return isRecord(v) && typeof v.message === 'string' && typeof v.code === 'string'; -} - -function isOutcome(v: unknown): v is ToolOutcome { - if (!isRecord(v) || typeof v.status !== 'string' || !OUTCOMES.has(v.status)) return false; - if (v.status === 'denied') return typeof v.reason === 'string'; - return typeof v.output === 'string'; -} - -export function decodeFrame(raw: unknown): DecodeResult { - if (!isRecord(raw)) return { ok: false, reason: 'shape', raw }; - if (typeof raw.sessionId !== 'string' || typeof raw.seq !== 'number') { - return { ok: false, reason: 'shape', raw }; - } - const turnId = raw.turnId; - if (turnId !== null && typeof turnId !== 'number') { - return { ok: false, reason: 'shape', raw }; - } - - const family = raw.family; - if (family === 'fatal') { - if (!isError(raw.fatal)) return { ok: false, reason: 'shape', raw }; - return { ok: true, frame: raw as unknown as Frame }; - } - - if (family === 'transition') { - const t = raw.transition; - if (!isRecord(t) || typeof t.to !== 'string' || !TRANSITIONS.has(t.to)) { - return { ok: false, reason: 'unknown-transition', raw }; - } - if (t.to === 'start' && typeof t.turnId !== 'number') { - return { ok: false, reason: 'shape', raw }; - } - if (t.to === 'end') { - if (typeof t.reason !== 'string' || !END_REASONS.has(t.reason as EndReason)) { - return { ok: false, reason: 'shape', raw }; - } - if (t.reason === 'error' && !isError(t.error)) { - return { ok: false, reason: 'shape', raw }; - } - } - if (t.to === 'executing') { - if (t.responded !== undefined && !isRecord(t.responded)) { - return { ok: false, reason: 'shape', raw }; - } - } - return { ok: true, frame: raw as unknown as Frame }; - } - - if (family === 'event') { - const e = raw.event; - if (!isRecord(e) || typeof e.type !== 'string' || !EVENTS.has(e.type)) { - return { ok: false, reason: 'unknown-event', raw }; - } - if (e.type === 'text_delta' && typeof e.text !== 'string') { - return { ok: false, reason: 'shape', raw }; - } - if ( - (e.type === 'tool_call' || e.type === 'approval_request') && - (typeof e.id !== 'string' || !isRecord(e.args)) - ) { - return { ok: false, reason: 'shape', raw }; - } - if (e.type === 'tool_call' && typeof e.name !== 'string') { - return { ok: false, reason: 'shape', raw }; - } - if (e.type === 'tool_result') { - if (typeof e.id !== 'string' || typeof e.name !== 'string' || !isOutcome(e.outcome)) { - return { ok: false, reason: 'shape', raw }; - } - } - if (e.type === 'approval_request' && typeof e.tool !== 'string') { - return { ok: false, reason: 'shape', raw }; - } - return { ok: true, frame: raw as unknown as Frame }; - } - - return { ok: false, reason: 'unknown-family', raw }; -} diff --git a/packages/codingcode/src/contracts/mcp.ts b/packages/codingcode/src/contracts/mcp.ts index b9411fc4..cf06188d 100644 --- a/packages/codingcode/src/contracts/mcp.ts +++ b/packages/codingcode/src/contracts/mcp.ts @@ -21,12 +21,6 @@ export interface McpServerConfig { autoReconnect?: boolean; } -export interface McpStatus { - name: string; - toolCount: number; - transport: 'stdio' | 'http'; -} - /** MCP 远端工具的纯数据描述:zod schema 与 SDK client 等机制形状由实现层持有 */ export interface McpToolSpec { server: string; diff --git a/packages/codingcode/src/contracts/permission.ts b/packages/codingcode/src/contracts/permission.ts index 612b24d9..934c2780 100644 --- a/packages/codingcode/src/contracts/permission.ts +++ b/packages/codingcode/src/contracts/permission.ts @@ -1,5 +1,5 @@ export const PERMISSION_MODES = [ - 'default', + 'ask', 'acceptEdits', 'bypass', ] as const; diff --git a/packages/codingcode/src/contracts/profile.ts b/packages/codingcode/src/contracts/profile.ts new file mode 100644 index 00000000..066337b5 --- /dev/null +++ b/packages/codingcode/src/contracts/profile.ts @@ -0,0 +1,11 @@ +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/session.ts b/packages/codingcode/src/contracts/session.ts index 4bed2181..16b9fcd4 100644 --- a/packages/codingcode/src/contracts/session.ts +++ b/packages/codingcode/src/contracts/session.ts @@ -6,6 +6,8 @@ export interface SessionMetaEvent { sessionId: string; cwd: string; createdAt: string; + model: string; + title: string; activeProfile: ProfileName; permissionMode: PermissionMode; parentSessionId?: string; @@ -65,35 +67,15 @@ export type SessionEvent = | RollbackEvent | CompactEvent; -export interface SessionIndex { - sessionId: string; - cwd: string; - model: string; - createdAt: string; +export interface SessionSummary extends SessionMetaEvent { updatedAt: string; - messageCount: number; - title: string; - currentTurnId: number; - usage: TokenUsage | undefined; - activeProfile: ProfileName; - permissionMode: PermissionMode; - memorySnapshot?: string; - parentSessionId?: string; + usage?: TokenUsage; } -export interface SessionStoreState { - sessionId: string; - cwd: string; - messageCount: number; - sessionMeta: SessionMetaEvent | null; - model: string; - activeProfile: ProfileName; - permissionMode: PermissionMode; - title: string; +export interface SessionStoreState extends SessionMetaEvent { currentTurnId: number; - usage: TokenUsage | undefined; memorySnapshot: string; - parentSessionId?: string; + usage: TokenUsage | undefined; } export type UITurnItem = @@ -135,6 +117,7 @@ export interface UITurn { export interface SessionCreateOptions { model: string; + title?: string; activeProfile: ProfileName; permissionMode: PermissionMode; } diff --git a/packages/codingcode/src/contracts/tool.ts b/packages/codingcode/src/contracts/tool.ts index 7d2746de..582c0e9b 100644 --- a/packages/codingcode/src/contracts/tool.ts +++ b/packages/codingcode/src/contracts/tool.ts @@ -1,19 +1,20 @@ import type { Effect } from 'effect'; import type { AgentError } from '../core/error.js'; import type { ToolOutcome } from './frame.js'; -import type { ToolDescription } from './types.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; @@ -21,7 +22,6 @@ export interface ToolRunner { export type ToolLookup = (name: string) => ToolRunner | undefined; -// 一次装配出的工具集:tools 为纯描述喂模型,lookup 供执行器解析 export interface ToolCatalog { tools: ToolDescription[]; lookup: ToolLookup; diff --git a/packages/codingcode/src/core/path.ts b/packages/codingcode/src/core/path.ts index 2d5a3615..abdf2a5d 100644 --- a/packages/codingcode/src/core/path.ts +++ b/packages/codingcode/src/core/path.ts @@ -8,6 +8,14 @@ export function getGlobalDir(): string { return join(homedir(), CODINGCODE_DIRNAME); } +export function getProjectRoot(): string { + return process.env.CODINGCODE_PROJECT_ROOT ?? process.cwd(); +} + +export function getModelsFile(): string { + return join(getProjectRoot(), 'config', 'models.json'); +} + export function getProjectDir(projectRoot: string): string { return join(projectRoot, CODINGCODE_DIRNAME); } @@ -44,7 +52,6 @@ export function ensureTempCwd(): void { mkdirSync(getTempCwd(), { recursive: true }); } -/** 请求级 cwd:请求没带 cwd 时落到共用的临时工作目录。 */ export function resolveCwd(cwd?: string): string { return cwd ? resolve(cwd) : getTempCwd(); } @@ -54,7 +61,6 @@ export interface SessionPaths { cwd: string; projectPath: string; transcriptPath: string; - indexPath: string; } export function projectSessionsDir(encodedProjectPath: string): string { @@ -72,6 +78,5 @@ export function computePaths( const transcriptPath = parentSessionId ? join(sessionsDir, parentSessionId, 'subagents', `${sessionId}.jsonl`) : join(sessionsDir, `${sessionId}.jsonl`); - const indexPath = transcriptPath.replace('.jsonl', '.index.json'); - return { sessionId, cwd: normalizedCwd, projectPath, transcriptPath, indexPath }; + return { sessionId, cwd: normalizedCwd, projectPath, transcriptPath }; } diff --git a/packages/codingcode/src/hooks/config.ts b/packages/codingcode/src/hooks/config.ts index c9d57cf8..89d7c938 100644 --- a/packages/codingcode/src/hooks/config.ts +++ b/packages/codingcode/src/hooks/config.ts @@ -4,7 +4,7 @@ import { readNamedList, writeNamedList, type NamedListFile, -} from '@codingcode/infra/yaml-store'; +} from '../infra/yaml-store.js'; import { getGlobalDir, getProjectDir } from '../core/path.js'; import type { UserHookConfig } from '../contracts/hooks.js'; diff --git a/packages/codingcode/src/hooks/hooks.ts b/packages/codingcode/src/hooks/hooks.ts index 20332845..7d2ccba7 100644 --- a/packages/codingcode/src/hooks/hooks.ts +++ b/packages/codingcode/src/hooks/hooks.ts @@ -1,7 +1,7 @@ import { Layer, Effect } from 'effect'; import { resolveHookConfigs } from './config.js'; import { executeHookCommand, executeDecisionHookCommand } from './executor.js'; -import { createLogger } from '@codingcode/infra/logger'; +import { createLogger } from '../infra/logger.js'; import { HookService } from './port.js'; import type { HookPoint, HookDecision } from '../contracts/hooks.js'; import type { ObserverHandler, DecisionHandler, HandlerEntry, ProjectPath } from './types.js'; diff --git a/packages/infra/src/config.ts b/packages/codingcode/src/infra/config.ts similarity index 86% rename from packages/infra/src/config.ts rename to packages/codingcode/src/infra/config.ts index 7d124d59..6409bce3 100644 --- a/packages/infra/src/config.ts +++ b/packages/codingcode/src/infra/config.ts @@ -1,20 +1,14 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import { getGlobalDir } from './paths.js'; +import { getGlobalDir } from '../core/path.js'; export interface ContextConfig { - /** Model for context compaction. Empty string falls back to main session LLM. - * Use full id format "model@API_KEY_ENV" to avoid ambiguity (e.g. "deepseek-chat@DEEPSEEK_API_KEY"). - * Can also use bare model id (e.g. "deepseek-chat") or display name, first match wins. */ compactionModel: string; } export interface MemoryConfig { enabled: boolean; - /** Model for memory operations. Empty string falls back to main session LLM. - * Use full id format "model@API_KEY_ENV" to avoid ambiguity (e.g. "deepseek-chat@DEEPSEEK_API_KEY"). - * Can also use bare model id (e.g. "deepseek-chat") or display name, first match wins. */ model: string; promptMaxBytes: number; } @@ -31,6 +25,8 @@ export interface AppConfig { maxSteps: number; maxStopContinuations: number; activeModel?: ActiveModelConfig; + activeProfile: string; + permissionMode: string; context: ContextConfig; memory: MemoryConfig; } @@ -51,6 +47,8 @@ export const DEFAULT_CONFIG: AppConfig = { }, maxSteps: 200, maxStopContinuations: 2, + activeProfile: 'build', + permissionMode: 'ask', context: DEFAULT_CONTEXT, memory: DEFAULT_MEMORY, }; @@ -91,6 +89,20 @@ export function updateActiveModel(model: string, apiKeyEnv: string, configPath?: writeConfig(p, existing); } +export function updateActiveProfile(activeProfile: string, configPath?: string): void { + const p = configPath ?? getUserConfigPath(); + const existing = readExistingConfig(p); + existing.activeProfile = activeProfile; + writeConfig(p, existing); +} + +export function updatePermissionMode(permissionMode: string, configPath?: string): void { + const p = configPath ?? getUserConfigPath(); + const existing = readExistingConfig(p); + existing.permissionMode = permissionMode; + writeConfig(p, existing); +} + export function updateMemoryEnabled(enabled: boolean, configPath?: string): void { const p = configPath ?? getUserConfigPath(); const existing = readExistingConfig(p); diff --git a/packages/infra/src/logger.ts b/packages/codingcode/src/infra/logger.ts similarity index 77% rename from packages/infra/src/logger.ts rename to packages/codingcode/src/infra/logger.ts index a5de68a5..1bb73610 100644 --- a/packages/infra/src/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 './paths.js'; +import { getGlobalDir } from '../core/path.js'; export type Logger = pino.Logger; @@ -13,8 +13,7 @@ export function createLogger(level = process.env.LOG_LEVEL ?? 'info'): Logger { const isDev = process.env.NODE_ENV !== 'production'; if (!isDev) { - // 生产模式:使用 pino.destination 同步写入文件,不依赖 worker 线程 - // 这样可以被 esbuild 打包为单文件,无需额外 node_modules + // 生产模式:同步写文件,不依赖 worker 线程,便于 esbuild 打成单文件 const logDir = join(getGlobalDir(), 'logs'); try { mkdirSync(logDir, { recursive: true }); diff --git a/packages/codingcode/src/infra/models.ts b/packages/codingcode/src/infra/models.ts new file mode 100644 index 00000000..cf9cfe9a --- /dev/null +++ b/packages/codingcode/src/infra/models.ts @@ -0,0 +1,110 @@ +import { existsSync, readFileSync } from 'fs'; +import { AgentError } from '../core/error.js'; +import { getModelsFile } from '../core/path.js'; +import type { SelectableModel } from '../contracts/provider.js'; +import { loadConfig, updateActiveModel } from './config.js'; + +export interface ModelDescriptor { + id: string; + name: string; + context_window?: number; +} + +export interface ProviderEntry { + name: string; + driver: string; + base_url: string; + api_key_env: string; + default_model: string; + models: ModelDescriptor[]; +} + +interface ProviderCatalog { + providers: ProviderEntry[]; +} + +const DEFAULT_CONTEXT_WINDOW = 128000; + +let cached: ProviderCatalog | null = null; + +function readCatalog(): ProviderCatalog | null { + if (cached) return cached; + const path = getModelsFile(); + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as ProviderCatalog; + if (!parsed.providers || parsed.providers.length === 0) return null; + cached = parsed; + return cached; + } catch { + return null; + } +} + +export function flattenModels(cat: ProviderCatalog): SelectableModel[] { + const result: SelectableModel[] = []; + for (const p of cat.providers) { + for (const m of p.models) { + result.push({ + id: `${m.id}@${p.name}`, + provider: p.name, + driver: p.driver, + name: m.name, + model: m.id, + base_url: p.base_url, + api_key_env: p.api_key_env, + context_window: m.context_window ?? DEFAULT_CONTEXT_WINDOW, + }); + } + } + return result; +} + +export function listModels(): SelectableModel[] { + const cat = readCatalog(); + return cat ? flattenModels(cat) : []; +} + +/** 精确复合 id 优先;裸名 / 显示名仅在唯一定位时使用(配置文件允许写裸名) */ +export function findModel(target: string): SelectableModel | null { + const models = listModels(); + const exact = models.find((m) => m.id === target); + if (exact) return exact; + return models.find((m) => m.model === target || m.name === target) ?? null; +} + +/** config.yaml 的 activeModel → 模型条目;未配置或匹配不上时 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 + ); +} + +export function activeModelId(): string { + return activeModel()?.id ?? ''; +} + +/** activeModel 取不到时的说明文字 */ +export function activeModelError(): string { + const cfg = loadConfig().activeModel; + if (!cfg) { + return 'No active model configured. Set activeModel in config.yaml with model and apiKeyEnv fields'; + } + return `Model "${cfg.model}" with apiKeyEnv "${cfg.apiKeyEnv}" not found in models.json`; +} + +export function contextWindowOf(model: string): number { + const target = model?.trim(); + const entry = target ? findModel(target) : activeModel(); + return entry?.context_window ?? DEFAULT_CONTEXT_WINDOW; +} + +export function setGlobalActive(model: string): void { + const found = findModel(model.trim()); + if (!found) { + throw new AgentError('CONFIG_INVALID', `Model "${model}" not found in models.json`); + } + updateActiveModel(found.model, found.api_key_env); +} diff --git a/packages/infra/src/yaml-store.ts b/packages/codingcode/src/infra/yaml-store.ts similarity index 100% rename from packages/infra/src/yaml-store.ts rename to packages/codingcode/src/infra/yaml-store.ts diff --git a/packages/codingcode/src/llm/llm-resolver.ts b/packages/codingcode/src/llm/llm-resolver.ts deleted file mode 100644 index 62778ed1..00000000 --- a/packages/codingcode/src/llm/llm-resolver.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Effect } from 'effect'; -import { AgentError } from '../core/error.js'; -import { LLMFactoryService } from './port.js'; -import type { LLMClient } from '../contracts/provider.js'; - -export function resolveLLM( - target: string | null | undefined, - fallback: LLMClient | null -): Effect.Effect { - const trimmed = target?.trim(); - if (!trimmed) return Effect.succeed(fallback); - return Effect.gen(function* () { - const factory = yield* LLMFactoryService; - const found = yield* factory.findModel(trimmed); - if (!found) return fallback; - const result = yield* factory.createClient(found).pipe(Effect.either); - return result._tag === 'Right' ? result.right : fallback; - }); -} diff --git a/packages/codingcode/src/llm/llm.ts b/packages/codingcode/src/llm/llm.ts index 52dcbb1f..1e1b8d30 100644 --- a/packages/codingcode/src/llm/llm.ts +++ b/packages/codingcode/src/llm/llm.ts @@ -1,270 +1,98 @@ -import { readFileSync, existsSync } from 'fs'; -import { resolve } from 'path'; -import { Layer, Effect } from 'effect'; +import { Effect, Layer } from 'effect'; import { AgentError } from '../core/error.js'; -import type { LLMClient, SelectableModel } from '../contracts/provider.js'; +import type { LLMClient, LLMRequest, LLMResponse, LLMStreamPart, SelectableModel } from '../contracts/provider.js'; import { OpenAIProvider } from './providers/openai.js'; import { DeepSeekProvider } from './providers/deepseek.js'; -import { loadConfig, updateActiveModel } from '@codingcode/infra/config'; -import { LLMFactoryService } from './port.js'; +import { activeModel, activeModelError, findModel } from '../infra/models.js'; +import { LLMService } from './port.js'; -export interface ModelDescriptor { - id: string; - name: string; - context_window?: number; -} - -export interface ProviderEntry { - name: string; - driver: string; - base_url: string; - api_key_env: string; - default_model: string; - models: ModelDescriptor[]; -} - -interface ProviderCatalog { - providers: ProviderEntry[]; -} - -interface ProviderCatalog { - providers: ProviderEntry[]; -} - -function flattenModels(cat: ProviderCatalog): SelectableModel[] { - const result: SelectableModel[] = []; - for (const p of cat.providers) { - for (const m of p.models) { - result.push({ - id: `${m.id}@${p.api_key_env}`, - provider: p.name, - driver: p.driver, - name: m.name, - model: m.id, - base_url: p.base_url, - api_key_env: p.api_key_env, - context_window: m.context_window ?? 128000, - }); +function entryFor(model: string): Effect.Effect { + const target = model?.trim() ?? ''; + if (target) { + const found = findModel(target); + if (!found) { + return Effect.fail( + new AgentError('CONFIG_INVALID', `Model "${target}" not found in models.json`) + ); } + return Effect.succeed(found); + } + const entry = activeModel(); + if (!entry) { + return Effect.fail(new AgentError('CONFIG_INVALID', activeModelError())); } - return result; + return Effect.succeed(entry); } -export const LlmLayer = Layer.effect(LLMFactoryService, Effect.gen(function* () { - let catalog: ProviderCatalog | null = null; - let currentEntry: SelectableModel | null = null; - let currentClient: LLMClient | null = null; - - function modelsFile(): string { - return resolve(process.cwd(), 'config/models.json'); +function clientFor(entry: SelectableModel): Effect.Effect { + return Effect.gen(function* () { + const apiKey = process.env[entry.api_key_env] || process.env.OPENAI_API_KEY || ''; + if (!apiKey) { + return yield* Effect.fail( + new AgentError( + 'CONFIG_MISSING', + `API key not found. Set environment variable "${entry.api_key_env}" or "OPENAI_API_KEY".`, + undefined, + { apiKeyEnv: entry.api_key_env } + ) + ); } - const loadCatalog = (): Effect.Effect => - Effect.gen(function* () { - if (catalog) return catalog; - const path = modelsFile(); - if (!existsSync(path)) { - return yield* Effect.fail(AgentError.configMissing(path)); - } - try { - const raw = readFileSync(path, 'utf-8'); - const parsed = JSON.parse(raw) as ProviderCatalog; - if (!parsed.providers || parsed.providers.length === 0) { - return yield* Effect.fail( - new AgentError('CONFIG_INVALID', 'models.json has no providers defined') - ); - } - catalog = parsed; - return catalog; - } catch (e) { - return yield* Effect.fail( - new AgentError('CONFIG_INVALID', `Failed to parse models.json: ${e}`) - ); - } - }); - - return { - listModels: (): Effect.Effect => - Effect.gen(function* () { - const cat = yield* loadCatalog(); - return flattenModels(cat); - }), - - findModel: (target: string): Effect.Effect => - Effect.gen(function* () { - const cat = yield* loadCatalog().pipe(Effect.either); - if (cat._tag === 'Left') return null; - const models = flattenModels(cat.right); - const exactMatch = models.find((m) => m.id === target); - if (exactMatch) return exactMatch; - return models.find((m) => m.model === target || m.name === target) || null; - }), - - getActiveEntry: (): Effect.Effect => - Effect.gen(function* () { - if (currentEntry) return currentEntry; - const cfg = loadConfig().activeModel; - if (!cfg) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - 'No active model configured. Set activeModel in config.yaml with model and apiKeyEnv fields' - ) - ); - } - const cat = yield* loadCatalog(); - const found = flattenModels(cat).find( - (m) => m.model === cfg.model && m.api_key_env === cfg.apiKeyEnv - ); - if (!found) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - `Model "${cfg.model}" with apiKeyEnv "${cfg.apiKeyEnv}" not found in models.json` - ) - ); - } - currentEntry = found; - return currentEntry; - }), - - switchModel: (id: string): Effect.Effect => - Effect.gen(function* () { - const cat = yield* loadCatalog(); - const all = flattenModels(cat); - const found = all.find((m) => m.id === id); - if (!found) - return yield* Effect.fail( - new AgentError('CONFIG_INVALID', `Model "${id}" not found. Use /model to list.`) - ); - currentEntry = found; - currentClient = null; - updateActiveModel(found.model, found.api_key_env); - return found; - }), + switch (entry.driver) { + case 'openai': { + const { createOpenAI } = yield* Effect.tryPromise({ + try: () => import('@ai-sdk/openai'), + catch: (e) => new AgentError('CONFIG_INVALID', `Failed to import openai driver: ${e}`), + }); + const provider = createOpenAI({ + name: entry.provider, + baseURL: entry.base_url, + apiKey, + }); + return new OpenAIProvider(provider.chat(entry.model), entry); + } + case 'deepseek': { + const { createDeepSeek } = yield* Effect.tryPromise({ + try: () => import('@ai-sdk/deepseek'), + catch: (e) => new AgentError('CONFIG_INVALID', `Failed to import deepseek driver: ${e}`), + }); + const deepseek = createDeepSeek({ + baseURL: entry.base_url, + apiKey, + }); + return new DeepSeekProvider(deepseek(entry.model), entry); + } + default: + return yield* Effect.fail( + new AgentError( + 'CONFIG_INVALID', + `Unknown driver "${entry.driver}" for provider "${entry.provider}"` + ) + ); + } + }); +} - createClient: (entry: SelectableModel): Effect.Effect => - Effect.gen(function* () { - const apiKey = process.env[entry.api_key_env] || process.env.OPENAI_API_KEY || ''; - if (!apiKey) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_MISSING', - `API key not found. Set environment variable "${entry.api_key_env}" or "OPENAI_API_KEY".`, - undefined, - { apiKeyEnv: entry.api_key_env } - ) - ); - } +async function runOrThrow(eff: Effect.Effect): Promise { + const result = await Effect.runPromise(Effect.either(eff)); + if (result._tag === 'Left') throw result.left; + return result.right; +} - switch (entry.driver) { - case 'openai': { - const { createOpenAI } = yield* Effect.tryPromise({ - try: () => import('@ai-sdk/openai'), - catch: (e) => - new AgentError('CONFIG_INVALID', `Failed to import openai driver: ${e}`), - }); - const provider = createOpenAI({ - name: entry.provider, - baseURL: entry.base_url, - apiKey, - }); - return new OpenAIProvider(provider.chat(entry.model), entry); - } - case 'deepseek': { - const { createDeepSeek } = yield* Effect.tryPromise({ - try: () => import('@ai-sdk/deepseek'), - catch: (e) => - new AgentError('CONFIG_INVALID', `Failed to import deepseek driver: ${e}`), - }); - const deepseek = createDeepSeek({ - baseURL: entry.base_url, - apiKey, - }); - return new DeepSeekProvider(deepseek(entry.model), entry); - } - default: - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - `Unknown driver "${entry.driver}" for provider "${entry.provider}"` - ) - ); - } - }), +export const LlmLayer = Layer.succeed(LLMService, { + complete(req: LLMRequest, model: string, signal?: AbortSignal): Effect.Effect { + return Effect.gen(function* () { + const entry = yield* entryFor(model); + const client = yield* clientFor(entry); + return yield* client.complete(req, signal); + }); + }, - getLLMClient: (): Effect.Effect => - Effect.gen(function* () { - if (currentClient) return currentClient; - const cfg = loadConfig().activeModel; - if (!cfg) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - 'No active model configured. Set activeModel in config.yaml with model and apiKeyEnv fields' - ) - ); - } - const cat = yield* loadCatalog(); - const found = flattenModels(cat).find( - (m) => m.model === cfg.model && m.api_key_env === cfg.apiKeyEnv - ); - if (!found) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - `Model "${cfg.model}" with apiKeyEnv "${cfg.apiKeyEnv}" not found in models.json` - ) - ); - } - currentEntry = found; - const apiKey = process.env[found.api_key_env] || process.env.OPENAI_API_KEY || ''; - if (!apiKey) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_MISSING', - `API key not found. Set environment variable "${found.api_key_env}" or "OPENAI_API_KEY".`, - undefined, - { apiKeyEnv: found.api_key_env } - ) - ); - } - let client: LLMClient; - switch (found.driver) { - case 'openai': { - const { createOpenAI } = yield* Effect.tryPromise({ - try: () => import('@ai-sdk/openai'), - catch: (e) => - new AgentError('CONFIG_INVALID', `Failed to import openai driver: ${e}`), - }); - const provider = createOpenAI({ - name: found.provider, - baseURL: found.base_url, - apiKey, - }); - client = new OpenAIProvider(provider.chat(found.model), found); - break; - } - case 'deepseek': { - const { createDeepSeek } = yield* Effect.tryPromise({ - try: () => import('@ai-sdk/deepseek'), - catch: (e) => - new AgentError('CONFIG_INVALID', `Failed to import deepseek driver: ${e}`), - }); - const deepseek = createDeepSeek({ baseURL: found.base_url, apiKey }); - client = new DeepSeekProvider(deepseek(found.model), found); - break; - } - default: - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - `Unknown driver "${found.driver}" for provider "${found.provider}"` - ) - ); - } - currentClient = client; - return currentClient; - }), - }; -})); + completeStream(req: LLMRequest, model: string, signal?: AbortSignal): AsyncIterable { + return (async function* () { + const entry = await runOrThrow(entryFor(model)); + const client = await runOrThrow(clientFor(entry)); + yield* client.completeStream(req, signal); + })(); + }, +}); diff --git a/packages/codingcode/src/llm/port.ts b/packages/codingcode/src/llm/port.ts index 44a3d35c..9120e986 100644 --- a/packages/codingcode/src/llm/port.ts +++ b/packages/codingcode/src/llm/port.ts @@ -1,15 +1,11 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; import type { AgentError } from '../core/error.js'; -import type { LLMClient, SelectableModel } from '../contracts/provider.js'; +import type { LLMRequest, LLMResponse, LLMStreamPart } from '../contracts/provider.js'; -export interface LLMFactoryShape { - listModels(): Effect.Effect; - findModel(target: string): Effect.Effect; - getActiveEntry(): Effect.Effect; - switchModel(id: string): Effect.Effect; - createClient(entry: SelectableModel): Effect.Effect; - getLLMClient(): Effect.Effect; +export interface LLMShape { + complete(req: LLMRequest, model: string, signal?: AbortSignal): Effect.Effect; + completeStream(req: LLMRequest, model: string, signal?: AbortSignal): AsyncIterable; } -export class LLMFactoryService extends Context.Tag('LLMFactory')() {} +export class LLMService extends Context.Tag('LLM')() {} diff --git a/packages/codingcode/src/mcp/client.ts b/packages/codingcode/src/mcp/client.ts index 9024f17e..c0e3af15 100644 --- a/packages/codingcode/src/mcp/client.ts +++ b/packages/codingcode/src/mcp/client.ts @@ -44,10 +44,6 @@ export class McpClient { } } - get transportType(): 'stdio' | 'http' { - return this.config.command ? 'stdio' : 'http'; - } - async connect(): Promise { await this.client.connect(this.transport); this.reconnectAttempts = 0; diff --git a/packages/codingcode/src/mcp/config.ts b/packages/codingcode/src/mcp/config.ts index ccfe05d4..ad8a7a78 100644 --- a/packages/codingcode/src/mcp/config.ts +++ b/packages/codingcode/src/mcp/config.ts @@ -4,7 +4,7 @@ import { readNamedList, writeNamedList, type NamedListFile, -} from '@codingcode/infra/yaml-store'; +} from '../infra/yaml-store.js'; import { getGlobalDir, getProjectDir } from '../core/path.js'; import type { McpServerConfig } from '../contracts/mcp.js'; diff --git a/packages/codingcode/src/mcp/mcp.ts b/packages/codingcode/src/mcp/mcp.ts index e6dd7a42..27254441 100644 --- a/packages/codingcode/src/mcp/mcp.ts +++ b/packages/codingcode/src/mcp/mcp.ts @@ -2,13 +2,12 @@ import { Effect, Layer } from 'effect'; import { resolveMcpConfig } from './config.js'; import { McpClient } from './client.js'; import { McpService } from './port.js'; -import type { McpServerConfig, McpStatus, McpToolSpec } from '../contracts/mcp.js'; -import { createLogger } from '@codingcode/infra/logger'; +import type { McpServerConfig, McpToolSpec } from '../contracts/mcp.js'; +import { createLogger } from '../infra/logger.js'; import { AgentError } from '../core/error.js'; const logger = createLogger(); -/** 客户端 listTools() 的原始返回形状(SDK 类型在 client.ts 内部收口) */ interface McpRawTool { name: string; description: string; @@ -136,17 +135,6 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { } return specs; }), - - status: (projectPath: string): Effect.Effect => - Effect.sync(() => { - const projectClients = clientsByProject.get(projectPath); - if (!projectClients) return []; - return Array.from(projectClients.entries()).map(([name, entry]) => ({ - name, - toolCount: entry.rawTools.length, - transport: entry.client.transportType, - })); - }), }; } )); diff --git a/packages/codingcode/src/mcp/port.ts b/packages/codingcode/src/mcp/port.ts index d5b69a71..0a1059e4 100644 --- a/packages/codingcode/src/mcp/port.ts +++ b/packages/codingcode/src/mcp/port.ts @@ -1,11 +1,10 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { McpStatus, McpToolSpec } from '../contracts/mcp.js'; +import type { McpToolSpec } from '../contracts/mcp.js'; export interface McpShape { syncConnections(projectPath: string): Effect.Effect; listProjectMcpTools(projectPath: string): Effect.Effect; - status(projectPath: string): Effect.Effect; } export class McpService extends Context.Tag('Mcp')() {} diff --git a/packages/codingcode/src/memory/config.ts b/packages/codingcode/src/memory/config.ts index a11822c9..da85cc11 100644 --- a/packages/codingcode/src/memory/config.ts +++ b/packages/codingcode/src/memory/config.ts @@ -1,4 +1,4 @@ -import { loadConfig, type MemoryConfig } from '@codingcode/infra/config'; +import { loadConfig, type MemoryConfig } from '../infra/config.js'; export function getMemoryConfig(): MemoryConfig { return loadConfig().memory; diff --git a/packages/codingcode/src/memory/extractor.ts b/packages/codingcode/src/memory/extractor.ts index b3743c63..db8853c5 100644 --- a/packages/codingcode/src/memory/extractor.ts +++ b/packages/codingcode/src/memory/extractor.ts @@ -1,11 +1,12 @@ -import type { LLMClient } from '../contracts/provider.js'; +import type { LLMShape } from '../llm/port.js'; export async function extractMemory(opts: { currentMemory: string; transcript: string; - llm: LLMClient; + llm: LLMShape; + model: string; }): Promise { - const { currentMemory, transcript, llm } = opts; + const { currentMemory, transcript, llm, model } = opts; const systemPrompt = `你是记忆整理器。基于"已有记忆"和"会话记录",输出整份最新版长期记忆,放在 ... 块中,不要输出其它内容。 @@ -29,10 +30,13 @@ ${currentMemory || '(空)'} ${transcript || '(空)'}`; try { - const stream = llm.completeStream({ - messages: [{ role: 'user', content: userMessage }], - system: systemPrompt, - }); + const stream = llm.completeStream( + { + messages: [{ role: 'user', content: userMessage }], + system: systemPrompt, + }, + model + ); let fullOutput = ''; for await (const part of stream) { diff --git a/packages/codingcode/src/memory/memory.ts b/packages/codingcode/src/memory/memory.ts index 1626cd83..a8a244f1 100644 --- a/packages/codingcode/src/memory/memory.ts +++ b/packages/codingcode/src/memory/memory.ts @@ -1,5 +1,4 @@ import { Layer, Effect } from 'effect'; -import type { LLMClient } from '../contracts/provider.js'; import { readTranscript } from '../session/file-ops.js'; import type { SessionEvent } from '../contracts/session.js'; import { @@ -8,28 +7,26 @@ import { enforceMaxBytes, writeMemoryFileAtomic, } from './storage.js'; -import { resolveLLM } from '../llm/llm-resolver.js'; -import { LLMFactoryService } from '../llm/port.js'; +import { LLMService } from '../llm/port.js'; import { getMemoryConfig } from './config.js'; -import { updateMemoryEnabled } from '@codingcode/infra/config'; +import { updateMemoryEnabled } from '../infra/config.js'; +import { createLogger } from '../infra/logger.js'; import { extractMemory } from './extractor.js'; import { MemoryService } from './port.js'; const MAX_BYTES = 16384; +const NOT_WRITTEN = { written: false, bytes: 0 } as const; + +const logger = createLogger(); export const MemoryLayer = Layer.effect(MemoryService, Effect.gen(function* () { - const factory = yield* LLMFactoryService; + const llm = yield* LLMService; let _runtimeEnabled: boolean | null = null; - function getMemoryEnabled(): boolean { + function isEnabled(): boolean { return _runtimeEnabled ?? getMemoryConfig().enabled; } - function setMemoryEnabled(v: boolean): void { - _runtimeEnabled = v; - updateMemoryEnabled(v); - } - function truncateForPrompt(content: string, maxBytes: number): string { const contentBytes = Buffer.byteLength(content, 'utf-8'); if (contentBytes <= maxBytes) { @@ -49,8 +46,8 @@ export const MemoryLayer = Layer.effect(MemoryService, Effect.gen(function* () { return result; } - function loadMemoryForPrompt(cwd: string): string { - if (!getMemoryEnabled()) return ''; + function loadMemoryForPromptImpl(cwd: string): string { + if (!isEnabled()) return ''; const cfg = getMemoryConfig(); const projectPath = resolveMemoryPath(cwd); @@ -85,72 +82,58 @@ export const MemoryLayer = Layer.effect(MemoryService, Effect.gen(function* () { return lines.join('\n'); } - async function flushSessionToMemory( - sessionId: string, - llm: LLMClient | null, - sessionCwd: string - ): Promise<{ written: boolean; bytes: number }> { - if (!getMemoryEnabled()) { - return { written: false, bytes: 0 }; - } - if (!sessionCwd) { - return { written: false, bytes: 0 }; - } - - let events: SessionEvent[]; - try { - events = readTranscript(sessionCwd, sessionId).filter((e) => e.type !== 'session_meta'); - } catch { - return { written: false, bytes: 0 }; - } - if (events.length === 0) { - return { written: false, bytes: 0 }; - } - - const cfg = getMemoryConfig(); - const projectPath = resolveMemoryPath(sessionCwd); - const current = readMemoryFile(projectPath); - - try { - const transcript = buildTranscript(events); - - const resolvedLlm = await Effect.runPromise( - resolveLLM(cfg.model, llm).pipe(Effect.provideService(LLMFactoryService, factory)) - ); - if (!resolvedLlm) { - return { written: false, bytes: 0 }; - } - - const extracted = await extractMemory({ - currentMemory: current, - transcript, - llm: resolvedLlm, - }); - if (!extracted) { - return { written: false, bytes: 0 }; - } - - // 提取期间文件被手动改动则放弃本次写入 - if (readMemoryFile(projectPath) !== current) { - return { written: false, bytes: 0 }; - } - - const truncated = enforceMaxBytes(extracted, MAX_BYTES); - if (truncated === current) { - return { written: false, bytes: 0 }; - } - - writeMemoryFileAtomic(projectPath, truncated); - return { written: true, bytes: Buffer.byteLength(truncated, 'utf-8') }; - } catch { - return { written: false, bytes: 0 }; - } - } - return { - getMemoryEnabled, - setMemoryEnabled, - loadMemoryForPrompt, - flushSessionToMemory, + getMemoryEnabled: (): Effect.Effect => Effect.sync(() => isEnabled()), + + setMemoryEnabled: (v: boolean): Effect.Effect => + Effect.sync(() => { + _runtimeEnabled = v; + try { + updateMemoryEnabled(v); + } catch (e) { + logger.error('memory: failed to persist enabled flag:', e); + } + }), + + loadMemoryForPrompt: (cwd: string): Effect.Effect => + Effect.sync(() => loadMemoryForPromptImpl(cwd)), + + flushSessionToMemory: ( + sessionId: string, + model: string, + sessionCwd: string + ): Effect.Effect<{ written: boolean; bytes: number }> => + Effect.gen(function* () { + if (!isEnabled()) return { ...NOT_WRITTEN }; + if (!sessionCwd) return { ...NOT_WRITTEN }; + + const events = readTranscript(sessionCwd, sessionId).filter( + (e) => e.type !== 'session_meta' + ); + if (events.length === 0) return { ...NOT_WRITTEN }; + + const cfg = getMemoryConfig(); + const projectPath = resolveMemoryPath(sessionCwd); + const current = readMemoryFile(projectPath); + + const transcript = buildTranscript(events); + const extracted = yield* Effect.promise(() => + extractMemory({ + currentMemory: current, + transcript, + llm, + model: cfg.model?.trim() || model, + }) + ); + if (!extracted) return { ...NOT_WRITTEN }; + + if (readMemoryFile(projectPath) !== current) return { ...NOT_WRITTEN }; + + const truncated = enforceMaxBytes(extracted, MAX_BYTES); + if (truncated === current) return { ...NOT_WRITTEN }; + + writeMemoryFileAtomic(projectPath, truncated); + 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 e64faa43..e990c188 100644 --- a/packages/codingcode/src/memory/port.ts +++ b/packages/codingcode/src/memory/port.ts @@ -1,11 +1,11 @@ import { Context } from 'effect'; -import type { LLMClient } from '../contracts/provider.js'; +import type { Effect } from 'effect'; export interface MemoryShape { - getMemoryEnabled(): boolean; - setMemoryEnabled(v: boolean): void; - loadMemoryForPrompt(cwd: string): string; - flushSessionToMemory(sessionId: string, llm: LLMClient | null, sessionCwd: string): Promise<{ written: boolean; bytes: number }>; + 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 }>; } export class MemoryService extends Context.Tag('Memory')() {} diff --git a/packages/codingcode/src/rules/port.ts b/packages/codingcode/src/rules/port.ts index 06d4e9b8..6ed9d846 100644 --- a/packages/codingcode/src/rules/port.ts +++ b/packages/codingcode/src/rules/port.ts @@ -1,8 +1,9 @@ import { Context } from 'effect'; +import type { Effect } from 'effect'; export interface RulesShape { - getAllRules(projectPath?: string): string; - evictProjectRules(projectPath: string): void; + getAllRules(projectPath?: string): Effect.Effect; + evictProjectRules(projectPath: string): Effect.Effect; } export class RulesService extends Context.Tag('Rules')() {} diff --git a/packages/codingcode/src/rules/rules.ts b/packages/codingcode/src/rules/rules.ts index 8c2946fc..efec4dba 100644 --- a/packages/codingcode/src/rules/rules.ts +++ b/packages/codingcode/src/rules/rules.ts @@ -52,18 +52,20 @@ export const RulesLayer = Layer.effect(RulesService, Effect.sync(() => { } return { - getAllRules(projectPath?: string): string { - const key = projectPath ?? process.cwd(); - const cached = _allRulesCache.get(key); - if (cached !== undefined) return cached; - const result = buildAllRules(projectPath); - _allRulesCache.set(key, result); - return result; - }, + getAllRules: (projectPath?: string): Effect.Effect => + Effect.sync(() => { + const key = projectPath ?? process.cwd(); + const cached = _allRulesCache.get(key); + if (cached !== undefined) return cached; + const result = buildAllRules(projectPath); + _allRulesCache.set(key, result); + return result; + }), - evictProjectRules(projectPath: string): void { - _projectRulesCache.delete(projectPath); - _allRulesCache.delete(projectPath); - }, + evictProjectRules: (projectPath: string): Effect.Effect => + Effect.sync(() => { + _projectRulesCache.delete(projectPath); + _allRulesCache.delete(projectPath); + }), }; })); diff --git a/packages/codingcode/src/scheduler/port.ts b/packages/codingcode/src/scheduler/port.ts index ba765339..068f1a7b 100644 --- a/packages/codingcode/src/scheduler/port.ts +++ b/packages/codingcode/src/scheduler/port.ts @@ -1,6 +1,6 @@ import { Context } from 'effect'; import type { ManagedRuntime } from 'effect'; -import type { Automation, CreateAutomationInput, UpdateAutomationInput } from './types.js'; +import type { Automation, CreateAutomationInput, UpdateAutomationInput } from '../contracts/automation.js'; export interface SchedulerShape { setRuntime(rt: ManagedRuntime.ManagedRuntime): void; diff --git a/packages/codingcode/src/scheduler/scheduler.ts b/packages/codingcode/src/scheduler/scheduler.ts index 60dacfb8..4ae87c8b 100644 --- a/packages/codingcode/src/scheduler/scheduler.ts +++ b/packages/codingcode/src/scheduler/scheduler.ts @@ -1,11 +1,12 @@ import { Layer, Effect, ManagedRuntime } from 'effect'; import { CronJob } from 'cron'; import { randomUUID } from 'crypto'; -import { createLogger } from '@codingcode/infra/logger'; -import type { Automation, CreateAutomationInput, UpdateAutomationInput } from './types.js'; +import { createLogger } from '../infra/logger.js'; +import type { Automation, CreateAutomationInput, UpdateAutomationInput } from '../contracts/automation.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'; const logger = createLogger(); @@ -46,6 +47,8 @@ export const SchedulerLayer = Layer.effect(SchedulerService, Effect.sync(() => { return yield* agent.runTurn(auto.description, { cwd: auto.projectCwd, signal: controller.signal, + // 自动化没有独立的模型配置,统一用 config.yaml 的活动模型 + model: activeModelId(), activeProfile: 'build', permissionMode: 'bypass', }); @@ -177,6 +180,8 @@ export const SchedulerLayer = Layer.effect(SchedulerService, Effect.sync(() => { 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..ade1fac7 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 './types.js'; +import type { Automation } from '../contracts/automation.js'; interface AutomationsFile { automations: Automation[]; diff --git a/packages/codingcode/src/server/index.ts b/packages/codingcode/src/server/index.ts index 8f292908..43eb472c 100644 --- a/packages/codingcode/src/server/index.ts +++ b/packages/codingcode/src/server/index.ts @@ -45,7 +45,7 @@ export async function createServer(rt: ManagedRt): Promise { registerMessagesRoutes(app, rt); registerModelsRoutes(app, rt); registerApprovalRoutes(app, rt); - await registerSettingsRoutes(app, rt); + registerSettingsRoutes(app, rt); registerAutomationsRoutes(app, rt); return app; diff --git a/packages/codingcode/src/server/routes/agent.ts b/packages/codingcode/src/server/routes/agent.ts new file mode 100644 index 00000000..977fdbb8 --- /dev/null +++ b/packages/codingcode/src/server/routes/agent.ts @@ -0,0 +1,64 @@ +import type { Hono } from 'hono'; +import { + loadConfig, + updateMaxSteps, + updateMaxStopContinuations, + updateContextCompactionModel, + updateActiveProfile, + updatePermissionMode, +} from '../../infra/config.js'; +import { isAgentProfileName } from '../../agent/profile.js'; +import { isPermissionMode } from '../../approval/types.js'; +import { errorBody } from '../util.js'; + +/** config.yaml 的 agent 段;交互/权限模式非法时回落到 build / default */ +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 : 'ask', + }; +} + +export function registerAgentSettingsRoutes(router: Hono): void { + // ---- Agent config ---- + router.get('/api/settings/agent/config', (c) => { + return c.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()); + }); + + // ---- 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 }); + }); +} diff --git a/packages/codingcode/src/server/routes/automations.ts b/packages/codingcode/src/server/routes/automations.ts index 94876938..ec482ae5 100644 --- a/packages/codingcode/src/server/routes/automations.ts +++ b/packages/codingcode/src/server/routes/automations.ts @@ -1,9 +1,9 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { SchedulerService } from '../../scheduler/port.js'; -import { errorResponse } from '../util.js'; +import { errorBody, errorResponse } from '../util.js'; import { NotFoundError } from '../../contracts/error.js'; -import type { CreateAutomationInput, UpdateAutomationInput } from '../../scheduler/types.js'; +import type { CreateAutomationInput, UpdateAutomationInput } from '../../contracts/automation.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -36,7 +36,10 @@ export function registerAutomationsRoutes(router: Hono, rt: ManagedRt): void { const body = (await c.req.json()) as CreateAutomationInput; if (!body.name || !body.description || !body.cron || !body.projectCwd) { - return c.json({ error: 'Missing required fields: name, description, cron, projectCwd' }, 400); + return c.json( + errorBody('CONFIG_MISSING', 'Missing required fields: name, description, cron, projectCwd'), + 400 + ); } const result = await rt.runPromise( diff --git a/packages/codingcode/src/server/routes/hooks.ts b/packages/codingcode/src/server/routes/hooks.ts new file mode 100644 index 00000000..600634dd --- /dev/null +++ b/packages/codingcode/src/server/routes/hooks.ts @@ -0,0 +1,138 @@ +import type { Hono } from 'hono'; +import { isGlobalCwd, resolveCwd } from '../../core/path.js'; +import { AlreadyExistsError, NotFoundError } from '../../contracts/error.js'; +import type { UserHookConfig } from '../../contracts/hooks.js'; +import { + loadHookConfigs, + writeHookConfigs, + loadGlobalHookConfigs, + writeGlobalHookConfigs, + resolveHookConfigs, + setGlobalHookEnabled, + 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); +} + +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 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) + ); +} + +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 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, + }; + }) + ); + }); + + 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); + } + return c.json({ ok: true }); + }); + + 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); + } + return c.json({ ok: true }); + }); + + 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); + } + return c.json({ ok: true }); + }); + + // 开关就是配置里的 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); + } + return c.json({ ok: true }); + }); +} diff --git a/packages/codingcode/src/server/routes/mcp.ts b/packages/codingcode/src/server/routes/mcp.ts new file mode 100644 index 00000000..c6aae2ed --- /dev/null +++ b/packages/codingcode/src/server/routes/mcp.ts @@ -0,0 +1,138 @@ +import type { Hono } from 'hono'; +import { isGlobalCwd, resolveCwd } from '../../core/path.js'; +import { AlreadyExistsError, NotFoundError } from '../../contracts/error.js'; +import type { McpServerConfig } from '../../contracts/mcp.js'; +import { + loadMcpConfig, + writeMcpConfig, + loadGlobalMcpConfig, + writeGlobalMcpConfig, + resolveMcpConfig, + setGlobalMcpServerEnabled, + 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); +} + +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); +} + +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) + ); +} + +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 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, + }; + }) + ); + }); + + 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); + } + return c.json({ ok: true }); + }); + + 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); + } + return c.json({ ok: true }); + }); + + 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); + } + return c.json({ ok: true }); + }); + + // 开关就是配置里的 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); + } + return c.json({ ok: true }); + }); +} diff --git a/packages/codingcode/src/server/routes/memory.ts b/packages/codingcode/src/server/routes/memory.ts new file mode 100644 index 00000000..1624e778 --- /dev/null +++ b/packages/codingcode/src/server/routes/memory.ts @@ -0,0 +1,40 @@ +import type { Hono } from 'hono'; +import { Effect, ManagedRuntime } from 'effect'; +import { MemoryService } from '../../memory/port.js'; +import { getMemoryConfig } from '../../memory/config.js'; +import { updateMemoryModel } from '../../infra/config.js'; + +type ManagedRt = ManagedRuntime.ManagedRuntime; + +export function registerMemorySettingsRoutes(router: Hono, rt: ManagedRt): void { + router.get('/api/settings/memory/config', (c) => { + const cfg = getMemoryConfig(); + return c.json({ + enabled: cfg.enabled, + model: cfg.model, + }); + }); + + router.post('/api/settings/memory/enabled', async (c) => { + const body = (await c.req.json()) as { enabled: boolean }; + await rt.runPromise( + 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(); + }) + ); + 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 }); + }); +} diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index b3f7d383..04469f4c 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -2,7 +2,10 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { AgentService } from '../../agent/port.js'; import { resolveCwd } from '../../core/path.js'; -import { errorResponse } from '../util.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 { createSseHandler } from '../handler.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -12,17 +15,26 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/messages', async (c) => { let sessionId = c.req.param('id'); - const { input, cwd } = await c.req.json<{ input: string; cwd: string }>(); + const { input, cwd, model } = await c.req.json<{ input: string; cwd: string; model?: string }>(); + // 模型是回合的必要输入,缺失即拒绝,不允许在 agent 层兜底成空串 + if (!model?.trim()) { + return c.json(errorBody('CONFIG_MISSING', 'model is required'), 400); + } const normalizedCwd = resolveCwd(cwd); const isNew = sessionId === '_' || !sessionId; const runOpts: any = { cwd: normalizedCwd, signal: c.req.raw.signal, + model, }; if (isNew) { - runOpts.activeProfile = 'build'; - runOpts.permissionMode = 'default'; + // 新会话的交互/权限模式取自 config.yaml;会话一旦建立就以会话头为准 + const cfg = loadConfig(); + runOpts.activeProfile = isAgentProfileName(cfg.activeProfile) ? cfg.activeProfile : 'build'; + runOpts.permissionMode = isPermissionMode(cfg.permissionMode) + ? cfg.permissionMode + : 'ask'; } const result = await rt.runPromise( diff --git a/packages/codingcode/src/server/routes/models.ts b/packages/codingcode/src/server/routes/models.ts index 69393b67..a8852c58 100644 --- a/packages/codingcode/src/server/routes/models.ts +++ b/packages/codingcode/src/server/routes/models.ts @@ -1,36 +1,11 @@ import type { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; -import { LLMFactoryService } from '../../llm/port.js'; +import type { ManagedRuntime } from 'effect'; +import { activeModelId, listModels } from '../../infra/models.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; -export function registerModelsRoutes(router: Hono, rt: ManagedRt): void { - router.get('/api/models', async (c) => { - const result = await rt.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - const modelsResult = yield* Effect.either(factory.listModels()); - const activeResult = yield* Effect.either(factory.getActiveEntry()); - return { - models: modelsResult._tag === 'Right' ? modelsResult.right : [], - activeId: activeResult._tag === 'Right' ? activeResult.right.id : '', - }; - }) - ); - return c.json(result); - }); - - router.post('/api/models/switch', async (c) => { - const { modelId } = (await c.req.json()) as { modelId: string }; - const result = await rt.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* Effect.either(factory.switchModel(modelId)); - }) - ); - return c.json({ - ok: result._tag === 'Right', - error: result._tag === 'Left' ? result.left.message : undefined, - }); +export function registerModelsRoutes(router: Hono, _rt: ManagedRt): void { + router.get('/api/models', (c) => { + return c.json({ models: listModels(), activeId: activeModelId() }); }); } diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index ac43e37d..c2c2701d 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -9,11 +9,11 @@ import { computePaths, resolveCwd } from '../../core/path.js'; import { ContextService } from '../../context/port.js'; import { estimatePromptTokensFrom } from '../../context/context.js'; import { CheckpointService } from '../../checkpoint/port.js'; -import { LLMFactoryService } from '../../llm/port.js'; -import type { LLMClient } from '../../contracts/provider.js'; -import { errorResponse } from '../util.js'; +import { activeModelId, setGlobalActive } from '../../infra/models.js'; +import { errorBody, errorResponse } from '../util.js'; import { encodeProjectPath, getProjectBaseDir } from '../../core/path.js'; -import { AVAILABLE_PROFILES, isAgentProfileName } from '../../agent/profile.js'; +import { AVAILABLE_PROFILES } from '../../contracts/profile.js'; +import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; import type { PermissionMode } from '../../contracts/permission.js'; @@ -57,13 +57,16 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { model: string; }; if (!isAgentProfileName(body.activeProfile)) { - return c.json({ error: `Invalid activeProfile: ${body.activeProfile}` }, 400); + return c.json(errorBody('CONFIG_INVALID', `Invalid activeProfile: ${body.activeProfile}`), 400); } if (!isPermissionMode(body.permissionMode)) { - return c.json({ error: `Invalid permissionMode: ${body.permissionMode}` }, 400); + return c.json( + errorBody('CONFIG_INVALID', `Invalid permissionMode: ${body.permissionMode}`), + 400 + ); } if (!body.model) { - return c.json({ error: 'model required' }, 400); + return c.json(errorBody('CONFIG_MISSING', 'model required'), 400); } const normalizedCwd = resolveCwd(body.cwd); const result = await runWithLayer( @@ -104,26 +107,16 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/compact', async (c) => { const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string }; + 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 factory = yield* LLMFactoryService; const session = yield* SessionService; const state = yield* session.load(normalizedCwd, sessionId); - - let llm: LLMClient | null = null; - const entry = yield* factory.getActiveEntry().pipe(Effect.either); - if (entry._tag === 'Right') { - const client = yield* factory.createClient(entry.right).pipe(Effect.either); - if (client._tag === 'Right') llm = client.right; - } - - const maxTokens = llm?.modelInfo.maxTokens ?? 128000; - - return yield* Effect.promise(() => - context.compactWithLLM(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath, maxTokens, llm) + return yield* context.compactWithLLM( + computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath, + body.model ?? '' ); }) ); @@ -137,7 +130,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.delete('/api/sessions/:id', async (c) => { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); - if (!cwd) return c.json({ error: 'cwd required' }, 400); + if (!cwd) return c.json(errorBody('CONFIG_MISSING', 'cwd required'), 400); await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -150,7 +143,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { 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({ error: 'cwd required' }, 400); + if (!cwd) return c.json(errorBody('CONFIG_MISSING', 'cwd required'), 400); const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -205,7 +198,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { exists: true, }); } catch (e) { - return c.json({ error: `Failed to read plan: ${String(e)}` }, 500); + return c.json(errorBody('SESSION_IO_ERROR', `Failed to read plan: ${String(e)}`), 500); } }); @@ -240,7 +233,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const cwd = resolveCwd(body.cwd); const activeProfile = body.activeProfile; if (!isAgentProfileName(activeProfile)) { - return c.json({ error: `Invalid activeProfile: ${activeProfile}` }, 400); + return c.json(errorBody('CONFIG_INVALID', `Invalid activeProfile: ${activeProfile}`), 400); } const result = await runWithLayer( Effect.gen(function* () { @@ -263,7 +256,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { 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: 'default' }); + if (!cwd) return c.json({ mode: 'ask' }); const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -281,9 +274,9 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { 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({ error: 'cwd required' }, 400); + if (!cwd) return c.json(errorBody('CONFIG_MISSING', 'cwd required'), 400); if (!isPermissionMode(mode)) { - return c.json({ error: `Invalid permissionMode: ${mode}` }, 400); + return c.json(errorBody('CONFIG_INVALID', `Invalid permissionMode: ${mode}`), 400); } const setResult = await runWithLayer( Effect.gen(function* () { @@ -299,6 +292,39 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { return c.json({ ok: true }); }); + // ---- 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) { + try { + setGlobalActive(model); + } catch (e) { + const { status, body: errBody } = errorResponse(e); + return c.json(errBody, status as any); + } + 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 + ); + 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')); @@ -468,7 +494,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const newSessionId = yield* session.forkSession(state, atTurnId); const turns = yield* session.readUITurns(newSessionId, cwd); const newJsonlPath = computePaths(cwd, newSessionId).transcriptPath; - const promptEstimate = estimatePromptTokensFrom(session.readEvents(newJsonlPath)); + const promptEstimate = estimatePromptTokensFrom(yield* session.readEvents(newJsonlPath)); return { sessionId: newSessionId, turns, promptEstimate }; }) as any ); diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index 590bff41..54ab0328 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -1,413 +1,17 @@ import type { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; -import { SkillService } from '../../skills/port.js'; -import { isGlobalCwd, resolveCwd } from '../../core/path.js'; -import { AlreadyExistsError, NotFoundError } from '../../contracts/error.js'; -import type { McpServerConfig } from '../../contracts/mcp.js'; -import type { UserHookConfig } from '../../contracts/hooks.js'; -import { - loadMcpConfig, - writeMcpConfig, - loadGlobalMcpConfig, - writeGlobalMcpConfig, - resolveMcpConfig, - setGlobalMcpServerEnabled, - setProjectMcpServerEnabled, -} from '../../mcp/config.js'; -import { - loadHookConfigs, - writeHookConfigs, - loadGlobalHookConfigs, - writeGlobalHookConfigs, - resolveHookConfigs, - setGlobalHookEnabled, - setProjectHookEnabled, -} from '../../hooks/config.js'; -import { discoverGlobalSkillDirs, discoverProjectSkillDirs } from '../../skills/source.js'; -import { getMemoryConfig } from '../../memory/config.js'; -import { - loadConfig, - updateMaxSteps, - updateMaxStopContinuations, - updateContextCompactionModel, - updateMemoryModel, -} from '@codingcode/infra/config'; -import { MemoryService } from '../../memory/port.js'; -import { createRunWithLayer } from '../util.js'; +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'; type ManagedRt = ManagedRuntime.ManagedRuntime; -export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promise { - const runWithLayer = createRunWithLayer(rt); - - // ---- Helpers for CRUD with validation ---- - - 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); - } - - 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); - } - - 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) - ); - } - - 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); - } - - 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 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) - ); - } - - // ---- Memory ---- - router.get('/api/settings/memory/config', (c) => { - const cfg = getMemoryConfig(); - return c.json({ - enabled: cfg.enabled, - model: cfg.model, - }); - }); - - router.post('/api/settings/memory/enabled', async (c) => { - const body = (await c.req.json()) as { enabled: boolean }; - await rt.runPromise( - Effect.gen(function* () { - const m = yield* MemoryService; - m.setMemoryEnabled(body.enabled); - }) - ); - const enabled = await rt.runPromise( - Effect.gen(function* () { - const m = yield* MemoryService; - return 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 }); - }); - - // ---- Agent config ---- - router.get('/api/settings/agent/config', (c) => { - const cfg = loadConfig(); - return c.json({ maxSteps: cfg.maxSteps, maxStopContinuations: cfg.maxStopContinuations }); - }); - - router.post('/api/settings/agent/config', async (c) => { - const body = (await c.req.json()) as { maxSteps?: number; maxStopContinuations?: number }; - if (body.maxSteps !== undefined) updateMaxSteps(body.maxSteps); - if (body.maxStopContinuations !== undefined) - updateMaxStopContinuations(body.maxStopContinuations); - const cfg = loadConfig(); - return c.json({ maxSteps: cfg.maxSteps, maxStopContinuations: cfg.maxStopContinuations }); - }); - - // ---- 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 }); - }); - - // ---- Hooks ---- - 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 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, - }; - }) - ); - }); - - router.post('/api/settings/hooks', async (c) => { - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as UserHookConfig; - try { - 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); - } - return c.json({ ok: true }); - } catch (e) { - if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); - throw e; - } - }); - - 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; - try { - 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); - } - return c.json({ ok: true }); - } catch (e) { - if (e instanceof NotFoundError) return c.json({ error: e.message }, 404); - if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); - throw e; - } - }); - - 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); - } - return c.json({ ok: true }); - }); - - // 开关就是配置里的 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); - } - return c.json({ ok: true }); - }); - - // ---- MCP ---- - 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 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, - }; - }) - ); - }); - - router.post('/api/settings/mcp', async (c) => { - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as McpServerConfig; - try { - 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); - } - return c.json({ ok: true }); - } catch (e) { - if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); - throw e; - } - }); - - 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; - try { - 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); - } - return c.json({ ok: true }); - } catch (e) { - if (e instanceof NotFoundError) return c.json({ error: e.message }, 404); - if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); - throw e; - } - }); - - 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); - } - return c.json({ ok: true }); - }); - - // 开关就是配置里的 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); - } - return c.json({ ok: true }); - }); - - // ---- Skills ---- - router.get('/api/settings/skills', async (c) => { - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - const cwd = resolveCwd(rawCwd); - const result = 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, - source: 'global' as const, - })) - ); - } - const cwd = resolveCwd(rawCwd); - const globalDirs = discoverGlobalSkillDirs(); - const projectDirs = discoverProjectSkillDirs(cwd); - const globalNames = new Set(globalDirs.map((d) => d.name)); - const projectNames = new Set(projectDirs.map((d) => d.name)); - const result = 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) => { - const isFromProject = projectNames.has(s.name); - const isFromGlobal = globalNames.has(s.name); - const hasProjectOverride = isFromProject && isFromGlobal; - return { - ...s, - source: isFromProject ? 'project' : 'global', - hasProjectOverride, - }; - }) - ); - }); +export function registerSettingsRoutes(router: Hono, rt: ManagedRt): void { + registerMemorySettingsRoutes(router, rt); + registerAgentSettingsRoutes(router); + registerHooksSettingsRoutes(router); + registerMcpSettingsRoutes(router); + registerSkillsSettingsRoutes(router, rt); } diff --git a/packages/codingcode/src/server/routes/skills.ts b/packages/codingcode/src/server/routes/skills.ts new file mode 100644 index 00000000..53dfad81 --- /dev/null +++ b/packages/codingcode/src/server/routes/skills.ts @@ -0,0 +1,56 @@ +import type { Hono } from 'hono'; +import { Effect, ManagedRuntime } from 'effect'; +import { SkillService } from '../../skills/port.js'; +import { isGlobalCwd, resolveCwd } from '../../core/path.js'; +import { discoverGlobalSkillDirs, discoverProjectSkillDirs } from '../../skills/source.js'; +import { createRunWithLayer } from '../util.js'; + +type ManagedRt = ManagedRuntime.ManagedRuntime; + +export function registerSkillsSettingsRoutes(router: Hono, rt: ManagedRt): void { + const runWithLayer = createRunWithLayer(rt); + + router.get('/api/settings/skills', async (c) => { + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + const cwd = resolveCwd(rawCwd); + const result = 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, + source: 'global' as const, + })) + ); + } + const cwd = resolveCwd(rawCwd); + const globalDirs = discoverGlobalSkillDirs(); + const projectDirs = discoverProjectSkillDirs(cwd); + const globalNames = new Set(globalDirs.map((d) => d.name)); + const projectNames = new Set(projectDirs.map((d) => d.name)); + const result = 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) => { + const isFromProject = projectNames.has(s.name); + const isFromGlobal = globalNames.has(s.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...s, + source: isFromProject ? 'project' : 'global', + hasProjectOverride, + }; + }) + ); + }); +} diff --git a/packages/codingcode/src/server/util.ts b/packages/codingcode/src/server/util.ts index ec1c701c..73e9e5f3 100644 --- a/packages/codingcode/src/server/util.ts +++ b/packages/codingcode/src/server/util.ts @@ -23,17 +23,17 @@ export function createRunWithLayer(rt: ManagedRt) { }; } +/** 错误信封的唯一构造点:`{ error: { code, message } }` */ +export function errorBody(code: string, message: string) { + return { error: { code, message } }; +} + export function errorResponse(err: unknown) { if (err instanceof AgentError) { - return { status: err.httpStatus(), body: { error: { code: err.code, message: err.message } } }; + return { status: err.httpStatus(), body: errorBody(err.code, err.message) }; } return { status: 500, - body: { - error: { - code: 'INTERNAL_ERROR', - message: err instanceof Error ? err.message : 'Internal server error', - }, - }, + body: errorBody('INTERNAL_ERROR', err instanceof Error ? err.message : 'Internal server error'), }; } diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index 41c2d6e9..72e0b6fd 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -10,15 +10,21 @@ import { closeSync, unlinkSync, rmSync, + statSync, + renameSync, } from 'fs'; import { join, dirname } from 'path'; import { getGlobalDir, getProjectBaseDir, computePaths } from '../core/path.js'; -import type { PermissionMode } from '../contracts/permission.js'; -import type { SessionEvent, SessionMetaEvent, SessionIndex } from '../contracts/session.js'; +import type { SessionEvent, SessionMetaEvent, SessionSummary } from '../contracts/session.js'; +import type { TokenUsage } from '../contracts/types.js'; -/** session 的 jsonl 转录路径:由 cwd + sessionId 推出。 */ -export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string { - return computePaths(cwd, sessionId).transcriptPath; +/** 首行最大读取字节数:会话头远小于此值。 */ +const HEAD_BYTES = 8192; +/** 尾部扫描初始窗口,命中不到时按 4 倍扩大。 */ +const TAIL_WINDOW_BYTES = 16 * 1024; + +export function sessionJsonlPathFromCwd(cwd: string, sessionId: string, parentSessionId?: string): string { + return computePaths(cwd, sessionId, parentSessionId).transcriptPath; } export function ensureDirs(transcriptPath: string): void { @@ -28,56 +34,114 @@ export function ensureDirs(transcriptPath: string): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } -function quickReadMeta(path: string): SessionMetaEvent | null { +export function truncateTitle(content: string): string { + const cleaned = content.replace(/\n/g, ' ').trim(); + if (cleaned.length <= 30) return cleaned; + return cleaned.slice(0, 30) + '...'; +} + +/** 读取会话文件首行(会话索引行)。 */ +export function readSessionMeta(transcriptPath: string): SessionMetaEvent | null { try { - const fd = openSync(path, 'r'); - const buffer = Buffer.alloc(4096); - const bytesRead = readSync(fd, buffer, 0, 4096, 0); - closeSync(fd); - const firstLine = buffer.toString('utf8', 0, bytesRead).split('\n')[0]; - if (!firstLine) return null; - return JSON.parse(firstLine) as SessionMetaEvent; + const fd = openSync(transcriptPath, 'r'); + const buffer = Buffer.alloc(HEAD_BYTES); + let bytesRead = 0; + try { + bytesRead = readSync(fd, buffer, 0, HEAD_BYTES, 0); + } finally { + closeSync(fd); + } + const newlineAt = buffer.subarray(0, bytesRead).indexOf(0x0a); + const end = newlineAt === -1 ? bytesRead : newlineAt; + const line = buffer.toString('utf8', 0, end); + if (!line.trim()) return null; + const parsed = JSON.parse(line) as SessionEvent; + return parsed.type === 'session_meta' ? parsed : null; } catch { return null; } } -export function findFirstUserContent(history: SessionEvent[]): string | null { - for (const e of history) { - if (e.type === 'user') return e.content; +/** 原子重写首行(身份字段变更:标题 / 模型 / 权限模式 / profile)。 */ +export function rewriteSessionMeta(transcriptPath: string, patch: Partial): void { + const raw = readFileSync(transcriptPath, 'utf8'); + const newlineAt = raw.indexOf('\n'); + if (newlineAt < 0) return; + const meta = JSON.parse(raw.slice(0, newlineAt)) as SessionEvent; + if (meta.type !== 'session_meta') return; + const next = JSON.stringify({ ...meta, ...patch }); + const tmpPath = `${transcriptPath}.tmp`; + writeFileSync(tmpPath, next + raw.slice(newlineAt), 'utf8'); + renameSync(tmpPath, transcriptPath); +} + +function readWindow(transcriptPath: string, start: number, end: number): SessionEvent[] { + const fd = openSync(transcriptPath, 'r'); + const buffer = Buffer.alloc(end - start); + try { + readSync(fd, buffer, 0, buffer.length, start); + } finally { + closeSync(fd); + } + const rawLines = buffer.toString('utf8').split('\n'); + const lines = (start > 0 ? rawLines.slice(1) : rawLines).filter((l) => l.trim()); + const events: SessionEvent[] = []; + for (const line of lines) { + try { + events.push(JSON.parse(line) as SessionEvent); + } catch { + /* 截断行 */ + } } - return null; + return events; } -export function truncateTitle(content: string): string { - const cleaned = content.replace(/\n/g, ' ').trim(); - if (cleaned.length <= 30) return cleaned; - return cleaned.slice(0, 30) + '...'; +/** 从尾部按窗口 4 倍扩大读取,直到 visit 判定可返回或已覆盖整个文件。 */ +function scanTail(transcriptPath: string, visit: (events: SessionEvent[]) => T | undefined): T | undefined { + const size = statSync(transcriptPath).size; + let window = TAIL_WINDOW_BYTES; + for (;;) { + const start = Math.max(0, size - window); + const decided = visit(readWindow(transcriptPath, start, size)); + if (decided !== undefined || start === 0) return decided; + window *= 4; + } } -export function countNonMetaEvents(history: SessionEvent[]): number { - return history.filter((e) => e.type !== 'session_meta').length; +/** 尾部的最大 turnId;文件里没有带 turnId 的事件时返回 0。 */ +export function readLastTurnId(transcriptPath: string): number { + return ( + scanTail(transcriptPath, (events) => { + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]!; + if ('turnId' in ev && typeof ev.turnId === 'number') return ev.turnId; + } + return undefined; + }) ?? 0 + ); } -function buildIndexFromMeta(meta: SessionMetaEvent, history: SessionEvent[]): SessionIndex { - const firstUser = findFirstUserContent(history); - return { - sessionId: meta.sessionId, - cwd: meta.cwd, - model: 'unknown', - createdAt: meta.createdAt, - updatedAt: meta.createdAt, - messageCount: countNonMetaEvents(history), - title: firstUser ? truncateTitle(firstUser) : meta.sessionId.slice(0, 8), - currentTurnId: 0, - usage: undefined, - activeProfile: meta.activeProfile, - permissionMode: 'default', - }; +/** 尾部最后一个可见助手用量;回滚事件隐藏的 turn 会被跳过。 */ +export function readLastUsage(transcriptPath: string): TokenUsage | undefined { + return scanTail(transcriptPath, (events) => { + let minRollbackThrough = Infinity; + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]!; + if (ev.type === 'rollback') { + if (ev.throughTurnId < minRollbackThrough) minRollbackThrough = ev.throughTurnId; + continue; + } + if (ev.type === 'assistant' && ev.usage) { + if (minRollbackThrough <= ev.turnId) continue; + return ev.usage; + } + } + return undefined; + }); } -export function listSessions(encodedProjectPath?: string): SessionIndex[] { - const results: SessionIndex[] = []; +export function listSessions(encodedProjectPath?: string): SessionSummary[] { + const results: SessionSummary[] = []; const projectBase = getProjectBaseDir(); const encodedDirs = encodedProjectPath ? [encodedProjectPath] @@ -89,24 +153,14 @@ export function listSessions(encodedProjectPath?: string): SessionIndex[] { if (!existsSync(sessionsDir)) continue; for (const file of readdirSync(sessionsDir).filter((f) => f.endsWith('.jsonl'))) { const jsonlPath = join(sessionsDir, file); - const idxPath = jsonlPath.replace('.jsonl', '.index.json'); - let index: SessionIndex | null = null; - if (existsSync(idxPath)) { - try { - index = JSON.parse(readFileSync(idxPath, 'utf8')) as SessionIndex; - } catch { - /* corrupt */ - } - } - if (index) { - results.push(index); - } else { - const meta = quickReadMeta(jsonlPath); - if (meta?.cwd && meta?.sessionId) { - const h = readHistory(jsonlPath); - results.push(buildIndexFromMeta(meta, h)); - } + const meta = readSessionMeta(jsonlPath); + if (!meta) continue; + let updatedAt = meta.createdAt; + try { + updatedAt = statSync(jsonlPath).mtime.toISOString(); + } catch { } + results.push({ ...meta, updatedAt, usage: readLastUsage(jsonlPath) }); } } return results; @@ -125,62 +179,18 @@ export function appendLine(path: string, event: object): void { appendFileSync(path, JSON.stringify(event) + '\n', 'utf8'); } -export function readCurrentIndex(indexPath: string): Partial | null { - try { - return JSON.parse(readFileSync(indexPath, 'utf8')); - } catch { - return null; - } -} - export function readTranscript(cwd: string, sessionId: string): SessionEvent[] { return readHistory(sessionJsonlPathFromCwd(cwd, sessionId)); } -export function writeIndexAtomic(indexPath: string, patch: Partial): void { - let current: Partial = {}; - if (existsSync(indexPath)) { - try { - current = JSON.parse(readFileSync(indexPath, 'utf8')); - } catch { - /* corrupt */ - } - } - const merged = { ...current, ...patch, updatedAt: new Date().toISOString() }; - writeFileSync(indexPath, JSON.stringify(merged, null, 2), 'utf8'); -} - -export function setPermissionMode( - sessionId: string, - indexPath: string, - mode: PermissionMode -): void { - let index: SessionIndex | null = null; - if (existsSync(indexPath)) { - try { - index = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; - } catch { - /* corrupt */ - } - } - if (!index) throw new Error(`Session index not found: ${indexPath}`); - index.permissionMode = mode; - index.updatedAt = new Date().toISOString(); - writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf8'); -} - export function deleteSession(sessionId: string, cwd: string): void { const dir = dirname(sessionJsonlPathFromCwd(cwd, sessionId)); if (!dir) return; const jsonlPath = join(dir, `${sessionId}.jsonl`); - const idxPath = join(dir, `${sessionId}.index.json`); const subagentDir = join(dir, sessionId); try { if (existsSync(jsonlPath)) unlinkSync(jsonlPath); } catch {} - try { - if (existsSync(idxPath)) unlinkSync(idxPath); - } catch {} try { if (existsSync(subagentDir)) rmSync(subagentDir, { recursive: true, force: true }); } catch {} diff --git a/packages/codingcode/src/session/port.ts b/packages/codingcode/src/session/port.ts index 7a9735dd..7de3840d 100644 --- a/packages/codingcode/src/session/port.ts +++ b/packages/codingcode/src/session/port.ts @@ -1,17 +1,17 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; import type { AgentError } from '../core/error.js'; -import type { AssistantEvent, RollbackEvent, SessionCreateOptions, SessionEvent, SessionIndex, SessionStoreState, SummaryEvent, ToolResultEvent, UITurn, UserEvent } from '../contracts/session.js'; +import type { AssistantEvent, RollbackEvent, SessionCreateOptions, SessionEvent, SessionSummary, SessionStoreState, SummaryEvent, ToolResultEvent, UITurn, UserEvent } from '../contracts/session.js'; import type { TokenUsage, ProfileName } from '../contracts/types.js'; import type { PermissionMode } from '../contracts/permission.js'; export interface SessionShape { create(cwd: string, options: SessionCreateOptions, opts?: { parentSessionId?: string; agentName?: string }): Effect.Effect; - load(cwd: string, sessionId: 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; - listSessions(cwd?: string): Effect.Effect; + listSessions(cwd?: string): Effect.Effect; readHistory(state: SessionStoreState): Effect.Effect; recordUser(state: SessionStoreState, content: string): Effect.Effect; recordSystem(state: SessionStoreState, content: string): Effect.Effect; @@ -19,11 +19,12 @@ export interface SessionShape { recordToolResult(state: SessionStoreState, toolName: string, toolCallId: string, output: 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): SessionEvent[]; - appendEvent(transcriptPath: string, event: SessionEvent): void; + 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): Effect.Effect; - setActiveProfile(cwd: string, sessionId: string, profile: ProfileName): 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 3a1c75c6..49d36d03 100644 --- a/packages/codingcode/src/session/session.ts +++ b/packages/codingcode/src/session/session.ts @@ -1,10 +1,10 @@ import { Effect, Layer } from 'effect'; import { randomUUID } from 'crypto'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync } from 'fs'; import { join, dirname } from 'path'; import { AgentError } from '../core/error.js'; -import { encodeProjectPath } from '../core/path.js'; -import type { SessionMetaEvent, UserEvent, AssistantEvent, ToolResultEvent, SummaryEvent, RollbackEvent, SessionIndex, SessionEvent, SessionStoreState, CompactEvent, UITurn } from '../contracts/session.js'; +import { computePaths, encodeProjectPath } from '../core/path.js'; +import type { SessionMetaEvent, UserEvent, AssistantEvent, ToolResultEvent, 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 { SessionService } from './port.js'; @@ -13,23 +13,21 @@ import { readHistory, appendLine, listSessions, - setPermissionMode, - readCurrentIndex, - writeIndexAtomic, - countNonMetaEvents, + readSessionMeta, + rewriteSessionMeta, + readLastTurnId, + readLastUsage, truncateTitle, - findFirstUserContent, deleteSession as deleteSessionImpl, sessionJsonlPathFromCwd, } from './file-ops.js'; -import { computePaths } from '../core/path.js'; function pathsFromState(state: SessionStoreState) { return computePaths(state.cwd, state.sessionId, state.parentSessionId); } -function assertResumeWorkspace(cwd: string, sessionId: string): void { - const expectedPath = sessionJsonlPathFromCwd(cwd, sessionId); +function assertResumeWorkspace(cwd: string, sessionId: string, parentSessionId?: string): void { + const expectedPath = sessionJsonlPathFromCwd(cwd, sessionId, parentSessionId); if (!existsSync(expectedPath)) throw AgentError.sessionNotFound(sessionId); } @@ -158,31 +156,11 @@ function readUIHistory(sessionId: string, cwd: string): UITurn[] { export const SessionLayer = Layer.effect( SessionService, Effect.gen(function* () { - function updateIndex(state: SessionStoreState): void { - if (!state.sessionMeta) return; - const paths = pathsFromState(state); - const index: SessionIndex = { - sessionId: state.sessionId, - cwd: state.cwd, - model: state.model, - createdAt: state.sessionMeta.createdAt, - updatedAt: new Date().toISOString(), - messageCount: state.messageCount, - title: state.title, - currentTurnId: state.currentTurnId, - usage: state.usage, - permissionMode: state.permissionMode, - memorySnapshot: state.memorySnapshot, - activeProfile: state.activeProfile, - parentSessionId: state.parentSessionId, - }; - writeFileSync(paths.indexPath, JSON.stringify(index, null, 2), 'utf8'); - } - const create = ( cwd: string, options: { model: string; + title?: string; activeProfile: ProfileName; permissionMode: PermissionMode; }, @@ -193,36 +171,21 @@ export const SessionLayer = Layer.effect( const paths = computePaths(cwd, randomUUID(), opts?.parentSessionId); ensureDirs(paths.transcriptPath); - const state: SessionStoreState = { - sessionId: paths.sessionId, - cwd: paths.cwd, - messageCount: 0, - sessionMeta: null, - model: options.model, - permissionMode: options.permissionMode, - title: paths.sessionId.slice(0, 8), - currentTurnId: 0, - usage: undefined, - memorySnapshot: '', - activeProfile: options.activeProfile, - parentSessionId: opts?.parentSessionId, - }; - const meta: SessionMetaEvent = { type: 'session_meta', - sessionId: state.sessionId, - cwd: state.cwd, + sessionId: paths.sessionId, + cwd: paths.cwd, createdAt: new Date().toISOString(), + model: options.model, + title: options.title ? truncateTitle(options.title) : paths.sessionId.slice(0, 8), activeProfile: options.activeProfile, permissionMode: options.permissionMode, ...(opts?.parentSessionId && { parentSessionId: opts.parentSessionId }), ...(opts?.agentName && { agentName: opts.agentName }), }; - state.sessionMeta = meta; appendLine(paths.transcriptPath, meta); - state.messageCount++; - updateIndex(state); - return state; + + return { ...meta, currentTurnId: 0, memorySnapshot: '', usage: undefined }; }, catch: (e) => e instanceof AgentError @@ -230,43 +193,22 @@ export const SessionLayer = Layer.effect( : new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e), }); - const load = (cwd: string, sessionId: string): Effect.Effect => + const load = (cwd: string, sessionId: string, parentSessionId?: string): Effect.Effect => Effect.try({ try: () => { - assertResumeWorkspace(cwd, sessionId); - const paths = computePaths(cwd, sessionId); + assertResumeWorkspace(cwd, sessionId, parentSessionId); + const paths = computePaths(cwd, sessionId, parentSessionId); ensureDirs(paths.transcriptPath); - const idx = readCurrentIndex(paths.indexPath); - if (!idx?.activeProfile) throw new Error('Session index missing activeProfile'); + const meta = readSessionMeta(paths.transcriptPath); + if (!meta) throw new Error('Session file missing session_meta'); - const state: SessionStoreState = { - sessionId: paths.sessionId, - cwd: paths.cwd, - messageCount: 0, - sessionMeta: null, - model: idx?.model ?? '', - permissionMode: idx?.permissionMode ?? 'default', - title: paths.sessionId.slice(0, 8), - currentTurnId: idx?.currentTurnId ?? 0, - usage: idx?.usage ?? undefined, - memorySnapshot: idx?.memorySnapshot ?? '', - activeProfile: idx.activeProfile, + return { + ...meta, + currentTurnId: readLastTurnId(paths.transcriptPath), + memorySnapshot: '', + usage: readLastUsage(paths.transcriptPath), }; - - if (existsSync(paths.transcriptPath)) { - const history = readHistory(paths.transcriptPath); - const meta = history.find((e) => e.type === 'session_meta') as - | SessionMetaEvent - | undefined; - if (meta) { - state.sessionMeta = meta; - state.messageCount = history.filter((e) => e.type !== 'session_meta').length; - } - const firstUser = findFirstUserContent(history); - if (firstUser) state.title = truncateTitle(firstUser); - } - return state; }, catch: (e) => e instanceof AgentError @@ -287,12 +229,7 @@ export const SessionLayer = Layer.effect( content, source: 'user', }; - if (state.title === state.sessionId.slice(0, 8)) { - state.title = truncateTitle(content); - } appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; - updateIndex(state); return event; }, catch: (e) => @@ -314,8 +251,6 @@ export const SessionLayer = Layer.effect( source: 'system', }; appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; - updateIndex(state); return event; }, catch: (e) => @@ -340,8 +275,6 @@ export const SessionLayer = Layer.effect( usage, }; appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; - updateIndex(state); if (usage) { state.usage = usage; } @@ -369,8 +302,6 @@ export const SessionLayer = Layer.effect( output, }; appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; - updateIndex(state); return event; }, catch: (e) => @@ -395,9 +326,7 @@ export const SessionLayer = Layer.effect( summaryText, }; appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; state.usage = undefined; - updateIndex(state); return event; }, catch: (e) => @@ -417,28 +346,10 @@ export const SessionLayer = Layer.effect( throughTurnId, reason, }; - appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; - - const events = readHistory(pathsFromState(state).transcriptPath); - const minRollbackThrough = events.reduce( - (min, ev) => (ev.type === 'rollback' && ev.throughTurnId < min ? ev.throughTurnId : min), - Infinity - ); - let lastUsage: TokenUsage | undefined; - for (let i = events.length - 1; i >= 0; i--) { - const ev = events[i]!; - if ('turnId' in ev && minRollbackThrough <= (ev as { turnId: number }).turnId) { - continue; - } - if (ev.type === 'assistant' && (ev as AssistantEvent).usage) { - lastUsage = (ev as AssistantEvent).usage; - break; - } - } - state.usage = lastUsage; + const transcriptPath = pathsFromState(state).transcriptPath; + appendLine(transcriptPath, event); + state.usage = readLastUsage(transcriptPath); - updateIndex(state); return event; }); @@ -456,33 +367,47 @@ export const SessionLayer = Layer.effect( ): Effect.Effect => Effect.sync(() => { state.title = text; - updateIndex(state); + rewriteSessionMeta(pathsFromState(state).transcriptPath, { title: text }); }); const readHistoryFromState = (state: SessionStoreState): Effect.Effect => Effect.sync(() => readHistory(pathsFromState(state).transcriptPath)); - const listSessionsFromCwd = (cwd?: string): Effect.Effect => + const listSessionsFromCwd = (cwd?: string): Effect.Effect => Effect.sync(() => listSessions(cwd ? encodeProjectPath(cwd) : undefined)); const setPermissionModeByAddress = ( cwd: string, sessionId: string, - mode: PermissionMode + mode: PermissionMode, + parentSessionId?: string ): Effect.Effect => Effect.sync(() => { - const paths = computePaths(cwd, sessionId); - setPermissionMode(sessionId, paths.indexPath, mode); + const paths = computePaths(cwd, sessionId, parentSessionId); + rewriteSessionMeta(paths.transcriptPath, { permissionMode: mode }); }); const setActiveProfile = ( cwd: string, sessionId: string, - profile: ProfileName + profile: ProfileName, + parentSessionId?: string + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId, parentSessionId); + rewriteSessionMeta(paths.transcriptPath, { activeProfile: profile }); + }); + + // 会话级切模型:只写会话首行,不碰 config.yaml + const setModel = ( + cwd: string, + sessionId: string, + model: string, + parentSessionId?: string ): Effect.Effect => Effect.sync(() => { - const paths = computePaths(cwd, sessionId); - writeIndexAtomic(paths.indexPath, { activeProfile: profile }); + const paths = computePaths(cwd, sessionId, parentSessionId); + rewriteSessionMeta(paths.transcriptPath, { model }); }); return { @@ -504,15 +429,28 @@ export const SessionLayer = Layer.effect( appendSummary, rollbackToTurn, - readEvents: (transcriptPath: string): SessionEvent[] => readHistory(transcriptPath), - appendEvent: (transcriptPath: string, event: SessionEvent): void => - appendLine(transcriptPath, event), + readEvents: (transcriptPath: string): Effect.Effect => + Effect.try({ + try: () => readHistory(transcriptPath), + catch: (e) => + new AgentError('SESSION_IO_ERROR', `Failed to read transcript ${transcriptPath}`, e), + }), + appendEvent: ( + transcriptPath: string, + event: SessionEvent + ): Effect.Effect => + Effect.try({ + try: () => appendLine(transcriptPath, event), + catch: (e) => + new AgentError('SESSION_IO_ERROR', `Failed to append event to ${transcriptPath}`, e), + }), readUITurns: (sessionId: string, cwd: string) => Effect.sync(() => readUIHistory(sessionId, cwd)), setPermissionMode: setPermissionModeByAddress, setActiveProfile, + setModel, }; }) ); @@ -528,9 +466,6 @@ function forkSessionImpl(sourceJsonlPath: string, atTurnId: number): string { const sessionsDir = dirname(sourceJsonlPath); const newJsonlPath = join(sessionsDir, `${newSessionId}.jsonl`); - const newIndexPath = join(sessionsDir, `${newSessionId}.index.json`); - - let turnId = 0; for (const ev of chain) { const cloned: any = { ...ev }; @@ -538,46 +473,9 @@ function forkSessionImpl(sourceJsonlPath: string, atTurnId: number): string { if (cloned.type === 'session_meta') { cloned.sessionId = newSessionId; } - if ('turnId' in cloned) { - turnId = Math.max(turnId, cloned.turnId); - } appendLine(newJsonlPath, cloned); } - const sourceIdxPath = sourceJsonlPath.replace('.jsonl', '.index.json'); - let title = newSessionId.slice(0, 8); - let usage: TokenUsage | undefined = undefined; - let permissionMode: PermissionMode = 'default'; - let srcIdx: SessionIndex | undefined; - if (existsSync(sourceIdxPath)) { - try { - srcIdx = JSON.parse(readFileSync(sourceIdxPath, 'utf8')) as SessionIndex; - title = srcIdx.title; - usage = srcIdx.usage ?? undefined; - permissionMode = srcIdx.permissionMode ?? 'default'; - } catch { - /* corrupt */ - } - } - - const meta = chain[0] as SessionMetaEvent | undefined; - const activeProfile = srcIdx?.activeProfile ?? meta?.activeProfile; - if (!activeProfile) throw new Error('Fork source missing activeProfile'); - const newIdx: SessionIndex = { - sessionId: newSessionId, - cwd: meta?.cwd ?? '', - model: srcIdx?.model ?? '', - createdAt: meta?.createdAt ?? new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: countNonMetaEvents(chain), - title, - currentTurnId: turnId, - usage, - permissionMode, - activeProfile, - }; - writeFileSync(newIndexPath, JSON.stringify(newIdx, null, 2), 'utf8'); - return newSessionId; } diff --git a/packages/codingcode/src/subagent/port.ts b/packages/codingcode/src/subagent/port.ts index 282f8c8c..a5895527 100644 --- a/packages/codingcode/src/subagent/port.ts +++ b/packages/codingcode/src/subagent/port.ts @@ -10,7 +10,8 @@ export interface RunSubagentOptions { signal?: AbortSignal; activeProfile?: import('../contracts/types.js').ProfileName; permissionMode?: import('../contracts/permission.js').PermissionMode; - model?: string; + model: string; + systemPrompt?: string; parentSessionId?: string; agentName?: string; } diff --git a/packages/codingcode/src/subagent/subagent.ts b/packages/codingcode/src/subagent/subagent.ts index 5a691270..1150c490 100644 --- a/packages/codingcode/src/subagent/subagent.ts +++ b/packages/codingcode/src/subagent/subagent.ts @@ -17,8 +17,12 @@ export const SubagentRunnerLayer = Layer.effect( cwd: opts.cwd, signal: opts.signal, activeProfile: opts.activeProfile, - permissionMode: opts.permissionMode, + // 子代理不经审批:调用点未给定时固定 bypass,避免继承父会话的审批链路 + permissionMode: opts.permissionMode ?? 'bypass', model: opts.model, + parentSessionId: opts.parentSessionId, + agentName: opts.agentName, + systemPrompt: opts.systemPrompt, }); return { stream: result.stream as AsyncGenerator, unknown>, diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index efbd08b0..e479802d 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -1,38 +1,45 @@ import { z } from 'zod'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; +import { findModel } from '../../../infra/models.js'; import type { ToolDefinition } from '../../types.js'; import { HookService } from '../../../hooks/port.js'; import { SubagentRunnerService } from '../../../subagent/port.js'; -import { resolveSubagentProfile } from '../../../agent/profile.js'; export const dispatchAgentTool: ToolDefinition = { name: 'dispatch_agent', concurrencySafe: false, description: - 'Spawn an isolated subagent to handle specialized tasks. See "Available Subagents" in the system prompt for available profiles and their capabilities.', + 'Delegate a task to a subagent. The subagent runs in the same working directory as you and returns its final output. ' + + 'Keep the delegated write set disjoint from the files you edit yourself.', parameters: z.object({ - agent: z.string().describe('subagent profile name'), + agentName: z.string().min(1).describe('short nickname for the subagent; used for identification and display'), prompt: z.string().min(1).describe('task description for the subagent'), + model: z.string().optional().describe('model id for the subagent; must exist in models.json, otherwise the model of the current turn is used'), + systemPrompt: z.string().optional().describe('replaces the middle section of the subagent system prompt; the environment block and system notes are kept'), }), execute: (args, ctx) => Effect.gen(function* () { const hooks = yield* HookService; const runner = yield* SubagentRunnerService; - const { agent: agentName, prompt } = args as { agent: string; prompt: string }; + const { agentName, prompt, model, systemPrompt } = args as { + agentName: string; prompt: string; model?: string; systemPrompt?: string; + }; const projectPath = ctx?.projectPath || process.cwd(); - const profile = resolveSubagentProfile(agentName); - if (!profile) { + if (!ctx?.activeProfile) { return yield* Effect.fail( - new AgentError('TOOL_EXECUTION_FAILED', `Unknown subagent: ${agentName}`) + new AgentError('CONFIG_MISSING', 'dispatch_agent requires the parent session activeProfile') ); } const parentSessionId = ctx?.sessionId; + // 子代理只跑在模型清单内的模型上,参数空或不在清单里都继承父回合的模型 + const requestedModel = model?.trim(); + const effectiveModel = requestedModel && findModel(requestedModel) ? requestedModel : ctx.model; const spawnDecision = yield* hooks.emitDecision('agent.subagent.spawn.before', { - profile: agentName, prompt, parentSessionId, projectPath, + agentName, prompt, parentSessionId, projectPath, }); if (spawnDecision && spawnDecision.decision === 'deny') { return yield* Effect.fail( @@ -43,12 +50,17 @@ export const dispatchAgentTool: ToolDefinition((resume) => { @@ -79,7 +91,9 @@ export const dispatchAgentTool: ToolDefinition; - executeBatch(toolCalls: ToolCall[], sessionId?: string, opts?: { + executeBatch(toolCalls: ToolCall[], sessionId: string | undefined, opts: { turnId?: number; projectPath?: string; signal?: AbortSignal; toolLookup?: ToolLookup; + activeProfile?: ProfileName; + model: string; }): Effect.Effect; } diff --git a/packages/codingcode/src/tools/tools.ts b/packages/codingcode/src/tools/tools.ts index 6a5b80ac..9ec7a24c 100644 --- a/packages/codingcode/src/tools/tools.ts +++ b/packages/codingcode/src/tools/tools.ts @@ -1,7 +1,7 @@ import { Layer, Effect } from 'effect'; import { AgentError } from '../core/error.js'; import { HookService } from '../hooks/port.js'; -import type { ToolCall } from '../contracts/types.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 { ToolExecutorService } from './port.js'; @@ -13,28 +13,30 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu function execute( name: string, args: unknown, - opts?: { + opts: { signal?: AbortSignal; sessionId?: string; turnId?: number; projectPath?: string; callId?: string; toolLookup?: ToolLookup; + activeProfile?: ProfileName; + model: string; } ): Effect.Effect<{ output: string }, AgentError> { return Effect.gen(function* () { - const tool = opts?.toolLookup?.(name); + const tool = opts.toolLookup?.(name); if (!tool) return yield* Effect.fail(AgentError.toolNotFound(name)); const finalArgs = args as Record; - const callId = opts?.callId; + const callId = opts.callId; yield* hooks.emit('tool.execute.before', { toolName: name, args: finalArgs, - sessionId: opts?.sessionId, - turnId: opts?.turnId, - projectPath: opts?.projectPath, + sessionId: opts.sessionId, + turnId: opts.turnId, + projectPath: opts.projectPath, callId, }); @@ -43,15 +45,17 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu // Execute tool — now returns Effect directly const ctx = { - signal: opts?.signal, - sessionId: opts?.sessionId, - projectPath: opts?.projectPath, + signal: opts.signal, + sessionId: opts.sessionId, + projectPath: opts.projectPath, + activeProfile: opts.activeProfile, + model: opts.model, }; let toolEffect = tool.execute(parsedArgs, ctx); - if (opts?.signal) { + if (opts.signal) { if (opts.signal.aborted) { return yield* Effect.fail(new AgentError('TOOL_NOT_ALLOWED', 'Tool execution aborted')); } @@ -73,9 +77,9 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu args: finalArgs, result, durationMs: Date.now() - start, - sessionId: opts?.sessionId, - turnId: opts?.turnId, - projectPath: opts?.projectPath, + sessionId: opts.sessionId, + turnId: opts.turnId, + projectPath: opts.projectPath, callId, }); @@ -86,7 +90,7 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu toolName: name, args: args as Record, error, - projectPath: opts?.projectPath, + projectPath: opts.projectPath, }) ) ); @@ -94,12 +98,14 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu function execSingle( tc: ToolCall, - sessionId?: string, - opts?: { + sessionId: string | undefined, + opts: { turnId?: number; projectPath?: string; signal?: AbortSignal; toolLookup?: ToolLookup; + activeProfile?: ProfileName; + model: string; } ): Effect.Effect { return execute(tc.name, tc.arguments ?? {}, { sessionId, callId: tc.id, ...opts }).pipe( @@ -161,19 +167,21 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu function executeBatch( toolCalls: ToolCall[], - sessionId?: string, - opts?: { + sessionId: string | undefined, + opts: { turnId?: number; projectPath?: string; signal?: AbortSignal; toolLookup?: ToolLookup; + activeProfile?: ProfileName; + model: string; } ): Effect.Effect { return Effect.gen(function* () { const runTool = (tc: ToolCall): Effect.Effect => Effect.suspend(() => - opts?.signal?.aborted + opts.signal?.aborted ? Effect.succeed({ status: 'denied' as const, id: tc.id, @@ -184,7 +192,7 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu ); const waveResults = yield* Effect.forEach( - splitWaves(toolCalls, opts?.toolLookup), + splitWaves(toolCalls, opts.toolLookup), (wave) => Effect.forEach(wave, runTool, { concurrency: 'unbounded' }), { concurrency: 1 } ); diff --git a/packages/codingcode/test/agent/abort.test.ts b/packages/codingcode/test/agent/abort.test.ts index 69c01138..93a78f1d 100644 --- a/packages/codingcode/test/agent/abort.test.ts +++ b/packages/codingcode/test/agent/abort.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { makeState, runAgentTurn, textDeltas } from '../helpers/agent-harness.js'; import type { FrameBody, Transition } from '../../src/contracts/frame.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/agent-cache-stability.test.ts b/packages/codingcode/test/agent/agent-cache-stability.test.ts index 858a51a8..6e83c82a 100644 --- a/packages/codingcode/test/agent/agent-cache-stability.test.ts +++ b/packages/codingcode/test/agent/agent-cache-stability.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { makeState, runAgentTurn, llmStream, pEnd } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/agent-concurrent.test.ts b/packages/codingcode/test/agent/agent-concurrent.test.ts index 42f87510..3161c054 100644 --- a/packages/codingcode/test/agent/agent-concurrent.test.ts +++ b/packages/codingcode/test/agent/agent-concurrent.test.ts @@ -10,7 +10,7 @@ import { toolResults, } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/agent-todo-event.test.ts b/packages/codingcode/test/agent/agent-todo-event.test.ts index e15c2704..3061240b 100644 --- a/packages/codingcode/test/agent/agent-todo-event.test.ts +++ b/packages/codingcode/test/agent/agent-todo-event.test.ts @@ -11,7 +11,7 @@ import { type HarnessMocks, } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index 4331ab62..bd904731 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -12,7 +12,7 @@ import { endReason, } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index 5c9ddc74..01056ba7 100644 --- a/packages/codingcode/test/agent/hooks-deps-type.test.ts +++ b/packages/codingcode/test/agent/hooks-deps-type.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; import { makeState, runAgentTurn, llmStream, pText, pEnd, endReason } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/loop-options.test.ts b/packages/codingcode/test/agent/loop-options.test.ts index 620ff5b9..949eaa7e 100644 --- a/packages/codingcode/test/agent/loop-options.test.ts +++ b/packages/codingcode/test/agent/loop-options.test.ts @@ -2,7 +2,7 @@ import { expect, it, describe, vi } from 'vitest'; import { Effect } from 'effect'; import { makeState, runAgentTurn, llmStream, pText, pEnd, endReason } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index fe956d60..6a46d531 100644 --- a/packages/codingcode/test/agent/memory-snapshot.test.ts +++ b/packages/codingcode/test/agent/memory-snapshot.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { makeState, runAgentTurn, llmStream, pEnd } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/stop-hook.test.ts b/packages/codingcode/test/agent/stop-hook.test.ts index ec854f77..127e64b8 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.test.ts @@ -10,7 +10,7 @@ import { endReason, } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 100, maxStopContinuations: 2, 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 7663fbbe..45460a2f 100644 --- a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts +++ b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts @@ -10,7 +10,7 @@ import { endReason, } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/approval/pipeline.test.ts b/packages/codingcode/test/approval/pipeline.test.ts index 60197d74..75777fbb 100644 --- a/packages/codingcode/test/approval/pipeline.test.ts +++ b/packages/codingcode/test/approval/pipeline.test.ts @@ -41,7 +41,7 @@ describe('Approval Pipeline — PermissionMode auto-allow (merged from ReadonlyW { ruleEngine: createRuleEngine(rules), destructiveTools: new Set(), - permissionMode: 'default', + permissionMode: 'ask', sessionId: 'test', } ) @@ -50,14 +50,14 @@ describe('Approval Pipeline — PermissionMode auto-allow (merged from ReadonlyW expect((decision as any).source).toContain('rule:'); }); - it('default mode does NOT auto-allow read-only tools (no UI → system deny)', async () => { + it('ask mode does NOT auto-allow read-only tools (no UI → system deny)', async () => { const decision = await runWithLayer( runPipeline( { tool: 'read_file', input: { path: '/safe/file.txt' } }, { ruleEngine: createRuleEngine(), destructiveTools: new Set(), - permissionMode: 'default', + permissionMode: 'ask', sessionId: 'test', } ) diff --git a/packages/codingcode/test/architecture/boundaries.test.ts b/packages/codingcode/test/architecture/boundaries.test.ts index 20b190b4..997929b5 100644 --- a/packages/codingcode/test/architecture/boundaries.test.ts +++ b/packages/codingcode/test/architecture/boundaries.test.ts @@ -4,8 +4,11 @@ import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; // 架构边界回归:把 issue 里的 R1–R4 固化成断言,防止再次出现"半倒置"。 +// 扫描根是两个包:core 引擎(packages/codingcode/src)与接入层(packages/sdk/src)。 const HERE = dirname(fileURLToPath(import.meta.url)); -const SRC = resolve(HERE, '../../src'); +const PACKAGES = resolve(HERE, '../../..'); +const SRC = resolve(PACKAGES, 'codingcode/src'); +const SDK_SRC = resolve(PACKAGES, 'sdk/src'); const norm = (p: string) => p.replace(/\\/g, '/'); @@ -19,7 +22,9 @@ function listTs(dir: string): string[] { return out; } -const FILES = listTs(SRC); +/** R4 的作用域是后端:sdk 是独立的客户端协议层,允许自持同形但同名的类型。 */ +const CORE_FILES = listTs(SRC); +const FILES = [...CORE_FILES, ...listTs(SDK_SRC)]; /** 抽取文件里的模块说明符(静态 import 与动态 import) */ function specifiersOf(file: string): string[] { @@ -37,6 +42,10 @@ function resolveSpec(file: string, spec: string): string | null { } const relSrc = (abs: string) => norm(relative(SRC, abs)); +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')) + '/'); @@ -52,7 +61,7 @@ describe('R1 契约不得 import 实现', () => { if (target === null) continue; // 第三方库 const sameDir = dirname(target) === dirname(file); if (!isCore(target) && !isContracts(target) && !sameDir) { - violations.push(`${relSrc(file)} → ${spec}`); + violations.push(`${relPkg(file)} → ${spec}`); } } } @@ -80,7 +89,7 @@ describe('R3 core 零内部依赖', () => { for (const spec of specifiersOf(file)) { const target = resolveSpec(file, spec); if (target === null) continue; // 第三方 / 其他 workspace 包 - if (!isCore(target)) violations.push(`${relSrc(file)} → ${spec}`); + if (!isCore(target)) violations.push(`${relPkg(file)} → ${spec}`); } } expect(violations).toEqual([]); @@ -99,11 +108,11 @@ describe('core/ 准入:只承载通用件', () => { for (const spec of specifiersOf(file)) { if (spec.startsWith('.')) { const target = resolveSpec(file, spec)!; - if (dirname(target) !== dirname(file)) violations.push(`${relSrc(file)} → ${spec}`); + if (dirname(target) !== dirname(file)) violations.push(`${relPkg(file)} → ${spec}`); continue; } if (!NODE_BUILTINS.has(spec.replace(/^node:/, ''))) { - violations.push(`${relSrc(file)} → ${spec}`); + violations.push(`${relPkg(file)} → ${spec}`); } } } @@ -119,7 +128,7 @@ describe('contracts/ 准入:不得依赖领域实现', () => { const target = resolveSpec(file, spec); if (target === null) continue; const sameDir = dirname(target) === dirname(file); - if (!isCore(target) && !sameDir) violations.push(`${relSrc(file)} → ${spec}`); + if (!isCore(target) && !sameDir) violations.push(`${relPkg(file)} → ${spec}`); } } expect(violations).toEqual([]); @@ -150,7 +159,10 @@ describe('R4 一个概念只允许一处类型定义', () => { HookDecision: 'contracts/hooks.ts', Skill: 'contracts/skill.ts', McpServerConfig: 'contracts/mcp.ts', - McpStatus: 'contracts/mcp.ts', + Automation: 'contracts/automation.ts', + AutomationSandbox: 'contracts/automation.ts', + CreateAutomationInput: 'contracts/automation.ts', + UpdateAutomationInput: 'contracts/automation.ts', }; it.each(Object.entries(CANONICAL))('%s 只在 %s 声明一次', (name, expected) => { @@ -158,7 +170,7 @@ describe('R4 一个概念只允许一处类型定义', () => { `^export\\s+(?:declare\\s+)?(?:abstract\\s+)?(?:interface|type|class)\\s+${name}\\b`, 'm' ); - const owners = 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)]); }); }); @@ -173,7 +185,7 @@ describe('相对 import 必须可解析', () => { const ok = ['.ts', '.tsx', '/index.ts', '/index.tsx'].some((ext) => existsSync(target + ext) ); - if (!ok) missing.push(`${relSrc(file)} → ${spec}`); + if (!ok) missing.push(`${relPkg(file)} → ${spec}`); } } expect(missing).toEqual([]); diff --git a/packages/codingcode/test/context/append-turn-end.test.ts b/packages/codingcode/test/context/append-turn-end.test.ts index 2bdd08de..e7ee9459 100644 --- a/packages/codingcode/test/context/append-turn-end.test.ts +++ b/packages/codingcode/test/context/append-turn-end.test.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'crypto'; import { estimateTokensForContent } from '../../src/context/tokens.js'; import { useTempProjectBase } from '../helpers/project-base.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ context: { compactionModel: '', diff --git a/packages/codingcode/test/context/budget-integration.test.ts b/packages/codingcode/test/context/budget-integration.test.ts index f80f75a4..81953c41 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 { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; +import { LLMService } from '../../src/llm/port.js'; import type { SessionEvent } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; import { ContextLayer } from '../../src/context/context.js'; @@ -16,13 +16,9 @@ const base = useTempProjectBase(); const TestLayer = Layer.merge( SessionLayer, - Layer.succeed(LLMFactoryService, { - listModels: () => Effect.succeed([]), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new Error('no active model')), - switchModel: () => Effect.fail(new Error('no models')), - createClient: () => Effect.fail(new Error('no client')), - getLLMClient: () => Effect.fail(new Error('no client')), + Layer.succeed(LLMService, { + complete: () => Effect.fail(new Error('no llm')), + completeStream: () => (async function* () {})(), } as any) ); @@ -39,24 +35,23 @@ describe('assemblePayload integration', () => { let sessionId: string; let sessionDir: string; let jsonlPath: string; - let indexPath: string; beforeEach(() => { sessionId = randomUUID(); sessionDir = join(base.dir, projectSlug, 'sessions'); mkdirSync(sessionDir, { recursive: true }); jsonlPath = join(sessionDir, `${sessionId}.jsonl`); - indexPath = join(sessionDir, `${sessionId}.index.json`); const lines: any[] = [ { type: 'session_meta', sessionId, cwd: '/tmp/test', - createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'q1' }, { @@ -84,21 +79,6 @@ describe('assemblePayload integration', () => { }, ]; writeFileSync(jsonlPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - - const idx = { - sessionId, - cwd: '/tmp/test', - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: lines.length, - title: 'fixture', - currentTurnId: 1, - usage: undefined, - permissionMode: 'default', - activeProfile: 'build', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); }); afterEach(() => { @@ -108,7 +88,7 @@ describe('assemblePayload integration', () => { it('returns messages assembled from the transcript', async () => { const ctx = await getCtxService(); - const messages = await ctx.assemblePayload(jsonlPath, 128000, null); + const messages = await Effect.runPromise(ctx.assemblePayload(jsonlPath, 'test-model')); expect(messages.length).toBeGreaterThan(0); }); @@ -117,7 +97,7 @@ describe('assemblePayload integration', () => { const emptyJsonl = join(sessionDir, `${sessionId}-empty.jsonl`); writeFileSync(emptyJsonl, '', 'utf8'); const ctx = await getCtxService(); - const messages = await ctx.assemblePayload(emptyJsonl, 128000, null); + const messages = await Effect.runPromise(ctx.assemblePayload(emptyJsonl, 'test-model')); expect(messages).toEqual([]); }); }); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index e05e900f..ad2e01b5 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; @@ -7,22 +7,26 @@ import { ContextService } from '../../../src/context/port.js'; import type { ContextShape } from '../../../src/context/port.js'; import { SessionService } from '../../../src/session/port.js'; import { SessionLayer } from '../../../src/session/session.js'; -import { LLMFactoryService } from '../../../src/llm/port.js'; -import type { LLMClient } from '../../../src/contracts/provider.js'; -import type { SessionIndex, SessionEvent, SummaryEvent } from '../../../src/contracts/session.js'; +import { LLMService } from '../../../src/llm/port.js'; +import type { SessionEvent, SummaryEvent } from '../../../src/contracts/session.js'; import { filterForContext, buildContextMessages } 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'; import { ContextLayer } from '../../../src/context/context.js'; +// 上下文窗口现在由 catalog 按模型值现取,测试里钉死成一个可控值 +const windowState = vi.hoisted(() => ({ value: 128000 })); +vi.mock('../../../src/infra/models.js', () => ({ + contextWindowOf: () => windowState.value, +})); + const base = useTempProjectBase(); interface FixtureOptions { numTurns: number; toolContentSize?: number; toolName?: string; - currentTurnId?: number; } function makeFixture(opts: FixtureOptions) { @@ -31,7 +35,6 @@ function makeFixture(opts: FixtureOptions) { const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); - const indexPath = join(dir, `${sessionId}.index.json`); const lines: any[] = [ { @@ -39,6 +42,10 @@ function makeFixture(opts: FixtureOptions) { sessionId, cwd: '/tmp/test', createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, ]; @@ -66,22 +73,7 @@ function makeFixture(opts: FixtureOptions) { writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd: '/tmp/test', - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: opts.numTurns * 3, - title: 'fixture', - currentTurnId: opts.currentTurnId ?? opts.numTurns, - usage: undefined, - activeProfile: 'build', - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { sessionId, slug, dir, transcriptPath, indexPath }; + return { sessionId, slug, dir, transcriptPath }; } function cleanup(slug: string) { @@ -98,7 +90,7 @@ function readSummaryEvents(jsonlPath: string): SummaryEvent[] { .filter((ev): ev is SummaryEvent => ev.type === 'summary'); } -function makeMockLLM(content: string): LLMClient { +function makeMockLLM(content: string) { return { complete: () => Effect.succeed({ content }), completeStream: () => @@ -106,36 +98,29 @@ function makeMockLLM(content: string): LLMClient { yield { type: 'text' as const, text: content }; yield { type: 'end' as const }; })(), - modelInfo: { - provider: 'mock', - model: 'mock', - maxTokens: 1000, - supportsToolCalling: false, - supportsStreaming: true, - }, - }; + } as any; } -const TestLayer = Layer.merge( - SessionLayer, - Layer.succeed(LLMFactoryService, { - listModels: () => Effect.succeed([]), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new Error('no active model')), - switchModel: () => Effect.fail(new Error('no models')), - createClient: () => Effect.fail(new Error('no client')), - getLLMClient: () => Effect.fail(new Error('no client')), - } as any) -); - -async function getCtxService(): Promise { +const FailingLLM = { + complete: () => Effect.fail(new Error('no llm')), + completeStream: () => (async function* () {})(), +} as any; + +function makeTestLayer(llm: unknown) { + return Layer.merge(SessionLayer, Layer.succeed(LLMService, llm as any)); +} + +async function getCtxService(llm: unknown): Promise { return Effect.runPromise( Effect.gen(function* () { return yield* ContextService; - }).pipe(Effect.provide(ContextLayer), Effect.provide(TestLayer)) + }).pipe(Effect.provide(ContextLayer), Effect.provide(makeTestLayer(llm))) ); } +/** ContextShape 现在返回 Effect,测试统一用 runPromise 驱动 */ +const run = (eff: Effect.Effect) => Effect.runPromise(eff); + describe('compressor behavior', () => { describe('L5 compaction', () => { it('writes summary event with five-section system summary', async () => { @@ -143,9 +128,9 @@ describe('compressor behavior', () => { try { const summary = '## Compacted History\n\n### Goal\nfix bug\n\n### Instructions\nbe careful\n\n### Discoveries\nrace condition\n\n### Accomplished\npatched\n\n### Relevant Files\nsrc/x.ts'; - const llm = makeMockLLM(summary); - const ctx = await getCtxService(); - await ctx.compactWithLLM(fx.transcriptPath, llm.modelInfo.maxTokens, llm); + windowState.value = 1000; + const ctx = await getCtxService(makeMockLLM(summary)); + await run(ctx.compactWithLLM(fx.transcriptPath, 'test-model')); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries.length).toBe(1); expect(summaries[0]!.summaryText).toContain('### Goal'); @@ -159,8 +144,9 @@ describe('compressor behavior', () => { it('returns no-op when no LLM available', async () => { const fx = makeFixture({ numTurns: 5 }); try { - const ctx = await getCtxService(); - const result = await ctx.compactWithLLM(fx.transcriptPath, 1000, null); + windowState.value = 1000; + const ctx = await getCtxService(FailingLLM); + const result = await run(ctx.compactWithLLM(fx.transcriptPath, 'test-model')); expect(result.didCompress).toBe(false); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries).toHaveLength(0); @@ -174,11 +160,13 @@ describe('compressor behavior', () => { it('appends summary event directly to JSONL after L5', async () => { const fx = makeFixture({ numTurns: 5 }); try { - const llm = makeMockLLM( - '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' + windowState.value = 1000; + const ctx = await getCtxService( + makeMockLLM( + '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' + ) ); - const ctx = await getCtxService(); - await ctx.compactWithLLM(fx.transcriptPath, llm.modelInfo.maxTokens, llm); + await run(ctx.compactWithLLM(fx.transcriptPath, 'test-model')); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries).toHaveLength(1); @@ -198,11 +186,13 @@ describe('compressor behavior', () => { readHistory(fx.transcriptPath) ); const before = estimateTokens(buildContextMessages(bVisible, bCompacted)); - const llm = makeMockLLM( - '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' + windowState.value = 1000; + const ctx = await getCtxService( + makeMockLLM( + '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' + ) ); - const ctx = await getCtxService(); - const result = await ctx.compactWithLLM(fx.transcriptPath, llm.modelInfo.maxTokens, llm); + const result = await run(ctx.compactWithLLM(fx.transcriptPath, 'test-model')); expect(result.didCompress).toBe(true); expect(result.promptEstimate).toBeGreaterThan(0); expect(result.promptEstimate).toBeLessThan(before); @@ -220,8 +210,9 @@ describe('compressor behavior', () => { it('folds history into a compacted summary message when it exceeds the window', async () => { const fx = makeFixture({ numTurns: 3, toolContentSize: 8000 }); try { - const ctx = await getCtxService(); - const messages = await ctx.assemblePayload(fx.transcriptPath, 1000, makeMockLLM(SUMMARY)); + windowState.value = 1000; + const ctx = await getCtxService(makeMockLLM(SUMMARY)); + const messages = await run(ctx.assemblePayload(fx.transcriptPath, 'test-model')); expect(messages.length).toBeGreaterThan(0); expect(messages.some((m) => m.name === 'compacted_history')).toBe(true); } finally { @@ -232,12 +223,9 @@ describe('compressor behavior', () => { it('leaves history uncompacted when it fits the window', async () => { const fx = makeFixture({ numTurns: 2, toolContentSize: 20 }); try { - const ctx = await getCtxService(); - const messages = await ctx.assemblePayload( - fx.transcriptPath, - 2_000_000, - makeMockLLM(SUMMARY) - ); + windowState.value = 2_000_000; + const ctx = await getCtxService(makeMockLLM(SUMMARY)); + const messages = await run(ctx.assemblePayload(fx.transcriptPath, 'test-model')); expect(messages.some((m) => m.name === 'compacted_history')).toBe(false); } finally { cleanup(fx.slug); diff --git a/packages/codingcode/test/context/compressor/llm-resolver.test.ts b/packages/codingcode/test/context/compressor/llm-resolver.test.ts deleted file mode 100644 index 0275977c..00000000 --- a/packages/codingcode/test/context/compressor/llm-resolver.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { Effect } from 'effect'; -import { LLMFactoryService } from '../../../src/llm/port.js'; -import { AgentError } from '../../../src/core/error.js'; -import type { LLMClient } from '../../../src/contracts/provider.js'; -import type { SelectableModel } from '../../../src/contracts/provider.js'; - -const { mockFindModel, mockCreateClient } = vi.hoisted(() => ({ - mockFindModel: vi.fn(() => Effect.succeed(null)), - mockCreateClient: vi.fn(() => Effect.succeed(null)), -})); - -const mockFactory = { - listModels: () => Effect.succeed([]), - findModel: mockFindModel, - getActiveEntry: () => Effect.fail(new AgentError('CONFIG_INVALID', 'no active entry')), - switchModel: (_id: string) => Effect.fail(new AgentError('CONFIG_INVALID', 'not found')), - createClient: mockCreateClient, - getLLMClient: () => Effect.fail(new AgentError('CONFIG_INVALID', 'no client')), -}; - -import { resolveLLM } from '../../../src/llm/llm-resolver.js'; - -const fakeFallback: LLMClient = { - complete: () => Effect.succeed({ content: '' }), - completeStream: () => - (async function* () { - yield { type: 'end' as const }; - })(), - modelInfo: { - provider: 'fake', - model: 'fake', - maxTokens: 1, - supportsToolCalling: false, - supportsStreaming: false, - }, -}; - -async function runResolveLLM(target: string | null | undefined, fallback: LLMClient | null) { - return Effect.runPromise( - resolveLLM(target, fallback).pipe(Effect.provideService(LLMFactoryService, mockFactory as any)) - ); -} - -describe('resolveLLM (compaction)', () => { - afterEach(() => { - vi.resetAllMocks(); - mockFindModel.mockReturnValue(Effect.succeed(null)); - mockCreateClient.mockReturnValue(Effect.succeed(null)); - }); - - it('returns fallback when target is empty', async () => { - const result = await runResolveLLM('', fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns fallback when target is whitespace-only', async () => { - const result = await runResolveLLM(' ', fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns fallback when target is null', async () => { - const result = await runResolveLLM(null, fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns fallback when target is undefined', async () => { - const result = await runResolveLLM(undefined, fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns null when target empty and fallback is null', async () => { - const result = await runResolveLLM('', null); - expect(result).toBeNull(); - }); - - it('returns fallback when model not found', async () => { - mockFindModel.mockReturnValue(Effect.succeed(null)); - const result = await runResolveLLM('definitely-not-a-real-model-xyz', fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns fallback when createClient throws', async () => { - mockFindModel.mockReturnValue(Effect.succeed({ id: 'test-model' } as SelectableModel) as any); - mockCreateClient.mockReturnValue( - Effect.fail(new AgentError('CONFIG_MISSING', 'creation failed')) as any - ); - const result = await runResolveLLM('test-model', fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns fallback when createClient returns error', async () => { - mockFindModel.mockReturnValue(Effect.succeed({ id: 'test-model' } as SelectableModel) as any); - mockCreateClient.mockReturnValue(Effect.fail(new AgentError('CONFIG_INVALID', 'error')) as any); - const result = await runResolveLLM('test-model', fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns created client on success', async () => { - const client = { modelInfo: { maxTokens: 100 } } as LLMClient; - mockFindModel.mockReturnValue(Effect.succeed({ id: 'test-model' } as SelectableModel) as any); - mockCreateClient.mockReturnValue(Effect.succeed(client) as any); - const result = await runResolveLLM('test-model', fakeFallback); - expect(result).toBe(client); - }); -}); diff --git a/packages/codingcode/test/context/organizer.test.ts b/packages/codingcode/test/context/organizer.test.ts index f1373131..c712c4bb 100644 --- a/packages/codingcode/test/context/organizer.test.ts +++ b/packages/codingcode/test/context/organizer.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from 'effect'; import { ContextService } from '../../src/context/port.js'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; +import { LLMService } from '../../src/llm/port.js'; import type { SessionEvent, ToolResultEvent } from '../../src/contracts/session.js'; import { ContextLayer } from '../../src/context/context.js'; @@ -41,13 +41,9 @@ function makeToolResult( const TestLayer = Layer.merge( SessionLayer, - Layer.succeed(LLMFactoryService, { - listModels: () => Effect.succeed([]), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new Error('no active model')), - switchModel: () => Effect.fail(new Error('no models')), - createClient: () => Effect.fail(new Error('no client')), - getLLMClient: () => Effect.fail(new Error('no client')), + Layer.succeed(LLMService, { + complete: () => Effect.fail(new Error('no llm')), + completeStream: () => (async function* () {})(), } as any) ); diff --git a/packages/codingcode/test/core/error.test.ts b/packages/codingcode/test/core/error.test.ts index 7e4bd280..8aa5bf5b 100644 --- a/packages/codingcode/test/core/error.test.ts +++ b/packages/codingcode/test/core/error.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest'; import { AgentError } from '../../src/core/error.js'; -import { ApiError } from '../../src/contracts/error.js'; describe('AgentError.httpStatus', () => { it('returns 400 for CONFIG_MISSING', () => { @@ -38,23 +37,3 @@ describe('AgentError.httpStatus', () => { expect(err.httpStatus()).toBe(500); }); }); - -describe('ApiError', () => { - it('formats message from body.message when provided', () => { - const err = new ApiError(404, '/api/agent/permission-mode', { - code: 'NOT_FOUND', - message: 'gone', - }); - expect(err.message).toBe('gone'); - expect(err.status).toBe(404); - expect(err.path).toBe('/api/agent/permission-mode'); - expect(err.body?.code).toBe('NOT_FOUND'); - expect(err.name).toBe('ApiError'); - }); - - it('falls back to "HTTP : " when body missing', () => { - const err = new ApiError(500, '/x'); - expect(err.message).toBe('HTTP 500: /x'); - expect(err.body).toBeUndefined(); - }); -}); diff --git a/packages/codingcode/test/core/frame-io.test.ts b/packages/codingcode/test/core/frame-io.test.ts index 46958f8e..64ab3e35 100644 --- a/packages/codingcode/test/core/frame-io.test.ts +++ b/packages/codingcode/test/core/frame-io.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createFrameAssembler, decodeFrame, encodeFrame } from '../../src/contracts/frame-io.js'; +import { createFrameAssembler } from '../../src/contracts/frame-io.js'; import type { FrameBody } from '../../src/contracts/frame.js'; // ---- body builders ---- @@ -37,94 +37,3 @@ describe('createFrameAssembler — envelope', () => { expect(a.stamp(start(2))).toMatchObject({ seq: 2, turnId: 2 }); }); }); - -// ---- codec ---- - -describe('frame codec', () => { - it('round-trips an assembled frame through encode/decode', () => { - const f = createFrameAssembler({ sessionId: 's' }); - const stamped = f.stamp(start(3)); - const decoded = decodeFrame(JSON.parse(encodeFrame(stamped))); - expect(decoded.ok).toBe(true); - if (decoded.ok) expect(decoded.frame).toEqual(stamped); - }); - - it('rejects a non-object payload', () => { - expect(decodeFrame('nope')).toMatchObject({ ok: false, reason: 'shape' }); - }); - - it('rejects a payload missing envelope fields', () => { - expect(decodeFrame({ family: 'event' })).toMatchObject({ ok: false, reason: 'shape' }); - }); - - it('rejects a non-numeric, non-null turnId', () => { - expect( - decodeFrame({ sessionId: 's', turnId: 'x', seq: 1, family: 'fatal', fatal: { message: 'm', code: 'c' } }) - ).toMatchObject({ ok: false, reason: 'shape' }); - }); - - it('rejects an unknown family', () => { - expect(decodeFrame({ sessionId: 's', turnId: null, seq: 1, family: 'nope' })).toMatchObject({ - ok: false, - reason: 'unknown-family', - }); - }); - - it('rejects an unknown transition target', () => { - expect( - decodeFrame({ sessionId: 's', turnId: 1, seq: 1, family: 'transition', transition: { to: 'zzz' } }) - ).toMatchObject({ ok: false, reason: 'unknown-transition' }); - }); - - it('accepts a compress frame', () => { - const decoded = decodeFrame({ - sessionId: 's', - turnId: 1, - seq: 1, - family: 'transition', - transition: { to: 'compress' }, - }); - expect(decoded.ok).toBe(true); - }); - - it('rejects an unknown event type', () => { - expect( - decodeFrame({ sessionId: 's', turnId: 1, seq: 1, family: 'event', event: { type: 'zzz' } }) - ).toMatchObject({ ok: false, reason: 'unknown-event' }); - }); - - it('rejects an end(error) frame without a well-formed error', () => { - expect( - decodeFrame({ - sessionId: 's', - turnId: 1, - seq: 1, - family: 'transition', - transition: { to: 'end', reason: 'error' }, - }) - ).toMatchObject({ ok: false, reason: 'shape' }); - }); - - it('rejects a tool_result with a malformed outcome', () => { - expect( - decodeFrame({ - sessionId: 's', - turnId: 1, - seq: 1, - family: 'event', - event: { type: 'tool_result', id: 't1', name: 'bash', outcome: { status: 'ok' } }, - }) - ).toMatchObject({ ok: false, reason: 'shape' }); - }); - - it('accepts a denied outcome carrying a reason', () => { - const decoded = decodeFrame({ - sessionId: 's', - turnId: 1, - seq: 1, - family: 'event', - event: { type: 'tool_result', id: 't1', name: 'bash', outcome: { status: 'denied', reason: 'no' } }, - }); - expect(decoded.ok).toBe(true); - }); -}); diff --git a/packages/codingcode/test/core/path.test.ts b/packages/codingcode/test/core/path.test.ts index ccb47039..4e0cf2d2 100644 --- a/packages/codingcode/test/core/path.test.ts +++ b/packages/codingcode/test/core/path.test.ts @@ -11,7 +11,6 @@ import { getGlobalDir, getProjectDir, } from '../../src/core/path.js'; -import { CODINGCODE_DIRNAME as INFRA_DIRNAME } from '@codingcode/infra/paths'; import { ShadowGit } from '../../src/checkpoint/shadow-git.js'; import { setFakeHome, restoreHome } from '../helpers/temp-home.js'; @@ -65,10 +64,8 @@ describe('core/path', () => { }); describe('core/path:全局与工作区共用的私有目录名', () => { - it('目录名字面量被钉住:改它必须显式改这里(含 infra 那份副本)', () => { + it('目录名字面量被钉住:改它必须显式改这里', () => { expect(CODINGCODE_DIRNAME).toBe('.codingcode'); - // infra 是独立 workspace 包、位于更低层,无法 import codingcode,只能各持一份 - expect(INFRA_DIRNAME).toBe(CODINGCODE_DIRNAME); }); it('全局目录落在用户目录下', () => { diff --git a/packages/codingcode/test/helpers/agent-harness.ts b/packages/codingcode/test/helpers/agent-harness.ts index 515290ab..b599562d 100644 --- a/packages/codingcode/test/helpers/agent-harness.ts +++ b/packages/codingcode/test/helpers/agent-harness.ts @@ -12,7 +12,7 @@ import { ApprovalService } from '../../src/approval/port.js'; import { CheckpointService } from '../../src/checkpoint/port.js'; import { ContextService } from '../../src/context/port.js'; import { HookService } from '../../src/hooks/port.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; +import { LLMService } from '../../src/llm/port.js'; import { McpService } from '../../src/mcp/port.js'; import { MemoryService } from '../../src/memory/port.js'; import { RulesService } from '../../src/rules/port.js'; @@ -154,22 +154,22 @@ export interface HarnessMocks { export function makeState(partial: Partial = {}): SessionStoreState { return { + type: 'session_meta', sessionId: 'test-sid', cwd: '/tmp', - messageCount: 0, - sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, + createdAt: new Date().toISOString(), model: 'test-model', title: 'test', - currentTurnId: 1, - usage: undefined, activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', + currentTurnId: 1, memorySnapshot: '', + usage: undefined, ...partial, } as SessionStoreState; } -export function makeDefaultMocks(overrides: Partial = {}): HarnessMocks { +export function makeHarnessMocks(overrides: Partial = {}): HarnessMocks { const llm = overrides.llm ?? ({ @@ -199,6 +199,7 @@ export interface RunAgentOptions { signal?: AbortSignal; activeProfile?: 'plan' | 'build'; permissionMode?: string; + model?: string; } export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { @@ -244,13 +245,16 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), }; const context = { - willCompact: async () => (mocks.contextWillCompact ? mocks.contextWillCompact() : false), - assemblePayload: async () => - mocks.contextAssemble ? mocks.contextAssemble() : [{ role: 'user' as const, content: 'hi' }], + willCompact: () => + Effect.promise(async () => (mocks.contextWillCompact ? mocks.contextWillCompact() : false)), + assemblePayload: () => + Effect.promise(async () => + mocks.contextAssemble ? mocks.contextAssemble() : [{ role: 'user' as const, content: 'hi' }] + ), }; const memory = { - loadMemoryForPrompt: () => mocks.memorySnapshot ?? '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + loadMemoryForPrompt: () => Effect.succeed(mocks.memorySnapshot ?? ''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), }; const mcpLayer = Layer.succeed(McpService, { @@ -271,10 +275,14 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { Layer.succeed(SkillService, skills as any), Layer.succeed(ContextService, context as any), Layer.succeed(MemoryService, memory as any), - Layer.succeed(LLMFactoryService, { getLLMClient: () => Effect.succeed(mocks.llm) } as any), + Layer.succeed(LLMService, { + complete: () => Effect.fail(new Error('complete not implemented in harness')), + completeStream: (params: any, model: string, signal?: AbortSignal) => + mocks.llm.completeStream(params, model, signal), + } as any), Layer.succeed(RulesService, { - getAllRules: () => '', - evictProjectRules: () => {}, + getAllRules: () => Effect.succeed(''), + evictProjectRules: () => Effect.void, } as any), // agent 与 todo_write 工具消费同一个 TodoService Layer.succeed(TodoService, { @@ -306,8 +314,8 @@ function tick(): Promise { // 一口气跑完导致队列事件被丢弃。 function paceLlm(llm: any): any { const completeStream = llm.completeStream.bind(llm); - llm.completeStream = (params: any, signal?: AbortSignal) => { - const raw = completeStream(params, signal) as AsyncIterable; + llm.completeStream = (params: any, model: string, signal?: AbortSignal) => { + const raw = completeStream(params, model, signal) as AsyncIterable; return (async function* () { for await (const part of raw) { yield part; @@ -327,7 +335,7 @@ export async function runAgentTurn( const appLayer = Layer.mergeAll(services, AgentLayer.pipe(Layer.provide(services))) as any; const program = Effect.gen(function* () { const agent = yield* AgentService; - const runOpts: any = { cwd: opts.cwd ?? '/tmp' }; + const runOpts: any = { cwd: opts.cwd ?? '/tmp', model: opts.model ?? 'test-model' }; if (opts.sessionId) runOpts.sessionId = opts.sessionId; if (opts.signal) runOpts.signal = opts.signal; if (opts.activeProfile) runOpts.activeProfile = opts.activeProfile; diff --git a/packages/infra/test/config.test.ts b/packages/codingcode/test/infra/config.test.ts similarity index 99% rename from packages/infra/test/config.test.ts rename to packages/codingcode/test/infra/config.test.ts index f3108fe7..6ef034dd 100644 --- a/packages/infra/test/config.test.ts +++ b/packages/codingcode/test/infra/config.test.ts @@ -8,7 +8,7 @@ import { updateMaxStopContinuations, updateContextCompactionModel, updateMemoryModel, -} from '../src/config.js'; +} from '../../src/infra/config.js'; const TMP_DIR = resolve(__dirname, '..', '..', '.tmp-test-config'); diff --git a/packages/infra/test/logger.test.ts b/packages/codingcode/test/infra/logger.test.ts similarity index 92% rename from packages/infra/test/logger.test.ts rename to packages/codingcode/test/infra/logger.test.ts index 1713c323..54a25fe4 100644 --- a/packages/infra/test/logger.test.ts +++ b/packages/codingcode/test/infra/logger.test.ts @@ -46,7 +46,7 @@ describe('createLogger', () => { it('returns sync logger without transport in Electron', async () => { setVersions({ ...process.versions, electron: '35.0.0' }); - const { createLogger } = await import('../src/logger.js'); + const { createLogger } = await import('../../src/infra/logger.js'); const logger = createLogger('debug'); expect(logger).toBeDefined(); @@ -61,7 +61,7 @@ describe('createLogger', () => { setVersions(versions); process.env.NODE_ENV = 'development'; - const { createLogger } = await import('../src/logger.js'); + const { createLogger } = await import('../../src/infra/logger.js'); const logger = createLogger('info'); expect(logger).toBeDefined(); @@ -76,7 +76,7 @@ describe('createLogger', () => { setVersions(versions); process.env.NODE_ENV = 'production'; - const { createLogger } = await import('../src/logger.js'); + const { createLogger } = await import('../../src/infra/logger.js'); const logger = createLogger('warn'); expect(logger).toBeDefined(); diff --git a/packages/codingcode/test/infra/models.test.ts b/packages/codingcode/test/infra/models.test.ts new file mode 100644 index 00000000..bf36ffc0 --- /dev/null +++ b/packages/codingcode/test/infra/models.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Effect } from 'effect'; + +const mockCatalog = { + providers: [ + { + name: 'provider-a', + driver: 'openai', + base_url: 'https://api.a.com', + api_key_env: 'API_KEY_A', + default_model: 'model-x', + models: [ + { id: 'model-x', name: 'Model X', context_window: 32000 }, + { id: 'model-y', name: 'Model Y' }, + ], + }, + { + name: 'provider-b', + driver: 'deepseek', + base_url: 'https://api.b.com', + api_key_env: 'API_KEY_B', + default_model: 'model-x', + models: [{ id: 'model-x', name: 'Model X' }], + }, + ], +}; + +function mockFs() { + vi.doMock('fs', async (importOriginal: any) => { + const orig = await importOriginal(); + return { + ...orig, + existsSync: (p: string) => (p.includes('models.json') ? true : orig.existsSync(p)), + readFileSync: (p: string, enc?: any) => + p.includes('models.json') ? JSON.stringify(mockCatalog) : orig.readFileSync(p, enc), + }; + }); +} + +/** catalog.ts 的 activeModel 来自 loadConfig(),与 updateActiveModel 同模块,一并桩掉。 */ +function mockActiveModel( + activeModel: { model: string; apiKeyEnv: string } | undefined, + extra: Record = {} +) { + vi.doMock('../../src/infra/config.js', async (importOriginal: any) => { + const orig = await importOriginal(); + return { ...orig, loadConfig: () => ({ activeModel }), ...extra }; + }); +} + +/** 消费完整条流;首个 part 前抛出的错误在此暴露 */ +async function drain(iterable: AsyncIterable): Promise { + for await (const _ of iterable) { + // 只是把迭代推进到实际调用点 + } +} + +/** + * 用真实的 LlmLayer 走一次 completeStream,只观察模型解析阶段的失败。 + * Tag 与 Layer 必须取自同一份 import 图,故两者都在调用点动态导入。 + */ +async function streamOnce(model: string) { + const { LLMService } = await import('../../src/llm/port.js'); + const { LlmLayer } = await import('../../src/llm/llm.js'); + return Effect.runPromise( + Effect.gen(function* () { + const llm = yield* LLMService; + yield* Effect.tryPromise({ + try: () => drain(llm.completeStream({ messages: [] }, model)), + catch: (e) => (e instanceof Error ? e : new Error(String(e))), + }); + }).pipe(Effect.provide(LlmLayer), Effect.either) + ); +} + +describe('catalog - listModels / findModel', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('flattens provider x model into {model}@{provider} ids', async () => { + mockFs(); + mockActiveModel(undefined); + 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', + ]); + }); + + it('prefers the exact compound id over a bare-name match', async () => { + mockFs(); + mockActiveModel(undefined); + const { findModel } = await import('../../src/infra/models.js'); + + expect(findModel('model-x@provider-b')?.provider).toBe('provider-b'); + }); + + it('falls back to the first bare-name match when no exact id matches', async () => { + mockFs(); + mockActiveModel(undefined); + const { findModel } = await import('../../src/infra/models.js'); + + expect(findModel('model-x')?.id).toBe('model-x@provider-a'); + expect(findModel('Model Y')?.id).toBe('model-y@provider-a'); + expect(findModel('nope')).toBeNull(); + }); + + it('reports the configured context window, falling back to the default', async () => { + mockFs(); + mockActiveModel(undefined); + const { contextWindowOf } = await import('../../src/infra/models.js'); + + expect(contextWindowOf('model-x@provider-a')).toBe(32000); + expect(contextWindowOf('model-y@provider-a')).toBe(128000); + }); +}); + +describe('activeModel - config priority', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('resolves the configured activeModel to a catalog entry', async () => { + mockFs(); + mockActiveModel({ model: 'model-y', apiKeyEnv: 'API_KEY_A' }); + const { activeModelId } = await import('../../src/infra/models.js'); + + expect(activeModelId()).toBe('model-y@provider-a'); + }); + + it('returns an empty id when activeModel is not set in config', async () => { + mockFs(); + mockActiveModel(undefined); + const { activeModelId } = await import('../../src/infra/models.js'); + + expect(activeModelId()).toBe(''); + }); + + it('returns an empty id when activeModel matches no catalog entry', async () => { + mockFs(); + mockActiveModel({ model: 'nonexistent', apiKeyEnv: 'UNKNOWN_KEY' }); + const { activeModelId } = await import('../../src/infra/models.js'); + + expect(activeModelId()).toBe(''); + }); +}); + +describe('setGlobalActive - persists to config', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('writes model and api_key_env through updateActiveModel', async () => { + const updateActiveModel = vi.fn(); + mockFs(); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }, { updateActiveModel }); + const { setGlobalActive } = await import('../../src/infra/models.js'); + + setGlobalActive('model-y@provider-a'); + + expect(updateActiveModel).toHaveBeenCalledWith('model-y', 'API_KEY_A'); + }); + + it('throws CONFIG_INVALID and does not touch config when the id is unknown', async () => { + const updateActiveModel = vi.fn(); + mockFs(); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }, { updateActiveModel }); + const { setGlobalActive } = await import('../../src/infra/models.js'); + + expect(() => setGlobalActive('nonexistent@provider-a')).toThrow(/not found/); + expect(updateActiveModel).not.toHaveBeenCalled(); + }); +}); + +describe('LlmLayer.completeStream - model resolution', () => { + beforeEach(() => { + vi.resetModules(); + delete (process.env as any).API_KEY_A; + delete (process.env as any).OPENAI_API_KEY; + }); + + afterEach(() => { + delete (process.env as any).API_KEY_A; + delete (process.env as any).OPENAI_API_KEY; + }); + + it('fails with CONFIG_INVALID when the requested model is not in the catalog', async () => { + mockFs(); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }); + + const result = await streamOnce('nonexistent@provider-a'); + + expect(result._tag).toBe('Left'); + if (result._tag === 'Left') { + expect((result.left as any).code).toBe('CONFIG_INVALID'); + expect(result.left.message).toContain('nonexistent@provider-a'); + } + }); + + it('falls back to the configured activeModel when the requested model is empty', async () => { + mockFs(); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }); + + const result = await streamOnce(''); + + expect(result._tag).toBe('Left'); + if (result._tag === 'Left') { + // 已经越过模型解析,卡在缺 API key 这一步 + expect((result.left as any).code).toBe('CONFIG_MISSING'); + expect(result.left.message).toContain('API_KEY_A'); + } + }); + + it('fails with CONFIG_INVALID when no activeModel is configured', async () => { + mockFs(); + mockActiveModel(undefined); + + const result = await streamOnce(''); + + expect(result._tag).toBe('Left'); + if (result._tag === 'Left') { + expect((result.left as any).code).toBe('CONFIG_INVALID'); + expect(result.left.message).toContain('activeModel'); + } + }); +}); diff --git a/packages/infra/test/yaml-store.test.ts b/packages/codingcode/test/infra/yaml-store.test.ts similarity index 99% rename from packages/infra/test/yaml-store.test.ts rename to packages/codingcode/test/infra/yaml-store.test.ts index f4f962aa..2829e437 100644 --- a/packages/infra/test/yaml-store.test.ts +++ b/packages/codingcode/test/infra/yaml-store.test.ts @@ -10,7 +10,7 @@ import { writeNamedList, yamlStorePath, type NamedListFile, -} from '../src/yaml-store.js'; +} from '../../src/infra/yaml-store.js'; interface Item { name: string; diff --git a/packages/codingcode/test/llm/factory.test.ts b/packages/codingcode/test/llm/factory.test.ts deleted file mode 100644 index 79f0e117..00000000 --- a/packages/codingcode/test/llm/factory.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Effect } from 'effect'; - -const mockCatalog = { - providers: [ - { - name: 'provider-a', - driver: 'openai', - base_url: 'https://api.a.com', - api_key_env: 'API_KEY_A', - default_model: 'model-x', - models: [ - { id: 'model-x', name: 'Model X' }, - { id: 'model-y', name: 'Model Y' }, - ], - }, - ], -}; - -function mockFs() { - vi.doMock('fs', async (importOriginal: any) => { - const orig = await importOriginal(); - return { - ...orig, - existsSync: (p: string) => (p.includes('models.json') ? true : orig.existsSync(p)), - readFileSync: (p: string, enc?: any) => - p.includes('models.json') ? JSON.stringify(mockCatalog) : orig.readFileSync(p, enc), - }; - }); -} - -/** llm.ts 的 activeModel 来自 loadConfig(),与 updateActiveModel 同模块,一并桩掉。 */ -function mockActiveModel( - activeModel: { model: string; apiKeyEnv: string } | undefined, - extra: Record = {} -) { - vi.doMock('@codingcode/infra/config', async (importOriginal: any) => { - const orig = await importOriginal(); - return { ...orig, loadConfig: () => ({ activeModel }), ...extra }; - }); -} - -describe('switchModel - persists to config', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('calls updateActiveModel with model and api_key_env after switching', async () => { - const updateActiveModel = vi.fn(); - mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }, { updateActiveModel }); - mockFs(); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.switchModel('model-y@API_KEY_A'); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Right'); - if (result._tag === 'Right') { - expect(result.right.id).toBe('model-y@API_KEY_A'); - } - expect(updateActiveModel).toHaveBeenCalledWith('model-y', 'API_KEY_A'); - }); - - it('does not call updateActiveModel when model id is not found', async () => { - const updateActiveModel = vi.fn(); - mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }, { updateActiveModel }); - mockFs(); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.switchModel('nonexistent@API_KEY_A'); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Left'); - if (result._tag === 'Left') { - expect(result.left.message).toContain('not found'); - } - expect(updateActiveModel).not.toHaveBeenCalled(); - }); -}); - -describe('getActiveEntry - activeModel priority', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('uses activeModel from config when it matches a catalog entry', async () => { - mockFs(); - - mockActiveModel({ model: 'model-y', apiKeyEnv: 'API_KEY_A' }); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Right'); - if (result._tag === 'Right') { - expect(result.right.id).toBe('model-y@API_KEY_A'); - } - }); - - it('returns error when activeModel is not set in config', async () => { - mockActiveModel(undefined); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Left'); - if (result._tag === 'Left') { - expect(result.left.code).toBe('CONFIG_INVALID'); - expect(result.left.message).toContain('activeModel'); - } - }); - - it('returns error when activeModel does not match any catalog entry', async () => { - mockFs(); - - mockActiveModel({ model: 'nonexistent', apiKeyEnv: 'UNKNOWN_KEY' }); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Left'); - if (result._tag === 'Left') { - expect(result.left.code).toBe('CONFIG_INVALID'); - expect(result.left.message).toContain('nonexistent'); - } - }); -}); - -describe('createClient - API key validation', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('returns CONFIG_MISSING when API key env is not set', async () => { - mockFs(); - - mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const entryResult = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(entryResult._tag).toBe('Right'); - if (entryResult._tag === 'Left') return; - - delete (process.env as any).API_KEY_A; - delete (process.env as any).OPENAI_API_KEY; - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.createClient(entryResult.right); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Left'); - if (result._tag === 'Left') { - expect(result.left.code).toBe('CONFIG_MISSING'); - expect(result.left.message).toContain('API_KEY_A'); - } - }); - - it('succeeds when OPENAI_API_KEY fallback is set', async () => { - mockFs(); - - mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const entryResult = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(entryResult._tag).toBe('Right'); - if (entryResult._tag === 'Left') return; - - delete (process.env as any).API_KEY_A; - (process.env as any).OPENAI_API_KEY = 'sk-test'; - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.createClient(entryResult.right); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Right'); - }); -}); diff --git a/packages/codingcode/test/mcp/service.test.ts b/packages/codingcode/test/mcp/service.test.ts index 445d2c01..a1c630cf 100644 --- a/packages/codingcode/test/mcp/service.test.ts +++ b/packages/codingcode/test/mcp/service.test.ts @@ -14,7 +14,6 @@ interface MockTool { vi.mock('../../src/mcp/client.js', () => { class MockMcpClient { static instances: MockMcpClient[] = []; - transportType = 'stdio' as const; connectCalls = 0; disconnectCalls = 0; private _tools: MockTool[]; @@ -221,19 +220,4 @@ describe('McpService(syncConnections / listProjectMcpTools / status)', () => }) ); }); - - it('status 返回 name/toolCount/transport,未连接的项目返回空数组', async () => { - mockConfigs = [cfg('db', [tool('query'), tool('schema')])]; - - await run( - Effect.gen(function* () { - const mcp = yield* McpService; - yield* mcp.syncConnections(TEST_PROJECT); - expect(yield* mcp.status(TEST_PROJECT)).toEqual([ - { name: 'db', toolCount: 2, transport: 'stdio' }, - ]); - expect(yield* mcp.status('/other-project')).toEqual([]); - }) - ); - }); }); diff --git a/packages/codingcode/test/memory/config.test.ts b/packages/codingcode/test/memory/config.test.ts index 580ca52a..d7601d4d 100644 --- a/packages/codingcode/test/memory/config.test.ts +++ b/packages/codingcode/test/memory/config.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { getMemoryConfig } from '../../src/memory/config.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: vi.fn(() => ({ memory: { enabled: true, model: 'memory-model', promptMaxBytes: 4096 }, })), @@ -16,7 +16,7 @@ describe('getMemoryConfig', () => { }); it('reflects updated loadConfig result', async () => { - const { loadConfig } = await import('@codingcode/infra/config'); + const { loadConfig } = await import('../../src/infra/config.js'); vi.mocked(loadConfig).mockReturnValue({ memory: { enabled: false, model: '', promptMaxBytes: 8192 }, } as any); diff --git a/packages/codingcode/test/memory/index.test.ts b/packages/codingcode/test/memory/index.test.ts index 4e0b1df6..cdf92c8e 100644 --- a/packages/codingcode/test/memory/index.test.ts +++ b/packages/codingcode/test/memory/index.test.ts @@ -4,27 +4,24 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { MemoryService } from '../../src/memory/port.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; +import { LLMService } from '../../src/llm/port.js'; import { MemoryLayer } from '../../src/memory/memory.js'; const tmpDir = path.join(os.tmpdir(), 'memory-index-test'); const memFile = path.join(tmpDir, '.codingcode', 'memory.md'); -const mockFactory = { - findModel: vi.fn(() => Effect.succeed(null)), - createClient: vi.fn(() => Effect.succeed({})), - listModels: vi.fn(() => Effect.succeed([])), - getActiveEntry: vi.fn(() => Effect.succeed({})), - switchModel: vi.fn(() => Effect.succeed({})), - getLLMClient: vi.fn(() => Effect.succeed({})), +const mockLlm = { + complete: vi.fn(() => Effect.succeed({ content: '' })), + completeStream: vi.fn(), } as any; -const testLayer = MemoryLayer.pipe( - Layer.provide(Layer.succeed(LLMFactoryService, mockFactory)) -); +const testLayer = MemoryLayer.pipe(Layer.provide(Layer.succeed(LLMService, mockLlm))); let service: any; +/** MemoryShape 现在返回 Effect,测试统一用 Effect.runPromise 驱动 */ +const run = (eff: Effect.Effect) => Effect.runPromise(eff); + function cleanup() { if (fs.existsSync(tmpDir)) { fs.rmSync(tmpDir, { recursive: true }); @@ -46,7 +43,7 @@ vi.mock('../../src/memory/config.js', () => ({ // setMemoryEnabled persists via the infra config store, which writes the real // ~/.codingcode/config.yaml. Stub the writer so the suite never touches user config. -vi.mock('@codingcode/infra/config', async (importOriginal) => { +vi.mock('../../src/infra/config.js', async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, @@ -62,29 +59,23 @@ vi.mock('../../src/session/file-ops.js', async (importOriginal) => { }; }); -function createMockLlm(response: string, beforeYield?: () => void) { - return { - complete: vi.fn(() => Effect.succeed({ content: response })), - completeStream: vi.fn(() => - (async function* () { - beforeYield?.(); - yield { type: 'text' as const, text: response }; - yield { type: 'end' as const }; - })() - ), - modelInfo: { - provider: 'mock', - model: 'mock', - maxTokens: 4096, - supportsToolCalling: true, - supportsStreaming: true, - }, - }; +function setLlmResponse(response: string, beforeYield?: () => void) { + mockLlm.completeStream.mockImplementation(() => + (async function* () { + beforeYield?.(); + yield { type: 'text' as const, text: response }; + yield { type: 'end' as const }; + })() + ); } +const TEST_MODEL = 'demo-model@demo'; + beforeEach(async () => { cleanup(); fs.mkdirSync(tmpDir, { recursive: true }); + mockLlm.completeStream.mockReset(); + setLlmResponse(''); const { getMemoryConfig } = await import('../../src/memory/config.js'); vi.mocked(getMemoryConfig).mockReturnValue({ enabled: false, @@ -114,14 +105,14 @@ async function enableConfig() { } describe('loadMemoryForPrompt', () => { - it('returns empty string when memory is disabled', () => { - const result = service.loadMemoryForPrompt(tmpDir); + it('returns empty string when memory is disabled', async () => { + const result = await run(service.loadMemoryForPrompt(tmpDir)); expect(result).toBe(''); }); it('returns empty string when no memory file exists', async () => { await enableConfig(); - const result = service.loadMemoryForPrompt(tmpDir); + const result = await run(service.loadMemoryForPrompt(tmpDir)); expect(result).toBe(''); }); @@ -129,7 +120,7 @@ describe('loadMemoryForPrompt', () => { await enableConfig(); writeMemory('### project\n- Architecture decision 1'); - const result = service.loadMemoryForPrompt(tmpDir); + const result = await run(service.loadMemoryForPrompt(tmpDir)); expect(result).toContain('## Long-term Memory'); expect(result).toContain('### project'); expect(result).toContain('Architecture decision 1'); @@ -145,7 +136,7 @@ describe('loadMemoryForPrompt', () => { writeMemory(`### project - Very long content that should be truncated ${' x'.repeat(200)}`); - const result = service.loadMemoryForPrompt(tmpDir); + const result = await run(service.loadMemoryForPrompt(tmpDir)); const bytes = Buffer.byteLength(result.replace('## Long-term Memory\n\n', ''), 'utf-8'); expect(bytes).toBeLessThanOrEqual(100); }); @@ -153,23 +144,26 @@ describe('loadMemoryForPrompt', () => { describe('flushSessionToMemory', () => { it('returns early when memory disabled', async () => { - const result = await service.flushSessionToMemory('fake-session-id', null, tmpDir); + const result = await run(service.flushSessionToMemory('fake-session-id', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); }); it('returns early when session has no events', async () => { await enableConfig(); - const result = await service.flushSessionToMemory('empty-session', null, tmpDir); + const result = await run(service.flushSessionToMemory('empty-session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); }); - it('gracefully handles missing LLM', async () => { + 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); - const result = await service.flushSessionToMemory('session', null, tmpDir); + mockLlm.completeStream.mockImplementation(() => { + throw new Error('llm unavailable'); + }); + const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); }); @@ -181,10 +175,11 @@ describe('flushSessionToMemory', () => { { type: 'user', content: '记住新架构决策' }, { type: 'assistant', content: '好的' }, ] as any); - const llm = createMockLlm('### 项目\n- 新的架构决策'); + setLlmResponse('### 项目\n- 新的架构决策'); - const result = await service.flushSessionToMemory('session', llm, tmpDir); + const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); + expect(mockLlm.completeStream.mock.calls[0]?.[1]).toBe(TEST_MODEL); expect(result.written).toBe(true); expect(result.bytes).toBeGreaterThan(0); expect(fs.readFileSync(memFile, 'utf-8')).toBe('### 项目\n- 新的架构决策'); @@ -197,8 +192,9 @@ describe('flushSessionToMemory', () => { vi.mocked(readTranscript).mockImplementation(() => [ { type: 'user', content: 'hello' }, ] as any); + setLlmResponse(''); - const result = await service.flushSessionToMemory('session', createMockLlm(''), tmpDir); + const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); expect(fs.readFileSync(memFile, 'utf-8')).toBe('### 旧主题\n- 旧内容'); @@ -211,12 +207,9 @@ describe('flushSessionToMemory', () => { vi.mocked(readTranscript).mockImplementation(() => [ { type: 'user', content: '无新信息' }, ] as any); + setLlmResponse('### 主题\n- 不变的内容'); - const result = await service.flushSessionToMemory( - 'session', - createMockLlm('### 主题\n- 不变的内容'), - tmpDir - ); + const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); }); @@ -228,11 +221,11 @@ describe('flushSessionToMemory', () => { vi.mocked(readTranscript).mockImplementation(() => [ { type: 'user', content: 'hello' }, ] as any); - const llm = createMockLlm('### 自动\n- 新记忆', () => { + setLlmResponse('### 自动\n- 新记忆', () => { writeMemory('### 手动\n- 用户并发编辑'); }); - const result = await service.flushSessionToMemory('session', llm, tmpDir); + const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); expect(fs.readFileSync(memFile, 'utf-8')).toBe('### 手动\n- 用户并发编辑'); @@ -240,36 +233,36 @@ describe('flushSessionToMemory', () => { }); describe('runtime memory toggle', () => { - afterEach(() => { - service.setMemoryEnabled(false); + afterEach(async () => { + await run(service.setMemoryEnabled(false)); }); - it('setMemoryEnabled(true) makes getMemoryEnabled return true', () => { - service.setMemoryEnabled(true); - expect(service.getMemoryEnabled()).toBe(true); + it('setMemoryEnabled(true) makes getMemoryEnabled return true', async () => { + await run(service.setMemoryEnabled(true)); + expect(await run(service.getMemoryEnabled())).toBe(true); }); - it('setMemoryEnabled(false) makes getMemoryEnabled return false', () => { - service.setMemoryEnabled(false); - expect(service.getMemoryEnabled()).toBe(false); + it('setMemoryEnabled(false) makes getMemoryEnabled return false', async () => { + await run(service.setMemoryEnabled(false)); + expect(await run(service.getMemoryEnabled())).toBe(false); }); - it('toggle sequence works correctly', () => { - service.setMemoryEnabled(true); - expect(service.getMemoryEnabled()).toBe(true); - service.setMemoryEnabled(false); - expect(service.getMemoryEnabled()).toBe(false); + it('toggle sequence works correctly', async () => { + await run(service.setMemoryEnabled(true)); + expect(await run(service.getMemoryEnabled())).toBe(true); + await run(service.setMemoryEnabled(false)); + expect(await run(service.getMemoryEnabled())).toBe(false); }); - it('loadMemoryForPrompt returns empty when runtime disabled', () => { - service.setMemoryEnabled(false); - const result = service.loadMemoryForPrompt(tmpDir); + it('loadMemoryForPrompt returns empty when runtime disabled', async () => { + await run(service.setMemoryEnabled(false)); + const result = await run(service.loadMemoryForPrompt(tmpDir)); expect(result).toBe(''); }); it('flushSessionToMemory returns early when runtime disabled', async () => { - service.setMemoryEnabled(false); - const result = await service.flushSessionToMemory('any-session', null, tmpDir); + await run(service.setMemoryEnabled(false)); + const result = await run(service.flushSessionToMemory('any-session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); }); }); diff --git a/packages/codingcode/test/memory/llm-resolver.test.ts b/packages/codingcode/test/memory/llm-resolver.test.ts deleted file mode 100644 index 5cf0702a..00000000 --- a/packages/codingcode/test/memory/llm-resolver.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { Effect } from 'effect'; -import { resolveLLM } from '../../src/llm/llm-resolver.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; -import { AgentError } from '../../src/core/error.js'; -import type { LLMClient } from '../../src/contracts/provider.js'; -import type { SelectableModel } from '../../src/contracts/provider.js'; - -const { mockFindModel, mockCreateClient } = vi.hoisted(() => ({ - mockFindModel: vi.fn(), - mockCreateClient: vi.fn(), -})); - -const mockFactory = { - findModel: mockFindModel, - createClient: mockCreateClient, - listModels: vi.fn(() => Effect.succeed([])), - getActiveEntry: vi.fn(() => Effect.succeed({})), - switchModel: vi.fn(() => Effect.succeed({})), - getLLMClient: vi.fn(() => Effect.succeed({})), -} as any; - -const fallbackClient = {} as LLMClient; - -async function runResolveLLM(target: string | null | undefined, fallback: LLMClient | null) { - return Effect.runPromise( - resolveLLM(target, fallback).pipe(Effect.provideService(LLMFactoryService, mockFactory)) - ); -} - -describe('resolveLLM (memory)', () => { - afterEach(() => { - vi.resetAllMocks(); - }); - - it('returns fallback when target is empty', async () => { - const result = await runResolveLLM('', fallbackClient); - expect(result).toBe(fallbackClient); - }); - - it('returns fallback when target is whitespace-only', async () => { - const result = await runResolveLLM(' ', fallbackClient); - expect(result).toBe(fallbackClient); - }); - - it('returns fallback when model not found', async () => { - mockFindModel.mockReturnValue(Effect.succeed(null)); - const result = await runResolveLLM('nonexistent-model', fallbackClient); - expect(result).toBe(fallbackClient); - }); - - it('returns null when fallback is null and create fails', async () => { - mockFindModel.mockReturnValue(Effect.succeed({ id: 'claude-opus-4-7' } as SelectableModel)); - mockCreateClient.mockReturnValue( - Effect.fail(new AgentError('CONFIG_INVALID', 'creation failed')) - ); - const result = await runResolveLLM('claude-opus-4-7', null); - expect(result).toBeNull(); - }); - - it('returns null when fallback is null and create returns error', async () => { - mockFindModel.mockReturnValue(Effect.succeed({ id: 'claude-opus-4-7' } as SelectableModel)); - mockCreateClient.mockReturnValue(Effect.fail(new AgentError('CONFIG_INVALID', 'error'))); - const result = await runResolveLLM('claude-opus-4-7', null); - expect(result).toBeNull(); - }); - - it('creates and returns client when model matches by id', async () => { - const client = { modelInfo: { maxTokens: 4096 } } as LLMClient; - mockFindModel.mockReturnValue( - Effect.succeed({ id: 'claude-opus-4-7@ANTHROPIC_API_KEY' } as SelectableModel) - ); - mockCreateClient.mockReturnValue(Effect.succeed(client)); - const result = await runResolveLLM('claude-opus-4-7@ANTHROPIC_API_KEY', fallbackClient); - expect(result).toBe(client); - }); - - it('creates and returns client when model matches by bare model id', async () => { - const client = { modelInfo: { maxTokens: 4096 } } as LLMClient; - mockFindModel.mockReturnValue( - Effect.succeed({ - id: 'deepseek-chat@DEEPSEEK_API_KEY', - model: 'deepseek-chat', - } as SelectableModel) - ); - mockCreateClient.mockReturnValue(Effect.succeed(client)); - const result = await runResolveLLM('deepseek-chat', fallbackClient); - expect(result).toBe(client); - }); -}); diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index cd1d79c0..b37f2865 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -9,7 +9,7 @@ import { endReason, } from './helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/plan/gate-pipeline.test.ts b/packages/codingcode/test/plan/gate-pipeline.test.ts index a54290d4..5818d178 100644 --- a/packages/codingcode/test/plan/gate-pipeline.test.ts +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -38,7 +38,7 @@ function makeMockApprovalWait() { function runPipelineWithMock(opts: { tool: string; input: any; - permissionMode: 'default' | 'acceptEdits' | 'bypass'; + permissionMode: 'ask' | 'acceptEdits' | 'bypass'; sessionId: string; profile: ProfileName; }) { @@ -76,7 +76,7 @@ describe('plan profile permission mode (Layer 2)', () => { const decision: any = await runPipelineWithMock({ tool: 'write_file', input: { path: '/tmp/x', content: 'foo' }, - permissionMode: 'default', + permissionMode: 'ask', sessionId: 's2', profile: 'plan', }); @@ -90,7 +90,7 @@ describe('plan profile permission mode (Layer 2)', () => { const decision: any = await runPipelineWithMock({ tool: 'execute_command', input: { command: 'rm -rf /' }, - permissionMode: 'default', + permissionMode: 'ask', sessionId: 's3', profile: 'plan', }); @@ -104,7 +104,7 @@ describe('plan profile permission mode (Layer 2)', () => { const decision: any = await runPipelineWithMock({ tool: 'dispatch_agent', input: { agent: 'build', prompt: 'do something' }, - permissionMode: 'default', + permissionMode: 'ask', sessionId: 's4', profile: 'plan', }); @@ -118,7 +118,7 @@ describe('plan profile permission mode (Layer 2)', () => { const decision: any = await runPipelineWithMock({ tool: 'write_file', input: { path: '/tmp/x', content: 'foo' }, - permissionMode: 'default', + permissionMode: 'ask', sessionId: 's5', profile: 'build', }); @@ -130,7 +130,7 @@ describe('plan profile permission mode (Layer 2)', () => { const decision: any = await runPipelineWithMock({ tool: 'submit_plan', input: { plan_content: '# plan' }, - permissionMode: 'default', + permissionMode: 'ask', sessionId: 's6', profile: 'plan', }); diff --git a/packages/codingcode/test/prompts/system-prompt.test.ts b/packages/codingcode/test/prompts/system-prompt.test.ts index 458d10e8..671eaffe 100644 --- a/packages/codingcode/test/prompts/system-prompt.test.ts +++ b/packages/codingcode/test/prompts/system-prompt.test.ts @@ -30,6 +30,14 @@ describe('buildSystemPrompt', () => { expect(prompt).toContain('dispatch_agent'); }); + it('Rule 7 delegation does not point at a removed subagent catalog', () => { + const prompt = buildSystemPrompt(baseOpts); + expect(prompt).not.toContain('runtime-configured subagent'); + expect(prompt).not.toContain('Available Subagents'); + expect(prompt).toContain('short agentName'); + expect(prompt).toContain('keep the delegated write set disjoint'); + }); + it('includes Using your tools section', () => { const prompt = buildSystemPrompt(baseOpts); expect(prompt).toContain('Using your tools'); diff --git a/packages/codingcode/test/scheduler/store.test.ts b/packages/codingcode/test/scheduler/store.test.ts index f25ec4ab..bc49f98c 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/scheduler/types.js'; +import type { Automation } from '../../src/contracts/automation.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 54274ec4..c71026b3 100644 --- a/packages/codingcode/test/security/plan-profile-restart.test.ts +++ b/packages/codingcode/test/security/plan-profile-restart.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { mkdtempSync, rmSync, readFileSync } from 'fs'; +import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; import { HookService } from '../../src/hooks/port.js'; import { ApprovalService } from '../../src/approval/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; @@ -59,7 +58,6 @@ function setProfileEffect(cwd: string, sessionId: string, profile: 'plan' | 'bui describe('plan profile security boundary (permission-mode, disk-persisted profile)', () => { let cwd: string; let sessionId: string; - let indexPath: string; let rt: ManagedRuntime.ManagedRuntime; beforeEach(async () => { @@ -71,16 +69,12 @@ describe('plan profile security boundary (permission-mode, disk-persisted profil const state = yield* session.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); - return { - sessionId: state.sessionId, - indexPath: computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, - }; + return { sessionId: state.sessionId }; }) ); sessionId = result.sessionId; - indexPath = result.indexPath; }); afterEach(async () => { @@ -101,7 +95,7 @@ describe('plan profile security boundary (permission-mode, disk-persisted profil input, sessionId, projectPath: cwd, - permissionMode: 'default', + permissionMode: 'ask', profile, }); }) @@ -131,9 +125,6 @@ describe('plan profile security boundary (permission-mode, disk-persisted profil it('after restart (state reloaded from disk), plan profile persists', async () => { await rt.runPromise(setProfileEffect(cwd, sessionId, 'plan')); - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.activeProfile).toBe('plan'); - await rt.dispose(); rt = ManagedRuntime.make(makeLayer() as any); await rt.runPromise( diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index ae851e95..b35e1da5 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { createServer } from '../../src/server/index.js'; import { SessionService } from '../../src/session/port.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; import { ApprovalService } from '../../src/approval/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { HookService } from '../../src/hooks/port.js'; @@ -23,17 +22,15 @@ const MockSessionLayer = Layer.succeed(SessionService, { Effect.succeed({ sessionId: 'test-sid', cwd: '/tmp/test', - model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }), load: () => Effect.succeed({ sessionId: 'test-sid', cwd: '/tmp/test', - model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }), recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), recordAssistant: () => @@ -53,46 +50,6 @@ const MockSessionLayer = Layer.succeed(SessionService, { }), } as any); -const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { - findModel: () => - Effect.succeed({ - id: 'deepseek-chat', - model: 'deepseek-chat', - activeProfile: 'build', - permissionMode: 'default', - provider: 'deepseek', - driver: 'openai', - api_key_env: 'DEEPSEEK_API_KEY', - base_url: 'https://api.deepseek.com', - }), - createClient: () => - Effect.succeed({ - modelInfo: { - provider: 'deepseek', - model: 'deepseek-chat', - activeProfile: 'build', - permissionMode: 'default', - maxTokens: 64000, - supportsToolCalling: true, - supportsStreaming: true, - }, - }), - getLLMClient: () => Effect.succeed(null), - listModels: () => Effect.succeed([]), - getActiveEntry: () => - Effect.succeed({ - id: 'deepseek-chat', - model: 'deepseek-chat', - activeProfile: 'build', - permissionMode: 'default', - provider: 'deepseek', - driver: 'openai', - api_key_env: 'DEEPSEEK_API_KEY', - base_url: 'https://api.deepseek.com', - }), - switchModel: () => Effect.fail(new Error('no models')), -} as any); - const MockApprovalLayer = ApprovalLayer.pipe( Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer)) ); @@ -110,10 +67,10 @@ const MockMcpLayer = Layer.succeed(McpService, { } as any); const MockMemoryLayer = Layer.succeed(MemoryService, { - getMemoryEnabled: () => true, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + getMemoryEnabled: () => Effect.succeed(true), + setMemoryEnabled: () => Effect.void, + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), } as any); const MockSchedulerLayer = Layer.succeed(SchedulerService, { @@ -125,7 +82,7 @@ const MockSchedulerLayer = Layer.succeed(SchedulerService, { } as any); const MockContextLayer = Layer.succeed(ContextService, { - assemblePayload: async () => [], + assemblePayload: () => Effect.succeed([]), compactWithLLM: mockCompactWithLLM, } as any); @@ -153,7 +110,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { const TestLayer = Layer.mergeAll( MockSessionLayer, - MockLLMFactoryLayer, MockApprovalLayer, HookLayer, ApprovalWaitLayer, @@ -170,28 +126,29 @@ const rt = ManagedRuntime.make(TestLayer as any); describe('POST /api/sessions/:id/compact (manual compact)', () => { beforeEach(() => { mockCompactWithLLM.mockReset(); - mockCompactWithLLM.mockResolvedValue({ - didCompress: true, - released: 5000, - promptEstimate: 3000, - }); + mockCompactWithLLM.mockReturnValue( + Effect.succeed({ + didCompress: true, + released: 5000, + promptEstimate: 3000, + }) + ); }); - it('should call compactWithLLM with a non-null llm when session has a valid model', async () => { + it('should pass the requested model through to compactWithLLM', async () => { const app = await createServer(rt); const res = await app.request('/api/sessions/test-sid/compact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cwd: '' }), + body: JSON.stringify({ cwd: '', model: 'deepseek-chat@deepseek' }), }); expect(res.status).toBe(200); expect(mockCompactWithLLM).toHaveBeenCalledTimes(1); const args = mockCompactWithLLM.mock.calls[0]; - // args[2] is the llm parameter — should not be null - expect(args?.[2]).not.toBeNull(); - expect(args?.[2].modelInfo.model).toBe('deepseek-chat'); + expect(typeof args?.[0]).toBe('string'); + expect(args?.[1]).toBe('deepseek-chat@deepseek'); }); it('should return CompressResult from the API', async () => { @@ -206,42 +163,8 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { expect(body).toEqual({ didCompress: true, released: 5000, promptEstimate: 3000 }); }); - it('should call compactWithLLM with null llm when getActiveEntry fails', async () => { - const FailingFactoryLayer = Layer.succeed(LLMFactoryService, { - findModel: () => Effect.succeed(null), - createClient: () => - Effect.succeed({ - modelInfo: { - provider: 'deepseek', - model: 'deepseek-chat', - activeProfile: 'build', - permissionMode: 'default', - maxTokens: 64000, - supportsToolCalling: true, - supportsStreaming: true, - }, - }), - getLLMClient: () => Effect.succeed(null), - listModels: () => Effect.succeed([]), - getActiveEntry: () => Effect.fail(new Error('no active model')), - switchModel: () => Effect.fail(new Error('no models')), - } as any); - - const FailLayer = Layer.mergeAll( - MockSessionLayer, - FailingFactoryLayer, - MockApprovalLayer, - HookLayer, - ApprovalWaitLayer, - MockSkillLayer, - MockMcpLayer, - MockMemoryLayer, - MockSchedulerLayer, - MockContextLayer, - MockCheckpointLayer - ); - const failRt = ManagedRuntime.make(FailLayer as any); - const app = await createServer(failRt); + it('should fall back to the global model (empty string) when the request omits a model', async () => { + const app = await createServer(rt); const res = await app.request('/api/sessions/test-sid/compact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -249,9 +172,7 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { }); expect(res.status).toBe(200); - expect(mockCompactWithLLM).toHaveBeenCalledTimes(1); - const args = mockCompactWithLLM.mock.calls[0]; - expect(args?.[2]).toBeNull(); + expect(args?.[1]).toBe(''); }); }); 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 2a9a2671..b64daa9a 100644 --- a/packages/codingcode/test/server/create-session-active-profile.test.ts +++ b/packages/codingcode/test/server/create-session-active-profile.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, ManagedRuntime } from 'effect'; import { Hono } from 'hono'; -import { readFileSync, mkdirSync } from 'fs'; +import { mkdirSync } from 'fs'; import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -28,35 +27,32 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { await rt.dispose(); }); - it('writes idx.activeProfile=plan and idx.permissionMode=default when activeProfile=plan', async () => { + it('persists activeProfile=plan and permissionMode=ask when activeProfile=plan', async () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cwd, activeProfile: 'plan', - permissionMode: 'default', + permissionMode: 'ask', model: 'gpt-4', }), }); expect(res.status).toBe(200); const { sessionId } = await res.json(); - const indexPath = await rt.runPromise( + await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); - return computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; + expect(state.activeProfile).toBe('plan'); + expect(state).not.toHaveProperty('mode'); + expect(state.permissionMode).toBe('ask'); }) ); - - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.activeProfile).toBe('plan'); - expect(idx).not.toHaveProperty('mode'); - expect(idx.permissionMode).toBe('default'); }); - it('writes idx.activeProfile=build and idx.permissionMode=bypass when activeProfile=build+bypass', async () => { + it('persists activeProfile=build and permissionMode=bypass when activeProfile=build+bypass', async () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -70,18 +66,15 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { expect(res.status).toBe(200); const { sessionId } = await res.json(); - const indexPath = await rt.runPromise( + await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); - return computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; + expect(state.activeProfile).toBe('build'); + expect(state).not.toHaveProperty('mode'); + expect(state.permissionMode).toBe('bypass'); }) ); - - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.activeProfile).toBe('build'); - expect(idx).not.toHaveProperty('mode'); - expect(idx.permissionMode).toBe('bypass'); }); it('allows plan profile with any permissionMode (plan no longer overrides perm)', async () => { @@ -97,25 +90,22 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { }); expect(res.status).toBe(200); const { sessionId } = await res.json(); - const indexPath = await rt.runPromise( + await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); - return computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; + expect(state.activeProfile).toBe('plan'); + expect(state).not.toHaveProperty('mode'); + expect(state.permissionMode).toBe('bypass'); }) ); - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.activeProfile).toBe('plan'); - expect(idx).not.toHaveProperty('mode'); - expect(idx.permissionMode).toBe('bypass'); - expect(idx.activeProfile).toBe('plan'); }); it('rejects missing model', async () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ cwd, activeProfile: 'build', permissionMode: 'default' }), + body: JSON.stringify({ cwd, activeProfile: 'build', permissionMode: 'ask' }), }); expect(res.status).toBe(400); }); @@ -124,7 +114,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ cwd, permissionMode: 'default', model: 'gpt-4' }), + body: JSON.stringify({ cwd, permissionMode: 'ask', model: 'gpt-4' }), }); expect(res.status).toBe(400); }); @@ -136,7 +126,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { body: JSON.stringify({ cwd, activeProfile: 'plan', - permissionMode: 'default', + permissionMode: 'ask', model: 'gpt-4', }), }); @@ -149,7 +139,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { const state = yield* session.load(cwd, sessionId); expect(state.activeProfile).toBe('plan'); expect(state).not.toHaveProperty('mode'); - expect(state.permissionMode).toBe('default'); + expect(state.permissionMode).toBe('ask'); expect(state.activeProfile).toBe('plan'); }) ); diff --git a/packages/codingcode/test/server/index.test.ts b/packages/codingcode/test/server/index.test.ts index b6dab99f..bd3b9af6 100644 --- a/packages/codingcode/test/server/index.test.ts +++ b/packages/codingcode/test/server/index.test.ts @@ -57,10 +57,10 @@ const MockMcpLayer = Layer.succeed(McpService, { } as any); const MockMemoryLayer = Layer.succeed(MemoryService, { - getMemoryEnabled: () => true, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + getMemoryEnabled: () => Effect.succeed(true), + setMemoryEnabled: () => Effect.void, + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), } as any); const MockSchedulerLayer = Layer.succeed(SchedulerService, { 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 196f4f5c..dbe987de 100644 --- a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts +++ b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { Hono } from 'hono'; -import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'fs'; +import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { registerMessagesRoutes } from '../../src/server/routes/messages.js'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; import { HookService } from '../../src/hooks/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { AgentService } from '../../src/agent/port.js'; @@ -32,7 +31,7 @@ const mockApprovalWaitService = { }; // The message-send path now lives in AgentService.runTurn. A real runTurn loads -// the persisted session (which reads permissionMode from the on-disk index) +// the persisted session (which reads permissionMode from the session head) // before streaming. We mirror that seam here so the test keeps validating that // the fork/send path starts from the persisted session state. const loadedPermissionModes: string[] = []; @@ -74,15 +73,17 @@ describe('POST /api/sessions/:id/messages — reads permissionMode from disk', ( return yield* session.create(cwd, { model: 'm', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); sessionId = state.sessionId; - const indexPath = computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - idx.permissionMode = 'bypass'; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setPermissionMode(cwd, sessionId, 'bypass'); + }) + ); loadedPermissionModes.length = 0; @@ -99,7 +100,7 @@ describe('POST /api/sessions/:id/messages — reads permissionMode from disk', ( const res = await app.request('/api/sessions/' + sessionId + '/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ input: 'hello', cwd }), + body: JSON.stringify({ input: 'hello', cwd, model: 'm' }), }); expect(res.status).not.toBe(404); expect(loadedPermissionModes[0]).toBe('bypass'); diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts index a037a829..80a19dcf 100644 --- a/packages/codingcode/test/server/plan-file-route.test.ts +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -31,7 +31,7 @@ const MockSessionLayer = Layer.succeed(SessionService, { cwd: '/tmp/test', model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }), load: () => Effect.succeed({ @@ -39,7 +39,7 @@ const MockSessionLayer = Layer.succeed(SessionService, { cwd: '/tmp/test', model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }), recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), recordAssistant: () => @@ -60,7 +60,7 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { id: 'deepseek-chat', model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', provider: 'deepseek', driver: 'openai', api_key_env: 'DEEPSEEK_API_KEY', @@ -72,7 +72,7 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { provider: 'deepseek', model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', maxTokens: 64000, supportsToolCalling: true, supportsStreaming: true, @@ -85,7 +85,7 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { id: 'deepseek-chat', model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', provider: 'deepseek', driver: 'openai', api_key_env: 'DEEPSEEK_API_KEY', @@ -111,10 +111,10 @@ const MockMcpLayer = Layer.succeed(McpService, { } as any); const MockMemoryLayer = Layer.succeed(MemoryService, { - getMemoryEnabled: () => true, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + getMemoryEnabled: () => Effect.succeed(true), + setMemoryEnabled: () => Effect.void, + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), } as any); const MockSchedulerLayer = Layer.succeed(SchedulerService, { @@ -126,8 +126,8 @@ const MockSchedulerLayer = Layer.succeed(SchedulerService, { } as any); const MockContextLayer = Layer.succeed(ContextService, { - assemblePayload: async () => [], - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 0 }), + assemblePayload: () => Effect.succeed([]), + compactWithLLM: () => Effect.succeed({ didCompress: false, released: 0, promptEstimate: 0 }), } as any); const MockCheckpointLayer = Layer.succeed(CheckpointService, { diff --git a/packages/codingcode/test/session/compute-paths.test.ts b/packages/codingcode/test/session/compute-paths.test.ts index f7109dd9..34736f32 100644 --- a/packages/codingcode/test/session/compute-paths.test.ts +++ b/packages/codingcode/test/session/compute-paths.test.ts @@ -23,7 +23,6 @@ describe('computePaths', () => { const result = computePaths(cwd, sid); expect(result.transcriptPath).toBe(sessionJsonlPathFromCwd(cwd, sid)); - expect(result.indexPath).toBe(result.transcriptPath.replace('.jsonl', '.index.json')); expect(result.sessionId).toBe(sid); expect(result.cwd).toBe(normalizePath(cwd)); expect(result.projectPath).toBe(encodeProjectPath(normalizePath(cwd))); @@ -38,7 +37,6 @@ describe('computePaths', () => { const sessionsDir = projectSessionsDir(encodeProjectPath(normalizePath(cwd))); const expectedTranscript = join(sessionsDir, parentSid, 'subagents', `${sid}.jsonl`); expect(result.transcriptPath).toBe(expectedTranscript); - expect(result.indexPath).toBe(expectedTranscript.replace('.jsonl', '.index.json')); expect(result.sessionId).toBe(sid); }); @@ -50,7 +48,7 @@ describe('computePaths', () => { return yield* svc.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -60,9 +58,6 @@ describe('computePaths', () => { expect(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath).toBe( expected.transcriptPath ); - expect(computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath).toBe( - expected.indexPath - ); expect(computePaths(state.cwd, state.sessionId, state.parentSessionId).projectPath).toBe( expected.projectPath ); @@ -86,7 +81,7 @@ describe('computePaths', () => { return yield* svc.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -100,7 +95,7 @@ describe('computePaths', () => { { model: 'subagent-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { parentSessionId: state.sessionId, @@ -118,7 +113,6 @@ describe('computePaths', () => { ); expect(actual).toEqual(expected); expect(existsSync(actual.transcriptPath)).toBe(true); - expect(existsSync(actual.indexPath)).toBe(true); expect(childState).not.toHaveProperty('projectPath'); expect(childState).not.toHaveProperty('transcriptPath'); expect(childState).not.toHaveProperty('indexPath'); diff --git a/packages/codingcode/test/session/create-active-profile.test.ts b/packages/codingcode/test/session/create-active-profile.test.ts index a8c35e1a..5d4ddc90 100644 --- a/packages/codingcode/test/session/create-active-profile.test.ts +++ b/packages/codingcode/test/session/create-active-profile.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'fs'; import { Effect } from 'effect'; import { computePaths } from '../../src/core/path.js'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; +import { readSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase(); @@ -21,22 +21,21 @@ describe('session activeProfile persistence', () => { return yield* session.create(cwd, { model: 'gpt-4o', activeProfile: 'plan', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); const paths = computePaths(state.cwd, state.sessionId, state.parentSessionId); - const index = JSON.parse(readFileSync(paths.indexPath, 'utf8')); + const meta = readSessionMeta(paths.transcriptPath); expect(state.activeProfile).toBe('plan'); - expect(state.sessionMeta?.activeProfile).toBe('plan'); - expect(index.activeProfile).toBe('plan'); + expect(meta?.activeProfile).toBe('plan'); expect(state).not.toHaveProperty('mode'); - expect(index).not.toHaveProperty('mode'); + expect(meta).not.toHaveProperty('mode'); }); - it('keeps an updated profile when later events rewrite the index', async () => { + it('keeps an updated profile when later events rewrite the session head', async () => { const cwd = '/tmp/test-active-profile-update'; const state = await run( Effect.gen(function* () { @@ -44,7 +43,7 @@ describe('session activeProfile persistence', () => { return yield* session.create(cwd, { model: 'gpt-4o', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -59,9 +58,9 @@ describe('session activeProfile persistence', () => { ); const paths = computePaths(state.cwd, state.sessionId, state.parentSessionId); - const index = JSON.parse(readFileSync(paths.indexPath, 'utf8')); + const meta = readSessionMeta(paths.transcriptPath); - expect(index.activeProfile).toBe('plan'); - expect(index).not.toHaveProperty('mode'); + expect(meta?.activeProfile).toBe('plan'); + expect(meta).not.toHaveProperty('mode'); }); }); diff --git a/packages/codingcode/test/session/create-session-profile.test.ts b/packages/codingcode/test/session/create-session-profile.test.ts index ab3bbc5f..81f60c67 100644 --- a/packages/codingcode/test/session/create-session-profile.test.ts +++ b/packages/codingcode/test/session/create-session-profile.test.ts @@ -18,7 +18,7 @@ describe('SessionService.create profile', () => { return yield* session.create('/tmp/test-session-profile', { model: 'gpt-4o', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); diff --git a/packages/codingcode/test/session/disk-setters.test.ts b/packages/codingcode/test/session/disk-setters.test.ts index bd08cc9d..16d2d5d9 100644 --- a/packages/codingcode/test/session/disk-setters.test.ts +++ b/packages/codingcode/test/session/disk-setters.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { existsSync, readFileSync, mkdirSync } from 'fs'; +import { mkdirSync } from 'fs'; import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; +import { readSessionMeta } from '../../src/session/file-ops.js'; import { HookService } from '../../src/hooks/port.js'; import { McpService } from '../../src/mcp/port.js'; import { RulesService } from '../../src/rules/port.js'; @@ -25,8 +26,8 @@ const mockMcpService = { } as any; const mockRulesService = { - getAllRules: () => '', - evictProjectRules: () => undefined, + getAllRules: () => Effect.succeed(''), + evictProjectRules: () => Effect.void, } as any; function makeLayer() { @@ -56,7 +57,7 @@ describe('SessionService disk setter/getter consistency', () => { return yield* session.create(cwd, { model: 'm', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -113,7 +114,7 @@ describe('SessionService disk setter/getter consistency', () => { }) ); expect(state.activeProfile).toBe('plan'); - expect(state.permissionMode).toBe('default'); + expect(state.permissionMode).toBe('ask'); }); it('setActiveProfile to build leaves permissionMode untouched', async () => { @@ -131,10 +132,10 @@ describe('SessionService disk setter/getter consistency', () => { }) ); expect(state.activeProfile).toBe('build'); - expect(state.permissionMode).toBe('default'); + expect(state.permissionMode).toBe('ask'); }); - it('setActiveProfile is durable across reload (file exists on disk)', async () => { + it('setActiveProfile is durable across reload (session head on disk)', async () => { await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; @@ -147,16 +148,10 @@ describe('SessionService disk setter/getter consistency', () => { return yield* session.load(cwd, sessionId); }) ); - expect( - existsSync(computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath) - ).toBe(true); - const idx = JSON.parse( - readFileSync( - computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, - 'utf8' - ) + const meta = readSessionMeta( + computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath ); - expect(idx.activeProfile).toBe('plan'); - expect(idx).not.toHaveProperty('mode'); + expect(meta?.activeProfile).toBe('plan'); + expect(meta).not.toHaveProperty('mode'); }); }); diff --git a/packages/codingcode/test/session/facade-surface.test.ts b/packages/codingcode/test/session/facade-surface.test.ts index a5bb1e22..b4593489 100644 --- a/packages/codingcode/test/session/facade-surface.test.ts +++ b/packages/codingcode/test/session/facade-surface.test.ts @@ -29,6 +29,7 @@ describe('session service surface', () => { 'renameSession', 'rollbackToTurn', 'setActiveProfile', + 'setModel', 'setPermissionMode', ]); }); diff --git a/packages/codingcode/test/session/filter-ui.test.ts b/packages/codingcode/test/session/filter-ui.test.ts index 186cf448..d39c6a4d 100644 --- a/packages/codingcode/test/session/filter-ui.test.ts +++ b/packages/codingcode/test/session/filter-ui.test.ts @@ -10,7 +10,7 @@ function makeBaseEvents(extra: SessionEvent[] = []): SessionEvent[] { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, @@ -127,7 +127,7 @@ describe('sessionEventsToTurns with summary', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, diff --git a/packages/codingcode/test/session/fork.test.ts b/packages/codingcode/test/session/fork.test.ts index 9865916f..fd87b04c 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 { SessionIndex, SessionEvent } from '../../src/contracts/session.js'; +import type { SessionMetaEvent, SessionEvent } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; import { computePaths } from '../../src/core/path.js'; @@ -19,7 +19,6 @@ function makeFixture(sessionId: string, slug: string) { const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = paths.transcriptPath; - const indexPath = paths.indexPath; const lines: any[] = [ { @@ -27,6 +26,10 @@ function makeFixture(sessionId: string, slug: string) { sessionId, cwd, createdAt: new Date().toISOString(), + model: 'test', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'first' }, { @@ -60,22 +63,23 @@ function makeFixture(sessionId: string, slug: string) { writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { + return { cwd, dir, transcriptPath }; +} + +function makeState(sessionId: string, cwd: string, title: string, currentTurnId: number) { + return { + type: 'session_meta' as const, sessionId, cwd, - model: 'test', createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 7, - title: 'fixture', - currentTurnId: 3, - usage: undefined, + model: 'test', + title, activeProfile: 'build' as const, - permissionMode: 'default' as const, + permissionMode: 'ask' as const, + currentTurnId, + usage: undefined, + memorySnapshot: '', }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { cwd, dir, transcriptPath, indexPath }; } function readEvents(jsonlPath: string): SessionEvent[] { @@ -111,25 +115,13 @@ describe('forkSession', () => { const slug = randomUUID(); const fx = makeFixture(sessionId, slug); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 7, - currentTurnId: 3, - sessionMeta: null, - model: 'test', - title: 'fixture', - usage: undefined, - activeProfile: 'build' as const, - permissionMode: 'default' as const, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, 'fixture', 3); // Fork at turn 2 (user message "second") const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 2); + return yield* svc.forkSession(state as any, 2); }) ); @@ -154,25 +146,13 @@ describe('forkSession', () => { const slug = randomUUID(); const fx = makeFixture(sessionId, slug); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 7, - currentTurnId: 3, - sessionMeta: null, - model: 'test', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'fixture', - usage: undefined, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, 'fixture', 3); // Fork at non-existent turnId so chain = all events (including summary + compact) const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 999); + return yield* svc.forkSession(state as any, 999); }) ); @@ -205,24 +185,12 @@ describe('forkSession', () => { const slug = randomUUID(); const fx = makeFixture(sessionId, slug); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 7, - currentTurnId: 3, - sessionMeta: null, - model: 'test', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'fixture', - usage: undefined, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, 'fixture', 3); const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 2); + return yield* svc.forkSession(state as any, 2); }) ); @@ -263,40 +231,28 @@ describe('forkSession', () => { } }); - it('fork creates index.json with correct metadata', async () => { + it('fork writes correct metadata into the forked session head', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 7, - currentTurnId: 3, - sessionMeta: null, - model: 'test', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'fixture', - usage: undefined, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, 'fixture', 3); const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 1); + return yield* svc.forkSession(state as any, 1); }) ); - const newIndexPath = join(fx.dir, `${newSessionId}.index.json`); - expect(existsSync(newIndexPath)).toBe(true); + const newTranscriptPath = join(fx.dir, `${newSessionId}.jsonl`); + expect(existsSync(newTranscriptPath)).toBe(true); - const idx = JSON.parse(readFileSync(newIndexPath, 'utf8')) as SessionIndex; - expect(idx.sessionId).toBe(newSessionId); - expect(idx.title).toBe('fixture'); - expect(idx.permissionMode).toBe('default'); - expect(idx.model).toBe('test'); + const meta = readEvents(newTranscriptPath)[0] as SessionMetaEvent; + expect(meta.sessionId).toBe(newSessionId); + expect(meta.title).toBe('fixture'); + expect(meta.permissionMode).toBe('ask'); + expect(meta.model).toBe('test'); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } @@ -310,7 +266,6 @@ describe('forkSession', () => { const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = paths.transcriptPath; - const indexPath = paths.indexPath; const fixedSummaryUuid = '11111111-1111-1111-1111-111111111111'; const fixedCompactUuid = '22222222-2222-2222-2222-222222222222'; @@ -321,6 +276,10 @@ describe('forkSession', () => { sessionId, cwd, createdAt: new Date().toISOString(), + model: 'test', + title: 'uuid-fixture', + activeProfile: 'build', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'q1' }, { type: 'assistant', turnId: 1, content: 'a1', toolCalls: [] }, @@ -330,40 +289,14 @@ describe('forkSession', () => { { type: 'compact', uuid: fixedCompactUuid, startTurnId: 1, endTurnId: 2 }, ]; writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd, - model: 'test', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: lines.length - 1, - title: 'uuid-fixture', - currentTurnId: 2, - usage: undefined, - activeProfile: 'build' as const, - permissionMode: 'default' as const, - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); try { - const state = { - sessionId, - cwd, - messageCount: lines.length - 1, - currentTurnId: 2, - sessionMeta: null, - model: 'test', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'uuid-fixture', - usage: undefined, - memorySnapshot: '', - }; + const state = makeState(sessionId, cwd, 'uuid-fixture', 2); const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 999); + return yield* svc.forkSession(state as any, 999); }) ); diff --git a/packages/codingcode/test/session/index-write-error.test.ts b/packages/codingcode/test/session/index-write-error.test.ts deleted file mode 100644 index f85ef745..00000000 --- a/packages/codingcode/test/session/index-write-error.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { appendFileSync, mkdirSync } from 'fs'; -import { dirname } from 'path'; -import { Effect } from 'effect'; -import { SessionService } from '../../src/session/port.js'; -import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; -import { AgentError } from '../../src/core/error.js'; -import * as fs from 'fs'; - -vi.mock('fs', async (importOriginal) => ({ - ...(await importOriginal()), - writeFileSync: vi.fn(() => { - throw new Error('index write failed'); - }), -})); - -describe('SessionService — index write error propagation', () => { - it('recordUser propagates SESSION_IO_ERROR when writeFileSync throws', async () => { - const sessionId = 'idx-err-user'; - const cwd = '/tmp'; - const paths = computePaths(cwd, sessionId); - mkdirSync(dirname(paths.transcriptPath), { recursive: true }); - appendFileSync(paths.transcriptPath, '', 'utf8'); - const state: any = { - sessionId, - cwd, - messageCount: 0, - currentTurnId: 1, - sessionMeta: { - type: 'session_meta', - sessionId, - cwd, - createdAt: new Date().toISOString(), - activeProfile: 'build', - permissionMode: 'default', - }, - model: 'test', - activeProfile: 'build', - permissionMode: 'default', - title: 'idx-err', - usage: undefined, - memorySnapshot: '', - }; - - const exit = await Effect.runPromiseExit( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.recordUser(state, 'hello'); - }).pipe(Effect.provide(SessionLayer)) - ); - - expect(exit._tag).toBe('Failure'); - if (exit._tag === 'Failure') { - const msg = String(exit.cause); - expect(msg).toContain('SESSION_IO_ERROR'); - expect(msg).toContain('index write failed'); - } - }); - - it('recordAssistant propagates SESSION_IO_ERROR when writeFileSync throws', async () => { - const sessionId = 'idx-err-asst'; - const cwd = '/tmp'; - const paths = computePaths(cwd, sessionId); - mkdirSync(dirname(paths.transcriptPath), { recursive: true }); - appendFileSync(paths.transcriptPath, '', 'utf8'); - const state: any = { - sessionId, - cwd, - messageCount: 0, - currentTurnId: 1, - sessionMeta: { - type: 'session_meta', - sessionId, - cwd, - createdAt: new Date().toISOString(), - activeProfile: 'build', - permissionMode: 'default', - }, - model: 'test', - activeProfile: 'build', - permissionMode: 'default', - title: 'idx-err', - usage: undefined, - memorySnapshot: '', - }; - - const exit = await Effect.runPromiseExit( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.recordAssistant(state, 'hi', []); - }).pipe(Effect.provide(SessionLayer)) - ); - - expect(exit._tag).toBe('Failure'); - if (exit._tag === 'Failure') { - const msg = String(exit.cause); - expect(msg).toContain('SESSION_IO_ERROR'); - expect(msg).toContain('index write failed'); - } - }); -}); diff --git a/packages/codingcode/test/session/index-write-sync.test.ts b/packages/codingcode/test/session/index-write-sync.test.ts deleted file mode 100644 index cb3af999..00000000 --- a/packages/codingcode/test/session/index-write-sync.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { randomUUID } from 'crypto'; -import { Effect } from 'effect'; -import { SessionService } from '../../src/session/port.js'; -import { SessionLayer } from '../../src/session/session.js'; - -import { encodeProjectPath, computePaths } from '../../src/core/path.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; -import { useTempProjectBase } from '../helpers/project-base.js'; - -const base = useTempProjectBase(); - -function run(eff: Effect.Effect): Promise { - return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); -} - -describe('index write is synchronous', () => { - it('recordUser immediately updates index file', async () => { - const slug = randomUUID(); - const dir = join(base.dir, slug); - mkdirSync(dir, { recursive: true }); - - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, { - model: 'test-model', - activeProfile: 'build', - permissionMode: 'default', - }); - }) - ); - - const indexPath = computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; - - const before = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; - expect(before.messageCount).toBe(1); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordUser(state, 'hello'); - }) - ); - - const after = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; - expect(after.messageCount).toBe(2); - expect(after.title).toBe('hello'); - } finally { - rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('recordAssistant immediately updates index file', async () => { - const slug = randomUUID(); - const dir = join(base.dir, slug); - mkdirSync(dir, { recursive: true }); - - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, { - model: 'test-model', - activeProfile: 'build', - permissionMode: 'default', - }); - }) - ); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordUser(state, 'hello'); - }) - ); - - const indexPath = computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'reply', [ - { id: 'tc1', name: 'bash', arguments: { cmd: 'echo' } }, - ]); - }) - ); - - const updated = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; - expect(updated.messageCount).toBe(3); - } finally { - rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/codingcode/test/session/io-error.test.ts b/packages/codingcode/test/session/io-error.test.ts index 0dfbc591..b6907d52 100644 --- a/packages/codingcode/test/session/io-error.test.ts +++ b/packages/codingcode/test/session/io-error.test.ts @@ -15,23 +15,15 @@ vi.mock('fs', async (importOriginal) => ({ describe('SessionService — SESSION_IO_ERROR', () => { it('recordUser propagates SESSION_IO_ERROR when appendFileSync throws', async () => { const state: any = { + type: 'session_meta', sessionId: 'io-err-sid', cwd: '/tmp', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { - type: 'session_meta', - sessionId: 'io-err-sid', - cwd: '/tmp', - createdAt: new Date().toISOString(), - activeProfile: 'build', - permissionMode: 'default', - }, + createdAt: new Date().toISOString(), model: 'test', + title: 'io-err-s', activeProfile: 'build', - permissionMode: 'default', - - title: 'io-err-sid'.slice(0, 8), + permissionMode: 'ask', + currentTurnId: 1, usage: undefined, memorySnapshot: '', }; @@ -53,23 +45,15 @@ describe('SessionService — SESSION_IO_ERROR', () => { it('recordAssistant propagates SESSION_IO_ERROR when appendFileSync throws', async () => { const state: any = { + type: 'session_meta', sessionId: 'io-err-asst', cwd: '/tmp', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { - type: 'session_meta', - sessionId: 'io-err-asst', - cwd: '/tmp', - createdAt: new Date().toISOString(), - activeProfile: 'build', - permissionMode: 'default', - }, + createdAt: new Date().toISOString(), model: 'test', + title: 'io-err-a', activeProfile: 'build', - permissionMode: 'default', - - title: 'io-err-asst'.slice(0, 8), + permissionMode: 'ask', + currentTurnId: 1, usage: undefined, memorySnapshot: '', }; @@ -90,23 +74,15 @@ describe('SessionService — SESSION_IO_ERROR', () => { it('Effect.try wraps I/O error as SESSION_IO_ERROR in service method', async () => { const state: any = { + type: 'session_meta', sessionId: 'io-err-eff', cwd: '/tmp', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { - type: 'session_meta', - sessionId: 'io-err-eff', - cwd: '/tmp', - createdAt: new Date().toISOString(), - activeProfile: 'build', - permissionMode: 'default', - }, + createdAt: new Date().toISOString(), model: 'test', + title: 'io-err-e', activeProfile: 'build', - permissionMode: 'default', - - title: 'io-err-eff'.slice(0, 8), + permissionMode: 'ask', + currentTurnId: 1, usage: undefined, memorySnapshot: '', }; diff --git a/packages/codingcode/test/session/load-create.test.ts b/packages/codingcode/test/session/load-create.test.ts index 2bdd9833..9a95d709 100644 --- a/packages/codingcode/test/session/load-create.test.ts +++ b/packages/codingcode/test/session/load-create.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { mkdirSync, readFileSync, rmSync } from 'fs'; +import { mkdirSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; 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 { encodeProjectPath, computePaths } from '../../src/core/path.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; +import { readSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -21,8 +20,8 @@ function cleanup(dir: string) { rmSync(dir, { recursive: true, force: true }); } -describe('load — restores model from disk, not overwritten', () => { - it('load restores model from index.json, not overwritten by caller', async () => { +describe('load — keeps the persisted model untouched', () => { + it('load does not overwrite model in the session head', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -34,7 +33,7 @@ describe('load — restores model from disk, not overwritten', () => { return yield* svc.create(dir, { model: 'gpt-4o', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -47,15 +46,18 @@ describe('load — restores model from disk, not overwritten', () => { }) ); - expect(loaded.model).toBe('gpt-4o'); expect(loaded.sessionId).toBe(sid); - expect(loaded.sessionMeta).not.toBeNull(); + + const meta = readSessionMeta( + computePaths(created.cwd, created.sessionId, created.parentSessionId).transcriptPath + ); + expect(meta?.model).toBe('gpt-4o'); } finally { cleanup(dir); } }); - it('load then rollbackToTurn preserves real model in index.json', async () => { + it('load then rollbackToTurn preserves real model in the session head', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -67,7 +69,7 @@ describe('load — restores model from disk, not overwritten', () => { return yield* svc.create(dir, { model: 'claude-3-5-sonnet', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -81,13 +83,12 @@ describe('load — restores model from disk, not overwritten', () => { }) ); - const beforeRollback = JSON.parse( - readFileSync( - computePaths(created.cwd, created.sessionId, created.parentSessionId).indexPath, - 'utf8' - ) - ) as SessionIndex; - expect(beforeRollback.model).toBe('claude-3-5-sonnet'); + const transcriptPath = computePaths( + created.cwd, + created.sessionId, + created.parentSessionId + ).transcriptPath; + expect(readSessionMeta(transcriptPath)?.model).toBe('claude-3-5-sonnet'); await run( Effect.gen(function* () { @@ -97,13 +98,7 @@ describe('load — restores model from disk, not overwritten', () => { }) ); - const afterRollback = JSON.parse( - readFileSync( - computePaths(created.cwd, created.sessionId, created.parentSessionId).indexPath, - 'utf8' - ) - ) as SessionIndex; - expect(afterRollback.model).toBe('claude-3-5-sonnet'); + expect(readSessionMeta(transcriptPath)?.model).toBe('claude-3-5-sonnet'); } finally { cleanup(dir); } @@ -146,7 +141,7 @@ describe('load — restores model from disk, not overwritten', () => { return yield* svc.create(dir, { model: 'gpt-4o', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -183,21 +178,20 @@ describe('create — generates sessionId internally', () => { return yield* svc.create(dir, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); expect(state.sessionId).toBeTruthy(); expect(state.sessionId.length).toBeGreaterThan(8); - expect(state.model).toBe('test-model'); - expect(state.sessionMeta).not.toBeNull(); + expect(state.type).toBe('session_meta'); } finally { cleanup(dir); } }); - it('create writes model to index.json immediately', async () => { + it('create writes model to the session head immediately', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -209,18 +203,15 @@ describe('create — generates sessionId internally', () => { return yield* svc.create(dir, { model: 'my-special-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); - const idx = JSON.parse( - readFileSync( - computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, - 'utf8' - ) - ) as SessionIndex; - expect(idx.model).toBe('my-special-model'); + const meta = readSessionMeta( + computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath + ); + expect(meta?.model).toBe('my-special-model'); } finally { cleanup(dir); } @@ -238,7 +229,7 @@ describe('create — generates sessionId internally', () => { return yield* svc.create(dir, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -253,7 +244,7 @@ describe('create — generates sessionId internally', () => { }); describe('load restores persisted fields', () => { - it('load restores currentTurnId from index.json', async () => { + it('load restores currentTurnId from the transcript tail', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -265,7 +256,7 @@ describe('load restores persisted fields', () => { return yield* svc.create(dir, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -286,14 +277,6 @@ describe('load restores persisted fields', () => { }) ); - const idx = JSON.parse( - readFileSync( - computePaths(created.cwd, created.sessionId, created.parentSessionId).indexPath, - 'utf8' - ) - ) as SessionIndex; - expect(idx.currentTurnId).toBe(2); - const loaded = await run( Effect.gen(function* () { const svc = yield* SessionService; @@ -307,7 +290,7 @@ describe('load restores persisted fields', () => { } }); - it('load restores usage from index.json', async () => { + it('load restores usage from the transcript tail', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -319,7 +302,7 @@ describe('load restores persisted fields', () => { return yield* svc.create(dir, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -343,13 +326,7 @@ describe('load restores persisted fields', () => { }) ); - const idx = JSON.parse( - readFileSync( - computePaths(created.cwd, created.sessionId, created.parentSessionId).indexPath, - 'utf8' - ) - ) as SessionIndex; - expect(idx.usage).toEqual(loaded.usage); + expect(loaded.usage).toBeUndefined(); } finally { cleanup(dir); } diff --git a/packages/codingcode/test/session/load-restore-profile.test.ts b/packages/codingcode/test/session/load-restore-profile.test.ts index 4284170e..4e614efe 100644 --- a/packages/codingcode/test/session/load-restore-profile.test.ts +++ b/packages/codingcode/test/session/load-restore-profile.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, ManagedRuntime } from 'effect'; -import { mkdirSync, writeFileSync, readFileSync } from 'fs'; +import { mkdirSync } from 'fs'; import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; +import { readSessionMeta, rewriteSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -12,7 +13,7 @@ const base = useTempProjectBase(); describe('SessionStoreState.activeProfile persistence (disk only)', () => { let cwd: string; let sessionId: string; - let indexPath: string; + let transcriptPath: string; let rt: ManagedRuntime.ManagedRuntime; function loadState() { @@ -34,16 +35,17 @@ describe('SessionStoreState.activeProfile persistence (disk only)', () => { const state = yield* session.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); return { sessionId: state.sessionId, - indexPath: computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, + transcriptPath: computePaths(state.cwd, state.sessionId, state.parentSessionId) + .transcriptPath, }; }) ); sessionId = result.sessionId; - indexPath = result.indexPath; + transcriptPath = result.transcriptPath; }); afterEach(async () => { @@ -67,11 +69,8 @@ describe('SessionStoreState.activeProfile persistence (disk only)', () => { expect(stateAfter.activeProfile).toBe('plan'); }); - it('state.activeProfile is set when index file has activeProfile field', async () => { - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - idx.activeProfile = 'plan'; - idx.permissionMode = 'default'; - writeFileSync(indexPath, JSON.stringify(idx, null, 2)); + it('state.activeProfile is set when the session head carries activeProfile', async () => { + rewriteSessionMeta(transcriptPath, { activeProfile: 'plan', permissionMode: 'ask' }); const state = await loadState(); expect(state.activeProfile).toBe('plan'); @@ -84,7 +83,7 @@ describe('SessionStoreState.activeProfile persistence (disk only)', () => { yield* session.setActiveProfile(cwd, sessionId, 'plan'); }) ); - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.activeProfile).toBe('plan'); + const meta = readSessionMeta(transcriptPath); + expect(meta?.activeProfile).toBe('plan'); }); }); diff --git a/packages/codingcode/test/session/parent-session-id.test.ts b/packages/codingcode/test/session/parent-session-id.test.ts index 7ce7a47f..3aba951f 100644 --- a/packages/codingcode/test/session/parent-session-id.test.ts +++ b/packages/codingcode/test/session/parent-session-id.test.ts @@ -1,20 +1,19 @@ import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { join } from 'path'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { encodeProjectPath, computePaths } from '../../src/core/path.js'; +import { computePaths } from '../../src/core/path.js'; +import { readSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; -const base = useTempProjectBase(); +useTempProjectBase(); function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } -describe('parentSessionId in index.json', () => { - it('write parentSessionId to index.json when passed to create opts', async () => { +describe('parentSessionId in session head', () => { + it('writes parentSessionId to the session head when passed to create opts', async () => { const cwd = '/tmp/test-parent-session-id'; const parentId = '00000000-0000-0000-0000-000000000001'; const state = await run( @@ -22,21 +21,16 @@ describe('parentSessionId in index.json', () => { const svc = yield* SessionService; return yield* svc.create( cwd, - { model: 'gpt-4o', activeProfile: 'build', permissionMode: 'default' }, + { model: 'gpt-4o', activeProfile: 'build', permissionMode: 'ask' }, { parentSessionId: parentId } ); }) ); - const idxRaw = readFileSync( - computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, - 'utf8' + const meta = readSessionMeta( + computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath ); - const idx = JSON.parse(idxRaw); - expect(idx.parentSessionId).toBe(parentId); - expect(idx.sessionId).toBe(state.sessionId); - - const projectDir = join(base.dir, encodeProjectPath(cwd)); - void projectDir; + expect(meta?.parentSessionId).toBe(parentId); + expect(meta?.sessionId).toBe(state.sessionId); }); }); diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index 10d80b79..8e2bd072 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; @@ -7,10 +7,10 @@ import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { estimatePromptTokensFrom } from '../../src/context/context.js'; -import { readHistory } from '../../src/session/file-ops.js'; +import { readHistory, readLastUsage, readSessionMeta } from '../../src/session/file-ops.js'; import { estimateTokensForContent } from '../../src/context/tokens.js'; import { encodeProjectPath, computePaths } from '../../src/core/path.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; +import type { SessionStoreState } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -25,7 +25,6 @@ function makeFixture( const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = paths.transcriptPath; - const indexPath = paths.indexPath; const lines: any[] = [ { @@ -33,6 +32,10 @@ function makeFixture( sessionId, cwd, createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, { type: 'user', @@ -68,22 +71,27 @@ function makeFixture( writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { + return { cwd, dir, transcriptPath }; +} + +function makeState( + sessionId: string, + cwd: string, + usage: { prompt: number; completion: number; total: number } | undefined +): SessionStoreState { + return { + type: 'session_meta', sessionId, cwd, - model: 'test-model', createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 4, + model: 'test-model', title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', currentTurnId: 2, - usage: usage ?? undefined, - activeProfile: 'build' as const, - permissionMode: 'default' as const, + memorySnapshot: '', + usage, }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { cwd, dir, transcriptPath, indexPath }; } function run(eff: Effect.Effect): Promise { @@ -91,69 +99,41 @@ function run(eff: Effect.Effect): Promise { } describe('promptEstimate', () => { - it('forkSession restores usage and promptEstimate from last visible assistant', async () => { + it('forkSession keeps the last visible assistant usage on the forked transcript', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const usage = { prompt: 800, completion: 400, total: 1200 }; const fx = makeFixture(sessionId, slug, usage); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 4, - currentTurnId: 2, - sessionMeta: null, - model: 'test-model', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'fixture', - usage, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, usage); const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; return yield* svc.forkSession(state, 2); }) ); - const newIndexPath = join(fx.dir, `${newSessionId}.index.json`); - const idx = JSON.parse(readFileSync(newIndexPath, 'utf8')) as SessionIndex; - expect(idx.usage).toEqual(usage); + const forkedPath = join(fx.dir, `${newSessionId}.jsonl`); + expect(readLastUsage(forkedPath)).toEqual(usage); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); - it('forkSession falls back to estimateTokens when no assistant usage', async () => { + it('forkSession produces a readable session with a positive prompt estimate when no assistant usage', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug, undefined); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 4, - currentTurnId: 2, - sessionMeta: null, - model: 'test-model', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'fixture', - usage: undefined, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, undefined); const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; return yield* svc.forkSession(state, 2); }) ); - const newIndexPath = join(fx.dir, `${newSessionId}.index.json`); - const idx = JSON.parse(readFileSync(newIndexPath, 'utf8')) as SessionIndex; - expect(idx.sessionId).toBe(newSessionId); - expect( - estimatePromptTokensFrom(readHistory(join(fx.dir, `${newSessionId}.jsonl`))) - ).toBeGreaterThan(0); + const forkedPath = join(fx.dir, `${newSessionId}.jsonl`); + expect(readSessionMeta(forkedPath)?.sessionId).toBe(newSessionId); + expect(estimatePromptTokensFrom(readHistory(forkedPath))).toBeGreaterThan(0); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } @@ -168,7 +148,7 @@ describe('token estimation', () => { }); describe('SessionService create sets model', () => { - it('create sets state.model and persists it to index', async () => { + it('create persists model to the session head', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -179,19 +159,15 @@ describe('SessionService create sets model', () => { return yield* svc.create(dir, { model: 'my-test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); - expect(state.model).toBe('my-test-model'); - const idx = JSON.parse( - readFileSync( - computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, - 'utf8' - ) + const meta = readSessionMeta( + computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath ); - expect(idx.model).toBe('my-test-model'); + expect(meta?.model).toBe('my-test-model'); } finally { await new Promise((r) => setTimeout(r, 50)); rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); diff --git a/packages/codingcode/test/session/record-tool-result-persist.test.ts b/packages/codingcode/test/session/record-tool-result-persist.test.ts index 3d4aeab2..f9cca5b0 100644 --- a/packages/codingcode/test/session/record-tool-result-persist.test.ts +++ b/packages/codingcode/test/session/record-tool-result-persist.test.ts @@ -19,7 +19,7 @@ describe('recordToolResult', () => { return yield* svc.create('/tmp/persist-test', { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -52,7 +52,7 @@ describe('recordToolResult', () => { return yield* svc.create('/tmp/persist-test-small', { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); diff --git a/packages/codingcode/test/session/rollback.test.ts b/packages/codingcode/test/session/rollback.test.ts index b8651691..23ab3944 100644 --- a/packages/codingcode/test/session/rollback.test.ts +++ b/packages/codingcode/test/session/rollback.test.ts @@ -4,7 +4,6 @@ import { join } from 'path'; import { randomUUID } from 'crypto'; import { filterForContext, buildContextMessages } from '../../src/context/context.js'; import { readHistory } from '../../src/session/file-ops.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -13,7 +12,6 @@ function makeFixture(sessionId: string, slug: string) { const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); - const indexPath = join(dir, `${sessionId}.index.json`); const lines: any[] = [ { @@ -21,6 +19,10 @@ function makeFixture(sessionId: string, slug: string) { sessionId, cwd: '/tmp/test', createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, @@ -44,22 +46,7 @@ function makeFixture(sessionId: string, slug: string) { writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd: '/tmp/test', - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 7, - title: 'fixture', - currentTurnId: 3, - usage: undefined, - activeProfile: 'build', - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { dir, transcriptPath, indexPath }; + return { dir, transcriptPath }; } function appendEvent(jsonlPath: string, event: object): void { diff --git a/packages/codingcode/test/session/session-jsonl-path.test.ts b/packages/codingcode/test/session/session-jsonl-path.test.ts index fae54972..e0433378 100644 --- a/packages/codingcode/test/session/session-jsonl-path.test.ts +++ b/packages/codingcode/test/session/session-jsonl-path.test.ts @@ -24,7 +24,7 @@ describe('sessionJsonlPathFromCwd', () => { return yield* svc.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -51,7 +51,7 @@ describe('sessionJsonlPathFromCwd', () => { return yield* svc.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -60,18 +60,12 @@ describe('sessionJsonlPathFromCwd', () => { expect( existsSync(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath) ).toBe(true); - expect( - existsSync(computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath) - ).toBe(true); deleteSession(state.sessionId, cwd); expect( existsSync(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath) ).toBe(false); - expect( - existsSync(computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath) - ).toBe(false); } finally { rmSync( join(base.dir, computePaths(state.cwd, state.sessionId, state.parentSessionId).projectPath), diff --git a/packages/codingcode/test/session/store-compact-usage.test.ts b/packages/codingcode/test/session/store-compact-usage.test.ts index 77737b65..b6589410 100644 --- a/packages/codingcode/test/session/store-compact-usage.test.ts +++ b/packages/codingcode/test/session/store-compact-usage.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; @@ -7,7 +7,7 @@ import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; +import type { SessionStoreState } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -29,7 +29,6 @@ function makeFixture( const paths = computePaths(cwd, sessionId); mkdirSync(join(base.dir, slug, 'sessions'), { recursive: true }); const transcriptPath = paths.transcriptPath; - const indexPath = paths.indexPath; const lines: any[] = [ { @@ -37,6 +36,10 @@ function makeFixture( sessionId, cwd, createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, ]; turns.forEach((t, i) => { @@ -53,22 +56,7 @@ function makeFixture( writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd, - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: lines.length, - title: 'fixture', - currentTurnId: turns.length, - usage: turns[turns.length - 1]?.usage, - activeProfile: 'build', - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { cwd, transcriptPath, indexPath }; + return { cwd, transcriptPath }; } function buildState( @@ -76,31 +64,24 @@ function buildState( cwd: string, initialUsage: { prompt: number; completion: number; total: number } | undefined, currentTurnId: number -) { +): SessionStoreState { return { + type: 'session_meta', sessionId, cwd, - messageCount: 0, - currentTurnId, - sessionMeta: { - type: 'session_meta' as const, - sessionId, - cwd, - createdAt: new Date().toISOString(), - activeProfile: 'build' as const, - permissionMode: 'default' as const, - }, + createdAt: new Date().toISOString(), model: 'test-model', - activeProfile: 'build' as const, - permissionMode: 'default' as const, title: 'fixture', - usage: initialUsage, + activeProfile: 'build', + permissionMode: 'ask', + currentTurnId, memorySnapshot: '', + usage: initialUsage, }; } describe('SessionService.appendSummary - state.usage reset (used by tryCompaction)', () => { - it('clears state.usage and persists the cleared value to the session index', async () => { + it('clears state.usage', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const usage1 = { prompt: 100, completion: 50, total: 150 }; @@ -114,14 +95,12 @@ describe('SessionService.appendSummary - state.usage reset (used by tryCompactio }) ); expect(state.usage).toBeUndefined(); - const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; - expect(idx.usage).toBeUndefined(); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); - it('preserves state.usage when called with state that has no prior usage', async () => { + it('keeps state.usage undefined when called with state that has no prior usage', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug, [{ user: 'q1', assistant: 'a1', usage: undefined }]); @@ -134,8 +113,6 @@ describe('SessionService.appendSummary - state.usage reset (used by tryCompactio }) ); expect(state.usage).toBeUndefined(); - const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; - expect(idx.usage).toBeUndefined(); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } diff --git a/packages/codingcode/test/session/store-diff-rebuild.test.ts b/packages/codingcode/test/session/store-diff-rebuild.test.ts index 04a608e2..3b7ced75 100644 --- a/packages/codingcode/test/session/store-diff-rebuild.test.ts +++ b/packages/codingcode/test/session/store-diff-rebuild.test.ts @@ -11,7 +11,7 @@ describe('sessionEventsToTurns', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -61,7 +61,7 @@ describe('sessionEventsToTurns', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -109,7 +109,7 @@ describe('sessionEventsToTurns', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', diff --git a/packages/codingcode/test/session/store-rollback-usage.test.ts b/packages/codingcode/test/session/store-rollback-usage.test.ts index fa195c02..741b1356 100644 --- a/packages/codingcode/test/session/store-rollback-usage.test.ts +++ b/packages/codingcode/test/session/store-rollback-usage.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; @@ -7,7 +7,7 @@ import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; +import type { SessionStoreState } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -29,7 +29,6 @@ function makeFixture( const paths = computePaths(cwd, sessionId); mkdirSync(join(base.dir, slug, 'sessions'), { recursive: true }); const transcriptPath = paths.transcriptPath; - const indexPath = paths.indexPath; const lines: any[] = [ { @@ -37,6 +36,10 @@ function makeFixture( sessionId, cwd, createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, ]; turns.forEach((t, i) => { @@ -53,22 +56,7 @@ function makeFixture( writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd, - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: lines.length, - title: 'fixture', - currentTurnId: turns.length, - usage: turns[turns.length - 1]?.usage, - activeProfile: 'build', - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { cwd, transcriptPath, indexPath }; + return { cwd, transcriptPath }; } function buildState( @@ -76,26 +64,19 @@ function buildState( cwd: string, initialUsage: { prompt: number; completion: number; total: number } | undefined, currentTurnId: number -) { +): SessionStoreState { return { + type: 'session_meta', sessionId, cwd, - messageCount: 0, - currentTurnId, - sessionMeta: { - type: 'session_meta' as const, - sessionId, - cwd, - createdAt: new Date().toISOString(), - activeProfile: 'build' as const, - permissionMode: 'default' as const, - }, + createdAt: new Date().toISOString(), model: 'test-model', - activeProfile: 'build' as const, - permissionMode: 'default' as const, title: 'fixture', - usage: initialUsage, + activeProfile: 'build', + permissionMode: 'ask', + currentTurnId, memorySnapshot: '', + usage: initialUsage, }; } @@ -107,15 +88,15 @@ describe('SessionService.rollbackToTurn - state.usage reset', () => { const fx = makeFixture(sessionId, slug, [{ user: 'q1', assistant: 'a1', usage: usage1 }]); try { const state = buildState(sessionId, fx.cwd, usage1, 1); - await run( + const reloaded = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.rollbackToTurn(state, 1, 'user rollback'); + yield* svc.rollbackToTurn(state, 1, 'user rollback'); + return yield* svc.load(fx.cwd, sessionId); }) ); expect(state.usage).toBeUndefined(); - const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; - expect(idx.usage).toBeUndefined(); + expect(reloaded.usage).toBeUndefined(); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } @@ -134,15 +115,15 @@ describe('SessionService.rollbackToTurn - state.usage reset', () => { ]); try { const state = buildState(sessionId, fx.cwd, usage3, 3); - await run( + const reloaded = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.rollbackToTurn(state, 2, 'user rollback'); + yield* svc.rollbackToTurn(state, 2, 'user rollback'); + return yield* svc.load(fx.cwd, sessionId); }) ); expect(state.usage).toEqual(usage1); - const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; - expect(idx.usage).toEqual(usage1); + expect(reloaded.usage).toEqual(usage1); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } diff --git a/packages/codingcode/test/session/types-export.test.ts b/packages/codingcode/test/session/types-export.test.ts index 5068818e..cf9b248e 100644 --- a/packages/codingcode/test/session/types-export.test.ts +++ b/packages/codingcode/test/session/types-export.test.ts @@ -4,16 +4,16 @@ import type { SessionStoreState } from '../../src/contracts/session.js'; describe('SessionStoreState export', () => { it('contains only cwd as its path source', () => { const state: SessionStoreState = { + type: 'session_meta', sessionId: 'test-sid', cwd: '/tmp', - messageCount: 0, - sessionMeta: null, + createdAt: '2026-01-01T00:00:00.000Z', model: 'gpt-4', title: '', + activeProfile: 'build', + permissionMode: 'ask', currentTurnId: 0, usage: undefined, - activeProfile: 'build', - permissionMode: 'default', memorySnapshot: '', }; diff --git a/packages/codingcode/test/session/ui-history-rollback.test.ts b/packages/codingcode/test/session/ui-history-rollback.test.ts index db73b85f..4f23b24b 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, SessionIndex } from '../../src/contracts/session.js'; +import type { SessionEvent } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -14,7 +14,6 @@ function makeFixture(sessionId: string, slug: string, extraEvents?: object[]) { const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); - const indexPath = join(dir, `${sessionId}.index.json`); const lines: any[] = [ { @@ -22,6 +21,10 @@ function makeFixture(sessionId: string, slug: string, extraEvents?: object[]) { sessionId, cwd: '/tmp/test', createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, @@ -46,22 +49,7 @@ function makeFixture(sessionId: string, slug: string, extraEvents?: object[]) { writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd: '/tmp/test', - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: lines.length, - title: 'fixture', - currentTurnId: 3, - usage: undefined, - activeProfile: 'build', - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { dir, transcriptPath, indexPath }; + return { dir, transcriptPath }; } describe('filterForContext', () => { @@ -75,8 +63,10 @@ describe('filterForContext', () => { sessionId, cwd: '/tmp', createdAt: new Date().toISOString(), + model: 'test-model', + title: 'test', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, @@ -122,8 +112,10 @@ describe('readUIHistory with visibility filtering', () => { sessionId, cwd: '/tmp', createdAt: new Date().toISOString(), + model: 'test-model', + title: 'test', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, @@ -135,22 +127,6 @@ describe('readUIHistory with visibility filtering', () => { mkdirSync(dir, { recursive: true }); const tp = join(dir, `${sessionId}.jsonl`); writeFileSync(tp, events.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - writeFileSync( - join(dir, `${sessionId}.index.json`), - JSON.stringify({ - sessionId, - cwd: '/tmp', - model: 't', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 7, - title: 'test', - currentTurnId: 2, - usage: undefined, - activeProfile: 'build', - permissionMode: 'default', - }) - ); const visible = filterForUI(readHistory(tp)); const turnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); diff --git a/packages/codingcode/test/session/update-index-dedup.test.ts b/packages/codingcode/test/session/update-index-dedup.test.ts deleted file mode 100644 index b44310d4..00000000 --- a/packages/codingcode/test/session/update-index-dedup.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { mkdirSync, rmSync } from 'fs'; -import { join } from 'path'; -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 * as fileOps from '../../src/session/file-ops.js'; -import { useTempProjectBase } from '../helpers/project-base.js'; - -const base = useTempProjectBase(); - -function run(eff: Effect.Effect): Promise { - return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); -} - -describe('updateIndex writes from state without rereading the index', () => { - it('recordUser does not reread the index', async () => { - const slug = randomUUID(); - const dir = join(base.dir, slug); - mkdirSync(dir, { recursive: true }); - - const spy = vi.spyOn(fileOps, 'readCurrentIndex'); - - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, { - model: 'test-model', - activeProfile: 'build', - permissionMode: 'default', - }); - }) - ); - spy.mockClear(); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordUser(state, 'hello world'); - }) - ); - - expect(spy).not.toHaveBeenCalled(); - } finally { - spy.mockRestore(); - rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('recordAssistant does not reread the index', async () => { - const slug = randomUUID(); - const dir = join(base.dir, slug); - mkdirSync(dir, { recursive: true }); - - const spy = vi.spyOn(fileOps, 'readCurrentIndex'); - - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, { - model: 'test-model', - activeProfile: 'build', - permissionMode: 'default', - }); - }) - ); - spy.mockClear(); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'reply', []); - }) - ); - - expect(spy).not.toHaveBeenCalled(); - } finally { - spy.mockRestore(); - rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('rollbackToTurn does not reread the index', async () => { - const slug = randomUUID(); - const dir = join(base.dir, slug); - mkdirSync(dir, { recursive: true }); - - const spy = vi.spyOn(fileOps, 'readCurrentIndex'); - - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, { - model: 'test-model', - activeProfile: 'build', - permissionMode: 'default', - }); - }) - ); - spy.mockClear(); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.rollbackToTurn(state, 1, 'test'); - }) - ); - - expect(spy).not.toHaveBeenCalled(); - } finally { - spy.mockRestore(); - rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/codingcode/test/session/view-assembly.test.ts b/packages/codingcode/test/session/view-assembly.test.ts index 252dbce2..d970b854 100644 --- a/packages/codingcode/test/session/view-assembly.test.ts +++ b/packages/codingcode/test/session/view-assembly.test.ts @@ -15,7 +15,7 @@ function makeEvents(extra: SessionEvent[] = []): SessionEvent[] { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { @@ -109,7 +109,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -136,7 +136,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -185,7 +185,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -239,7 +239,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -283,7 +283,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -326,7 +326,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'q1' }, { diff --git a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts index 2183a952..85cce985 100644 --- a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts +++ b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts @@ -10,7 +10,7 @@ import { AgentService } from '../../src/agent/port.js'; import { ApprovalService } from '../../src/approval/port.js'; import { CheckpointService } from '../../src/checkpoint/port.js'; import { ContextService } from '../../src/context/port.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; +import { LLMService } from '../../src/llm/port.js'; import { MemoryService } from '../../src/memory/port.js'; import { RulesService } from '../../src/rules/port.js'; import { SkillService } from '../../src/skills/port.js'; @@ -29,26 +29,18 @@ import { computePaths, } from '../../src/core/path.js'; import type { Message } from '../../src/contracts/types.js'; -import type { LLMClient } from '../../src/contracts/provider.js'; import type { FrameBody } from '../../src/contracts/frame.js'; -function makeMockLLM(content: string): LLMClient { - return { - complete: () => Effect.succeed({ content }), - completeStream: () => - (async function* () { - yield { type: 'text' as const, text: content }; - yield { type: 'end' as const }; - })(), - modelInfo: { - provider: 'mock', - model: 'mock', - maxTokens: 128000, - supportsToolCalling: false, - supportsStreaming: true, - }, - }; -} +const ANSWER = 'subagent final answer'; + +const LLMMock = Layer.succeed(LLMService, { + complete: () => Effect.succeed({ content: ANSWER }), + completeStream: () => + (async function* () { + yield { type: 'text' as const, text: ANSWER }; + yield { type: 'end' as const }; + })(), +} as any); /** Read events back into the message list an LLM would see (like context.assemblePayload). */ function readMessages(transcriptPath: string): Message[] { @@ -107,19 +99,17 @@ const AgentDeps = Layer.mergeAll( extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), } as any), Layer.succeed(ContextService, { - willCompact: async () => false, - assemblePayload: async (transcriptPath: string) => readMessages(transcriptPath), + willCompact: () => Effect.succeed(false), + assemblePayload: (transcriptPath: string) => Effect.sync(() => readMessages(transcriptPath)), } as any), Layer.succeed(MemoryService, { - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), - } as any), - Layer.succeed(LLMFactoryService, { - getLLMClient: () => Effect.succeed(makeMockLLM('subagent final answer') as LLMClient), + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), } as any), + LLMMock, Layer.succeed(RulesService, { - getAllRules: () => '', - evictProjectRules: () => {}, + getAllRules: () => Effect.succeed(''), + evictProjectRules: () => Effect.void, } as any), HookMock, McpMock, @@ -196,8 +186,9 @@ describe('subagent run end-to-end (session transcript is read by the agent loop) const session = yield* SessionService; const { stream, sessionId } = yield* agent.runTurn('analyze this code', { cwd, + model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); const content = yield* drainStream(stream); const state = yield* session.load(normalizePath(cwd), sessionId); @@ -235,11 +226,11 @@ describe('subagent run end-to-end (session transcript is read by the agent loop) const parent = yield* session.create(cwd, { model: 'parent-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); const child = yield* session.create( cwd, - { model: 'child-model', activeProfile: 'build', permissionMode: 'default' }, + { model: 'child-model', activeProfile: 'build', permissionMode: 'ask' }, { parentSessionId: parent.sessionId, agentName: 'build' } ); return { parentId: parent.sessionId, childId: child.sessionId }; diff --git a/packages/codingcode/test/subagent/dispatch-production-path.test.ts b/packages/codingcode/test/subagent/dispatch-production-path.test.ts new file mode 100644 index 00000000..4fd6f786 --- /dev/null +++ b/packages/codingcode/test/subagent/dispatch-production-path.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer } from 'effect'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { useTempHome } from '../helpers/temp-home.js'; +import { AgentLayer } from '../../src/agent/agent.js'; +import { ToolEnvLayer } from '../../src/agent/tool-env.js'; +import { AgentService } from '../../src/agent/port.js'; +import { SubagentRunnerLayer } from '../../src/subagent/subagent.js'; +import { ApprovalService } from '../../src/approval/port.js'; +import { CheckpointService } from '../../src/checkpoint/port.js'; +import { ContextService } from '../../src/context/port.js'; +import { LLMService } from '../../src/llm/port.js'; +import { MemoryService } from '../../src/memory/port.js'; +import { RulesService } from '../../src/rules/port.js'; +import { SkillService } from '../../src/skills/port.js'; +import { ToolExecutorLayer } from '../../src/tools/tools.js'; +import { SessionLayer } from '../../src/session/session.js'; +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, getProjectBaseDir } from '../../src/core/path.js'; +import type { SessionMetaEvent, ToolResultEvent } from '../../src/contracts/session.js'; +import type { Message } from '../../src/contracts/types.js'; +import type { LLMStreamPart } from '../../src/contracts/provider.js'; +import type { FrameBody, ToolOutcome } from '../../src/contracts/frame.js'; + +/** + * Scripted LLM: the parent turn asks for dispatch_agent, the child turn answers, + * then the parent turn wraps up. Ordering is fixed because the delegated turn + * runs synchronously inside the tool execution. + */ +const script: LLMStreamPart[][] = []; +let call = 0; + +const LLMMock = Layer.succeed(LLMService, { + complete: () => Effect.succeed({ content: '' }), + completeStream: () => + (async function* () { + const parts = script[call++] ?? [{ type: 'end' as const }]; + for (const p of parts) yield p; + })(), +} as any); + +function readMessages(transcriptPath: string): Message[] { + return readHistory(transcriptPath).flatMap((e) => { + if (e.type === 'user') return [{ role: 'user', content: e.content }] as Message[]; + if (e.type === 'assistant') + return [{ role: 'assistant', content: e.content, tool_calls: e.toolCalls }] as Message[]; + if (e.type === 'tool_result') + return [ + { + role: 'tool', + content: e.output ?? '', + tool_call_id: e.toolCallId, + tool_name: e.toolName, + } as Message, + ]; + return []; + }); +} + +interface Draining { + text: string; + toolResults: Array<{ id: string; name: string; outcome: ToolOutcome }>; + endReason: string; +} + +function drain(stream: AsyncGenerator): Effect.Effect { + return Effect.async((resume) => { + (async () => { + const out: Draining = { text: '', toolResults: [], endReason: 'unknown' }; + try { + for await (const body of stream) { + if (body.family === 'event') { + if (body.event.type === 'text_delta') out.text += body.event.text; + if (body.event.type === 'tool_result') { + out.toolResults.push({ + id: body.event.id, + name: body.event.name, + outcome: body.event.outcome, + }); + } + } else if (body.family === 'transition' && body.transition.to === 'end') { + out.endReason = body.transition.reason; + } + } + resume(Effect.succeed(out)); + } catch (e) { + resume(Effect.fail(e instanceof Error ? e : new Error(String(e)))); + } + })(); + }); +} + +const McpMock = Layer.succeed(McpService, { + syncConnections: () => Effect.void, + listProjectMcpTools: () => Effect.succeed([]), +} as any); + +const HookMock = Layer.succeed(HookService, { + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), +} as any); + +const TodoMock = Layer.succeed(TodoService, { read: () => [], write: () => {}, reset: () => {} } as any); + +// 真实的工具执行层:dispatch_agent 的参数解析与 ctx 装配都走生产代码 +const ToolExecutorWithDeps = ToolExecutorLayer.pipe(Layer.provide(HookMock)); + +const AgentDeps = Layer.mergeAll( + SessionLayer, + ToolExecutorWithDeps, + Layer.succeed(CheckpointService, { + snapshotBaseline: () => Effect.void, + snapshotFinal: () => Effect.void, + } as any), + Layer.succeed(ApprovalService, { + evaluate: () => Effect.succeed({ type: 'allow' }), + } as any), + Layer.succeed(SkillService, { + extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), + } as any), + Layer.succeed(ContextService, { + willCompact: () => Effect.succeed(false), + assemblePayload: (transcriptPath: string) => Effect.sync(() => readMessages(transcriptPath)), + } as any), + Layer.succeed(MemoryService, { + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), + } as any), + LLMMock, + Layer.succeed(RulesService, { + getAllRules: () => Effect.succeed(''), + evictProjectRules: () => Effect.void, + } as any), + HookMock, + McpMock, + TodoMock, + ToolEnvLayer +); + +const AgentWired = AgentLayer.pipe(Layer.provide(AgentDeps as any)); +const SubagentWired = SubagentRunnerLayer.pipe(Layer.provide(AgentWired)); + +const Runtime = Layer.mergeAll( + AgentWired, + SubagentWired, + SessionLayer, + ToolExecutorWithDeps, + HookMock, + McpMock, + TodoMock +); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(Runtime as any)) as any); +} + +describe('dispatch_agent on the production path (parent turn -> tool -> subagent turn)', () => { + useTempHome('codingcode-test-prod-path-home-'); + + let projectBase: string; + let cwd: string; + + beforeEach(() => { + projectBase = getProjectBaseDir(); + mkdirSync(projectBase, { recursive: true }); + cwd = mkdtempSync(join(tmpdir(), 'codingcode-test-prod-path-cwd-')); + script.length = 0; + call = 0; + }); + + afterEach(() => { + if (existsSync(cwd)) rmSync(cwd, { recursive: true, force: true }); + }); + + it('delegates without CONFIG_MISSING and lands the child transcript under the parent', async () => { + script.push( + [ + { + type: 'tool_call', + id: 'call-1', + name: 'dispatch_agent', + args: { agentName: 'reviewer', prompt: 'inspect the module' }, + }, + { type: 'end' }, + ], + // 子代理的那一回合 + [{ type: 'text', text: 'child answer' }, { type: 'end' }], + // 父代理收尾 + [{ type: 'text', text: 'parent done' }, { type: 'end' }] + ); + + const { drained, parentId } = await run( + Effect.gen(function* () { + const agent = yield* AgentService; + const { stream, sessionId } = yield* agent.runTurn('please delegate', { + cwd, + model: 'test-model', + activeProfile: 'build', + permissionMode: 'ask', + }); + const drained = yield* drain(stream); + return { drained, parentId: sessionId }; + }) + ); + + expect(drained.endReason).toBe('done'); + + const delegated = drained.toolResults.find((r) => r.name === 'dispatch_agent'); + expect(delegated).toBeDefined(); + // 阶段一之前这里必然是 [Error: CONFIG_MISSING],父会话的 activeProfile 到不了 dispatch_agent + expect(delegated!.outcome.status).toBe('ok'); + if (delegated!.outcome.status === 'ok') { + expect(delegated!.outcome.output).toContain('child answer'); + } + + const sessionsRoot = join(projectBase, encodeProjectPath(normalizePath(cwd)), 'sessions'); + const subagentDir = join(sessionsRoot, parentId, 'subagents'); + expect(existsSync(subagentDir)).toBe(true); + const childFiles = readdirSync(subagentDir).filter((f) => f.endsWith('.jsonl')); + expect(childFiles).toHaveLength(1); + + const childEvents = readHistory(join(subagentDir, childFiles[0]!)); + const meta = childEvents[0] as SessionMetaEvent; + expect(meta.type).toBe('session_meta'); + expect(meta.agentName).toBe('reviewer'); + expect(meta.parentSessionId).toBe(parentId); + expect(childEvents.some((e) => e.type === 'assistant')).toBe(true); + + // 父转录里留下了这次委派的调用与结果 + const parentEvents = readHistory(join(sessionsRoot, `${parentId}.jsonl`)); + const toolResult = parentEvents.find( + (e) => e.type === 'tool_result' && e.toolName === 'dispatch_agent' + ) as ToolResultEvent | undefined; + expect(toolResult).toBeDefined(); + expect(toolResult!.output).toContain('child answer'); + }, 30_000); +}); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 5eaad5b9..bc8052d9 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -1,11 +1,19 @@ import { expect, it, describe, beforeEach, vi } from 'vitest'; import { Effect, Layer } from 'effect'; +import { z } from 'zod'; import { dispatchAgentTool } from '../../src/tools/domains/subagent/dispatch.js'; import { HookService } from '../../src/hooks/port.js'; import { SubagentRunnerService } from '../../src/subagent/port.js'; import type { ToolExecCtx } from '../../src/contracts/tool.js'; import type { FrameBody } from '../../src/contracts/frame.js'; +// 只有清单内的模型能被透传,测试内固定一份最小清单 +vi.mock('../../src/infra/models.js', async (importOriginal) => ({ + ...(await importOriginal()), + findModel: (id: string) => + id === 'child-model' ? ({ id: 'child-model@test', model: 'child-model' } as any) : null, +})); + const mockHooks = { emit: vi.fn(() => Effect.succeed(undefined)), emitDecision: vi.fn(() => Effect.succeed(null)), @@ -13,7 +21,7 @@ const mockHooks = { }; const mockRunner = { - runSubagent: vi.fn(() => + runSubagent: vi.fn((_input: string, _opts: Record) => Effect.succeed({ stream: makeRunStream(), sessionId: 'child-1' }) ), }; @@ -32,29 +40,58 @@ function makeLayers() { ); } +/** 工具执行上下文:activeProfile / model 由父会话透传,缺前者则派发不出来 */ +function parentCtx(overrides: Partial = {}): ToolExecCtx { + return { + projectPath: '/test', + sessionId: 'parent-1', + activeProfile: 'build', + model: 'parent-model', + ...overrides, + }; +} + function runTool(args: unknown, ctx: ToolExecCtx): Promise { return Effect.runPromise( dispatchAgentTool.execute(args, ctx).pipe(Effect.provide(makeLayers())) ); } +function runToolEither(args: unknown, ctx: ToolExecCtx) { + return Effect.runPromise( + Effect.either(dispatchAgentTool.execute(args, ctx).pipe(Effect.provide(makeLayers()))) + ); +} + describe('dispatch_agent (runner-based subagent spawn)', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('case 1: dispatches build subagent and returns the runner output', async () => { + it('exposes agentName/prompt/model/systemPrompt and no dangling catalog pointer', () => { + expect(dispatchAgentTool.description).not.toContain('Available Subagents'); + const schema = z.toJSONSchema(dispatchAgentTool.parameters) as any; + expect(Object.keys(schema.properties).sort()).toEqual([ + 'agentName', + 'model', + 'prompt', + 'systemPrompt', + ]); + expect(schema.required).toEqual(['agentName', 'prompt']); + }); + + it('case 1: dispatches a subagent and returns the runner output', async () => { const out = await runTool( - { agent: 'build', prompt: 'go' }, - { projectPath: '/test', sessionId: 'parent-1' } + { agentName: 'build', prompt: 'go' }, + parentCtx() ); expect(out).toBe('done'); }); - it('case 2: forwards prompt, cwd and parent session id to the runner', async () => { + it('case 2: forwards prompt, cwd, parent session id, profile and bypass to the runner', async () => { await runTool( - { agent: 'build', prompt: 'analyze this code' }, - { projectPath: '/test', sessionId: 'parent-1' } + { agentName: 'reviewer', prompt: 'analyze this code' }, + parentCtx() ); expect(mockRunner.runSubagent).toHaveBeenCalledTimes(1); @@ -64,43 +101,66 @@ describe('dispatch_agent (runner-based subagent spawn)', () => { cwd: '/test', parentSessionId: 'parent-1', activeProfile: 'build', - agentName: 'build', + agentName: 'reviewer', + permissionMode: 'bypass', }) ); }); - it('case 3: rejects unknown profile (custom subagents removed)', async () => { - const outcome = await Effect.runPromise( - Effect.either( - dispatchAgentTool - .execute( - { agent: 'custom', prompt: 'go' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - .pipe(Effect.provide(makeLayers())) - ) + it('case 3: passes model and systemPrompt through, inherits the turn model when model is omitted or not in the catalog', async () => { + await runTool( + { agentName: 'build', prompt: 'go', model: 'child-model', systemPrompt: 'CUSTOM PROMPT' }, + parentCtx() + ); + expect(mockRunner.runSubagent).toHaveBeenLastCalledWith( + 'go', + expect.objectContaining({ model: 'child-model', systemPrompt: 'CUSTOM PROMPT' }) + ); + + mockRunner.runSubagent.mockClear(); + await runTool({ agentName: 'build', prompt: 'go' }, parentCtx()); + const omitted = mockRunner.runSubagent.mock.calls.at(-1)![1]; + expect(omitted.model).toBe('parent-model'); + expect(omitted.systemPrompt).toBeUndefined(); + + mockRunner.runSubagent.mockClear(); + await runTool({ agentName: 'build', prompt: 'go', model: 'not-in-catalog' }, parentCtx()); + expect(mockRunner.runSubagent.mock.calls.at(-1)![1].model).toBe('parent-model'); + }); + + it('case 4: accepts any non-empty agentName (no profile lookup)', async () => { + const out = await runTool( + { agentName: 'custom-name', prompt: 'go' }, + parentCtx() + ); + expect(out).toBe('done'); + expect(mockRunner.runSubagent).toHaveBeenCalledWith( + 'go', + expect.objectContaining({ agentName: 'custom-name' }) + ); + }); + + it('case 5: fails with CONFIG_MISSING when the parent profile is missing', async () => { + const outcome = await runToolEither( + { agentName: 'build', prompt: 'go' }, + { projectPath: '/test', sessionId: 'parent-1', model: 'parent-model' } ); expect(outcome._tag).toBe('Left'); if (outcome._tag === 'Left') { const err: any = outcome.left; - expect(err.code).toBe('TOOL_EXECUTION_FAILED'); - expect(String(err.message)).toContain('Unknown subagent: custom'); + expect(err.code).toBe('CONFIG_MISSING'); + expect(String(err.message)).toContain('activeProfile'); } + expect(mockRunner.runSubagent).not.toHaveBeenCalled(); }); - it('case 4: spawn.before deny hook blocks the dispatch', async () => { + it('case 6: spawn.before deny hook blocks the dispatch', async () => { mockHooks.emitDecision.mockReturnValueOnce( Effect.succeed({ decision: 'deny' as const, reason: 'policy forbids it' }) as any ); - const outcome = await Effect.runPromise( - Effect.either( - dispatchAgentTool - .execute( - { agent: 'build', prompt: 'go' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - .pipe(Effect.provide(makeLayers())) - ) + const outcome = await runToolEither( + { agentName: 'build', prompt: 'go' }, + parentCtx() ); expect(outcome._tag).toBe('Left'); if (outcome._tag === 'Left') { @@ -109,15 +169,15 @@ describe('dispatch_agent (runner-based subagent spawn)', () => { } }); - it('case 5: emits spawn.after and complete carrying the child session id', async () => { + it('case 7: emits spawn.after and complete carrying the child session id and agentName', async () => { await runTool( - { agent: 'build', prompt: 'go' }, - { projectPath: '/test', sessionId: 'parent-1' } + { agentName: 'reviewer', prompt: 'go' }, + parentCtx() ); expect(mockHooks.emit).toHaveBeenCalledWith( 'agent.subagent.spawn.after', - expect.objectContaining({ childSessionId: 'child-1', profile: 'build' }) + expect.objectContaining({ childSessionId: 'child-1', agentName: 'reviewer' }) ); expect(mockHooks.emit).toHaveBeenCalledWith( 'agent.subagent.complete', @@ -125,7 +185,7 @@ describe('dispatch_agent (runner-based subagent spawn)', () => { ); }); - it('case 6: a stream that ends with error fails the tool and skips complete', async () => { + it('case 8: a stream that ends with error fails the tool and skips complete', async () => { mockRunner.runSubagent.mockReturnValueOnce( Effect.succeed({ stream: (async function* () { @@ -138,15 +198,9 @@ describe('dispatch_agent (runner-based subagent spawn)', () => { }) as any ); - const outcome = await Effect.runPromise( - Effect.either( - dispatchAgentTool - .execute( - { agent: 'build', prompt: 'go' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - .pipe(Effect.provide(makeLayers())) - ) + const outcome = await runToolEither( + { agentName: 'build', prompt: 'go' }, + parentCtx() ); expect(outcome._tag).toBe('Left'); diff --git a/packages/codingcode/test/subagent/runner-service.test.ts b/packages/codingcode/test/subagent/runner-service.test.ts index 75b597f0..c7d2b223 100644 --- a/packages/codingcode/test/subagent/runner-service.test.ts +++ b/packages/codingcode/test/subagent/runner-service.test.ts @@ -51,7 +51,7 @@ describe('SubagentRunnerService', () => { ( Effect.gen(function* () { const runner = yield* SubagentRunnerService; - const { stream, sessionId } = yield* runner.runSubagent('go', { cwd: '/test' }); + 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 new file mode 100644 index 00000000..e8186e4d --- /dev/null +++ b/packages/codingcode/test/subagent/runner-wiring.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer } from 'effect'; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { useTempHome } from '../helpers/temp-home.js'; +import { AgentLayer } from '../../src/agent/agent.js'; +import { ToolEnvLayer } from '../../src/agent/tool-env.js'; +import { SubagentRunnerLayer } from '../../src/subagent/subagent.js'; +import { SubagentRunnerService } from '../../src/subagent/port.js'; +import { ApprovalService } from '../../src/approval/port.js'; +import { CheckpointService } from '../../src/checkpoint/port.js'; +import { ContextService } from '../../src/context/port.js'; +import { LLMService } from '../../src/llm/port.js'; +import { AgentError } from '../../src/core/error.js'; +import { MemoryService } from '../../src/memory/port.js'; +import { RulesService } from '../../src/rules/port.js'; +import { SkillService } from '../../src/skills/port.js'; +import { ToolExecutorService } from '../../src/tools/port.js'; +import { SessionLayer } from '../../src/session/session.js'; +import { SessionService } from '../../src/session/port.js'; +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, + getProjectBaseDir, + computePaths, +} from '../../src/core/path.js'; +import type { SessionMetaEvent } 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'; + +const CHILD_MODEL = 'child-model'; +const PARENT_MODEL = 'parent-model'; + +/** 记录每次推理请求:system 是那次回合真正用的系统提示词,model 是本次请求的模型值 */ +interface Recorder { + calls: Array<{ system: string; model: string }>; +} + +const rec: Recorder = { calls: [] }; +const CHILD_ANSWER = 'child answer'; + +/** 未命中即失败(与 LlmLayer 的 entryFor 同语义);空模型同样不是合法模型 */ +const LLMMock = Layer.succeed(LLMService, { + complete: () => Effect.succeed({ content: CHILD_ANSWER }), + completeStream: (req: LLMRequest, model: string) => + (async function* () { + const target = model?.trim() ?? ''; + if (target !== CHILD_MODEL) { + throw new AgentError('CONFIG_INVALID', `Model "${target}" not found in models.json`); + } + rec.calls.push({ system: req.system ?? '', model: target }); + yield { type: 'text' as const, text: CHILD_ANSWER }; + yield { type: 'end' as const }; + })(), +} as any); + +function readMessages(transcriptPath: string): Message[] { + return readHistory(transcriptPath).flatMap((e) => { + if (e.type === 'user') return [{ role: 'user', content: e.content }] as Message[]; + if (e.type === 'assistant') + return [{ role: 'assistant', content: e.content, tool_calls: e.toolCalls }] as Message[]; + if (e.type === 'tool_result') + return [ + { + role: 'tool', + content: e.output ?? '', + tool_call_id: e.toolCallId, + tool_name: e.toolName, + } as Message, + ]; + return []; + }); +} + +/** Drain a frame stream and return the accumulated assistant text. */ +function drainStream(stream: AsyncGenerator): Effect.Effect { + return Effect.async((resume) => { + (async () => { + let content = ''; + try { + for await (const body of stream) { + if (body.family === 'event' && body.event.type === 'text_delta') { + content += body.event.text; + } else if ( + body.family === 'transition' && + body.transition.to === 'end' && + body.transition.reason === 'error' + ) { + resume(Effect.fail(new Error(`subagent failed: ${body.transition.error.message}`))); + return; + } + } + resume(Effect.succeed(content)); + } catch (e) { + resume(Effect.fail(e instanceof Error ? e : new Error(String(e)))); + } + })(); + }); +} + +const McpMock = Layer.succeed(McpService, { + syncConnections: () => Effect.void, + listProjectMcpTools: () => Effect.succeed([]), +} as any); + +const HookMock = Layer.succeed(HookService, { + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), +} 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 }), + } as any), + Layer.succeed(CheckpointService, { + snapshotBaseline: () => Effect.void, + snapshotFinal: () => Effect.void, + } as any), + Layer.succeed(ApprovalService, { + evaluate: () => Effect.succeed({ type: 'allow' }), + } as any), + Layer.succeed(SkillService, { + extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), + } as any), + Layer.succeed(ContextService, { + willCompact: () => Effect.succeed(false), + assemblePayload: (transcriptPath: string) => Effect.sync(() => readMessages(transcriptPath)), + } as any), + Layer.succeed(MemoryService, { + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), + } as any), + LLMMock, + Layer.succeed(RulesService, { + getAllRules: () => Effect.succeed(''), + evictProjectRules: () => Effect.void, + } as any), + HookMock, + McpMock, + TodoMock, + ToolEnvLayer +); + +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); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(Runtime as any)) as any); +} + +describe('subagent runner wiring (child session mounts under the parent)', () => { + useTempHome('codingcode-test-runner-home-'); + + let projectBase: string; + let cwd: string; + + beforeEach(() => { + projectBase = getProjectBaseDir(); + mkdirSync(projectBase, { recursive: true }); + cwd = mkdtempSync(join(tmpdir(), 'codingcode-test-runner-cwd-')); + rec.calls.length = 0; + }); + + afterEach(() => { + if (existsSync(cwd)) rmSync(cwd, { recursive: true, force: true }); + }); + + /** Create a parent session, then run one subagent turn under it. */ + function spawnChild(opts: { model?: string; systemPrompt?: string; agentName?: string }) { + return run( + Effect.gen(function* () { + const session = yield* SessionService; + const runner = yield* SubagentRunnerService; + const parent = yield* session.create(cwd, { + model: PARENT_MODEL, + activeProfile: 'build', + permissionMode: 'ask', + }); + const { stream, sessionId } = yield* runner.runSubagent('do the thing', { + cwd, + activeProfile: 'build', + parentSessionId: parent.sessionId, + agentName: opts.agentName ?? 'reviewer', + model: opts.model ?? CHILD_MODEL, + ...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}), + }); + const content = yield* drainStream(stream); + return { parentId: parent.sessionId, childId: sessionId, content }; + }) + ); + } + + function pathsFor(childId: string, parentId: string) { + return computePaths(normalizePath(cwd), childId, parentId); + } + + it('writes the child transcript under sessions//subagents/ and reloads it with the parent id', async () => { + const { parentId, childId, content } = await spawnChild({}); + + const nested = pathsFor(childId, parentId).transcriptPath; + expect(existsSync(nested)).toBe(true); + expect(nested).toContain(join(parentId, 'subagents', `${childId}.jsonl`)); + + const sessionsRoot = join(projectBase, encodeProjectPath(normalizePath(cwd)), 'sessions'); + expect(existsSync(join(sessionsRoot, `${childId}.jsonl`))).toBe(false); + + expect(content.length).toBeGreaterThan(0); + const types = readHistory(nested).map((e) => e.type); + expect(types[0]).toBe('session_meta'); + expect(types).toContain('user'); + expect(types).toContain('assistant'); + }); + + it('records parentSessionId, agentName and the bypass permission mode in the child session', async () => { + const { parentId, childId } = await spawnChild({ agentName: 'reviewer' }); + + const meta = readHistory(pathsFor(childId, parentId).transcriptPath)[0] as SessionMetaEvent; + expect(meta.parentSessionId).toBe(parentId); + // permissionMode 默认 bypass:runSubagent 未传时由 runner 兜底 + expect(meta.permissionMode).toBe('bypass'); + expect(meta.agentName).toBe('reviewer'); + }); + + it('reloads the child session through session.load when the parent id is supplied', async () => { + const { parentId, childId } = await spawnChild({}); + + const reloaded = await run( + Effect.gen(function* () { + const session = yield* SessionService; + 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 }; + }) + ); + + expect(reloaded.ok).not.toBeNull(); + expect(reloaded.ok!.parentSessionId).toBe(parentId); + expect(reloaded.ok!.cwd).toBe(normalizePath(cwd)); + + // 不带父参会算成顶层路径,子会话因此找不到 + expect(reloaded.withoutParent._tag).toBe('Left'); + if (reloaded.withoutParent._tag === 'Left') { + expect((reloaded.withoutParent.left as any).code).toBe('SESSION_NOT_FOUND'); + } + }); + + it('forwards the requested model through to the LLM layer', async () => { + await spawnChild({ model: CHILD_MODEL }); + expect(rec.calls.at(-1)!.model).toBe(CHILD_MODEL); + }); + + it('fails the child turn when the requested model is not in the catalog', async () => { + await expect(spawnChild({ model: 'not-registered' })).rejects.toThrow(/not-registered/); + }); + + it('replaces only the middle section of the child system prompt', async () => { + await spawnChild({ systemPrompt: 'CUSTOM-MIDDLE-MARKER' }); + const system = rec.calls.at(-1)!.system; + + expect(system).toContain('## Environment'); + expect(system).toContain('## System Notes'); + // profile 段被整段换掉:默认 build 提示词不再出现 + expect(system).not.toContain('How you work'); + + const envIdx = system.indexOf('## Environment'); + const notesIdx = system.indexOf('## System Notes'); + const customIdx = system.indexOf('CUSTOM-MIDDLE-MARKER'); + expect(customIdx).toBeGreaterThan(envIdx); + expect(customIdx).toBeLessThan(notesIdx); + }); +}); diff --git a/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts b/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts index 2908eaa1..5210cbae 100644 --- a/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts +++ b/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts @@ -36,7 +36,7 @@ describe('tools/domains/bash projectPath isolation', () => { } }); - const ctx = (cwd: string) => ({ projectPath: cwd }); + const ctx = (cwd: string) => ({ projectPath: cwd, model: 'test-model' }); it('executes command in ctx.projectPath when cwd arg is absent', async () => { // On Windows, use PowerShell to write a file in the current directory diff --git a/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts b/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts index 82b3ca5e..159e6981 100644 --- a/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts +++ b/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts @@ -40,7 +40,7 @@ describe('tools/domains/fs projectPath isolation', () => { } }); - const ctx = (cwd: string) => ({ projectPath: cwd }); + const ctx = (cwd: string) => ({ projectPath: cwd, model: 'test-model' }); it('read_file uses ctx.projectPath over workspaceCwd', async () => { writeFileSync(join(projectDir, 'a.txt'), 'hello', 'utf8'); diff --git a/packages/codingcode/test/tools/executor-concurrency.test.ts b/packages/codingcode/test/tools/executor-concurrency.test.ts index d02e6414..37dcf6e8 100644 --- a/packages/codingcode/test/tools/executor-concurrency.test.ts +++ b/packages/codingcode/test/tools/executor-concurrency.test.ts @@ -118,7 +118,13 @@ function tc(id: string, name: string, args: Record): ToolCall { function runBatch( calls: ToolCall[], - ctx: { projectPath: string; sessionId?: string; signal?: AbortSignal }, + ctx: { + projectPath: string; + sessionId?: string; + signal?: AbortSignal; + activeProfile?: 'plan' | 'build'; + model: string; + }, h: Harness, mcpSpecs: McpToolSpec[] = [] ): Promise { @@ -131,6 +137,8 @@ function runBatch( projectPath: ctx.projectPath, signal: ctx.signal, toolLookup: catalog.lookup, + activeProfile: ctx.activeProfile, + model: ctx.model, }); }); return Effect.runPromise( @@ -177,7 +185,7 @@ describe('executeBatch 保序波次调度', () => { tc('r2', 'read_file', { path: 'b.txt' }), tc('r3', 'read_file', { path: 'c.txt' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); @@ -199,7 +207,7 @@ describe('executeBatch 保序波次调度', () => { tc('e1', 'edit_file', { path: 'f.txt', old_string: 'AAA', new_string: 'XXX' }), tc('e2', 'edit_file', { path: 'f.txt', old_string: 'BBB', new_string: 'YYY' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); @@ -221,7 +229,7 @@ describe('executeBatch 保序波次调度', () => { tc('c1', 'execute_command', { command: 'echo hi' }), tc('r1', 'read_file', { path: 'a.txt' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); @@ -241,7 +249,7 @@ describe('executeBatch 保序波次调度', () => { tc('r', 'read_file', { path: 's.txt' }), tc('w', 'write_file', { path: 's.txt', content: 'AFTER' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); expect(okOutput(before[0])).toContain('BEFORE'); @@ -254,7 +262,7 @@ describe('executeBatch 保序波次调度', () => { tc('w', 'write_file', { path: 's.txt', content: 'AFTER' }), tc('r', 'read_file', { path: 's.txt' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); expect(okOutput(after[1])).toContain('AFTER'); @@ -272,7 +280,7 @@ describe('executeBatch 保序波次调度', () => { tc('e1', 'edit_file', { path: 't.txt', old_string: 'OLD', new_string: 'NEW' }), tc('r2', 'read_file', { path: 't.txt' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); @@ -299,13 +307,13 @@ describe('executeBatch 保序波次调度', () => { }); h.clear(); - const serial = await runBatch(calls, { projectPath: dir }, 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 }, 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); }); @@ -320,7 +328,7 @@ describe('executeBatch 保序波次调度', () => { plan: [{ step: 'step one', status: 'pending' }], }), ], - { projectPath: dir, sessionId: 'sid-1' }, + { projectPath: dir, sessionId: 'sid-1', model: 'test-model' }, h ); @@ -334,10 +342,10 @@ describe('executeBatch 保序波次调度', () => { it('[dispatch_agent, write_file] 零重叠,且委派与写入的先后由声明顺序决定', async () => { const results = await runBatch( [ - tc('d1', 'dispatch_agent', { agent: 'build', prompt: 'go' }), + tc('d1', 'dispatch_agent', { agentName: 'build', prompt: 'go' }), tc('w1', 'write_file', { path: 'z.txt', content: 'z' }), ], - { projectPath: dir, sessionId: 'sid-1' }, + { projectPath: dir, sessionId: 'sid-1', activeProfile: 'build', model: 'm' }, h ); @@ -352,9 +360,9 @@ describe('executeBatch 保序波次调度', () => { const reversed = await runBatch( [ tc('w2', 'write_file', { path: 'z2.txt', content: 'z' }), - tc('d2', 'dispatch_agent', { agent: 'build', prompt: 'go' }), + tc('d2', 'dispatch_agent', { agentName: 'build', prompt: 'go' }), ], - { projectPath: dir, sessionId: 'sid-1' }, + { projectPath: dir, sessionId: 'sid-1', activeProfile: 'build', model: 'm' }, h ); expect(reversed.map((r) => r.id)).toEqual(['w2', 'd2']); @@ -372,7 +380,7 @@ describe('executeBatch 保序波次调度', () => { tc('w1', 'write_file', { path: 'w.txt', content: 'x' }), tc('r2', 'read_file', { path: 'a.txt' }), ], - { projectPath: dir, signal: controller.signal }, + { projectPath: dir, signal: controller.signal, model: 'test-model' }, h ); @@ -394,7 +402,7 @@ describe('executeBatch 保序波次调度', () => { tc('w1', 'write_file', { path: 'w.txt', content: 'x' }), tc('r2', 'read_file', { path: 'a.txt' }), ], - { projectPath: dir, signal: controller.signal }, + { projectPath: dir, signal: controller.signal, model: 'test-model' }, h ); diff --git a/packages/codingcode/test/tools/todo.test.ts b/packages/codingcode/test/tools/todo.test.ts index 3661dbb0..f5d2c843 100644 --- a/packages/codingcode/test/tools/todo.test.ts +++ b/packages/codingcode/test/tools/todo.test.ts @@ -21,7 +21,7 @@ describe('todo_write tool', () => { { step: 'third', status: 'completed' }, ], }, - { sessionId: 'test-agent' } + { sessionId: 'test-agent', model: 'test-model' } ) .pipe(Effect.provide(TodoLayer)) ); @@ -63,7 +63,7 @@ describe('todo_write tool', () => { it('fails with AgentError if sessionId is missing', async () => { const exit = await Effect.runPromiseExit( tool - .execute({ plan: [{ step: 'x', status: 'pending' }] }, {}) + .execute({ plan: [{ step: 'x', status: 'pending' }] }, { model: 'test-model' }) .pipe(Effect.provide(TodoLayer)) ); expect(exit._tag).toBe('Failure'); diff --git a/packages/codingcode/test/tsconfig.json b/packages/codingcode/test/tsconfig.json index 7e0a889a..8d182a63 100644 --- a/packages/codingcode/test/tsconfig.json +++ b/packages/codingcode/test/tsconfig.json @@ -8,5 +8,5 @@ "noEmit": true }, "include": ["**/*.ts"], - "references": [{ "path": "../tsconfig.json" }, { "path": "../../infra" }] + "references": [{ "path": "../tsconfig.json" }] } diff --git a/packages/codingcode/test/types/type-collapse.test.ts b/packages/codingcode/test/types/type-collapse.test.ts index 2c5145e1..bdf24e20 100644 --- a/packages/codingcode/test/types/type-collapse.test.ts +++ b/packages/codingcode/test/types/type-collapse.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import type { UITurn, UITurnItem } from '../../src/contracts/session.js'; -import type { ForkResult, RollbackContextResult } from '../../src/client/contracts.js'; import type { TodoItem, TokenUsage } from '../../src/contracts/types.js'; type AssertNotAny = 0 extends 1 & T ? never : T; @@ -9,10 +8,6 @@ type _UITurnNotAny = AssertNotAny; type _TodoNotAny = AssertNotAny; type _UsageNotAny = AssertNotAny; -// 同构断言:两侧互相可赋值才通过 -type _ForkTurnsIsUITurn = ForkResult['turns'] extends UITurn[] ? true : never; -type _ContextTurnsIsUITurn = RollbackContextResult['turns'] extends UITurn[] ? true : never; - describe('类型收口', () => { it('UITurn.status 不再退化为 string', () => { const turn: UITurn = { id: '1', items: [], status: 'completed' }; diff --git a/packages/codingcode/tsconfig.json b/packages/codingcode/tsconfig.json index fcd63a61..232d7618 100644 --- a/packages/codingcode/tsconfig.json +++ b/packages/codingcode/tsconfig.json @@ -7,6 +7,5 @@ "module": "NodeNext", "moduleResolution": "NodeNext" }, - "include": ["src/**/*.ts", "src/**/*.tsx"], - "references": [{ "path": "../infra" }] + "include": ["src/**/*.ts", "src/**/*.tsx"] } diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 401a14d2..3e87722a 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -4,9 +4,6 @@ import tailwindcss from '@tailwindcss/vite'; import { resolve } from 'path'; import { readFileSync } from 'fs'; -const codingcodeRoot = resolve(__dirname, '../codingcode/src'); -const infraRoot = resolve(__dirname, '../infra/src'); - // 读取 package.json 获取第三方依赖列表 const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')); const thirdPartyDeps = Object.keys(pkg.dependencies || {}).filter( @@ -26,14 +23,6 @@ export default defineConfig({ resolve: { alias: { '@shared': resolve('shared'), - '@codingcode/core/layer': resolve(codingcodeRoot, 'layer.ts'), - '@codingcode/core/server/create': resolve(codingcodeRoot, 'server/index.ts'), - '@codingcode/core/server/port-discovery': resolve( - codingcodeRoot, - 'server/port-discovery.ts' - ), - '@codingcode/infra/config': resolve(infraRoot, 'config.ts'), - '@codingcode/infra/logger': resolve(infraRoot, 'logger.ts'), }, }, }, diff --git a/packages/desktop/electron/core/child-process.ts b/packages/desktop/electron/core/child-process.ts index 22fe3717..6efdd42d 100644 --- a/packages/desktop/electron/core/child-process.ts +++ b/packages/desktop/electron/core/child-process.ts @@ -45,6 +45,7 @@ export async function startBackend(): Promise { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], shell: true, + env: { ...process.env, CODINGCODE_PROJECT_ROOT: root }, }); } else { // 生产模式:node 运行 esbuild 打包后的单文件 @@ -54,6 +55,7 @@ export async function startBackend(): Promise { env: { ...process.env, NODE_ENV: 'production', + CODINGCODE_PROJECT_ROOT: root, }, }); } diff --git a/packages/desktop/package.json b/packages/desktop/package.json index c41fb612..919def07 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -20,8 +20,7 @@ "release": "pnpm run bundle:backend && pnpm run build && electron-builder --publish always" }, "dependencies": { - "@codingcode/core": "workspace:*", - "@codingcode/infra": "workspace:*", + "@codingcode/sdk": "workspace:*", "@tanstack/react-virtual": "^3.12.6", "immer": "^10.1.1", "lucide-react": "^1.17.0", diff --git a/packages/desktop/shared/types.ts b/packages/desktop/shared/types.ts index 8525ce67..cb6f62e3 100644 --- a/packages/desktop/shared/types.ts +++ b/packages/desktop/shared/types.ts @@ -47,6 +47,7 @@ export interface Thread { projectId: string; title: string; cwd: string; + model?: string; turns: Turn[]; createdAt: number; updatedAt: number; diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index 3db821bb..ca4b0e32 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -1,16 +1,38 @@ import { useState, useRef, useCallback, useLayoutEffect, useEffect } from 'react'; import { createPortal } from 'react-dom'; -import { Send, Square, ShieldAlert, ShieldCheck, Shield, Eye, FileText } from 'lucide-react'; +import { Send, Square, ShieldAlert, ShieldCheck, Shield, FileText } from 'lucide-react'; import { useAgentStore } from '../stores/agent.store'; import { useWorkspaceStore } from '../stores/workspace.store'; -import { API_BASE, api } from '../lib/api'; -import { setSessionPermissionMode } from '../lib/core-api'; +import { + compactSession, + setSessionModel, + setSessionPermissionMode, + setAgentConfig, +} from '../lib/core-api'; import MessageStream from './MessageStream'; import TodoPanel from './TodoPanel'; import ApprovalPanel from './ApprovalPanel'; import ProfileIndicator from './ProfileIndicator'; -import { APPROVAL_POLICY_TO_PERMISSION_MODE } from '../hooks/useAgent'; import PlanPanel from '../shared/PlanPanel'; +import type { PermissionMode } from '@codingcode/sdk'; + +const MODE_LABELS: Record = { + ask: '全部询问', + acceptEdits: '半自动', + bypass: '完全放行', +}; + +const MODE_NEXT: Record = { + ask: 'acceptEdits', + acceptEdits: 'bypass', + bypass: 'ask', +}; + +const MODE_ICONS: Record = { + ask: , + acceptEdits: , + bypass: , +}; // ─── ContextIndicator ────────────────────────────────────────────────────── @@ -22,6 +44,7 @@ function ContextIndicator({ threadId }: { threadId: string }) { const isCompressing = useAgentStore((s) => s.isCompressing); const startCompressing = useAgentStore((s) => s.startCompressing); const stopCompressing = useAgentStore((s) => s.stopCompressing); + const model = useAgentStore((s) => s.model); const r = 7; const circ = 2 * Math.PI * r; @@ -70,14 +93,7 @@ function ContextIndicator({ threadId }: { threadId: string }) { onClick={async () => { startCompressing(); try { - const res = await api<{ promptEstimate: number; didCompress: boolean; released: number }>( - `/api/sessions/${threadId}/compact`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cwd: '' }), - } - ); + const res = await compactSession(threadId, '', model); if (res.didCompress && contextUsage) { setContextUsage({ used: res.promptEstimate, @@ -118,7 +134,9 @@ function ContextIndicator({ threadId }: { threadId: string }) { function ModelSelector() { const model = useAgentStore((s) => s.model); const models = useAgentStore((s) => s.models); - const setModel = useAgentStore((s) => s.setModel); + const selectModel = useAgentStore((s) => s.selectModel); + const currentThreadId = useAgentStore((s) => s.currentThreadId); + const rootPath = useWorkspaceStore((s) => s.rootPath); const [open, setOpen] = useState(false); const buttonRef = useRef(null); const dropdownRef = useRef(null); @@ -169,13 +187,9 @@ function ModelSelector() { type="button" key={m.id} onClick={async () => { - setModel(m.id); + selectModel(m.id); setOpen(false); - await api(`/api/models/switch`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ modelId: m.id }), - }).catch((e) => { + await setSessionModel(currentThreadId, rootPath ?? '', m.id).catch((e) => { console.error('Failed to switch model:', e); }); }} @@ -225,15 +239,21 @@ function InputBox({ const thread = s.threads[tid]; return thread?.turns.some((t) => t.status === 'running') ?? false; }); - const approvalPolicy = useAgentStore((s) => s.approvalPolicy); + const storePermissionMode = useAgentStore((s) => s.permissionMode); + const setPermissionMode = useAgentStore((s) => s.setPermissionMode); const workspace = useWorkspaceStore(); - const setApprovalPolicy = useAgentStore((s) => s.setApprovalPolicy); const pendingInput = useAgentStore((s) => s.pendingInput); const setPendingInput = useAgentStore((s) => s.setPendingInput); + // 有会话时显示该会话真实的权限模式;还没有会话时显示 config.yaml 里的值 + const sessionPermissionMode = useAgentStore((s) => + s.currentThreadId ? (s.profileByThreadId[s.currentThreadId]?.permissionMode ?? null) : null + ); + const permissionMode: PermissionMode = sessionPermissionMode ?? storePermissionMode; + const isPlanProfile = useAgentStore((s) => { if (!s.currentThreadId) { - return s.pendingProfile === 'plan'; + return s.profile === 'plan'; } return s.profileByThreadId[s.currentThreadId]?.activeProfile === 'plan'; }); @@ -259,24 +279,6 @@ function InputBox({ sendMessage(trimmed, workspace.rootPath || undefined); }, [text, isStreaming, sendMessage, workspace.rootPath]); - const POLICY_LABELS: Record = { - 'ask-all': '全部询问', - 'smart-allow': '半自动', - 'full-allow': '完全放行', - 'read-only': '只读模式', - }; - const POLICY_NEXT: Record = { - 'ask-all': 'smart-allow', - 'smart-allow': 'full-allow', - 'full-allow': 'read-only', - 'read-only': 'ask-all', - }; - const POLICY_ICONS: Record = { - 'ask-all': , - 'smart-allow': , - 'full-allow': , - 'read-only': , - }; return (
@@ -328,22 +330,30 @@ function InputBox({ )} diff --git a/packages/desktop/src/agent/AutomationForm.tsx b/packages/desktop/src/agent/AutomationForm.tsx index 6cefd4aa..924dbdb1 100644 --- a/packages/desktop/src/agent/AutomationForm.tsx +++ b/packages/desktop/src/agent/AutomationForm.tsx @@ -1,11 +1,12 @@ import { useState, useEffect } from 'react'; import { X } from 'lucide-react'; -import { useAgentStore, type Automation } from '../stores/agent.store'; +import { useAgentStore } from '../stores/agent.store'; +import type { AutomationSandbox } from '@codingcode/sdk'; import { createAutomation, updateAutomation } from '../lib/core-api'; interface AutomationFormProps { automationId: string | null; - defaultProjectCwd: string; + initialProjectCwd: string; onClose: () => void; onSaved: () => void; } @@ -34,12 +35,12 @@ const WEEKDAYS = [ export function AutomationForm({ automationId, - defaultProjectCwd, + initialProjectCwd, onClose, onSaved, }: AutomationFormProps) { const automations = useAgentStore((s) => s.automations); - const existing = automationId ? automations.find((a: Automation) => a.id === automationId) : null; + const existing = automationId ? automations.find((a) => a.id === automationId) : null; const [name, setName] = useState(existing?.name ?? ''); const [description, setDescription] = useState(existing?.description ?? ''); @@ -51,10 +52,10 @@ export function AutomationForm({ const [intervalUnit, setIntervalUnit] = useState<'hours' | 'minutes'>('hours'); const [customCron, setCustomCron] = useState(existing?.cron ?? ''); const [timezone, setTimezone] = useState(existing?.timezone ?? 'Asia/Shanghai'); - const [sandbox, setSandbox] = useState<'readonly' | 'workspace-write'>( + const [sandbox, setSandbox] = useState( existing?.sandbox ?? 'workspace-write' ); - const [projectCwd, setProjectCwd] = useState(existing?.projectCwd ?? defaultProjectCwd); + const [projectCwd, setProjectCwd] = useState(existing?.projectCwd ?? initialProjectCwd); const [runOnce, setRunOnce] = useState(existing?.runOnce ?? false); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); @@ -389,7 +390,7 @@ export function AutomationForm({