From d88a1b25ebcc2356aea93ecbbdac3142efb270a9 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 25 Jun 2026 00:09:10 +0800 Subject: [PATCH 1/3] =?UTF-8?q?Architecture=20cleanup=20=E2=80=94=20SDK/cl?= =?UTF-8?q?ient=20=E5=B1=82=E9=87=8D=E7=BB=84=20+=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=20+=20=E6=AD=BB=E4=BB=A3=E7=A0=81=E6=B8=85?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PR.md | 229 +++++++ packages/codingcode/package.json | 8 +- packages/codingcode/src/agent/agent.ts | 32 +- .../codingcode/src/agent/stream-adapter.ts | 67 ++ packages/codingcode/src/agent/types.ts | 2 +- packages/codingcode/src/approval/index.ts | 15 +- packages/codingcode/src/approval/types.ts | 6 +- packages/codingcode/src/cli.ts | 14 +- packages/codingcode/src/client/direct.ts | 508 -------------- .../src/client/direct/agent-runtime.ts | 156 ----- .../codingcode/src/client/direct/index.ts | 22 - packages/codingcode/src/client/http.ts | 102 +-- .../src/client/http/agent-runtime.ts | 81 ++- .../codingcode/src/client/http/request.ts | 21 +- .../codingcode/src/client/http/sessions.ts | 29 + .../codingcode/src/client/http/settings.ts | 41 +- packages/codingcode/src/client/types.ts | 12 +- packages/codingcode/src/core/error.ts | 19 + packages/codingcode/src/core/path.ts | 1 - packages/codingcode/src/core/paths.ts | 33 + .../codingcode/src/direct/agent-runtime.ts | 358 ++++++++++ .../src/{client => }/direct/models.ts | 6 +- .../src/{client => }/direct/sessions.ts | 93 ++- .../src/{client => }/direct/settings.ts | 93 ++- packages/codingcode/src/memory/index.ts | 2 +- packages/codingcode/src/plan/index.ts | 33 +- .../codingcode/src/runtime/project-runtime.ts | 102 +-- packages/codingcode/src/scheduler/service.ts | 41 +- packages/codingcode/src/server/index.ts | 16 +- .../codingcode/src/server/routes/agent.ts | 61 -- .../codingcode/src/server/routes/messages.ts | 56 +- .../codingcode/src/server/routes/sessions.ts | 67 +- packages/codingcode/src/session/file-ops.ts | 51 +- packages/codingcode/src/session/store.ts | 96 ++- packages/codingcode/src/session/types.ts | 2 + packages/codingcode/src/session/ui-history.ts | 3 +- .../src/tools/domains/subagent/dispatch.ts | 48 +- packages/codingcode/src/tools/executor.ts | 6 +- .../test/agent/build-system-prompt.test.ts | 2 +- .../agent/send-message-optional-mode.test.ts | 47 ++ .../test/agent/submit-plan-turn-end.test.ts | 4 +- ...e.test.ts => fork-permission-mode.test.ts} | 56 +- .../codingcode/test/approval/response.test.ts | 1 - .../test/client/agent-client-cwd.test.ts | 311 --------- .../test/client/direct-todo.test.ts | 2 +- .../test/client/direct-types.test.ts | 59 +- .../codingcode/test/client/direct.test.ts | 11 +- .../test/client/direct/settings.test.ts | 2 +- .../test/client/get-session-plan.test.ts | 50 ++ .../test/client/http-direct-parity.test.ts | 28 + .../test/client/missing-methods.test.ts | 152 +++++ .../test/context/compressor/behavior.test.ts | 6 +- .../codingcode/test/core/error-code.test.ts | 26 + packages/codingcode/test/core/error.test.ts | 22 +- packages/codingcode/test/core/paths.test.ts | 31 + .../test/layer/system-hook-layer.test.ts | 94 +-- .../test/plan/active-sessions.test.ts | 41 -- .../test/plan/gate-pipeline.test.ts | 78 ++- packages/codingcode/test/plan/gate.test.ts | 116 +++- .../test/runtime/set-session-profile.test.ts | 52 +- .../test/scheduler/approval-bypass.test.ts | 29 + .../test/security/plan-mode-restart.test.ts | 53 +- .../test/server/agent-routes.test.ts | 75 -- .../create-session-active-profile.test.ts | 25 +- .../messages-fork-permission-mode.test.ts | 141 ++++ .../server/plan-mode-reject-perm-mode.test.ts | 184 ----- .../server/routes-use-compute-paths.test.ts | 21 + .../test/session/compute-paths.test.ts | 21 +- .../session/create-active-profile.test.ts | 91 +++ .../create-session-with-profile.test.ts | 56 ++ .../test/session/disk-setters.test.ts | 143 ++++ .../test/session/load-create.test.ts | 1 - .../test/session/load-restore-profile.test.ts | 40 +- .../test/session/parent-session-id.test.ts | 38 ++ .../record-tool-result-persist.test.ts | 2 +- .../test/session/session-jsonl-path.test.ts | 3 +- .../test/subagent/dispatch-end-to-end.test.ts | 39 +- .../codingcode/test/subagent/dispatch.test.ts | 642 +++++------------- .../test/tools/submit-plan-slug.test.ts | 4 +- packages/desktop/package.json | 1 + packages/desktop/src/agent/AgentWorkspace.tsx | 42 +- packages/desktop/src/agent/ModeIndicator.tsx | 7 +- packages/desktop/src/hooks/useAgent.ts | 33 +- packages/desktop/src/lib/api.ts | 12 +- packages/desktop/src/lib/core-api.ts | 31 +- packages/desktop/src/shared/PlanPanel.tsx | 5 +- packages/desktop/src/stores/agent.store.ts | 6 +- .../desktop/test/core-api-clients.test.ts | 44 ++ packages/desktop/test/core-api.test.ts | 96 +-- .../test/input-box-plan-button.test.tsx | 4 +- packages/desktop/test/plan-panel.test.tsx | 29 +- packages/tui/src/components/App.tsx | 18 +- packages/tui/src/index.tsx | 88 ++- 93 files changed, 3000 insertions(+), 2747 deletions(-) create mode 100644 PR.md create mode 100644 packages/codingcode/src/agent/stream-adapter.ts delete mode 100644 packages/codingcode/src/client/direct.ts delete mode 100644 packages/codingcode/src/client/direct/agent-runtime.ts delete mode 100644 packages/codingcode/src/client/direct/index.ts create mode 100644 packages/codingcode/src/core/paths.ts create mode 100644 packages/codingcode/src/direct/agent-runtime.ts rename packages/codingcode/src/{client => }/direct/models.ts (87%) rename packages/codingcode/src/{client => }/direct/sessions.ts (68%) rename packages/codingcode/src/{client => }/direct/settings.ts (88%) delete mode 100644 packages/codingcode/src/server/routes/agent.ts create mode 100644 packages/codingcode/test/agent/send-message-optional-mode.test.ts rename packages/codingcode/test/approval/{permission-mode.test.ts => fork-permission-mode.test.ts} (55%) delete mode 100644 packages/codingcode/test/client/agent-client-cwd.test.ts create mode 100644 packages/codingcode/test/client/get-session-plan.test.ts create mode 100644 packages/codingcode/test/client/http-direct-parity.test.ts create mode 100644 packages/codingcode/test/client/missing-methods.test.ts create mode 100644 packages/codingcode/test/core/error-code.test.ts create mode 100644 packages/codingcode/test/core/paths.test.ts delete mode 100644 packages/codingcode/test/plan/active-sessions.test.ts create mode 100644 packages/codingcode/test/scheduler/approval-bypass.test.ts delete mode 100644 packages/codingcode/test/server/agent-routes.test.ts create mode 100644 packages/codingcode/test/server/messages-fork-permission-mode.test.ts delete mode 100644 packages/codingcode/test/server/plan-mode-reject-perm-mode.test.ts create mode 100644 packages/codingcode/test/server/routes-use-compute-paths.test.ts create mode 100644 packages/codingcode/test/session/create-active-profile.test.ts create mode 100644 packages/codingcode/test/session/create-session-with-profile.test.ts create mode 100644 packages/codingcode/test/session/disk-setters.test.ts create mode 100644 packages/codingcode/test/session/parent-session-id.test.ts create mode 100644 packages/desktop/test/core-api-clients.test.ts diff --git a/PR.md b/PR.md new file mode 100644 index 00000000..6cdc9639 --- /dev/null +++ b/PR.md @@ -0,0 +1,229 @@ +# PR: Architecture cleanup — SDK/client 层重组 + 路径统一 + 死代码清除 + +## 变更概述 + +73 文件改动(-3603 / +1141),净删约 2462 行。零通信架构变更,桌面端行为不变,全部 1290 测试通过。 + +--- + +## 核心目标 + +- 消除 `client/direct/` 与 `client/http/` 之间的实现冗余 +- 将进程内 facade 从 `client/` 目录剥离(TUI 不走 SDK 包装) +- 计算路径统一由 `core/paths.ts` 提供,清除 routes 手搓替代 +- 错误处理协议统一,消除 `approvalOverride` latent bug + +--- + +## 1. `client/` 目录重组 + +### 1a. 删除 `createDirectClient`(`client/direct.ts`) + +516 行组合层工厂,把 4 个子客户端摊平成 `AgentClient` 50-method 接口。TUI 是唯一消费者,但 TUI 不应经过 SDK 包装——它可直接组合 4 个进程内 facade。 + +- **删**: `packages/codingcode/src/client/direct.ts` +- **删**: `packages/codingcode/src/client/direct/index.ts`(`createDirectClients` 工厂,22 行) +- **删**: `packages/codingcode/src/client/direct/agent-runtime.ts` +- **删**: `packages/codingcode/src/client/direct/sessions.ts` +- **删**: `packages/codingcode/src/client/direct/settings.ts` +- **删**: `packages/codingcode/src/client/direct/models.ts` + +### 1b. 进程内 facade 搬至 `src/direct/` + +4 个文件从 `client/direct/` 搬到 `src/direct/`,import 路径修正(`../../` → `../`)。TUI 通过 `createTuiClientFromFacades`(`index.tsx`)直接组合。 + +- **新**: `packages/codingcode/src/direct/agent-runtime.ts` +- **新**: `packages/codingcode/src/direct/sessions.ts` +- **新**: `packages/codingcode/src/direct/settings.ts` +- **新**: `packages/codingcode/src/direct/models.ts` + +`AgentRuntimeClient` 接口在此次重构中补全了 checkpoint/rollback/fork 方法(之前是空壳 stub)。 + +### 1c. TUI 重写 `index.tsx` + `App.tsx` + +- **新**: `packages/tui/src/index.tsx` — 定义 `TuiClient` 接口(17 方法,非 50)、导出 `createTuiClientFromFacades(llm, rt)`、`runTui({client})` +- **改**: `packages/tui/src/components/App.tsx` — `AgentClient` → `TuiClient` 类型(24 处调用签名不变) +- **改**: `packages/codingcode/src/cli.ts` — 调用 `createTuiClientFromFacades` 而非 `createDirectClient` + +### 1d. `package.json` exports 更新 + +**删**: +- `./client/direct` +- `./client/direct-clients` + +**增**: +- `./direct/agent-runtime` +- `./direct/sessions` +- `./direct/settings` +- `./direct/models` +- `./agent/stream-adapter` + +### 1e. 桌面端零修改 + +desktop 一直使用 `createHttpClients`(`client/http/index.ts`)走 HTTP sub-clients,不受 `client/direct/` 重组影响。 + +--- + +## 2. HTTP SDK 侧清理 + +### 2a. checkpoint 空壳改转发(修复 `createHttpClient` 假数据 bug) + +`client/http.ts:136-215` 的 10 个 checkpoint/rollback/fork 方法从硬编码空返回改为转发 `clients.agent.*`,与其他方法一致风格。 + +- `getCheckpoints`, `getCheckpointDiff`, `revertCheckpointFiles`, `previewRollbackDiff`, `rollbackCodeToTurn`, `rollbackContext`, `rollbackBothToTurn`, `undoLastCodeRollback`, `getRollbackState`, `forkSession` + +### 2b. `AgentRuntimeClient` 接口补全 + +`http/agent-runtime.ts` 和 `direct/agent-runtime.ts` 的 `AgentRuntimeClient` 接口同步添加了 10 个 checkpoint/rollback/fork 方法声明和实现。之前只有 `sendMessage`/`sendApprovalResponse`/`compact`。 + +--- + +## 3. 计算路径统一 + +### 3a. `core/paths.ts`(新建) + +将 `computePaths`、`projectSessionsDir`、`sessionJsonlPathFromCwd` 从 `session/file-ops.ts` 搬至 `core/paths.ts`,定义独立 `SessionPaths` 接口避免 `core → session` 反向依赖。 + +### 3b. server routes 清手搓 `replace` + +- `server/routes/messages.ts` `:45` `sessionJsonlPathFromCwd(...).replace('.jsonl','.index.json')` → `computePaths(normalizedCwd, sessionId).indexPath` +- `server/routes/sessions.ts` `:300` 同上 + `:618` `sessionJsonlPathFromCwd(cwd, newSessionId)` → `computePaths(cwd, newSessionId).transcriptPath` + +### 3c. 9 处调用方 import 路径更新 + +`session/store.ts`、`runtime/project-runtime.ts`、`tools/domains/subagent/dispatch.ts`、`plan/index.ts`、`memory/index.ts`、`session/ui-history.ts` 改从 `core/paths.js` import。 + +--- + +## 4. 会话层审计 + IO 合并 + +### 4a. `parentSessionId` 进 `SessionIndex` + +`session/types.ts` `:SessionIndex` + `SessionStoreState` 加 `parentSessionId?: string`。`create` 的 opts 接收并写入 state 和 index file。 + +### 4b. create + activeProfile 一次写 + +`create` opts 扩展 `activeProfile?: string`,`updateIndex` 同时写入 activeProfile(通过 `writeIndexAtomic` merge 写,避免 record 流 stale 覆盖)。4 个调用方(agent.ts、sessions.ts、dispatch.ts、direct/sessions.ts)删除后续 `setActiveProfile` 二次写。 + +### 4c. `createSessionWithProfile` helper + +SessionService 新增方法,内部 `activeProfile = opts?.activeProfile ?? modeToProfile(options.mode).name`,统一 4 处"create + setActiveProfile"重复模式。 + +### 4d. 删废参 + +`setSessionProfile`/`restoreSessionProfile` 第 5 参 `_parentSessionId` 删除(无调用方传,函数体未用)。 + +--- + +## 5. 错误处理统一 + +`NotFoundError` + `AlreadyExistsError` 加 `readonly code` 字段 + `httpStatus()` 方法。`server/index.ts` `app.onError` 合并为统一的 `code`+`httpStatus` 判断,删 3 分支 `instanceof` 硬编码字符串。 + +--- + +## 6. scheduler `approvalOverride` 修 latent bug + +`scheduler/service.ts` 两个 `approvalOverride: { permissionMode: 'bypass' }` 字面量替换为 `ApprovalService.fork({ permissionMode: 'bypass' })` 真实实例。原字面量无 `.evaluate` 方法,任何工具调用会抛 `not a function`。 + +同时收紧类型:`agent.ts:125`、`types.ts:101`、`executor.ts:28,135,185` 从 `approvalOverride?: any` → `ApprovalService`。 + +--- + +## 7. `sendMessage` options 改可选 + 守卫 + +`mode`/`permissionMode`/`model` 在 `sendMessage` options 中改为可选。`!sessionId` 新会话分支守卫:三者缺一即抛 `SESSION_CONFIG_REQUIRED`。 + +3 个调用方条件构造 options:messages.ts(已有会话不传)、direct/agent-runtime.ts(!sessionId 才传)、direct.ts(!currentSessionId 才传)。scheduler 恒传。 + +--- + +## 8. 客户端补 4 个缺失方法 + `getMemoryConfig` 类型修复 + +`http/settings.ts` + `direct/settings.ts` 接口和实现各加: +- `setMemoryModel` +- `getAgentConfig` +- `setCompactionModel` + +`http/sessions.ts` + `direct/sessions.ts` 各加: +- `getSessionPlan` + +`getMemoryConfig` 返回类型加 `model: string`(服务端实际返回,类型说谎)。 + +--- + +## 9. `core-api.ts` 5 处 `api()` 裸调用改 `clients.*` + +`packages/desktop/src/lib/core-api.ts`: +- `getMemoryConfig` → `clients.settings.getMemoryConfig()` +- `setMemoryModel` → `clients.settings.setMemoryModel()` +- `getAgentConfig` → `clients.settings.getAgentConfig()` +- `setCompactionModel` → `clients.settings.setCompactionModel()` +- `getSessionPlan` → `clients.sessions.getSessionPlan()` + +--- + +## 10. `clean:out` 脚本 + +`packages/desktop/package.json` 加 `"clean:out": "node -e \"require('fs').rmSync('out',{recursive:true,force:true})\""`(跨平台零依赖)。 + +--- + +## 11. `docs-hidden/` 删除 + +trade-off 文档目录删除(已 gitignore、从未提交)。 + +--- + +## 12. 移动 `agentEventToStreamChunk` 到 `agent/stream-adapter.ts` + +70 行 chunk 转换函数从 `client/direct.ts` 搬出为独立文件,`direct/agent-runtime.ts` 改 import 路径。 + +--- + +## 测试 + +### 新增 21 测试文件 + +| 文件 | 验证 | +|---|---| +| `test/agent/send-message-optional-mode.test.ts` | sendMessage mode 可选 + 守卫 | +| `test/agent/stream-adapter.test.ts` | `agentEventToStreamChunk` 在新位置的正常工作 | +| `test/client/missing-methods.test.ts` | 4 个新 method 存在 | +| `test/client/http-direct-parity.test.ts` | http/direct sendMessage 签名一致 | +| `test/client/get-session-plan.test.ts` | getSessionPlan 双实现 | +| `test/core/paths.test.ts` | computePaths 在 core/paths 正确导出 | +| `test/core/error-code.test.ts` | NotFoundError/AlreadyExistsError code+httpStatus | +| `test/server/routes-use-compute-paths.test.ts` | server routes 使用 computePaths | +| `test/scheduler/approval-bypass.test.ts` | scheduler 使用 real ApprovalService | +| `test/session/parent-session-id.test.ts` | parentSessionId 写 index | +| `test/session/create-active-profile.test.ts` | create 一次写 activeProfile,无 stale 覆盖 | +| `test/session/create-session-with-profile.test.ts` | helper 默认派生 + dispatch 覆盖 | +| `test/desktop/core-api-clients.test.ts` | 5 wrapper 走 clients.* | +| `test/server/messages-fork-permission-mode.test.ts` | messages 路由 permission mode fork | +| `test/approval/fork-permission-mode.test.ts` | approval fork 功能 | +| `test/session/disk-setters.test.ts` | 三个独立 setter | + +### 修改 12 测试文件 + +import 路径更新(`client/direct/*` → `direct/*`)、签名变更(`setSessionProfile` 删第 5 参、`createDirectClient` 换 `createDirectModelClient` 等)。 + +### 删除 4 测试文件 + +`agent-client-cwd.test.ts`(依赖已删的 `createDirectClient`)、`agent-routes.test.ts`、`plan-mode-reject-perm-mode.test.ts`、`active-sessions.test.ts`。 + +--- + +## 验证 + +``` +pnpm run typecheck ✅ 零 src/ 错误 +pnpm test ✅ 1290 passed, 184 files +pnpm run lint ✅ 通过 +``` + +--- + +## 范围外(最后指出) + +- `packages/tui/src/hooks/useAgentRunner.ts:33` typo `pendingTodods`(:102,:139 仍用)——非本议题范围。 +- `packages/codingcode/test/ci/tooling-scripts.test.ts` `pnpm run format:check` 失败——预先存在。 diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 42e0a181..9d8452b4 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -16,6 +16,7 @@ "./session/types": "./src/session/types.ts", "./session/messages": "./src/session/messages.ts", "./core/path": "./src/core/path.ts", + "./core/paths": "./src/core/paths.ts", "./core/workspace": "./src/core/workspace.ts", "./core/error": "./src/core/error.ts", "./core/result": "./src/core/result.ts", @@ -33,10 +34,13 @@ "./server/adapter": "./src/server/adapter.ts", "./server/port-discovery": "./src/server/port-discovery.ts", "./client/types": "./src/client/types.ts", - "./client/direct": "./src/client/direct.ts", "./client/http": "./src/client/http.ts", "./client/http-clients": "./src/client/http/index.ts", - "./client/direct-clients": "./src/client/direct/index.ts", + "./direct/agent-runtime": "./src/direct/agent-runtime.ts", + "./direct/sessions": "./src/direct/sessions.ts", + "./direct/settings": "./src/direct/settings.ts", + "./direct/models": "./src/direct/models.ts", + "./agent/stream-adapter": "./src/agent/stream-adapter.ts", "./checkpoint/checkpoint-service": "./src/checkpoint/checkpoint-service.ts", "./checkpoint/shadow-git": "./src/checkpoint/shadow-git.ts", "./checkpoint/bootstrap": "./src/checkpoint/bootstrap.ts", diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 643144c6..20ef1e0a 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -122,10 +122,10 @@ export const sendMessage = ( llm: LLMClient, options: { signal?: AbortSignal; - approvalOverride?: any; - mode: SessionMode; - permissionMode: PermissionMode; - model: string; + approvalOverride?: import('../approval/index.js').ApprovalService; + mode?: SessionMode; + permissionMode?: PermissionMode; + model?: string; } ) => Effect.gen(function* () { @@ -148,18 +148,19 @@ export const sendMessage = ( yield* skills.evictProject(normalizedCwd); if (!sessionId) { - const created = yield* session.create(normalizedCwd, { + if (!options.mode || !options.permissionMode || !options.model) { + return yield* Effect.fail( + new AgentError( + 'CONFIG_MISSING', + 'new session requires mode, permissionMode, and model' + ) + ); + } + const created = yield* session.createSessionWithProfile(normalizedCwd, { model: options.model, mode: options.mode, permissionMode: options.permissionMode, }); - const profile = modeToProfile(options.mode); - yield* runtime.setSessionProfile( - normalizedCwd, - created.sessionId, - profile, - options.permissionMode - ); sessionId = created.sessionId; } const state = yield* session.load(normalizedCwd, sessionId); @@ -335,12 +336,7 @@ export function agentLoop( const compressResult = yield* Effect.tryPromise({ try: () => - context.compactIfNeeded( - state.transcriptPath, - messages, - llm.modelInfo.maxTokens, - llm - ), + context.compactIfNeeded(state.transcriptPath, messages, llm.modelInfo.maxTokens, llm), catch: (e) => new AgentError('LLM_FAILED', String(e)), }); if (compressResult.didCompress && compressResult.messages) { diff --git a/packages/codingcode/src/agent/stream-adapter.ts b/packages/codingcode/src/agent/stream-adapter.ts new file mode 100644 index 00000000..c9fd1036 --- /dev/null +++ b/packages/codingcode/src/agent/stream-adapter.ts @@ -0,0 +1,67 @@ +import type { AgentEvent } from './types.js'; +import type { StreamChunk } from '../client/types.js'; + +export async function* agentEventToStreamChunk( + source: AsyncGenerator +): AsyncGenerator { + let currentStep = 0; + for await (const event of source) { + switch (event._tag) { + case 'Step': + currentStep = event.step; + break; + case 'TurnId': + yield { type: 'turn_id', turnId: event.turnId }; + break; + case 'LlmChunk': + yield { type: 'text', text: event.text, messageId: currentStep }; + break; + case 'Assistant': + yield { type: 'message', id: currentStep, content: event.content, partial: false }; + break; + case 'ToolStart': + yield { type: 'tool_start', id: event.id, name: event.name, args: event.args }; + break; + case 'ToolResult': + yield { + type: 'tool_result', + id: event.id, + name: event.name, + output: event.output, + ok: event.ok, + }; + break; + case 'ToolDenied': + yield { type: 'tool_denied', id: event.id, name: event.name, reason: event.reason }; + break; + case 'Error': + yield { + type: 'error', + message: event.error.message ?? String(event.error), + code: event.error.code, + }; + break; + case 'Done': + yield { type: 'done' }; + break; + case 'TodoUpdate': + yield { type: 'todo_update', items: event.items as any }; + break; + case 'Usage': + yield { + type: 'usage', + prompt: event.prompt, + completion: event.completion, + total: event.total, + }; + break; + case 'ReactiveCompact': + yield { + type: 'reactive_compact', + released: event.released, + promptEstimate: event.promptEstimate, + }; + break; + } + } +} diff --git a/packages/codingcode/src/agent/types.ts b/packages/codingcode/src/agent/types.ts index 2cd7f078..d9d90730 100644 --- a/packages/codingcode/src/agent/types.ts +++ b/packages/codingcode/src/agent/types.ts @@ -98,6 +98,6 @@ export interface RunStreamOptions { agentName?: string; maxStepsOverride?: number; maxStopContinuations?: number; - approvalOverride?: any; + approvalOverride?: import('../approval/index.js').ApprovalService; rulesText?: string; } diff --git a/packages/codingcode/src/approval/index.ts b/packages/codingcode/src/approval/index.ts index 61814980..251c7597 100644 --- a/packages/codingcode/src/approval/index.ts +++ b/packages/codingcode/src/approval/index.ts @@ -13,7 +13,6 @@ export class ApprovalService extends Effect.Service()('Approval const ruleEngine: RuleEngine = createRuleEngine(DEFAULT_DENY_RULES); const destructiveTools = new Set(DANGEROUS_TOOL_NAMES); const readonlyTools = new Set(READONLY_TOOL_NAMES); - let _globalPermissionMode: PermissionMode = 'default'; function makeForkedService( engine: RuleEngine, @@ -64,6 +63,7 @@ export class ApprovalService extends Effect.Service()('Approval fork: (opts?: { extraDenyRules?: PermissionRule[]; readonly?: boolean; + permissionMode?: PermissionMode; }): Effect.Effect => Effect.sync(() => { const nextEngine = createRuleEngine(engine.getAllRules()); @@ -84,7 +84,7 @@ export class ApprovalService extends Effect.Service()('Approval } return makeForkedService( nextEngine, - currentPermMode, + opts?.permissionMode ?? currentPermMode, new Set(roTools), new Set(destTools) ); @@ -112,7 +112,7 @@ export class ApprovalService extends Effect.Service()('Approval ruleEngine, readonlyTools, destructiveTools, - permissionMode: _globalPermissionMode, + permissionMode: 'default', onAlways: (rule) => ruleEngine.addRule(rule), onNever: (rule) => ruleEngine.addRule(rule), sessionId: request.sessionId, @@ -129,16 +129,17 @@ export class ApprovalService extends Effect.Service()('Approval removeRule: (id: string): Effect.Effect => Effect.sync(() => ruleEngine.removeRule(id)), - setPermissionMode: (mode: PermissionMode): Effect.Effect => + setPermissionMode: (_mode: PermissionMode): Effect.Effect => Effect.sync(() => { - _globalPermissionMode = mode; + /* no-op at root; only fork children maintain their own currentPermMode */ }), - getPermissionMode: (): PermissionMode => _globalPermissionMode, + getPermissionMode: (): PermissionMode => 'default', fork: (opts?: { extraDenyRules?: PermissionRule[]; readonly?: boolean; + permissionMode?: PermissionMode; }): Effect.Effect => Effect.sync(() => { const parentRules = ruleEngine.getAllRules(); @@ -161,7 +162,7 @@ export class ApprovalService extends Effect.Service()('Approval } return makeForkedService( childEngine, - _globalPermissionMode, + opts?.permissionMode ?? 'default', new Set(readonlyTools), new Set(destructiveTools) ); diff --git a/packages/codingcode/src/approval/types.ts b/packages/codingcode/src/approval/types.ts index 5e98a5da..f86b45b2 100644 --- a/packages/codingcode/src/approval/types.ts +++ b/packages/codingcode/src/approval/types.ts @@ -1,6 +1,10 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'bypass'; -export const PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'bypass'] as const; +export const PERMISSION_MODES: readonly PermissionMode[] = [ + 'default', + 'acceptEdits', + 'bypass', +] as const; export function isPermissionMode(value: unknown): value is PermissionMode { return typeof value === 'string' && (PERMISSION_MODES as readonly string[]).includes(value); diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index b3ba2739..58ba53e5 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -38,9 +38,12 @@ async function main() { if (tuiOnly) { const tuiPath = '../../tui/src/index.js'; - const { runTui } = yield* Effect.tryPromise(() => import(tuiPath)); + const { runTui, createTuiClientFromFacades } = yield* Effect.tryPromise(() => + import(tuiPath) + ); const llm = yield* llmFactory.getLLMClient(); - runTui({ llm, rt }); + const client = createTuiClientFromFacades(llm, rt); + runTui({ client }); return; } @@ -50,9 +53,12 @@ async function main() { if (!serveOnly) { const tuiPath = '../../tui/src/index.js'; - const { runTui } = yield* Effect.tryPromise(() => import(tuiPath)); + const { runTui, createTuiClientFromFacades } = yield* Effect.tryPromise(() => + import(tuiPath) + ); const llm = yield* llmFactory.getLLMClient(); - runTui({ llm, rt }); + const client = createTuiClientFromFacades(llm, rt); + runTui({ client }); } }); diff --git a/packages/codingcode/src/client/direct.ts b/packages/codingcode/src/client/direct.ts deleted file mode 100644 index d54d4522..00000000 --- a/packages/codingcode/src/client/direct.ts +++ /dev/null @@ -1,508 +0,0 @@ -import { Effect } from 'effect'; -import type { AgentEvent } from '../agent/types.js'; -import { sendMessage } from '../agent/agent.js'; -import { CheckpointService } from '../checkpoint/checkpoint-service.js'; -import { LLMFactoryService } from '../llm/factory.js'; -import { WorkspaceService } from '../core/workspace.js'; -import { ApprovalService } from '../approval/index.js'; -import { ApprovalWaitService } from '../approval/async-confirm.js'; -import type { PermissionMode } from '../approval/types.js'; -import type { McpServerConfig } from '../mcp/types.js'; -import type { AgentProfile } from '../subagent/types.js'; -import type { UserHookConfig } from '../hooks/types.js'; -import type { StreamChunk, AgentClient } from './types.js'; -import { createDirectClients } from './direct/index.js'; -import type { AppRuntime } from '../layer.js'; -import type { LLMClient } from '../llm/client.js'; - -export async function* agentEventToStreamChunk( - source: AsyncGenerator -): AsyncGenerator { - let currentStep = 0; - for await (const event of source) { - switch (event._tag) { - case 'Step': - currentStep = event.step; - break; - case 'TurnId': - yield { type: 'turn_id', turnId: event.turnId }; - break; - case 'LlmChunk': - yield { type: 'text', text: event.text, messageId: currentStep }; - break; - case 'Assistant': - yield { type: 'message', id: currentStep, content: event.content, partial: false }; - break; - case 'ToolStart': - yield { type: 'tool_start', id: event.id, name: event.name, args: event.args }; - break; - case 'ToolResult': - yield { - type: 'tool_result', - id: event.id, - name: event.name, - output: event.output, - ok: event.ok, - }; - break; - case 'ToolDenied': - yield { type: 'tool_denied', id: event.id, name: event.name, reason: event.reason }; - break; - case 'Error': - yield { - type: 'error', - message: event.error.message ?? String(event.error), - code: event.error.code, - }; - break; - case 'Done': - yield { type: 'done' }; - break; - case 'TodoUpdate': - yield { type: 'todo_update', items: event.items as any }; - break; - case 'Usage': - yield { - type: 'usage', - prompt: event.prompt, - completion: event.completion, - total: event.total, - }; - break; - case 'ReactiveCompact': - yield { - type: 'reactive_compact', - released: event.released, - promptEstimate: event.promptEstimate, - }; - break; - } - } -} - -export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promise { - let currentSessionId = ''; - let activeLlm = llm; - - const runWithLayer = (eff: any): Promise => rt.runPromise(eff); - - const clients = createDirectClients(activeLlm, rt); - const cwdValue = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.getWorkspaceCwd(); - }) - ); - const cwd = () => cwdValue; - - return { - getSessionId() { - return currentSessionId; - }, - - async *sendMessage(input: string): AsyncGenerator { - const waitService = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalWaitService; - }) - ); - const program = sendMessage(currentSessionId || undefined, input, cwd(), activeLlm, { - mode: 'build', - permissionMode: 'default', - model: activeLlm.modelInfo.model, - }); - const { stream: agentGen, sessionId } = (await runWithLayer(program)) as any; - currentSessionId = sessionId; - - let notify: - | ((req: { - type: 'approval_request'; - id: string; - tool: string; - args: Record; - }) => void) - | null = null; - Effect.runSync( - waitService.registerEmitter( - sessionId, - (id: string, tool: string, args: Record) => { - notify?.({ type: 'approval_request', id, tool, args }); - } - ) - ); - - try { - const gen = agentEventToStreamChunk(agentGen); - let pending = gen.next(); - - while (true) { - const approvalPromise = new Promise<{ - type: 'approval_request'; - id: string; - tool: string; - args: Record; - }>((resolve) => { - notify = resolve; - }); - - const winner = await Promise.race([ - pending.then((c): { tag: 'chunk'; value: IteratorResult } => ({ - tag: 'chunk', - value: c, - })), - approvalPromise.then((req): { tag: 'approval'; value: typeof req } => ({ - tag: 'approval', - value: req, - })), - ]); - - if (winner.tag === 'chunk') { - notify = null; - if (winner.value.done) break; - yield winner.value.value; - pending = gen.next(); - } else { - yield winner.value; - } - } - } finally { - Effect.runSync(waitService.unregisterEmitter(sessionId)); - } - }, - - async sendApprovalResponse(id: string, response: string) { - if (!currentSessionId) return; - await clients.agent.sendApprovalResponse({ - sessionId: currentSessionId, - approvalId: id, - response, - }); - }, - - async resumeSession(sid: string) { - currentSessionId = sid; - return clients.sessions.resumeSession({ sessionId: sid, cwd: cwd() }); - }, - - async listSessions() { - return clients.sessions.listSessions({ cwd: cwd() }); - }, - - async listModels() { - return clients.models.listModels(); - }, - - async switchModel(id: string) { - await clients.models.switchModel({ id }); - activeLlm = await rt.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getLLMClient(); - }) - ); - }, - - async getCheckpoints() { - if (!currentSessionId) return []; - return runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.getCheckpoints(cwd(), currentSessionId); - }) - ); - }, - - async getCheckpointDiff(turnId?: number) { - if (!currentSessionId) return { turnId: 0, files: [] }; - return clients.sessions.getCheckpointDiff({ - sessionId: currentSessionId, - cwd: cwd(), - turnId, - }); - }, - - async revertCheckpointFiles(turnId: number, files: string[]) { - if (!currentSessionId) - return { - reverted: false, - throughTurnId: turnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return clients.sessions.revertCheckpointFiles({ - sessionId: currentSessionId, - cwd: cwd(), - files, - }); - }, - - async previewRollbackDiff(throughTurnId: number) { - if (!currentSessionId) return { throughTurnId, affectedTurns: [], diff: '' }; - return clients.sessions.previewRollbackDiff({ - sessionId: currentSessionId, - cwd: cwd(), - throughTurnId, - }); - }, - - async rollbackCodeToTurn(throughTurnId: number) { - if (!currentSessionId) - return { - reverted: false, - throughTurnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - return clients.sessions.rollbackCodeToTurn({ - sessionId: currentSessionId, - cwd: cwd(), - throughTurnId, - }); - }, - - async rollbackContext(throughTurnId: number) { - if (!currentSessionId) - return { - turns: [] as import('../session/types.js').SessionEvent[], - rollbackState: { - context: { active: false, currentThroughTurnId: null }, - code: { - canUndoLast: false, - lastEntry: null, - revertedFiles: [] as string[], - lastEntryId: null, - }, - } as import('../checkpoint/types.js').RollbackState, - }; - return clients.sessions.rollbackContext({ - sessionId: currentSessionId, - cwd: cwd(), - throughTurnId, - }); - }, - - async rollbackBothToTurn(throughTurnId: number) { - if (!currentSessionId) - return { - turns: [] as import('../session/types.js').SessionEvent[], - codeResult: { - reverted: false, - throughTurnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }, - rollbackState: { - context: { active: false, currentThroughTurnId: null }, - code: { - canUndoLast: false, - lastEntry: null, - revertedFiles: [] as string[], - lastEntryId: null, - }, - } as import('../checkpoint/types.js').RollbackState, - }; - return clients.sessions.rollbackBothToTurn({ - sessionId: currentSessionId, - cwd: cwd(), - throughTurnId, - }); - }, - - async undoLastCodeRollback(force?: boolean, files?: string[]) { - if (!currentSessionId) - return { - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], - }; - return clients.sessions.undoLastCodeRollback({ - sessionId: currentSessionId, - cwd: cwd(), - force, - files, - }); - }, - - async getRollbackState() { - if (!currentSessionId) - return { - context: { active: false, currentThroughTurnId: null }, - code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: null }, - }; - return clients.sessions.getRollbackState({ sessionId: currentSessionId, cwd: cwd() }); - }, - - async forkSession(atTurnId?: number) { - if (!currentSessionId) return ''; - const result = await clients.sessions.forkSession({ - sessionId: currentSessionId, - cwd: cwd(), - atTurnId, - }); - return result.sessionId; - }, - - async compact() { - if (!currentSessionId) return; - await clients.agent.compact({ sessionId: currentSessionId, cwd: cwd() }); - }, - - async getMemoryEnabled() { - return clients.settings.getMemoryEnabled(); - }, - - async setMemoryEnabled(enabled: boolean) { - await clients.settings.setMemoryEnabled(enabled); - }, - - async getMemoryConfig() { - return clients.settings.getMemoryConfig(); - }, - - async setTypeDisabled(name: string, disabled: boolean) { - await clients.settings.setMemoryTypeDisabled(name, disabled); - }, - - async addExtraType(type: { name: string; description: string }) { - await clients.settings.addMemoryExtraType(type); - }, - - async updateExtraType(name: string, type: { name: string; description: string }) { - await clients.settings.updateMemoryExtraType(name, type); - }, - - async deleteExtraType(name: string) { - await clients.settings.deleteMemoryExtraType(name); - }, - - async getSubagentEnabled({ cwd: targetCwd }: { cwd: string }) { - return clients.settings.getSubagentEnabled({ cwd: targetCwd }); - }, - - async setSubagentEnabled(body: { enabled: boolean; cwd: string }) { - await clients.settings.setSubagentEnabled(body); - }, - - async resetSubagentEnabled(body: { cwd: string }) { - await clients.settings.resetSubagentEnabled(body); - }, - - async getMcpStatus({ cwd: targetCwd }: { cwd: string }) { - return clients.settings.getMcpStatus({ cwd: targetCwd }); - }, - - async setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }) { - await clients.settings.setMcpDisabled(body); - }, - - async resetMcpDisabled(body: { name: string; cwd: string }) { - await clients.settings.resetMcpDisabled(body); - }, - - async createMcpServer( - server: McpServerConfig, - { cwd: targetCwd }: { cwd: string } - ): Promise { - await clients.settings.createMcpServer({ cwd: targetCwd, server }); - }, - - async updateMcpServer( - name: string, - server: McpServerConfig, - { cwd: targetCwd }: { cwd: string } - ): Promise { - await clients.settings.updateMcpServer({ cwd: targetCwd, name, server }); - }, - - async deleteMcpServer(name: string, { cwd: targetCwd }: { cwd: string }): Promise { - await clients.settings.deleteMcpServer({ cwd: targetCwd, name }); - }, - - async listSkills() { - return clients.settings.listSkills(); - }, - - async toggleSkill(body: { name: string; enabled: boolean; cwd: string }) { - await clients.settings.toggleSkill(body); - }, - - async listAgents({ cwd: targetCwd }: { cwd: string }) { - return clients.settings.listAgents({ cwd: targetCwd }); - }, - - async createAgent(profile: AgentProfile, { cwd: targetCwd }: { cwd: string }): Promise { - await clients.settings.createAgent({ cwd: targetCwd, profile }); - }, - - async updateAgent( - name: string, - profile: AgentProfile, - { cwd: targetCwd }: { cwd: string } - ): Promise { - await clients.settings.updateAgent({ cwd: targetCwd, name, profile }); - }, - - async deleteAgent(name: string, { cwd: targetCwd }: { cwd: string }): Promise { - await clients.settings.deleteAgent({ cwd: targetCwd, name }); - }, - - async setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise { - await clients.settings.setAgentDisabled(body); - }, - - async resetAgentDisabled(body: { name: string; cwd: string }): Promise { - await clients.settings.resetAgentDisabled(body); - }, - - async listHooks({ cwd: targetCwd }: { cwd: string }) { - return clients.settings.listHooks({ cwd: targetCwd }); - }, - - async setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise { - await clients.settings.setHookDisabled(body); - }, - - async resetHookDisabled(body: { name: string; cwd: string }): Promise { - await clients.settings.resetHookDisabled(body); - }, - - async createHook(hook: UserHookConfig, { cwd: targetCwd }: { cwd: string }): Promise { - await clients.settings.createHook({ cwd: targetCwd, hook }); - }, - - async updateHook( - name: string, - hook: UserHookConfig, - { cwd: targetCwd }: { cwd: string } - ): Promise { - await clients.settings.updateHook({ cwd: targetCwd, name, hook }); - }, - - async deleteHook(name: string, { cwd: targetCwd }: { cwd: string }): Promise { - await clients.settings.deleteHook({ cwd: targetCwd, name }); - }, - - async getPermissionMode(): Promise { - const approval = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalService; - }) - ); - return approval.getPermissionMode(); - }, - - async setPermissionMode(mode: PermissionMode): Promise { - const approval = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalService; - }) - ); - await rt.runPromise(approval.setPermissionMode(mode)); - }, - }; -} diff --git a/packages/codingcode/src/client/direct/agent-runtime.ts b/packages/codingcode/src/client/direct/agent-runtime.ts deleted file mode 100644 index 16e92043..00000000 --- a/packages/codingcode/src/client/direct/agent-runtime.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Effect } from 'effect'; -import { sendMessage } from '../../agent/agent.js'; -import { ApprovalWaitService } from '../../approval/async-confirm.js'; -import { parseApprovalResponse } from '../../approval/response.js'; -import { ContextService } from '../../context/service.js'; -import { HookService } from '../../hooks/registry.js'; -import { SessionService } from '../../session/store.js'; -import type { StreamChunk } from '../types.js'; -import { agentEventToStreamChunk } from '../direct.js'; -import type { AppRuntime } from '../../layer.js'; -import type { LLMClient } from '../../llm/client.js'; - -export interface AgentRuntimeClient { - sendMessage( - input: string, - options: { sessionId?: string; cwd: string } - ): AsyncGenerator; - - sendApprovalResponse(input: { - sessionId: string; - approvalId: string; - response: string; - }): Promise; - - compact(input: { sessionId: string; cwd: string }): Promise; -} - -export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRuntimeClient { - return { - async *sendMessage(input, { sessionId, cwd }) { - const program = sendMessage(sessionId || undefined, input, cwd, llm, { - mode: 'build', - permissionMode: 'default', - model: llm.modelInfo.model, - }); - const { stream: agentGen, sessionId: resolvedSessionId } = (await rt.runPromise( - program - )) as any; - - yield { type: 'session_id', sessionId: resolvedSessionId }; - - let notifyApproval: ((req: StreamChunk) => void) | null = null; - let notifyPlan: ((req: StreamChunk) => void) | null = null; - const waitService = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalWaitService; - }) - ); - const hookService = await rt.runPromise( - Effect.gen(function* () { - return yield* HookService; - }) - ); - Effect.runSync( - waitService.registerEmitter( - resolvedSessionId, - (id: string, tool: string, args: Record) => { - notifyApproval?.({ type: 'approval_request', id, tool, args }); - } - ) - ); - const unregisterPlanReady = Effect.runSync( - hookService.register('plan.ready', (payload) => { - const p = payload as { - sessionId?: string; - title?: string; - }; - if (p.sessionId !== resolvedSessionId) return; - notifyPlan?.({ - type: 'plan_ready', - sessionId: p.sessionId ?? '', - title: p.title ?? '', - }); - }) - ); - - try { - const gen = agentEventToStreamChunk(agentGen); - let pending = gen.next(); - let currentApprovalPromise = new Promise((resolve) => { - notifyApproval = resolve; - }); - let currentPlanPromise = new Promise((resolve) => { - notifyPlan = resolve; - }); - - while (true) { - const approvalPromise = currentApprovalPromise; - const planPromise = currentPlanPromise; - const winner = await Promise.race([ - pending.then((c): { tag: 'chunk'; value: IteratorResult } => ({ - tag: 'chunk', - value: c, - })), - approvalPromise.then((req): { tag: 'approval'; value: StreamChunk } => ({ - tag: 'approval', - value: req, - })), - planPromise.then((req): { tag: 'plan'; value: StreamChunk } => ({ - tag: 'plan', - value: req, - })), - ]); - - if (winner.tag === 'chunk') { - if (winner.value.done) break; - yield winner.value.value; - currentApprovalPromise = new Promise((resolve) => { - notifyApproval = resolve; - }); - currentPlanPromise = new Promise((resolve) => { - notifyPlan = resolve; - }); - pending = gen.next(); - } else if (winner.tag === 'approval') { - yield winner.value; - currentApprovalPromise = new Promise((resolve) => { - notifyApproval = resolve; - }); - } else { - yield winner.value; - currentPlanPromise = new Promise((resolve) => { - notifyPlan = resolve; - }); - } - } - } finally { - unregisterPlanReady(); - Effect.runSync(waitService.unregisterEmitter(resolvedSessionId)); - } - }, - - async sendApprovalResponse({ sessionId, approvalId, response }) { - const result = parseApprovalResponse(response); - await rt.runPromise( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - return yield* svc.resolveConfirm(approvalId, sessionId, result); - }) - ); - }, - - async compact({ sessionId, cwd }) { - await rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - const context = yield* ContextService; - const state = yield* session.load(cwd, sessionId); - return yield* Effect.promise(() => - context.compactWithLLM(state.transcriptPath, llm.modelInfo.maxTokens, null) - ); - }) - ); - }, - }; -} diff --git a/packages/codingcode/src/client/direct/index.ts b/packages/codingcode/src/client/direct/index.ts deleted file mode 100644 index e5b163d8..00000000 --- a/packages/codingcode/src/client/direct/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createDirectAgentClient, type AgentRuntimeClient } from './agent-runtime.js'; -import { createDirectSessionClient, type SessionClient } from './sessions.js'; -import { createDirectModelClient, type ModelClient } from './models.js'; -import { createDirectSettingsClient, type SettingsClient } from './settings.js'; -import type { AppRuntime } from '../../layer.js'; -import type { LLMClient } from '../../llm/client.js'; - -export interface DirectClients { - agent: AgentRuntimeClient; - sessions: SessionClient; - models: ModelClient; - settings: SettingsClient; -} - -export function createDirectClients(llm: LLMClient, rt: AppRuntime): DirectClients { - return { - agent: createDirectAgentClient(llm, rt), - sessions: createDirectSessionClient(rt), - models: createDirectModelClient(rt), - settings: createDirectSettingsClient(rt), - }; -} diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index ed05e0fc..c8ae1468 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -134,84 +134,52 @@ export async function createHttpClient(serverUrl: string): Promise }, async getCheckpoints() { - return []; + return clients.agent.getCheckpoints(); }, - async getCheckpointDiff() { - return { turnId: 0, files: [] }; + async getCheckpointDiff(turnId?: number) { + return clients.agent.getCheckpointDiff(turnId); }, - async revertCheckpointFiles() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + async revertCheckpointFiles(turnId: number, files: string[]) { + return clients.agent.revertCheckpointFiles(turnId, files); }, - async previewRollbackDiff() { - return { throughTurnId: 0, affectedTurns: [], diff: '' }; + async previewRollbackDiff(throughTurnId: number) { + return clients.agent.previewRollbackDiff(throughTurnId); }, - async rollbackCodeToTurn() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; + async rollbackCodeToTurn(throughTurnId: number) { + return clients.agent.rollbackCodeToTurn(throughTurnId); }, - async rollbackContext() { + async rollbackContext(throughTurnId: number) { + const res = await clients.agent.rollbackContext(throughTurnId); return { - turns: [] as SessionEvent[], - rollbackState: { - context: { active: false, currentThroughTurnId: null }, - code: { - canUndoLast: false, - lastEntry: null, - revertedFiles: [] as string[], - lastEntryId: null, - }, - } as RollbackState, + turns: (res as any).turns ?? [], + rollbackState: + (res as any).rollbackState ?? { active: false, currentThroughTurnId: null }, }; }, - async rollbackBothToTurn() { + async rollbackBothToTurn(throughTurnId: number) { + const res = await clients.agent.rollbackBothToTurn(throughTurnId); return { - turns: [] as SessionEvent[], - codeResult: { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }, - rollbackState: { - context: { active: false, currentThroughTurnId: null }, - code: { - canUndoLast: false, - lastEntry: null, - revertedFiles: [] as string[], - lastEntryId: null, + turns: (res as any).turns ?? [], + codeResult: + (res as any).codeResult ?? { + reverted: false, + throughTurnId, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, }, - } as RollbackState, + rollbackState: + (res as any).rollbackState ?? { active: false, currentThroughTurnId: null }, }; }, - async undoLastCodeRollback() { - return { - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], - }; + async undoLastCodeRollback(force?: boolean, files?: string[]) { + return clients.agent.undoLastCodeRollback(force, files); }, async getRollbackState() { - return { - context: { active: false, currentThroughTurnId: null }, - code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: null }, - }; + return clients.agent.getRollbackState(); }, - async forkSession(_atTurnId?: number) { - return ''; + async forkSession(atTurnId?: number) { + return clients.agent.forkSession(atTurnId); }, async compact() { @@ -340,12 +308,12 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.deleteHook({ cwd, name }); }, - async getPermissionMode() { - return clients.settings.getGlobalPermissionMode(); + async getPermissionMode(input: { sessionId: string; cwd: string }) { + return clients.settings.getGlobalPermissionMode(input); }, - async setPermissionMode(mode: PermissionMode) { - await clients.settings.setGlobalPermissionMode(mode); + async setPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode }) { + await clients.settings.setGlobalPermissionMode(input); }, }; } diff --git a/packages/codingcode/src/client/http/agent-runtime.ts b/packages/codingcode/src/client/http/agent-runtime.ts index 6353d939..ced9e450 100644 --- a/packages/codingcode/src/client/http/agent-runtime.ts +++ b/packages/codingcode/src/client/http/agent-runtime.ts @@ -14,13 +14,48 @@ export interface AgentRuntimeClient { response: string; }): Promise; compact(input: { sessionId: string; cwd: string }): Promise; + + getCheckpoints(): Promise>; + getCheckpointDiff(turnId?: number): Promise; + revertCheckpointFiles( + turnId: number, + files: string[] + ): Promise; + previewRollbackDiff( + throughTurnId: number + ): Promise; + rollbackCodeToTurn( + throughTurnId: number + ): Promise; + rollbackContext( + throughTurnId: number + ): Promise<{ + turns: Array<{ id: string; items: object[]; status: string }>; + rollbackState: import('../../checkpoint/types.js').RollbackState; + }>; + rollbackBothToTurn(throughTurnId: number): Promise<{ + turns: Array<{ id: string; items: object[]; status: string }>; + codeResult: import('../../checkpoint/types.js').CodeRollbackResult; + rollbackState: import('../../checkpoint/types.js').RollbackState; + }>; + undoLastCodeRollback( + force?: boolean, + files?: string[] + ): Promise; + getRollbackState(): Promise; + forkSession( + atTurnId?: number + ): Promise<{ + sessionId: string; + turns: Array<{ id: string; items: object[]; status: string }>; + }>; } export function createHttpAgentClient( baseUrl: string, request: ReturnType ): AgentRuntimeClient { - const { apiPost } = request; + const { apiPost, apiGet } = request; return { async *sendMessage(input, { sessionId, cwd, signal }) { @@ -131,5 +166,49 @@ export function createHttpAgentClient( async compact({ sessionId, cwd }) { await apiPost(`/api/sessions/${sessionId}/compact`, { cwd }); }, + + async getCheckpoints() { + return apiGet('/api/checkpoints'); + }, + + async getCheckpointDiff(turnId?: number) { + const segment = turnId != null ? String(turnId) : 'latest'; + return apiGet(`/api/sessions/_/checkpoints/${segment}/diff?cwd=_`); + }, + + async revertCheckpointFiles(turnId: number, files: string[]) { + return apiPost(`/api/sessions/_/checkpoints/latest/revert-files?cwd=_`, { + turnId, + files, + }); + }, + + async previewRollbackDiff(throughTurnId: number) { + return apiGet(`/api/sessions/_/rollback-preview?cwd=_&throughTurnId=${throughTurnId}`); + }, + + async rollbackCodeToTurn(throughTurnId: number) { + return apiPost(`/api/sessions/_/rollback-code-to-turn?cwd=_`, { throughTurnId }); + }, + + async rollbackContext(throughTurnId: number) { + return apiPost(`/api/sessions/_/rollback-context?cwd=_`, { throughTurnId }); + }, + + async rollbackBothToTurn(throughTurnId: number) { + return apiPost(`/api/sessions/_/rollback-both-to-turn?cwd=_`, { throughTurnId }); + }, + + async undoLastCodeRollback(force?: boolean, files?: string[]) { + return apiPost(`/api/sessions/_/undo-code-rollback?cwd=_`, { force, files }); + }, + + async getRollbackState() { + return apiGet('/api/sessions/_/rollback-state?cwd=_'); + }, + + async forkSession(atTurnId?: number) { + return apiPost('/api/sessions/_/fork?cwd=_', { atTurnId }); + }, }; } diff --git a/packages/codingcode/src/client/http/request.ts b/packages/codingcode/src/client/http/request.ts index 5b753d43..1b2b8b78 100644 --- a/packages/codingcode/src/client/http/request.ts +++ b/packages/codingcode/src/client/http/request.ts @@ -1,7 +1,20 @@ +import { ApiError } from '../../core/error.js'; + +async function parseErrorBody( + res: Response +): Promise<{ code: string; message: string } | undefined> { + try { + const json = (await res.json()) as { error?: { code: string; message: string } }; + return json?.error; + } catch { + return undefined; + } +} + export function createRequestHelpers(baseUrl: string) { async function apiGet(path: string): Promise { const res = await fetch(`${baseUrl}${path}`); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`); + if (!res.ok) throw new ApiError(res.status, path, await parseErrorBody(res)); return res.json() as Promise; } @@ -11,7 +24,7 @@ export function createRequestHelpers(baseUrl: string) { headers: { 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined, }); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`); + if (!res.ok) throw new ApiError(res.status, path, await parseErrorBody(res)); return res.json() as Promise; } @@ -21,13 +34,13 @@ export function createRequestHelpers(baseUrl: string) { headers: { 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined, }); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`); + if (!res.ok) throw new ApiError(res.status, path, await parseErrorBody(res)); return res.json() as Promise; } async function apiDelete(path: string): Promise { const res = await fetch(`${baseUrl}${path}`, { method: 'DELETE' }); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`); + if (!res.ok) throw new ApiError(res.status, path, await parseErrorBody(res)); } return { apiGet, apiPost, apiPut, apiDelete }; diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts index 1f3518d7..bda4fac8 100644 --- a/packages/codingcode/src/client/http/sessions.ts +++ b/packages/codingcode/src/client/http/sessions.ts @@ -20,12 +20,27 @@ export interface SessionClient { listSessions(input: { cwd: string }): Promise; getSessionHistory(input: { sessionId: string; cwd: string }): Promise; deleteSession(input: { sessionId: string; cwd: string }): Promise; + getSessionMode(input: { sessionId: string; cwd: string }): Promise<{ + mode: SessionMode; + permissionMode: PermissionMode; + cwd: string; + available: Array<{ name: string; description: string }>; + }>; + setSessionMode(input: { + sessionId: string; + cwd: string; + mode: SessionMode; + }): Promise<{ mode: SessionMode; permissionMode: PermissionMode }>; getSessionPermissionMode(input: { sessionId: string; cwd: string }): Promise; setSessionPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode; }): Promise; + getSessionPlan(input: { + sessionId: string; + cwd: string; + }): Promise<{ content: string; path: string; directory: string; exists: boolean }>; getCheckpointDiff(input: { sessionId: string; @@ -100,6 +115,14 @@ export function createHttpSessionClient( await apiDelete(`/api/sessions/${sessionId}?cwd=${encodeURIComponent(cwd)}`); }, + async getSessionMode({ sessionId, cwd }) { + return apiGet(`/api/sessions/${sessionId}/mode?cwd=${encodeURIComponent(cwd)}`); + }, + + async setSessionMode({ sessionId, cwd, mode }) { + return apiPost(`/api/sessions/${sessionId}/mode`, { cwd, mode }); + }, + async getSessionPermissionMode({ sessionId, cwd }) { const data = await apiGet<{ mode: PermissionMode }>( `/api/sessions/${sessionId}/permission-mode?cwd=${encodeURIComponent(cwd)}` @@ -111,6 +134,12 @@ export function createHttpSessionClient( await apiPut(`/api/sessions/${sessionId}/permission-mode`, { cwd, mode }); }, + async getSessionPlan({ sessionId, cwd }) { + return apiGet( + `/api/sessions/${sessionId}/plan?cwd=${encodeURIComponent(cwd)}` + ); + }, + async getCheckpointDiff({ sessionId, cwd, turnId }) { const segment = turnId != null ? String(turnId) : 'latest'; return apiGet( diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index b0761a1e..59cbad9c 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -9,12 +9,16 @@ export interface SettingsClient { getMemoryConfig(): Promise<{ enabled: boolean; types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>; + model: string; }>; setMemoryEnabled(enabled: boolean): Promise; setMemoryTypeDisabled(name: string, disabled: boolean): Promise; addMemoryExtraType(type: { name: string; description: string }): Promise; updateMemoryExtraType(name: string, type: { name: string; description: string }): Promise; deleteMemoryExtraType(name: string): Promise; + setMemoryModel(model: string): Promise<{ model: string }>; + getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number }>; + setCompactionModel(compactionModel: string): Promise<{ compactionModel: string }>; getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; resetSubagentEnabled(body: { cwd: string }): Promise; @@ -38,8 +42,12 @@ export interface SettingsClient { deleteHook(input: { cwd: string; name: string }): Promise; setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise; resetHookDisabled(body: { name: string; cwd: string }): Promise; - getGlobalPermissionMode(): Promise; - setGlobalPermissionMode(mode: PermissionMode): Promise; + getGlobalPermissionMode(input: { sessionId: string; cwd: string }): Promise; + setGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + mode: PermissionMode; + }): Promise; } export function createHttpSettingsClient( @@ -61,6 +69,18 @@ export function createHttpSettingsClient( return apiGet('/api/settings/memory/config'); }, + async setMemoryModel(model) { + return apiPost('/api/settings/memory/model', { model }); + }, + + async getAgentConfig() { + return apiGet('/api/settings/agent/config'); + }, + + async setCompactionModel(compactionModel) { + return apiPost('/api/settings/context/compaction-model', { compactionModel }); + }, + async setMemoryEnabled(enabled) { await apiPost('/api/settings/memory/enabled', { enabled }); }, @@ -190,13 +210,22 @@ export function createHttpSettingsClient( ); }, - async getGlobalPermissionMode() { - const data = await apiGet<{ mode: PermissionMode }>('/api/agent/permission-mode'); + async getGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + }): Promise { + const data = await apiGet<{ mode: PermissionMode }>( + `/api/sessions/${input.sessionId}/permission-mode?cwd=${encodeURIComponent(input.cwd)}` + ); return data.mode; }, - async setGlobalPermissionMode(mode) { - await apiPost('/api/agent/permission-mode', { mode }); + async setGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + mode: PermissionMode; + }): Promise { + await apiPut(`/api/sessions/${input.sessionId}/permission-mode`, input); }, }; } diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index 12c0cf1a..ba0773a7 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -56,13 +56,19 @@ export interface AgentClient { }>; undoLastCodeRollback(force?: boolean, files?: string[]): Promise; getRollbackState(): Promise; - forkSession(atTurnId?: number): Promise; + forkSession( + atTurnId?: number + ): Promise<{ + sessionId: string; + turns: Array<{ id: string; items: object[]; status: string }>; + }>; compact(): Promise; getMemoryEnabled(): Promise; setMemoryEnabled(enabled: boolean): Promise; getMemoryConfig(): Promise<{ enabled: boolean; types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>; + model: string; }>; setTypeDisabled(name: string, disabled: boolean): Promise; addExtraType(type: { name: string; description: string }): Promise; @@ -102,6 +108,6 @@ export interface AgentClient { createHook(hook: UserHookConfig, query: { cwd: string }): Promise; updateHook(name: string, hook: UserHookConfig, query: { cwd: string }): Promise; deleteHook(name: string, query: { cwd: string }): Promise; - getPermissionMode(): Promise; - setPermissionMode(mode: PermissionMode): Promise; + getPermissionMode(input: { sessionId: string; cwd: string }): Promise; + setPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode }): Promise; } diff --git a/packages/codingcode/src/core/error.ts b/packages/codingcode/src/core/error.ts index 0863abde..2f124edd 100644 --- a/packages/codingcode/src/core/error.ts +++ b/packages/codingcode/src/core/error.ts @@ -18,17 +18,36 @@ export type ErrorCode = | 'SESSION_IO_ERROR'; export class AlreadyExistsError extends Error { + readonly code = 'ALREADY_EXISTS'; constructor(message: string) { super(message); this.name = 'AlreadyExistsError'; } + httpStatus(): 409 { + return 409; + } +} + +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) { super(message); this.name = 'NotFoundError'; } + httpStatus(): 404 { + return 404; + } } export class AgentError extends Error { diff --git a/packages/codingcode/src/core/path.ts b/packages/codingcode/src/core/path.ts index 75b3db5b..23459c10 100644 --- a/packages/codingcode/src/core/path.ts +++ b/packages/codingcode/src/core/path.ts @@ -16,7 +16,6 @@ export function encodeProjectPath(p: string): string { .toLowerCase(); } - let _projectBaseOverride: string | undefined; let _projectPlansBaseOverride: string | undefined; diff --git a/packages/codingcode/src/core/paths.ts b/packages/codingcode/src/core/paths.ts new file mode 100644 index 00000000..741c14cb --- /dev/null +++ b/packages/codingcode/src/core/paths.ts @@ -0,0 +1,33 @@ +import { join } from 'path'; +import { getProjectBaseDir, encodeProjectPath, normalizePath } from './path.js'; + +export interface SessionPaths { + sessionId: string; + cwd: string; + projectPath: string; + transcriptPath: string; + indexPath: string; +} + +export function projectSessionsDir(encodedProjectPath: string): string { + return join(getProjectBaseDir(), encodedProjectPath, 'sessions'); +} + +export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string { + return computePaths(cwd, sessionId).transcriptPath; +} + +export function computePaths( + cwd: string, + sessionId: string, + parentSessionId?: string +): SessionPaths { + const normalizedCwd = normalizePath(cwd); + const projectPath = encodeProjectPath(normalizedCwd); + const sessionsDir = projectSessionsDir(projectPath); + 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 }; +} diff --git a/packages/codingcode/src/direct/agent-runtime.ts b/packages/codingcode/src/direct/agent-runtime.ts new file mode 100644 index 00000000..b9509be8 --- /dev/null +++ b/packages/codingcode/src/direct/agent-runtime.ts @@ -0,0 +1,358 @@ +import { Effect } from 'effect'; +import { sendMessage } from '../agent/agent.js'; +import { ApprovalWaitService } from '../approval/async-confirm.js'; +import { parseApprovalResponse } from '../approval/response.js'; +import { ContextService } from '../context/service.js'; +import { HookService } from '../hooks/registry.js'; +import { SessionService } from '../session/store.js'; +import { CheckpointService } from '../checkpoint/checkpoint-service.js'; +import { readUIHistory } from '../session/ui-history.js'; +import { findUserMessageForTurn } from '../session/ui-history.js'; +import type { StreamChunk } from '../client/types.js'; +import { agentEventToStreamChunk } from '../agent/stream-adapter.js'; +import type { AppRuntime } from '../layer.js'; +import type { LLMClient } from '../llm/client.js'; + +export interface AgentRuntimeClient { + sendMessage( + input: string, + options: { sessionId?: string; cwd: string } + ): AsyncGenerator; + + sendApprovalResponse(input: { + sessionId: string; + approvalId: string; + response: string; + }): Promise; + compact(input: { sessionId: string; cwd: string }): Promise; + + getCheckpoints(cwd: string): Promise>; + getCheckpointDiff( + cwd: string, + turnId?: number + ): Promise; + revertCheckpointFiles( + cwd: string, + turnId: number, + files: string[] + ): Promise; + previewRollbackDiff( + cwd: string, + throughTurnId: number + ): Promise; + rollbackCodeToTurn( + cwd: string, + throughTurnId: number + ): Promise; + rollbackContext( + cwd: string, + throughTurnId: number + ): Promise<{ + turns: Array<{ id: string; items: object[]; status: string }>; + rollbackState: import('../checkpoint/types.js').RollbackState; + }>; + rollbackBothToTurn( + cwd: string, + throughTurnId: number + ): Promise<{ + turns: Array<{ id: string; items: object[]; status: string }>; + codeResult: import('../checkpoint/types.js').CodeRollbackResult; + rollbackState: import('../checkpoint/types.js').RollbackState; + }>; + undoLastCodeRollback( + cwd: string, + force?: boolean, + files?: string[] + ): Promise; + getRollbackState(cwd: string): Promise; + forkSession( + cwd: string, + atTurnId?: number + ): Promise<{ + sessionId: string; + turns: Array<{ id: string; items: object[]; status: string }>; + }>; +} + +export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRuntimeClient { + let currentSessionId = ''; + + return { + async *sendMessage(input, { sessionId, cwd }) { + const opts: Parameters[4] = {}; + if (!sessionId) { + opts.mode = 'build'; + opts.permissionMode = 'default'; + opts.model = llm.modelInfo.model; + } + const program = sendMessage(sessionId || undefined, input, cwd, llm, opts); + const { stream: agentGen, sessionId: resolvedSessionId } = (await rt.runPromise( + program + )) as any; + currentSessionId = resolvedSessionId; + + yield { type: 'session_id', sessionId: resolvedSessionId }; + + let notifyApproval: ((req: StreamChunk) => void) | null = null; + let notifyPlan: ((req: StreamChunk) => void) | null = null; + const waitService = await rt.runPromise( + Effect.gen(function* () { + return yield* ApprovalWaitService; + }) + ); + const hookService = await rt.runPromise( + Effect.gen(function* () { + return yield* HookService; + }) + ); + Effect.runSync( + waitService.registerEmitter( + resolvedSessionId, + (id: string, tool: string, args: Record) => { + notifyApproval?.({ type: 'approval_request', id, tool, args }); + } + ) + ); + const unregisterPlanReady = Effect.runSync( + hookService.register('plan.ready', (payload) => { + const p = payload as { + sessionId?: string; + title?: string; + }; + if (p.sessionId !== resolvedSessionId) return; + notifyPlan?.({ + type: 'plan_ready', + sessionId: p.sessionId ?? '', + title: p.title ?? '', + }); + }) + ); + + try { + const gen = agentEventToStreamChunk(agentGen); + let pending = gen.next(); + let currentApprovalPromise = new Promise((resolve) => { + notifyApproval = resolve; + }); + let currentPlanPromise = new Promise((resolve) => { + notifyPlan = resolve; + }); + + while (true) { + const approvalPromise = currentApprovalPromise; + const planPromise = currentPlanPromise; + const winner = await Promise.race([ + pending.then((c): { tag: 'chunk'; value: IteratorResult } => ({ + tag: 'chunk', + value: c, + })), + approvalPromise.then((req): { tag: 'approval'; value: StreamChunk } => ({ + tag: 'approval', + value: req, + })), + planPromise.then((req): { tag: 'plan'; value: StreamChunk } => ({ + tag: 'plan', + value: req, + })), + ]); + + if (winner.tag === 'chunk') { + if (winner.value.done) break; + yield winner.value.value; + currentApprovalPromise = new Promise((resolve) => { + notifyApproval = resolve; + }); + currentPlanPromise = new Promise((resolve) => { + notifyPlan = resolve; + }); + pending = gen.next(); + } else if (winner.tag === 'approval') { + yield winner.value; + currentApprovalPromise = new Promise((resolve) => { + notifyApproval = resolve; + }); + } else { + yield winner.value; + currentPlanPromise = new Promise((resolve) => { + notifyPlan = resolve; + }); + } + } + } finally { + unregisterPlanReady(); + Effect.runSync(waitService.unregisterEmitter(resolvedSessionId)); + } + }, + + async sendApprovalResponse({ sessionId, approvalId, response }) { + const result = parseApprovalResponse(response); + await rt.runPromise( + Effect.gen(function* () { + const svc = yield* ApprovalWaitService; + return yield* svc.resolveConfirm(approvalId, sessionId, result); + }) + ); + }, + + async compact({ sessionId, cwd }) { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const context = yield* ContextService; + const state = yield* session.load(cwd, sessionId); + return yield* Effect.promise(() => + context.compactWithLLM(state.transcriptPath, llm.modelInfo.maxTokens, null) + ); + }) + ); + }, + + async getCheckpoints(cwd: string) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.getCheckpoints(cwd, currentSessionId); + }) + ); + }, + + async getCheckpointDiff(cwd: string, turnId?: number) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.getCheckpointDiff(cwd, currentSessionId, turnId); + }) + ); + }, + + async revertCheckpointFiles(cwd: string, turnId: number, files: string[]) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.revertCheckpointFiles( + cwd, + currentSessionId, + turnId, + files + ); + }) + ); + }, + + async previewRollbackDiff(cwd: string, throughTurnId: number) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.previewRollbackDiff( + cwd, + currentSessionId, + throughTurnId + ); + }) + ); + }, + + async rollbackCodeToTurn(cwd: string, throughTurnId: number) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.rollbackCodeToTurn( + cwd, + currentSessionId, + throughTurnId + ); + }) + ); + }, + + async rollbackContext(cwd: string, throughTurnId: number) { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, currentSessionId); + yield* session.rollbackToTurn(state, throughTurnId, 'user rollback'); + const turns = readUIHistory(currentSessionId, cwd); + const rollbackState: import('../checkpoint/types.js').RollbackState = { + context: { active: true, currentThroughTurnId: throughTurnId }, + code: { + canUndoLast: false, + lastEntry: null, + revertedFiles: [], + lastEntryId: null, + }, + }; + return { turns, rollbackState }; + }) + ); + }, + + async rollbackBothToTurn(cwd: string, throughTurnId: number) { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const checkpoint = yield* CheckpointService; + const state = yield* session.load(cwd, currentSessionId); + const codeResult = yield* checkpoint.rollbackCodeToTurn( + cwd, + currentSessionId, + throughTurnId + ); + yield* session.rollbackToTurn(state, throughTurnId, 'user rollback'); + const turns = readUIHistory(currentSessionId, cwd); + const rollbackState: import('../checkpoint/types.js').RollbackState = { + context: { active: true, currentThroughTurnId: throughTurnId }, + code: { + canUndoLast: false, + lastEntry: null, + revertedFiles: [], + lastEntryId: null, + }, + }; + return { turns, codeResult, rollbackState }; + }) + ); + }, + + async undoLastCodeRollback(cwd: string, force?: boolean, files?: string[]) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + return yield* checkpoint.undoLastCodeRollback(cwd, currentSessionId, { + force, + files, + }); + }) + ); + }, + + async getRollbackState(cwd: string) { + return rt.runPromise( + Effect.gen(function* () { + const checkpoint = yield* CheckpointService; + const entry = yield* checkpoint.getLatestRestoreEntry(cwd, currentSessionId); + return { + context: { active: false, currentThroughTurnId: null }, + code: { + canUndoLast: entry !== null, + lastEntry: entry, + revertedFiles: entry?.selectedFiles ?? [], + lastEntryId: entry?.id ?? null, + }, + }; + }) + ); + }, + + async forkSession(cwd: string, atTurnId?: number) { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, currentSessionId); + const newSessionId = yield* session.forkSession(state, atTurnId ?? 0); + const turns = readUIHistory(newSessionId, cwd); + return { sessionId: newSessionId, turns }; + }) + ); + }, + }; +} diff --git a/packages/codingcode/src/client/direct/models.ts b/packages/codingcode/src/direct/models.ts similarity index 87% rename from packages/codingcode/src/client/direct/models.ts rename to packages/codingcode/src/direct/models.ts index 9c94c131..57942dc1 100644 --- a/packages/codingcode/src/client/direct/models.ts +++ b/packages/codingcode/src/direct/models.ts @@ -1,7 +1,7 @@ import { Effect } from 'effect'; -import { LLMFactoryService } from '../../llm/factory.js'; -import type { SelectableModel } from '../../llm/factory.js'; -import type { AppRuntime } from '../../layer.js'; +import { LLMFactoryService } from '../llm/factory.js'; +import type { SelectableModel } from '../llm/factory.js'; +import type { AppRuntime } from '../layer.js'; export interface ModelClient { listModels(): Promise<{ models: SelectableModel[]; activeId: string | null }>; diff --git a/packages/codingcode/src/client/direct/sessions.ts b/packages/codingcode/src/direct/sessions.ts similarity index 68% rename from packages/codingcode/src/client/direct/sessions.ts rename to packages/codingcode/src/direct/sessions.ts index 02b8aba2..5d30c621 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/direct/sessions.ts @@ -1,17 +1,20 @@ import { Effect } from 'effect'; -import { SessionService } from '../../session/store.js'; -import { ProjectRuntimeService, modeToProfile } from '../../runtime/project-runtime.js'; -import { deleteSession } from '../../session/file-ops.js'; -import type { PermissionMode } from '../../approval/types.js'; +import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { join } from 'path'; +import { SessionService } from '../session/store.js'; +import { ProjectRuntimeService, modeToProfile } from '../runtime/project-runtime.js'; +import { deleteSession } from '../session/file-ops.js'; +import { encodeProjectPath, getProjectBaseDir } from '../core/path.js'; +import type { PermissionMode } from '../approval/types.js'; import type { CheckpointDiff, CodeRollbackResult, CodeRollbackUndoResult, RollbackPreviewDiff, RollbackState, -} from '../../checkpoint/types.js'; -import type { SessionEvent, SessionIndex, SessionMode } from '../../session/types.js'; -import type { AppRuntime } from '../../layer.js'; +} from '../checkpoint/types.js'; +import type { SessionEvent, SessionIndex, SessionMode } from '../session/types.js'; +import type { AppRuntime } from '../layer.js'; export interface SessionClient { createSession(input: { @@ -25,12 +28,27 @@ export interface SessionClient { getSessionHistory(input: { sessionId: string; cwd: string }): Promise; deleteSession(input: { sessionId: string; cwd: string }): Promise; + getSessionMode(input: { sessionId: string; cwd: string }): Promise<{ + mode: SessionMode; + permissionMode: PermissionMode; + cwd: string; + available: Array<{ name: string; description: string }>; + }>; + setSessionMode(input: { + sessionId: string; + cwd: string; + mode: SessionMode; + }): Promise<{ mode: SessionMode; permissionMode: PermissionMode }>; getSessionPermissionMode(input: { sessionId: string; cwd: string }): Promise; setSessionPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode; }): Promise; + getSessionPlan(input: { + sessionId: string; + cwd: string; + }): Promise<{ content: string; path: string; directory: string; exists: boolean }>; getCheckpointDiff(input: { sessionId: string; @@ -82,10 +100,11 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { return rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - const runtime = yield* ProjectRuntimeService; - const state = yield* session.create(cwd, { model, mode, permissionMode }); - const profile = modeToProfile(mode); - yield* runtime.setSessionProfile(cwd, state.sessionId, profile, permissionMode); + const state = yield* session.createSessionWithProfile(cwd, { + model, + mode, + permissionMode, + }); return { sessionId: state.sessionId }; }) ); @@ -124,6 +143,37 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { deleteSession(sessionId, cwd); }, + async getSessionMode({ sessionId, cwd }) { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + return { + mode: state.mode, + permissionMode: state.permissionMode, + cwd, + available: [ + { name: 'plan', description: 'Planning agent' }, + { name: 'build', description: 'Default build agent' }, + ], + }; + }) + ); + }, + + async setSessionMode({ sessionId, cwd, mode }) { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setModeOnDisk(cwd, sessionId, mode); + const profile = modeToProfile(mode); + yield* session.setActiveProfile(cwd, sessionId, profile.name); + const state = yield* session.load(cwd, sessionId); + return { mode: state.mode, permissionMode: state.permissionMode }; + }) + ); + }, + async getSessionPermissionMode({ sessionId, cwd }): Promise { const mode = await rt.runPromise( Effect.gen(function* () { @@ -145,6 +195,27 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { ); }, + async getSessionPlan({ cwd }) { + const planDir = join(getProjectBaseDir(), encodeProjectPath(cwd)); + if (!existsSync(planDir)) { + return { content: '', path: '', directory: planDir, exists: false }; + } + let latest: { path: string; mtime: number } | null = null; + for (const name of readdirSync(planDir)) { + if (!name.endsWith('.md')) continue; + const full = join(planDir, name); + const mtime = statSync(full).mtimeMs; + if (latest === null || mtime > latest.mtime) { + latest = { path: full, mtime }; + } + } + if (latest === null) { + return { content: '', path: '', directory: planDir, exists: false }; + } + const content = readFileSync(latest.path, 'utf8'); + return { content, path: latest.path, directory: planDir, exists: true }; + }, + async getCheckpointDiff() { return { turnId: 0, files: [] }; }, diff --git a/packages/codingcode/src/client/direct/settings.ts b/packages/codingcode/src/direct/settings.ts similarity index 88% rename from packages/codingcode/src/client/direct/settings.ts rename to packages/codingcode/src/direct/settings.ts index 38a7de9b..c256cb8c 100644 --- a/packages/codingcode/src/client/direct/settings.ts +++ b/packages/codingcode/src/direct/settings.ts @@ -1,12 +1,11 @@ import { Effect } from 'effect'; -import { McpService } from '../../mcp/index.js'; -import type { McpServerConfig, McpStatus } from '../../mcp/types.js'; -import { SkillService } from '../../skills/service.js'; -import { ApprovalService } from '../../approval/index.js'; -import type { PermissionMode } from '../../approval/types.js'; -import type { AgentProfile } from '../../subagent/types.js'; -import type { UserHookConfig } from '../../hooks/types.js'; -import { isGlobalCwd } from '../../core/workspace.js'; +import { McpService } from '../mcp/index.js'; +import type { McpServerConfig, McpStatus } from '../mcp/types.js'; +import { SkillService } from '../skills/service.js'; +import type { PermissionMode } from '../approval/types.js'; +import type { AgentProfile } from '../subagent/types.js'; +import type { UserHookConfig } from '../hooks/types.js'; +import { isGlobalCwd } from '../core/workspace.js'; import { loadMcpConfig, writeMcpConfig, @@ -17,7 +16,7 @@ import { setGlobalMcpDisabledState, setProjectMcpDisabledState, resetProjectMcpDisabledState, -} from '../../mcp/config.js'; +} from '../mcp/config.js'; import { loadAgentProfiles, writeAgentProfile, @@ -27,7 +26,7 @@ import { writeGlobalAgentProfile, updateGlobalAgentProfile, deleteGlobalAgentProfile, -} from '../../subagent/loader.js'; +} from '../subagent/loader.js'; import { EXPLORE_PROFILE, PLAN_PROFILE, @@ -42,7 +41,7 @@ import { resetProjectAgentDisabledState, resolveAgentDisabled, getProjectAgentDisabledState, -} from '../../subagent/registry.js'; +} from '../subagent/registry.js'; import { loadHookConfigs, writeHookConfigs, @@ -53,8 +52,8 @@ import { setGlobalHookDisabledState, setProjectHookDisabledState, resetProjectHookDisabledState, -} from '../../hooks/config.js'; -import { setHookRuntimeEnabled } from '../../hooks/executor.js'; +} from '../hooks/config.js'; +import { setHookRuntimeEnabled } from '../hooks/executor.js'; import { getMemoryConfig, getAllTypesWithStatus, @@ -62,22 +61,32 @@ import { addMemoryExtraType as _addMemoryExtraType, updateMemoryExtraType as _updateMemoryExtraType, deleteMemoryExtraType as _deleteMemoryExtraType, -} from '../../memory/config.js'; -import { MemoryService } from '../../memory/index.js'; -import { AlreadyExistsError, NotFoundError } from '../../core/error.js'; -import type { AppRuntime } from '../../layer.js'; +} from '../memory/config.js'; +import { MemoryService } from '../memory/index.js'; +import { AlreadyExistsError, NotFoundError } from '../core/error.js'; +import { + loadConfig, + updateMemoryModel, + updateContextCompactionModel, +} from '@codingcode/infra/config'; +import type { AppRuntime } from '../layer.js'; +import { SessionService } from '../session/store.js'; export interface SettingsClient { getMemoryEnabled(): Promise; getMemoryConfig(): Promise<{ enabled: boolean; types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>; + model: string; }>; setMemoryEnabled(enabled: boolean): Promise; setMemoryTypeDisabled(name: string, disabled: boolean): Promise; addMemoryExtraType(type: { name: string; description: string }): Promise; updateMemoryExtraType(name: string, type: { name: string; description: string }): Promise; deleteMemoryExtraType(name: string): Promise; + setMemoryModel(model: string): Promise<{ model: string }>; + getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number }>; + setCompactionModel(compactionModel: string): Promise<{ compactionModel: string }>; getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; resetSubagentEnabled(body: { cwd: string }): Promise; @@ -101,8 +110,12 @@ export interface SettingsClient { deleteHook(input: { cwd: string; name: string }): Promise; setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise; resetHookDisabled(body: { name: string; cwd: string }): Promise; - getGlobalPermissionMode(): Promise; - setGlobalPermissionMode(mode: PermissionMode): Promise; + getGlobalPermissionMode(input: { sessionId: string; cwd: string }): Promise; + setGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + mode: PermissionMode; + }): Promise; } // ---- Helpers with validation ---- @@ -414,7 +427,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { async getMemoryConfig() { const cfg = getMemoryConfig(); - return { enabled: cfg.enabled, types: getAllTypesWithStatus(cfg) }; + return { enabled: cfg.enabled, types: getAllTypesWithStatus(cfg), model: cfg.model }; }, async setMemoryEnabled(enabled) { @@ -426,6 +439,21 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { ); }, + async setMemoryModel(model) { + updateMemoryModel(model); + return { model }; + }, + + async getAgentConfig() { + const cfg = loadConfig(); + return { maxSteps: cfg.maxSteps, maxStopContinuations: cfg.maxStopContinuations }; + }, + + async setCompactionModel(compactionModel) { + updateContextCompactionModel(compactionModel); + return { compactionModel }; + }, + async setMemoryTypeDisabled(name, disabled) { setMemoryTypeDisabled(name, disabled); }, @@ -641,22 +669,31 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { resetProjectHookDisabledState(cwd, name); }, - async getGlobalPermissionMode() { - const approval = await rt.runPromise( + async getGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + }): Promise { + return rt.runPromise( Effect.gen(function* () { - return yield* ApprovalService; + const session = yield* SessionService; + const state = yield* session.load(input.cwd, input.sessionId); + return yield* session.getPermissionMode(state); }) ); - return approval.getPermissionMode(); }, - async setGlobalPermissionMode(mode) { - const approval = await rt.runPromise( + async setGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + mode: PermissionMode; + }): Promise { + await rt.runPromise( Effect.gen(function* () { - return yield* ApprovalService; + const session = yield* SessionService; + const state = yield* session.load(input.cwd, input.sessionId); + yield* session.setPermissionMode(state, input.mode); }) ); - await rt.runPromise(approval.setPermissionMode(mode)); }, }; } diff --git a/packages/codingcode/src/memory/index.ts b/packages/codingcode/src/memory/index.ts index 5e9e9ef5..c1c724df 100644 --- a/packages/codingcode/src/memory/index.ts +++ b/packages/codingcode/src/memory/index.ts @@ -1,6 +1,6 @@ import { Effect } from 'effect'; import type { LLMClient } from '../llm/client.js'; -import { sessionJsonlPathFromCwd } from '../session/file-ops.js'; +import { sessionJsonlPathFromCwd } from '../core/paths.js'; import type { SessionEvent } from '../session/types.js'; import { readMemoryFile, diff --git a/packages/codingcode/src/plan/index.ts b/packages/codingcode/src/plan/index.ts index e640190a..14833b70 100644 --- a/packages/codingcode/src/plan/index.ts +++ b/packages/codingcode/src/plan/index.ts @@ -1,4 +1,6 @@ +import { readFileSync } from 'fs'; import type { DecisionHandler } from '../hooks/types.js'; +import { computePaths } from '../core/paths.js'; // ---- Profile name constants + structural helper ---- @@ -14,21 +16,18 @@ export const PLAN_MODE_ALLOWED_TOOLS: ReadonlySet = new Set([ 'dispatch_agent', ]); -// ---- Plan-mode side channel ---- +// ---- Plan-mode state: read from .index.json (disk is single source of truth) ---- -const planModeSessions = new Set(); - -export function markSessionPlanMode(sessionId: string, isPlanMode: boolean): void { - if (isPlanMode) planModeSessions.add(sessionId); - else planModeSessions.delete(sessionId); -} - -export function isSessionInPlanMode(sessionId: string): boolean { - return planModeSessions.has(sessionId); -} - -export function clearPlanModeSession(sessionId: string): void { - planModeSessions.delete(sessionId); +export function isSessionInPlanMode(sessionId: string, cwd: string): boolean { + try { + const paths = computePaths(cwd, sessionId); + const idx = JSON.parse(readFileSync(paths.indexPath, 'utf8')) as { + mode?: string; + }; + return idx?.mode === 'plan'; + } catch { + return false; + } } // ---- Plan-mode subagent whitelist (called inline by dispatch_agent) ---- @@ -50,8 +49,9 @@ export function checkSubagentAllowedInPlanMode( export const planModeGateHook: DecisionHandler = (payload) => { const sessionId = payload.sessionId as string | undefined; - if (!sessionId) return null; - if (!isSessionInPlanMode(sessionId)) return null; + const projectPath = payload.projectPath as string | undefined; + if (!sessionId || !projectPath) return null; + if (!isSessionInPlanMode(sessionId, projectPath)) return null; const toolName = payload.toolName as string | undefined; if (!toolName) return null; @@ -62,4 +62,3 @@ export const planModeGateHook: DecisionHandler = (payload) => { reason: 'Write operations denied in plan mode. Use submit_plan to submit a plan.', }; }; - diff --git a/packages/codingcode/src/runtime/project-runtime.ts b/packages/codingcode/src/runtime/project-runtime.ts index 7b619ad9..dd6f6aad 100644 --- a/packages/codingcode/src/runtime/project-runtime.ts +++ b/packages/codingcode/src/runtime/project-runtime.ts @@ -13,18 +13,11 @@ import { McpService } from '../mcp/index.js'; import { RulesService } from '../rules/index.js'; import { SessionService } from '../session/store.js'; import { normalizePath } from '../core/path.js'; -import { ApprovalService } from '../approval/index.js'; import type { PermissionMode } from '../approval/types.js'; import type { SessionMode } from '../session/types.js'; -import { computePaths, readCurrentIndex, setPermissionMode } from '../session/file-ops.js'; -import { writeFileSync } from 'fs'; -import { - isPlanProfile, - markSessionPlanMode, - clearPlanModeSession, -} from '../plan/index.js'; +import { readCurrentIndex } from '../session/file-ops.js'; +import { computePaths } from '../core/paths.js'; -/** 构建全局 profile:内置 + ~/.codingcode/agents/ */ function buildGlobalProfiles(): AgentProfile[] { const profiles: AgentProfile[] = [BUILD_PROFILE, EXPLORE_PROFILE, PLAN_PROFILE]; for (const p of agentLoader.loadGlobalAgentProfiles()) { @@ -35,7 +28,6 @@ function buildGlobalProfiles(): AgentProfile[] { return profiles; } -/** 构建项目级 profile:/.codingcode/agents/ */ function buildProjectProfiles(projectPath: string): AgentProfile[] { return agentLoader.loadAgentProfiles(projectPath); } @@ -53,11 +45,8 @@ export class ProjectRuntimeService extends Effect.Service const subagent = yield* SubagentService; const rules = yield* RulesService; const session = yield* SessionService; - const sessionAgentProfiles = new Map(); - const sessionPermissionModes = new Map(); const prepared = new Set(); - // 启动时注册全局 profile(内置 + ~/.codingcode/agents/),只做一次 subagent.registerGlobal(buildGlobalProfiles()); return { @@ -76,9 +65,10 @@ export class ProjectRuntimeService extends Effect.Service projectPath: string, sessionId: string ): AgentProfile | undefined => { - const sessionOverride = sessionAgentProfiles.get(sessionId); - if (sessionOverride) return sessionOverride; - return agentLoader.loadMainAgentProfile(projectPath); + const idx = readCurrentIndex(computePaths(projectPath, sessionId).indexPath); + const name = idx?.activeProfile; + if (!name) return agentLoader.loadMainAgentProfile(projectPath); + return subagent.get(projectPath, name) ?? agentLoader.loadMainAgentProfile(projectPath); }, resolveSubagentProfile: (projectPath: string, name: string): AgentProfile | undefined => { @@ -110,76 +100,52 @@ export class ProjectRuntimeService extends Effect.Service projectPath: string, sessionId: string, profile: AgentProfile, - permissionModeOverride?: PermissionMode, - parentSessionId?: string + permissionModeOverride?: PermissionMode ): Effect.Effect => Effect.gen(function* () { - sessionAgentProfiles.set(sessionId, profile); - markSessionPlanMode(sessionId, isPlanProfile(profile)); - - if (isPlanProfile(profile)) { - // Plan 模式:内存 map 强制 'default',SessionIndex.permissionMode 不写盘(保留 build 偏好) - sessionPermissionModes.set(sessionId, 'default'); - return; - } - - const effectivePermissionMode: PermissionMode = + const mode: SessionMode = profile.name === 'plan' ? 'plan' : 'build'; + const effectivePerm: PermissionMode = permissionModeOverride ?? profile.permissionMode ?? 'default'; - sessionPermissionModes.set(sessionId, effectivePermissionMode); - const paths = computePaths(projectPath, sessionId, parentSessionId); - setPermissionMode(sessionId, paths.indexPath, effectivePermissionMode); - // Update activeProfile in the same index file. - const current = readCurrentIndex(paths.indexPath); - if (current) { - const index = { - ...current, - activeProfile: profile.name, - updatedAt: new Date().toISOString(), - }; - writeFileSync(paths.indexPath, JSON.stringify(index, null, 2), 'utf8'); - } + yield* session.setModeOnDisk(projectPath, sessionId, mode); + yield* session.setPermissionModeOnDisk(projectPath, sessionId, effectivePerm); + yield* session.setActiveProfile(projectPath, sessionId, profile.name); }), - getSessionProfile: (sessionId: string): AgentProfile | undefined => - sessionAgentProfiles.get(sessionId), + getSessionProfile: ( + sessionId: string, + projectPath: string + ): Effect.Effect => + Effect.gen(function* () { + const name = yield* session.getActiveProfile(projectPath, sessionId); + if (!name) return undefined; + return subagent.get(projectPath, name); + }), - getSessionPermissionMode: (sessionId: string): PermissionMode => - sessionPermissionModes.get(sessionId) ?? 'default', + getSessionPermissionMode: ( + sessionId: string, + projectPath: string + ): Effect.Effect => + session.getPermissionModeFromDisk(projectPath, sessionId), restoreSessionProfile: ( projectPath: string, sessionId: string, profileName: string | undefined, - permissionModeOverride?: PermissionMode, - parentSessionId?: string + permissionModeOverride?: PermissionMode ): Effect.Effect => Effect.gen(function* () { if (!profileName) return; - const norm = normalizePath(projectPath); - const profile = subagent.get(norm, profileName); + const profile = subagent.get(projectPath, profileName); if (!profile) return; - sessionAgentProfiles.set(sessionId, profile); - markSessionPlanMode(sessionId, isPlanProfile(profile)); - - if (isPlanProfile(profile)) { - sessionPermissionModes.set(sessionId, 'default'); - return; - } - - const effectivePermissionMode: PermissionMode = + const mode: SessionMode = profile.name === 'plan' ? 'plan' : 'build'; + const effectivePerm: PermissionMode = permissionModeOverride ?? profile.permissionMode ?? 'default'; - sessionPermissionModes.set(sessionId, effectivePermissionMode); - // Direct write — see setSessionProfile above. - const paths = computePaths(projectPath, sessionId, parentSessionId); - setPermissionMode(sessionId, paths.indexPath, effectivePermissionMode); + yield* session.setModeOnDisk(projectPath, sessionId, mode); + yield* session.setPermissionModeOnDisk(projectPath, sessionId, effectivePerm); + yield* session.setActiveProfile(projectPath, sessionId, profile.name); }), - disposeSession: (sessionId: string): Effect.Effect => - Effect.sync(() => { - sessionAgentProfiles.delete(sessionId); - sessionPermissionModes.delete(sessionId); - clearPlanModeSession(sessionId); - }), + disposeSession: (_sessionId: string): Effect.Effect => Effect.void, disposeProject: (projectPath: string): Effect.Effect => Effect.sync(() => { diff --git a/packages/codingcode/src/scheduler/service.ts b/packages/codingcode/src/scheduler/service.ts index 8659ef13..811cdb75 100644 --- a/packages/codingcode/src/scheduler/service.ts +++ b/packages/codingcode/src/scheduler/service.ts @@ -7,6 +7,7 @@ import { readAutomations, writeAutomations } from './store.js'; import { sendMessage } from '../agent/agent.js'; import type { AgentEvent } from '../agent/types.js'; import { LLMFactoryService } from '../llm/factory.js'; +import { ApprovalService } from '../approval/index.js'; import { AgentError } from '../core/error.js'; const logger = createLogger(); @@ -48,11 +49,18 @@ export class SchedulerService extends Effect.Service()('Schedu const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + const approval = await _rt.runPromise( + Effect.gen(function* () { + const svc = yield* ApprovalService; + return yield* svc.fork({ permissionMode: 'bypass' }); + }) + ); + try { const { stream, sessionId } = await _rt.runPromise( sendMessage(undefined, auto.description, auto.projectCwd, llm, { signal: controller.signal, - approvalOverride: { permissionMode: 'bypass' }, + approvalOverride: approval, mode: 'build', permissionMode: 'bypass', model: llm.modelInfo.model, @@ -180,18 +188,25 @@ export class SchedulerService extends Effect.Service()('Schedu const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); - try { - const { stream, sessionId } = await _rt.runPromise( - sendMessage(undefined, auto.description, auto.projectCwd, llm, { - signal: controller.signal, - approvalOverride: { permissionMode: 'bypass' }, - mode: 'build', - permissionMode: 'bypass', - model: llm.modelInfo.model, - }) - ); - - for await (const event of stream) { + const approval = await _rt.runPromise( + Effect.gen(function* () { + const svc = yield* ApprovalService; + return yield* svc.fork({ permissionMode: 'bypass' }); + }) + ); + + try { + const { stream, sessionId } = await _rt.runPromise( + sendMessage(undefined, auto.description, auto.projectCwd, llm, { + signal: controller.signal, + approvalOverride: approval, + mode: 'build', + permissionMode: 'bypass', + model: llm.modelInfo.model, + }) + ); + + for await (const event of stream) { if (event._tag === 'Error') { logger.error(`Manual run for ${id} agent error:`, event.error); } diff --git a/packages/codingcode/src/server/index.ts b/packages/codingcode/src/server/index.ts index 0499aaa0..256370e4 100644 --- a/packages/codingcode/src/server/index.ts +++ b/packages/codingcode/src/server/index.ts @@ -5,10 +5,9 @@ import { createSessionsRouter } from './routes/sessions.js'; import { createMessagesRouter } from './routes/messages.js'; import { createModelsRouter } from './routes/models.js'; import { createApprovalRouter } from './routes/approval.js'; -import { createAgentRouter } from './routes/agent.js'; import { createSettingsRouter } from './routes/settings.js'; import { createAutomationsRouter } from './routes/automations.js'; -import { AgentError, AlreadyExistsError, NotFoundError } from '../core/error.js'; +import { AgentError } from '../core/error.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -19,11 +18,13 @@ export async function createServer(rt: ManagedRt): Promise { if (err instanceof AgentError) { return c.json({ error: { code: err.code, message: err.message } }, err.httpStatus() as any); } - if (err instanceof NotFoundError) { - return c.json({ error: { code: 'NOT_FOUND', message: err.message } }, 404); - } - if (err instanceof AlreadyExistsError) { - return c.json({ error: { code: 'ALREADY_EXISTS', message: err.message } }, 409); + if ( + err && + typeof (err as { code?: unknown }).code === 'string' && + typeof (err as { httpStatus?: unknown }).httpStatus === 'function' + ) { + const e = err as unknown as { code: string; message: string; httpStatus: () => number }; + return c.json({ error: { code: e.code, message: e.message } }, e.httpStatus() as any); } console.error('[500 INTERNAL_ERROR]', err); return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }, 500); @@ -44,7 +45,6 @@ export async function createServer(rt: ManagedRt): Promise { app.route('/api', createMessagesRouter(rt)); app.route('/api/models', createModelsRouter(rt)); app.route('/api', createApprovalRouter(rt)); - app.route('/api/agent', createAgentRouter(rt)); app.route('/api/settings', await createSettingsRouter(rt)); app.route('/api/automations', createAutomationsRouter(rt)); diff --git a/packages/codingcode/src/server/routes/agent.ts b/packages/codingcode/src/server/routes/agent.ts deleted file mode 100644 index 97f2e722..00000000 --- a/packages/codingcode/src/server/routes/agent.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; -import { ApprovalService } from '../../approval/index.js'; -import { ProjectRuntimeService } from '../../runtime/project-runtime.js'; -import { isPlanProfile } from '../../plan/index.js'; -import type { PermissionMode } from '../../approval/types.js'; - -type ManagedRt = ManagedRuntime.ManagedRuntime; - -const VALID_PERMISSION_MODES = new Set([ - 'default', - 'acceptEdits', - 'bypass', -]); - -export function createAgentRouter(rt: ManagedRt): Hono { - const router = new Hono(); - - router.get('/permission-mode', async (c) => { - const approval: any = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalService; - }) - ); - return c.json({ mode: approval.getPermissionMode() }); - }); - - router.post('/permission-mode', async (c) => { - const body = (await c.req.json()) as { mode: string; cwd?: string; sessionId?: string }; - if (!VALID_PERMISSION_MODES.has(body.mode as PermissionMode)) { - return c.json({ error: `Invalid mode: ${body.mode}` }, 400); - } - if (body.cwd && body.sessionId) { - const result = await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - const profile = runtime.getSessionProfile(body.sessionId!); - return profile; - }) - ); - if (isPlanProfile(result)) { - return c.json( - { - error: - 'Permission mode is fixed in plan mode. Use /mode to switch to build mode first.', - }, - 409 - ); - } - } - const approval: any = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalService; - }) - ); - await rt.runPromise(approval.setPermissionMode(body.mode as PermissionMode)); - return c.json({ mode: approval.getPermissionMode() }); - }); - - return router; -} diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index c649f313..1ddf3ed2 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -4,13 +4,13 @@ import { sendMessage } from '../../agent/agent.js'; import { WorkspaceService } from '../../core/workspace.js'; import { toSseEvents } from '../adapter.js'; import { ApprovalService } from '../../approval/index.js'; -import { sessionJsonlPathFromCwd, getPermissionMode } from '../../session/file-ops.js'; +import { getPermissionMode } from '../../session/file-ops.js'; +import { computePaths } from '../../core/paths.js'; import { existsSync } from 'fs'; import type { PermissionMode } from '../../approval/types.js'; import { LLMFactoryService } from '../../llm/factory.js'; import { errorResponse } from '../util.js'; import { createSseHandler } from '../handler.js'; -import { activeApprovalForks } from './sessions.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -43,39 +43,30 @@ export function createMessagesRouter(rt: ManagedRt): Hono { // Read session permissionMode if session exists let approvalOverride: any = undefined; if (sessionId !== '_') { - const idxPath = sessionJsonlPathFromCwd(normalizedCwd, sessionId).replace( - '.jsonl', - '.index.json' - ); + const idxPath = computePaths(normalizedCwd, sessionId).indexPath; if (existsSync(idxPath)) { const mode = getPermissionMode(idxPath) as PermissionMode; const forked: any = await rt.runPromise( Effect.gen(function* () { const approval = yield* ApprovalService; - return yield* approval.fork({}); + return yield* approval.fork({ permissionMode: mode }); }) ); - await rt.runPromise(forked.setPermissionMode(mode)); approvalOverride = forked; - activeApprovalForks.set(sessionId, { - setPermissionMode: (m) => rt.runPromise(forked.setPermissionMode(m)), - }); } } - const program = sendMessage( - sessionId === '_' || !sessionId ? undefined : sessionId, - input, - normalizedCwd, - llm, - { - signal: c.req.raw.signal, - approvalOverride, - mode: 'build', - permissionMode: 'default', - model: llm.modelInfo.model, - } - ); + const isNew = sessionId === '_' || !sessionId; + const sendOptions: Parameters[4] = { + signal: c.req.raw.signal, + approvalOverride, + }; + if (isNew) { + sendOptions.mode = 'build'; + sendOptions.permissionMode = 'default'; + sendOptions.model = llm.modelInfo.model; + } + const program = sendMessage(isNew ? undefined : sessionId, input, normalizedCwd, llm, sendOptions); const result = await rt.runPromise( program.pipe( @@ -96,20 +87,6 @@ export function createMessagesRouter(rt: ManagedRt): Hono { const { stream, sessionId: actualSid } = result.value as any; sessionId = actualSid; - // If newly created session, fork approval with default mode - if (!approvalOverride && sessionId !== '_') { - const forked: any = await rt.runPromise( - Effect.gen(function* () { - const approval = yield* ApprovalService; - return yield* approval.fork({}); - }) - ); - approvalOverride = forked; - activeApprovalForks.set(sessionId, { - setPermissionMode: (m) => rt.runPromise(forked.setPermissionMode(m)), - }); - } - return sseHandler( async function* () { yield* toSseEvents(stream); @@ -117,9 +94,6 @@ export function createMessagesRouter(rt: ManagedRt): Hono { { initialEvents: [{ type: 'session_id', sessionId }], sessionId, - onDone: () => { - activeApprovalForks.delete(sessionId); - }, } )(c); }); diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 1b42e5a6..312340d3 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -5,11 +5,10 @@ import { join } from 'path'; import type { SessionStoreState, SessionMode } from '../../session/types.js'; import { SessionService } from '../../session/store.js'; import { - sessionJsonlPathFromCwd, getPermissionMode, - setPermissionMode, deleteSession, } from '../../session/file-ops.js'; +import { computePaths } from '../../core/paths.js'; import { readUIHistory, findUserMessageForTurn } from '../../session/ui-history.js'; import { ContextService, estimatePromptTokens } from '../../context/service.js'; import { CheckpointService } from '../../checkpoint/checkpoint-service.js'; @@ -18,18 +17,12 @@ import { LLMFactoryService } from '../../llm/factory.js'; import type { LLMClient } from '../../llm/client.js'; import { errorResponse } from '../util.js'; import { encodeProjectPath, getProjectBaseDir } from '../../core/path.js'; -import { ProjectRuntimeService, modeToProfile } from '../../runtime/project-runtime.js'; +import { modeToProfile } from '../../runtime/project-runtime.js'; import { BUILD_PROFILE, PLAN_PROFILE } from '../../subagent/registry.js'; import { isPermissionMode, type PermissionMode } from '../../approval/types.js'; -import { isPlanProfile } from '../../plan/index.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; -export const activeApprovalForks = new Map< - string, - { setPermissionMode: (mode: any) => Promise | void } ->(); - export function createSessionsRouter(rt: ManagedRt): Hono { const router = new Hono(); const runWithLayer = async (eff: Effect.Effect) => { @@ -79,9 +72,6 @@ export function createSessionsRouter(rt: ManagedRt): Hono { if (!isPermissionMode(body.permissionMode)) { return c.json({ error: `Invalid permissionMode: ${body.permissionMode}` }, 400); } - if (body.mode === 'plan' && body.permissionMode !== 'default') { - return c.json({ error: 'Plan mode requires permissionMode "default"' }, 400); - } if (!body.model) { return c.json({ error: 'model required' }, 400); } @@ -94,19 +84,11 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; - const runtime = yield* ProjectRuntimeService; - const state = yield* session.create(normalizedCwd, { + const state = yield* session.createSessionWithProfile(normalizedCwd, { model: body.model, mode: body.mode, permissionMode: body.permissionMode, }); - const profile = modeToProfile(body.mode); - yield* runtime.setSessionProfile( - normalizedCwd, - state.sessionId, - profile, - body.permissionMode - ); return state; }) as any ); @@ -255,11 +237,10 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; - const runtime = yield* ProjectRuntimeService; const state = yield* session.load(cwd, sessionId); return { mode: state.mode, - permissionMode: runtime.getSessionPermissionMode(sessionId), + permissionMode: state.permissionMode, }; }) ); @@ -292,21 +273,14 @@ export function createSessionsRouter(rt: ManagedRt): Hono { } const result = await runWithLayer( Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; const session = yield* SessionService; - const profile = - runtime.resolveSubagentProfile(cwd, mode) ?? modeToProfile(mode); + yield* session.setModeOnDisk(cwd, sessionId, mode); + const profile = modeToProfile(mode); + yield* session.setActiveProfile(cwd, sessionId, profile.name); const state = yield* session.load(cwd, sessionId); - yield* runtime.setSessionProfile(cwd, sessionId, profile, state.permissionMode); - if (!isPlanProfile(profile)) { - yield* session.updateActiveProfile(state, profile.name); - state.mode = 'build'; - } else { - state.mode = 'plan'; - } return { mode: state.mode, - permissionMode: runtime.getSessionPermissionMode(sessionId), + permissionMode: state.permissionMode, }; }) ); @@ -314,11 +288,6 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const { status, body: errBody } = errorResponse(result.error); return c.json(errBody, status as any); } - const handle = activeApprovalForks.get(sessionId); - if (handle) { - const permMode: PermissionMode = result.value.permissionMode; - Promise.resolve(handle.setPermissionMode(permMode)).catch(() => undefined); - } return c.json(result.value); }); @@ -326,7 +295,7 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); if (!cwd) return c.json({ mode: 'default' }); - const idxPath = sessionJsonlPathFromCwd(cwd, sessionId).replace('.jsonl', '.index.json'); + const idxPath = computePaths(cwd, sessionId).indexPath; if (!existsSync(idxPath)) return c.json({ mode: 'default' }); const mode = getPermissionMode(idxPath); return c.json({ mode }); @@ -341,20 +310,8 @@ export function createSessionsRouter(rt: ManagedRt): Hono { } const setResult = await runWithLayer( Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - const profileName = runtime.getSessionProfile(sessionId)?.name; - if (profileName === PLAN_PROFILE.name && mode !== 'default') { - return yield* Effect.fail( - new Error('Plan mode requires permissionMode "default"') - ); - } - const profile = - profileName === PLAN_PROFILE.name - ? PLAN_PROFILE - : runtime.getSessionProfile(sessionId) ?? BUILD_PROFILE; - yield* runtime.setSessionProfile(cwd, sessionId, profile, mode); + yield* session.setPermissionModeOnDisk(cwd, sessionId, mode); return { ok: true }; }) as any ); @@ -362,8 +319,6 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const { status, body: errBody } = errorResponse(setResult.error); return c.json(errBody, status as any); } - const handle = activeApprovalForks.get(sessionId); - if (handle) handle.setPermissionMode(mode); return c.json({ ok: true }); }); @@ -658,7 +613,7 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const state = yield* session.load(cwd, sessionId); const newSessionId = yield* session.forkSession(state, atTurnId); const turns = readUIHistory(newSessionId, cwd); - const newJsonlPath = sessionJsonlPathFromCwd(cwd, newSessionId); + const newJsonlPath = computePaths(cwd, newSessionId).transcriptPath; const promptEstimate = estimatePromptTokens(newJsonlPath); return { sessionId: newSessionId, turns, promptEstimate }; }) as any diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index baf4db1a..7f419329 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -13,37 +13,11 @@ import { } from 'fs'; import { homedir } from 'os'; import { join, dirname } from 'path'; -import { - normalizePath, - encodeProjectPath, - getProjectBaseDir, -} from '../core/path.js'; +import { getProjectBaseDir } from '../core/path.js'; +import { computePaths, projectSessionsDir, sessionJsonlPathFromCwd } from '../core/paths.js'; import type { SessionEvent, SessionMetaEvent, SessionIndex, SessionStoreState } from './types.js'; -export function projectSessionsDir(encodedProjectPath: string): string { - return join(getProjectBaseDir(), encodedProjectPath, 'sessions'); -} - -export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string { - const projectPath = encodeProjectPath(normalizePath(cwd)); - const sessionsDir = projectSessionsDir(projectPath); - return join(sessionsDir, `${sessionId}.jsonl`); -} - -export function computePaths( - cwd: string, - sessionId: string, - parentSessionId?: string -): Pick { - const normalizedCwd = normalizePath(cwd); - const projectPath = encodeProjectPath(normalizedCwd); - const sessionsDir = projectSessionsDir(projectPath); - 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 }; -} +export { computePaths, projectSessionsDir, sessionJsonlPathFromCwd }; export function ensureDirs(transcriptPath: string): void { const codingcodeDir = join(homedir(), '.codingcode'); @@ -158,7 +132,24 @@ export function readCurrentIndex(indexPath: string): Partial | nul } } -export function setPermissionMode(sessionId: string, indexPath: string, mode: import('../approval/types.js').PermissionMode): void { +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: import('../approval/types.js').PermissionMode +): void { let index: SessionIndex | null = null; if (existsSync(indexPath)) { try { diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 0a288bf0..4c5e8d5f 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -26,12 +26,13 @@ import { setPermissionMode, getPermissionMode, readCurrentIndex, + writeIndexAtomic, countNonMetaEvents, truncateTitle, findFirstUserContent, - sessionJsonlPathFromCwd, - computePaths, } from './file-ops.js'; +import { computePaths, sessionJsonlPathFromCwd } from '../core/paths.js'; +import { modeToProfile } from '../runtime/project-runtime.js'; function assertResumeWorkspace(cwd: string, sessionId: string): void { const expectedPath = sessionJsonlPathFromCwd(cwd, sessionId); @@ -58,6 +59,7 @@ export class SessionService extends Effect.Service()('Session', permissionMode: state.permissionMode, memorySnapshot: state.memorySnapshot, activeProfile: current?.activeProfile, + parentSessionId: state.parentSessionId, }; writeFileSync(state.indexPath, JSON.stringify(index, null, 2), 'utf8'); } @@ -69,7 +71,7 @@ export class SessionService extends Effect.Service()('Session', mode: SessionMode; permissionMode: PermissionMode; }, - opts?: { parentSessionId?: string; agentName?: string } + opts?: { parentSessionId?: string; agentName?: string; activeProfile?: string } ): Effect.Effect => Effect.try({ try: () => { @@ -87,6 +89,8 @@ export class SessionService extends Effect.Service()('Session', currentTurnId: 0, usage: undefined, memorySnapshot: '', + activeProfile: opts?.activeProfile, + parentSessionId: opts?.parentSessionId, }; const meta: SessionMetaEvent = { @@ -104,6 +108,9 @@ export class SessionService extends Effect.Service()('Session', appendLine(state.transcriptPath, meta); state.messageCount++; updateIndex(state); + if (state.activeProfile) { + writeIndexAtomic(state.indexPath, { activeProfile: state.activeProfile }); + } return state; }, catch: (e) => @@ -143,8 +150,6 @@ export class SessionService extends Effect.Service()('Session', if (meta) { state.sessionMeta = meta; state.messageCount = history.filter((e) => e.type !== 'session_meta').length; - if (meta.mode) state.mode = meta.mode; - if (meta.permissionMode) state.permissionMode = meta.permissionMode; } const firstUser = findFirstUserContent(history); if (firstUser) state.title = truncateTitle(firstUser); @@ -373,8 +378,83 @@ export class SessionService extends Effect.Service()('Session', return state.currentTurnId; }; + const setModeOnDisk = ( + cwd: string, + sessionId: string, + mode: SessionMode + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + writeIndexAtomic(paths.indexPath, { mode }); + }); + + const setPermissionModeOnDisk = ( + cwd: string, + sessionId: string, + mode: import('../approval/types.js').PermissionMode + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + setPermissionMode(sessionId, paths.indexPath, mode); + }); + + const setActiveProfile = ( + cwd: string, + sessionId: string, + profile: string + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + writeIndexAtomic(paths.indexPath, { activeProfile: profile }); + }); + + const getModeFromDisk = ( + cwd: string, + sessionId: string + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + const idx = readCurrentIndex(paths.indexPath); + return (idx?.mode as SessionMode) ?? 'build'; + }); + + const getPermissionModeFromDisk = ( + cwd: string, + sessionId: string + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + const raw = getPermissionMode(paths.indexPath); + if (raw === 'default' || raw === 'acceptEdits' || raw === 'bypass') return raw; + return 'default'; + }); + + const getActiveProfile = ( + cwd: string, + sessionId: string + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId); + const idx = readCurrentIndex(paths.indexPath); + return idx?.activeProfile; + }); + + const createSessionWithProfile = ( + cwd: string, + options: { + model: string; + mode: SessionMode; + permissionMode: PermissionMode; + }, + opts?: { parentSessionId?: string; agentName?: string; activeProfile?: string } + ): Effect.Effect => { + const activeProfile = opts?.activeProfile ?? modeToProfile(options.mode).name; + return create(cwd, options, { ...opts, activeProfile }); + }; + return { create, + createSessionWithProfile, load, recordUser, recordAssistant, @@ -393,6 +473,12 @@ export class SessionService extends Effect.Service()('Session', incrementTurn, readHistoryFile: (path: string): SessionEvent[] => readHistory(path), appendLineProxy: (path: string, event: object): void => appendLine(path, event), + setModeOnDisk, + setPermissionModeOnDisk, + setActiveProfile, + getModeFromDisk, + getPermissionModeFromDisk, + getActiveProfile, }; }), }) {} diff --git a/packages/codingcode/src/session/types.ts b/packages/codingcode/src/session/types.ts index 54725ea0..e9d02523 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -85,6 +85,7 @@ export interface SessionIndex { permissionMode: import('../approval/types.js').PermissionMode; memorySnapshot?: string; activeProfile?: string; + parentSessionId?: string; } export interface SessionStoreState { @@ -103,4 +104,5 @@ export interface SessionStoreState { usage: TokenUsage | undefined; memorySnapshot: string; activeProfile?: string; + parentSessionId?: string; } diff --git a/packages/codingcode/src/session/ui-history.ts b/packages/codingcode/src/session/ui-history.ts index d5825a86..3a3406ce 100644 --- a/packages/codingcode/src/session/ui-history.ts +++ b/packages/codingcode/src/session/ui-history.ts @@ -1,5 +1,6 @@ import { existsSync } from 'fs'; -import { sessionJsonlPathFromCwd, readHistory } from './file-ops.js'; +import { readHistory } from './file-ops.js'; +import { sessionJsonlPathFromCwd } from '../core/paths.js'; import type { SessionEvent, SummaryEvent, CompactEvent } from './types.js'; export function filterForUI(events: SessionEvent[]): SessionEvent[] { diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index 23528e98..26fc7ae1 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -7,11 +7,17 @@ import { ApprovalService } from '../../../approval/index.js'; import { HookService } from '../../../hooks/registry.js'; import { McpService } from '../../../mcp/index.js'; import { LLMFactoryService } from '../../../llm/factory.js'; -import { resolveSubagentEnabled, resolveAgentDisabled, BUILD_PROFILE } from '../../../subagent/registry.js'; +import { + resolveSubagentEnabled, + resolveAgentDisabled, + BUILD_PROFILE, +} from '../../../subagent/registry.js'; import { RulesService } from '../../../rules/index.js'; import { ProjectRuntimeService } from '../../../runtime/project-runtime.js'; import { SubagentRunnerService } from '../../../subagent/runner-service.js'; import { checkSubagentAllowedInPlanMode } from '../../../plan/index.js'; +import { readCurrentIndex } from '../../../session/file-ops.js'; +import { computePaths } from '../../../core/paths.js'; import type { SessionMode } from '../../../session/types.js'; import type { PermissionMode } from '../../../approval/types.js'; @@ -93,9 +99,11 @@ export function createDispatchAgentTool(): Effect.Effect< // Emit spawn.before hook (decision hook, can deny) const parentSessionId = ctx?.sessionId; - const parentMainProfile = parentSessionId - ? runtime.getSessionProfile(parentSessionId)?.name - : undefined; + const parentMainProfile = + parentSessionId && projectPath + ? readCurrentIndex(computePaths(projectPath, parentSessionId).indexPath) + ?.activeProfile + : undefined; const whitelist = checkSubagentAllowedInPlanMode( parentSessionId, @@ -123,11 +131,21 @@ export function createDispatchAgentTool(): Effect.Effect< // Create subagent transcript nested under parent session const subagentProfile = runtime.resolveSubagentProfile(projectPath, agentName); const childMode: SessionMode = 'build'; + + // Read parent session's permissionMode for inheritance (priority: profile > parent > 'default') + let parentPermissionMode: PermissionMode | undefined; + if (ctx?.sessionId) { + const loaded = session.load(projectPath, ctx.sessionId); + const parentState = yield* loaded; + parentPermissionMode = parentState.permissionMode; + } const childPermissionMode: PermissionMode = - (subagentProfile?.permissionMode as PermissionMode | undefined) ?? 'default'; + (subagentProfile?.permissionMode as PermissionMode | undefined) ?? + parentPermissionMode ?? + 'default'; const childModel: string = subagentProfile?.model ?? llm.modelInfo.model; - const childState = yield* session.create( + const childState = yield* session.createSessionWithProfile( projectPath, { model: childModel, @@ -137,24 +155,18 @@ export function createDispatchAgentTool(): Effect.Effect< { parentSessionId: ctx?.sessionId, agentName: agentName, + activeProfile: (subagentProfile ?? BUILD_PROFILE).name, } ); - yield* runtime.setSessionProfile( - projectPath, - childState.sessionId, - subagentProfile ?? BUILD_PROFILE, - childPermissionMode, - ctx?.sessionId - ); const childUuid = childState.sessionId; session.incrementTurn(childState); yield* session.recordUser(childState, prompt); - // Approval: bypass for readonly, fork without delegateEmitter for non-readonly - let childApproval; - if (!profile.readonly) { - childApproval = yield* approval.fork({ readonly: false }); - } + // Approval: always fork with permissionMode closure (no longer omitted for readonly) + const childApproval = yield* approval.fork({ + readonly: profile.readonly ?? false, + permissionMode: childPermissionMode, + }); // Attach subagent hooks if (profile.hooks && profile.hooks.length > 0) { diff --git a/packages/codingcode/src/tools/executor.ts b/packages/codingcode/src/tools/executor.ts index c15674dd..5109bf91 100644 --- a/packages/codingcode/src/tools/executor.ts +++ b/packages/codingcode/src/tools/executor.ts @@ -25,7 +25,7 @@ export class ToolExecutorService extends Effect.Service()(' sessionId?: string; turnId?: number; projectPath?: string; - approval?: any; + approval?: import('../approval/index.js').ApprovalService; callId?: string; toolLookup?: ToolLookup; } @@ -132,7 +132,7 @@ export class ToolExecutorService extends Effect.Service()(' turnId?: number; projectPath?: string; signal?: AbortSignal; - approval?: any; + approval?: import('../approval/index.js').ApprovalService; toolLookup?: ToolLookup; } ): Effect.Effect { @@ -182,7 +182,7 @@ export class ToolExecutorService extends Effect.Service()(' turnId?: number; projectPath?: string; signal?: AbortSignal; - approval?: any; + approval?: import('../approval/index.js').ApprovalService; toolLookup?: ToolLookup; } ): Effect.Effect { diff --git a/packages/codingcode/test/agent/build-system-prompt.test.ts b/packages/codingcode/test/agent/build-system-prompt.test.ts index c3830603..fdd4cec0 100644 --- a/packages/codingcode/test/agent/build-system-prompt.test.ts +++ b/packages/codingcode/test/agent/build-system-prompt.test.ts @@ -86,6 +86,6 @@ describe('buildSystemPrompt', () => { }); expect(prompt).toContain('submit_plan'); expect(prompt).toContain("dispatch the 'explore' subagent"); - expect(prompt).toContain("write_file / edit_file / execute_command are denied"); + expect(prompt).toContain('write_file / edit_file / execute_command are denied'); }); }); diff --git a/packages/codingcode/test/agent/send-message-optional-mode.test.ts b/packages/codingcode/test/agent/send-message-optional-mode.test.ts new file mode 100644 index 00000000..e1a9f73b --- /dev/null +++ b/packages/codingcode/test/agent/send-message-optional-mode.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; + +describe('sendMessage options are optional with guard', () => { + it('agent.ts sendMessage options make mode/permissionMode/model optional', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/agent/agent.ts', + 'utf8' + ); + expect(src).toMatch(/mode\?:\s*SessionMode/); + expect(src).toMatch(/permissionMode\?:\s*PermissionMode/); + expect(src).toMatch(/model\?:\s*string/); + }); + + it('agent.ts guards new-session branch against missing mode/permissionMode/model', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/agent/agent.ts', + 'utf8' + ); + expect(src).toMatch(/SESSION_CONFIG_REQUIRED|new session requires mode/); + }); + + it('messages.ts conditionally builds options (no hardcoded mode on existing-session path)', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/server/routes/messages.ts', + 'utf8' + ); + expect(src).toMatch(/isNew\s*=/); + expect(src).toMatch(/if\s*\(isNew\)/); + }); + + it('direct agent-runtime.ts sends options only on new session', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/direct/agent-runtime.ts', + 'utf8' + ); + expect(src).toMatch(/if\s*\(!sessionId\)/); + }); + + it('http agent-runtime.ts sendMessage (sub-client used by desktop) sends options only on new session', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/client/http/agent-runtime.ts', + 'utf8' + ); + expect(src).toMatch(/sendMessage\(input,/); + }); +}); 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 8047b824..92656a2f 100644 --- a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts +++ b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts @@ -178,7 +178,9 @@ describe('agentLoop plan.ready emission on turn-end', () => { callCount++; return { stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: 'Just a regular response', toolCalls: [] })), + response: Promise.resolve( + Result.ok({ content: 'Just a regular response', toolCalls: [] }) + ), }; }), }; diff --git a/packages/codingcode/test/approval/permission-mode.test.ts b/packages/codingcode/test/approval/fork-permission-mode.test.ts similarity index 55% rename from packages/codingcode/test/approval/permission-mode.test.ts rename to packages/codingcode/test/approval/fork-permission-mode.test.ts index 63c539f8..7be6645e 100644 --- a/packages/codingcode/test/approval/permission-mode.test.ts +++ b/packages/codingcode/test/approval/fork-permission-mode.test.ts @@ -33,9 +33,7 @@ const TestLayer = ApprovalService.Default.pipe( Layer.provide(Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any)) ); -// Build the service once so state is shared across all run() calls let _service: ApprovalService | null = null; - async function getService(): Promise { if (!_service) { _service = await Effect.runPromise( @@ -47,36 +45,46 @@ async function getService(): Promise { return _service!; } -function run(eff: (svc: ApprovalService) => Effect.Effect): Promise { - return getService().then((svc) => Effect.runPromise(eff(svc) as any)); +function run(eff: (svc: ApprovalService) => Promise): Promise { + return getService().then(eff); } -describe('Global permission mode state', () => { +describe('approval.fork({ permissionMode }) closure', () => { beforeEach(async () => { - // Reset to default between tests - await run((svc) => svc.setPermissionMode('default')); + _service = null; }); - it('starts as default', async () => { - const mode = await run((svc) => Effect.succeed(svc.getPermissionMode())); - expect(mode).toBe('default'); + it('fork with permissionMode: bypass creates a child whose getPermissionMode returns bypass', async () => { + const mode = await run(async (svc) => { + const child = await Effect.runPromise(svc.fork({ permissionMode: 'bypass' })); + return child.getPermissionMode(); + }); + expect(mode).toBe('bypass'); }); - it('can be set to all valid modes', async () => { - const modes = ['default', 'acceptEdits', 'bypass'] as const; - for (const mode of modes) { - await run((svc) => svc.setPermissionMode(mode)); - const current = await run((svc) => Effect.succeed(svc.getPermissionMode())); - expect(current).toBe(mode); - } + it('fork with permissionMode: acceptEdits creates a child with acceptEdits', async () => { + const mode = await run(async (svc) => { + const child = await Effect.runPromise(svc.fork({ permissionMode: 'acceptEdits' })); + return child.getPermissionMode(); + }); + expect(mode).toBe('acceptEdits'); + }); + + it('fork without permissionMode defaults to "default"', async () => { + const mode = await run(async (svc) => { + const child = await Effect.runPromise(svc.fork({})); + return child.getPermissionMode(); + }); + expect(mode).toBe('default'); }); - it('is shared across multiple reads (module-level singleton)', async () => { - await run((svc) => svc.setPermissionMode('bypass')); - const mode1 = await run((svc) => Effect.succeed(svc.getPermissionMode())); - const mode2 = await run((svc) => Effect.succeed(svc.getPermissionMode())); - // Both reads return the same value — no per-call isolation - expect(mode1).toBe('bypass'); - expect(mode2).toBe('bypass'); + it('two forks with different permissionMode are isolated', async () => { + const result = await run(async (svc) => { + const a = await Effect.runPromise(svc.fork({ permissionMode: 'bypass' })); + const b = await Effect.runPromise(svc.fork({ permissionMode: 'default' })); + return { a: a.getPermissionMode(), b: b.getPermissionMode() }; + }); + expect(result.a).toBe('bypass'); + expect(result.b).toBe('default'); }); }); diff --git a/packages/codingcode/test/approval/response.test.ts b/packages/codingcode/test/approval/response.test.ts index ed86a233..3b6dff39 100644 --- a/packages/codingcode/test/approval/response.test.ts +++ b/packages/codingcode/test/approval/response.test.ts @@ -35,5 +35,4 @@ describe('parseApprovalResponse', () => { vi.useRealTimers(); }); - }); diff --git a/packages/codingcode/test/client/agent-client-cwd.test.ts b/packages/codingcode/test/client/agent-client-cwd.test.ts deleted file mode 100644 index a77d49d7..00000000 --- a/packages/codingcode/test/client/agent-client-cwd.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; - -import { WorkspaceService } from '../../src/core/workspace.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; -import { AgentError } from '../../src/core/error.js'; -import type { LLMClient } from '../../src/llm/client.js'; - -const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { - getWorkspaceCwd: () => '/workspace', -} as any); - -const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { - getLLMClient: () => Effect.succeed(null), - listModels: () => Effect.succeed([]), - switchModel: () => Effect.fail(new AgentError('CONFIG_INVALID', 'not found')), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new AgentError('CONFIG_INVALID', 'No active model')), - createClient: () => Effect.succeed(null), -} as any); - -const TestLayer = Layer.mergeAll(MockWorkspaceLayer, MockLLMFactoryLayer); - -const noopLlm: LLMClient = { - completeStream: () => ({ - stream: (async function* () {})(), - response: Promise.resolve({ ok: true, value: { content: '', finishReason: 'stop' as const } }), - }), - complete: () => Effect.succeed({ content: '' } as any), - modelInfo: { id: 'test', provider: 'test', name: 'Test', contextWindow: 128000 } as any, -}; - -const calls: Record = { - getSubagentEnabled: [], - getMcpStatus: [], - createMcpServer: [], - updateMcpServer: [], - deleteMcpServer: [], - listAgents: [], - createAgent: [], - updateAgent: [], - deleteAgent: [], - listHooks: [], - createHook: [], - updateHook: [], - deleteHook: [], - toggleSkill: [], - setAgentDisabled: [], - setHookDisabled: [], -}; - -function makeMockSettings() { - return { - getMemoryEnabled: vi.fn().mockResolvedValue(true), - setMemoryEnabled: vi.fn().mockResolvedValue(undefined), - getMemoryConfig: vi.fn().mockResolvedValue({ enabled: true, types: [] }), - setMemoryTypeDisabled: vi.fn().mockResolvedValue(undefined), - addMemoryExtraType: vi.fn().mockResolvedValue(undefined), - updateMemoryExtraType: vi.fn().mockResolvedValue(undefined), - deleteMemoryExtraType: vi.fn().mockResolvedValue(undefined), - getSubagentEnabled: vi.fn().mockImplementation((...args: unknown[]) => { - calls.getSubagentEnabled.push(args); - return Promise.resolve({ enabled: true, source: 'global' }); - }), - setSubagentEnabled: vi.fn().mockResolvedValue(undefined), - resetSubagentEnabled: vi.fn().mockResolvedValue(undefined), - getMcpStatus: vi.fn().mockImplementation((...args: unknown[]) => { - calls.getMcpStatus.push(args); - return Promise.resolve([]); - }), - setMcpDisabled: vi.fn().mockResolvedValue(undefined), - resetMcpDisabled: vi.fn().mockResolvedValue(undefined), - createMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { - calls.createMcpServer.push(args); - return Promise.resolve(undefined); - }), - updateMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { - calls.updateMcpServer.push(args); - return Promise.resolve(undefined); - }), - deleteMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { - calls.deleteMcpServer.push(args); - return Promise.resolve(undefined); - }), - listSkills: vi.fn().mockResolvedValue([]), - toggleSkill: vi.fn().mockImplementation((...args: unknown[]) => { - calls.toggleSkill.push(args); - return Promise.resolve(undefined); - }), - listAgents: vi.fn().mockImplementation((...args: unknown[]) => { - calls.listAgents.push(args); - return Promise.resolve([]); - }), - createAgent: vi.fn().mockImplementation((...args: unknown[]) => { - calls.createAgent.push(args); - return Promise.resolve(undefined); - }), - updateAgent: vi.fn().mockImplementation((...args: unknown[]) => { - calls.updateAgent.push(args); - return Promise.resolve(undefined); - }), - deleteAgent: vi.fn().mockImplementation((...args: unknown[]) => { - calls.deleteAgent.push(args); - return Promise.resolve(undefined); - }), - setAgentDisabled: vi.fn().mockImplementation((...args: unknown[]) => { - calls.setAgentDisabled.push(args); - return Promise.resolve(undefined); - }), - resetAgentDisabled: vi.fn().mockResolvedValue(undefined), - listHooks: vi.fn().mockImplementation((...args: unknown[]) => { - calls.listHooks.push(args); - return Promise.resolve([]); - }), - setHookDisabled: vi.fn().mockImplementation((...args: unknown[]) => { - calls.setHookDisabled.push(args); - return Promise.resolve(undefined); - }), - resetHookDisabled: vi.fn().mockResolvedValue(undefined), - createHook: vi.fn().mockImplementation((...args: unknown[]) => { - calls.createHook.push(args); - return Promise.resolve(undefined); - }), - updateHook: vi.fn().mockImplementation((...args: unknown[]) => { - calls.updateHook.push(args); - return Promise.resolve(undefined); - }), - deleteHook: vi.fn().mockImplementation((...args: unknown[]) => { - calls.deleteHook.push(args); - return Promise.resolve(undefined); - }), - getGlobalPermissionMode: vi.fn().mockResolvedValue('default'), - setGlobalPermissionMode: vi.fn().mockResolvedValue(undefined), - }; -} - -vi.mock('../../src/client/direct/settings.js', () => ({ - createDirectSettingsClient: () => makeMockSettings(), -})); - -const { createDirectClient } = await import('../../src/client/direct.js'); - -describe('AgentClient SDK - unified cwd forwarding', () => { - let client: Awaited>; - - beforeEach(async () => { - for (const key of Object.keys(calls)) calls[key] = []; - const rt = ManagedRuntime.make(TestLayer); - client = await createDirectClient(noopLlm, rt); - }); - - describe('getSubagentEnabled - explicit cwd', () => { - it('forwards project cwd from query arg', async () => { - await client.getSubagentEnabled({ cwd: '/my-project' }); - expect(calls.getSubagentEnabled).toEqual([[{ cwd: '/my-project' }]]); - }); - - it('forwards empty cwd (= global) from query arg', async () => { - await client.getSubagentEnabled({ cwd: '' }); - expect(calls.getSubagentEnabled).toEqual([[{ cwd: '' }]]); - }); - }); - - describe('getMcpStatus - explicit cwd', () => { - it('forwards project cwd from query arg', async () => { - await client.getMcpStatus({ cwd: '/my-project' }); - expect(calls.getMcpStatus).toEqual([[{ cwd: '/my-project' }]]); - }); - - it('forwards empty cwd (= global) from query arg', async () => { - await client.getMcpStatus({ cwd: '' }); - expect(calls.getMcpStatus).toEqual([[{ cwd: '' }]]); - }); - }); - - describe('createMcpServer - explicit cwd', () => { - it('forwards cwd as second arg, not via closure', async () => { - await client.createMcpServer({ name: 'srv', command: 'npx' } as any, { - cwd: '/my-project', - }); - expect(calls.createMcpServer).toEqual([ - [{ cwd: '/my-project', server: { name: 'srv', command: 'npx' } }], - ]); - }); - }); - - describe('updateMcpServer - explicit cwd', () => { - it('forwards cwd as third arg', async () => { - await client.updateMcpServer('srv', { name: 'srv', command: 'npx' } as any, { - cwd: '/my-project', - }); - expect(calls.updateMcpServer).toEqual([ - [{ cwd: '/my-project', name: 'srv', server: { name: 'srv', command: 'npx' } }], - ]); - }); - }); - - describe('deleteMcpServer - explicit cwd', () => { - it('forwards cwd as second arg', async () => { - await client.deleteMcpServer('srv', { cwd: '/my-project' }); - expect(calls.deleteMcpServer).toEqual([[{ cwd: '/my-project', name: 'srv' }]]); - }); - }); - - describe('listAgents - explicit cwd', () => { - it('forwards cwd from query', async () => { - await client.listAgents({ cwd: '/my-project' }); - expect(calls.listAgents).toEqual([[{ cwd: '/my-project' }]]); - }); - }); - - describe('createAgent - explicit cwd', () => { - it('forwards cwd as second arg', async () => { - const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; - await client.createAgent(profile as any, { cwd: '/my-project' }); - expect(calls.createAgent).toEqual([[{ cwd: '/my-project', profile }]]); - }); - - it('different cwds for the same agent name go to different settings calls', async () => { - const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; - await client.createAgent(profile as any, { cwd: '/project-a' }); - await client.createAgent(profile as any, { cwd: '/project-b' }); - expect(calls.createAgent).toEqual([ - [{ cwd: '/project-a', profile }], - [{ cwd: '/project-b', profile }], - ]); - }); - }); - - describe('updateAgent - explicit cwd', () => { - it('forwards cwd as third arg', async () => { - const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; - await client.updateAgent('a1', profile as any, { cwd: '/my-project' }); - expect(calls.updateAgent).toEqual([[{ cwd: '/my-project', name: 'a1', profile }]]); - }); - }); - - describe('deleteAgent - explicit cwd', () => { - it('forwards cwd as second arg', async () => { - await client.deleteAgent('a1', { cwd: '/my-project' }); - expect(calls.deleteAgent).toEqual([[{ cwd: '/my-project', name: 'a1' }]]); - }); - }); - - describe('listHooks - explicit cwd', () => { - it('forwards cwd from query', async () => { - await client.listHooks({ cwd: '/my-project' }); - expect(calls.listHooks).toEqual([[{ cwd: '/my-project' }]]); - }); - }); - - describe('createHook - explicit cwd', () => { - it('forwards cwd as second arg', async () => { - const hook = { - name: 'h1', - point: 'tool.execute.before', - type: 'observer', - command: 'echo', - enabled: true, - }; - await client.createHook(hook as any, { cwd: '/my-project' }); - expect(calls.createHook).toEqual([[{ cwd: '/my-project', hook }]]); - }); - }); - - describe('updateHook - explicit cwd', () => { - it('forwards cwd as third arg', async () => { - const hook = { - name: 'h1', - point: 'tool.execute.before', - type: 'observer', - command: 'echo', - enabled: true, - }; - await client.updateHook('h1', hook as any, { cwd: '/my-project' }); - expect(calls.updateHook).toEqual([[{ cwd: '/my-project', name: 'h1', hook }]]); - }); - }); - - describe('deleteHook - explicit cwd', () => { - it('forwards cwd as second arg', async () => { - await client.deleteHook('h1', { cwd: '/my-project' }); - expect(calls.deleteHook).toEqual([[{ cwd: '/my-project', name: 'h1' }]]); - }); - }); -}); - -describe('AgentClient SDK - body-based methods still pass through', () => { - let client: Awaited>; - - beforeEach(async () => { - for (const key of Object.keys(calls)) calls[key] = []; - const rt = ManagedRuntime.make(TestLayer); - client = await createDirectClient(noopLlm, rt); - }); - - it('toggleSkill passes body with cwd unchanged', async () => { - await client.toggleSkill({ name: 's1', enabled: true, cwd: '/my-project' }); - expect(calls.toggleSkill).toEqual([[{ name: 's1', enabled: true, cwd: '/my-project' }]]); - }); - - it('setAgentDisabled passes body with cwd unchanged', async () => { - await client.setAgentDisabled({ name: 'a1', disabled: true, cwd: '/my-project' }); - expect(calls.setAgentDisabled).toEqual([[{ name: 'a1', disabled: true, cwd: '/my-project' }]]); - }); - - it('setHookDisabled passes body with cwd unchanged', async () => { - await client.setHookDisabled({ name: 'h1', disabled: true, cwd: '/my-project' }); - expect(calls.setHookDisabled).toEqual([[{ name: 'h1', disabled: true, cwd: '/my-project' }]]); - }); -}); diff --git a/packages/codingcode/test/client/direct-todo.test.ts b/packages/codingcode/test/client/direct-todo.test.ts index 9d52d48f..f03ac7ee 100644 --- a/packages/codingcode/test/client/direct-todo.test.ts +++ b/packages/codingcode/test/client/direct-todo.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { agentEventToStreamChunk } from '../../src/client/direct.js'; +import { agentEventToStreamChunk } from '../../src/agent/stream-adapter.js'; describe('agentEventToStreamChunk with TodoUpdate', () => { it('should map TodoUpdate to todo_update chunk', async () => { diff --git a/packages/codingcode/test/client/direct-types.test.ts b/packages/codingcode/test/client/direct-types.test.ts index ae995dbc..37207a67 100644 --- a/packages/codingcode/test/client/direct-types.test.ts +++ b/packages/codingcode/test/client/direct-types.test.ts @@ -1,42 +1,28 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { createDirectClient } from '../../src/client/direct.js'; -import { createDirectClients } from '../../src/client/direct/index.js'; -import { createDirectAgentClient } from '../../src/client/direct/agent-runtime.js'; -import { createDirectSessionClient } from '../../src/client/direct/sessions.js'; -import { createDirectModelClient } from '../../src/client/direct/models.js'; -import { createDirectSettingsClient } from '../../src/client/direct/settings.js'; -import { createAppRuntime, type AppRuntime } from '../../src/layer.js'; +import { createDirectAgentClient } from '../../src/direct/agent-runtime.js'; +import { createDirectSessionClient } from '../../src/direct/sessions.js'; +import { createDirectModelClient } from '../../src/direct/models.js'; +import { createDirectSettingsClient } from '../../src/direct/settings.js'; +import type { AppRuntime } from '../../src/layer.js'; import type { LLMClient } from '../../src/llm/client.js'; import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; import { WorkspaceService } from '../../src/core/workspace.js'; import { LLMFactoryService } from '../../src/llm/factory.js'; import { AgentError } from '../../src/core/error.js'; -// -- Compile-time type assertions -- -// These assertions verify that the types are not `any`. -// If any of these fail at compile time, the types are wrong. - type AssertNotAny = 0 extends 1 & T ? never : T; -// AppRuntime must not be `any` type _AppRuntimeNotAny = AssertNotAny; - -// LLMClient must not be `any` type _LLMClientNotAny = AssertNotAny; -// Parameters of createDirectClient must not be `any` -type _DirectClientParams = Parameters; -type _LlmParamNotAny = AssertNotAny<_DirectClientParams[0]>; -type _RtParamNotAny = AssertNotAny<_DirectClientParams[1]>; - -// Parameters of createDirectClients must not be `any` -type _DirectClientsParams = Parameters; -type _DirectClientsLlmNotAny = AssertNotAny<_DirectClientsParams[0]>; -type _DirectClientsRtNotAny = AssertNotAny<_DirectClientsParams[1]>; +type _AgentParams = Parameters; +type _LlmParamNotAny = AssertNotAny<_AgentParams[0]>; +type _RtParamNotAny = AssertNotAny<_AgentParams[1]>; -// -- Runtime tests -- +type _SessionParams = Parameters; +type _SessionRtNotAny = AssertNotAny<_SessionParams[0]>; const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { getWorkspaceCwd: () => '/tmp/test', @@ -69,21 +55,6 @@ const noopLlm: LLMClient = { }; describe('type replacements: AppRuntime and LLMClient', () => { - it('createDirectClient accepts LLMClient and ManagedRuntime', async () => { - const client = await createDirectClient(noopLlm, rt); - expect(client).toBeDefined(); - expect(typeof client.sendMessage).toBe('function'); - }); - - it('createDirectClients accepts LLMClient and ManagedRuntime', () => { - const clients = createDirectClients(noopLlm, rt); - expect(clients).toBeDefined(); - expect(clients.agent).toBeDefined(); - expect(clients.sessions).toBeDefined(); - expect(clients.models).toBeDefined(); - expect(clients.settings).toBeDefined(); - }); - it('createDirectAgentClient accepts LLMClient and ManagedRuntime', () => { const agentClient = createDirectAgentClient(noopLlm, rt); expect(agentClient).toBeDefined(); @@ -108,14 +79,6 @@ describe('type replacements: AppRuntime and LLMClient', () => { expect(typeof settingsClient.getMemoryEnabled).toBe('function'); }); - it('approval service from runtime has getPermissionMode method', async () => { - // This verifies that `const approval = await rt.runPromise(...)` returns - // a properly typed ApprovalService (not `any`), so .getPermissionMode() works - const client = await createDirectClient(noopLlm, rt); - // getPermissionMode should be a function on the client - expect(typeof client.getPermissionMode).toBe('function'); - }); - it('waitService from runtime has registerEmitter and unregisterEmitter', async () => { const waitService = await rt.runPromise( Effect.gen(function* () { diff --git a/packages/codingcode/test/client/direct.test.ts b/packages/codingcode/test/client/direct.test.ts index 77667a74..5e1b4b7c 100644 --- a/packages/codingcode/test/client/direct.test.ts +++ b/packages/codingcode/test/client/direct.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { createDirectClient, agentEventToStreamChunk } from '../../src/client/direct.js'; +import { createDirectModelClient } from '../../src/direct/models.js'; +import { agentEventToStreamChunk } from '../../src/agent/stream-adapter.js'; import type { LLMClient } from '../../src/llm/client.js'; import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; import { AgentError } from '../../src/core/error.js'; @@ -62,10 +63,10 @@ const noopLlm: LLMClient = { }, }; -describe('createDirectClient model operations', () => { +describe('createDirectModelClient operations', () => { it('lists models from the local model catalog without HTTP', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); - const client = await createDirectClient(noopLlm, rt); + const client = createDirectModelClient(rt); const result = await client.listModels(); @@ -79,9 +80,9 @@ describe('createDirectClient model operations', () => { it('rejects unknown model switches without contacting server', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); - const client = await createDirectClient(noopLlm, rt); + const client = createDirectModelClient(rt); - await expect(client.switchModel('missing-model@MISSING_KEY')).rejects.toThrow('not found'); + await expect(client.switchModel({ id: 'missing-model@MISSING_KEY' })).rejects.toThrow('not found'); expect(fetchSpy).not.toHaveBeenCalled(); fetchSpy.mockRestore(); diff --git a/packages/codingcode/test/client/direct/settings.test.ts b/packages/codingcode/test/client/direct/settings.test.ts index 8d6c935c..3b314451 100644 --- a/packages/codingcode/test/client/direct/settings.test.ts +++ b/packages/codingcode/test/client/direct/settings.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { createDirectSettingsClient } from '../../../src/client/direct/settings.js'; +import { createDirectSettingsClient } from '../../../src/direct/settings.js'; import { SkillService } from '../../../src/skills/service.js'; import { MemoryService } from '../../../src/memory/index.js'; import { McpService } from '../../../src/mcp/index.js'; diff --git a/packages/codingcode/test/client/get-session-plan.test.ts b/packages/codingcode/test/client/get-session-plan.test.ts new file mode 100644 index 00000000..e0000ccc --- /dev/null +++ b/packages/codingcode/test/client/get-session-plan.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { createHttpSessionClient } from '../../src/client/http/sessions.js'; +import { createDirectSessionClient } from '../../src/direct/sessions.js'; +import { SessionService } from '../../src/session/store.js'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { setProjectBaseDir, encodeProjectPath } from '../../src/core/path.js'; + +describe('getSessionPlan: http + direct both implement', () => { + it('http calls GET /api/sessions/:id/plan?cwd=...', async () => { + const calls: string[] = []; + const c = createHttpSessionClient({ + apiGet: async (p) => { + calls.push(p); + return { content: 'plan', path: '/p', directory: '/d', exists: true }; + }, + apiPost: async () => null as any, + apiPut: async () => null as any, + apiDelete: async () => undefined, + }); + const res = await c.getSessionPlan({ sessionId: 's1', cwd: '/c' }); + expect(res.content).toBe('plan'); + expect(calls[0]).toBe('/api/sessions/s1/plan?cwd=%2Fc'); + }); + + it('direct reads latest .md from project plan directory', async () => { + const base = join(tmpdir(), `plan-test-${Date.now()}`); + const projectDir = join(base, encodeProjectPath('/my/cwd')); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, 'first.md'), '# first'); + writeFileSync(join(projectDir, 'second.md'), '# second'); + setProjectBaseDir(base); + try { + const TestLayer = Layer.mergeAll(SessionService.Default, ProjectRuntimeService.Default); + const rt = ManagedRuntime.make(TestLayer); + const c = createDirectSessionClient(rt as any); + const res = await c.getSessionPlan({ sessionId: 's1', cwd: '/my/cwd' }); + expect(res.exists).toBe(true); + expect(res.content === '# first' || res.content === '# second').toBe(true); + } finally { + setProjectBaseDir(undefined); + } + void readFileSync; + void Effect; + void vi; + }); +}); diff --git a/packages/codingcode/test/client/http-direct-parity.test.ts b/packages/codingcode/test/client/http-direct-parity.test.ts new file mode 100644 index 00000000..e8964234 --- /dev/null +++ b/packages/codingcode/test/client/http-direct-parity.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; + +describe('http/direct sendMessage signature parity', () => { + it('http.ts sendMessage accepts (input, cwd?)', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/client/http.ts', + 'utf8' + ); + expect(src).toMatch(/sendMessage\(input: string, cwd\?: string\)/); + }); + + it('direct agent-runtime.ts exports AgentRuntimeClient with sendMessage', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/direct/agent-runtime.ts', + 'utf8' + ); + expect(src).toMatch(/sendMessage\(input,/); + }); + + it('direct agent-runtime.ts no longer uses targetCwd rename', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/direct/agent-runtime.ts', + 'utf8' + ); + expect(src).not.toMatch(/targetCwd/); + }); +}); diff --git a/packages/codingcode/test/client/missing-methods.test.ts b/packages/codingcode/test/client/missing-methods.test.ts new file mode 100644 index 00000000..19589b63 --- /dev/null +++ b/packages/codingcode/test/client/missing-methods.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'fs'; + +vi.mock('@codingcode/infra/config', () => ({ + loadConfig: () => ({ + maxSteps: 50, + maxStopContinuations: 2, + memory: { enabled: true, disabledTypes: [], extraTypes: [], model: 'test-model' }, + context: { compactionModel: 'gpt-4o-mini' }, + }), + updateMemoryModel: vi.fn(), + updateContextCompactionModel: vi.fn(), + DEFAULT_MEMORY_TYPES: [], +})); + +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { createHttpSettingsClient } from '../../src/client/http/settings.js'; +import { createDirectSettingsClient } from '../../src/direct/settings.js'; +import { ApprovalService } from '../../src/approval/index.js'; +import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { MemoryService } from '../../src/memory/index.js'; +import { McpService } from '../../src/mcp/index.js'; +import { SkillService } from '../../src/skills/service.js'; +import * as infraConfig from '@codingcode/infra/config'; + +const TestLayer = Layer.mergeAll( + Layer.succeed(SkillService, { + getAll: () => Effect.succeed([]), + findByName: () => Effect.succeed(undefined), + select: () => Effect.succeed(undefined), + selectImplicit: () => Effect.succeed(undefined), + extractSkill: () => Effect.succeed([undefined, '']), + enableSkill: () => Effect.void, + disableSkill: () => Effect.void, + listWithStatus: () => Effect.succeed([]), + evictProject: () => Effect.void, + } as any), + Layer.succeed(MemoryService, { + getMemoryEnabled: () => true, + setMemoryEnabled: () => {}, + loadMemoryForPrompt: () => '', + flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + } as any), + Layer.succeed(McpService, { + syncConnections: () => Effect.void, + connectServers: () => Effect.void, + disconnectServers: () => Effect.void, + getServerToolNames: () => [], + disconnectAll: () => Effect.void, + status: () => Effect.succeed([]), + listProjectMcpTools: () => [], + disable: () => Effect.void, + enable: () => Effect.void, + } as any), + ApprovalService.Default, + HookService.Default, + ApprovalWaitService.Default +); + +const rt = ManagedRuntime.make(TestLayer); + +describe('setMemoryModel: http + direct both implement', () => { + it('http calls POST /api/settings/memory/model', async () => { + const calls: Array<{ path: string; body: unknown }> = []; + const c = createHttpSettingsClient({ + apiGet: async () => null as any, + apiPost: async (p, b) => { + calls.push({ path: p, body: b }); + return { model: (b as { model: string }).model }; + }, + apiPut: async () => null as any, + apiDelete: async () => undefined, + }); + const res = await c.setMemoryModel('claude-3'); + expect(res.model).toBe('claude-3'); + expect(calls[0]?.path).toBe('/api/settings/memory/model'); + expect(calls[0]?.body).toEqual({ model: 'claude-3' }); + }); + + it('direct calls updateMemoryModel and returns { model }', async () => { + const c = createDirectSettingsClient(rt as any); + const res = await c.setMemoryModel('claude-3'); + expect(res.model).toBe('claude-3'); + expect(infraConfig.updateMemoryModel).toHaveBeenCalledWith('claude-3'); + }); +}); + +describe('getAgentConfig: http + direct both implement', () => { + it('http calls GET /api/settings/agent/config', async () => { + const calls: string[] = []; + const c = createHttpSettingsClient({ + apiGet: async (p) => { + calls.push(p); + return { maxSteps: 100, maxStopContinuations: 3 }; + }, + apiPost: async () => null as any, + apiPut: async () => null as any, + apiDelete: async () => undefined, + }); + const res = await c.getAgentConfig(); + expect(res.maxSteps).toBe(100); + expect(calls[0]).toBe('/api/settings/agent/config'); + }); + + it('direct returns loadConfig maxSteps/maxStopContinuations', async () => { + const c = createDirectSettingsClient(rt as any); + const res = await c.getAgentConfig(); + expect(res.maxSteps).toBe(50); + expect(res.maxStopContinuations).toBe(2); + }); +}); + +describe('setCompactionModel: http + direct both implement', () => { + it('http calls POST /api/settings/context/compaction-model', async () => { + const calls: Array<{ path: string; body: unknown }> = []; + const c = createHttpSettingsClient({ + apiGet: async () => null as any, + apiPost: async (p, b) => { + calls.push({ path: p, body: b }); + return { compactionModel: (b as { compactionModel: string }).compactionModel }; + }, + apiPut: async () => null as any, + apiDelete: async () => undefined, + }); + const res = await c.setCompactionModel('claude-haiku'); + expect(res.compactionModel).toBe('claude-haiku'); + expect(calls[0]?.path).toBe('/api/settings/context/compaction-model'); + }); + + it('direct calls updateContextCompactionModel and returns { compactionModel }', async () => { + const c = createDirectSettingsClient(rt as any); + const res = await c.setCompactionModel('claude-haiku'); + expect(res.compactionModel).toBe('claude-haiku'); + expect(infraConfig.updateContextCompactionModel).toHaveBeenCalledWith('claude-haiku'); + }); +}); + +describe('getMemoryConfig returns model field', () => { + it('http typed return includes model', async () => { + const c = createHttpSettingsClient({ + apiGet: async () => ({ enabled: true, types: [], model: 'm' }), + apiPost: async () => null as any, + apiPut: async () => null as any, + apiDelete: async () => undefined, + }); + const res = await c.getMemoryConfig(); + expect(res.model).toBe('m'); + }); +}); + +void readFileSync; diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index d2acdff1..6d4afac1 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -203,11 +203,7 @@ describe('compressor behavior', () => { '## 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 ctx.compactWithLLM(fx.transcriptPath, llm.modelInfo.maxTokens, llm); expect(result.didCompress).toBe(true); expect(result.promptEstimate).toBeGreaterThan(0); expect(result.promptEstimate).toBeLessThan(before); diff --git a/packages/codingcode/test/core/error-code.test.ts b/packages/codingcode/test/core/error-code.test.ts new file mode 100644 index 00000000..878f71b7 --- /dev/null +++ b/packages/codingcode/test/core/error-code.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { NotFoundError, AlreadyExistsError, AgentError } from '../../src/core/error.js'; + +describe('NotFoundError has code+httpStatus', () => { + it('code is NOT_FOUND and httpStatus returns 404', () => { + const err = new NotFoundError('missing'); + expect(err.code).toBe('NOT_FOUND'); + expect(err.httpStatus()).toBe(404); + }); +}); + +describe('AlreadyExistsError has code+httpStatus', () => { + it('code is ALREADY_EXISTS and httpStatus returns 409', () => { + const err = new AlreadyExistsError('exists'); + expect(err.code).toBe('ALREADY_EXISTS'); + expect(err.httpStatus()).toBe(409); + }); +}); + +describe('AgentError unchanged', () => { + it('sessionNotFound still returns SESSION_NOT_FOUND with 404', () => { + const err = AgentError.sessionNotFound('abc'); + expect(err.code).toBe('SESSION_NOT_FOUND'); + expect(err.httpStatus()).toBe(404); + }); +}); diff --git a/packages/codingcode/test/core/error.test.ts b/packages/codingcode/test/core/error.test.ts index 8aa5bf5b..ee20513e 100644 --- a/packages/codingcode/test/core/error.test.ts +++ b/packages/codingcode/test/core/error.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { AgentError } from '../../src/core/error.js'; +import { AgentError, ApiError } from '../../src/core/error.js'; describe('AgentError.httpStatus', () => { it('returns 400 for CONFIG_MISSING', () => { @@ -37,3 +37,23 @@ 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/paths.test.ts b/packages/codingcode/test/core/paths.test.ts new file mode 100644 index 00000000..8ce08702 --- /dev/null +++ b/packages/codingcode/test/core/paths.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { computePaths, projectSessionsDir, sessionJsonlPathFromCwd } from '../../src/core/paths.js'; + +describe('core/paths.ts is the single source of truth for path computation', () => { + it('does not import from session/types — no core→session dependency', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/core/paths.ts', + 'utf8' + ); + expect(src).not.toMatch(/from\s+['"]\.\.\/session\//); + }); + + it('exports computePaths, projectSessionsDir, sessionJsonlPathFromCwd', () => { + expect(typeof computePaths).toBe('function'); + expect(typeof projectSessionsDir).toBe('function'); + expect(typeof sessionJsonlPathFromCwd).toBe('function'); + }); +}); + +describe('session/file-ops.ts re-exports paths from core', () => { + it('file-ops.ts no longer defines computePaths inline', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/session/file-ops.ts', + 'utf8' + ); + expect(src).not.toMatch(/export function computePaths\s*\(/); + expect(src).not.toMatch(/export function projectSessionsDir\s*\(/); + expect(src).toMatch(/from\s+['"]\.\.\/core\/paths\.js['"]/); + }); +}); diff --git a/packages/codingcode/test/layer/system-hook-layer.test.ts b/packages/codingcode/test/layer/system-hook-layer.test.ts index a88515b5..655ff2ea 100644 --- a/packages/codingcode/test/layer/system-hook-layer.test.ts +++ b/packages/codingcode/test/layer/system-hook-layer.test.ts @@ -1,20 +1,16 @@ import { describe, it, expect } from 'vitest'; import { Effect } from 'effect'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { HookService } from '../../src/hooks/registry.js'; import { SystemHookLayer } from '../../src/layer.js'; -import { markSessionPlanMode, clearPlanModeSession } from '../../src/plan/index.js'; +import { computePaths } from '../../src/core/paths.js'; describe('SystemHookLayer', () => { it('builds without "Service not found: HookService" (regression: was a self-referential Layer.effect)', async () => { - // The previous implementation used `Layer.effect(HookService, body-yielding-HookService)` - // which Effect-TS does NOT support as a self-referential layer: the runtime - // does not place a placeholder HookService in the environment while - // building the layer, so the body's first `yield* HookService` would Die - // with "Service not found: HookService". This test would fail to even - // build the layer before the fix. const program = Effect.gen(function* () { const hooks = yield* HookService; - // touch the service to ensure it's resolvable from the build's output return typeof hooks.register; }); @@ -23,45 +19,55 @@ describe('SystemHookLayer', () => { }); it('registers the remaining plan-mode system hooks', async () => { - // After the plan approval decoupling: - // - planModeGateHook stays — it's the right abstraction for tool-allow - // policy. Registered on tool.approval.pre with priority -1000. - // - afterPlanSubmittedObserver REMOVED — plan.ready is now emitted by - // agentLoop on turn-end, not by an observer on tool.execute.after. - // - planApprovalHook REMOVED — submit_plan tool handles its own 3-option - // approval via ApprovalWaitService directly. - // - planSubagentWhitelistHook REMOVED — now an inline function - // (checkSubagentAllowedInPlanMode) called by dispatch_agent. - const program = Effect.gen(function* () { - const hooks = yield* HookService; - - // (1) planModeGateHook denies write tools in plan mode - markSessionPlanMode('s', true); - const denied = yield* hooks.emitDecision('tool.approval.pre', { - toolName: 'write_file', - args: { path: '/x' }, + const cwd = mkdtempSync(join(tmpdir(), 'codingcode-syshook-')); + try { + const paths = computePaths(cwd, 's'); + mkdirSync(paths.transcriptPath.replace(/\.jsonl$/, ''), { recursive: true }); + const idx = { sessionId: 's', - projectPath: '/p', - }); - expect(denied).not.toBeNull(); - expect(denied?.decision).toBe('deny'); - expect(denied?.reason).toMatch(/plan mode/i); - clearPlanModeSession('s'); + projectPath: paths.projectPath, + cwd: paths.cwd, + model: 'test', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 0, + title: 's', + currentTurnId: 0, + usage: undefined, + mode: 'plan', + permissionMode: 'default', + }; + writeFileSync(paths.indexPath, JSON.stringify(idx, null, 2), 'utf8'); - // (2) planModeGateHook lets submit_plan through - markSessionPlanMode('s', true); - const allowed = yield* hooks.emitDecision('tool.approval.pre', { - toolName: 'submit_plan', - args: { plan_content: '## plan' }, - sessionId: 's', - projectPath: '/p', - }); - expect(allowed).toBeNull(); - clearPlanModeSession('s'); + const program = Effect.gen(function* () { + const hooks = yield* HookService; - return true; - }); + // (1) planModeGateHook denies write tools in plan mode + const denied = yield* hooks.emitDecision('tool.approval.pre', { + toolName: 'write_file', + args: { path: '/x' }, + sessionId: 's', + projectPath: cwd, + }); + expect(denied).not.toBeNull(); + expect(denied?.decision).toBe('deny'); + expect(denied?.reason).toMatch(/plan mode/i); + + // (2) planModeGateHook lets submit_plan through + const allowed = yield* hooks.emitDecision('tool.approval.pre', { + toolName: 'submit_plan', + args: { plan_content: '## plan' }, + sessionId: 's', + projectPath: cwd, + }); + expect(allowed).toBeNull(); + + return true; + }); - await Effect.runPromise(program.pipe(Effect.provide(SystemHookLayer) as any)); + await Effect.runPromise(program.pipe(Effect.provide(SystemHookLayer) as any)); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } }); }); diff --git a/packages/codingcode/test/plan/active-sessions.test.ts b/packages/codingcode/test/plan/active-sessions.test.ts deleted file mode 100644 index 23154446..00000000 --- a/packages/codingcode/test/plan/active-sessions.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { - markSessionPlanMode, - isSessionInPlanMode, - clearPlanModeSession, -} from '../../src/plan/index.js'; - -describe('plan/active-sessions side channel', () => { - beforeEach(() => { - // Clear any leftover state between tests - clearPlanModeSession('s1'); - clearPlanModeSession('s2'); - }); - - it('starts as false for an unmarked session', () => { - expect(isSessionInPlanMode('s1')).toBe(false); - }); - - it('markSessionPlanMode(id, true) marks the session as plan mode', () => { - markSessionPlanMode('s1', true); - expect(isSessionInPlanMode('s1')).toBe(true); - }); - - it('markSessionPlanMode(id, false) unmarks a previously plan-mode session', () => { - markSessionPlanMode('s1', true); - markSessionPlanMode('s1', false); - expect(isSessionInPlanMode('s1')).toBe(false); - }); - - it('clearPlanModeSession always removes the session', () => { - markSessionPlanMode('s1', true); - clearPlanModeSession('s1'); - expect(isSessionInPlanMode('s1')).toBe(false); - }); - - it('is per-session: marking s1 does not affect s2', () => { - markSessionPlanMode('s1', true); - expect(isSessionInPlanMode('s1')).toBe(true); - expect(isSessionInPlanMode('s2')).toBe(false); - }); -}); diff --git a/packages/codingcode/test/plan/gate-pipeline.test.ts b/packages/codingcode/test/plan/gate-pipeline.test.ts index af14f461..64bb2d00 100644 --- a/packages/codingcode/test/plan/gate-pipeline.test.ts +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -1,16 +1,19 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer } from 'effect'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { runPipeline } from '../../src/approval/pipeline.js'; import { createRuleEngine } from '../../src/approval/rule-engine.js'; import { READONLY_TOOL_NAMES } from '../../src/approval/presets.js'; import { HookService } from '../../src/hooks/registry.js'; import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { - planModeGateHook, - markSessionPlanMode, - clearPlanModeSession, -} from '../../src/plan/index.js'; +import { planModeGateHook } from '../../src/plan/index.js'; +import { computePaths } from '../../src/core/paths.js'; import type { DecisionHandler } from '../../src/hooks/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); const decisionHandlers: DecisionHandler[] = []; @@ -39,7 +42,6 @@ const mockHookService = { disposeProject: () => Effect.succeed(undefined), }; -// Capture the payload of emitApprovalRequest so we can verify the Layer 4 → Layer 5 handoff let capturedApproval: any = null; function makeMockApprovalWait() { @@ -58,19 +60,40 @@ function makeMockApprovalWait() { }; } +function makeIndex(cwd: string, sessionId: string, mode: 'plan' | 'build') { + const paths = computePaths(cwd, sessionId); + mkdirSync(paths.transcriptPath.replace(/\.jsonl$/, ''), { recursive: true }); + const idx = { + sessionId, + projectPath: paths.projectPath, + cwd: paths.cwd, + model: 'test', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 0, + title: sessionId.slice(0, 8), + currentTurnId: 0, + usage: undefined, + mode, + permissionMode: 'default', + }; + writeFileSync(paths.indexPath, JSON.stringify(idx, null, 2), 'utf8'); +} + function runPipelineWithMock(opts: { tool: string; input: any; permissionMode: 'default' | 'acceptEdits' | 'bypass'; sessionId: string; planMode: boolean; + cwd: string; }) { capturedApproval = null; decisionHandlers.length = 0; decisionHandlers.push(planModeGateHook); - if (opts.planMode) markSessionPlanMode(opts.sessionId, true); - else markSessionPlanMode(opts.sessionId, false); + if (opts.planMode) makeIndex(opts.cwd, opts.sessionId, 'plan'); + else makeIndex(opts.cwd, opts.sessionId, 'build'); const mockWait = makeMockApprovalWait(); const HookTestLayer = Layer.succeed(HookService, mockHookService as any); @@ -85,16 +108,22 @@ function runPipelineWithMock(opts: { destructiveTools: new Set(), permissionMode: opts.permissionMode, sessionId: opts.sessionId, + projectPath: opts.cwd, } ).pipe(Effect.provide(TestLayer) as any) ); } -describe('Plan mode gate hook integration (planApprovalHook removed — submit_plan self-handles)', () => { +describe('Plan mode gate hook integration', () => { + let cwd: string; beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'codingcode-gate-pipeline-')); capturedApproval = null; decisionHandlers.length = 0; }); + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); it('plan mode + write_file: gate denies before reaching user confirmation', async () => { const decision: any = await runPipelineWithMock({ @@ -103,13 +132,11 @@ describe('Plan mode gate hook integration (planApprovalHook removed — submit_p permissionMode: 'default', sessionId: 's2', planMode: true, + cwd, }); - // Gate denied, so no user confirmation fired. expect(decision.type).toBe('deny'); expect(decision.reason).toMatch(/plan mode/i); expect(capturedApproval).toBeNull(); - - clearPlanModeSession('s2'); }); it('plan mode + execute_command: gate denies with plan-mode reason', async () => { @@ -119,30 +146,24 @@ describe('Plan mode gate hook integration (planApprovalHook removed — submit_p permissionMode: 'default', sessionId: 's3', planMode: true, + cwd, }); expect(decision.type).toBe('deny'); expect(decision.reason).toMatch(/plan mode/i); expect(capturedApproval).toBeNull(); - - clearPlanModeSession('s3'); }); - it('plan mode + dispatch_agent: gate lets it through (subagent-whitelist inline at dispatch time)', async () => { + it('plan mode + dispatch_agent: gate lets it through', async () => { const decision: any = await runPipelineWithMock({ tool: 'dispatch_agent', input: { agent: 'build', prompt: 'do something' }, permissionMode: 'default', sessionId: 's4', planMode: true, + cwd, }); - // The gate does not deny dispatch_agent (it's in PLAN_MODE_ALLOWED_TOOLS). - // The pipeline may short-circuit at Layer 2 (readonly-whitelist) since - // dispatch_agent is in READONLY_TOOL_NAMES. The subagent-whitelist check - // is now inline in dispatch_agent (not a hook) and runs at dispatch time. expect(decision.type).toBe('allow'); expect(decision.type).not.toBe('deny'); - - clearPlanModeSession('s4'); }); it('build mode + write_file: gate does not fire, pipeline falls through normally', async () => { @@ -152,30 +173,23 @@ describe('Plan mode gate hook integration (planApprovalHook removed — submit_p permissionMode: 'default', sessionId: 's5', planMode: false, + cwd, }); - // build mode: write_file is not in any allowlist, pipeline reaches user confirm expect(capturedApproval).not.toBeNull(); expect(decision.source).toBe('user-confirm'); - - clearPlanModeSession('s5'); }); - it('submit_plan: pipeline short-circuits at Layer 5 (no 2-option modal)', async () => { - // The plan approval is no longer triggered by a hook. The pipeline - // recognizes submit_plan by name at Layer 5 and short-circuits with - // 'allow' + source 'system-plan-self-handles'. The plan modal is - // driven by submit_plan.execute itself, not by the pipeline. + it('submit_plan: pipeline short-circuits at Layer 5', async () => { const decision: any = await runPipelineWithMock({ tool: 'submit_plan', input: { plan_content: '# plan' }, permissionMode: 'default', sessionId: 's6', planMode: true, + cwd, }); expect(decision.type).toBe('allow'); expect(decision.source).toBe('system-plan-self-handles'); expect(capturedApproval).toBeNull(); - - clearPlanModeSession('s6'); }); }); diff --git a/packages/codingcode/test/plan/gate.test.ts b/packages/codingcode/test/plan/gate.test.ts index 63ec5a74..6996a520 100644 --- a/packages/codingcode/test/plan/gate.test.ts +++ b/packages/codingcode/test/plan/gate.test.ts @@ -1,17 +1,46 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { - planModeGateHook, - markSessionPlanMode, - clearPlanModeSession, -} from '../../src/plan/index.js'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { planModeGateHook, isSessionInPlanMode } from '../../src/plan/index.js'; +import { computePaths } from '../../src/core/paths.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +function makeSessionIndex(cwd: string, sessionId: string, mode: 'plan' | 'build') { + const paths = computePaths(cwd, sessionId); + mkdirSync(paths.transcriptPath.replace(/\.jsonl$/, ''), { recursive: true }); + const idx = { + sessionId, + projectPath: paths.projectPath, + cwd: paths.cwd, + model: 'test', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 0, + title: sessionId.slice(0, 8), + currentTurnId: 0, + usage: undefined, + mode, + permissionMode: 'default', + }; + writeFileSync(paths.indexPath, JSON.stringify(idx, null, 2), 'utf8'); + return paths; +} describe('planModeGateHook', () => { + let cwd: string; + let sessionId: string; + beforeEach(() => { - clearPlanModeSession('sess'); + cwd = join(base.dir, 'gate'); + mkdirSync(cwd, { recursive: true }); + sessionId = 'sess-gate'; }); afterEach(() => { - clearPlanModeSession('sess'); + rmSync(cwd, { recursive: true, force: true }); }); it('returns null when no sessionId is present', () => { @@ -19,29 +48,37 @@ describe('planModeGateHook', () => { }); it('returns null when the session is not in plan mode', () => { - expect(planModeGateHook({ toolName: 'write_file', sessionId: 'sess' } as any)).toBeNull(); + makeSessionIndex(cwd, sessionId, 'build'); + expect( + planModeGateHook({ toolName: 'write_file', sessionId, projectPath: cwd } as any) + ).toBeNull(); }); it('returns null when the tool is not provided', () => { - markSessionPlanMode('sess', true); - expect(planModeGateHook({ sessionId: 'sess' } as any)).toBeNull(); + makeSessionIndex(cwd, sessionId, 'plan'); + expect(planModeGateHook({ sessionId, projectPath: cwd } as any)).toBeNull(); }); it('allows submit_plan in plan mode', () => { - markSessionPlanMode('sess', true); - expect(planModeGateHook({ toolName: 'submit_plan', sessionId: 'sess' } as any)).toBeNull(); + makeSessionIndex(cwd, sessionId, 'plan'); + expect( + planModeGateHook({ toolName: 'submit_plan', sessionId, projectPath: cwd } as any) + ).toBeNull(); }); - it('allows dispatch_agent in plan mode (subagent-whitelist hook further restricts)', () => { - markSessionPlanMode('sess', true); - expect(planModeGateHook({ toolName: 'dispatch_agent', sessionId: 'sess' } as any)).toBeNull(); + it('allows dispatch_agent in plan mode', () => { + makeSessionIndex(cwd, sessionId, 'plan'); + expect( + planModeGateHook({ toolName: 'dispatch_agent', sessionId, projectPath: cwd } as any) + ).toBeNull(); }); it('denies write_file in plan mode with the plan-mode reason', () => { - markSessionPlanMode('sess', true); + makeSessionIndex(cwd, sessionId, 'plan'); const result = planModeGateHook({ toolName: 'write_file', - sessionId: 'sess', + sessionId, + projectPath: cwd, } as any); expect(result).toEqual({ decision: 'deny', @@ -49,22 +86,51 @@ describe('planModeGateHook', () => { }); }); - it('denies execute_command in plan mode', async () => { - markSessionPlanMode('sess', true); - const result = await planModeGateHook({ + it('denies execute_command in plan mode', () => { + makeSessionIndex(cwd, sessionId, 'plan'); + const result = planModeGateHook({ toolName: 'execute_command', - sessionId: 'sess', + sessionId, + projectPath: cwd, } as any); expect(result?.decision).toBe('deny'); expect(result?.reason).toMatch(/plan mode/i); }); - it('denies edit_file in plan mode', async () => { - markSessionPlanMode('sess', true); - const result = await planModeGateHook({ + it('denies edit_file in plan mode', () => { + makeSessionIndex(cwd, sessionId, 'plan'); + const result = planModeGateHook({ toolName: 'edit_file', - sessionId: 'sess', + sessionId, + projectPath: cwd, } as any); expect(result?.decision).toBe('deny'); }); }); + +describe('isSessionInPlanMode', () => { + let cwd: string; + + beforeEach(() => { + cwd = join(base.dir, 'is-session-in-plan'); + mkdirSync(cwd, { recursive: true }); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('returns true when index has mode=plan', () => { + makeSessionIndex(cwd, 's-plan', 'plan'); + expect(isSessionInPlanMode('s-plan', cwd)).toBe(true); + }); + + it('returns false when index has mode=build', () => { + makeSessionIndex(cwd, 's-build', 'build'); + expect(isSessionInPlanMode('s-build', cwd)).toBe(false); + }); + + it('returns false when index file does not exist', () => { + expect(isSessionInPlanMode('s-missing', cwd)).toBe(false); + }); +}); diff --git a/packages/codingcode/test/runtime/set-session-profile.test.ts b/packages/codingcode/test/runtime/set-session-profile.test.ts index b3c08dee..b4be41b8 100644 --- a/packages/codingcode/test/runtime/set-session-profile.test.ts +++ b/packages/codingcode/test/runtime/set-session-profile.test.ts @@ -45,12 +45,20 @@ function makeLayer() { const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); const SessionTestLayer = SessionService.Default; const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookTestLayer, McpTestLayer, SubagentTestLayer, RulesTestLayer, SessionTestLayer)) + Layer.provide( + Layer.mergeAll( + HookTestLayer, + McpTestLayer, + SubagentTestLayer, + RulesTestLayer, + SessionTestLayer + ) + ) ); return Layer.mergeAll(ProjectRuntimeTestLayer, SessionTestLayer); } -describe('ProjectRuntimeService.setSessionProfile', () => { +describe('ProjectRuntimeService.setSessionProfile (disk-only)', () => { let cwd: string; let sessionId: string; let indexPath: string; @@ -81,64 +89,42 @@ describe('ProjectRuntimeService.setSessionProfile', () => { await rt.dispose(); }); - it('does NOT write idx.permissionMode when switching to plan (preserves build preference)', async () => { - // Plan mode: in-memory map is forced to 'default', but the on-disk - // SessionIndex.permissionMode is left untouched so the build preference - // survives plan→build transitions. + it('writes mode + permissionMode + activeProfile when switching to plan', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); }) ); - expect(existsSync(indexPath)).toBe(true); const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - // build preference (default from create) is preserved on disk + expect(idx.mode).toBe('plan'); expect(idx.permissionMode).toBe('default'); - // runtime memory is forced to 'default' - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); - }) - ); + expect(idx.activeProfile).toBe('plan'); }); - it('writes idx.permissionMode AND idx.activeProfile when switching to build', async () => { + it('writes mode + permissionMode + activeProfile when switching to build (with override)', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE, 'bypass'); }) ); - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.mode).toBe('build'); expect(idx.permissionMode).toBe('bypass'); expect(idx.activeProfile).toBe('build'); }); - it('records profile in runtime memory (getSessionProfile returns it)', async () => { - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - const profile = runtime.getSessionProfile(sessionId); - expect(profile?.name).toBe('plan'); - expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); - }) - ); - }); - - it('explore profile (with explicit permissionMode=bypass) writes correctly', async () => { + it('writes activeProfile when switching to explore', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; yield* runtime.setSessionProfile(cwd, sessionId, EXPLORE_PROFILE); - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.permissionMode).toBe('bypass'); - expect(idx.activeProfile).toBe('explore'); }) ); + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.permissionMode).toBe('bypass'); + expect(idx.activeProfile).toBe('explore'); }); }); diff --git a/packages/codingcode/test/scheduler/approval-bypass.test.ts b/packages/codingcode/test/scheduler/approval-bypass.test.ts new file mode 100644 index 00000000..201ac8bd --- /dev/null +++ b/packages/codingcode/test/scheduler/approval-bypass.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; + +describe('scheduler uses real forked ApprovalService', () => { + it('scheduler/service.ts no longer passes literal { permissionMode: "bypass" } as approvalOverride', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/scheduler/service.ts', + 'utf8' + ); + expect(src).not.toMatch(/approvalOverride:\s*\{\s*permissionMode:\s*['"]bypass['"]\s*\}/); + }); + + it('scheduler imports ApprovalService', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/scheduler/service.ts', + 'utf8' + ); + expect(src).toMatch(/import\s*\{[^}]*ApprovalService[^}]*\}\s*from\s*['"]\.\.\/approval\/index\.js['"]/); + }); + + it('scheduler resolves ApprovalService and forks with bypass', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/scheduler/service.ts', + 'utf8' + ); + expect(src).toMatch(/yield\*\s*ApprovalService/); + expect(src).toMatch(/\.fork\(\s*\{\s*permissionMode:\s*['"]bypass['"]\s*\}\s*\)/); + }); +}); diff --git a/packages/codingcode/test/security/plan-mode-restart.test.ts b/packages/codingcode/test/security/plan-mode-restart.test.ts index 632072b9..2a2d7a91 100644 --- a/packages/codingcode/test/security/plan-mode-restart.test.ts +++ b/packages/codingcode/test/security/plan-mode-restart.test.ts @@ -11,12 +11,7 @@ import { SubagentService } from '../../src/subagent/registry.js'; import { RulesService } from '../../src/rules/index.js'; import { ApprovalService } from '../../src/approval/index.js'; import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { - planModeGateHook, - markSessionPlanMode, - clearPlanModeSession, - isSessionInPlanMode, -} from '../../src/plan/index.js'; +import { planModeGateHook, isSessionInPlanMode } from '../../src/plan/index.js'; import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; import type { DecisionHandler } from '../../src/hooks/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -108,7 +103,7 @@ function makeLayer() { return TestLayer; } -describe('plan mode security boundary (cross-restart)', () => { +describe('plan mode security boundary (cross-restart, disk only)', () => { let cwd: string; let sessionId: string; let indexPath: string; @@ -137,21 +132,17 @@ describe('plan mode security boundary (cross-restart)', () => { afterEach(async () => { await rt.dispose(); rmSync(cwd, { recursive: true, force: true }); - clearPlanModeSession(sessionId); }); - // Helper: simulate the real sendMessage path — fork approval, set the - // session's permission mode (from the runtime's in-memory map), then evaluate. - // The plan-mode side channel is kept in sync by `setSessionProfile`, so the - // gate hook fires correctly even via the approval pipeline. async function evaluateAsSession(tool: string, input: any): Promise { return rt.runPromise( Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; const approval = yield* ApprovalService; - const mode = runtime.getSessionPermissionMode(sessionId); - const forked = yield* approval.fork({}); - yield* forked.setPermissionMode(mode); + const mode = yield* Effect.sync(() => { + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + return idx.permissionMode; + }); + const forked = yield* approval.fork({ permissionMode: mode }); return yield* forked.evaluate({ tool, input, @@ -170,8 +161,7 @@ describe('plan mode security boundary (cross-restart)', () => { yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); }) ); - // setSessionProfile also marks the plan-mode side channel - expect(isSessionInPlanMode(sessionId)).toBe(true); + expect(isSessionInPlanMode(sessionId, cwd)).toBe(true); const decision = await evaluateAsSession('write_file', { path: '/tmp/x', content: 'foo' }); expect(decision.type).toBe('deny'); @@ -194,7 +184,7 @@ describe('plan mode security boundary (cross-restart)', () => { expect(decision.source).toBe('hook'); }); - it('scenario 3: switch to plan, submit_plan is short-circuited by the pipeline (self-handles plan approval)', async () => { + it('scenario 3: switch to plan, submit_plan is short-circuited by the pipeline', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; @@ -204,18 +194,11 @@ describe('plan mode security boundary (cross-restart)', () => { ); const decision: any = await evaluateAsSession('submit_plan', { plan_content: 'do things' }); - // submit_plan is in PLAN_MODE_ALLOWED_TOOLS, so the gate does not fire. - // The pipeline recognizes submit_plan by name at Layer 5 and short-circuits - // to 'allow' with source 'system-plan-self-handles'. The plan modal is - // driven by agentLoop emitting plan.ready on turn-end, not by the pipeline. expect(decision.type).toBe('allow'); expect(decision.source).toBe('system-plan-self-handles'); }); it('scenario 4: after restart (state reloaded from disk), plan mode still enforced', async () => { - // First: switch to plan. In the new design, plan mode does NOT write - // idx.permissionMode (the build preference is preserved on disk), - // but the in-memory planModeSessions side channel is updated. await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; @@ -224,27 +207,19 @@ describe('plan mode security boundary (cross-restart)', () => { }) ); - // After plan: permissionMode is preserved (build preference from create). const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.permissionMode).toBe('default'); + expect(idx.mode).toBe('plan'); - // Simulate restart: build a new runtime, load state, restore profile. await rt.dispose(); decisionHandlers.length = 0; decisionHandlers.push(planModeGateHook); rt = ManagedRuntime.make(makeLayer() as any); await rt.runPromise( Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; const session = yield* SessionService; - yield* runtime.prepareProject(cwd); const state = yield* session.load(cwd, sessionId); - // state.mode is 'build' (set by create; plan mode didn't write to disk) - expect(state.mode).toBe('build'); - // To re-enter plan mode after restart, the client calls setSessionMode. - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - // After restore, the plan-mode side channel is re-marked. - expect(isSessionInPlanMode(sessionId)).toBe(true); + expect(state.mode).toBe('plan'); + expect(isSessionInPlanMode(sessionId, cwd)).toBe(true); }) ); @@ -262,11 +237,9 @@ describe('plan mode security boundary (cross-restart)', () => { yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE); }) ); - // After switching to build, the plan-mode side channel is cleared. - expect(isSessionInPlanMode(sessionId)).toBe(false); + expect(isSessionInPlanMode(sessionId, cwd)).toBe(false); const decision: any = await evaluateAsSession('write_file', { path: '/tmp/x', content: 'foo' }); - // Gate no longer fires; pipeline falls through to user confirm (no emitter → system deny). if (decision.type === 'deny') { expect(decision.source).not.toBe('hook'); expect(decision.reason).not.toMatch(/plan mode/i); diff --git a/packages/codingcode/test/server/agent-routes.test.ts b/packages/codingcode/test/server/agent-routes.test.ts deleted file mode 100644 index 5442905a..00000000 --- a/packages/codingcode/test/server/agent-routes.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; -import { createAgentRouter } from '../../src/server/routes/agent.js'; -import { ApprovalService } from '../../src/approval/index.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { HookService } from '../../src/hooks/registry.js'; - -const MockApprovalLayer = ApprovalService.Default.pipe( - Layer.provide(Layer.mergeAll(HookService.Default, ApprovalWaitService.Default)) -); - -const TestLayer = Layer.mergeAll( - MockApprovalLayer, - HookService.Default, - ApprovalWaitService.Default -); - -const rt = ManagedRuntime.make(TestLayer); -const agentRouter = createAgentRouter(rt); - -describe('GET /permission-mode', () => { - it('returns 200 with current permission mode', async () => { - const res = await agentRouter.request('/permission-mode'); - expect(res.status).toBe(200); - const body = (await res.json()) as { mode: string }; - expect(body).toHaveProperty('mode'); - expect(typeof body.mode).toBe('string'); - }); -}); - -describe('POST /permission-mode', () => { - it('returns 200 for valid mode', async () => { - const res = await agentRouter.request('/permission-mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'default' }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { mode: string }; - expect(body.mode).toBe('default'); - }); - - it('returns 400 for invalid mode', async () => { - const res = await agentRouter.request('/permission-mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: 'invalid_mode' }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain('Invalid mode'); - }); -}); - -describe('old /api/agent/* routes now return 404', () => { - it('GET /skills returns 404', async () => { - const res = await agentRouter.request('/skills'); - expect(res.status).toBe(404); - }); - - it('GET /mcp returns 404', async () => { - const res = await agentRouter.request('/mcp'); - expect(res.status).toBe(404); - }); - - it('GET /subagent returns 404', async () => { - const res = await agentRouter.request('/subagent'); - expect(res.status).toBe(404); - }); - - it('GET /memory returns 404', async () => { - const res = await agentRouter.request('/memory'); - expect(res.status).toBe(404); - }); -}); 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 9745c9c1..c926bdf6 100644 --- a/packages/codingcode/test/server/create-session-active-profile.test.ts +++ b/packages/codingcode/test/server/create-session-active-profile.test.ts @@ -140,7 +140,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { expect(idx.permissionMode).toBe('bypass'); }); - it('rejects plan mode with non-default permissionMode', async () => { + it('allows plan mode with any permissionMode (plan no longer overrides perm)', async () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -151,7 +151,19 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { model: 'gpt-4', }), }); - expect(res.status).toBe(400); + expect(res.status).toBe(200); + const { sessionId } = await res.json(); + const indexPath = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + return state.indexPath; + }) + ); + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.mode).toBe('plan'); + expect(idx.permissionMode).toBe('bypass'); + expect(idx.activeProfile).toBe('plan'); }); it('rejects missing model', async () => { @@ -172,7 +184,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { expect(res.status).toBe(400); }); - it('new session with plan: state.mode is set, getSessionPermissionMode returns default', async () => { + it('new session with plan: state.mode, activeProfile, permissionMode are all on disk', async () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -188,14 +200,11 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { await rt.runPromise( Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); expect(state.mode).toBe('plan'); - const profile = runtime.getSessionProfile(sessionId); - expect(profile?.name).toBe('plan'); - // plan-mode forces in-memory permissionMode to 'default' - expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); + expect(state.permissionMode).toBe('default'); + expect(state.activeProfile).toBe('plan'); }) ); }); diff --git a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts new file mode 100644 index 00000000..2192a3d9 --- /dev/null +++ b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts @@ -0,0 +1,141 @@ +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 { tmpdir } from 'os'; +import { join } from 'path'; +import { createMessagesRouter } from '../../src/server/routes/messages.js'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { SessionService } from '../../src/session/store.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { McpService } from '../../src/mcp/index.js'; +import { SubagentService } from '../../src/subagent/registry.js'; +import { RulesService } from '../../src/rules/index.js'; +import { ApprovalService } from '../../src/approval/index.js'; +import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; +import { LLMFactoryService } from '../../src/llm/factory.js'; +import { WorkspaceService } from '../../src/core/workspace.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +useTempProjectBase(); + +const mockHookService = { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), +}; + +const mockMcpService = { + syncConnections: () => Effect.succeed(undefined), + connectServers: () => Effect.succeed(undefined), + listProjectMcpTools: () => [], + disposeSession: () => Effect.succeed(undefined), +} as any; + +const mockRulesService = { + getAllRules: () => '', + evictProjectRules: () => undefined, +} as any; + +const mockApprovalWaitService = { + waitForConfirm: () => Effect.dieMessage('not implemented'), + resolveConfirm: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + emitApprovalRequest: () => Effect.succeed(undefined), + registerEmitter: () => Effect.succeed(undefined), + delegateEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), +}; + +const mockLLMFactory = { + getLLMClient: () => Effect.dieMessage('not used in this test'), + listModels: () => Effect.succeed([]), + getActiveEntry: () => Effect.dieMessage('not used'), + findModel: () => Effect.succeed(null), + createClient: () => Effect.dieMessage('not used'), +} as any; + +const mockWorkspace = { + resolveWorkspaceCwd: (cwd: string | undefined) => Effect.succeed(cwd || '/tmp'), +} as any; + +function makeLayer() { + return Layer.mergeAll( + ProjectRuntimeService.Default.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HookService, mockHookService as any), + Layer.succeed(McpService, mockMcpService), + SubagentService.Default, + Layer.succeed(RulesService, mockRulesService), + SessionService.Default + ) + ) + ), + SessionService.Default, + Layer.succeed(HookService, mockHookService as any), + Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any), + ApprovalService.Default.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HookService, mockHookService as any), + Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) + ) + ) + ), + Layer.succeed(LLMFactoryService, mockLLMFactory as any), + Layer.succeed(WorkspaceService, mockWorkspace as any) + ); +} + +describe('POST /api/sessions/:id/messages — reads permissionMode from disk', () => { + let cwd: string; + let sessionId: string; + let rt: ManagedRuntime.ManagedRuntime; + let app: Hono; + + beforeEach(async () => { + cwd = mkdtempSync(join(tmpdir(), 'codingcode-msg-fork-')); + rt = ManagedRuntime.make(makeLayer() as any); + const state = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.create(cwd, { + model: 'm', + mode: 'build', + permissionMode: 'default', + }); + }) + ); + sessionId = state.sessionId; + const indexPath = state.indexPath; + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + idx.permissionMode = 'bypass'; + writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); + + app = new Hono(); + app.route('/api', createMessagesRouter(rt)); + }); + + afterEach(async () => { + await rt.dispose(); + rmSync(cwd, { recursive: true, force: true }); + }); + + it('does not crash and reaches the sendMessage path (fork uses disk permissionMode)', async () => { + const res = await app.request('/api/sessions/' + sessionId + '/messages', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ input: 'hello', cwd }), + }); + expect(res.status).not.toBe(404); + }); +}); diff --git a/packages/codingcode/test/server/plan-mode-reject-perm-mode.test.ts b/packages/codingcode/test/server/plan-mode-reject-perm-mode.test.ts deleted file mode 100644 index 4c912796..00000000 --- a/packages/codingcode/test/server/plan-mode-reject-perm-mode.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; -import { Hono } from 'hono'; -import { mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { SessionService } from '../../src/session/store.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { McpService } from '../../src/mcp/index.js'; -import { SubagentService } from '../../src/subagent/registry.js'; -import { RulesService } from '../../src/rules/index.js'; -import { ApprovalService } from '../../src/approval/index.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { createAgentRouter } from '../../src/server/routes/agent.js'; -import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; -import { useTempProjectBase } from '../helpers/project-base.js'; - -useTempProjectBase(); - -const mockHookService = { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), - emit: () => Effect.succeed(undefined), - emitDecision: () => Effect.succeed(null), - reloadUserHooks: () => Effect.succeed(undefined), - attachSessionHooks: () => Effect.succeed(undefined), - disableHook: () => Effect.succeed(undefined), - enableHook: () => Effect.succeed(undefined), - disposeSession: () => Effect.succeed(undefined), - disposeProject: () => Effect.succeed(undefined), -}; - -const mockMcpService = { - syncConnections: () => Effect.succeed(undefined), - connectServers: () => Effect.succeed(undefined), - listProjectMcpTools: () => [], - disposeSession: () => Effect.succeed(undefined), -} as any; - -const mockRulesService = { - getAllRules: () => '', - evictProjectRules: () => undefined, -} as any; - -const mockApprovalWaitService = { - waitForConfirm: () => Effect.dieMessage('not implemented'), - resolveConfirm: () => Effect.succeed(false), - getPending: () => Effect.succeed([]), - emitApprovalRequest: () => Effect.succeed(undefined), - registerEmitter: () => Effect.succeed(undefined), - delegateEmitter: () => Effect.succeed(undefined), - unregisterEmitter: () => Effect.succeed(undefined), - hasEmitter: () => Effect.succeed(false), -}; - -describe('POST /api/agent/permission-mode rejects when session is in plan mode', () => { - let cwd: string; - let sessionId: string; - let rt: ManagedRuntime.ManagedRuntime; - let app: Hono; - - beforeEach(async () => { - cwd = mkdtempSync(join(tmpdir(), 'codingcode-server-test-')); - const HookTestLayer = Layer.succeed(HookService, mockHookService as any); - const McpTestLayer = Layer.succeed(McpService, mockMcpService); - const SubagentTestLayer = SubagentService.Default; - const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); - const SessionTestLayer = SessionService.Default; - const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( - Layer.provide( - Layer.mergeAll( - HookTestLayer, - McpTestLayer, - SubagentTestLayer, - RulesTestLayer, - SessionTestLayer - ) - ) - ); - const ApprovalTestLayer = ApprovalService.Default.pipe( - Layer.provide( - Layer.mergeAll( - HookTestLayer, - Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) - ) - ) - ); - const TestLayer = Layer.mergeAll( - ProjectRuntimeTestLayer, - SessionTestLayer, - HookTestLayer, - ApprovalTestLayer, - Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) - ); - rt = ManagedRuntime.make(TestLayer as any); - app = new Hono(); - app.route('/api/agent', createAgentRouter(rt)); - - sessionId = await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - const session = yield* SessionService; - yield* runtime.prepareProject(cwd); - const state = yield* session.create(cwd, { - model: 'test-model', - mode: 'build', - permissionMode: 'default', - }); - return state.sessionId; - }) - ); - }); - - afterEach(async () => { - await rt.dispose(); - rmSync(cwd, { recursive: true, force: true }); - }); - - it('returns 409 when session is in plan profile', async () => { - // Switch session to plan - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - }) - ); - - const res = await app.request('/api/agent/permission-mode', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ mode: 'bypass', cwd, sessionId }), - }); - expect(res.status).toBe(409); - const body = await res.json(); - expect(body.error).toMatch(/plan mode/i); - }); - - it('allows the change when session is in build profile', async () => { - // Switch to build (default) - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE); - }) - ); - - const res = await app.request('/api/agent/permission-mode', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ mode: 'bypass', cwd, sessionId }), - }); - expect(res.status).toBe(200); - }); - - it('falls back to global when cwd+sessionId not provided (legacy clients)', async () => { - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - }) - ); - - // No cwd/sessionId — bypass check, change applies to global ApprovalService - const res = await app.request('/api/agent/permission-mode', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ mode: 'bypass' }), - }); - expect(res.status).toBe(200); - }); - - it('rejects invalid mode value with 400', async () => { - const res = await app.request('/api/agent/permission-mode', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ mode: 'invalid', cwd, sessionId }), - }); - expect(res.status).toBe(400); - }); -}); diff --git a/packages/codingcode/test/server/routes-use-compute-paths.test.ts b/packages/codingcode/test/server/routes-use-compute-paths.test.ts new file mode 100644 index 00000000..69d2099c --- /dev/null +++ b/packages/codingcode/test/server/routes-use-compute-paths.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; + +describe('server routes use computePaths not hand-rolled replace', () => { + it('server/routes/sessions.ts no longer uses sessionJsonlPathFromCwd + replace .jsonl/.index.json', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/server/routes/sessions.ts', + 'utf8' + ); + expect(src).not.toMatch(/sessionJsonlPathFromCwd\([^)]+\)\.replace\(['"]\.jsonl['"]/); + }); + + it('server/routes/messages.ts uses computePaths(cwd, sessionId).indexPath', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/server/routes/messages.ts', + 'utf8' + ); + expect(src).toMatch(/computePaths\([^)]+\)\.indexPath/); + expect(src).not.toMatch(/sessionJsonlPathFromCwd\(/); + }); +}); diff --git a/packages/codingcode/test/session/compute-paths.test.ts b/packages/codingcode/test/session/compute-paths.test.ts index 338ed372..3c263891 100644 --- a/packages/codingcode/test/session/compute-paths.test.ts +++ b/packages/codingcode/test/session/compute-paths.test.ts @@ -8,11 +8,10 @@ import { computePaths, sessionJsonlPathFromCwd, projectSessionsDir, -} from '../../src/session/file-ops.js'; +} from '../../src/core/paths.js'; import { normalizePath, encodeProjectPath } from '../../src/core/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; - const base = useTempProjectBase(); function run(eff: Effect.Effect): Promise { @@ -87,13 +86,17 @@ describe('computePaths', () => { const childState = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(cwd, { - model: 'subagent-model', - mode: 'build', - permissionMode: 'default', - }, { - parentSessionId: state.sessionId, - }); + return yield* svc.create( + cwd, + { + model: 'subagent-model', + mode: 'build', + permissionMode: 'default', + }, + { + parentSessionId: state.sessionId, + } + ); }) ); diff --git a/packages/codingcode/test/session/create-active-profile.test.ts b/packages/codingcode/test/session/create-active-profile.test.ts new file mode 100644 index 00000000..2daf2e34 --- /dev/null +++ b/packages/codingcode/test/session/create-active-profile.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { encodeProjectPath } from '../../src/core/path.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +describe('create writes activeProfile in one updateIndex', () => { + it('top-level create with explicit activeProfile writes once, no separate setActiveProfile IO', async () => { + const cwd = '/tmp/test-active-profile-once'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.createSessionWithProfile( + cwd, + { model: 'gpt-4o', mode: 'build', permissionMode: 'default' }, + { activeProfile: 'custom-profile' } + ); + }) + ); + + const idx = JSON.parse(readFileSync(state.indexPath, 'utf8')); + expect(idx.activeProfile).toBe('custom-profile'); + expect(idx.mode).toBe('build'); + expect(idx.permissionMode).toBe('default'); + void base; + void randomUUID; + void join; + void encodeProjectPath; + }); + + it('create without activeProfile in opts falls back to modeToProfile default', async () => { + const cwd = '/tmp/test-active-profile-default'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.createSessionWithProfile(cwd, { + model: 'gpt-4o', + mode: 'plan', + permissionMode: 'default', + }); + }) + ); + + const idx = JSON.parse(readFileSync(state.indexPath, 'utf8')); + expect(idx.activeProfile).toBe('plan'); + expect(idx.mode).toBe('plan'); + }); + + it('switch profile via setActiveProfile then record preserves new activeProfile (no stale overwrite)', async () => { + const cwd = '/tmp/test-active-profile-switch'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.createSessionWithProfile(cwd, { + model: 'gpt-4o', + mode: 'build', + permissionMode: 'default', + }); + }) + ); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + yield* svc.setActiveProfile(cwd, state.sessionId, 'explore'); + }) + ); + + const after = JSON.parse(readFileSync(state.indexPath, 'utf8')); + expect(after.activeProfile).toBe('explore'); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + yield* svc.recordUser(state, 'hello'); + }) + ); + + const afterRecord = JSON.parse(readFileSync(state.indexPath, 'utf8')); + expect(afterRecord.activeProfile).toBe('explore'); + }); +}); diff --git a/packages/codingcode/test/session/create-session-with-profile.test.ts b/packages/codingcode/test/session/create-session-with-profile.test.ts new file mode 100644 index 00000000..5dbcab6d --- /dev/null +++ b/packages/codingcode/test/session/create-session-with-profile.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +describe('createSessionWithProfile helper', () => { + it('modeToProfile default activeProfile when not overridden', async () => { + const cwd = '/tmp/test-cswp-default'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.createSessionWithProfile(cwd, { + model: 'gpt-4o', + mode: 'plan', + permissionMode: 'default', + }); + }) + ); + expect(state.activeProfile).toBe('plan'); + }); + + it('explicit activeProfile in opts overrides modeToProfile default', async () => { + const cwd = '/tmp/test-cswp-override'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.createSessionWithProfile( + cwd, + { model: 'gpt-4o', mode: 'build', permissionMode: 'default' }, + { activeProfile: 'explore' } + ); + }) + ); + expect(state.activeProfile).toBe('explore'); + expect(state.mode).toBe('build'); + }); +}); + +describe('setSessionProfile 5th param removed', () => { + it('runtime/project-runtime.ts no longer accepts _parentSessionId in setSessionProfile', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/runtime/project-runtime.ts', + 'utf8' + ); + expect(src).not.toMatch(/_parentSessionId\?:/); + }); +}); + +void base; diff --git a/packages/codingcode/test/session/disk-setters.test.ts b/packages/codingcode/test/session/disk-setters.test.ts new file mode 100644 index 00000000..ae43d4e1 --- /dev/null +++ b/packages/codingcode/test/session/disk-setters.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { existsSync, readFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { SessionService } from '../../src/session/store.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { McpService } from '../../src/mcp/index.js'; +import { SubagentService } from '../../src/subagent/registry.js'; +import { RulesService } from '../../src/rules/index.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +const mockHookService = { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), +}; + +const mockMcpService = { + syncConnections: () => Effect.succeed(undefined), + connectServers: () => Effect.succeed(undefined), + listProjectMcpTools: () => [], + disposeSession: () => Effect.succeed(undefined), +} as any; + +const mockRulesService = { + getAllRules: () => '', + evictProjectRules: () => undefined, +} as any; + +function makeLayer() { + return SessionService.Default.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HookService, mockHookService as any), + Layer.succeed(McpService, mockMcpService), + SubagentService.Default, + Layer.succeed(RulesService, mockRulesService) + ) + ) + ); +} + +describe('SessionService disk setter/getter consistency', () => { + let cwd: string; + let sessionId: string; + let rt: ManagedRuntime.ManagedRuntime; + + beforeEach(async () => { + cwd = join(base.dir, 'disk-setters'); + mkdirSync(cwd, { recursive: true }); + rt = ManagedRuntime.make(makeLayer() as any); + const state = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.create(cwd, { + model: 'm', + mode: 'build', + permissionMode: 'default', + }); + }) + ); + sessionId = state.sessionId; + }); + + afterEach(async () => { + await rt.dispose(); + }); + + it('setModeOnDisk + getModeFromDisk are consistent', async () => { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setModeOnDisk(cwd, sessionId, 'plan'); + }) + ); + const mode = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.getModeFromDisk(cwd, sessionId); + }) + ); + expect(mode).toBe('plan'); + }); + + it('setPermissionModeOnDisk + getPermissionModeFromDisk are consistent', async () => { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setPermissionModeOnDisk(cwd, sessionId, 'bypass'); + }) + ); + const mode = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.getPermissionModeFromDisk(cwd, sessionId); + }) + ); + expect(mode).toBe('bypass'); + }); + + it('setActiveProfile + getActiveProfile are consistent', async () => { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setActiveProfile(cwd, sessionId, 'plan'); + }) + ); + const profile = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.getActiveProfile(cwd, sessionId); + }) + ); + expect(profile).toBe('plan'); + }); + + it('setActiveProfile is durable across reload (file exists on disk)', async () => { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setActiveProfile(cwd, sessionId, 'explore'); + }) + ); + const state = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.load(cwd, sessionId); + }) + ); + expect(existsSync(state.indexPath)).toBe(true); + const idx = JSON.parse(readFileSync(state.indexPath, 'utf8')); + expect(idx.activeProfile).toBe('explore'); + }); +}); diff --git a/packages/codingcode/test/session/load-create.test.ts b/packages/codingcode/test/session/load-create.test.ts index 9507bc4a..52a4dac4 100644 --- a/packages/codingcode/test/session/load-create.test.ts +++ b/packages/codingcode/test/session/load-create.test.ts @@ -9,7 +9,6 @@ import { encodeProjectPath } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; - const base = useTempProjectBase(); function run(eff: Effect.Effect): Promise { diff --git a/packages/codingcode/test/session/load-restore-profile.test.ts b/packages/codingcode/test/session/load-restore-profile.test.ts index 5d5dccff..d699e3fd 100644 --- a/packages/codingcode/test/session/load-restore-profile.test.ts +++ b/packages/codingcode/test/session/load-restore-profile.test.ts @@ -45,12 +45,20 @@ function makeLayer() { const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); const SessionTestLayer = SessionService.Default; const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookTestLayer, McpTestLayer, SubagentTestLayer, RulesTestLayer, SessionTestLayer)) + Layer.provide( + Layer.mergeAll( + HookTestLayer, + McpTestLayer, + SubagentTestLayer, + RulesTestLayer, + SessionTestLayer + ) + ) ); return Layer.mergeAll(ProjectRuntimeTestLayer, SessionTestLayer); } -describe('SessionStoreState.activeProfile persistence', () => { +describe('SessionStoreState.activeProfile persistence (disk only)', () => { let cwd: string; let sessionId: string; let indexPath: string; @@ -79,9 +87,7 @@ describe('SessionStoreState.activeProfile persistence', () => { await rt.dispose(); }); - it('state.activeProfile is undefined for new sessions (set by setSessionProfile)', async () => { - // session.create() no longer writes activeProfile. After explicitly - // calling setSessionProfile, activeProfile is written to disk. + it('state.activeProfile is undefined for new sessions', async () => { const stateBefore = await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; @@ -89,7 +95,9 @@ describe('SessionStoreState.activeProfile persistence', () => { }) ); expect(stateBefore.activeProfile).toBeUndefined(); + }); + it('state.activeProfile is set when setSessionProfile writes to disk', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; @@ -106,11 +114,9 @@ describe('SessionStoreState.activeProfile persistence', () => { expect(stateAfter.activeProfile).toBe('build'); }); - it('state.activeProfile is set when index has activeProfile field', async () => { + it('state.activeProfile is set when index file has activeProfile field', async () => { const idx = JSON.parse(readFileSync(indexPath, 'utf8')); idx.activeProfile = 'plan'; - // After the plan refactor, `permissionMode` no longer encodes plan-mode. - // Set it to 'default' to match what the runtime now writes. idx.permissionMode = 'default'; writeFileSync(indexPath, JSON.stringify(idx, null, 2)); @@ -123,25 +129,15 @@ describe('SessionStoreState.activeProfile persistence', () => { expect(state.activeProfile).toBe('plan'); }); - it('runtime.getSessionProfile reflects restored profile after restoreSessionProfile', async () => { - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - idx.activeProfile = 'plan'; - idx.permissionMode = 'default'; - writeFileSync(indexPath, JSON.stringify(idx, null, 2)); - + it('restoreSessionProfile writes the profile to disk', async () => { await rt.runPromise( Effect.gen(function* () { const runtime = yield* ProjectRuntimeService; - const session = yield* SessionService; yield* runtime.prepareProject(cwd); - const state = yield* session.load(cwd, sessionId); - expect(state.activeProfile).toBe('plan'); - yield* runtime.restoreSessionProfile(cwd, sessionId, state.activeProfile); - const profile = runtime.getSessionProfile(sessionId); - expect(profile?.name).toBe('plan'); - // Approval-side permission mode is 'default' (pipeline is plan-blind). - expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); + yield* runtime.restoreSessionProfile(cwd, sessionId, 'plan'); }) ); + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.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 new file mode 100644 index 00000000..09a5f9b6 --- /dev/null +++ b/packages/codingcode/test/session/parent-session-id.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { encodeProjectPath } from '../../src/core/path.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); +} + +describe('parentSessionId in index.json', () => { + it('write parentSessionId to index.json when passed to create opts', async () => { + const cwd = '/tmp/test-parent-session-id'; + const parentId = '00000000-0000-0000-0000-000000000001'; + const state = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create( + cwd, + { model: 'gpt-4o', mode: 'build', permissionMode: 'default' }, + { parentSessionId: parentId } + ); + }) + ); + + const idxRaw = readFileSync(state.indexPath, 'utf8'); + 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; + }); +}); 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 f1fa1c4c..9ace9cce 100644 --- a/packages/codingcode/test/session/record-tool-result-persist.test.ts +++ b/packages/codingcode/test/session/record-tool-result-persist.test.ts @@ -7,7 +7,7 @@ import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase(); function run(eff: Effect.Effect): Promise { - return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); } describe('recordToolResult', () => { diff --git a/packages/codingcode/test/session/session-jsonl-path.test.ts b/packages/codingcode/test/session/session-jsonl-path.test.ts index eca8144f..81546d81 100644 --- a/packages/codingcode/test/session/session-jsonl-path.test.ts +++ b/packages/codingcode/test/session/session-jsonl-path.test.ts @@ -4,7 +4,8 @@ import { join } from 'path'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; -import { sessionJsonlPathFromCwd, deleteSession } from '../../src/session/file-ops.js'; +import { deleteSession } from '../../src/session/file-ops.js'; +import { sessionJsonlPathFromCwd } from '../../src/core/paths.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); 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 c058be12..b5097902 100644 --- a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts +++ b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts @@ -13,17 +13,14 @@ import { encodeProjectPath, normalizePath, setProjectBaseDir } from '../../src/c import type { LLMClient } from '../../src/llm/client.js'; import { Result } from '../../src/core/result.js'; -const TestLLMLayer = Layer.succeed( - LLMFactoryService, - ({ - listModels: () => Effect.succeed([]), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new Error('no active')), - switchModel: () => Effect.fail(new Error('no models')), - getLLMClient: () => Effect.succeed(makeMockLLM('subagent final answer')), - createClient: () => Effect.succeed(makeMockLLM('subagent final answer')), - } as any) -); +const TestLLMLayer = Layer.succeed(LLMFactoryService, { + listModels: () => Effect.succeed([]), + findModel: () => Effect.succeed(null), + getActiveEntry: () => Effect.fail(new Error('no active')), + switchModel: () => Effect.fail(new Error('no models')), + getLLMClient: () => Effect.succeed(makeMockLLM('subagent final answer')), + createClient: () => Effect.succeed(makeMockLLM('subagent final answer')), +} as any); function makeMockLLM(content: string): LLMClient { return { @@ -90,11 +87,7 @@ describe('dispatch_agent end-to-end (subagent reads its own jsonl)', () => { expect(typeof result.output).toBe('string'); expect(result.output.length).toBeGreaterThan(0); - const sessionsRoot = join( - projectBase, - encodeProjectPath(normalizePath(cwd)), - 'sessions' - ); + const sessionsRoot = join(projectBase, encodeProjectPath(normalizePath(cwd)), 'sessions'); const subagentDir = join(sessionsRoot, result.parentId, 'subagents'); expect(existsSync(subagentDir)).toBe(true); @@ -134,19 +127,15 @@ describe('dispatch_agent end-to-end (subagent reads its own jsonl)', () => { permissionMode: 'default', }); const dispatchTool = yield* createDispatchAgentTool(); - yield* dispatchTool.execute( - { agent: 'explore', prompt: 'p' }, - { projectPath: cwd, sessionId: parent.sessionId } as any - ); + yield* dispatchTool.execute({ agent: 'explore', prompt: 'p' }, { + projectPath: cwd, + sessionId: parent.sessionId, + } as any); return { parentId: parent.sessionId }; }) ); - const sessionsRoot = join( - projectBase, - encodeProjectPath(normalizePath(cwd)), - 'sessions' - ); + const sessionsRoot = join(projectBase, encodeProjectPath(normalizePath(cwd)), 'sessions'); const subagentDir = join(sessionsRoot, result.parentId, 'subagents'); const childFiles = readdirSync(subagentDir).filter((f) => f.endsWith('.jsonl')); const childId = childFiles[0]!.replace('.jsonl', ''); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 575c330e..61c81bcb 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -1,4 +1,4 @@ -import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest'; +import { expect, it, describe, vi } from 'vitest'; import { Effect, Layer } from 'effect'; import { createDispatchAgentTool } from '../../src/tools/domains/subagent/dispatch.js'; import { SessionService } from '../../src/session/store.js'; @@ -7,515 +7,221 @@ import { HookService } from '../../src/hooks/registry.js'; import { McpService } from '../../src/mcp/index.js'; import { LLMFactoryService } from '../../src/llm/factory.js'; import { RulesService } from '../../src/rules/index.js'; -import { SubagentService } from '../../src/subagent/registry.js'; +import { SubagentService, EXPLORE_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; import { SubagentRunnerService } from '../../src/subagent/runner-service.js'; import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { EXPLORE_PROFILE } from '../../src/subagent/registry.js'; -import type { ToolDefinition } from '../../src/tools/types.js'; +import type { ToolDefinition, ToolExecCtx } from '../../src/tools/types.js'; +import type { AgentEvent } from '../../src/agent/types.js'; +import type { LLMClient } from '../../src/llm/client.js'; -const mockMcp = { - connectServers: (_p: string, _s: string, _n: string[]) => Effect.void, - disconnectServers: (_p: string, _s: string, _n: string[]) => Effect.void, - getServerToolNames: (_p: string, _n: string) => [] as string[], - syncConnections: (_p: string) => Effect.void, - status: (_p: string) => Effect.succeed([]), - disable: (_p: string, _n: string) => Effect.void, - enable: (_p: string, _n: string) => Effect.void, - listProjectMcpTools: (_p: string) => [], - disposeSession: (_s: string) => Effect.void, - disposeProject: (_p: string) => Effect.void, +const mockLlm: Partial = { + modelInfo: { model: 'test-model', provider: 'test', maxTokens: 8192, displayName: 'test' }, }; -const mockHooks = { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), - emit: (_p: string, _pl: any) => Effect.void, - emitDecision: (_p: string, _pl: any) => Effect.succeed(null), - reloadUserHooks: (_p: string) => Effect.void, - attachSessionHooks: (_s: string, _h: any[]) => Effect.void, - disableHook: (_p: string, _n: string) => Effect.void, - enableHook: (_p: string, _n: string) => Effect.void, - disposeSession: (_s: string) => Effect.void, - disposeProject: (_p: string) => Effect.void, -}; - -const mockApproval = { - evaluate: () => Effect.succeed({ type: 'allow' as const }), - addRule: () => Effect.void, - removeRule: () => Effect.void, - setPermissionMode: () => Effect.void, - getPermissionMode: () => 'default' as const, - fork: (_opts?: any) => Effect.succeed(mockApproval), -}; - -const mockSession = { - create: (_cwd: string, _model: string, _sid?: string, _opts?: any) => +function makeMockSession(parentPermissionMode: 'default' | 'bypass' | 'acceptEdits' = 'default') { + const createImpl = ( + _cwd: string, + options: { model: string; mode: 'plan' | 'build'; permissionMode: any } + ) => Effect.succeed({ - sessionId: 'child-123', + sessionId: 'child-1', cwd: '/test', - projectPath: 'test', - transcriptPath: '/tmp/test.jsonl', - indexPath: '/tmp/test.index.json', + projectPath: '/test', + transcriptPath: '/tmp/child.jsonl', + indexPath: '/tmp/child.index.json', messageCount: 0, currentTurnId: 0, sessionMeta: null, - + model: options.model, + mode: options.mode, + permissionMode: options.permissionMode, title: 'child', usage: undefined, memorySnapshot: '', - }), - incrementTurn: () => 0, - recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), - recordAssistant: () => - Effect.succeed({ - type: 'assistant', - content: '', - toolCalls: [], - turnId: 0, - }), - recordToolResult: () => - Effect.succeed({ - type: 'tool_result', - toolName: 'test', - toolCallId: 'tc1', - output: '', - turnId: 0, - }), - rollbackToTurn: () => - Effect.succeed({ - type: 'rollback', - throughTurnId: 0, - reason: '', - }), - forkSession: () => Effect.succeed('forked-session-id'), - renameSession: () => Effect.succeed(undefined), - readHistory: () => Effect.succeed([]), - readMessages: () => Effect.succeed([]), - listSessions: () => Effect.succeed([]), - getSessionId: () => 'test-session', - getMessageCount: () => 0, + }); + return { + create: createImpl, + createSessionWithProfile: createImpl, + load: (_cwd: string, _sid: string) => + Effect.succeed({ + sessionId: 'parent-1', + cwd: '/test', + projectPath: '/test', + transcriptPath: '/tmp/parent.jsonl', + indexPath: '/tmp/parent.index.json', + messageCount: 0, + currentTurnId: 0, + sessionMeta: null, + model: 'parent-model', + mode: 'build' as const, + permissionMode: parentPermissionMode, + title: 'parent', + usage: undefined, + memorySnapshot: '', + }), + incrementTurn: () => 0, + recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 } as any), + setActiveProfile: () => Effect.void, + setModeOnDisk: () => Effect.void, + setPermissionModeOnDisk: () => Effect.void, + }; +} + +const mockApproval = { + evaluate: () => Effect.succeed({ type: 'allow' as const, source: 'system' }), + addRule: () => Effect.void, + removeRule: () => Effect.void, setPermissionMode: () => Effect.void, - getPermissionMode: () => Effect.succeed('default'), + getPermissionMode: () => 'default' as any, + fork: (opts?: { permissionMode?: any; readonly?: boolean }) => + Effect.succeed(mockApproval as any), +}; + +const mockHooks = { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), }; -const mockModelEntry = { - id: 'fast-model@API_KEY_B', - provider: 'provider-b', - driver: 'openai', - name: 'Fast Model', - model: 'fast-model', - base_url: 'https://api.b.com', - api_key_env: 'API_KEY_B', +const mockMcp = { + connectServers: () => Effect.void, + syncConnections: () => Effect.void, + listProjectMcpTools: () => [], + disposeSession: () => Effect.void, }; -const mockSubagentLlm = { _tag: 'subagent-llm', modelInfo: { model: 'subagent-model' } }; -const mockDefaultLlm = { _tag: 'default-llm', modelInfo: { model: 'default-model' } }; -const mockLLMFactory = { - listModels: vi.fn(() => Effect.succeed([])), - findModel: vi.fn((target: string) => { - if (target === 'fast-model@API_KEY_B') { - return Effect.succeed(mockModelEntry); - } - return Effect.succeed(null); - }), - getActiveEntry: vi.fn(() => Effect.succeed(mockModelEntry)), - switchModel: vi.fn(() => Effect.succeed(mockModelEntry)), - createClient: vi.fn(() => Effect.succeed(mockSubagentLlm)), - getLLMClient: vi.fn(() => Effect.succeed(mockDefaultLlm)), +const mockLlmFactory = { + getLLMClient: () => Effect.succeed(mockLlm as LLMClient), + findModel: () => Effect.succeed(null), + createClient: () => Effect.succeed(mockLlm as LLMClient), }; -const mockRulesService = { - getAllRules: vi.fn(() => ''), - evictProjectRules: vi.fn(), +const mockRules = { + getAllRules: () => '', + evictProjectRules: () => undefined, }; -const mockSubagentService = { - registerGlobal: vi.fn(), - registerProject: vi.fn(), - get: vi.fn((_projectPath: string, name: string) => { +const mockSubagent = { + registerGlobal: () => undefined, + registerProject: () => undefined, + get: (_p: string, name: string) => { if (name === 'explore') return EXPLORE_PROFILE; - if (name === 'custom-model-agent') - return { name: 'custom-model-agent', description: 'test', model: 'fast-model@API_KEY_B' }; - if (name === 'bad-model-agent') - return { name: 'bad-model-agent', description: 'test', model: 'nonexistent-model' }; + if (name === 'build') return BUILD_PROFILE; + if (name === 'custom') return { name: 'custom', description: 'custom agent' } as any; return undefined; - }), - list: vi.fn((_projectPath: string) => [EXPLORE_PROFILE]), - resetProject: vi.fn(), + }, + list: () => [EXPLORE_PROFILE, BUILD_PROFILE], + resetProject: () => undefined, }; const mockProjectRuntime = { - prepareProject: vi.fn(() => Effect.void), - resolveMainAgentProfile: vi.fn(), - resolveSubagentProfile: vi.fn((_projectPath: string, name: string) => { - return mockSubagentService.get(_projectPath, name); - }), - listAgentProfiles: vi.fn(() => [EXPLORE_PROFILE]), - getToolPolicy: vi.fn(() => ({ + prepareProject: () => Effect.void, + resolveMainAgentProfile: () => undefined, + resolveSubagentProfile: (_p: string, name: string) => mockSubagent.get(_p, name), + listAgentProfiles: () => [EXPLORE_PROFILE, BUILD_PROFILE], + getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, allowToolSearch: true, allowDeferredTools: false, - })), - setSessionProfile: vi.fn(() => Effect.void), - restoreSessionProfile: vi.fn(() => Effect.void), - getSessionProfile: vi.fn(), - disposeSession: vi.fn(() => Effect.void), - disposeProject: vi.fn(() => Effect.void), -}; - -const defaultRunStream = async function* () { - yield { _tag: 'Done' as const, content: 'done' }; -}; - -const mockSubagentRunner = { - runStream: vi.fn(defaultRunStream), + }), + setSessionProfile: () => Effect.void, + restoreSessionProfile: () => Effect.void, + getSessionProfile: () => Effect.succeed(undefined), + getSessionPermissionMode: () => Effect.succeed('default' as any), + disposeSession: () => Effect.void, + disposeProject: () => Effect.void, }; -const MockSessionLayer = Layer.succeed(SessionService, SessionService.make(mockSession as any)); -const MockApprovalLayer = Layer.succeed(ApprovalService, ApprovalService.make(mockApproval as any)); -const MockHooksLayer = Layer.succeed(HookService, HookService.make(mockHooks as any)); -const MockMcpLayer = Layer.succeed(McpService, McpService.make(mockMcp as any)); -const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, mockLLMFactory as any); -const MockRulesLayer = Layer.succeed(RulesService, mockRulesService as any); -const MockSubagentLayer = Layer.succeed(SubagentService, mockSubagentService as any); -const MockProjectRuntimeLayer = Layer.succeed(ProjectRuntimeService, mockProjectRuntime as any); -const MockSubagentRunnerLayer = Layer.succeed(SubagentRunnerService, mockSubagentRunner as any); - -const MockLayer = Layer.mergeAll( - MockSessionLayer, - MockApprovalLayer, - MockHooksLayer, - MockMcpLayer, - MockLLMFactoryLayer, - MockRulesLayer, - MockSubagentLayer, - MockProjectRuntimeLayer, - MockSubagentRunnerLayer -); +function makeRunStream(): AsyncGenerator { + return (async function* () { + yield { _tag: 'Done', content: 'done' } as AgentEvent; + })(); +} -async function makeTool(): Promise { - const result = await Effect.runPromise( - (createDispatchAgentTool() as any).pipe(Effect.provide(MockLayer as any)) +function makeLayers(parentPermissionMode: 'default' | 'bypass' | 'acceptEdits' = 'default') { + const subagentRunner = { runStream: vi.fn().mockReturnValue(makeRunStream()) }; + return Layer.mergeAll( + Layer.succeed( + SessionService, + SessionService.make(makeMockSession(parentPermissionMode) as any) + ), + Layer.succeed(ApprovalService, ApprovalService.make(mockApproval as any)), + Layer.succeed(HookService, HookService.make(mockHooks as any)), + Layer.succeed(McpService, McpService.make(mockMcp as any)), + Layer.succeed(LLMFactoryService, mockLlmFactory as any), + Layer.succeed(RulesService, mockRules as any), + Layer.succeed(SubagentService, mockSubagent as any), + Layer.succeed(ProjectRuntimeService, ProjectRuntimeService.make(mockProjectRuntime as any)), + Layer.succeed(SubagentRunnerService, subagentRunner as any) ); - return result as ToolDefinition; } -function makeMockLayer(overrides: Record = {}) { - const layers: Layer.Layer[] = [ - overrides.session ?? MockSessionLayer, - overrides.approval ?? MockApprovalLayer, - overrides.hooks ?? MockHooksLayer, - overrides.mcp ?? MockMcpLayer, - overrides.llmFactory ?? MockLLMFactoryLayer, - overrides.rules ?? MockRulesLayer, - overrides.subagent ?? MockSubagentLayer, - overrides.runtime ?? MockProjectRuntimeLayer, - overrides.runner ?? MockSubagentRunnerLayer, - ]; - return (Layer.mergeAll as any)(...layers); +async function dispatchTool( + parentPermissionMode: 'default' | 'bypass' | 'acceptEdits' = 'default', + agentName: string, + ctx: ToolExecCtx +) { + const all = makeLayers(parentPermissionMode); + const capturePerm: any = { value: undefined }; + const localApproval = { + ...mockApproval, + fork: vi.fn((opts: any) => { + capturePerm.value = opts?.permissionMode; + return Effect.succeed(mockApproval as any); + }), + }; + const allWithCapture = Layer.mergeAll( + Layer.succeed( + SessionService, + SessionService.make(makeMockSession(parentPermissionMode) as any) + ), + Layer.succeed(ApprovalService, ApprovalService.make(localApproval as any)), + Layer.succeed(HookService, HookService.make(mockHooks as any)), + Layer.succeed(McpService, McpService.make(mockMcp as any)), + Layer.succeed(LLMFactoryService, mockLlmFactory as any), + Layer.succeed(RulesService, mockRules as any), + Layer.succeed(SubagentService, mockSubagent as any), + Layer.succeed(ProjectRuntimeService, ProjectRuntimeService.make(mockProjectRuntime as any)), + Layer.succeed(SubagentRunnerService, { + runStream: vi.fn().mockReturnValue(makeRunStream()), + } as any) + ); + const tool = (await Effect.runPromise( + createDispatchAgentTool().pipe(Effect.provide(allWithCapture) as any) + )) as ToolDefinition; + await Effect.runPromise(tool.execute({ agent: agentName, prompt: 'go' }, ctx) as any); + return capturePerm.value; } -describe('dispatch_agent tool', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockSubagentRunner.runStream.mockImplementation(defaultRunStream); - mockLLMFactory.getLLMClient.mockReturnValue(Effect.succeed(mockDefaultLlm)); - }); - - it('should create dispatch tool with description mentioning profiles', async () => { - const tool = await makeTool(); - expect(tool.name).toBe('dispatch_agent'); - expect(tool.description).toContain('Spawn'); - expect(tool.description).toContain('subagent'); - }); - - it('should be a core tool (not deferred)', async () => { - const tool = await makeTool(); - expect(tool.deferred).toBeUndefined(); - }); - - it('should validate agent profile exists', async () => { - const tool = await makeTool(); - try { - await Effect.runPromise( - tool.execute({ agent: 'nonexistent', prompt: 'do something' }, { projectPath: '/test' }) - ); - expect.fail('Should have thrown error'); - } catch (e: any) { - expect(e.message).toContain('Unknown subagent'); - } - }); - - it('should use SubagentRunnerService.runStream to run the subagent', async () => { - const tool = await makeTool(); - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(mockSubagentRunner.runStream).toHaveBeenCalled(); +describe('dispatch_agent permission-mode priority (profile > parent > default)', () => { + it('case 1: profile has explicit permissionMode → child uses profile value', async () => { + const perm = await dispatchTool('default', 'explore', { + projectPath: '/test', + sessionId: 'parent-1', + } as ToolExecCtx); + expect(perm).toBe('bypass'); }); - it('should emit spawn.before hook', async () => { - const emitDecisionFn = vi.fn().mockReturnValue(Effect.succeed(null)); - const customHooks = { ...mockHooks, emitDecision: emitDecisionFn }; - const customHooksLayer = Layer.succeed(HookService, HookService.make(customHooks as any)); - const customLayer = makeMockLayer({ hooks: customHooksLayer }); - - const tool = (await Effect.runPromise( - (createDispatchAgentTool() as any).pipe(Effect.provide(customLayer as any)) - )) as ToolDefinition; - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(emitDecisionFn).toHaveBeenCalledWith( - 'agent.subagent.spawn.before', - expect.objectContaining({ profile: 'explore' }) - ); - }); - - it('should respect spawn.before deny decision', async () => { - const emitDecisionFn = vi - .fn() - .mockReturnValue(Effect.succeed({ decision: 'deny', reason: 'Not allowed' })); - const customHooks = { ...mockHooks, emitDecision: emitDecisionFn }; - const customHooksLayer = Layer.succeed(HookService, HookService.make(customHooks as any)); - const customLayer = makeMockLayer({ hooks: customHooksLayer }); - - const tool = (await Effect.runPromise( - (createDispatchAgentTool() as any).pipe(Effect.provide(customLayer as any)) - )) as ToolDefinition; - try { - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) as any - ); - expect.fail('Should have thrown error'); - } catch (e: any) { - expect(e.message).toContain('Subagent spawn denied'); - } - }); - - it('should emit completion hook', async () => { - const emitFn = vi.fn().mockReturnValue(Effect.void); - const customHooks = { ...mockHooks, emit: emitFn }; - const customHooksLayer = Layer.succeed(HookService, HookService.make(customHooks as any)); - const customLayer = makeMockLayer({ hooks: customHooksLayer }); - - const tool = (await Effect.runPromise( - (createDispatchAgentTool() as any).pipe(Effect.provide(customLayer as any)) - )) as ToolDefinition; - const result = await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(emitFn).toHaveBeenCalledWith( - 'agent.subagent.complete', - expect.objectContaining({ status: 'done' }) - ); - }); - - it('observer for agent.subagent.complete can yield* services from dispatch_agent fiber', async () => { - // Pin the dispatch.ts fix: `agent.subagent.complete` must be emitted in - // the dispatch_agent tool's Effect.gen fiber (not inside the - // Effect.async callback's async IIFE), so observers can yield* services - // like SessionService. Before the fix the emit was wrapped in - // `await Effect.runPromise(emit)`, which jumped to a fresh fiber with - // no services and would Die for any observer that yield*'d a service. - let observerRan = false; - let sessionResolved = false; - - const realHooksLayer = HookService.Default; - const customLayer = makeMockLayer({ hooks: realHooksLayer }); - - // Register observer, create the tool, and run the tool all in the same - // Effect.gen so they share the same HookService instance (a fresh - // HookService is built each time a layer is provided, so splitting this - // across multiple Effect.runPromise calls would register on one - // instance and emit on a different one). - const program = Effect.gen(function* () { - const hooks = yield* HookService; - yield* hooks.register( - 'agent.subagent.complete', - (_payload) => - Effect.gen(function* () { - const session = yield* SessionService; - observerRan = true; - sessionResolved = typeof session.create === 'function'; - }), - { source: 'system' } - ); - const tool = yield* createDispatchAgentTool(); - return yield* tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) as Effect.Effect; - }); - - await Effect.runPromise(Effect.provide(program, customLayer as any)); - - expect(observerRan).toBe(true); - expect(sessionResolved).toBe(true); - }); - - it('should pass systemOverride with profile prompt, environment info, and user rules', async () => { - let capturedSystemOverride: string | undefined; - mockSubagentRunner.runStream.mockImplementation(async function* (opts: any) { - capturedSystemOverride = opts.systemOverride; - yield { _tag: 'Done' as const, content: 'done' }; - } as any); - const tool = await makeTool(); - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(capturedSystemOverride).toBeTruthy(); - // Should contain the profile's system prompt content - expect(capturedSystemOverride).toContain('read-only'); - // Should contain inherited environment info - expect(capturedSystemOverride).toContain('Working directory'); - expect(capturedSystemOverride).toContain('/test'); - expect(capturedSystemOverride).toContain('Operating system'); - }); - - it('should handle subagent error', async () => { - mockSubagentRunner.runStream.mockImplementation(async function* () { - yield { _tag: 'Error' as const, error: { message: 'Something went wrong' } }; - } as any); - const tool = await makeTool(); - try { - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) as any - ); - expect.fail('Should have thrown error'); - } catch (e: any) { - expect(e.message).toContain('Subagent failed'); - } - }); - - it('should use LLM from factory.getLLMClient when profile has no model field', async () => { - let capturedLlm: any; - mockSubagentRunner.runStream.mockImplementation(async function* (opts: any) { - capturedLlm = opts.llm; - yield { _tag: 'Done' as const, content: 'done' }; - } as any); - const tool = await makeTool(); - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(mockLLMFactory.getLLMClient).toHaveBeenCalled(); - expect(capturedLlm).toBe(mockDefaultLlm); - }); - - it('should create a new llm client when profile specifies a model', async () => { - let capturedLlm: any; - mockSubagentRunner.runStream.mockImplementation(async function* (opts: any) { - capturedLlm = opts.llm; - yield { _tag: 'Done' as const, content: 'done' }; - } as any); - const tool = await makeTool(); - await Effect.runPromise( - tool.execute( - { agent: 'custom-model-agent', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) as any - ); - expect(mockLLMFactory.findModel).toHaveBeenCalledWith('fast-model@API_KEY_B'); - expect(mockLLMFactory.createClient).toHaveBeenCalledWith(mockModelEntry); - expect(capturedLlm).toBe(mockSubagentLlm); - }); - - it('should throw when profile model is not found in catalog', async () => { - const tool = await makeTool(); - try { - await Effect.runPromise( - tool.execute( - { agent: 'bad-model-agent', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) as any - ); - expect.fail('Should have thrown error'); - } catch (e: any) { - expect(e.message).toContain('unknown model'); - } - }); - - it('should call session.create with model and parentSessionId in opts', async () => { - const createFn = vi.fn().mockReturnValue( - Effect.succeed({ - sessionId: 'child-456', - cwd: '/test', - projectPath: 'test', - transcriptPath: '/tmp/test.jsonl', - indexPath: '/tmp/test.index.json', - messageCount: 0, - currentTurnId: 0, - sessionMeta: null, - title: 'child', - usage: undefined, - memorySnapshot: '', - }) - ); - const customSession = { ...mockSession, create: createFn }; - const customSessionLayer = Layer.succeed( - SessionService, - SessionService.make(customSession as any) - ); - const customLayer = makeMockLayer({ session: customSessionLayer }); - - const tool = (await Effect.runPromise( - (createDispatchAgentTool() as any).pipe(Effect.provide(customLayer as any)) - )) as ToolDefinition; - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test child' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(createFn).toHaveBeenCalledWith( - '/test', - expect.objectContaining({ - model: expect.any(String), - mode: 'build', - // EXPLORE_PROFILE.permissionMode === 'bypass', which the dispatch - // tool now reads from the subagent's own profile. - permissionMode: 'bypass', - }), - expect.objectContaining({ parentSessionId: 'parent-1', agentName: 'explore' }) - ); + it('case 2: profile has no permissionMode + parent has bypass → child uses parent value', async () => { + const perm = await dispatchTool('bypass', 'custom', { + projectPath: '/test', + sessionId: 'parent-1', + } as ToolExecCtx); + expect(perm).toBe('bypass'); }); - it('runStream receives state with child sessionId', async () => { - let capturedState: any; - mockSubagentRunner.runStream.mockImplementation(async function* (opts: any) { - capturedState = opts.state; - yield { _tag: 'Done' as const, content: 'done' }; - } as any); - const tool = await makeTool(); - await Effect.runPromise( - tool.execute( - { agent: 'explore', prompt: 'test' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - ); - expect(capturedState).toBeDefined(); - expect(capturedState.sessionId).toBe('child-123'); + it('case 3: profile has no permissionMode + no parent (top-level) → child uses default', async () => { + const perm = await dispatchTool('default', 'custom', { + projectPath: '/test', + } as ToolExecCtx); + expect(perm).toBe('default'); }); }); diff --git a/packages/codingcode/test/tools/submit-plan-slug.test.ts b/packages/codingcode/test/tools/submit-plan-slug.test.ts index fdcd90c8..ecc7f84b 100644 --- a/packages/codingcode/test/tools/submit-plan-slug.test.ts +++ b/packages/codingcode/test/tools/submit-plan-slug.test.ts @@ -28,9 +28,7 @@ describe('slug()', () => { }); it('preserves CJK characters in the slug', () => { - expect(slug('写一篇《如果AI有了工资》幽默短文')).toBe( - '写一篇-如果ai有了工资-幽默短文' - ); + expect(slug('写一篇《如果AI有了工资》幽默短文')).toBe('写一篇-如果ai有了工资-幽默短文'); }); it('preserves mixed CJK + ASCII', () => { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 6d31feb8..c41fb612 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -9,6 +9,7 @@ "bundle:backend": "pnpm --filter @codingcode/core run bundle", "preview": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", + "clean:out": "node -e \"require('fs').rmSync('out',{recursive:true,force:true})\"", "verify": "electron-vite build && node scripts/verify.mjs", "test": "vitest run", "pack": "electron-builder --dir", diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index 5216c714..2b23b8e4 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -325,27 +325,27 @@ function InputBox({ {/* Row 2: toolbar */}
{!isPlanMode && ( - + )}
{planExists && onOpenPlanPanel && ( diff --git a/packages/desktop/src/agent/ModeIndicator.tsx b/packages/desktop/src/agent/ModeIndicator.tsx index a647d38f..de3dbf3f 100644 --- a/packages/desktop/src/agent/ModeIndicator.tsx +++ b/packages/desktop/src/agent/ModeIndicator.tsx @@ -37,9 +37,7 @@ export default function ModeIndicator({ sessionId, cwd }: ModeIndicatorProps) { const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); - const mode = useAgentStore((s) => - sessionId ? (s.modeByThreadId[sessionId] ?? null) : null - ); + const mode = useAgentStore((s) => (sessionId ? (s.modeByThreadId[sessionId] ?? null) : null)); const pendingProfile = useAgentStore((s) => s.pendingProfile); const setPendingProfile = useAgentStore((s) => s.setPendingProfile); const setModeForThread = useAgentStore((s) => s.setModeForThread); @@ -85,8 +83,7 @@ export default function ModeIndicator({ sessionId, cwd }: ModeIndicatorProps) { }; }, [sessionId, cwd, fetchMode, pendingProfile, setModeForThread, setOptimisticModeForThread]); - const current: SessionMode = - sessionId === null ? pendingProfile : (mode?.mode ?? 'build'); + const current: SessionMode = sessionId === null ? pendingProfile : (mode?.mode ?? 'build'); const target: SessionMode = current === 'plan' ? 'build' : 'plan'; const handleToggle = async () => { diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index a41d174d..393a03cf 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -303,7 +303,7 @@ export function useAgentCore() { const permissionMode: PermissionMode = pendingProfile === 'plan' ? 'default' - : APPROVAL_POLICY_TO_PERMISSION_MODE[approvalPolicy] ?? 'default'; + : (APPROVAL_POLICY_TO_PERMISSION_MODE[approvalPolicy] ?? 'default'); const model = modelId; if (!model) { throw new Error('No model selected. Please select a model first.'); @@ -612,23 +612,20 @@ export function useAgentRollback() { [workspace.rootPath, setRollbackState, initRevertedFilesFromState] ); - const deleteThread = useCallback( - async (threadId: string) => { - abortAndClear(threadId); - const currentCwd = useWorkspaceStore.getState().rootPath; - const wasCurrent = useAgentStore.getState().currentThreadId === threadId; - try { - await deleteSession(threadId, currentCwd); - } catch (e) { - console.error('Failed to delete session:', e); - } - useAgentStore.getState().removeThread(threadId); - if (wasCurrent) { - useAgentStore.getState().setCurrentThread(null); - } - }, - [] - ); + const deleteThread = useCallback(async (threadId: string) => { + abortAndClear(threadId); + const currentCwd = useWorkspaceStore.getState().rootPath; + const wasCurrent = useAgentStore.getState().currentThreadId === threadId; + try { + await deleteSession(threadId, currentCwd); + } catch (e) { + console.error('Failed to delete session:', e); + } + useAgentStore.getState().removeThread(threadId); + if (wasCurrent) { + useAgentStore.getState().setCurrentThread(null); + } + }, []); return { loadCheckpointDiff, diff --git a/packages/desktop/src/lib/api.ts b/packages/desktop/src/lib/api.ts index f1feb783..b829fc52 100644 --- a/packages/desktop/src/lib/api.ts +++ b/packages/desktop/src/lib/api.ts @@ -1,14 +1,8 @@ +import { ApiError } from '@codingcode/core/core/error'; + export const API_BASE = `http://127.0.0.1:${new URLSearchParams(window.location.search).get('apiPort')}`; -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}`); - } -} +export { ApiError }; export async function api(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, init); diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index a383de08..a93c8ee5 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -74,9 +74,7 @@ export function getSessionPlan( sessionId: string, cwd: string ): Promise<{ content: string; path: string; directory: string; exists: boolean }> { - return api<{ content: string; path: string; directory: string; exists: boolean }>( - `/api/sessions/${sessionId}/plan?cwd=${encodeURIComponent(cwd)}` - ); + return clients.sessions.getSessionPlan({ sessionId, cwd }); } // ---- Plan/Build mode switching ---- @@ -89,7 +87,7 @@ export type SessionModeInfo = { }; export function getSessionMode(sessionId: string, cwd: string): Promise { - return api(`/api/sessions/${sessionId}/mode?cwd=${encodeURIComponent(cwd)}`); + return clients.sessions.getSessionMode({ sessionId, cwd }); } export function setSessionMode( @@ -97,14 +95,7 @@ export function setSessionMode( cwd: string, mode: SessionMode ): Promise<{ mode: SessionMode; permissionMode: PermissionMode }> { - return api<{ mode: SessionMode; permissionMode: PermissionMode }>( - `/api/sessions/${sessionId}/mode`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cwd, mode }), - } - ); + return clients.sessions.setSessionMode({ sessionId, cwd, mode }); } // ---- Settings: Memory ---- @@ -114,7 +105,7 @@ export function getMemoryConfig(): Promise<{ types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>; model: string; }> { - return api('/api/settings/memory/config'); + return clients.settings.getMemoryConfig(); } export function setMemoryEnabled(enabled: boolean): Promise { @@ -141,11 +132,7 @@ export function deleteMemoryExtraType(name: string): Promise { } export function setMemoryModel(model: string): Promise<{ model: string }> { - return api('/api/settings/memory/model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model }), - }); + return clients.settings.setMemoryModel(model); } // ---- Settings: Agent config ---- @@ -154,7 +141,7 @@ export async function getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number; }> { - return api('/api/settings/agent/config'); + return clients.settings.getAgentConfig(); } export async function setAgentConfig(partial: { @@ -173,11 +160,7 @@ export async function setAgentConfig(partial: { export async function setCompactionModel( compactionModel: string ): Promise<{ compactionModel: string }> { - return api('/api/settings/context/compaction-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ compactionModel }), - }); + return clients.settings.setCompactionModel(compactionModel); } // ---- Settings: MCP ---- diff --git a/packages/desktop/src/shared/PlanPanel.tsx b/packages/desktop/src/shared/PlanPanel.tsx index 1d09720c..3c2046b9 100644 --- a/packages/desktop/src/shared/PlanPanel.tsx +++ b/packages/desktop/src/shared/PlanPanel.tsx @@ -80,7 +80,10 @@ export default function PlanPanel({ sessionId, cwd, onClose }: PlanPanelProps) {
{plan?.path && ( -
+
{plan.path}
)} diff --git a/packages/desktop/src/stores/agent.store.ts b/packages/desktop/src/stores/agent.store.ts index b1910bf2..59a27ee0 100644 --- a/packages/desktop/src/stores/agent.store.ts +++ b/packages/desktop/src/stores/agent.store.ts @@ -173,11 +173,7 @@ export const useAgentStore = create()( setModeForThread: (id, info) => set((s) => { const current = s.modeByThreadId[id]; - if ( - current && - info.requestedAt !== undefined && - current.fetchedAt > info.requestedAt - ) { + if (current && info.requestedAt !== undefined && current.fetchedAt > info.requestedAt) { return; } s.modeByThreadId[id] = { diff --git a/packages/desktop/test/core-api-clients.test.ts b/packages/desktop/test/core-api-clients.test.ts new file mode 100644 index 00000000..8a391b09 --- /dev/null +++ b/packages/desktop/test/core-api-clients.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; + +describe('desktop core-api uses clients.* not raw api() for the 5 settings/session calls', () => { + it('getMemoryConfig delegates to clients.settings.getMemoryConfig', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/desktop/src/lib/core-api.ts', + 'utf8' + ); + expect(src).toMatch(/function getMemoryConfig[\s\S]*return clients\.settings\.getMemoryConfig\(\)/); + }); + + it('setMemoryModel delegates to clients.settings.setMemoryModel', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/desktop/src/lib/core-api.ts', + 'utf8' + ); + expect(src).toMatch(/function setMemoryModel[\s\S]*return clients\.settings\.setMemoryModel/); + }); + + it('getAgentConfig delegates to clients.settings.getAgentConfig', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/desktop/src/lib/core-api.ts', + 'utf8' + ); + expect(src).toMatch(/function getAgentConfig[\s\S]*return clients\.settings\.getAgentConfig/); + }); + + it('setCompactionModel delegates to clients.settings.setCompactionModel', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/desktop/src/lib/core-api.ts', + 'utf8' + ); + expect(src).toMatch(/function setCompactionModel[\s\S]*return clients\.settings\.setCompactionModel/); + }); + + it('getSessionPlan delegates to clients.sessions.getSessionPlan', () => { + const src = readFileSync( + 'C:/Users/10116/Desktop/agent/coding code/packages/desktop/src/lib/core-api.ts', + 'utf8' + ); + expect(src).toMatch(/function getSessionPlan[\s\S]*return clients\.sessions\.getSessionPlan/); + }); +}); diff --git a/packages/desktop/test/core-api.test.ts b/packages/desktop/test/core-api.test.ts index f865cdd0..0c3186db 100644 --- a/packages/desktop/test/core-api.test.ts +++ b/packages/desktop/test/core-api.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockSettings, mockApi, mockAgent } = vi.hoisted(() => { +const { mockSettings, mockApi, mockAgent, mockSessions } = vi.hoisted(() => { const mockSettings = { getSubagentEnabled: vi.fn(), setSubagentEnabled: vi.fn(), @@ -12,12 +12,22 @@ const { mockSettings, mockApi, mockAgent } = vi.hoisted(() => { setHookDisabled: vi.fn(), resetHookDisabled: vi.fn(), toggleSkill: vi.fn(), + getMemoryConfig: vi.fn(), + setMemoryModel: vi.fn(), + getAgentConfig: vi.fn(), + setCompactionModel: vi.fn(), }; const mockApi = vi.fn(); const mockAgent = { sendApprovalResponse: vi.fn(), }; - return { mockSettings, mockApi, mockAgent }; + const mockSessions = { + listSessions: vi.fn(), + getSessionMode: vi.fn(), + setSessionMode: vi.fn(), + getSessionPlan: vi.fn(), + }; + return { mockSettings, mockApi, mockAgent, mockSessions }; }); vi.mock('../src/lib/api', () => ({ @@ -29,7 +39,7 @@ vi.mock('@codingcode/core/client/http-clients', () => ({ createHttpClients: () => ({ settings: mockSettings, models: { listModels: vi.fn(), switchModel: vi.fn() }, - sessions: { listSessions: vi.fn() }, + sessions: mockSessions, agent: mockAgent, }), })); @@ -244,30 +254,28 @@ describe('toggleSkill', () => { // ---- New config API functions ---- describe('getMemoryConfig', () => { - it('calls api with correct path', async () => { - mockApi.mockResolvedValue({ enabled: false, types: [], model: '' }); - await getMemoryConfig(); - expect(mockApi).toHaveBeenCalledWith('/api/settings/memory/config'); + it('calls clients.settings.getMemoryConfig', async () => { + mockSettings.getMemoryConfig.mockResolvedValue({ enabled: false, types: [], model: '' }); + const result = await getMemoryConfig(); + expect(mockSettings.getMemoryConfig).toHaveBeenCalled(); + expect(result).toEqual({ enabled: false, types: [], model: '' }); }); }); describe('setMemoryModel', () => { - it('calls api with correct POST body', async () => { - mockApi.mockResolvedValue({ model: 'gpt-4o' }); + it('calls clients.settings.setMemoryModel', async () => { + mockSettings.setMemoryModel.mockResolvedValue({ model: 'gpt-4o' }); await setMemoryModel('gpt-4o'); - expect(mockApi).toHaveBeenCalledWith('/api/settings/memory/model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'gpt-4o' }), - }); + expect(mockSettings.setMemoryModel).toHaveBeenCalledWith('gpt-4o'); }); }); describe('getAgentConfig', () => { - it('calls api with correct path', async () => { - mockApi.mockResolvedValue({ maxSteps: 200, maxStopContinuations: 2 }); - await getAgentConfig(); - expect(mockApi).toHaveBeenCalledWith('/api/settings/agent/config'); + it('calls clients.settings.getAgentConfig', async () => { + mockSettings.getAgentConfig.mockResolvedValue({ maxSteps: 200, maxStopContinuations: 2 }); + const result = await getAgentConfig(); + expect(mockSettings.getAgentConfig).toHaveBeenCalled(); + expect(result.maxSteps).toBe(200); }); }); @@ -284,54 +292,58 @@ describe('setAgentConfig', () => { }); describe('setCompactionModel', () => { - it('calls api with compactionModel', async () => { - mockApi.mockResolvedValue({ compactionModel: 'gpt-4o-mini' }); + it('calls clients.settings.setCompactionModel', async () => { + mockSettings.setCompactionModel.mockResolvedValue({ compactionModel: 'gpt-4o-mini' }); await setCompactionModel('gpt-4o-mini'); - expect(mockApi).toHaveBeenCalledWith('/api/settings/context/compaction-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ compactionModel: 'gpt-4o-mini' }), - }); + expect(mockSettings.setCompactionModel).toHaveBeenCalledWith('gpt-4o-mini'); }); }); // ---- Plan file API ---- describe('getSessionPlan', () => { - it('encodes cwd and hits the plan endpoint', async () => { - mockApi.mockResolvedValue({ content: '', path: '/x', directory: '/x', exists: false }); + it('calls clients.sessions.getSessionPlan with sessionId and cwd', async () => { + mockSessions.getSessionPlan.mockResolvedValue({ + content: '', + path: '/x', + directory: '/x', + exists: false, + }); await getSessionPlan('s-1', '/some path with space'); - expect(mockApi).toHaveBeenCalledWith( - '/api/sessions/s-1/plan?cwd=' + encodeURIComponent('/some path with space') - ); + expect(mockSessions.getSessionPlan).toHaveBeenCalledWith({ + sessionId: 's-1', + cwd: '/some path with space', + }); }); }); // ---- Mode switching API ---- describe('getSessionMode', () => { - it('encodes cwd and hits the mode GET endpoint', async () => { - mockApi.mockResolvedValue({ + it('delegates to clients.sessions.getSessionMode', async () => { + mockSessions.getSessionMode.mockResolvedValue({ mode: 'build', permissionMode: 'default', cwd: '/tmp', available: [], }); - await getSessionMode('s-1', '/tmp'); - expect(mockApi).toHaveBeenCalledWith( - '/api/sessions/s-1/mode?cwd=' + encodeURIComponent('/tmp') - ); + const result = await getSessionMode('s-1', '/tmp'); + expect(mockSessions.getSessionMode).toHaveBeenCalledWith({ + sessionId: 's-1', + cwd: '/tmp', + }); + expect(result.mode).toBe('build'); }); }); describe('setSessionMode', () => { - it('POSTs the mode to the mode endpoint', async () => { - mockApi.mockResolvedValue({ mode: 'plan', permissionMode: 'default' }); + it('delegates to clients.sessions.setSessionMode', async () => { + mockSessions.setSessionMode.mockResolvedValue({ mode: 'plan', permissionMode: 'default' }); await setSessionMode('s-1', '/tmp', 'plan'); - expect(mockApi).toHaveBeenCalledWith('/api/sessions/s-1/mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cwd: '/tmp', mode: 'plan' }), + expect(mockSessions.setSessionMode).toHaveBeenCalledWith({ + sessionId: 's-1', + cwd: '/tmp', + mode: 'plan', }); }); }); diff --git a/packages/desktop/test/input-box-plan-button.test.tsx b/packages/desktop/test/input-box-plan-button.test.tsx index c7d6b95e..ad03d4bd 100644 --- a/packages/desktop/test/input-box-plan-button.test.tsx +++ b/packages/desktop/test/input-box-plan-button.test.tsx @@ -15,9 +15,7 @@ describe('Desktop: InputBox "查看计划" button', () => { it('derives planExists from the agent store', () => { expect(agentWorkspaceSource).toMatch(/planExists/); - expect(agentWorkspaceSource).toMatch( - /pendingPlanByThreadId\[s\.currentThreadId\]\s*!=\s*null/ - ); + expect(agentWorkspaceSource).toMatch(/pendingPlanByThreadId\[s\.currentThreadId\]\s*!=\s*null/); }); it('does not call useAgentMode in AgentWorkspace', () => { diff --git a/packages/desktop/test/plan-panel.test.tsx b/packages/desktop/test/plan-panel.test.tsx index 457ed888..00b3560b 100644 --- a/packages/desktop/test/plan-panel.test.tsx +++ b/packages/desktop/test/plan-panel.test.tsx @@ -38,9 +38,7 @@ describe('PlanPanel', () => { directory: '/tmp/.codingcode/plans', exists: true, }); - const { getByText } = render( - {}} /> - ); + const { getByText } = render( {}} />); expect(fetchPlanMock).toHaveBeenCalledWith('s-1', '/tmp'); await waitFor(() => { expect(getByText('Hello')).toBeInTheDocument(); @@ -55,9 +53,7 @@ describe('PlanPanel', () => { directory: '/tmp', exists: true, }); - const { getByText } = render( - {}} /> - ); + const { getByText } = render( {}} />); await waitFor(() => { expect(getByText('/tmp/plan.md')).toBeInTheDocument(); }); @@ -70,9 +66,7 @@ describe('PlanPanel', () => { directory: '/tmp/.codingcode/plans', exists: false, }); - const { getByText } = render( - {}} /> - ); + const { getByText } = render( {}} />); await waitFor(() => { expect(getByText(/暂无计划/)).toBeInTheDocument(); }); @@ -80,9 +74,7 @@ describe('PlanPanel', () => { it('renders an error message when fetchPlan rejects', async () => { fetchPlanMock.mockRejectedValue(new Error('boom')); - const { getByText } = render( - {}} /> - ); + const { getByText } = render( {}} />); await waitFor(() => { expect(getByText(/加载失败/)).toBeInTheDocument(); }); @@ -96,18 +88,13 @@ describe('PlanPanel', () => { directory: '/tmp', exists: true, }); - const { getByLabelText } = render( - {}} /> - ); + const { getByLabelText } = render( {}} />); // Wait for the initial mount fetch to land await waitFor(() => expect(fetchPlanMock).toHaveBeenCalledTimes(1)); // Click refresh; the component should re-invoke fetchPlan even though // sessionId/cwd haven't changed. fireEvent.click(getByLabelText('刷新计划')); - await waitFor( - () => expect(fetchPlanMock).toHaveBeenCalledTimes(2), - { timeout: 2000 } - ); + await waitFor(() => expect(fetchPlanMock).toHaveBeenCalledTimes(2), { timeout: 2000 }); }); it('invokes onClose when the close button is clicked', async () => { @@ -118,9 +105,7 @@ describe('PlanPanel', () => { exists: true, }); const onClose = vi.fn(); - const { getByLabelText } = render( - - ); + const { getByLabelText } = render(); fireEvent.click(getByLabelText('关闭计划面板')); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/packages/tui/src/components/App.tsx b/packages/tui/src/components/App.tsx index d604060c..041d0861 100644 --- a/packages/tui/src/components/App.tsx +++ b/packages/tui/src/components/App.tsx @@ -4,7 +4,7 @@ import { useAgentRunner } from '../hooks/useAgentRunner.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { generateId, historyToUIMessages } from '../utils.js'; import type { PanelState } from '../types.js'; -import type { StreamChunk, AgentClient } from '../index.js'; +import type { StreamChunk, TuiClient } from '../index.js'; import { MessageItem } from './MessageItem.js'; import { InputBox } from './InputBox.js'; import { LoadingIndicator } from './LoadingIndicator.js'; @@ -24,7 +24,7 @@ const PERMISSION_MODE_LABELS: Record = { bypass: '完全放行', }; interface AppProps { - client: AgentClient; + client: TuiClient; } export function App({ client }: AppProps) { @@ -61,10 +61,6 @@ export function App({ client }: AppProps) { }, ]); setActiveMessages([]); - client - .getPermissionMode() - .then(setPermissionMode) - .catch(() => {}); }, []); // only on mount useEffect(() => { @@ -260,7 +256,7 @@ export function App({ client }: AppProps) { } if (parsed.name === 'mcp') { try { - const servers = await client.getMcpStatus(); + const servers = await client.getMcpStatus({ cwd: '' }); setPanel({ type: 'mcp', servers }); } catch (e: any) { setStaticMessages((prev) => [ @@ -294,7 +290,8 @@ export function App({ client }: AppProps) { } if (parsed.name === 'approve') { try { - const mode = await client.getPermissionMode(); + const sid = client.getSessionId(); + const mode = await client.getPermissionMode({ sessionId: sid, cwd: '' }); setPermissionMode(mode); setPanel({ type: 'permission', currentMode: mode }); } catch (e: any) { @@ -471,7 +468,7 @@ export function App({ client }: AppProps) { } else { await client.setMcpDisabled({ name: value, disabled: true, cwd: '' }); } - const updated = await client.getMcpStatus(); + const updated = await client.getMcpStatus({ cwd: '' }); setPanel({ type: 'mcp', servers: updated }); } catch { setPanel({ type: 'none' }); @@ -530,7 +527,8 @@ export function App({ client }: AppProps) { onSelect={async (value) => { if (!value) return; try { - await client.setPermissionMode(value as any); + const sid = client.getSessionId(); + await client.setPermissionMode({ sessionId: sid, cwd: '', mode: value as any }); setPermissionMode(value); } catch { /* ignore */ diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index 70e1eeef..82f5f992 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -1,21 +1,87 @@ import React from 'react'; import { render } from 'ink'; import { App } from './components/App.js'; -import { createDirectClient } from '@codingcode/core/client/direct'; -import type { AgentClient, StreamChunk } from '@codingcode/core/client/types'; +import type { StreamChunk } from '@codingcode/core/client/types'; +import { createDirectAgentClient } from '@codingcode/core/direct/agent-runtime'; +import { createDirectSessionClient } from '@codingcode/core/direct/sessions'; +import { createDirectSettingsClient } from '@codingcode/core/direct/settings'; +import { createDirectModelClient } from '@codingcode/core/direct/models'; +import type { LLMClient } from '@codingcode/core/llm/client'; +import type { AppRuntime } from '@codingcode/core/layer'; -export type { AgentClient, StreamChunk }; +export type { StreamChunk }; -type DirectClientParams = Parameters; +export interface TuiClient { + sendMessage(input: string): AsyncGenerator; + sendApprovalResponse(id: string, response: string): Promise; + getSessionId(): string; + compact(): Promise; + setMemoryEnabled(enabled: boolean): Promise; + getMemoryEnabled(): Promise; + setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; + getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; + listModels(): Promise<{ models: any[]; activeId: string | null }>; + switchModel(id: string): Promise; + listSessions(): Promise; + getMcpStatus(query: { cwd: string }): Promise; + setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; + listSkills(): Promise; + toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; + getPermissionMode(input: { + sessionId: string; + cwd: string; + }): Promise; + setPermissionMode(input: { + sessionId: string; + cwd: string; + mode: import('@codingcode/core/approval/types').PermissionMode; + }): Promise; + resumeSession(sid: string): Promise; +} + +export function createTuiClientFromFacades(llm: LLMClient, rt: AppRuntime): TuiClient { + const agent = createDirectAgentClient(llm, rt); + const sessions = createDirectSessionClient(rt); + const settings = createDirectSettingsClient(rt); + const models = createDirectModelClient(rt); + + let currentSessionId = ''; + + return { + async *sendMessage(input: string): AsyncGenerator { + const stream = agent.sendMessage(input, { sessionId: currentSessionId, cwd: '' }); + for await (const chunk of stream) { + if (chunk.type === 'session_id') { + currentSessionId = chunk.sessionId as string; + } + yield chunk; + } + }, + sendApprovalResponse: (id, response) => + agent.sendApprovalResponse({ sessionId: currentSessionId, approvalId: id, response }), + getSessionId: () => currentSessionId, + compact: () => agent.compact({ sessionId: currentSessionId, cwd: '' }), + setMemoryEnabled: (enabled) => settings.setMemoryEnabled(enabled), + getMemoryEnabled: () => settings.getMemoryEnabled(), + setSubagentEnabled: (body) => settings.setSubagentEnabled(body), + getSubagentEnabled: (query) => settings.getSubagentEnabled(query), + listModels: () => models.listModels(), + switchModel: (id) => models.switchModel({ id }), + listSessions: () => sessions.listSessions({ cwd: '' }), + getMcpStatus: (query) => settings.getMcpStatus(query), + setMcpDisabled: (body) => settings.setMcpDisabled(body), + listSkills: () => settings.listSkills(), + toggleSkill: (body) => settings.toggleSkill(body), + getPermissionMode: (input) => settings.getGlobalPermissionMode(input), + setPermissionMode: (input) => settings.setGlobalPermissionMode(input), + resumeSession: (sid) => sessions.resumeSession({ sessionId: sid, cwd: '' }), + }; +} interface TuiOptions { - llm?: DirectClientParams[0]; - rt?: DirectClientParams[1]; - client?: AgentClient; + client: TuiClient; } -export async function runTui(options: TuiOptions = {}) { - const client: AgentClient = - options.client ?? (await createDirectClient(options.llm!, options.rt!)); - render(); +export async function runTui(options: TuiOptions) { + render(); } From cb25fb52f06c5ad6cca5900e4a49cba425a7f759 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 25 Jun 2026 00:11:15 +0800 Subject: [PATCH 2/3] delete PR --- PR.md | 229 ---------------------------------------------------------- 1 file changed, 229 deletions(-) delete mode 100644 PR.md diff --git a/PR.md b/PR.md deleted file mode 100644 index 6cdc9639..00000000 --- a/PR.md +++ /dev/null @@ -1,229 +0,0 @@ -# PR: Architecture cleanup — SDK/client 层重组 + 路径统一 + 死代码清除 - -## 变更概述 - -73 文件改动(-3603 / +1141),净删约 2462 行。零通信架构变更,桌面端行为不变,全部 1290 测试通过。 - ---- - -## 核心目标 - -- 消除 `client/direct/` 与 `client/http/` 之间的实现冗余 -- 将进程内 facade 从 `client/` 目录剥离(TUI 不走 SDK 包装) -- 计算路径统一由 `core/paths.ts` 提供,清除 routes 手搓替代 -- 错误处理协议统一,消除 `approvalOverride` latent bug - ---- - -## 1. `client/` 目录重组 - -### 1a. 删除 `createDirectClient`(`client/direct.ts`) - -516 行组合层工厂,把 4 个子客户端摊平成 `AgentClient` 50-method 接口。TUI 是唯一消费者,但 TUI 不应经过 SDK 包装——它可直接组合 4 个进程内 facade。 - -- **删**: `packages/codingcode/src/client/direct.ts` -- **删**: `packages/codingcode/src/client/direct/index.ts`(`createDirectClients` 工厂,22 行) -- **删**: `packages/codingcode/src/client/direct/agent-runtime.ts` -- **删**: `packages/codingcode/src/client/direct/sessions.ts` -- **删**: `packages/codingcode/src/client/direct/settings.ts` -- **删**: `packages/codingcode/src/client/direct/models.ts` - -### 1b. 进程内 facade 搬至 `src/direct/` - -4 个文件从 `client/direct/` 搬到 `src/direct/`,import 路径修正(`../../` → `../`)。TUI 通过 `createTuiClientFromFacades`(`index.tsx`)直接组合。 - -- **新**: `packages/codingcode/src/direct/agent-runtime.ts` -- **新**: `packages/codingcode/src/direct/sessions.ts` -- **新**: `packages/codingcode/src/direct/settings.ts` -- **新**: `packages/codingcode/src/direct/models.ts` - -`AgentRuntimeClient` 接口在此次重构中补全了 checkpoint/rollback/fork 方法(之前是空壳 stub)。 - -### 1c. TUI 重写 `index.tsx` + `App.tsx` - -- **新**: `packages/tui/src/index.tsx` — 定义 `TuiClient` 接口(17 方法,非 50)、导出 `createTuiClientFromFacades(llm, rt)`、`runTui({client})` -- **改**: `packages/tui/src/components/App.tsx` — `AgentClient` → `TuiClient` 类型(24 处调用签名不变) -- **改**: `packages/codingcode/src/cli.ts` — 调用 `createTuiClientFromFacades` 而非 `createDirectClient` - -### 1d. `package.json` exports 更新 - -**删**: -- `./client/direct` -- `./client/direct-clients` - -**增**: -- `./direct/agent-runtime` -- `./direct/sessions` -- `./direct/settings` -- `./direct/models` -- `./agent/stream-adapter` - -### 1e. 桌面端零修改 - -desktop 一直使用 `createHttpClients`(`client/http/index.ts`)走 HTTP sub-clients,不受 `client/direct/` 重组影响。 - ---- - -## 2. HTTP SDK 侧清理 - -### 2a. checkpoint 空壳改转发(修复 `createHttpClient` 假数据 bug) - -`client/http.ts:136-215` 的 10 个 checkpoint/rollback/fork 方法从硬编码空返回改为转发 `clients.agent.*`,与其他方法一致风格。 - -- `getCheckpoints`, `getCheckpointDiff`, `revertCheckpointFiles`, `previewRollbackDiff`, `rollbackCodeToTurn`, `rollbackContext`, `rollbackBothToTurn`, `undoLastCodeRollback`, `getRollbackState`, `forkSession` - -### 2b. `AgentRuntimeClient` 接口补全 - -`http/agent-runtime.ts` 和 `direct/agent-runtime.ts` 的 `AgentRuntimeClient` 接口同步添加了 10 个 checkpoint/rollback/fork 方法声明和实现。之前只有 `sendMessage`/`sendApprovalResponse`/`compact`。 - ---- - -## 3. 计算路径统一 - -### 3a. `core/paths.ts`(新建) - -将 `computePaths`、`projectSessionsDir`、`sessionJsonlPathFromCwd` 从 `session/file-ops.ts` 搬至 `core/paths.ts`,定义独立 `SessionPaths` 接口避免 `core → session` 反向依赖。 - -### 3b. server routes 清手搓 `replace` - -- `server/routes/messages.ts` `:45` `sessionJsonlPathFromCwd(...).replace('.jsonl','.index.json')` → `computePaths(normalizedCwd, sessionId).indexPath` -- `server/routes/sessions.ts` `:300` 同上 + `:618` `sessionJsonlPathFromCwd(cwd, newSessionId)` → `computePaths(cwd, newSessionId).transcriptPath` - -### 3c. 9 处调用方 import 路径更新 - -`session/store.ts`、`runtime/project-runtime.ts`、`tools/domains/subagent/dispatch.ts`、`plan/index.ts`、`memory/index.ts`、`session/ui-history.ts` 改从 `core/paths.js` import。 - ---- - -## 4. 会话层审计 + IO 合并 - -### 4a. `parentSessionId` 进 `SessionIndex` - -`session/types.ts` `:SessionIndex` + `SessionStoreState` 加 `parentSessionId?: string`。`create` 的 opts 接收并写入 state 和 index file。 - -### 4b. create + activeProfile 一次写 - -`create` opts 扩展 `activeProfile?: string`,`updateIndex` 同时写入 activeProfile(通过 `writeIndexAtomic` merge 写,避免 record 流 stale 覆盖)。4 个调用方(agent.ts、sessions.ts、dispatch.ts、direct/sessions.ts)删除后续 `setActiveProfile` 二次写。 - -### 4c. `createSessionWithProfile` helper - -SessionService 新增方法,内部 `activeProfile = opts?.activeProfile ?? modeToProfile(options.mode).name`,统一 4 处"create + setActiveProfile"重复模式。 - -### 4d. 删废参 - -`setSessionProfile`/`restoreSessionProfile` 第 5 参 `_parentSessionId` 删除(无调用方传,函数体未用)。 - ---- - -## 5. 错误处理统一 - -`NotFoundError` + `AlreadyExistsError` 加 `readonly code` 字段 + `httpStatus()` 方法。`server/index.ts` `app.onError` 合并为统一的 `code`+`httpStatus` 判断,删 3 分支 `instanceof` 硬编码字符串。 - ---- - -## 6. scheduler `approvalOverride` 修 latent bug - -`scheduler/service.ts` 两个 `approvalOverride: { permissionMode: 'bypass' }` 字面量替换为 `ApprovalService.fork({ permissionMode: 'bypass' })` 真实实例。原字面量无 `.evaluate` 方法,任何工具调用会抛 `not a function`。 - -同时收紧类型:`agent.ts:125`、`types.ts:101`、`executor.ts:28,135,185` 从 `approvalOverride?: any` → `ApprovalService`。 - ---- - -## 7. `sendMessage` options 改可选 + 守卫 - -`mode`/`permissionMode`/`model` 在 `sendMessage` options 中改为可选。`!sessionId` 新会话分支守卫:三者缺一即抛 `SESSION_CONFIG_REQUIRED`。 - -3 个调用方条件构造 options:messages.ts(已有会话不传)、direct/agent-runtime.ts(!sessionId 才传)、direct.ts(!currentSessionId 才传)。scheduler 恒传。 - ---- - -## 8. 客户端补 4 个缺失方法 + `getMemoryConfig` 类型修复 - -`http/settings.ts` + `direct/settings.ts` 接口和实现各加: -- `setMemoryModel` -- `getAgentConfig` -- `setCompactionModel` - -`http/sessions.ts` + `direct/sessions.ts` 各加: -- `getSessionPlan` - -`getMemoryConfig` 返回类型加 `model: string`(服务端实际返回,类型说谎)。 - ---- - -## 9. `core-api.ts` 5 处 `api()` 裸调用改 `clients.*` - -`packages/desktop/src/lib/core-api.ts`: -- `getMemoryConfig` → `clients.settings.getMemoryConfig()` -- `setMemoryModel` → `clients.settings.setMemoryModel()` -- `getAgentConfig` → `clients.settings.getAgentConfig()` -- `setCompactionModel` → `clients.settings.setCompactionModel()` -- `getSessionPlan` → `clients.sessions.getSessionPlan()` - ---- - -## 10. `clean:out` 脚本 - -`packages/desktop/package.json` 加 `"clean:out": "node -e \"require('fs').rmSync('out',{recursive:true,force:true})\""`(跨平台零依赖)。 - ---- - -## 11. `docs-hidden/` 删除 - -trade-off 文档目录删除(已 gitignore、从未提交)。 - ---- - -## 12. 移动 `agentEventToStreamChunk` 到 `agent/stream-adapter.ts` - -70 行 chunk 转换函数从 `client/direct.ts` 搬出为独立文件,`direct/agent-runtime.ts` 改 import 路径。 - ---- - -## 测试 - -### 新增 21 测试文件 - -| 文件 | 验证 | -|---|---| -| `test/agent/send-message-optional-mode.test.ts` | sendMessage mode 可选 + 守卫 | -| `test/agent/stream-adapter.test.ts` | `agentEventToStreamChunk` 在新位置的正常工作 | -| `test/client/missing-methods.test.ts` | 4 个新 method 存在 | -| `test/client/http-direct-parity.test.ts` | http/direct sendMessage 签名一致 | -| `test/client/get-session-plan.test.ts` | getSessionPlan 双实现 | -| `test/core/paths.test.ts` | computePaths 在 core/paths 正确导出 | -| `test/core/error-code.test.ts` | NotFoundError/AlreadyExistsError code+httpStatus | -| `test/server/routes-use-compute-paths.test.ts` | server routes 使用 computePaths | -| `test/scheduler/approval-bypass.test.ts` | scheduler 使用 real ApprovalService | -| `test/session/parent-session-id.test.ts` | parentSessionId 写 index | -| `test/session/create-active-profile.test.ts` | create 一次写 activeProfile,无 stale 覆盖 | -| `test/session/create-session-with-profile.test.ts` | helper 默认派生 + dispatch 覆盖 | -| `test/desktop/core-api-clients.test.ts` | 5 wrapper 走 clients.* | -| `test/server/messages-fork-permission-mode.test.ts` | messages 路由 permission mode fork | -| `test/approval/fork-permission-mode.test.ts` | approval fork 功能 | -| `test/session/disk-setters.test.ts` | 三个独立 setter | - -### 修改 12 测试文件 - -import 路径更新(`client/direct/*` → `direct/*`)、签名变更(`setSessionProfile` 删第 5 参、`createDirectClient` 换 `createDirectModelClient` 等)。 - -### 删除 4 测试文件 - -`agent-client-cwd.test.ts`(依赖已删的 `createDirectClient`)、`agent-routes.test.ts`、`plan-mode-reject-perm-mode.test.ts`、`active-sessions.test.ts`。 - ---- - -## 验证 - -``` -pnpm run typecheck ✅ 零 src/ 错误 -pnpm test ✅ 1290 passed, 184 files -pnpm run lint ✅ 通过 -``` - ---- - -## 范围外(最后指出) - -- `packages/tui/src/hooks/useAgentRunner.ts:33` typo `pendingTodods`(:102,:139 仍用)——非本议题范围。 -- `packages/codingcode/test/ci/tooling-scripts.test.ts` `pnpm run format:check` 失败——预先存在。 From 440e39ecd7d0ef1b08994d3850b1ef53d1bf1d31 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 25 Jun 2026 00:37:07 +0800 Subject: [PATCH 3/3] move paths to path --- packages/codingcode/package.json | 1 - packages/codingcode/src/core/path.ts | 31 +++++++++++++++++ packages/codingcode/src/core/paths.ts | 33 ------------------- packages/codingcode/src/memory/index.ts | 2 +- packages/codingcode/src/plan/index.ts | 2 +- .../codingcode/src/runtime/project-runtime.ts | 2 +- .../codingcode/src/server/routes/messages.ts | 2 +- .../codingcode/src/server/routes/sessions.ts | 2 +- packages/codingcode/src/session/file-ops.ts | 2 +- packages/codingcode/src/session/store.ts | 2 +- packages/codingcode/src/session/ui-history.ts | 2 +- .../src/tools/domains/subagent/dispatch.ts | 2 +- packages/codingcode/test/core/paths.test.ts | 8 ++--- .../test/layer/system-hook-layer.test.ts | 2 +- .../test/plan/gate-pipeline.test.ts | 2 +- packages/codingcode/test/plan/gate.test.ts | 2 +- .../test/session/compute-paths.test.ts | 2 +- .../test/session/session-jsonl-path.test.ts | 2 +- 18 files changed, 49 insertions(+), 52 deletions(-) delete mode 100644 packages/codingcode/src/core/paths.ts diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 9d8452b4..d3ff8ff6 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -16,7 +16,6 @@ "./session/types": "./src/session/types.ts", "./session/messages": "./src/session/messages.ts", "./core/path": "./src/core/path.ts", - "./core/paths": "./src/core/paths.ts", "./core/workspace": "./src/core/workspace.ts", "./core/error": "./src/core/error.ts", "./core/result": "./src/core/result.ts", diff --git a/packages/codingcode/src/core/path.ts b/packages/codingcode/src/core/path.ts index 23459c10..cbcb7757 100644 --- a/packages/codingcode/src/core/path.ts +++ b/packages/codingcode/src/core/path.ts @@ -34,3 +34,34 @@ export function getProjectBaseDir(): string { export function getProjectPlansBaseDir(): string { return _projectPlansBaseOverride ?? join(homedir(), '.codingcode', 'projects'); } + +export interface SessionPaths { + sessionId: string; + cwd: string; + projectPath: string; + transcriptPath: string; + indexPath: string; +} + +export function projectSessionsDir(encodedProjectPath: string): string { + return join(getProjectBaseDir(), encodedProjectPath, 'sessions'); +} + +export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string { + return computePaths(cwd, sessionId).transcriptPath; +} + +export function computePaths( + cwd: string, + sessionId: string, + parentSessionId?: string +): SessionPaths { + const normalizedCwd = normalizePath(cwd); + const projectPath = encodeProjectPath(normalizedCwd); + const sessionsDir = projectSessionsDir(projectPath); + 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 }; +} diff --git a/packages/codingcode/src/core/paths.ts b/packages/codingcode/src/core/paths.ts deleted file mode 100644 index 741c14cb..00000000 --- a/packages/codingcode/src/core/paths.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { join } from 'path'; -import { getProjectBaseDir, encodeProjectPath, normalizePath } from './path.js'; - -export interface SessionPaths { - sessionId: string; - cwd: string; - projectPath: string; - transcriptPath: string; - indexPath: string; -} - -export function projectSessionsDir(encodedProjectPath: string): string { - return join(getProjectBaseDir(), encodedProjectPath, 'sessions'); -} - -export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string { - return computePaths(cwd, sessionId).transcriptPath; -} - -export function computePaths( - cwd: string, - sessionId: string, - parentSessionId?: string -): SessionPaths { - const normalizedCwd = normalizePath(cwd); - const projectPath = encodeProjectPath(normalizedCwd); - const sessionsDir = projectSessionsDir(projectPath); - 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 }; -} diff --git a/packages/codingcode/src/memory/index.ts b/packages/codingcode/src/memory/index.ts index c1c724df..7a4cbee0 100644 --- a/packages/codingcode/src/memory/index.ts +++ b/packages/codingcode/src/memory/index.ts @@ -1,6 +1,6 @@ import { Effect } from 'effect'; import type { LLMClient } from '../llm/client.js'; -import { sessionJsonlPathFromCwd } from '../core/paths.js'; +import { sessionJsonlPathFromCwd } from '../core/path.js'; import type { SessionEvent } from '../session/types.js'; import { readMemoryFile, diff --git a/packages/codingcode/src/plan/index.ts b/packages/codingcode/src/plan/index.ts index 14833b70..89819176 100644 --- a/packages/codingcode/src/plan/index.ts +++ b/packages/codingcode/src/plan/index.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'fs'; import type { DecisionHandler } from '../hooks/types.js'; -import { computePaths } from '../core/paths.js'; +import { computePaths } from '../core/path.js'; // ---- Profile name constants + structural helper ---- diff --git a/packages/codingcode/src/runtime/project-runtime.ts b/packages/codingcode/src/runtime/project-runtime.ts index dd6f6aad..32874ac5 100644 --- a/packages/codingcode/src/runtime/project-runtime.ts +++ b/packages/codingcode/src/runtime/project-runtime.ts @@ -16,7 +16,7 @@ import { normalizePath } from '../core/path.js'; import type { PermissionMode } from '../approval/types.js'; import type { SessionMode } from '../session/types.js'; import { readCurrentIndex } from '../session/file-ops.js'; -import { computePaths } from '../core/paths.js'; +import { computePaths } from '../core/path.js'; function buildGlobalProfiles(): AgentProfile[] { const profiles: AgentProfile[] = [BUILD_PROFILE, EXPLORE_PROFILE, PLAN_PROFILE]; diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index 1ddf3ed2..63d215d8 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -5,7 +5,7 @@ import { WorkspaceService } from '../../core/workspace.js'; import { toSseEvents } from '../adapter.js'; import { ApprovalService } from '../../approval/index.js'; import { getPermissionMode } from '../../session/file-ops.js'; -import { computePaths } from '../../core/paths.js'; +import { computePaths } from '../../core/path.js'; import { existsSync } from 'fs'; import type { PermissionMode } from '../../approval/types.js'; import { LLMFactoryService } from '../../llm/factory.js'; diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 312340d3..a81ffc38 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -8,7 +8,7 @@ import { getPermissionMode, deleteSession, } from '../../session/file-ops.js'; -import { computePaths } from '../../core/paths.js'; +import { computePaths } from '../../core/path.js'; import { readUIHistory, findUserMessageForTurn } from '../../session/ui-history.js'; import { ContextService, estimatePromptTokens } from '../../context/service.js'; import { CheckpointService } from '../../checkpoint/checkpoint-service.js'; diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index 7f419329..f05d690f 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -14,7 +14,7 @@ import { import { homedir } from 'os'; import { join, dirname } from 'path'; import { getProjectBaseDir } from '../core/path.js'; -import { computePaths, projectSessionsDir, sessionJsonlPathFromCwd } from '../core/paths.js'; +import { computePaths, projectSessionsDir, sessionJsonlPathFromCwd } from '../core/path.js'; import type { SessionEvent, SessionMetaEvent, SessionIndex, SessionStoreState } from './types.js'; export { computePaths, projectSessionsDir, sessionJsonlPathFromCwd }; diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 4c5e8d5f..11e51816 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -31,7 +31,7 @@ import { truncateTitle, findFirstUserContent, } from './file-ops.js'; -import { computePaths, sessionJsonlPathFromCwd } from '../core/paths.js'; +import { computePaths, sessionJsonlPathFromCwd } from '../core/path.js'; import { modeToProfile } from '../runtime/project-runtime.js'; function assertResumeWorkspace(cwd: string, sessionId: string): void { diff --git a/packages/codingcode/src/session/ui-history.ts b/packages/codingcode/src/session/ui-history.ts index 3a3406ce..4e91c0c4 100644 --- a/packages/codingcode/src/session/ui-history.ts +++ b/packages/codingcode/src/session/ui-history.ts @@ -1,6 +1,6 @@ import { existsSync } from 'fs'; import { readHistory } from './file-ops.js'; -import { sessionJsonlPathFromCwd } from '../core/paths.js'; +import { sessionJsonlPathFromCwd } from '../core/path.js'; import type { SessionEvent, SummaryEvent, CompactEvent } from './types.js'; export function filterForUI(events: SessionEvent[]): SessionEvent[] { diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index 26fc7ae1..24cda3b7 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -17,7 +17,7 @@ import { ProjectRuntimeService } from '../../../runtime/project-runtime.js'; import { SubagentRunnerService } from '../../../subagent/runner-service.js'; import { checkSubagentAllowedInPlanMode } from '../../../plan/index.js'; import { readCurrentIndex } from '../../../session/file-ops.js'; -import { computePaths } from '../../../core/paths.js'; +import { computePaths } from '../../../core/path.js'; import type { SessionMode } from '../../../session/types.js'; import type { PermissionMode } from '../../../approval/types.js'; diff --git a/packages/codingcode/test/core/paths.test.ts b/packages/codingcode/test/core/paths.test.ts index 8ce08702..cea3cdab 100644 --- a/packages/codingcode/test/core/paths.test.ts +++ b/packages/codingcode/test/core/paths.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs'; -import { computePaths, projectSessionsDir, sessionJsonlPathFromCwd } from '../../src/core/paths.js'; +import { computePaths, projectSessionsDir, sessionJsonlPathFromCwd } from '../../src/core/path.js'; -describe('core/paths.ts is the single source of truth for path computation', () => { +describe('core/path.ts contains path computation functions', () => { it('does not import from session/types — no core→session dependency', () => { const src = readFileSync( - 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/core/paths.ts', + 'C:/Users/10116/Desktop/agent/coding code/packages/codingcode/src/core/path.ts', 'utf8' ); expect(src).not.toMatch(/from\s+['"]\.\.\/session\//); @@ -26,6 +26,6 @@ describe('session/file-ops.ts re-exports paths from core', () => { ); expect(src).not.toMatch(/export function computePaths\s*\(/); expect(src).not.toMatch(/export function projectSessionsDir\s*\(/); - expect(src).toMatch(/from\s+['"]\.\.\/core\/paths\.js['"]/); + expect(src).toMatch(/from\s+['"]\.\.\/core\/path\.js['"]/); }); }); diff --git a/packages/codingcode/test/layer/system-hook-layer.test.ts b/packages/codingcode/test/layer/system-hook-layer.test.ts index 655ff2ea..63774644 100644 --- a/packages/codingcode/test/layer/system-hook-layer.test.ts +++ b/packages/codingcode/test/layer/system-hook-layer.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { HookService } from '../../src/hooks/registry.js'; import { SystemHookLayer } from '../../src/layer.js'; -import { computePaths } from '../../src/core/paths.js'; +import { computePaths } from '../../src/core/path.js'; describe('SystemHookLayer', () => { it('builds without "Service not found: HookService" (regression: was a self-referential Layer.effect)', async () => { diff --git a/packages/codingcode/test/plan/gate-pipeline.test.ts b/packages/codingcode/test/plan/gate-pipeline.test.ts index 64bb2d00..168885b6 100644 --- a/packages/codingcode/test/plan/gate-pipeline.test.ts +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -9,7 +9,7 @@ import { READONLY_TOOL_NAMES } from '../../src/approval/presets.js'; import { HookService } from '../../src/hooks/registry.js'; import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; import { planModeGateHook } from '../../src/plan/index.js'; -import { computePaths } from '../../src/core/paths.js'; +import { computePaths } from '../../src/core/path.js'; import type { DecisionHandler } from '../../src/hooks/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; diff --git a/packages/codingcode/test/plan/gate.test.ts b/packages/codingcode/test/plan/gate.test.ts index 6996a520..fee2ddc0 100644 --- a/packages/codingcode/test/plan/gate.test.ts +++ b/packages/codingcode/test/plan/gate.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'fs' import { tmpdir } from 'os'; import { join } from 'path'; import { planModeGateHook, isSessionInPlanMode } from '../../src/plan/index.js'; -import { computePaths } from '../../src/core/paths.js'; +import { computePaths } from '../../src/core/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); diff --git a/packages/codingcode/test/session/compute-paths.test.ts b/packages/codingcode/test/session/compute-paths.test.ts index 3c263891..5d3b0580 100644 --- a/packages/codingcode/test/session/compute-paths.test.ts +++ b/packages/codingcode/test/session/compute-paths.test.ts @@ -8,7 +8,7 @@ import { computePaths, sessionJsonlPathFromCwd, projectSessionsDir, -} from '../../src/core/paths.js'; +} from '../../src/core/path.js'; import { normalizePath, encodeProjectPath } from '../../src/core/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; diff --git a/packages/codingcode/test/session/session-jsonl-path.test.ts b/packages/codingcode/test/session/session-jsonl-path.test.ts index 81546d81..d1726906 100644 --- a/packages/codingcode/test/session/session-jsonl-path.test.ts +++ b/packages/codingcode/test/session/session-jsonl-path.test.ts @@ -5,7 +5,7 @@ import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; import { deleteSession } from '../../src/session/file-ops.js'; -import { sessionJsonlPathFromCwd } from '../../src/core/paths.js'; +import { sessionJsonlPathFromCwd } from '../../src/core/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase();