diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 4295ff20..7f018376 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -26,6 +26,7 @@ jobs: - 'packages/**' - 'package.json' - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' - 'tsconfig*.json' - 'vitest.config.ts' - 'eslint.config.mjs' @@ -37,12 +38,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 + - uses: pnpm/action-setup@v4 + with: + version: 11.3.0 - uses: actions/setup-node@v4 with: node-version: '22' cache: 'pnpm' - - run: pnpm install + - run: pnpm install --frozen-lockfile - run: pnpm run lint typecheck: @@ -51,12 +54,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 + - uses: pnpm/action-setup@v4 + with: + version: 11.3.0 - uses: actions/setup-node@v4 with: node-version: '22' cache: 'pnpm' - - run: pnpm install + - run: pnpm install --frozen-lockfile - run: pnpm run typecheck test: @@ -65,12 +70,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 + - uses: pnpm/action-setup@v4 + with: + version: 11.3.0 - uses: actions/setup-node@v4 with: node-version: '22' cache: 'pnpm' - - run: pnpm install + - run: pnpm install --frozen-lockfile - run: pnpm vitest run build-desktop: @@ -79,10 +86,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v3 + - uses: pnpm/action-setup@v4 + with: + version: 11.3.0 - uses: actions/setup-node@v4 with: node-version: '22' cache: 'pnpm' - - run: pnpm install + - run: pnpm install --frozen-lockfile - run: pnpm --filter @codingcode/desktop run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a967c9ef..299054da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,8 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 + with: + version: 11.3.0 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 491a887c..00000000 --- a/.gitignore +++ /dev/null @@ -1,53 +0,0 @@ -node_modules/ -dist/ -packages/desktop/out/ -.env -.env.example -*.lockb -.DS_Store -.claude/ -.codingcode/ -docs-hidden/ -typecheck-output.txt -neoneo-v2 -cb_test.json -err_test.json -lose_match.json -ndk2.json -.gitignore -ndk3.json -replay_full.json -test/tank-v2.test.ts -.gitignore - -# TypeScript build cache -*.tsbuildinfo -# electron-vite config cache -packages/desktop/electron.vite.config.*.mjs -# Vite cache -.vite/ - -# Test output artifacts from shell redirection -test-output*.txt -client-tests.txt -desktop-test.txt -h-test.txt -test-fallback.txt - -# Compiled JS artifacts leaked into src directories -packages/infra/src/*.js -packages/infra/src/*.d.ts - -# TypeScript dist-test build artifacts -dist-test/ - -# Desktop sub-package residual workspace files -packages/desktop/pnpm-lock.yaml -packages/desktop/pnpm-workspace.yaml - -# Defensive: ignore any stray test temp dirs that may slip into the workspace. -.test-* -# OS / editor cruft -Thumbs.db -.idea/ -.vscode/ diff --git a/.prettierignore b/.prettierignore index b9d26309..330c03fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,5 @@ node_modules/ package-lock.json pnpm-lock.yaml *.md +vitest-result.json +test-output*.txt diff --git a/.prettierrc b/.prettierrc index 1f4c4bbc..85a8e67e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,5 +3,6 @@ "singleQuote": true, "tabWidth": 2, "trailingComma": "es5", - "printWidth": 100 + "printWidth": 100, + "endOfLine": "auto" } diff --git a/docs/configuration.md b/docs/configuration.md index f81dc40a..561d1faf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,10 +40,7 @@ context: memory: enabled: false # 启用长期记忆 model: "" # 记忆提取模型,空字符串回退主模型 - maxBytes: 16384 # 记忆文件最大字节数 - promptMaxBytes: 8192 # 注入提示的最大字节数 - extraTypes: [] # 自定义记忆类型 - disabledTypes: [] # 禁用的记忆类型名 + promptMaxBytes: 8192 # 注入提示的记忆内容最大字节数 ``` ### 字段详细说明 @@ -57,26 +54,7 @@ memory: | `context.compactionModel` | `''` | 上下文压缩使用的模型,空字符串回退到主会话 LLM | | `memory.enabled` | `false` | 是否启用长期记忆系统 | | `memory.model` | `''` | 记忆提取使用的模型,空字符串回退到主模型 | -| `memory.maxBytes` | `16384` | 单个记忆文件的最大字节数 | | `memory.promptMaxBytes` | `8192` | 注入 system prompt 的记忆内容最大字节数 | -| `memory.extraTypes` | `[]` | 自定义记忆类型列表 | -| `memory.disabledTypes` | `[]` | 禁用的内置记忆类型名列表 | - -### 自定义记忆类型示例 - -```yaml -memory: - enabled: true - extraTypes: - - name: feedback - description: 工作流程中的教训和已验证的方法 - enabled: true - - name: decision - description: 重要的架构和设计决策 - enabled: true - disabledTypes: - - reference -``` --- diff --git a/docs/memory.md b/docs/memory.md index 15b72890..b8b91aad 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -1,116 +1,74 @@ # 长期记忆系统 -Coding Code 支持跨会话的长期记忆,自动从对话中提取和存储关键信息。本文档介绍记忆类型、内容分类、自动提取机制和手动编辑方法。 +Coding Code 支持跨会话的长期记忆:自动从对话中提取关键信息,并在下一次会话开始时重新注入。本文档介绍记忆文件、自动提取机制和手动编辑方法。 --- -## 内存类型 +## 记忆文件 -记忆文件存储在项目的 `.codingcode/memory.md` 中。 +记忆存储为单个 Markdown 文件: ---- - -## 记忆内容 +``` +.codingcode/memory.md +``` -内置三种记忆类型: +**整个文件就是长期记忆**,没有分区、没有标记块。文件的全部内容会作为记忆注入,也会作为"已有记忆"参与下一次提取。 -| 类型 | 提取来源 | 内容 | -|------|---------|------| -| `user` | `[user]` 标签的消息 | 用户角色、技能栈、工作偏好及对 Agent 的纠正 | -| `project` | `[user]` + `[assistant]` 消息 | 架构决策、技术选型、部署信息 | -| `reference` | `[user]` + `[tool:*]` 消息 | 外部资源、文档、Dashboard 链接 | +```markdown +### 项目 +- 采用 monorepo 架构,使用 pnpm workspaces +- 入口文件:packages/codingcode/src/cli.ts -可通过 `memory.extraTypes` 添加自定义记忆类型,通过 `memory.disabledTypes` 禁用内置类型。 +### 用户偏好 +- 偏好结构化 Markdown 输出 +``` --- ## 自动提取 -Agent 在每次会话后自动执行记忆提取: +记忆模式开启后,Agent 在会话结束时自动执行记忆更新: -1. 构建 system prompt,包含各记忆类型的提取指引 -2. 发送已有记忆 + 会话记录给 LLM -3. LLM 输出 `...` 块 -4. 提取块内容,返回新记忆文本(null 表示无新内容) -5. 矛盾时新信息替换旧条目,同一会话以最新为准 +1. 读取记忆文件全文作为"已有记忆" +2. 将会话记录(按 `[user]` / `[assistant]` / `[tool:名称]` 标注)与已有记忆一起发送给 LLM +3. LLM 输出整份**最新版记忆**,放在 `...` 块中 +4. 直接用输出内容整体替换记忆文件(受字节上限约束) -提取使用的模型可通过 `memory.model` 配置,留空则回退到主会话模型。 - ---- +模型自行决定更新哪些内容:可以新增条目、修改过时信息、删除不再相关的内容,代码不做"模型只改动哪部分"的任何假设。若模型没有输出有效内容、或输出与当前文件一致,则不写入。 -## 记忆文件格式 +### 提取提示词 -记忆文件使用 Markdown 格式,自动提取内容包裹在标记块中: +提取行为的规范全部写在提示词中,代码不感知记忆内容结构: -```markdown - -### user -- 偏好使用函数式编程风格 -- 常用技术栈:React + TypeScript +- 只保留值得跨会话记住的信息:用户偏好与纠正、项目架构决策、技术选型、外部资源与链接等 +- 忽略一次性任务、调试过程、报错堆栈、闲聊 +- 输出必须是一份完整、自洽的最新记忆,而不是只输出变动部分 +- 新旧信息矛盾时以最新为准 +- 记忆用 `### 主题` 小节组织,小节下用 `- ` 列要点 -### project -- 采用 monorepo 架构,使用 pnpm workspaces -- 入口文件:packages/codingcode/src/cli.ts +提取使用的模型可通过 `memory.model` 配置,留空则回退到主会话模型。 -### reference -- [API 文档](https://example.com/api) - +--- -手动添加的内容可以写在标记块之外,不会被自动提取覆盖。 -``` +## 手动编辑 -### 标记块机制 +记忆文件就是普通 Markdown,用户可以直接编辑: -- `replaceAutoBlock()`:原子替换 `` 和 `` 之间的内容 -- `stripMarkersForPrompt()`:去掉标记后注入系统提示 -- `enforceMaxBytes()`:按 `### ` 小节逐个裁剪到字节上限(默认 16384 字节) -- `mergeAutoBlocks()`:以 `### ` 小节名为 key 合并,incoming 覆盖 base +- 手动写下的内容会在下次会话时作为记忆注入 Agent +- 手动编辑也会被下一次自动提取作为"已有记忆"读到;保留、修改还是删除由模型根据后续对话自行决定 +- 自动提取在写入前会重新检查文件:若提取期间文件被手动改动,则放弃本次写入,避免覆盖用户编辑 --- ## 配置 -在 `codingcode.yaml` 中配置记忆系统: - -```yaml -memory: - enabled: true # 启用长期记忆(默认 false) - model: "" # 记忆提取模型,空字符串回退到主模型 - maxBytes: 16384 # 记忆文件最大字节数 - promptMaxBytes: 8192 # 注入提示的最大字节数 - extraTypes: [] # 自定义记忆类型 - disabledTypes: [] # 禁用的记忆类型名 -``` - -### 自定义记忆类型 +在 `~/.codingcode/config.yaml` 中配置记忆系统: ```yaml memory: - enabled: true - extraTypes: - - name: feedback - description: 工作流程中的教训和已验证的方法 - enabled: true - - name: decision - description: 重要的架构和设计决策 - enabled: true - disabledTypes: - - reference # 禁用内置的 reference 类型 + enabled: true # 启用长期记忆(默认 false) + model: "" # 记忆提取模型,空字符串回退到主模型 + promptMaxBytes: 8192 # 注入提示词的记忆内容最大字节数 ``` ---- - -## 手动编辑 - -记忆文件采用 Markdown 格式,支持手动编辑。手动内容可写在 `` 标记之后,不会被自动提取覆盖: - -```markdown - -### user -- 偏好使用函数式编程风格 - - -### 手动备注 -- 项目部署流程:npm run build -> scp dist/ -> pm2 restart -- 数据库连接字符串在 Vault 中 -``` +记忆文件本身有 16KB 的硬上限,超限时按 `### ` 小节从后往前裁掉超出部分。 diff --git a/docs/tools.md b/docs/tools.md index bfcce750..190c53c2 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -97,16 +97,15 @@ interface ToolVisibilityPolicy { ### 审批流水线(始终生效) -六层决策链,按顺序执行,任一层返回 deny/allow 即终止: +五层决策链,按顺序执行,任一层返回 deny/allow 即终止: | 层级 | 名称 | 逻辑 | |------|------|------| | 1 | **RuleEngine** | 规则引擎匹配,支持 glob 模式匹配工具名和参数,按优先级排序 | -| 2 | **ReadonlyWhitelist** | 只读工具自动放行(read_file, search_code, search_files, fetch_url, web_search, dispatch_agent, todo_write) | -| 3 | **PermissionMode** | 权限模式判断:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行)、`default`(继续下一层)。`plan` Profile 由独立的 `agent/profile.ts` 中的 `planProfileGateHook` 在 Layer 4 强制,不在此层处理 | -| 4 | **HookPreToolUse** | 钩子决策,可返回 allow/deny/ask/continue,支持 `modifiedInput` 修改参数 | -| 5 | **UserConfirmation** | 异步用户确认,支持 allow/deny/always/never 四种响应,always/never 会持久化为规则 | -| 6 | **AuditLog** | 每一层决策后记录审计日志,通过 `tool.approval.post` 钩子发出 | +| 2 | **PermissionMode** | 权限模式驱动的自动放行:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行,涵盖只读与编辑工具)、`default`(不自动放行,继续下一层)。只读工具不再有无条件的独立白名单层;`plan` Profile 由 `agent/profile.ts` 中的 `planProfileGateHook` 在下一层强制,不在此层处理 | +| 3 | **HookPreToolUse** | 钩子决策,可返回 allow/deny/ask/continue,支持 `modifiedInput` 修改参数 | +| 4 | **UserConfirmation** | 异步用户确认,支持 allow/deny/always/never 四种响应,always/never 会持久化为规则 | +| 5 | **AuditLog** | 每一层决策后记录审计日志,通过 `tool.approval.post` 钩子发出 | ### 预设安全规则 @@ -130,11 +129,13 @@ interface ToolVisibilityPolicy { type PermissionMode = 'default' | 'acceptEdits' | 'bypass'; ``` -- `default`:逐层审批,危险操作需用户确认 -- `acceptEdits`:非破坏性工具自动放行,减少确认弹窗 +- `default`:不自动放行任何工具(含只读工具),全部逐层审批 +- `acceptEdits`:非破坏性工具自动放行(涵盖只读工具与编辑类工具),破坏性工具仍需确认 - `bypass`:全部放行,跳过所有审批(慎用) -> `plan` 不再是 `PermissionMode` 的成员。plan Profile 通过 `AgentProfile.name === 'plan'` 结构化识别,由 `agent/profile.ts` 的 `planProfileGateHook` 和 `PLAN_PROFILE_ALLOWED_TOOLS` 共同限制工具。 +> 原独立的 `ReadonlyWhitelist` 层(在 `default` 下也无条件放行只读工具)已废弃,其语义并入 `PermissionMode` 的自动放行判定:`acceptEdits` 视只读工具为非破坏性工具自动放行,`default` 不再自动放行。 + +> `plan` 不再是 `PermissionMode` 的成员。plan Profile 通过 `AgentProfile.name === 'plan'` 结构化识别,由 `agent/profile.ts` 的 `planProfileGateHook` 和 `PLAN_PROFILE_ALLOWED_TOOLS` 共同限制工具。因只读白名单层已删除,`dispatch_agent` 等在 plan 下不再被流水线上层提前放行,统一由 plan gate 拦截。 ### OS 级沙箱(预留) diff --git a/package.json b/package.json index daa5b99a..265005ac 100644 --- a/package.json +++ b/package.json @@ -55,10 +55,5 @@ "typescript-eslint": "^8.60.1", "vite": "^6.4.2", "vitest": "^3.0.0" - }, - "pnpm": { - "overrides": { - "vite": "^6.4.2" - } } } diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 6ad3221d..61c3045d 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -8,44 +8,22 @@ }, "exports": { ".": "./src/layer.ts", - "./agent/agent": "./src/agent/agent.ts", - "./agent/todo": "./src/agent/todo.ts", - "./agent/prompt": "./src/agent/prompt.ts", - "./session/store": "./src/session/store.ts", - "./session/io": "./src/session/io.ts", - "./session/types": "./src/session/types.ts", - "./session/messages": "./src/session/messages.ts", - "./core/path": "./src/core/path.ts", - "./core/workspace": "./src/core/workspace.ts", - "./core/error": "./src/core/error.ts", - "./core/result": "./src/core/result.ts", - "./core/types": "./src/core/types.ts", - "./context/context": "./src/context/context.ts", - "./hooks/registry": "./src/hooks/registry.ts", - "./tools/executor": "./src/tools/executor.ts", - "./mcp/client": "./src/mcp/client.ts", - "./mcp/types": "./src/mcp/types.ts", - "./skills/types": "./src/skills/types.ts", - "./approval/types": "./src/approval/types.ts", - "./approval/async-confirm": "./src/approval/async-confirm.ts", - "./server/create": "./src/server/index.ts", - "./server/adapter": "./src/server/adapter.ts", - "./server/port-discovery": "./src/server/port-discovery.ts", - "./client/types": "./src/client/types.ts", - "./client/http": "./src/client/http.ts", - "./client/http-clients": "./src/client/http/index.ts", + "./client": "./src/client/http/index.ts", + "./server": "./src/server/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", - "./llm/factory": "./src/llm/factory.ts", - "./llm/client": "./src/llm/client.ts", - "./layer": "./src/layer.ts", - "./subagent/types": "./src/subagent/types.ts" + "./approval/types": "./src/approval/types.ts", + "./agent/profile": "./src/agent/profile.ts", + "./core/frame": "./src/core/frame.ts", + "./core/error": "./src/core/error.ts", + "./core/types": "./src/core/types.ts", + "./checkpoint/types": "./src/checkpoint/types.ts", + "./session/port": "./src/session/port.ts", + "./mcp/types": "./src/mcp/types.ts", + "./hooks/types": "./src/hooks/types.ts", + "./llm/client": "./src/llm/client.ts" }, "dependencies": { "@ai-sdk/deepseek": "^2.0.35", diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index a5b2efb1..906ded72 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -1,246 +1,189 @@ -import { Effect, Queue, Stream, Fiber } from 'effect'; -import type { Message } from '../core/types.js'; +import { Effect, Either, Queue, Stream, Fiber, Layer } from 'effect'; import { AgentError } from '../core/error.js'; import { Result } from '../core/result.js'; -import type { LLMClient } from '../llm/client.js'; -import { ToolExecutorService, type ToolLookup } from '../tools/executor.js'; -import { SessionService } from '../session/store.js'; -import { CheckpointService } from '../checkpoint/checkpoint-service.js'; -import { ApprovalService } from '../approval/index.js'; -import { ApprovalWaitService } from '../approval/async-confirm.js'; +import { AgentService } from './port.js'; +import type { RunTurnOptions } from './port.js'; +import { + SessionPort, ToolExecutorPort, CheckpointPort, HookPort, + ApprovalPort, SkillPort, McpPort, ContextPort, MemoryPort, + LlmPort, RulesPort, TodoPort, ToolEnvPort, ToolCatalogPort, +} from './deps.js'; +import type { ToolEnv, ToolCatalog } from './deps.js'; import { buildSystemPrompt } from './prompt.js'; -import type { AgentEvent, RunStreamOptions } from './types.js'; -import { resolveConfig } from './config.js'; -import { TodoService } from './todo.js'; -import { HookService } from '../hooks/registry.js'; -import { SkillService } from '../skills/service.js'; -import { McpService } from '../mcp/index.js'; -import { ContextService } from '../context/service.js'; -import { MemoryService } from '../memory/index.js'; +import type { FrameBody, FrameError, ResponseMeta, Transition, ToolOutcome } from '../core/frame.js'; +import { isTurnEnd } from '../core/frame.js'; +import type { ToolCall } from '../core/types.js'; +import { loadConfig } from '@codingcode/infra/config'; import { createLogger } from '@codingcode/infra/logger'; -import { ProjectRuntimeService } from '../runtime/project-runtime.js'; -import { registerBuiltinTools } from '../tools/builtin-tools.js'; -import { ToolRegistry } from '../tools/registry.js'; -import { submitPlanTool } from '../tools/domains/subagent/submit-plan.js'; -import { createDispatchAgentTool } from '../tools/domains/subagent/dispatch.js'; -import { normalizePath } from '../core/path.js'; -import { isPlanProfile } from './profile.js'; -import type { AgentProfileName } from '../subagent/types.js'; +import { normalizePath, computePaths } from '../core/path.js'; +import { resolveProfile, getToolNames } from './profile.js'; +import type { AgentProfile } from './profile.js'; import type { PermissionMode } from '../approval/types.js'; -const REACTIVE_COMPACT_MAX_RETRIES = 3; -import { RulesService } from '../rules/index.js'; - const logger = createLogger(); -export class AgentService extends Effect.Service()('Agent', { - effect: Effect.gen(function* () { - const executor = yield* ToolExecutorService; - const hooks = yield* HookService; - const approval = yield* ApprovalService; - const approvalWait = yield* ApprovalWaitService; - const session = yield* SessionService; - const checkpoint = yield* CheckpointService; - const runtime = yield* ProjectRuntimeService; - const todo = yield* TodoService; - const context = yield* ContextService; - const memory = yield* MemoryService; - const { maxSteps, maxStopContinuations } = resolveConfig(); - - const runStream = ( - opts: RunStreamOptions - ): AsyncGenerator, unknown> => { - const q = Effect.runSync(Queue.unbounded()); - - const program = Effect.scoped( - Effect.gen(function* () { - yield* Effect.addFinalizer(() => - Effect.sync(() => { - hooks.disposeSession(opts.state.sessionId); - }) - ); - return yield* agentLoop(executor, hooks, maxSteps, maxStopContinuations, opts, q); - }).pipe( - Effect.provideService(HookService, hooks), - Effect.provideService(ToolExecutorService, executor), - Effect.provideService(ApprovalService, approval), - Effect.provideService(ApprovalWaitService, approvalWait), - Effect.provideService(SessionService, session), - Effect.provideService(CheckpointService, checkpoint), - Effect.provideService(ProjectRuntimeService, runtime), - Effect.provideService(TodoService, todo), - Effect.provideService(ContextService, context), - Effect.provideService(MemoryService, memory) - ) - ); - - return (async function* () { - const fiber = Effect.runFork(program); +function toFrameError(e: AgentError): FrameError { + return { message: e.message, code: e.code }; +} - if (opts.abortSignal) { - opts.abortSignal.addEventListener( - 'abort', - () => { - Effect.runFork(Fiber.interrupt(fiber)); - }, - { once: true } +export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { + const session = yield* SessionPort; + const executor = yield* ToolExecutorPort; + const checkpoint = yield* CheckpointPort; + const hooks = yield* HookPort; + const approval = yield* ApprovalPort; + const skills = yield* SkillPort; + const mcp = yield* McpPort; + const context = yield* ContextPort; + const memory = yield* MemoryPort; + const llmFactory = yield* LlmPort; + const rules = yield* RulesPort; + const todo = yield* TodoPort; + const toolEnvPort = yield* ToolEnvPort; + const toolCatalog = yield* ToolCatalogPort; + const cfg = loadConfig(); + const maxSteps = cfg.maxSteps ?? 250; + const maxStopContinuations = cfg.maxStopContinuations ?? 3; + + const runTurn = (input: string, opts: RunTurnOptions) => + Effect.gen(function* () { + const normalizedCwd = normalizePath(opts.cwd); + + rules.evictProjectRules(normalizedCwd); + yield* hooks.emit('agent.turn.start', { sessionId: '' }).pipe(Effect.catchAll(() => Effect.void)); + yield* mcp.syncConnections(normalizedCwd).pipe(Effect.catchAll(() => Effect.void)); + + let sessionId = opts.sessionId; + const llm = yield* llmFactory.getLLMClient(); + if (!sessionId) { + if (!opts.activeProfile || !opts.permissionMode) { + return yield* Effect.fail( + new AgentError('CONFIG_MISSING', 'new session requires activeProfile and permissionMode') ); - if (opts.abortSignal.aborted) { - Effect.runFork(Fiber.interrupt(fiber)); - } } + const model = opts.model ?? llm.modelInfo.model; + const created = yield* session.create(normalizedCwd, { + model, + activeProfile: opts.activeProfile, + permissionMode: opts.permissionMode, + }); + sessionId = created.sessionId; + } - const stream = Stream.fromQueue(q).pipe(Stream.interruptWhen(Fiber.await(fiber))); - - for await (const event of Stream.toAsyncIterable(stream) as AsyncIterable) { - yield event; - } + const state = yield* session.load(normalizedCwd, sessionId); - try { - const result = await Effect.runPromise(Fiber.join(fiber)); - return result; - } catch (e) { - return Result.err( - e instanceof AgentError ? e : new AgentError('AGENT_ABORTED' as any, String(e)) - ); - } - })(); - }; - - return { runStream }; - }), -}) {} - -export const sendMessage = ( - sessionId: string | undefined, - input: string, - cwd: string, - llm: LLMClient, - options: { - signal?: AbortSignal; - approvalOverride?: import('../approval/index.js').ApprovalService; - activeProfile?: AgentProfileName; - permissionMode?: PermissionMode; - model?: string; - } -) => - Effect.gen(function* () { - const session = yield* SessionService; - const agent = yield* AgentService; - const hooks = yield* HookService; - const mcp = yield* McpService; - const checkpoint = yield* CheckpointService; - const approval = yield* ApprovalService; - const skills = yield* SkillService; - const runtime = yield* ProjectRuntimeService; - const todo = yield* TodoService; - const rules = yield* RulesService; - const context = yield* ContextService; - const memory = yield* MemoryService; - - const normalizedCwd = normalizePath(cwd); - yield* runtime.prepareProject(normalizedCwd); - yield* skills.evictProject(normalizedCwd); - - if (!sessionId) { - if (!options.activeProfile || !options.permissionMode || !options.model) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_MISSING', - 'new session requires activeProfile, permissionMode, and model' - ) - ); + // restore session profile/permission from the frontend request, falling back to persisted values + const profileName = opts.activeProfile ?? state.activeProfile; + const effectivePerm = opts.permissionMode ?? state.permissionMode; + if (opts.permissionMode) { + yield* session.setPermissionMode(normalizedCwd, sessionId, opts.permissionMode); } - const created = yield* session.create(normalizedCwd, { - model: options.model, - activeProfile: options.activeProfile, - permissionMode: options.permissionMode, - }); - sessionId = created.sessionId; - } - const state = yield* session.load(normalizedCwd, sessionId); - yield* runtime.restoreSessionProfile( - normalizedCwd, - state.sessionId, - state.activeProfile, - state.permissionMode - ); - state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd); - const sid = state.sessionId; + if (opts.activeProfile) { + yield* session.setActiveProfile(normalizedCwd, sessionId, opts.activeProfile); + } + + state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd); - const profile = runtime.resolveMainAgentProfile(normalizedCwd, state.sessionId); - const policy = runtime.getToolPolicy(profile); + const profile: AgentProfile | undefined = profileName ? resolveProfile(profileName) : undefined; - const dispatchTool = yield* createDispatchAgentTool(); + // get MCP tools + const mcpTools = mcp.listProjectMcpTools(normalizedCwd); - const activeLlm = llm; - const effectiveMaxSteps = profile?.maxSteps; - const effectiveApproval: any = options?.approvalOverride; + const catalog = toolCatalog.register(getToolNames(profile), mcpTools); - const mcpTools = mcp.listProjectMcpTools(normalizedCwd); + const toolEnv = yield* toolEnvPort.getToolEnv(); - const turnId = session.incrementTurn(state); - const [, actualInput] = yield* skills.extractSkill(state.cwd, input); + // record user (increments turn) + extract skill + const [, actualInput] = yield* skills.extractSkill(state.cwd, input); + const userEvent = yield* session.recordUser(state, actualInput); - yield* session.recordUser(state, actualInput); + // checkpoint baseline + yield* checkpoint.snapshotBaseline(state.cwd, sessionId, userEvent.turnId); - yield* checkpoint.snapshotBaseline(state.cwd, sid, turnId); + // get rules text + const rulesText = rules.getAllRules(state.cwd); - const rulesText = rules.getAllRules(state.cwd); + // run agent loop + const stream = runAgentLoop({ + state, llm, profile, catalog, + toolEnv, + abortSignal: opts.signal, rulesText, + sid: sessionId, projectPath: state.cwd, permissionMode: effectivePerm, + }); - const stream = agent.runStream({ - state, - llm: activeLlm, - profile, - toolPolicy: policy, - maxStepsOverride: effectiveMaxSteps, - approvalOverride: effectiveApproval, - mcpTools, - abortSignal: options?.signal, - rulesText, - dispatchTool, + return { stream, sessionId }; }); - return { stream, sessionId: sid }; - }); - -export function agentLoop( - executor: ToolExecutorService, - hooks: HookService, - maxSteps: number, - maxStopContinuations: number, - opts: RunStreamOptions, - q: Queue.Queue -): Effect.Effect< - Result, - AgentError, - | HookService - | ToolExecutorService - | CheckpointService - | SessionService - | ProjectRuntimeService - | TodoService - | ContextService - | MemoryService -> { - const state = opts.state; - const llm = opts.llm; - const profile = opts.profile; - const sessionId = state.sessionId; - const projectPath = state.cwd; - - return Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - const session = yield* SessionService; - const runtime = yield* ProjectRuntimeService; - const todo = yield* TodoService; - const context = yield* ContextService; - const memory = yield* MemoryService; - const { rulesText } = opts; - - const basePrompt = - opts.systemOverride ?? - buildSystemPrompt({ + function runAgentLoop(opts: { + state: any; llm: any; profile: AgentProfile | undefined; + abortSignal: AbortSignal | undefined; + catalog: ToolCatalog; + toolEnv: ToolEnv; + rulesText: string; + sid: string; projectPath: string; permissionMode: PermissionMode; + }): AsyncGenerator { + const q = Effect.runSync(Queue.unbounded()); + + const program: any = Effect.scoped( + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { hooks.disposeSession(opts.sid); }) + ); + return yield* agentLoopInternal(opts, q); + }).pipe( + Effect.provideService(SessionPort, session), + Effect.provideService(ToolExecutorPort, executor), + Effect.provideService(CheckpointPort, checkpoint), + Effect.provideService(HookPort, hooks), + Effect.provideService(ApprovalPort, approval), + Effect.provideService(SkillPort, skills), + Effect.provideService(McpPort, mcp), + Effect.provideService(ContextPort, context), + Effect.provideService(MemoryPort, memory), + Effect.provideService(LlmPort, llmFactory), + Effect.provideService(RulesPort, rules), + Effect.provideService(TodoPort, todo), + ) + ); + + return (async function* () { + const fiber = Effect.runFork(opts.toolEnv.provide(program)); + if (opts.abortSignal) { + opts.abortSignal.addEventListener('abort', () => { + Effect.runFork(Fiber.interrupt(fiber)); + }, { once: true }); + if (opts.abortSignal.aborted) Effect.runFork(Fiber.interrupt(fiber)); + } + + const stream = Stream.fromQueue(q).pipe( + Stream.takeUntil((body: FrameBody) => isTurnEnd(body)) + ); + for await (const body of Stream.toAsyncIterable(stream) as AsyncIterable) { + yield body; + } + })(); + } + + function agentLoopInternal(opts: { + state: any; llm: any; profile: AgentProfile | undefined; + abortSignal: AbortSignal | undefined; + catalog: ToolCatalog; + rulesText: string; + sid: string; projectPath: string; permissionMode: PermissionMode; + }, q: Queue.Queue): any { + const { state, llm, profile, abortSignal, catalog, rulesText, sid, projectPath, permissionMode } = opts; + const { tools, lookup: toolLookup } = catalog; + + let ended = false; + const offerEnd = (transition: Extract) => + Effect.sync(() => { + if (ended) return; + ended = true; + Effect.runSync(q.offer({ family: 'transition', transition })); + }); + + return Effect.gen(function* () { + const basePrompt = buildSystemPrompt({ cwd: projectPath, platform: process.platform, shell: process.env.SHELL || process.env.ComSpec || 'bash', @@ -248,318 +191,195 @@ export function agentLoop( profileSystemPrompt: profile?.systemPrompt, }); - const memoryBlock = state.memorySnapshot; - const memorySection = memoryBlock ? `## Session Memory\n\n${memoryBlock}` : ''; - const system = [basePrompt, memorySection].filter(Boolean).join('\n\n'); - - const maxOverflowRetries = REACTIVE_COMPACT_MAX_RETRIES; - const effectiveMaxSteps = opts.maxStepsOverride ?? maxSteps; + const memoryBlock = state.memorySnapshot; + const memorySection = memoryBlock ? `## Session Memory\n\n${memoryBlock}` : ''; + const system = [basePrompt, memorySection].filter(Boolean).join('\n\n'); - let stopContinuations = 0; - const effectiveMaxStopContinuations = opts.maxStopContinuations ?? maxStopContinuations; - - const registry = new ToolRegistry(); - yield* registerBuiltinTools(registry); - registry.register(...(opts.mcpTools ?? [])); - if (opts.dispatchTool) registry.register(opts.dispatchTool); - if (isPlanProfile(profile)) registry.register(submitPlanTool); - - let messages: Message[] = []; - let submittedPlanTitle: string | null = null; - - for (let attempt = 0; attempt <= maxOverflowRetries; attempt++) { - const payload = yield* Effect.sync(() => - context.assemblePayload(session.getTranscriptPath(state), llm.modelInfo.maxTokens) - ); - messages = payload.messages; + let stopContinuations = 0; + const effectiveMaxStopContinuations = maxStopContinuations; let lastResult: Result | null = null; - let overflow = false; - - yield* hooks.emit('agent.turn.start', { sessionId }); - yield* q.offer({ _tag: 'TurnId', turnId: state.currentTurnId }); + yield* hooks.emit('agent.turn.start', { sessionId: sid }); + yield* q.offer({ family: 'transition', transition: { to: 'start', turnId: state.currentTurnId } }); - for (let step = 0; step < effectiveMaxSteps; step++) { - yield* q.offer({ _tag: 'Step', step: step + 1, max: effectiveMaxSteps }); + for (let step = 0; step < maxSteps; step++) { + yield* hooks.emitDecision('agent.step.before', { sessionId: sid, step: step + 1 }); - const allowedByPolicy = opts.toolPolicy?.allowedTools; - const tools = registry.describe(allowedByPolicy); - const toolLookup: ToolLookup = (name: string) => registry.get(name, allowedByPolicy); - const systemWithCatalog = system; + if (step === 0) { + yield* q.offer({ family: 'transition', transition: { to: 'executing' } }); + } - const stepBeforePayload = { sessionId, step: step + 1 }; - yield* hooks.emitDecision('agent.step.before', stepBeforePayload); + const transcriptPath = computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath; - const compressResult = yield* Effect.tryPromise({ - try: () => - context.compactIfNeeded( - session.getTranscriptPath(state), - messages, - llm.modelInfo.maxTokens, - llm - ), + const willCompact = yield* Effect.either(Effect.tryPromise({ + try: () => context.willCompact(transcriptPath, llm.modelInfo.maxTokens), catch: (e) => new AgentError('LLM_FAILED', String(e)), - }); - if (compressResult.didCompress && compressResult.messages) { - yield* q.offer({ - _tag: 'ReactiveCompact', - attempt: 1, - released: compressResult.released, - promptEstimate: compressResult.promptEstimate, - }); + })); + if (Either.isLeft(willCompact)) { + yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(willCompact.left) }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' }); + return Result.err(willCompact.left); + } + if (willCompact.right) { + yield* q.offer({ family: 'transition', transition: { to: 'compress' } }); + } - messages = compressResult.messages; - state.usage = undefined; + const assembled = yield* Effect.either(Effect.tryPromise({ + try: () => context.assemblePayload(transcriptPath, llm.modelInfo.maxTokens, llm), + catch: (e) => new AgentError('LLM_FAILED', String(e)), + })); + if (Either.isLeft(assembled)) { + yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(assembled.left) }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' }); + return Result.err(assembled.left); + } + if (willCompact.right) { + yield* q.offer({ family: 'transition', transition: { to: 'executing' } }); } - const llmMessages = [...messages]; + const llmMessages = [...assembled.right]; - const { stream: rawStream, response: respPromise } = llm.completeStream( - { - messages: llmMessages, - system: systemWithCatalog, - tools, - maxSteps: 1, - }, - opts.abortSignal - ); + let content = ''; + const toolCalls: ToolCall[] = []; + let responded: ResponseMeta = {}; - yield* Effect.tryPromise({ + const streamed = yield* Effect.either(Effect.tryPromise({ try: async () => { - for await (const chunk of rawStream) { - if (opts.abortSignal?.aborted) break; - Effect.runSync(q.offer({ _tag: 'LlmChunk', text: chunk })); + for await (const part of llm.completeStream({ messages: llmMessages, system, tools, maxSteps: 1 }, abortSignal)) { + if (abortSignal?.aborted) break; + if (part.type === 'text') { + content += part.text; + Effect.runSync(q.offer({ family: 'event', event: { type: 'text_delta', text: part.text } })); + } else if (part.type === 'tool_call') { + toolCalls.push({ id: part.id, name: part.name, arguments: part.args }); + Effect.runSync(q.offer({ + family: 'event', + event: { type: 'tool_call', id: part.id, name: part.name, args: part.args }, + })); + } else { + responded = part.usage ? { usage: part.usage } : {}; + Effect.runSync(q.offer({ + family: 'transition', + transition: { to: 'executing', responded }, + })); + } } }, - catch: (e) => new AgentError('LLM_FAILED', String(e)), - }); - - const llmResult = yield* Effect.tryPromise({ - try: () => respPromise, - catch: (e) => new AgentError('LLM_FAILED', String(e)), - }); - if (!llmResult.ok) { - if (llmResult.error.code === 'CONTEXT_OVERFLOW' && attempt < maxOverflowRetries) { - const compressResult = yield* Effect.tryPromise({ - try: () => - context.compactWithLLM( - session.getTranscriptPath(state), - llm.modelInfo.maxTokens, - llm, - undefined - ), - catch: (e) => new AgentError('LLM_FAILED', String(e)), - }); - if (compressResult.didCompress && compressResult.messages) { - messages = compressResult.messages; - } - yield* q.offer({ - _tag: 'ReactiveCompact', - attempt: attempt + 1, - released: compressResult.released, - promptEstimate: compressResult.promptEstimate, - }); - overflow = true; - break; - } - yield* q.offer({ _tag: 'Error', error: llmResult.error }); - lastResult = Result.err(llmResult.error); - yield* hooks.emit('agent.turn.end', { - sessionId, - turnId: state.currentTurnId, - status: 'error', - }); - break; + catch: (e) => (e instanceof AgentError ? e : new AgentError('LLM_FAILED', String(e))), + })); + if (Either.isLeft(streamed)) { + yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(streamed.left) }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' }); + return Result.err(streamed.left); } - const resp = llmResult.value; - const toolCalls = resp.toolCalls; - const assistantMsg: Message = { role: 'assistant', content: resp.content }; - if (toolCalls && toolCalls.length > 0) { - assistantMsg.tool_calls = toolCalls; - } - messages.push(assistantMsg); - yield* q.offer({ _tag: 'Assistant', content: resp.content, toolCalls }); - if (resp.usage) { - yield* q.offer({ - _tag: 'Usage', - prompt: resp.usage.prompt, - completion: resp.usage.completion, - total: resp.usage.total, - }); - } - - if (!toolCalls || toolCalls.length === 0) { - if (session) { - yield* session.recordAssistant(state, resp.content, toolCalls || [], resp.usage); - } - const stopDecision = yield* hooks.emitDecision('agent.turn.stop', { - sessionId, - content: resp.content, - turnId: state.currentTurnId, - }); + if (toolCalls.length === 0) { + yield* session.recordAssistant(state, content, [], responded.usage); + const stopDecision = yield* hooks.emitDecision('agent.turn.stop', { sessionId: sid, content, turnId: state.currentTurnId }); if (stopDecision && stopDecision.decision === 'continue') { if (stopContinuations >= effectiveMaxStopContinuations) { - yield* q.offer({ - _tag: 'Error', - error: new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded'), - }); - yield* hooks.emit('agent.turn.end', { - sessionId, - turnId: state.currentTurnId, - status: 'error', - }); - memory - .flushSessionToMemory(state.sessionId, llm, state.cwd) - .catch((e) => logger.error('memory flush failed:', e)); - return Result.err( - new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded') - ); + const loopErr = new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded'); + yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(loopErr) }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' }); + memory.flushSessionToMemory(state.sessionId, llm, state.cwd).catch((e) => logger.error('memory flush failed:', e)); + return Result.err(loopErr); } stopContinuations++; const injection = stopDecision.injection ?? '(continue)'; - if (session) { - yield* session.recordUser(state, injection); - } - messages.push({ role: 'user', content: injection }); + yield* session.recordSystem(state, injection); continue; } - if (submittedPlanTitle !== null) { - yield* hooks.emit('plan.ready', { - sessionId, - projectPath, - title: submittedPlanTitle, - }); - submittedPlanTitle = null; - } - - yield* q.offer({ _tag: 'Done', content: resp.content }); - lastResult = Result.ok(resp.content); - yield* hooks.emit('agent.turn.end', { - sessionId, - turnId: state.currentTurnId, - status: 'done', - }); + yield* offerEnd({ to: 'end', reason: 'done' }); + lastResult = Result.ok(content); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'done' }); break; } - if (toolCalls) { - for (const tc of toolCalls) { - yield* q.offer({ - _tag: 'ToolStart', - id: tc.id, - name: tc.name, - args: tc.arguments ?? {}, - }); + yield* session.recordAssistant(state, content, toolCalls, responded.usage); + + const approvedCalls: any[] = []; + const deniedResults: any[] = []; + for (const tc of toolCalls) { + const decision = yield* approval.evaluate({ + tool: tc.name, + input: tc.arguments ?? {}, + callId: tc.id, + sessionId: state.sessionId, + projectPath, + permissionMode, + profile: profile?.name, + }); + if (decision.type === 'deny') { + deniedResults.push({ type: 'denied', id: tc.id, name: tc.name, reason: decision.reason }); + } else { + approvedCalls.push(tc); } } - const record = yield* session.recordAssistant(state, resp.content, toolCalls!, resp.usage); - const allResults = yield* executor.executeBatch(toolCalls, state.sessionId, { - turnId: state.currentTurnId, - projectPath, - signal: opts.abortSignal, - approval: opts.approvalOverride, - toolLookup, - }); + const approvedResults = approvedCalls.length > 0 + ? yield* executor.executeBatch(approvedCalls, state.sessionId, { + turnId: state.currentTurnId, projectPath, signal: abortSignal, toolLookup, + }) + : []; + + const allResults = [...approvedResults, ...deniedResults]; let todoPrinted = false; for (const r of allResults) { const resultOut = r.type === 'denied' ? '' : r.output; yield* session.recordToolResult(state, r.name, r.id, resultOut); - if (r.type === 'denied') { - yield* q.offer({ _tag: 'ToolDenied', id: r.id, name: r.name, reason: r.reason }); - } else { - const isOk = r.type === 'ok'; - yield* q.offer({ - _tag: 'ToolResult', - id: r.id, - name: r.name, - output: resultOut, - ok: isOk, - }); - } - if (!messages.find((m) => m.tool_call_id === r.id)) { - const content = - r.type === 'denied' - ? `[Denied] Tool "${r.name}" was denied: ${r.reason}` - : (r.output ?? ''); - messages.push({ role: 'tool', content, tool_call_id: r.id, tool_name: r.name }); - } - if (!todoPrinted && r.name === 'todo_write') { - yield* q.offer({ _tag: 'TodoUpdate', items: todo.read(sessionId) }); - todoPrinted = true; - } - } - - const submitPlanCall = toolCalls?.find((tc) => tc.name === 'submit_plan'); - const submitPlanResult = allResults.find( - (r) => r.name === 'submit_plan' && r.type === 'ok' - ); - if (submitPlanCall && submitPlanResult && submittedPlanTitle === null) { - submittedPlanTitle = String(submitPlanCall.arguments?.title ?? ''); + const outcome: ToolOutcome = r.type === 'denied' + ? { status: 'denied', reason: r.reason } + : r.type === 'ok' + ? { status: 'ok', output: resultOut } + : { status: 'error', output: resultOut }; + const todos = !todoPrinted && r.type === 'ok' && r.name === 'todo_write' + ? todo.read(sid) + : undefined; + if (todos) todoPrinted = true; + yield* q.offer({ + family: 'event', + event: { + type: 'tool_result', id: r.id, name: r.name, outcome, + ...(todos ? { todos } : {}), + }, + }); } } - if (overflow) continue; - yield* checkpoint.snapshotFinal(projectPath, state.sessionId, state.currentTurnId); - - memory - .flushSessionToMemory(state.sessionId, llm, state.cwd) - .catch((e) => logger.error('memory flush failed:', e)); + memory.flushSessionToMemory(state.sessionId, llm, state.cwd).catch((e) => logger.error('memory flush failed:', e)); if (lastResult) return lastResult; - yield* q.offer({ _tag: 'Error', error: AgentError.maxStepsReached(effectiveMaxSteps) }); - yield* hooks.emit('agent.turn.end', { - sessionId, - turnId: state.currentTurnId, - status: 'maxSteps', - }); - return Result.err(AgentError.maxStepsReached(effectiveMaxSteps)); - } - - yield* q.offer({ _tag: 'Error', error: AgentError.maxStepsReached(effectiveMaxSteps) }); - yield* hooks.emit('agent.turn.end', { - sessionId, - turnId: state.currentTurnId, - status: 'maxSteps', - }); - memory - .flushSessionToMemory(state.sessionId, llm, state.cwd) - .catch((e) => logger.error('memory flush failed:', e)); - return Result.err(AgentError.maxStepsReached(effectiveMaxSteps)); - }).pipe( - Effect.interruptible, - Effect.onInterrupt(() => - Effect.gen(function* () { - yield* Effect.sync(() => { - Effect.runSync( - q.offer({ _tag: 'Error', error: new AgentError('AGENT_ABORTED', 'cancelled') }) - ); - }); - yield* hooks - .emit('agent.turn.end', { - sessionId, - turnId: state.currentTurnId, - status: 'aborted', - }) - .pipe(Effect.ignore); - }) - ), - Effect.ensuring( - Effect.gen(function* () { - const cp = yield* CheckpointService; - yield* cp.snapshotFinal(projectPath, sessionId, state.currentTurnId).pipe(Effect.ignore); - const mem = yield* MemoryService; - mem - .flushSessionToMemory(state.sessionId, llm, state.cwd) - .catch((e) => logger.error('memory flush failed:', e)); - }) - ) - ); -} + const maxErr = AgentError.maxStepsReached(maxSteps); + yield* offerEnd({ to: 'end', reason: 'maxSteps' }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'maxSteps' }); + return Result.err(maxErr); + }).pipe( + Effect.interruptible, + Effect.onInterrupt(() => + Effect.gen(function* () { + yield* offerEnd({ to: 'end', reason: 'aborted' }); + yield* hooks.emit('agent.turn.end', { sessionId: opts.sid, turnId: opts.state.currentTurnId, status: 'aborted' }).pipe(Effect.ignore); + }) + ), + Effect.ensuring( + Effect.gen(function* () { + yield* offerEnd({ + to: 'end', + reason: 'error', + error: { message: 'agent terminated without end frame', code: 'AGENT_TERMINATED' }, + }); + yield* checkpoint.snapshotFinal(opts.projectPath, opts.sid, opts.state.currentTurnId).pipe(Effect.ignore); + memory.flushSessionToMemory(opts.state.sessionId, opts.llm, opts.projectPath).catch((e) => logger.error('memory flush failed:', e)); + }) + ) + ); + } + + return { runTurn }; +} as any)); diff --git a/packages/codingcode/src/agent/config.ts b/packages/codingcode/src/agent/config.ts deleted file mode 100644 index 7ac00240..00000000 --- a/packages/codingcode/src/agent/config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { loadConfig } from '@codingcode/infra/config'; -import type { ResolvedConfig } from './types.js'; - -export function resolveConfig(): ResolvedConfig { - const cfg = loadConfig(); - return { - maxSteps: cfg.maxSteps ?? 250, - maxStopContinuations: cfg.maxStopContinuations ?? 3, - }; -} diff --git a/packages/codingcode/src/agent/deps.ts b/packages/codingcode/src/agent/deps.ts new file mode 100644 index 00000000..2da55f6b --- /dev/null +++ b/packages/codingcode/src/agent/deps.ts @@ -0,0 +1,100 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { AgentError } from '../core/error.js'; +import type { ToolCall, Message, ToolDescription, TodoItem } from '../core/types.js'; +import type { AssistantEvent, ToolResultEvent, TokenUsage, UserEvent } from '../session/types.js'; +import type { SessionStoreState } from '../session/types.js'; +import type { HookDecision } from '../hooks/types.js'; +import type { Skill } from '../skills/types.js'; +import type { ToolResultUnion, ToolLookup } from '../tools/port.js'; +import type { ToolDefinition } from '../tools/types.js'; +import type { LLMClient } from '../llm/client.js'; +import type { ProfileName } from '../core/types.js'; +import type { PermissionMode } from '../approval/types.js'; +import type { ApprovalDecision } from '../approval/types.js'; + +export class SessionPort extends Context.Tag('AgentSessionPort'); + create(cwd: string, opts: { model: string; activeProfile: ProfileName; permissionMode: PermissionMode }, extra?: { parentSessionId?: string; agentName?: string }): Effect.Effect; + recordUser(state: SessionStoreState, content: string): Effect.Effect; + recordSystem(state: SessionStoreState, content: string): Effect.Effect; + recordAssistant(state: SessionStoreState, content: string, toolCalls: ToolCall[], usage?: TokenUsage): Effect.Effect; + recordToolResult(state: SessionStoreState, name: string, id: string, output: string): Effect.Effect; + setPermissionMode(cwd: string, sid: string, mode: PermissionMode): Effect.Effect; + setActiveProfile(cwd: string, sid: string, profile: ProfileName): Effect.Effect; +}>() {} + +export class ToolExecutorPort extends Context.Tag('AgentToolExecutorPort'); +}>() {} + +export class CheckpointPort extends Context.Tag('AgentCheckpointPort'); + snapshotFinal(cwd: string, sid: string, turnId: number): Effect.Effect; +}>() {} + +export class HookPort extends Context.Tag('AgentHookPort')): Effect.Effect; + emitDecision(point: string, payload: Record): Effect.Effect; + disposeSession(sid: string): Effect.Effect; +}>() {} + +export class ApprovalPort extends Context.Tag('AgentApprovalPort'); callId?: string; sessionId: string; projectPath?: string; permissionMode?: PermissionMode; profile?: ProfileName }): Effect.Effect; +}>() {} + +export class SkillPort extends Context.Tag('AgentSkillPort'); +}>() {} + +export class McpPort extends Context.Tag('AgentMcpPort'); +}>() {} + +export class ContextPort extends Context.Tag('AgentContextPort'); + assemblePayload( + transcriptPath: string, + contextWindow: number, + llm: LLMClient | null + ): Promise; +}>() {} + +export class MemoryPort extends Context.Tag('AgentMemoryPort'); +}>() {} + +export class LlmPort extends Context.Tag('AgentLlmPort'); +}>() {} + +export class RulesPort extends Context.Tag('AgentRulesPort')() {} + +export class TodoPort extends Context.Tag('AgentTodoPort')() {} + +export interface ToolEnv { + provide(effect: Effect.Effect): Effect.Effect; +} + +export class ToolEnvPort extends Context.Tag('AgentToolEnvPort'); +}>() {} + +export interface ToolCatalog { + tools: ToolDescription[]; + lookup: ToolLookup; +} + +export class ToolCatalogPort extends Context.Tag('AgentToolCatalogPort')() {} diff --git a/packages/codingcode/src/agent/port.ts b/packages/codingcode/src/agent/port.ts new file mode 100644 index 00000000..a7fe3ebb --- /dev/null +++ b/packages/codingcode/src/agent/port.ts @@ -0,0 +1,26 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { FrameBody } from '../core/frame.js'; +import type { ProfileName } from '../core/types.js'; +import type { PermissionMode } from '../approval/types.js'; + +export interface RunTurnOptions { + sessionId?: string; + cwd: string; + signal?: AbortSignal; + permissionMode?: PermissionMode; + model?: string; + activeProfile?: ProfileName; +} + +export interface AgentShape { + runTurn( + input: string, + opts: RunTurnOptions + ): Effect.Effect<{ + stream: AsyncGenerator; + sessionId: string; + }>; +} + +export class AgentService extends Context.Tag('AgentService')() {} diff --git a/packages/codingcode/src/agent/profile.ts b/packages/codingcode/src/agent/profile.ts index d2f4291f..50d6adf4 100644 --- a/packages/codingcode/src/agent/profile.ts +++ b/packages/codingcode/src/agent/profile.ts @@ -1,16 +1,113 @@ -import { readFileSync } from 'fs'; -import type { DecisionHandler } from '../hooks/types.js'; -import { computePaths } from '../core/path.js'; -import type { AgentProfile } from '../subagent/types.js'; -import { BUILD_PROMPT, PLAN_PROMPT } from './prompt.js'; +import type { ProfileName } from '../core/types.js'; + +export interface AgentProfile { + name: ProfileName; + systemPrompt?: string; +} + +import { PLAN_ALLOWED_TOOLS } from '../approval/types.js'; export const PLAN_PROFILE_NAME = 'plan' as const; export const BUILD_PROFILE_NAME = 'build' as const; +export const BUILD_PROMPT = `You are a coding assistant —an AI agent that helps users with software engineering tasks. + +## How you work +- Your text output is displayed to the user as formatted text. Tool calls and their results are shown separately —the user can see what tools you used and their outcomes. +- Tools run behind a permission system. If a tool call is denied, the user declined it —adjust your approach, do not retry the same call verbatim. +- Messages may contain tags injected by the system, not by the user. They contain useful operational information —always read and follow them. + +## Rules +1. Read files before modifying them —never guess file contents +2. Use search_code or search_files to locate code before reading —this is faster than reading entire files blindly +3. Prefer editing existing files over creating new ones +4. Make small, focused changes —avoid large rewrites +5. Run tests or type-check after changes when applicable +6. If the user's request is ambiguous, ask for clarification +7. For complex or broad tasks (understanding a whole module, cross-file analysis, comprehensive search): + a. Briefly assess the task scope using your own reasoning —do not use tools for exploration at this stage, as that would consume your limited context window. + b. If you can clearly handle it without extensive file reading or searching, proceed yourself. + c. Otherwise, delegate the discovery task with dispatch_agent when a runtime-configured subagent is available. + +## Using your tools +- **Prefer dedicated tools over shell commands.** Use read_file instead of cat, edit_file instead of sed, search_code instead of grep. Dedicated tools give the user better visibility into your work. +- **Call multiple tools in parallel** when they are independent —for example, reading several files at once, or searching with different patterns. Do NOT make sequential calls when the calls don't depend on each other. +- After editing a file, do NOT re-read it to verify —the edit tool already confirms success or reports failure. Only re-read if you suspect the edit did not apply correctly. +- Reserve execute_command for actual system commands and terminal operations (git, npm, build, test). Do not use it for file operations that dedicated tools can handle. + +## Executing actions with care +Consider the reversibility and blast radius of actions before taking them: +- **Freely take** local, reversible actions: editing files, running tests, reading code. +- **Confirm with the user before** hard-to-reverse or outward-facing actions: pushing code, deleting files/branches, force-pushing, modifying CI/CD pipelines, sending messages to external services. +- **Never** use destructive commands (rm -rf /, sudo, git reset --hard, git push --force, git clean -f) unless explicitly requested and approved by the user. +- When you encounter unexpected state (unfamiliar files, branches, or configuration), investigate before deleting or overwriting —it may be the user's in-progress work. Never revert changes you did not make. + +## Git operations +- Do NOT commit changes unless the user explicitly asks you to. +- Do NOT push to remote unless the user explicitly asks you to. +- Do NOT use destructive git commands (git reset --hard, git push --force, git clean -f, git checkout -- .) unless explicitly requested and approved. +- If you notice unexpected changes in the working tree that you did not make, investigate before acting —they may be the user's in-progress work. + +## Professional objectivity +Prioritize technical accuracy over validating the user's beliefs. When necessary, push back respectfully —honest guidance is more valuable than false agreement. +- Do not begin responses with conversational interjections ("Got it", "Sure", "Great question") +- Do not apologize unnecessarily when results are unexpected + +## Follow existing conventions +When modifying code, first look at the surrounding code's style (naming, frameworks, imports) and match it: +- **Never assume a library is available** —check imports in neighboring files, or check the dependency file (package.json, cargo.toml, requirements.txt, etc.) before using it. +- **When creating a new component**, first look at existing components to understand naming conventions, typing patterns, and framework choices. +- **When editing code**, look at the surrounding context (especially imports) to understand the code's choice of frameworks and libraries, then make your change in the most idiomatic way. +- **Comments**: default to writing no comments. Only add one when the WHY is non-obvious —a hidden constraint, a subtle invariant, or a workaround for a specific bug. Do not explain WHAT the code does. + +## Code references +When referencing code, use the format \`file_path:line_number\` for easy navigation. + +## Output efficiency +- Be concise. Lead with the answer or action, not with reasoning or preamble. +- Skip filler words and unnecessary transitions. Do not restate what the user said —just do it. +- When working on a multi-step task, give brief updates at key moments (when you find something, change direction, or hit a blocker). One sentence per update is enough. +- When the task is done, give a one-to-two sentence summary of what changed. Do not narrate your entire process. +- Match the response to the question: a simple question gets a direct answer, not headers and sections. + + +Respond in the user's language. Use code blocks for code.`; + +export const PLAN_PROMPT = `You are a planning agent. Your role is to analyze the codebase and produce an implementation plan that the user reviews and approves before any code is written. + +You can read files and search code. You can submit a plan via the \`submit_plan\` tool — each call overwrites the previous plan file; use it to revise your plan based on user feedback. + +In plan profile, write_file / edit_file / execute_command are denied. The only write operation allowed is \`submit_plan\`. + +## Research process +1. Understand the project structure and conventions +2. Identify relevant files and existing patterns +3. Analyze dependencies and potential impacts +4. Assess complexity and risks +5. Check for existing implementations or similar patterns + +## Output format +When ready, call \`submit_plan({ title, plan_content: "..." })\` with a Markdown plan: +- **Current state**: What exists today +- **Key files**: Files that need modification or creation, with line references +- **Dependencies and risks**: Breaking changes, third-party concerns +- **Recommended approach**: Step-by-step implementation strategy +- **Phases**: If complex, break into ordered phases + +## After submit_plan +submit_plan returns synchronously after writing the plan file. Once you have called it, stop and wait for the user's decision — do not call submit_plan again until the user responds, and do not attempt to use any other write tool. + +The user's decision arrives as the next user message. The system has already handled the agent-profile switch (plan → build on approval, plan → plan on revise, no change on cancel); the message body itself is your signal: + +- "Implement"/"proceed"/"go ahead" (or any explicit approval) — the plan is approved. Acknowledge briefly and stop. The build agent will pick up the plan from the persisted file. +- The body contains a revised plan (a Markdown document, often with explicit section headers, or with a "Revise the plan with these changes:" wrapper) — treat the body as the new plan_content, call \`submit_plan\` again with the same title and the revised content, then stop. +- "Cancel"/"do not implement" — the plan is rejected. Acknowledge briefly and stop. + +Never re-call submit_plan on your own initiative. Never treat an implement message as a request for further exploration.`; + export const PLAN_PROFILE: AgentProfile = { name: PLAN_PROFILE_NAME, systemPrompt: PLAN_PROMPT, - maxSteps: 180, }; export const BUILD_PROFILE: AgentProfile = { @@ -18,42 +115,45 @@ export const BUILD_PROFILE: AgentProfile = { systemPrompt: BUILD_PROMPT, }; -export function isPlanProfile(p: { name: string } | null | undefined): boolean { - return p?.name === PLAN_PROFILE_NAME; -} +// 各 profile 的工具名字名单:agent 只把这份名单交给工具模块注册,工具模块按名查表装配。 +// build 含写工具、不含 submit_plan;plan 相反(只读 + submit_plan),名单即审批层白名单。 +export const PLAN_TOOL_NAMES: readonly string[] = [...PLAN_ALLOWED_TOOLS]; -export const PLAN_PROFILE_ALLOWED_TOOLS: ReadonlySet = new Set([ +export const BUILD_TOOL_NAMES: readonly string[] = [ 'read_file', - 'search_files', + 'write_file', + 'edit_file', + 'execute_command', 'search_code', + 'search_files', 'fetch_url', - 'submit_plan', -]); - -export function isSessionUsingPlanProfile(sessionId: string, cwd: string): boolean { - try { - const paths = computePaths(cwd, sessionId); - const idx = JSON.parse(readFileSync(paths.indexPath, 'utf8')) as { - activeProfile?: string; - }; - return idx?.activeProfile === PLAN_PROFILE_NAME; - } catch { - return false; - } + 'web_search', + 'todo_write', + 'dispatch_agent', +]; + +// 运行时审批兜底(plan 模式 deny 非名单工具),从名单派生 +export function isPlanProfile(p: { name: string } | null | undefined): boolean { + return p?.name === PLAN_PROFILE_NAME; } -export const planProfileGateHook: DecisionHandler = (payload) => { - const sessionId = payload.sessionId as string | undefined; - const projectPath = payload.projectPath as string | undefined; - if (!sessionId || !projectPath) return null; - if (!isSessionUsingPlanProfile(sessionId, projectPath)) return null; +export function isAgentProfileName(name: string): name is ProfileName { + return name === PLAN_PROFILE_NAME || name === BUILD_PROFILE_NAME; +} - const toolName = payload.toolName as string | undefined; - if (!toolName) return null; - if (PLAN_PROFILE_ALLOWED_TOOLS.has(toolName)) return null; +export function resolveProfile(name: ProfileName): AgentProfile { + return name === PLAN_PROFILE_NAME ? PLAN_PROFILE : BUILD_PROFILE; +} - return { - decision: 'deny', - reason: 'Write operations denied in plan profile. Use submit_plan to submit a plan.', - }; -}; +export function resolveSubagentProfile(name: string): AgentProfile | undefined { + return isAgentProfileName(name) ? resolveProfile(name) : undefined; +} + +export function getToolNames(profile: AgentProfile | undefined): readonly string[] { + return isPlanProfile(profile) ? PLAN_TOOL_NAMES : BUILD_TOOL_NAMES; +} + +export const AVAILABLE_PROFILES: Array<{ name: ProfileName; description: string }> = [ + { name: PLAN_PROFILE_NAME, description: 'Planning agent' }, + { name: BUILD_PROFILE_NAME, description: 'Build agent' }, +]; diff --git a/packages/codingcode/src/agent/prompt.ts b/packages/codingcode/src/agent/prompt.ts index 6b675856..c9372c3d 100644 --- a/packages/codingcode/src/agent/prompt.ts +++ b/packages/codingcode/src/agent/prompt.ts @@ -1,99 +1,13 @@ -import type { SystemPromptOptions } from './types.js'; - -export const BUILD_PROMPT = `You are a coding assistant —an AI agent that helps users with software engineering tasks. - -## How you work -- Your text output is displayed to the user as formatted text. Tool calls and their results are shown separately —the user can see what tools you used and their outcomes. -- Tools run behind a permission system. If a tool call is denied, the user declined it —adjust your approach, do not retry the same call verbatim. -- Messages may contain tags injected by the system, not by the user. They contain useful operational information —always read and follow them. - -## Rules -1. Read files before modifying them —never guess file contents -2. Use search_code or search_files to locate code before reading —this is faster than reading entire files blindly -3. Prefer editing existing files over creating new ones -4. Make small, focused changes —avoid large rewrites -5. Run tests or type-check after changes when applicable -6. If the user's request is ambiguous, ask for clarification -7. For complex or broad tasks (understanding a whole module, cross-file analysis, comprehensive search): - a. Briefly assess the task scope using your own reasoning —do not use tools for exploration at this stage, as that would consume your limited context window. - b. If you can clearly handle it without extensive file reading or searching, proceed yourself. - c. Otherwise, delegate the discovery task with dispatch_agent when a runtime-configured subagent is available. - -## Using your tools -- **Prefer dedicated tools over shell commands.** Use read_file instead of cat, edit_file instead of sed, search_code instead of grep. Dedicated tools give the user better visibility into your work. -- **Call multiple tools in parallel** when they are independent —for example, reading several files at once, or searching with different patterns. Do NOT make sequential calls when the calls don't depend on each other. -- After editing a file, do NOT re-read it to verify —the edit tool already confirms success or reports failure. Only re-read if you suspect the edit did not apply correctly. -- Reserve execute_command for actual system commands and terminal operations (git, npm, build, test). Do not use it for file operations that dedicated tools can handle. - -## Executing actions with care -Consider the reversibility and blast radius of actions before taking them: -- **Freely take** local, reversible actions: editing files, running tests, reading code. -- **Confirm with the user before** hard-to-reverse or outward-facing actions: pushing code, deleting files/branches, force-pushing, modifying CI/CD pipelines, sending messages to external services. -- **Never** use destructive commands (rm -rf /, sudo, git reset --hard, git push --force, git clean -f) unless explicitly requested and approved by the user. -- When you encounter unexpected state (unfamiliar files, branches, or configuration), investigate before deleting or overwriting —it may be the user's in-progress work. Never revert changes you did not make. - -## Git operations -- Do NOT commit changes unless the user explicitly asks you to. -- Do NOT push to remote unless the user explicitly asks you to. -- Do NOT use destructive git commands (git reset --hard, git push --force, git clean -f, git checkout -- .) unless explicitly requested and approved. -- If you notice unexpected changes in the working tree that you did not make, investigate before acting —they may be the user's in-progress work. - -## Professional objectivity -Prioritize technical accuracy over validating the user's beliefs. When necessary, push back respectfully —honest guidance is more valuable than false agreement. -- Do not begin responses with conversational interjections ("Got it", "Sure", "Great question") -- Do not apologize unnecessarily when results are unexpected - -## Follow existing conventions -When modifying code, first look at the surrounding code's style (naming, frameworks, imports) and match it: -- **Never assume a library is available** —check imports in neighboring files, or check the dependency file (package.json, cargo.toml, requirements.txt, etc.) before using it. -- **When creating a new component**, first look at existing components to understand naming conventions, typing patterns, and framework choices. -- **When editing code**, look at the surrounding context (especially imports) to understand the code's choice of frameworks and libraries, then make your change in the most idiomatic way. -- **Comments**: default to writing no comments. Only add one when the WHY is non-obvious —a hidden constraint, a subtle invariant, or a workaround for a specific bug. Do not explain WHAT the code does. - -## Code references -When referencing code, use the format \`file_path:line_number\` for easy navigation. - -## Output efficiency -- Be concise. Lead with the answer or action, not with reasoning or preamble. -- Skip filler words and unnecessary transitions. Do not restate what the user said —just do it. -- When working on a multi-step task, give brief updates at key moments (when you find something, change direction, or hit a blocker). One sentence per update is enough. -- When the task is done, give a one-to-two sentence summary of what changed. Do not narrate your entire process. -- Match the response to the question: a simple question gets a direct answer, not headers and sections. - - -Respond in the user's language. Use code blocks for code.`; - -export const PLAN_PROMPT = `You are a planning agent. Your role is to analyze the codebase and produce an implementation plan that the user reviews and approves before any code is written. - -You can read files and search code. You can submit a plan via the \`submit_plan\` tool — each call overwrites the previous plan file; use it to revise your plan based on user feedback. - -In plan profile, write_file / edit_file / execute_command are denied. The only write operation allowed is \`submit_plan\`. - -## Research process -1. Understand the project structure and conventions -2. Identify relevant files and existing patterns -3. Analyze dependencies and potential impacts -4. Assess complexity and risks -5. Check for existing implementations or similar patterns - -## Output format -When ready, call \`submit_plan({ title, plan_content: "..." })\` with a Markdown plan: -- **Current state**: What exists today -- **Key files**: Files that need modification or creation, with line references -- **Dependencies and risks**: Breaking changes, third-party concerns -- **Recommended approach**: Step-by-step implementation strategy -- **Phases**: If complex, break into ordered phases - -## After submit_plan -submit_plan returns synchronously after writing the plan file. Once you have called it, stop and wait for the user's decision — do not call submit_plan again until the user responds, and do not attempt to use any other write tool. - -The user's decision arrives as the next user message. The system has already handled the agent-profile switch (plan → build on approval, plan → plan on revise, no change on cancel); the message body itself is your signal: - -- "Implement"/"proceed"/"go ahead" (or any explicit approval) — the plan is approved. Acknowledge briefly and stop. The build agent will pick up the plan from the persisted file. -- The body contains a revised plan (a Markdown document, often with explicit section headers, or with a "Revise the plan with these changes:" wrapper) — treat the body as the new plan_content, call \`submit_plan\` again with the same title and the revised content, then stop. -- "Cancel"/"do not implement" — the plan is rejected. Acknowledge briefly and stop. +import { BUILD_PROMPT } from './profile.js'; + +interface SystemPromptOptions { + cwd: string; + platform: string; + shell: string; + rules?: string; + profileSystemPrompt?: string; +} -Never re-call submit_plan on your own initiative. Never treat an implement message as a request for further exploration.`; const DEFAULT_ENV_PROMPT = `## Environment - Working directory: {{cwd}} - Operating system: {{platform}} diff --git a/packages/codingcode/src/agent/stream-adapter.ts b/packages/codingcode/src/agent/stream-adapter.ts deleted file mode 100644 index c9fd1036..00000000 --- a/packages/codingcode/src/agent/stream-adapter.ts +++ /dev/null @@ -1,67 +0,0 @@ -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/todo.ts b/packages/codingcode/src/agent/todo.ts deleted file mode 100644 index c8d6ea90..00000000 --- a/packages/codingcode/src/agent/todo.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Effect } from 'effect'; -import type { Todo, TodoCounts } from './types.js'; - -export const TODO_MAX_ITEMS = 20; -export const TODO_MAX_STEP_LEN = 60; - -export function countByStatus(plan: Todo[]): TodoCounts { - const c: TodoCounts = { pending: 0, in_progress: 0, completed: 0 }; - for (const t of plan) c[t.status]++; - return c; -} - -export class TodoService extends Effect.Service()('Todo', { - sync: () => { - const store = new Map(); - - return { - read(sessionId: string): Todo[] { - return store.get(sessionId) ?? []; - }, - - write(sessionId: string, plan: Todo[]): void { - store.set(sessionId, plan); - }, - - reset(): void { - store.clear(); - }, - }; - }, -}) {} diff --git a/packages/codingcode/src/agent/tool-catalog.ts b/packages/codingcode/src/agent/tool-catalog.ts new file mode 100644 index 00000000..dbaeede1 --- /dev/null +++ b/packages/codingcode/src/agent/tool-catalog.ts @@ -0,0 +1,11 @@ +import { Layer } from 'effect'; +import { ToolCatalogPort } from './deps.js'; +import type { ToolCatalog } from './deps.js'; +import { createToolCatalog } from '../tools/catalog.js'; + +export const ToolCatalogLayer: Layer.Layer = Layer.succeed( + ToolCatalogPort, + { + register: (toolNames, mcpTools): ToolCatalog => createToolCatalog(toolNames, mcpTools), + } +); diff --git a/packages/codingcode/src/agent/tool-env.ts b/packages/codingcode/src/agent/tool-env.ts new file mode 100644 index 00000000..974f8a8b --- /dev/null +++ b/packages/codingcode/src/agent/tool-env.ts @@ -0,0 +1,30 @@ +import { Effect, Layer } from 'effect'; +import { TodoService } from '../todo/port.js'; +import { HookService } from '../hooks/port.js'; +import { McpService } from '../mcp/port.js'; +import { SubagentRunnerService } from '../subagent/port.js'; +import { ToolEnvPort } from './deps.js'; +import type { ToolEnv } from './deps.js'; + +export const ToolEnvLayer: Layer.Layer = Layer.effect( + ToolEnvPort, + Effect.succeed({ + getToolEnv: (): Effect.Effect => + Effect.gen(function* () { + const todoSvc = yield* TodoService; + const hookSvc = yield* HookService; + const mcpSvc = yield* McpService; + const subagentSvc = yield* SubagentRunnerService; + const env: ToolEnv = { + provide: (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(TodoService, todoSvc), + Effect.provideService(HookService, hookSvc), + Effect.provideService(McpService, mcpSvc), + Effect.provideService(SubagentRunnerService, subagentSvc), + ) as Effect.Effect, + }; + return env; + }), + }) +); diff --git a/packages/codingcode/src/agent/types.ts b/packages/codingcode/src/agent/types.ts deleted file mode 100644 index 4fb3cfce..00000000 --- a/packages/codingcode/src/agent/types.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { ToolCall } from '../core/types.js'; -import type { AgentError } from '../core/error.js'; -import type { SessionStoreState } from '../session/types.js'; -import type { LLMClient } from '../llm/client.js'; -import type { ToolDefinition, ToolVisibilityPolicy } from '../tools/types.js'; -import type { AgentProfile } from '../subagent/types.js'; - -export type TodoStatus = 'pending' | 'in_progress' | 'completed'; - -export interface Todo { - step: string; - status: TodoStatus; -} - -export interface TodoCounts { - pending: number; - in_progress: number; - completed: number; -} - -export interface SystemPromptOptions { - cwd: string; - platform: string; - shell: string; - rules?: string; - profileSystemPrompt?: string; -} - -export interface ResolvedConfig { - maxSteps: number; - maxStopContinuations: number; -} - -export type AgentEvent = - | { readonly _tag: 'LlmChunk'; readonly text: string } - | { readonly _tag: 'Assistant'; readonly content: string; readonly toolCalls?: ToolCall[] } - | { - readonly _tag: 'ToolStart'; - readonly id: string; - readonly name: string; - readonly args: Record; - } - | { - readonly _tag: 'ToolDenied'; - readonly id: string; - readonly name: string; - readonly reason: string; - } - | { - readonly _tag: 'ToolResult'; - readonly id: string; - readonly name: string; - readonly output: string; - readonly ok: boolean; - } - | { readonly _tag: 'Step'; readonly step: number; readonly max: number } - | { - readonly _tag: 'ReactiveCompact'; - readonly attempt: number; - readonly released: number; - readonly promptEstimate: number; - } - | { readonly _tag: 'Error'; readonly error: AgentError } - | { readonly _tag: 'Done'; readonly content: string } - | { - readonly _tag: 'TodoUpdate'; - readonly items: ReadonlyArray<{ - readonly step: string; - readonly status: 'pending' | 'in_progress' | 'completed'; - }>; - } - | { readonly _tag: 'TurnId'; readonly turnId: number } - | { - readonly _tag: 'Usage'; - readonly prompt: number; - readonly completion: number; - readonly total: number; - }; - -export interface RunStreamOptions { - state: SessionStoreState; - llm: LLMClient; - profile?: AgentProfile; - systemOverride?: string; - coreAllowlist?: ReadonlySet; - toolPolicy?: ToolVisibilityPolicy; - dispatchTool?: ToolDefinition; - mcpTools?: ToolDefinition[]; - abortSignal?: AbortSignal; - parentSessionId?: string; - agentName?: string; - maxStepsOverride?: number; - maxStopContinuations?: number; - approvalOverride?: import('../approval/index.js').ApprovalService; - rulesText?: string; -} diff --git a/packages/codingcode/src/approval/pipeline.ts b/packages/codingcode/src/approval/approval.ts similarity index 65% rename from packages/codingcode/src/approval/pipeline.ts rename to packages/codingcode/src/approval/approval.ts index d15a0921..f9851522 100644 --- a/packages/codingcode/src/approval/pipeline.ts +++ b/packages/codingcode/src/approval/approval.ts @@ -1,44 +1,93 @@ -import { Effect } from 'effect'; +import { Layer, Effect } from 'effect'; +import { HookService } from '../hooks/port.js'; import type { ApprovalDecision, PermissionMode, PermissionRule, ToolCallRequest } from './types.js'; -import type { RuleEngine } from './rule-engine.js'; +import type { ProfileName } from '../core/types.js'; +import { PLAN_ALLOWED_TOOLS } from './types.js'; +import { createRuleEngine, type RuleEngine } from './rule-engine.js'; import { userConfirmAsync } from './confirmation.js'; -import { ApprovalWaitService } from './async-confirm.js'; -import { HookService } from '../hooks/registry.js'; +import { ApprovalWaitService } from './wait-port.js'; +import { ApprovalService } from './port.js'; -export interface PipelineOptions { +const DANGEROUS_TOOL_NAMES = ['execute_command']; + +const LAYER_NAMES = [ + 'RuleEngine', + 'PermissionMode', + 'HookPreToolUse', + 'UserConfirmation', + 'AuditLog', +] as const; + +interface PipelineOptions { ruleEngine: RuleEngine; - readonlyTools: Set; destructiveTools: Set; permissionMode: PermissionMode; - /** Called when user selects Always — allows caller to persist the rule. */ + profile?: ProfileName; onAlways?: (rule: PermissionRule) => void; - /** Called when user selects Never — allows caller to persist the rule. */ onNever?: (rule: PermissionRule) => void; - /** Session ID for session-scoped approval routing. */ sessionId: string; - /** Project path for session-scoped approval routing (used by decision hooks - * that need to inspect the session's runtime state). */ projectPath?: string; - /** Optional LLM ToolCall ID to use as approval request ID. */ callId?: string; } -const LAYER_NAMES = [ - 'RuleEngine', - 'ReadonlyWhitelist', - 'PermissionMode', - 'HookPreToolUse', - 'UserConfirmation', - 'AuditLog', -] as const; +function applyPermissionMode( + tool: string, + mode: PermissionMode, + profile: ProfileName | undefined, + destructiveTools: Set +): ApprovalDecision | null { + if (profile === 'plan') { + if (PLAN_ALLOWED_TOOLS.has(tool)) { + return { type: 'allow', source: 'permission-mode' }; + } + return { + type: 'deny', + reason: 'Write operations denied in plan profile. Use submit_plan to submit a plan.', + source: 'permission-mode', + }; + } + + switch (mode) { + case 'bypass': + return { type: 'allow', source: 'permission-mode' }; + + case 'acceptEdits': + if (!destructiveTools.has(tool)) { + return { type: 'allow', source: 'permission-mode' }; + } + return null; + + case 'default': + default: + return null; + } +} + +function recordAuditAndReturn( + hooks: any, + request: ToolCallRequest, + decision: ApprovalDecision, + passedLayers: string[] +): any { + return Effect.gen(function* () { + passedLayers.push(LAYER_NAMES[4]); + yield* hooks.emit('tool.approval.post', { + tool: request.tool, + input: request.input, + decision, + layers: passedLayers, + }); + return decision; + }); +} export function runPipeline( request: ToolCallRequest, opts: PipelineOptions -): Effect.Effect { +): any { return Effect.gen(function* () { - const hooks = yield* HookService; - const approvalWait = yield* ApprovalWaitService; + const hooks: any = yield* HookService; + const approvalWait: any = yield* ApprovalWaitService; const asyncConfirm = yield* approvalWait.hasEmitter(opts.sessionId); const layers: string[] = []; @@ -52,35 +101,22 @@ export function runPipeline( } } - // Layer 2: Read-only Whitelist - { - if (opts.readonlyTools.has(request.tool)) { - const result: ApprovalDecision = { - type: 'allow', - source: 'readonly-whitelist', - }; - layers.push(LAYER_NAMES[1]); - const final = yield* recordAuditAndReturn(hooks, request, result, layers); - return final; - } - } - - // Layer 3: Permission Mode + // Layer 2: Permission Mode { const modeResult = applyPermissionMode( request.tool, opts.permissionMode, - opts.readonlyTools, + opts.profile, opts.destructiveTools ); if (modeResult) { - layers.push(LAYER_NAMES[2]); + layers.push(LAYER_NAMES[1]); const final = yield* recordAuditAndReturn(hooks, request, modeResult, layers); return final; } } - // Layer 4: Hook PreToolUse + // Layer 3: Hook PreToolUse { const hookResult = yield* Effect.gen(function* () { const result = yield* hooks.emitDecision('tool.approval.pre', { @@ -95,7 +131,7 @@ export function runPipeline( return result; }); if (hookResult) { - layers.push(LAYER_NAMES[3]); + layers.push(LAYER_NAMES[2]); if (hookResult.decision === 'deny') { const result: ApprovalDecision = { type: 'deny', @@ -110,7 +146,6 @@ export function runPipeline( const final = yield* recordAuditAndReturn(hooks, request, result, layers); return final; } - // 'ask' or no decision → continue to user confirmation const nextRequest: ToolCallRequest = { ...request }; if (hookResult.modifiedInput) { nextRequest.input = hookResult.modifiedInput; @@ -119,18 +154,9 @@ export function runPipeline( } } - // Layer 5: User Confirmation + // Layer 4: User Confirmation { - layers.push(LAYER_NAMES[4]); - - if (request.tool === 'submit_plan') { - const result: ApprovalDecision = { - type: 'allow', - source: 'system-plan-self-handles', - }; - const final = yield* recordAuditAndReturn(hooks, request, result, layers); - return final; - } + layers.push(LAYER_NAMES[3]); if (!asyncConfirm) { const result: ApprovalDecision = { @@ -173,44 +199,44 @@ export function runPipeline( }); } -function applyPermissionMode( - tool: string, - mode: PermissionMode, - readonlyTools: Set, - destructiveTools: Set -): ApprovalDecision | null { - switch (mode) { - case 'bypass': - // Bypass mode: everything allowed (sandbox still restricts at OS level) - return { type: 'allow', source: 'permission-mode' }; - - case 'acceptEdits': - // Accept edits: read-only + edit tools auto-allow, destructive tools need confirmation - if (!destructiveTools.has(tool)) { - return { type: 'allow', source: 'permission-mode' }; - } - return null; // Continue to next layers - - case 'default': - default: - return null; // Continue to next layers - } -} - -function recordAuditAndReturn( - hooks: HookService, - request: ToolCallRequest, - decision: ApprovalDecision, - passedLayers: string[] -): Effect.Effect { - return Effect.gen(function* () { - passedLayers.push(LAYER_NAMES[5]); - yield* hooks.emit('tool.approval.post', { - tool: request.tool, - input: request.input, - decision, - layers: passedLayers, - }); - return decision; - }); -} +export const ApprovalLayer = Layer.effect(ApprovalService, Effect.gen(function* () { + const hooks = yield* HookService; + const approvalWait = yield* ApprovalWaitService; + const ruleEngine: RuleEngine = createRuleEngine(); + const destructiveTools = new Set(DANGEROUS_TOOL_NAMES); + + return { + evaluate: (request: { + tool: string; + input: Record; + context?: Record; + callId?: string; + sessionId: string; + projectPath?: string; + permissionMode?: PermissionMode; + profile?: ProfileName; + }): any => + runPipeline( + { + tool: request.tool, + input: request.input, + context: request.context, + callId: request.callId, + }, + { + ruleEngine, + destructiveTools, + permissionMode: request.permissionMode ?? 'default', + profile: request.profile, + onAlways: (rule) => ruleEngine.addRule(rule), + onNever: (rule) => ruleEngine.addRule(rule), + sessionId: request.sessionId, + projectPath: request.projectPath, + callId: request.callId, + } + ).pipe( + Effect.provideService(HookService, hooks), + Effect.provideService(ApprovalWaitService, approvalWait) + ), + }; +} as any)); diff --git a/packages/codingcode/src/approval/confirmation.ts b/packages/codingcode/src/approval/confirmation.ts index a41bbac1..6c3f6045 100644 --- a/packages/codingcode/src/approval/confirmation.ts +++ b/packages/codingcode/src/approval/confirmation.ts @@ -1,6 +1,6 @@ import { Effect } from 'effect'; import type { PermissionRule } from './types.js'; -import { ApprovalWaitService } from './async-confirm.js'; +import { ApprovalWaitService } from './wait-port.js'; export type ConfirmResult = | { type: 'allow' } @@ -23,3 +23,36 @@ export function userConfirmAsync( return yield* waitSvc.waitForConfirm(id, sessionId); }); } + +export function parseApprovalResponse(response: string): ConfirmResult { + switch (response) { + case 'allow': + return { type: 'allow' }; + case 'deny': + return { type: 'deny' }; + case 'always': + return { + type: 'always', + rule: { + id: `user-allow-${Date.now()}`, + action: 'allow', + toolPattern: '*', + reason: 'User always allows', + source: 'user', + }, + }; + case 'never': + return { + type: 'never', + rule: { + id: `user-deny-${Date.now()}`, + action: 'deny', + toolPattern: '*', + reason: 'User never allows', + source: 'user', + }, + }; + default: + return { type: 'deny' }; + } +} diff --git a/packages/codingcode/src/approval/index.ts b/packages/codingcode/src/approval/index.ts deleted file mode 100644 index 251c7597..00000000 --- a/packages/codingcode/src/approval/index.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { Effect } from 'effect'; -import { HookService } from '../hooks/registry.js'; -import type { PermissionMode, PermissionRule, ApprovalDecision } from './types.js'; -import { createRuleEngine, type RuleEngine } from './rule-engine.js'; -import { DEFAULT_DENY_RULES, READONLY_TOOL_NAMES, DANGEROUS_TOOL_NAMES } from './presets.js'; -import { runPipeline } from './pipeline.js'; -import { ApprovalWaitService } from './async-confirm.js'; - -export class ApprovalService extends Effect.Service()('Approval', { - effect: Effect.gen(function* () { - const hooks = yield* HookService; - const approvalWait = yield* ApprovalWaitService; - const ruleEngine: RuleEngine = createRuleEngine(DEFAULT_DENY_RULES); - const destructiveTools = new Set(DANGEROUS_TOOL_NAMES); - const readonlyTools = new Set(READONLY_TOOL_NAMES); - - function makeForkedService( - engine: RuleEngine, - permMode: PermissionMode, - roTools: Set, - destTools: Set - ): ApprovalService { - let currentPermMode = permMode; - return ApprovalService.make({ - evaluate: (request: { - tool: string; - input: Record; - context?: Record; - callId?: string; - sessionId: string; - projectPath?: string; - }): Effect.Effect => - runPipeline( - { - tool: request.tool, - input: request.input, - context: request.context, - callId: request.callId, - }, - { - ruleEngine: engine, - readonlyTools: roTools, - destructiveTools: destTools, - permissionMode: currentPermMode, - onAlways: (rule) => engine.addRule(rule), - onNever: (rule) => engine.addRule(rule), - sessionId: request.sessionId, - projectPath: request.projectPath, - callId: request.callId, - } - ).pipe( - Effect.provideService(HookService, hooks), - Effect.provideService(ApprovalWaitService, approvalWait) - ), - addRule: (rule: PermissionRule): Effect.Effect => - Effect.sync(() => engine.addRule(rule)), - removeRule: (id: string): Effect.Effect => Effect.sync(() => engine.removeRule(id)), - setPermissionMode: (mode: PermissionMode): Effect.Effect => - Effect.sync(() => { - currentPermMode = mode; - }), - getPermissionMode: (): PermissionMode => currentPermMode, - fork: (opts?: { - extraDenyRules?: PermissionRule[]; - readonly?: boolean; - permissionMode?: PermissionMode; - }): Effect.Effect => - Effect.sync(() => { - const nextEngine = createRuleEngine(engine.getAllRules()); - if (opts?.extraDenyRules) { - for (const rule of opts.extraDenyRules) { - nextEngine.addRule(rule); - } - } - if (opts?.readonly) { - for (const toolName of DANGEROUS_TOOL_NAMES) { - nextEngine.addRule({ - id: `readonly-${toolName}`, - action: 'deny' as const, - toolPattern: toolName, - source: 'system' as const, - }); - } - } - return makeForkedService( - nextEngine, - opts?.permissionMode ?? currentPermMode, - new Set(roTools), - new Set(destTools) - ); - }), - }); - } - - return { - evaluate: (request: { - tool: string; - input: Record; - context?: Record; - callId?: string; - sessionId: string; - projectPath?: string; - }): Effect.Effect => - runPipeline( - { - tool: request.tool, - input: request.input, - context: request.context, - callId: request.callId, - }, - { - ruleEngine, - readonlyTools, - destructiveTools, - permissionMode: 'default', - onAlways: (rule) => ruleEngine.addRule(rule), - onNever: (rule) => ruleEngine.addRule(rule), - sessionId: request.sessionId, - projectPath: request.projectPath, - callId: request.callId, - } - ).pipe( - Effect.provideService(HookService, hooks), - Effect.provideService(ApprovalWaitService, approvalWait) - ), - - addRule: (rule: PermissionRule): Effect.Effect => - Effect.sync(() => ruleEngine.addRule(rule)), - - removeRule: (id: string): Effect.Effect => Effect.sync(() => ruleEngine.removeRule(id)), - - setPermissionMode: (_mode: PermissionMode): Effect.Effect => - Effect.sync(() => { - /* no-op at root; only fork children maintain their own currentPermMode */ - }), - - getPermissionMode: (): PermissionMode => 'default', - - fork: (opts?: { - extraDenyRules?: PermissionRule[]; - readonly?: boolean; - permissionMode?: PermissionMode; - }): Effect.Effect => - Effect.sync(() => { - const parentRules = ruleEngine.getAllRules(); - const childEngine = createRuleEngine(parentRules); - if (opts?.extraDenyRules) { - for (const rule of opts.extraDenyRules) { - childEngine.addRule(rule); - } - } - if (opts?.readonly) { - const denyRules: PermissionRule[] = DANGEROUS_TOOL_NAMES.map((toolName) => ({ - id: `readonly-${toolName}`, - action: 'deny' as const, - toolPattern: toolName, - source: 'system' as const, - })); - for (const rule of denyRules) { - childEngine.addRule(rule); - } - } - return makeForkedService( - childEngine, - opts?.permissionMode ?? 'default', - new Set(readonlyTools), - new Set(destructiveTools) - ); - }), - }; - }), -}) {} diff --git a/packages/codingcode/src/approval/port.ts b/packages/codingcode/src/approval/port.ts new file mode 100644 index 00000000..993a2297 --- /dev/null +++ b/packages/codingcode/src/approval/port.ts @@ -0,0 +1,10 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { ProfileName } from '../core/types.js'; +import type { PermissionMode, ApprovalDecision } from './types.js'; + +export interface ApprovalShape { + evaluate(request: { tool: string; input: Record; context?: Record; callId?: string; sessionId: string; projectPath?: string; permissionMode?: PermissionMode; profile?: ProfileName }): Effect.Effect; +} + +export class ApprovalService extends Context.Tag('Approval')() {} diff --git a/packages/codingcode/src/approval/presets.ts b/packages/codingcode/src/approval/presets.ts deleted file mode 100644 index c611e4f4..00000000 --- a/packages/codingcode/src/approval/presets.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { PermissionRule } from './types.js'; - -export const DEFAULT_DENY_RULES: PermissionRule[] = [ - { - id: 'deny-rm-rf-root', - action: 'deny', - toolPattern: '*', - argPattern: 'rm -rf /*', - reason: 'rm -rf / is not allowed', - priority: 100, - source: 'system', - }, - { - id: 'deny-sudo-raw', - action: 'deny', - toolPattern: '*', - argPattern: 'sudo *', - reason: 'Elevated commands require explicit user confirmation', - priority: 90, - source: 'system', - }, - { - id: 'deny-curl-sh', - action: 'deny', - toolPattern: '*', - argPattern: 'curl */sh', - reason: 'Piping curl to shell is not allowed', - priority: 90, - source: 'system', - }, - { - id: 'deny-chmod-suid', - action: 'deny', - toolPattern: '*', - argPattern: 'chmod u+s *', - reason: 'Setting SUID bit is not allowed', - priority: 90, - source: 'system', - }, - { - id: 'deny-shutdown', - action: 'deny', - toolPattern: '*', - argPattern: 'shutdown', - reason: 'System shutdown is not allowed', - priority: 80, - source: 'system', - }, - { - id: 'deny-etc-shadow-read', - action: 'deny', - toolPattern: 'read_file', - argPattern: '**/etc/shadow', - reason: 'Reading /etc/shadow is not allowed', - priority: 100, - source: 'system', - }, - { - id: 'deny-etc-passwd-read', - action: 'deny', - toolPattern: 'read_file', - argPattern: '**/etc/passwd', - reason: 'Reading /etc/passwd is not allowed', - priority: 100, - source: 'system', - }, - { - id: 'ask-ssh-key', - action: 'ask', - toolPattern: 'read_file', - argPattern: '**/.ssh/**', - reason: 'Reading SSH keys requires confirmation', - priority: 50, - source: 'system', - }, - { - id: 'ask-env-file', - action: 'ask', - toolPattern: 'read_file', - argPattern: '**/.env*', - reason: 'Reading environment files requires confirmation', - priority: 50, - source: 'system', - }, -]; - -export const READONLY_TOOL_NAMES: string[] = [ - 'read_file', - 'search_code', - 'search_files', - 'fetch_url', - 'web_search', - 'dispatch_agent', - 'todo_write', -]; - -export const DANGEROUS_TOOL_NAMES: string[] = ['execute_command']; diff --git a/packages/codingcode/src/approval/response.ts b/packages/codingcode/src/approval/response.ts deleted file mode 100644 index fc76704b..00000000 --- a/packages/codingcode/src/approval/response.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ConfirmResult } from './confirmation.js'; - -export function parseApprovalResponse(response: string): ConfirmResult { - switch (response) { - case 'allow': - return { type: 'allow' }; - case 'deny': - return { type: 'deny' }; - case 'always': - return { - type: 'always', - rule: { - id: `user-allow-${Date.now()}`, - action: 'allow', - toolPattern: '*', - reason: 'User always allows', - source: 'user', - }, - }; - case 'never': - return { - type: 'never', - rule: { - id: `user-deny-${Date.now()}`, - action: 'deny', - toolPattern: '*', - reason: 'User never allows', - source: 'user', - }, - }; - default: - return { type: 'deny' }; - } -} diff --git a/packages/codingcode/src/approval/types.ts b/packages/codingcode/src/approval/types.ts index f86b45b2..834afe62 100644 --- a/packages/codingcode/src/approval/types.ts +++ b/packages/codingcode/src/approval/types.ts @@ -6,6 +6,16 @@ export const PERMISSION_MODES: readonly PermissionMode[] = [ 'bypass', ] as const; +// plan 权限模式只允许这组工具(只读 + submit_plan),其余一律 deny。 +// 作为审批层的权威白名单,agent 侧的工具可见性名单也从这里派生。 +export const PLAN_ALLOWED_TOOLS: ReadonlySet = new Set([ + 'read_file', + 'search_files', + 'search_code', + 'fetch_url', + 'submit_plan', +]); + export function isPermissionMode(value: unknown): value is PermissionMode { return typeof value === 'string' && (PERMISSION_MODES as readonly string[]).includes(value); } diff --git a/packages/codingcode/src/approval/wait-port.ts b/packages/codingcode/src/approval/wait-port.ts new file mode 100644 index 00000000..8df55c24 --- /dev/null +++ b/packages/codingcode/src/approval/wait-port.ts @@ -0,0 +1,15 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { ConfirmResult } from './confirmation.js'; + +export interface ApprovalWaitShape { + waitForConfirm(id: string, sessionId: string): Effect.Effect; + resolveConfirm(id: string, sessionId: string, result: ConfirmResult): Effect.Effect; + emitApprovalRequest(sessionId: string, id: string, tool: string, args: Record): Effect.Effect; + registerEmitter(sessionId: string, fn: (id: string, tool: string, args: Record) => void): Effect.Effect; + delegateEmitter(childSessionId: string, parentSessionId: string): Effect.Effect; + unregisterEmitter(sessionId: string): Effect.Effect; + hasEmitter(sessionId: string): Effect.Effect; +} + +export class ApprovalWaitService extends Context.Tag('ApprovalWait')() {} diff --git a/packages/codingcode/src/approval/async-confirm.ts b/packages/codingcode/src/approval/wait.ts similarity index 77% rename from packages/codingcode/src/approval/async-confirm.ts rename to packages/codingcode/src/approval/wait.ts index e0228691..d76aa5e7 100644 --- a/packages/codingcode/src/approval/async-confirm.ts +++ b/packages/codingcode/src/approval/wait.ts @@ -1,13 +1,13 @@ -import { Effect, Deferred } from 'effect'; +import { Layer, Effect, Deferred } from 'effect'; import type { ConfirmResult } from './confirmation.js'; +import { ApprovalWaitService } from './wait-port.js'; interface PendingEntry { deferred: Deferred.Deferred; sessionId: string; } -export class ApprovalWaitService extends Effect.Service()('ApprovalWait', { - effect: Effect.gen(function* () { +export const ApprovalWaitLayer = Layer.effect(ApprovalWaitService, Effect.gen(function* () { const pendingConfirmations = new Map(); const approvalEmitters = new Map< string, @@ -24,27 +24,17 @@ export class ApprovalWaitService extends Effect.Service()(' resolveConfirm: ( id: string, - _sessionId: string, + sessionId: string, result: ConfirmResult ): Effect.Effect => Effect.sync(() => { const entry = pendingConfirmations.get(id); - if (!entry) return false; + if (!entry || entry.sessionId !== sessionId) return false; pendingConfirmations.delete(id); Deferred.unsafeDone(entry.deferred, Effect.succeed(result)); return true; }), - getPending: (sessionId?: string): Effect.Effect => - Effect.sync(() => { - if (sessionId) { - return Array.from(pendingConfirmations.entries()) - .filter(([_, e]) => e.sessionId === sessionId) - .map(([id]) => id); - } - return Array.from(pendingConfirmations.keys()); - }), - emitApprovalRequest: ( sessionId: string, id: string, @@ -79,5 +69,4 @@ export class ApprovalWaitService extends Effect.Service()(' hasEmitter: (sessionId: string): Effect.Effect => Effect.sync(() => approvalEmitters.has(sessionId)), }; - }), -}) {} +})); diff --git a/packages/codingcode/src/checkpoint/checkpoint-service.ts b/packages/codingcode/src/checkpoint/checkpoint.ts similarity index 50% rename from packages/codingcode/src/checkpoint/checkpoint-service.ts rename to packages/codingcode/src/checkpoint/checkpoint.ts index 165d4d88..1a0e50cf 100644 --- a/packages/codingcode/src/checkpoint/checkpoint-service.ts +++ b/packages/codingcode/src/checkpoint/checkpoint.ts @@ -1,18 +1,16 @@ -import { Effect } from 'effect'; -import { createHash } from 'crypto'; +import { Layer, Effect } from 'effect'; import { resolve } from 'path'; import { ShadowGit } from './shadow-git.js'; import { ProjectLock } from './project-lock.js'; import { normalizePath } from '../core/path.js'; -import { shortSid, commitMsg, toGitPath, hashWorkspaceFile, ProjectCache } from './utils.js'; -import { readRestoreEntry, writeRestoreEntry } from './undo-store.js'; +import { commitMsg, toGitPath, ProjectCache } from './utils.js'; import { getCompletedTurnsFor, getTurnRestorePlan, getRollbackToTurnPlan } from './turn-query.js'; import { emptyRollbackResult, executeRollback } from './rollback-engine.js'; +import { CheckpointService } from './port.js'; // ---- Effect Service ---- -export class CheckpointService extends Effect.Service()('Checkpoint', { - effect: Effect.gen(function* () { +export const CheckpointLayer = Layer.effect(CheckpointService, Effect.gen(function* () { const shadowGitByProject = new ProjectCache(10); const lockByProject = new ProjectCache(10); @@ -50,6 +48,11 @@ export class CheckpointService extends Effect.Service()('Chec doSnapshotFinal(sg, sessionId, candidate); } + function latestCompletedTurn(sg: ShadowGit, sessionId: string): number { + const completed = getCompletedTurnsFor(sg, sessionId); + return completed.length > 0 ? completed[completed.length - 1]! : 0; + } + return { snapshotBaseline: (projectPath: string, sessionId: string, turnId: number) => Effect.sync(() => { @@ -73,47 +76,11 @@ export class CheckpointService extends Effect.Service()('Chec doSnapshotFinal(sg, sessionId, turnId); }), - getCompletedTurns: (projectPath: string, sessionId: string) => - Effect.sync(() => { - const sg = ensure(projectPath); - repairIncompleteTurn(sg, sessionId); - return getCompletedTurnsFor(sg, sessionId); - }), - - getCheckpoints: (projectPath: string, sessionId: string) => - Effect.sync(() => { - const sg = ensure(projectPath); - repairIncompleteTurn(sg, sessionId); - const prefix = `turn-${shortSid(sessionId)}-`; - const completedTurns = getCompletedTurnsFor(sg, sessionId); - const result: Array<{ - turnId: number; - files: string[]; - }> = []; - - for (const i of completedTurns) { - const bCommit = sg.findCommitByMessage(`${prefix}${i}-baseline`); - if (!bCommit) continue; - const fCommit = sg.findCommitByMessage(`${prefix}${i}-final`); - if (!fCommit) continue; - - const allChanges = sg.diffFiles(bCommit, fCommit); - const files = [ - ...new Set(allChanges.map((c) => normalizePath(resolve(projectPath, c.file)))), - ]; - - result.push({ turnId: i, files }); - } - return result; - }), - getCheckpointDiff: (projectPath: string, sessionId: string, turnId?: number) => Effect.sync(() => { const sg = ensure(projectPath); repairIncompleteTurn(sg, sessionId); - const completedTurns = getCompletedTurnsFor(sg, sessionId); - const latestTurnId = - turnId ?? (completedTurns.length > 0 ? completedTurns[completedTurns.length - 1]! : 0); + const latestTurnId = turnId ?? latestCompletedTurn(sg, sessionId); if (latestTurnId === 0) { return { turnId: 0, files: [] }; } @@ -156,23 +123,18 @@ export class CheckpointService extends Effect.Service()('Chec revertCheckpointFiles: ( projectPath: string, sessionId: string, - turnId: number, + turnId: number | undefined, files: string[] ) => Effect.sync(() => { const sg = ensure(projectPath); - const plan = getTurnRestorePlan(sg, sessionId, turnId); + const targetTurnId = turnId ?? latestCompletedTurn(sg, sessionId); + if (targetTurnId === 0) return emptyRollbackResult(0); + const plan = getTurnRestorePlan(sg, sessionId, targetTurnId); if (!plan) { - return emptyRollbackResult(turnId); + return emptyRollbackResult(targetTurnId); } - return executeRollback( - sessionId, - plan, - files, - 'checkpoint-files', - sg, - lockFor(projectPath) - ); + return executeRollback(plan, files, sg, lockFor(projectPath)); }), previewRollbackDiff: (projectPath: string, sessionId: string, throughTurnId: number) => @@ -212,123 +174,10 @@ export class CheckpointService extends Effect.Service()('Chec throughTurnId, affectedTurns: plan.affectedTurns, selectedFiles: [], - restoreEntry: null, - }; - } - - return executeRollback( - sessionId, - plan, - selectedFiles, - 'rollback-to-turn', - sg, - lockFor(projectPath) - ); - }), - - undoLastCodeRollback: ( - projectPath: string, - sessionId: string, - opts?: { force?: boolean; files?: string[] } - ) => - Effect.sync(() => { - const sg = ensure(projectPath); - const entry = readRestoreEntry(sg.gitDir, sessionId); - if (!entry) { - return { - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], }; } - const normalizedOptsFiles = - opts?.files && opts.files.length > 0 - ? new Set(opts.files.map((f) => normalizePath(f).toLowerCase())) - : null; - const filesToRestore = normalizedOptsFiles - ? entry.selectedFiles.filter((f) => - normalizedOptsFiles.has(normalizePath(f).toLowerCase()) - ) - : [...entry.selectedFiles]; - - if (filesToRestore.length === 0) { - return { - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: entry.selectedFiles, - }; - } - - const baselineCommit = sg.findCommitByMessage( - commitMsg(sessionId, entry.throughTurnId, 'baseline') - ); - const conflictFiles: string[] = []; - - if (baselineCommit) { - for (const f of filesToRestore) { - const gitPath = toGitPath(projectPath, f); - const currentHash = hashWorkspaceFile(projectPath, f); - const baselineContent = sg.showFile(baselineCommit, gitPath); - const baselineHash = - baselineContent !== null - ? createHash('sha256').update(baselineContent).digest('hex') - : null; - - if (currentHash !== baselineHash) { - conflictFiles.push(f); - } - } - } - - if (conflictFiles.length > 0 && !opts?.force) { - return { - restored: false, - conflict: true, - conflictFiles, - restoredFiles: [], - remainingRolledBack: entry.selectedFiles, - }; - } - - const lock = lockFor(projectPath); - lock.lock(); - try { - sg.checkoutFiles(entry.safetyCommit, filesToRestore); - - const remainingFiles = entry.selectedFiles.filter( - (f) => - !filesToRestore.some( - (rf) => normalizePath(rf).toLowerCase() === normalizePath(f).toLowerCase() - ) - ); - if (remainingFiles.length === 0) { - writeRestoreEntry(sg.gitDir, sessionId, null); - } else { - writeRestoreEntry(sg.gitDir, sessionId, { ...entry, selectedFiles: remainingFiles }); - } - - return { - restored: true, - conflict: conflictFiles.length > 0, - conflictFiles, - restoredFiles: filesToRestore, - remainingRolledBack: remainingFiles, - }; - } finally { - lock.unlock(); - } - }), - - getLatestRestoreEntry: (projectPath: string, sessionId: string) => - Effect.sync(() => { - const sg = ensure(projectPath); - return readRestoreEntry(sg.gitDir, sessionId); + return executeRollback(plan, selectedFiles, sg, lockFor(projectPath)); }), }; - }), -}) {} +})); diff --git a/packages/codingcode/src/checkpoint/port.ts b/packages/codingcode/src/checkpoint/port.ts new file mode 100644 index 00000000..f712fc5b --- /dev/null +++ b/packages/codingcode/src/checkpoint/port.ts @@ -0,0 +1,14 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { CheckpointDiff, CodeRollbackResult, RollbackPreviewDiff } from './types.js'; + +export interface CheckpointShape { + snapshotBaseline(projectPath: string, sessionId: string, turnId: number): Effect.Effect; + snapshotFinal(projectPath: string, sessionId: string, turnId: number): Effect.Effect; + getCheckpointDiff(projectPath: string, sessionId: string, turnId?: number): Effect.Effect; + revertCheckpointFiles(projectPath: string, sessionId: string, turnId: number | undefined, files: string[]): Effect.Effect; + previewRollbackDiff(projectPath: string, sessionId: string, throughTurnId: number): Effect.Effect; + rollbackCodeToTurn(projectPath: string, sessionId: string, throughTurnId: number): Effect.Effect; +} + +export class CheckpointService extends Context.Tag('Checkpoint')() {} diff --git a/packages/codingcode/src/checkpoint/rollback-engine.ts b/packages/codingcode/src/checkpoint/rollback-engine.ts index a543660c..76bbe4e2 100644 --- a/packages/codingcode/src/checkpoint/rollback-engine.ts +++ b/packages/codingcode/src/checkpoint/rollback-engine.ts @@ -1,10 +1,6 @@ -import { createHash } from 'crypto'; -import { normalizePath } from '../core/path.js'; import type { ShadowGit } from './shadow-git.js'; import type { ProjectLock } from './project-lock.js'; -import type { CodeRollbackResult, CodeRestoreEntry, RestorePlan } from './types.js'; -import { commitMsg } from './utils.js'; -import { readRestoreEntry, writeRestoreEntry } from './undo-store.js'; +import type { CodeRollbackResult, RestorePlan } from './types.js'; export function emptyRollbackResult(turnId: number): CodeRollbackResult { return { @@ -12,15 +8,12 @@ export function emptyRollbackResult(turnId: number): CodeRollbackResult { throughTurnId: turnId, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }; } export function executeRollback( - sessionId: string, plan: RestorePlan, selectedFiles: string[], - action: CodeRestoreEntry['action'], sg: ShadowGit, lock: ProjectLock ): CodeRollbackResult { @@ -30,60 +23,18 @@ export function executeRollback( throughTurnId: plan.throughTurnId, affectedTurns: plan.affectedTurns, selectedFiles: [], - restoreEntry: null, }; } lock.lock(); try { - let safetyCommit: string; - const existingEntry = readRestoreEntry(sg.gitDir, sessionId); - - if ( - existingEntry && - existingEntry.throughTurnId === plan.throughTurnId && - existingEntry.safetyCommit - ) { - safetyCommit = existingEntry.safetyCommit; - } else { - safetyCommit = sg.commit(commitMsg(sessionId, plan.throughTurnId, 'revert-safety')); - } - - const combinedFiles = - existingEntry && existingEntry.throughTurnId === plan.throughTurnId - ? [ - ...new Map( - [...existingEntry.selectedFiles, ...selectedFiles].map((f) => [ - normalizePath(f).toLowerCase(), - f, - ]) - ).values(), - ] - : selectedFiles; - - const entry: CodeRestoreEntry = { - id: createHash('sha256') - .update(`${sessionId}-${plan.throughTurnId}-${Date.now()}`) - .digest('hex') - .slice(0, 12), - sessionId, - action, - throughTurnId: plan.throughTurnId, - affectedTurns: plan.affectedTurns, - selectedFiles: combinedFiles, - safetyCommit, - timestamp: new Date().toISOString(), - }; - writeRestoreEntry(sg.gitDir, sessionId, entry); - sg.checkoutFiles(plan.baseline, selectedFiles); return { reverted: true, throughTurnId: plan.throughTurnId, affectedTurns: plan.affectedTurns, - selectedFiles: combinedFiles, - restoreEntry: entry, + selectedFiles, }; } finally { lock.unlock(); diff --git a/packages/codingcode/src/checkpoint/types.ts b/packages/codingcode/src/checkpoint/types.ts index 74ccddf6..72935b88 100644 --- a/packages/codingcode/src/checkpoint/types.ts +++ b/packages/codingcode/src/checkpoint/types.ts @@ -14,15 +14,6 @@ export interface CodeRollbackResult { throughTurnId: number; affectedTurns: number[]; selectedFiles: string[]; - restoreEntry: CodeRestoreEntry | null; -} - -export interface CodeRollbackUndoResult { - restored: boolean; - conflict: boolean; - conflictFiles: string[]; - restoredFiles: string[]; - remainingRolledBack: string[]; } export interface RollbackPreviewDiff { @@ -31,29 +22,8 @@ export interface RollbackPreviewDiff { diff: string; } -export interface CodeRestoreEntry { - id: string; - sessionId: string; - action: 'checkpoint-files' | 'rollback-to-turn'; - throughTurnId: number; - affectedTurns: number[]; - selectedFiles: string[]; - safetyCommit: string; - timestamp: string; -} - export interface RestorePlan { throughTurnId: number; affectedTurns: number[]; baseline: string; } - -export interface RollbackState { - context: { active: boolean; currentThroughTurnId: number | null }; - code: { - canUndoLast: boolean; - lastEntry: CodeRestoreEntry | null; - revertedFiles: string[]; - lastEntryId: string | null; - }; -} diff --git a/packages/codingcode/src/checkpoint/undo-store.ts b/packages/codingcode/src/checkpoint/undo-store.ts deleted file mode 100644 index c0afd6af..00000000 --- a/packages/codingcode/src/checkpoint/undo-store.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; -import { join } from 'path'; -import type { CodeRestoreEntry } from './types.js'; -import { shortSid } from './utils.js'; - -function restorePath(gitDir: string, sessionId: string): string { - return join(gitDir, '..', `last-restore-${shortSid(sessionId)}.json`); -} - -export function readRestoreEntry(gitDir: string, sessionId: string): CodeRestoreEntry | null { - const path = restorePath(gitDir, sessionId); - if (!existsSync(path)) return null; - try { - return JSON.parse(readFileSync(path, 'utf8')) as CodeRestoreEntry; - } catch { - return null; - } -} - -export function writeRestoreEntry( - gitDir: string, - sessionId: string, - entry: CodeRestoreEntry | null -): void { - const path = restorePath(gitDir, sessionId); - if (!entry) { - try { - unlinkSync(path); - } catch { - /* ignore */ - } - } else { - writeFileSync(path, JSON.stringify(entry, null, 2), 'utf8'); - } -} diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index b3b8e56a..f3fa0eff 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -1,13 +1,13 @@ import { Effect } from 'effect'; import { serve } from '@hono/node-server'; -import { LLMFactoryService } from './llm/factory.js'; +import { LLMFactoryService } from './llm/port.js'; import { createServer } from './server/index.js'; import { createAppRuntime } from './layer.js'; import { loadConfig, ensureUserConfig } from '@codingcode/infra/config'; import { WorkspaceService, parseWorkspaceArgs } from './core/workspace.js'; import { findAvailablePort } from './server/port-discovery.js'; import { AgentError } from './core/error.js'; -import { SchedulerService } from './scheduler/service.js'; +import { SchedulerService } from './scheduler/port.js'; async function main() { const installRoot = process.cwd(); diff --git a/packages/codingcode/src/client/contracts.ts b/packages/codingcode/src/client/contracts.ts new file mode 100644 index 00000000..d4c18487 --- /dev/null +++ b/packages/codingcode/src/client/contracts.ts @@ -0,0 +1,155 @@ +import type { PermissionMode } from '../approval/types.js'; +import type { ProfileName, TokenUsage } from '../core/types.js'; +import type { + CheckpointDiff, + CodeRollbackResult, + RollbackPreviewDiff, +} from '../checkpoint/types.js'; +import type { SelectableModel } from '../llm/port.js'; +import type { McpServerConfig, McpStatus } from '../mcp/types.js'; +import type { UserHookConfig } from '../hooks/types.js'; +import type {SessionIndex } from '../session/types.js'; +import type { UITurn } from '../session/port.js'; +import type { AVAILABLE_PROFILES } from '../agent/profile.js'; +import type { Frame } from '../core/frame.js'; + +export type { TokenUsage, CheckpointDiff, CodeRollbackResult, RollbackPreviewDiff }; + +export type AvailableProfiles = typeof AVAILABLE_PROFILES; + +export interface SessionProfileInfo { + activeProfile: ProfileName; + permissionMode: PermissionMode; + cwd: string; + available: AvailableProfiles; +} + +export interface SessionPlanFile { + content: string; + path: string; + directory: string; + exists: boolean; +} + +export interface RollbackContextResult { + turns: UITurn[]; +} + +export interface RollbackBothResult { + turns: UITurn[]; + codeResult: CodeRollbackResult; +} + +export interface ForkResult { + sessionId: string; + turns: UITurn[]; +} + +export interface SessionClient { + createSession(input: { + cwd: string; + activeProfile: ProfileName; + permissionMode: PermissionMode; + model: string; + }): Promise<{ sessionId: string }>; + resumeSession(input: { sessionId: string; cwd: string }): Promise; + listSessions(input: { cwd: string }): Promise; + getSessionHistory(input: { sessionId: string; cwd: string }): Promise; + deleteSession(input: { sessionId: string; cwd: string }): Promise; + getSessionProfile(input: { sessionId: string; cwd: string }): Promise; + setSessionProfile(input: { + sessionId: string; + cwd: string; + activeProfile: ProfileName; + }): Promise<{ activeProfile: ProfileName; 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; + + getCheckpointDiff(input: { + sessionId: string; + cwd: string; + turnId?: number; + }): Promise; + revertCheckpointFiles(input: { + sessionId: string; + cwd: string; + files: string[]; + }): Promise; + previewRollbackDiff(input: { + sessionId: string; + cwd: string; + throughTurnId: number; + }): Promise; + rollbackCodeToTurn(input: { + sessionId: string; + cwd: string; + throughTurnId: number; + }): Promise; + rollbackContext(input: { + sessionId: string; + cwd: string; + throughTurnId: number; + }): Promise; + rollbackBothToTurn(input: { + sessionId: string; + cwd: string; + throughTurnId: number; + }): Promise; + forkSession(input: { + sessionId: string; + cwd: string; + atTurnId?: number; + }): Promise; +} + +export interface AgentRuntimeClient { + sendMessage( + input: string, + options: { sessionId?: string; cwd: string; signal?: AbortSignal } + ): AsyncGenerator; + + sendApprovalResponse(input: { + sessionId: string; + approvalId: string; + response: string; + }): Promise; + compact(input: { sessionId: string; cwd: string }): Promise; +} + +export interface ModelClient { + listModels(): Promise<{ models: SelectableModel[]; activeId: string | null }>; + switchModel(input: { id: string }): Promise; +} + +export interface SettingsClient { + getMemoryEnabled(): Promise; + getMemoryConfig(): Promise<{ enabled: boolean; model: string }>; + setMemoryEnabled(enabled: boolean): Promise; + setMemoryModel(model: string): Promise<{ model: string }>; + getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number }>; + setCompactionModel(compactionModel: string): Promise<{ compactionModel: string }>; + getMcpStatus(input: { cwd: string }): Promise; + setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; + resetMcpDisabled(body: { name: string; cwd: string }): Promise; + createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; + updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise; + deleteMcpServer(input: { cwd: string; name: string }): Promise; + listSkills(): Promise>; + listHooks(input: { cwd: string }): Promise; + createHook(input: { cwd: string; hook: UserHookConfig }): Promise; + updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise; + deleteHook(input: { cwd: string; name: string }): Promise; + setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise; + resetHookDisabled(body: { name: string; cwd: string }): Promise; + getGlobalPermissionMode(input: { sessionId: string; cwd: string }): Promise; + setGlobalPermissionMode(input: { + sessionId: string; + cwd: string; + mode: PermissionMode; + }): Promise; +} diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts deleted file mode 100644 index 9f7629b5..00000000 --- a/packages/codingcode/src/client/http.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { AgentClient, StreamChunk } from './types.js'; -import type { McpServerConfig } from '../mcp/types.js'; -import type { UserHookConfig } from '../hooks/types.js'; -import type { PermissionMode } from '../approval/types.js'; -import { parseSseStream } from './sse.js'; -import { createHttpClients } from './http/index.js'; - -export async function createHttpClient(serverUrl: string): Promise { - let currentSessionId: string | undefined; - const clients = createHttpClients(serverUrl); - - return { - async *sendMessage(input: string, cwd?: string): AsyncGenerator { - const response = await fetch( - `${serverUrl}/api/sessions/${currentSessionId || '_'}/messages`, - { - method: 'POST', - body: JSON.stringify({ input, cwd: cwd ?? '' }), - headers: { 'Content-Type': 'application/json' }, - } - ); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - for await (const data of parseSseStream(response)) { - switch (data.type) { - case 'session_id': - currentSessionId = data.sessionId as string; - yield { type: 'session_id', sessionId: data.sessionId as string }; - break; - case 'turn_id': - yield { type: 'turn_id', turnId: data.turnId as number }; - break; - case 'text': - yield { - type: 'text', - text: data.text as string, - messageId: data.messageId as number | undefined, - }; - break; - case 'message': - yield { - type: 'message', - id: data.id as number, - content: data.content as string, - partial: false, - }; - break; - case 'approval_request': - yield { - type: 'approval_request', - id: data.id as string, - tool: data.tool as string, - args: data.args as Record, - }; - break; - case 'tool_start': - yield { - type: 'tool_start', - id: data.id as string, - name: data.name as string, - args: data.args as Record, - }; - break; - case 'tool_result': - yield { - type: 'tool_result', - id: data.id as string, - name: data.name as string, - output: data.output as string, - ok: data.ok as boolean, - }; - break; - case 'tool_denied': - yield { - type: 'tool_denied', - id: data.id as string, - name: data.name as string, - reason: data.reason as string, - }; - break; - case 'todo_update': - yield { type: 'todo_update', items: data.items as any }; - break; - case 'usage': - yield { - type: 'usage', - prompt: data.prompt as number, - completion: data.completion as number, - total: data.total as number, - }; - break; - case 'error': - yield { type: 'error', message: data.message as string, code: data.code as string }; - return; - case 'done': - break; - case 'complete': - return; - } - } - }, - - 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: '' }); - }, - - async listSessions() { - return clients.sessions.listSessions({ cwd: '' }); - }, - - async listModels() { - return clients.models.listModels(); - }, - - async switchModel(id: string) { - await clients.models.switchModel({ id }); - }, - - getSessionId() { - return currentSessionId ?? 'unknown'; - }, - - async getCheckpoints() { - return clients.agent.getCheckpoints(); - }, - async getCheckpointDiff(turnId?: number) { - return clients.agent.getCheckpointDiff(turnId); - }, - async revertCheckpointFiles(turnId: number, files: string[]) { - return clients.agent.revertCheckpointFiles(turnId, files); - }, - async previewRollbackDiff(throughTurnId: number) { - return clients.agent.previewRollbackDiff(throughTurnId); - }, - async rollbackCodeToTurn(throughTurnId: number) { - return clients.agent.rollbackCodeToTurn(throughTurnId); - }, - async rollbackContext(throughTurnId: number) { - const res = await clients.agent.rollbackContext(throughTurnId); - return { - turns: (res as any).turns ?? [], - rollbackState: (res as any).rollbackState ?? { active: false, currentThroughTurnId: null }, - }; - }, - async rollbackBothToTurn(throughTurnId: number) { - const res = await clients.agent.rollbackBothToTurn(throughTurnId); - return { - turns: (res as any).turns ?? [], - codeResult: (res as any).codeResult ?? { - reverted: false, - throughTurnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }, - rollbackState: (res as any).rollbackState ?? { active: false, currentThroughTurnId: null }, - }; - }, - async undoLastCodeRollback(force?: boolean, files?: string[]) { - return clients.agent.undoLastCodeRollback(force, files); - }, - async getRollbackState() { - return clients.agent.getRollbackState(); - }, - async forkSession(atTurnId?: number) { - return clients.agent.forkSession(atTurnId); - }, - - async compact() { - if (!currentSessionId) return; - await clients.agent.compact({ sessionId: currentSessionId, cwd: '' }); - }, - - async getMemoryEnabled() { - const data = await clients.settings.getMemoryConfig(); - return data.enabled; - }, - - 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 getMcpStatus({ cwd }: { cwd: string }) { - return clients.settings.getMcpStatus({ cwd }); - }, - - 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 listSkills() { - return clients.settings.listSkills(); - }, - - async createMcpServer(server: McpServerConfig, { cwd }: { cwd: string }) { - await clients.settings.createMcpServer({ cwd, server }); - }, - - async updateMcpServer(name: string, server: McpServerConfig, { cwd }: { cwd: string }) { - await clients.settings.updateMcpServer({ cwd, name, server }); - }, - - async deleteMcpServer(name: string, { cwd }: { cwd: string }) { - await clients.settings.deleteMcpServer({ cwd, name }); - }, - - async listHooks({ cwd }: { cwd: string }) { - return clients.settings.listHooks({ cwd }); - }, - - async setHookDisabled(body: { name: string; disabled: boolean; cwd: string }) { - await clients.settings.setHookDisabled(body); - }, - - async resetHookDisabled(body: { name: string; cwd: string }) { - await clients.settings.resetHookDisabled(body); - }, - - async createHook(hook: UserHookConfig, { cwd }: { cwd: string }) { - await clients.settings.createHook({ cwd, hook }); - }, - - async updateHook(name: string, hook: UserHookConfig, { cwd }: { cwd: string }) { - await clients.settings.updateHook({ cwd, name, hook }); - }, - - async deleteHook(name: string, { cwd }: { cwd: string }) { - await clients.settings.deleteHook({ cwd, name }); - }, - - async getPermissionMode(input: { sessionId: string; cwd: string }) { - return clients.settings.getGlobalPermissionMode(input); - }, - - 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 3c8dfa0b..cc3b4c4c 100644 --- a/packages/codingcode/src/client/http/agent-runtime.ts +++ b/packages/codingcode/src/client/http/agent-runtime.ts @@ -1,57 +1,13 @@ -import type { StreamChunk } from '../types.js'; +import type { AgentRuntimeClient } from '../contracts.js'; +import { decodeFrame } from '../../core/frame-io.js'; import { parseSseStream } from '../sse.js'; import type { createRequestHelpers } from './request.js'; -export interface AgentRuntimeClient { - sendMessage( - input: string, - options: { sessionId?: string; cwd: string; signal?: AbortSignal } - ): AsyncGenerator; - - sendApprovalResponse(input: { - sessionId: string; - approvalId: string; - 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, apiGet } = request; + const { apiPost } = request; return { async *sendMessage(input, { sessionId, cwd, signal }) { @@ -64,94 +20,12 @@ export function createHttpAgentClient( if (!response.ok) throw new Error(`HTTP ${response.status}`); for await (const data of parseSseStream(response)) { - switch (data.type) { - case 'session_id': - yield { type: 'session_id', sessionId: data.sessionId as string }; - break; - case 'turn_id': - yield { type: 'turn_id', turnId: data.turnId as number }; - break; - case 'text': - yield { - type: 'text', - text: data.text as string, - messageId: data.messageId as number | undefined, - }; - break; - case 'message': - yield { - type: 'message', - id: data.id as number, - content: data.content as string, - partial: false, - }; - break; - case 'approval_request': - yield { - type: 'approval_request', - id: data.id as string, - tool: data.tool as string, - args: data.args as Record, - }; - break; - case 'plan_ready': - yield { - type: 'plan_ready', - sessionId: data.sessionId as string, - title: data.title as string, - }; - break; - case 'tool_start': - yield { - type: 'tool_start', - id: data.id as string, - name: data.name as string, - args: data.args as Record, - }; - break; - case 'tool_result': - yield { - type: 'tool_result', - id: data.id as string, - name: data.name as string, - output: data.output as string, - ok: data.ok as boolean, - }; - break; - case 'tool_denied': - yield { - type: 'tool_denied', - id: data.id as string, - name: data.name as string, - reason: data.reason as string, - }; - break; - case 'todo_update': - yield { type: 'todo_update', items: data.items as any }; - break; - case 'usage': - yield { - type: 'usage', - prompt: data.prompt as number, - completion: data.completion as number, - total: data.total as number, - }; - break; - case 'reactive_compact': - yield { - type: 'reactive_compact', - released: data.released as number, - promptEstimate: data.promptEstimate as number, - }; - break; - case 'error': - yield { type: 'error', message: data.message as string, code: data.code as string }; - return; - case 'done': - break; - case 'complete': - return; + const decoded = decodeFrame(data); + if (!decoded.ok) { + console.warn(`[agent-runtime] dropped frame (${decoded.reason})`, decoded.raw); + continue; } + yield decoded.frame; } }, @@ -162,49 +36,7 @@ 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 }); - }, }; } + +export type { AgentRuntimeClient }; diff --git a/packages/codingcode/src/client/http/index.ts b/packages/codingcode/src/client/http/index.ts index 59edb1de..a3394fad 100644 --- a/packages/codingcode/src/client/http/index.ts +++ b/packages/codingcode/src/client/http/index.ts @@ -1,8 +1,14 @@ import { createRequestHelpers } from './request.js'; -import { createHttpAgentClient, type AgentRuntimeClient } from './agent-runtime.js'; -import { createHttpSessionClient, type SessionClient } from './sessions.js'; -import { createHttpModelClient, type ModelClient } from './models.js'; -import { createHttpSettingsClient, type SettingsClient } from './settings.js'; +import { createHttpAgentClient } from './agent-runtime.js'; +import { createHttpSessionClient } from './sessions.js'; +import { createHttpModelClient } from './models.js'; +import { createHttpSettingsClient } from './settings.js'; +import type { + AgentRuntimeClient, + SessionClient, + ModelClient, + SettingsClient, +} from '../contracts.js'; export type { AgentRuntimeClient, SessionClient, ModelClient, SettingsClient }; diff --git a/packages/codingcode/src/client/http/models.ts b/packages/codingcode/src/client/http/models.ts index 416f5df6..a8637333 100644 --- a/packages/codingcode/src/client/http/models.ts +++ b/packages/codingcode/src/client/http/models.ts @@ -1,11 +1,6 @@ -import type { SelectableModel } from '../../llm/factory.js'; +import type { ModelClient } from '../contracts.js'; import type { createRequestHelpers } from './request.js'; -export interface ModelClient { - listModels(): Promise<{ models: SelectableModel[]; activeId: string | null }>; - switchModel(input: { id: string }): Promise; -} - export function createHttpModelClient( request: ReturnType ): ModelClient { @@ -21,3 +16,5 @@ export function createHttpModelClient( }, }; } + +export type { ModelClient }; diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts index 99551462..11da400b 100644 --- a/packages/codingcode/src/client/http/sessions.ts +++ b/packages/codingcode/src/client/http/sessions.ts @@ -1,92 +1,8 @@ import type { PermissionMode } from '../../approval/types.js'; -import type { - CheckpointDiff, - CodeRollbackResult, - CodeRollbackUndoResult, - RollbackPreviewDiff, - RollbackState, -} from '../../checkpoint/types.js'; -import type { SessionEvent, SessionIndex } from '../../session/types.js'; -import type { AgentProfileName } from '../../subagent/types.js'; +import type { SessionIndex } from '../../session/types.js'; +import type { SessionClient } from '../contracts.js'; import type { createRequestHelpers } from './request.js'; -export interface SessionClient { - createSession(input: { - cwd: string; - activeProfile: AgentProfileName; - permissionMode: PermissionMode; - model: string; - }): Promise<{ sessionId: string }>; - resumeSession(input: { sessionId: string; cwd: string }): Promise; - listSessions(input: { cwd: string }): Promise; - getSessionHistory(input: { sessionId: string; cwd: string }): Promise; - deleteSession(input: { sessionId: string; cwd: string }): Promise; - getSessionProfile(input: { sessionId: string; cwd: string }): Promise<{ - activeProfile: AgentProfileName; - permissionMode: PermissionMode; - cwd: string; - available: Array<{ name: string; description: string }>; - }>; - setSessionProfile(input: { - sessionId: string; - cwd: string; - activeProfile: AgentProfileName; - }): Promise<{ activeProfile: AgentProfileName; 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; - cwd: string; - turnId?: number; - }): Promise; - revertCheckpointFiles(input: { - sessionId: string; - cwd: string; - files: string[]; - }): Promise; - previewRollbackDiff(input: { - sessionId: string; - cwd: string; - throughTurnId: number; - }): Promise; - rollbackCodeToTurn(input: { - sessionId: string; - cwd: string; - throughTurnId: number; - }): Promise; - rollbackContext(input: { - sessionId: string; - cwd: string; - throughTurnId: number; - }): Promise<{ turns: SessionEvent[]; rollbackState: RollbackState }>; - rollbackBothToTurn(input: { sessionId: string; cwd: string; throughTurnId: number }): Promise<{ - turns: SessionEvent[]; - codeResult: CodeRollbackResult; - rollbackState: RollbackState; - }>; - undoLastCodeRollback(input: { - sessionId: string; - cwd: string; - force?: boolean; - files?: string[]; - }): Promise; - getRollbackState(input: { sessionId: string; cwd: string }): Promise; - forkSession(input: { - sessionId: string; - cwd: string; - atTurnId?: number; - }): Promise<{ sessionId: string; turns: SessionEvent[] }>; -} - export function createHttpSessionClient( request: ReturnType ): SessionClient { @@ -107,9 +23,7 @@ export function createHttpSessionClient( }, async getSessionHistory({ sessionId, cwd }) { - return apiGet( - `/api/sessions/${sessionId}/history?cwd=${encodeURIComponent(cwd)}` - ); + return apiGet(`/api/sessions/${sessionId}/history?cwd=${encodeURIComponent(cwd)}`); }, async deleteSession({ sessionId, cwd }) { @@ -147,7 +61,11 @@ export function createHttpSessionClient( }, async revertCheckpointFiles({ sessionId, cwd, files }) { - return apiPost(`/api/sessions/${sessionId}/checkpoints/latest/revert-files`, { cwd, files }); + const res = await apiPost<{ ok: boolean; result: import('../../checkpoint/types.js').CodeRollbackResult }>( + `/api/sessions/${sessionId}/checkpoints/latest/revert-files`, + { cwd, files } + ); + return res.result; }, async previewRollbackDiff({ sessionId, cwd, throughTurnId }) { @@ -157,7 +75,11 @@ export function createHttpSessionClient( }, async rollbackCodeToTurn({ sessionId, cwd, throughTurnId }) { - return apiPost(`/api/sessions/${sessionId}/rollback-code-to-turn`, { cwd, throughTurnId }); + const res = await apiPost<{ ok: boolean; result: import('../../checkpoint/types.js').CodeRollbackResult }>( + `/api/sessions/${sessionId}/rollback-code-to-turn`, + { cwd, throughTurnId } + ); + return res.result; }, async rollbackContext({ sessionId, cwd, throughTurnId }) { @@ -168,16 +90,10 @@ export function createHttpSessionClient( return apiPost(`/api/sessions/${sessionId}/rollback-both-to-turn`, { cwd, throughTurnId }); }, - async undoLastCodeRollback({ sessionId, cwd, force, files }) { - return apiPost(`/api/sessions/${sessionId}/undo-code-rollback`, { cwd, force, files }); - }, - - async getRollbackState({ sessionId, cwd }) { - return apiGet(`/api/sessions/${sessionId}/rollback-state?cwd=${encodeURIComponent(cwd)}`); - }, - async forkSession({ sessionId, cwd, atTurnId }) { return apiPost(`/api/sessions/${sessionId}/fork`, { cwd, atTurnId }); }, }; } + +export type { SessionClient }; diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index 5768be68..e06ef459 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -1,44 +1,8 @@ import type { PermissionMode } from '../../approval/types.js'; -import type { McpServerConfig, McpStatus } from '../../mcp/types.js'; -import type { UserHookConfig } from '../../hooks/types.js'; +import type { McpStatus } from '../../mcp/types.js'; +import type { SettingsClient } from '../contracts.js'; import type { createRequestHelpers } from './request.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 }>; - getMcpStatus(input: { cwd: string }): Promise; - setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; - resetMcpDisabled(body: { name: string; cwd: string }): Promise; - createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; - updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise; - deleteMcpServer(input: { cwd: string; name: string }): Promise; - listSkills(): Promise>; - listHooks(input: { cwd: string }): Promise; - createHook(input: { cwd: string; hook: UserHookConfig }): Promise; - updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise; - deleteHook(input: { cwd: string; name: string }): Promise; - setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise; - resetHookDisabled(body: { name: string; cwd: string }): Promise; - getGlobalPermissionMode(input: { sessionId: string; cwd: string }): Promise; - setGlobalPermissionMode(input: { - sessionId: string; - cwd: string; - mode: PermissionMode; - }): Promise; -} - export function createHttpSettingsClient( request: ReturnType ): SettingsClient { @@ -74,22 +38,6 @@ export function createHttpSettingsClient( await apiPost('/api/settings/memory/enabled', { enabled }); }, - async setMemoryTypeDisabled(name, disabled) { - await apiPost('/api/settings/memory/type-disabled', { name, disabled }); - }, - - async addMemoryExtraType(type) { - await apiPost('/api/settings/memory/extra-type', type); - }, - - async updateMemoryExtraType(name, type) { - await apiPut(`/api/settings/memory/extra-type/${encodeURIComponent(name)}`, type); - }, - - async deleteMemoryExtraType(name) { - await apiDelete(`/api/settings/memory/extra-type/${encodeURIComponent(name)}`); - }, - async getMcpStatus({ cwd }) { return apiGet(`/api/settings/mcp${qsCwd(cwd)}`); }, @@ -171,3 +119,5 @@ export function createHttpSettingsClient( }, }; } + +export type { SettingsClient }; diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts deleted file mode 100644 index 63756f83..00000000 --- a/packages/codingcode/src/client/types.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { PermissionMode } from '../approval/types.js'; -import type { McpServerConfig, McpStatus } from '../mcp/types.js'; -import type { UserHookConfig } from '../hooks/types.js'; -import type { SessionEvent, SessionIndex } from '../session/types.js'; -import type { SelectableModel } from '../llm/factory.js'; -import type { - CheckpointDiff, - CodeRollbackResult, - CodeRollbackUndoResult, - RollbackPreviewDiff, - RollbackState, -} from '../checkpoint/types.js'; - -export type StreamChunk = - | { type: 'session_id'; sessionId: string } - | { type: 'turn_id'; turnId: number } - | { type: 'text'; text: string; messageId?: number } - | { type: 'message'; id: number; content: string; partial: false } - | { - type: 'approval_request'; - id: string; - tool: string; - args: Record; - } - | { type: 'plan_ready'; sessionId: string; title: string } - | { type: 'tool_start'; id: string; name: string; args: Record } - | { type: 'tool_result'; id: string; name: string; output: string; ok: boolean } - | { type: 'tool_denied'; id: string; name: string; reason: string } - | { type: 'error'; message: string; code: string } - | { type: 'done' } - | { type: 'todo_update'; items: ReadonlyArray<{ step: string; status: string }> } - | { type: 'usage'; prompt: number; completion: number; total: number } - | { type: 'reactive_compact'; released: number; promptEstimate: number }; - -export interface AgentClient { - sendMessage(input: string, cwd?: string): AsyncGenerator; - sendApprovalResponse(id: string, response: string): Promise; - resumeSession(sid: string): Promise; - listSessions(): Promise; - listModels(): Promise<{ models: SelectableModel[]; activeId: string | null }>; - switchModel(id: string): Promise; - getSessionId(): string; - 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: SessionEvent[]; rollbackState: RollbackState }>; - rollbackBothToTurn(throughTurnId: number): Promise<{ - turns: SessionEvent[]; - codeResult: CodeRollbackResult; - rollbackState: RollbackState; - }>; - undoLastCodeRollback(force?: boolean, files?: string[]): Promise; - getRollbackState(): 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; - updateExtraType(name: string, type: { name: string; description: string }): Promise; - deleteExtraType(name: string): Promise; - getMcpStatus(query: { cwd: string }): Promise; - createMcpServer(server: McpServerConfig, query: { cwd: string }): Promise; - updateMcpServer(name: string, server: McpServerConfig, query: { cwd: string }): Promise; - deleteMcpServer(name: string, query: { cwd: string }): Promise; - setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; - resetMcpDisabled(body: { name: string; cwd: string }): Promise; - listSkills(): Promise>; - listHooks(query: { cwd: string }): Promise; - setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; - resetHookDisabled(body: { name: string; cwd: string }): Promise; - createHook(hook: UserHookConfig, query: { cwd: string }): Promise; - updateHook(name: string, hook: UserHookConfig, query: { cwd: string }): Promise; - deleteHook(name: string, query: { cwd: string }): Promise; - getPermissionMode(input: { sessionId: string; cwd: string }): Promise; - setPermissionMode(input: { sessionId: string; cwd: string; mode: PermissionMode }): Promise; -} diff --git a/packages/codingcode/src/context/service.ts b/packages/codingcode/src/context/context.ts similarity index 76% rename from packages/codingcode/src/context/service.ts rename to packages/codingcode/src/context/context.ts index da9e6191..e11f151f 100644 --- a/packages/codingcode/src/context/service.ts +++ b/packages/codingcode/src/context/context.ts @@ -1,13 +1,12 @@ -import { Effect } from 'effect'; +import { Layer, Effect } from 'effect'; import { randomUUID } from 'crypto'; import { readFileSync, existsSync } from 'fs'; import { loadConfig } from '@codingcode/infra/config'; import type { Message } from '../core/types.js'; -import { SessionService } from '../session/store.js'; +import { SessionService } from '../session/port.js'; import { estimateTokens, estimateMessageTokens } from '../core/util.js'; -import { appendLine, readHistory } from '../session/file-ops.js'; import { resolveLLM } from '../llm/llm-resolver.js'; -import { LLMFactoryService } from '../llm/factory.js'; +import { LLMFactoryService } from '../llm/port.js'; import { COMPACTION_SYSTEM_PROMPT } from './compaction-prompt.js'; import type { SessionEvent, @@ -17,7 +16,8 @@ import type { SummaryEvent, } from '../session/types.js'; import type { LLMClient } from '../llm/client.js'; -import type { BuildResult, CompressResult } from './types.js'; +import { ContextService } from './port.js'; +import type { CompressResult } from './port.js'; const COMPACTABLE_TOOLS = new Set([ 'read_file', @@ -34,6 +34,7 @@ const MICRO_COMPACT_THRESHOLD = 0.25; const MICRO_COMPACT_MIN_CHARS = 120; const COMPACTION_THRESHOLD = 0.85; const KEEP_RECENT_TURNS = 1; +const MAX_AUTO_COMPACT_PASSES = 3; // --- Internal: visibility computation for LLM context --- @@ -179,21 +180,25 @@ export function buildContextMessages( return filtered; } -/** Estimate prompt tokens for a session's jsonl file */ -export function estimatePromptTokens(jsonlPath: string): number { - const events = readHistory(jsonlPath); +/** Estimate prompt tokens for a filtered event stream */ +export function estimatePromptTokensFrom(events: SessionEvent[]): number { const { visible, compactedTurnIds } = filterForContext(events); return estimateTokens(buildContextMessages(visible, compactedTurnIds)); } -export class ContextService extends Effect.Service()('Context', { - effect: Effect.gen(function* () { +interface PayloadState { + jsonlPath: string; + currentTurnId: number; + visible: SessionEvent[]; + compactedTurnIds: Set; +} + +export const ContextLayer = Layer.effect(ContextService, Effect.gen(function* () { const session = yield* SessionService; const factory = yield* LLMFactoryService; - const assemblePayload = (transcriptPath: string, contextWindow: number): BuildResult => { - const jsonlPath = transcriptPath; - let events = session.readHistoryFile(jsonlPath); + const readState = (transcriptPath: string): PayloadState => { + const jsonlPath = transcriptPath; let currentTurnId = 0; const idxPath = transcriptPath.replace('.jsonl', '.index.json'); if (existsSync(idxPath)) { @@ -202,43 +207,27 @@ export class ContextService extends Effect.Service()('Context', currentTurnId = idx?.currentTurnId ?? 0; } catch {} } + const { visible, compactedTurnIds } = filterForContext(session.readEvents(jsonlPath)); + return { jsonlPath, currentTurnId, visible, compactedTurnIds }; + }; - let { visible, compactedTurnIds } = filterForContext(events); - - const preEstimate = estimateTokens(buildContextMessages(visible, compactedTurnIds)); + const estimateFor = (s: PayloadState): number => + estimateTokens(buildContextMessages(s.visible, s.compactedTurnIds)); - const didCompact = applyOldTurnCompaction( - visible, - currentTurnId, - preEstimate, - contextWindow, - jsonlPath - ); - - if (didCompact) { - events = session.readHistoryFile(jsonlPath); - ({ visible, compactedTurnIds } = filterForContext(events)); + // 微压缩:确定性截断旧 turn 的长工具输出;是否需要压缩由本模块内部判断 + function runMicroCompact(s: PayloadState, contextWindow: number): PayloadState { + if (estimateFor(s) <= contextWindow * MICRO_COMPACT_THRESHOLD) return s; + if (applyOldTurnCompact(s.visible, s.currentTurnId, s.jsonlPath)) { + return readState(s.jsonlPath); } + return s; + } - const messages = buildContextMessages(visible, compactedTurnIds); - return { - messages, - compactedEvents: visible, - promptEstimate: estimateTokens(messages), - currentTurnId, - compactedTurnIds, - }; - }; - - function applyOldTurnCompaction( + function applyOldTurnCompact( events: SessionEvent[], currentTurnId: number, - promptEstimate: number, - contextWindow: number, jsonlPath: string ): boolean { - if (promptEstimate <= contextWindow * MICRO_COMPACT_THRESHOLD) return false; - const compactedTurnIds = new Set(); for (const ev of events) { if (ev.type === 'compact') { @@ -270,78 +259,16 @@ export class ContextService extends Effect.Service()('Context', startTurnId, endTurnId, }; - appendLine(jsonlPath, compactEvent); + session.appendEvent(jsonlPath, compactEvent); return true; } - const compactIfNeeded = async ( - transcriptPath: string, - messages: Message[], - modelMaxTokens: number, - llm: LLMClient | null - ): Promise => { - const promptEstimate = estimateTokens(messages); - const threshold = modelMaxTokens * COMPACTION_THRESHOLD; - if (promptEstimate <= threshold) { - return { didCompress: false, released: 0, promptEstimate }; - } - - const result = await compactWithLLM(transcriptPath, modelMaxTokens, llm, promptEstimate); - - return result; - }; - - const compactWithLLM = async ( - transcriptPath: string, - modelMaxTokens: number, - llm: LLMClient | null, - usage?: number - ): Promise => { - let released = 0; - let preEstimate = usage; - - const threshold = modelMaxTokens * COMPACTION_THRESHOLD; - if (usage === undefined || usage - released > threshold) { - const { compactedEvents, currentTurnId, compactedTurnIds, promptEstimate } = - assemblePayload(transcriptPath, modelMaxTokens); - preEstimate = promptEstimate; - released += await tryCompaction( - transcriptPath, - llm, - compactedEvents, - currentTurnId, - compactedTurnIds - ); - } - - if (released <= 0) { - return { - didCompress: false, - released: 0, - promptEstimate: preEstimate ?? 0, - }; - } - - const postPayload = assemblePayload(transcriptPath, modelMaxTokens); - return { - didCompress: true, - released, - promptEstimate: estimateTokens(postPayload.messages), - messages: postPayload.messages, - }; - }; - - async function tryCompaction( - transcriptPath: string, - llm: LLMClient | null, - compactedEvents: SessionEvent[], - currentTurnId: number, - compactedTurnIds: Set - ): Promise { - const endTurn = currentTurnId - KEEP_RECENT_TURNS - 1; + // LLM 摘要压缩(老 turn → summary 事件),失败返回 0 释放量 + async function tryCompaction(s: PayloadState, llm: LLMClient | null): Promise { + const endTurn = s.currentTurnId - KEEP_RECENT_TURNS - 1; if (endTurn < 1) return 0; - const inRange = compactedEvents.filter((ev) => { + const inRange = s.visible.filter((ev) => { if (ev.type === 'session_meta') return false; if ('turnId' in ev && (ev as any).turnId >= 1 && (ev as any).turnId <= endTurn) return true; return false; @@ -351,7 +278,7 @@ export class ContextService extends Effect.Service()('Context', const targetEvents = getIncrementalEvents(inRange); if (targetEvents.length === 0) return 0; - const msgs = buildContextMessages(targetEvents, compactedTurnIds); + const msgs = buildContextMessages(targetEvents, s.compactedTurnIds); const totalTokens = estimateTokens(msgs); let compactionLlm = await Effect.runPromise( @@ -379,12 +306,68 @@ export class ContextService extends Effect.Service()('Context', endTurnId, summaryText: summary, }; - appendLine(transcriptPath, summaryEvent); + session.appendEvent(s.jsonlPath, summaryEvent); const summaryMsg: Message = { role: 'system', name: 'compacted_history', content: summary }; return Math.max(0, totalTokens - estimateMessageTokens(summaryMsg)); } + function needsCompaction(s: PayloadState, contextWindow: number): boolean { + return estimateFor(s) > contextWindow * COMPACTION_THRESHOLD; + } + + async function summarizeToFit( + s: PayloadState, + contextWindow: number, + llm: LLMClient | null + ): Promise<{ state: PayloadState; released: number }> { + let cur = s; + let releasedTotal = 0; + for (let i = 0; i < MAX_AUTO_COMPACT_PASSES; i++) { + if (!needsCompaction(cur, contextWindow)) break; + const released = await tryCompaction(cur, llm); + if (released <= 0) break; + releasedTotal += released; + cur = readState(cur.jsonlPath); + } + return { state: cur, released: releasedTotal }; + } + + const willCompact = async ( + transcriptPath: string, + contextWindow: number + ): Promise => { + const s = runMicroCompact(readState(transcriptPath), contextWindow); + return needsCompaction(s, contextWindow); + }; + + const assemblePayload = async ( + transcriptPath: string, + contextWindow: number, + llm: LLMClient | null + ): Promise => { + let s = readState(transcriptPath); + s = runMicroCompact(s, contextWindow); + const { state } = await summarizeToFit(s, contextWindow, llm); + return buildContextMessages(state.visible, state.compactedTurnIds); + }; + + const compactWithLLM = async ( + transcriptPath: string, + modelMaxTokens: number, + llm: LLMClient | null, + usage?: number + ): Promise => { + let s = runMicroCompact(readState(transcriptPath), modelMaxTokens); + const preEstimate = usage ?? estimateFor(s); + const released = await tryCompaction(s, llm); + if (released <= 0) { + return { didCompress: false, released: 0, promptEstimate: preEstimate }; + } + s = readState(transcriptPath); + return { didCompress: true, released, promptEstimate: estimateFor(s) }; + }; + function getIncrementalEvents(inRange: SessionEvent[]): SessionEvent[] { const existingSummary = [...inRange] .reverse() @@ -438,9 +421,8 @@ export class ContextService extends Effect.Service()('Context', } return { + willCompact, assemblePayload, - compactIfNeeded, compactWithLLM, }; - }), -}) {} +})); diff --git a/packages/codingcode/src/context/port.ts b/packages/codingcode/src/context/port.ts new file mode 100644 index 00000000..569106dc --- /dev/null +++ b/packages/codingcode/src/context/port.ts @@ -0,0 +1,17 @@ +import { Context } from 'effect'; +import type { Message } from '../core/types.js'; +import type { LLMClient } from '../llm/client.js'; + +export interface CompressResult { + didCompress: boolean; + released: number; + promptEstimate: number; +} + +export interface ContextShape { + willCompact(transcriptPath: string, contextWindow: number): Promise; + assemblePayload(transcriptPath: string, contextWindow: number, llm: LLMClient | null): Promise; + compactWithLLM(transcriptPath: string, modelMaxTokens: number, llm: LLMClient | null, usage?: number): Promise; +} + +export class ContextService extends Context.Tag('Context')() {} diff --git a/packages/codingcode/src/context/types.ts b/packages/codingcode/src/context/types.ts deleted file mode 100644 index 66f0118a..00000000 --- a/packages/codingcode/src/context/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Message } from '../core/types.js'; -import type { SessionEvent } from '../session/types.js'; - -export interface BuildResult { - messages: Message[]; - compactedEvents: SessionEvent[]; - promptEstimate: number; - currentTurnId: number; - compactedTurnIds: Set; -} - -export interface CompressResult { - didCompress: boolean; - released: number; - promptEstimate: number; - messages?: Message[]; -} diff --git a/packages/codingcode/src/core/frame-io.ts b/packages/codingcode/src/core/frame-io.ts new file mode 100644 index 00000000..891a1863 --- /dev/null +++ b/packages/codingcode/src/core/frame-io.ts @@ -0,0 +1,125 @@ +import type { EndReason, Frame, FrameBody, FrameError, ToolOutcome } from './frame.js'; + +export interface FrameAssembler { + stamp(body: FrameBody): Frame; +} + +export function createFrameAssembler(opts: { readonly sessionId: string }): FrameAssembler { + const { sessionId } = opts; + let turnId: number | null = null; + let seq = 0; + + return { + stamp(body: FrameBody): Frame { + if (body.family === 'transition' && body.transition.to === 'start') { + turnId = body.transition.turnId; + } + seq += 1; + return { sessionId, turnId, seq, ...body }; + }, + }; +} + +export function encodeFrame(frame: Frame): string { + return JSON.stringify(frame); +} + +export type DecodeFailureReason = + | 'shape' + | 'unknown-family' + | 'unknown-transition' + | 'unknown-event'; + +export type DecodeResult = + | { readonly ok: true; readonly frame: Frame } + | { readonly ok: false; readonly reason: DecodeFailureReason; readonly raw: unknown }; + +const TRANSITIONS = new Set(['start', 'executing', 'compress', 'end']); +const EVENTS = new Set(['text_delta', 'tool_call', 'tool_result', 'approval_request']); +const END_REASONS = new Set(['done', 'error', 'maxSteps', 'aborted']); +const OUTCOMES = new Set(['ok', 'error', 'denied']); + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null; +} + +function isError(v: unknown): v is FrameError { + return isRecord(v) && typeof v.message === 'string' && typeof v.code === 'string'; +} + +function isOutcome(v: unknown): v is ToolOutcome { + if (!isRecord(v) || typeof v.status !== 'string' || !OUTCOMES.has(v.status)) return false; + if (v.status === 'denied') return typeof v.reason === 'string'; + return typeof v.output === 'string'; +} + +export function decodeFrame(raw: unknown): DecodeResult { + if (!isRecord(raw)) return { ok: false, reason: 'shape', raw }; + if (typeof raw.sessionId !== 'string' || typeof raw.seq !== 'number') { + return { ok: false, reason: 'shape', raw }; + } + const turnId = raw.turnId; + if (turnId !== null && typeof turnId !== 'number') { + return { ok: false, reason: 'shape', raw }; + } + + const family = raw.family; + if (family === 'fatal') { + if (!isError(raw.fatal)) return { ok: false, reason: 'shape', raw }; + return { ok: true, frame: raw as unknown as Frame }; + } + + if (family === 'transition') { + const t = raw.transition; + if (!isRecord(t) || typeof t.to !== 'string' || !TRANSITIONS.has(t.to)) { + return { ok: false, reason: 'unknown-transition', raw }; + } + if (t.to === 'start' && typeof t.turnId !== 'number') { + return { ok: false, reason: 'shape', raw }; + } + if (t.to === 'end') { + if (typeof t.reason !== 'string' || !END_REASONS.has(t.reason as EndReason)) { + return { ok: false, reason: 'shape', raw }; + } + if (t.reason === 'error' && !isError(t.error)) { + return { ok: false, reason: 'shape', raw }; + } + } + if (t.to === 'executing') { + if (t.responded !== undefined && !isRecord(t.responded)) { + return { ok: false, reason: 'shape', raw }; + } + } + return { ok: true, frame: raw as unknown as Frame }; + } + + if (family === 'event') { + const e = raw.event; + if (!isRecord(e) || typeof e.type !== 'string' || !EVENTS.has(e.type)) { + return { ok: false, reason: 'unknown-event', raw }; + } + if (e.type === 'text_delta' && typeof e.text !== 'string') { + return { ok: false, reason: 'shape', raw }; + } + if ( + (e.type === 'tool_call' || e.type === 'approval_request') && + (typeof e.id !== 'string' || !isRecord(e.args)) + ) { + return { ok: false, reason: 'shape', raw }; + } + if (e.type === 'tool_call' && typeof e.name !== 'string') { + return { ok: false, reason: 'shape', raw }; + } + if (e.type === 'tool_result') { + if (typeof e.id !== 'string' || typeof e.name !== 'string' || !isOutcome(e.outcome)) { + return { ok: false, reason: 'shape', raw }; + } + } + if (e.type === 'approval_request' && typeof e.tool !== 'string') { + return { ok: false, reason: 'shape', raw }; + } + return { ok: true, frame: raw as unknown as Frame }; + } + + return { ok: false, reason: 'unknown-family', raw }; +} diff --git a/packages/codingcode/src/core/frame.ts b/packages/codingcode/src/core/frame.ts new file mode 100644 index 00000000..eedd14bd --- /dev/null +++ b/packages/codingcode/src/core/frame.ts @@ -0,0 +1,76 @@ +import type { TodoItem, TokenUsage } from './types.js'; + +export type EndReason = 'done' | 'error' | 'maxSteps' | 'aborted'; + +export interface FrameError { + readonly message: string; + readonly code: string; +} + +export interface ResponseMeta { + readonly usage?: TokenUsage; +} + +export interface Envelope { + readonly sessionId: string; + readonly turnId: number | null; + readonly seq: number; +} + +export type Transition = + | { readonly to: 'start'; readonly turnId: number } + | { + readonly to: 'executing'; + readonly responded?: ResponseMeta; + } + | { readonly to: 'compress' } + | { readonly to: 'end'; readonly reason: 'done' | 'maxSteps' | 'aborted' } + | { readonly to: 'end'; readonly reason: 'error'; readonly error: FrameError }; + +export type ToolOutcome = + | { readonly status: 'ok'; readonly output: string } + | { readonly status: 'error'; readonly output: string } + | { readonly status: 'denied'; readonly reason: string }; + +export type RuntimeEvent = + | { readonly type: 'text_delta'; readonly text: string } + | { + readonly type: 'tool_call'; + readonly id: string; + readonly name: string; + readonly args: Readonly>; + } + | { + readonly type: 'tool_result'; + readonly id: string; + readonly name: string; + readonly outcome: ToolOutcome; + /** 仅当本次结果使会话 todo 变更时携带 */ + readonly todos?: readonly TodoItem[]; + } + | { + readonly type: 'approval_request'; + readonly id: string; + readonly tool: string; + readonly args: Readonly>; + }; + +export interface Fatal { + readonly message: string; + readonly code: string; +} + +export type FrameBody = + | { readonly family: 'transition'; readonly transition: Transition } + | { readonly family: 'event'; readonly event: RuntimeEvent } + | { readonly family: 'fatal'; readonly fatal: Fatal }; + +export type Frame = Envelope & FrameBody; + +type EndTransition = Extract; + +export function isTurnEnd( + body: FrameBody +): body is { readonly family: 'transition'; readonly transition: EndTransition } { + return body.family === 'transition' && body.transition.to === 'end'; +} diff --git a/packages/codingcode/src/core/types.ts b/packages/codingcode/src/core/types.ts index 58abba64..a99c4585 100644 --- a/packages/codingcode/src/core/types.ts +++ b/packages/codingcode/src/core/types.ts @@ -1,3 +1,18 @@ +export type ProfileName = 'plan' | 'build'; + +export interface TokenUsage { + prompt: number; + completion: number; + total: number; +} + +export type TodoStatus = 'pending' | 'in_progress' | 'completed'; + +export interface TodoItem { + step: string; + status: TodoStatus; +} + export interface ToolDescription { name: string; description: string; @@ -13,7 +28,7 @@ export interface Message { tool_call_id?: string; tool_name?: string; name?: string; - usage?: { prompt: number; completion: number; total: number }; + usage?: TokenUsage; } export interface ToolCall { diff --git a/packages/codingcode/src/direct/agent-runtime.ts b/packages/codingcode/src/direct/agent-runtime.ts index 578be493..dbe60b1b 100644 --- a/packages/codingcode/src/direct/agent-runtime.ts +++ b/packages/codingcode/src/direct/agent-runtime.ts @@ -1,185 +1,83 @@ 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 { AgentService } from '../agent/port.js'; +import { ApprovalWaitService } from '../approval/wait-port.js'; +import { parseApprovalResponse } from '../approval/confirmation.js'; +import { ContextService } from '../context/port.js'; +import { SessionService } from '../session/port.js'; +import type { SessionStatePort } from '../session/port.js'; +import { computePaths } from '../core/path.js'; +import type { AgentRuntimeClient } from '../client/contracts.js'; +import type { FrameBody } from '../core/frame.js'; +import { createFrameAssembler } from '../core/frame-io.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] = {}; + const runOpts: { cwd: string; activeProfile?: 'build'; permissionMode?: 'default' } = { cwd }; if (!sessionId) { - opts.activeProfile = 'build'; - opts.permissionMode = 'default'; - opts.model = llm.modelInfo.model; + runOpts.activeProfile = 'build'; + runOpts.permissionMode = 'default'; } - const program = sendMessage(sessionId || undefined, input, cwd, llm, opts); - const { stream: agentGen, sessionId: resolvedSessionId } = (await rt.runPromise( - program - )) as any; - currentSessionId = resolvedSessionId; + const { stream: agentGen, sessionId: resolvedSessionId } = await rt.runPromise( + Effect.gen(function* () { + const agent = yield* AgentService; + return yield* agent.runTurn(input, { sessionId: sessionId || undefined, ...runOpts }); + }) + ); - yield { type: 'session_id', sessionId: resolvedSessionId }; + const assembler = createFrameAssembler({ sessionId: resolvedSessionId }); - let notifyApproval: ((req: StreamChunk) => void) | null = null; - let notifyPlan: ((req: StreamChunk) => void) | null = null; + let notifyApproval: ((body: FrameBody) => 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 }); + notifyApproval?.({ family: 'event', event: { 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) => { + let pending = agentGen.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', + pending.then((c): { tag: 'body'; value: IteratorResult } => ({ + tag: 'body', value: c, })), - approvalPromise.then((req): { tag: 'approval'; value: StreamChunk } => ({ + approvalPromise.then((body): { tag: 'approval'; value: FrameBody } => ({ tag: 'approval', - value: req, - })), - planPromise.then((req): { tag: 'plan'; value: StreamChunk } => ({ - tag: 'plan', - value: req, + value: body, })), ]); - if (winner.tag === 'chunk') { + if (winner.tag === 'body') { 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) => { + yield assembler.stamp(winner.value.value); + currentApprovalPromise = new Promise((resolve) => { notifyApproval = resolve; }); + pending = agentGen.next(); } else { - yield winner.value; - currentPlanPromise = new Promise((resolve) => { - notifyPlan = resolve; + yield assembler.stamp(winner.value); + currentApprovalPromise = new Promise((resolve) => { + notifyApproval = resolve; }); } } } finally { - unregisterPlanReady(); Effect.runSync(waitService.unregisterEmitter(resolvedSessionId)); } }, @@ -197,149 +95,20 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu async compact({ sessionId, cwd }) { await rt.runPromise( Effect.gen(function* () { - const session = yield* SessionService; + const session: SessionStatePort = yield* SessionService; const context = yield* ContextService; const state = yield* session.load(cwd, sessionId); return yield* Effect.promise(() => - context.compactWithLLM(session.getTranscriptPath(state), llm.modelInfo.maxTokens, null) + context.compactWithLLM( + computePaths(state.cwd, state.sessionId, state.parentSessionId).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 }; - }) - ); - }, }; } + +export type { AgentRuntimeClient }; diff --git a/packages/codingcode/src/direct/models.ts b/packages/codingcode/src/direct/models.ts index 57942dc1..55a1fbb8 100644 --- a/packages/codingcode/src/direct/models.ts +++ b/packages/codingcode/src/direct/models.ts @@ -1,13 +1,8 @@ import { Effect } from 'effect'; -import { LLMFactoryService } from '../llm/factory.js'; -import type { SelectableModel } from '../llm/factory.js'; +import { LLMFactoryService } from '../llm/port.js'; +import type { ModelClient } from '../client/contracts.js'; import type { AppRuntime } from '../layer.js'; -export interface ModelClient { - listModels(): Promise<{ models: SelectableModel[]; activeId: string | null }>; - switchModel(input: { id: string }): Promise; -} - export function createDirectModelClient(rt: AppRuntime): ModelClient { return { async listModels() { diff --git a/packages/codingcode/src/direct/sessions.ts b/packages/codingcode/src/direct/sessions.ts index 6b3d191b..e0f52ee4 100644 --- a/packages/codingcode/src/direct/sessions.ts +++ b/packages/codingcode/src/direct/sessions.ts @@ -1,105 +1,20 @@ import { Effect } from 'effect'; import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; import { join } from 'path'; -import { SessionService } from '../session/store.js'; -import { deleteSession } from '../session/file-ops.js'; +import { SessionService } from '../session/port.js'; +import type { SessionStorePort } from '../session/port.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 } from '../session/types.js'; -import type { AgentProfileName } from '../subagent/types.js'; +import { AVAILABLE_PROFILES } from '../agent/profile.js'; +import type { SessionClient } from '../client/contracts.js'; import type { AppRuntime } from '../layer.js'; -export interface SessionClient { - createSession(input: { - cwd: string; - activeProfile: AgentProfileName; - permissionMode: PermissionMode; - model: string; - }): Promise<{ sessionId: string }>; - resumeSession(input: { sessionId: string; cwd: string }): Promise; - listSessions(input: { cwd: string }): Promise; - getSessionHistory(input: { sessionId: string; cwd: string }): Promise; - - deleteSession(input: { sessionId: string; cwd: string }): Promise; - getSessionProfile(input: { sessionId: string; cwd: string }): Promise<{ - activeProfile: AgentProfileName; - permissionMode: PermissionMode; - cwd: string; - available: Array<{ name: string; description: string }>; - }>; - setSessionProfile(input: { - sessionId: string; - cwd: string; - activeProfile: AgentProfileName; - }): Promise<{ activeProfile: AgentProfileName; 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; - cwd: string; - turnId?: number; - }): Promise; - revertCheckpointFiles(input: { - sessionId: string; - cwd: string; - files: string[]; - }): Promise; - previewRollbackDiff(input: { - sessionId: string; - cwd: string; - throughTurnId: number; - }): Promise; - rollbackCodeToTurn(input: { - sessionId: string; - cwd: string; - throughTurnId: number; - }): Promise; - rollbackContext(input: { - sessionId: string; - cwd: string; - throughTurnId: number; - }): Promise<{ turns: SessionEvent[]; rollbackState: RollbackState }>; - rollbackBothToTurn(input: { sessionId: string; cwd: string; throughTurnId: number }): Promise<{ - turns: SessionEvent[]; - codeResult: CodeRollbackResult; - rollbackState: RollbackState; - }>; - undoLastCodeRollback(input: { - sessionId: string; - cwd: string; - force?: boolean; - files?: string[]; - }): Promise; - getRollbackState(input: { sessionId: string; cwd: string }): Promise; - forkSession(input: { - sessionId: string; - cwd: string; - atTurnId?: number; - }): Promise<{ sessionId: string; turns: SessionEvent[] }>; -} - export function createDirectSessionClient(rt: AppRuntime): SessionClient { return { async createSession({ cwd, activeProfile, permissionMode, model }) { return rt.runPromise( Effect.gen(function* () { - const session = yield* SessionService; + const session: SessionStorePort = yield* SessionService; const state = yield* session.create(cwd, { model, activeProfile, @@ -113,9 +28,8 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { async resumeSession({ sessionId, cwd }) { return rt.runPromise( Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - return yield* session.readHistory(state); + const session: SessionStorePort = yield* SessionService; + return yield* session.readUITurns(sessionId, cwd); }) ); }, @@ -123,7 +37,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { async listSessions({ cwd }) { return rt.runPromise( Effect.gen(function* () { - const session = yield* SessionService; + const session: SessionStorePort = yield* SessionService; return yield* session.listSessions(cwd); }) ); @@ -132,30 +46,31 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { async getSessionHistory({ sessionId, cwd }) { return rt.runPromise( Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - return yield* session.readHistory(state); + const session: SessionStorePort = yield* SessionService; + return yield* session.readUITurns(sessionId, cwd); }) ); }, async deleteSession({ sessionId, cwd }) { - deleteSession(sessionId, cwd); + await rt.runPromise( + Effect.gen(function* () { + const session: SessionStorePort = yield* SessionService; + yield* session.deleteSession(sessionId, cwd); + }) + ); }, async getSessionProfile({ sessionId, cwd }) { return rt.runPromise( Effect.gen(function* () { - const session = yield* SessionService; + const session: SessionStorePort = yield* SessionService; const state = yield* session.load(cwd, sessionId); return { activeProfile: state.activeProfile, permissionMode: state.permissionMode, cwd, - available: [ - { name: 'plan', description: 'Planning agent' }, - { name: 'build', description: 'Default build agent' }, - ], + available: AVAILABLE_PROFILES, }; }) ); @@ -164,7 +79,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { async setSessionProfile({ sessionId, cwd, activeProfile }) { return rt.runPromise( Effect.gen(function* () { - const session = yield* SessionService; + const session: SessionStorePort = yield* SessionService; yield* session.setActiveProfile(cwd, sessionId, activeProfile); const state = yield* session.load(cwd, sessionId); return { activeProfile: state.activeProfile, permissionMode: state.permissionMode }; @@ -175,9 +90,9 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { async getSessionPermissionMode({ sessionId, cwd }): Promise { const mode = await rt.runPromise( Effect.gen(function* () { - const session = yield* SessionService; + const session: SessionStorePort = yield* SessionService; const state = yield* session.load(cwd, sessionId); - return yield* session.getPermissionMode(state); + return state.permissionMode; }) ); return mode as PermissionMode; @@ -186,9 +101,8 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { async setSessionPermissionMode({ sessionId, cwd, mode }) { return rt.runPromise( Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - yield* session.setPermissionMode(state, mode); + const session: SessionStorePort = yield* SessionService; + yield* session.setPermissionMode(cwd, sessionId, mode); }) ); }, @@ -223,7 +137,6 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { throughTurnId: 0, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }; }, async previewRollbackDiff() { @@ -235,68 +148,31 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { throughTurnId: 0, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }; }, async rollbackContext() { - return { - turns: [] as SessionEvent[], - rollbackState: { - context: { active: false, currentThroughTurnId: null }, - code: { - canUndoLast: false, - lastEntry: null, - revertedFiles: [] as string[], - lastEntryId: null, - }, - } as RollbackState, - }; + return { turns: [] }; }, async rollbackBothToTurn() { return { - turns: [] as SessionEvent[], + turns: [], 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, - }, - } as RollbackState, - }; - }, - async undoLastCodeRollback() { - return { - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], - }; - }, - async getRollbackState() { - return { - context: { active: false, currentThroughTurnId: null }, - code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: null }, }; }, async forkSession({ sessionId, cwd, atTurnId }) { const newSessionId = await rt.runPromise( Effect.gen(function* () { - const session = yield* SessionService; + const session: SessionStorePort = yield* SessionService; const state = yield* session.load(cwd, sessionId); return yield* session.forkSession(state, atTurnId ?? 0); }) ); - return { sessionId: newSessionId, turns: [] as SessionEvent[] }; + return { sessionId: newSessionId, turns: [] }; }, }; } diff --git a/packages/codingcode/src/direct/settings.ts b/packages/codingcode/src/direct/settings.ts index f9b282c3..0629c0d4 100644 --- a/packages/codingcode/src/direct/settings.ts +++ b/packages/codingcode/src/direct/settings.ts @@ -1,7 +1,7 @@ import { Effect } from 'effect'; -import { McpService } from '../mcp/index.js'; +import { McpService } from '../mcp/port.js'; import type { McpServerConfig, McpStatus } from '../mcp/types.js'; -import { SkillService } from '../skills/service.js'; +import { SkillService } from '../skills/port.js'; import type { PermissionMode } from '../approval/types.js'; import type { UserHookConfig } from '../hooks/types.js'; import { isGlobalCwd } from '../core/workspace.js'; @@ -26,15 +26,8 @@ import { resetProjectHookDisabledState, } from '../hooks/config.js'; import { setHookRuntimeEnabled } from '../hooks/executor.js'; -import { - getMemoryConfig, - getAllTypesWithStatus, - setMemoryTypeDisabled, - addMemoryExtraType as _addMemoryExtraType, - updateMemoryExtraType as _updateMemoryExtraType, - deleteMemoryExtraType as _deleteMemoryExtraType, -} from '../memory/config.js'; -import { MemoryService } from '../memory/index.js'; +import { getMemoryConfig } from '../memory/config.js'; +import { MemoryService } from '../memory/port.js'; import { AlreadyExistsError, NotFoundError } from '../core/error.js'; import { loadConfig, @@ -42,43 +35,8 @@ import { 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 }>; - getMcpStatus(input: { cwd: string }): Promise; - setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; - resetMcpDisabled(body: { name: string; cwd: string }): Promise; - createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; - updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise; - deleteMcpServer(input: { cwd: string; name: string }): Promise; - listSkills(): Promise>; - listHooks(input: { cwd: string }): Promise; - createHook(input: { cwd: string; hook: UserHookConfig }): Promise; - updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise; - deleteHook(input: { cwd: string; name: string }): Promise; - setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise; - resetHookDisabled(body: { name: string; cwd: string }): Promise; - getGlobalPermissionMode(input: { sessionId: string; cwd: string }): Promise; - setGlobalPermissionMode(input: { - sessionId: string; - cwd: string; - mode: PermissionMode; - }): Promise; -} +import { SessionService } from '../session/port.js'; +import type { SettingsClient } from '../client/contracts.js'; // ---- Helpers with validation ---- @@ -238,7 +196,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { async getMemoryConfig() { const cfg = getMemoryConfig(); - return { enabled: cfg.enabled, types: getAllTypesWithStatus(cfg), model: cfg.model }; + return { enabled: cfg.enabled, model: cfg.model }; }, async setMemoryEnabled(enabled) { @@ -265,26 +223,6 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { return { compactionModel }; }, - async setMemoryTypeDisabled(name, disabled) { - setMemoryTypeDisabled(name, disabled); - }, - - async addMemoryExtraType(type) { - _addMemoryExtraType({ name: type.name, description: type.description, enabled: true }); - }, - - async updateMemoryExtraType(name, type) { - _updateMemoryExtraType(name, { - name: type.name, - description: type.description, - enabled: true, - }); - }, - - async deleteMemoryExtraType(name) { - _deleteMemoryExtraType(name); - }, - async getMcpStatus({ cwd }) { const projectCwd = isGlobalCwd(cwd) ? process.cwd() : cwd; const runtime = await rt.runPromise( @@ -426,7 +364,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(input.cwd, input.sessionId); - return yield* session.getPermissionMode(state); + return state.permissionMode; }) ); }, @@ -439,8 +377,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.load(input.cwd, input.sessionId); - yield* session.setPermissionMode(state, input.mode); + yield* session.setPermissionMode(input.cwd, input.sessionId, input.mode); }) ); }, diff --git a/packages/codingcode/src/hooks/registry.ts b/packages/codingcode/src/hooks/hooks.ts similarity index 76% rename from packages/codingcode/src/hooks/registry.ts rename to packages/codingcode/src/hooks/hooks.ts index 3e63e123..c4a1703a 100644 --- a/packages/codingcode/src/hooks/registry.ts +++ b/packages/codingcode/src/hooks/hooks.ts @@ -1,4 +1,4 @@ -import { Effect } from 'effect'; +import { Layer, Effect } from 'effect'; import { resolveHookConfigs, resolveHookDisabled } from './config.js'; import { executeHookCommand, @@ -6,6 +6,7 @@ import { isHookRuntimeEnabled, } from './executor.js'; import { createLogger } from '@codingcode/infra/logger'; +import { HookService } from './port.js'; import type { HookPoint, HookDecision, @@ -19,8 +20,7 @@ import type { const logger = createLogger(); -export class HookService extends Effect.Service()('HookService', { - effect: Effect.gen(function* () { +export const HookLayer = Layer.effect(HookService, Effect.gen(function* () { let entryCounter = 0; const globalHooks = new Map(); const hooksByProject = new Map>(); @@ -223,78 +223,10 @@ export class HookService extends Effect.Service()('HookService', { hooksByProject.set(projectPath, projectMap); }), - attachSessionHooks: ( - sessionId: string, - hooks: { - name: string; - point: HookPoint; - type: 'observer' | 'decision'; - command: string; - args?: string[]; - priority?: number; - }[] - ): Effect.Effect => - Effect.sync(() => { - const sessionMap = new Map(); - for (const hc of hooks) { - const observerHandler: ObserverHandler = (payload) => - Effect.tryPromise({ - try: () => - executeHookCommand({ command: hc.command, args: hc.args, env: {} }, payload), - catch: (e) => logger.error(`session hook ${hc.name} error:`, e), - }).pipe(Effect.ignore); - const decisionHandler: DecisionHandler = (payload) => - Effect.tryPromise({ - try: () => - executeDecisionHookCommand( - { command: hc.command, args: hc.args, env: {} }, - payload - ), - catch: (e) => { - logger.error(`session decision hook ${hc.name} error:`, e); - return null; - }, - }) as unknown as Promise; - const entry: HandlerEntry = { - id: `session-${hc.name}-${++entryCounter}`, - handler: hc.type === 'observer' ? observerHandler : decisionHandler, - priority: hc.priority ?? 0, - source: 'user', - type: hc.type, - }; - const set = sessionMap.get(hc.point) ?? []; - set.push(entry); - sessionMap.set(hc.point, set); - } - hooksBySession.set(sessionId, sessionMap); - }), - - disableHook: (projectPath: string, name: string): Effect.Effect => - Effect.sync(() => { - let set = disabledHooksByProject.get(projectPath); - if (!set) { - set = new Set(); - disabledHooksByProject.set(projectPath, set); - } - set.add(name); - }), - - enableHook: (projectPath: string, name: string): Effect.Effect => - Effect.sync(() => { - disabledHooksByProject.get(projectPath)?.delete(name); - }), - disposeSession: (sessionId: string): Effect.Effect => Effect.sync(() => { hooksBySession.delete(sessionId); disabledHooksBySession.delete(sessionId); }), - - disposeProject: (projectPath: string): Effect.Effect => - Effect.sync(() => { - hooksByProject.delete(projectPath); - disabledHooksByProject.delete(projectPath); - }), }; - }), -}) {} +})); diff --git a/packages/codingcode/src/hooks/port.ts b/packages/codingcode/src/hooks/port.ts new file mode 100644 index 00000000..f3efa197 --- /dev/null +++ b/packages/codingcode/src/hooks/port.ts @@ -0,0 +1,14 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { HookPoint, HookDecision, ObserverHandler, DecisionHandler } from './types.js'; + +export interface HookShape { + register(point: HookPoint, handler: ObserverHandler, opts?: { source?: 'system' | 'user' }): Effect.Effect<() => void>; + registerDecision(point: HookPoint, handler: DecisionHandler, opts?: { priority?: number; source?: 'system' | 'user' }): Effect.Effect<() => void>; + emit(point: HookPoint, payload: Record): Effect.Effect; + emitDecision(point: HookPoint, payload: Record): Effect.Effect; + reloadUserHooks(projectPath: string): Effect.Effect; + disposeSession(sessionId: string): Effect.Effect; +} + +export class HookService extends Context.Tag('HookService')() {} diff --git a/packages/codingcode/src/hooks/types.ts b/packages/codingcode/src/hooks/types.ts index 34193d77..981d94c0 100644 --- a/packages/codingcode/src/hooks/types.ts +++ b/packages/codingcode/src/hooks/types.ts @@ -18,8 +18,7 @@ export type HookPoint = | 'agent.turn.end' | 'agent.subagent.spawn.before' | 'agent.subagent.spawn.after' - | 'agent.subagent.complete' - | 'plan.ready'; + | 'agent.subagent.complete'; export interface HookDecision { decision?: 'allow' | 'deny' | 'ask' | 'continue'; diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts index 818fa603..15cba804 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -1,115 +1,161 @@ -import { Context, Layer, Effect, ManagedRuntime } from 'effect'; -import { AgentService } from './agent/agent.js'; -import { SessionService } from './session/store.js'; -import { HookService } from './hooks/registry.js'; -import { McpService } from './mcp/index.js'; -import { SkillService } from './skills/service.js'; -import { ApprovalService } from './approval/index.js'; -import { ApprovalWaitService } from './approval/async-confirm.js'; -import { ToolExecutorService } from './tools/executor.js'; -import { CheckpointService } from './checkpoint/checkpoint-service.js'; -import { ProjectRuntimeService } from './runtime/project-runtime.js'; -import { LLMFactoryService } from './llm/factory.js'; +import { Layer, Effect, ManagedRuntime } from 'effect'; +import { HookLayer } from './hooks/hooks.js'; +import { RulesLayer } from './rules/rules.js'; +import { SkillLayer } from './skills/skills.js'; +import { LlmLayer } from './llm/llm.js'; +import { McpLayer } from './mcp/mcp.js'; +import { CheckpointLayer } from './checkpoint/checkpoint.js'; +import { ApprovalLayer } from './approval/approval.js'; +import { ApprovalWaitLayer } from './approval/wait.js'; +import { TodoLayer } from './todo/todo.js'; +import { SessionLayer } from './session/session.js'; +import { ToolExecutorLayer } from './tools/tools.js'; +import { ContextLayer } from './context/context.js'; +import { MemoryLayer } from './memory/memory.js'; +import { AgentLayer } from './agent/agent.js'; +import { ToolEnvLayer } from './agent/tool-env.js'; +import { ToolCatalogLayer } from './agent/tool-catalog.js'; +import { SubagentRunnerLayer } from './subagent/subagent.js'; +import { SchedulerLayer } from './scheduler/scheduler.js'; import { WorkspaceService } from './core/workspace.js'; -import { TodoService } from './agent/todo.js'; -import { SubagentRunnerService } from './subagent/runner-service.js'; -import { RulesService } from './rules/index.js'; -import { MemoryService } from './memory/index.js'; -import { ContextService } from './context/service.js'; -import { SchedulerService } from './scheduler/service.js'; -import { planProfileGateHook } from './agent/profile.js'; - -export const WorkspaceLayer = WorkspaceService.Default; -export const TodoLayer = TodoService.Default; -export const RulesLayer = RulesService.Default; -export const SessionLayer = SessionService.Default; -export const LLMFactoryLayer = LLMFactoryService.Default.pipe(Layer.provide(WorkspaceLayer)); -export const MemoryLayer = MemoryService.Default.pipe(Layer.provide(LLMFactoryLayer)); -export const ContextLayer = ContextService.Default.pipe( - Layer.provide(Layer.mergeAll(SessionLayer, LLMFactoryLayer)) + +import { HookService } from './hooks/port.js'; +import { RulesService } from './rules/port.js'; +import { SkillService } from './skills/port.js'; +import { LLMFactoryService } from './llm/port.js'; +import { McpService } from './mcp/port.js'; +import { CheckpointService } from './checkpoint/port.js'; +import { ApprovalService } from './approval/port.js'; +import { ApprovalWaitService } from './approval/wait-port.js'; +import { TodoService } from './todo/port.js'; +import { SessionService } from './session/port.js'; +import { ToolExecutorService } from './tools/port.js'; +import { ContextService } from './context/port.js'; +import { MemoryService } from './memory/port.js'; + +import { + SessionPort, ToolExecutorPort, CheckpointPort, HookPort, + ApprovalPort, SkillPort, McpPort, ContextPort, MemoryPort, + LlmPort, RulesPort, TodoPort, +} from './agent/deps.js'; + +// adapter layers: map full services to agent's narrow ports +const AgentSessionAdapter = Layer.effect(SessionPort, Effect.gen(function* () { + const s = yield* SessionService; + return { + load: s.load.bind(s), create: s.create.bind(s), + recordUser: s.recordUser.bind(s), recordSystem: s.recordSystem.bind(s), recordAssistant: s.recordAssistant.bind(s), + recordToolResult: s.recordToolResult.bind(s), + setPermissionMode: s.setPermissionMode.bind(s), + setActiveProfile: s.setActiveProfile.bind(s), + }; +})); + +const AgentToolExecutorAdapter = Layer.effect(ToolExecutorPort, Effect.gen(function* () { + const e = yield* ToolExecutorService; + return { executeBatch: e.executeBatch.bind(e) }; +})); + +const AgentCheckpointAdapter = Layer.effect(CheckpointPort, Effect.gen(function* () { + const c = yield* CheckpointService; + return { snapshotBaseline: c.snapshotBaseline.bind(c), snapshotFinal: c.snapshotFinal.bind(c) }; +})); + +const AgentHookAdapter = Layer.effect(HookPort, Effect.gen(function* () { + const h = yield* HookService; + return { emit: h.emit.bind(h), emitDecision: h.emitDecision.bind(h), disposeSession: h.disposeSession.bind(h) }; +})); + +const AgentApprovalAdapter = Layer.effect(ApprovalPort, Effect.gen(function* () { + const a = yield* ApprovalService; + return { evaluate: a.evaluate.bind(a) }; +})); + +const AgentSkillAdapter = Layer.effect(SkillPort, Effect.gen(function* () { + const s = yield* SkillService; + return { extractSkill: s.extractSkill.bind(s) }; +})); + +const AgentMcpAdapter = Layer.effect(McpPort, Effect.gen(function* () { + const m = yield* McpService; + return { listProjectMcpTools: m.listProjectMcpTools.bind(m), syncConnections: m.syncConnections.bind(m) }; +})); + +const AgentContextAdapter = Layer.effect(ContextPort, Effect.gen(function* () { + const c = yield* ContextService; + return { + willCompact: c.willCompact.bind(c), + assemblePayload: c.assemblePayload.bind(c), + }; +})); + +const AgentMemoryAdapter = Layer.effect(MemoryPort, Effect.gen(function* () { + const m = yield* MemoryService; + return { loadMemoryForPrompt: m.loadMemoryForPrompt.bind(m), flushSessionToMemory: m.flushSessionToMemory.bind(m) }; +})); + +const AgentLlmAdapter = Layer.effect(LlmPort, Effect.gen(function* () { + const f = yield* LLMFactoryService; + return { getLLMClient: f.getLLMClient.bind(f) }; +})); + +const AgentRulesAdapter = Layer.effect(RulesPort, Effect.gen(function* () { + const r = yield* RulesService; + return { getAllRules: r.getAllRules.bind(r), evictProjectRules: r.evictProjectRules.bind(r) }; +})); + +const AgentTodoAdapter = Layer.effect(TodoPort, Effect.gen(function* () { + const t = yield* TodoService; + return { read: t.read.bind(t) }; +})); + +const AgentDepsAdapter = Layer.mergeAll( + AgentSessionAdapter, AgentToolExecutorAdapter, AgentCheckpointAdapter, + AgentHookAdapter, AgentApprovalAdapter, AgentSkillAdapter, AgentMcpAdapter, + AgentContextAdapter, AgentMemoryAdapter, AgentLlmAdapter, AgentRulesAdapter, + AgentTodoAdapter, ); -export const HookLayer = HookService.Default; -export const SkillLayer = SkillService.Default; -export const CheckpointLayer = CheckpointService.Default; -export const ApprovalWaitLayer = ApprovalWaitService.Default; -export const McpLayer = McpService.Default; -export const SchedulerLayer = SchedulerService.Default; -export const ProjectRuntimeLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookLayer, McpLayer, RulesLayer, SessionLayer)) + +// base layers +const InfraLayer = Layer.mergeAll( + WorkspaceService.Default, HookLayer, RulesLayer, SkillLayer, McpLayer, ApprovalWaitLayer, TodoLayer, ); -export const ApprovalLayer = ApprovalService.Default.pipe( - Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer)) + +const LlmWithDeps = LlmLayer.pipe(Layer.provide(WorkspaceService.Default)); +const ApprovalWithDeps = ApprovalLayer.pipe(Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer))); +const ToolExecutorWithDeps = ToolExecutorLayer.pipe(Layer.provide(Layer.mergeAll(HookLayer, ApprovalWithDeps))); +const ContextWithDeps = ContextLayer.pipe(Layer.provide(Layer.mergeAll(SessionLayer, LlmWithDeps))); +const MemoryWithDeps = MemoryLayer.pipe(Layer.provide(LlmWithDeps)); + +// agent deps adapters wrap concrete services, so provide them first +const AgentDepsWithDeps = AgentDepsAdapter.pipe( + Layer.provide(Layer.mergeAll( + InfraLayer, SessionLayer, ToolExecutorWithDeps, ApprovalWithDeps, + ContextWithDeps, MemoryWithDeps, CheckpointLayer, LlmWithDeps, + )) ); -export const SystemHookLayer = HookLayer.pipe( - Layer.tap((context) => - Effect.gen(function* () { - const hooks = Context.get(context, HookService); - yield* hooks.registerDecision('tool.approval.pre', planProfileGateHook, { - priority: -1000, - source: 'system', - }); - }) - ) +// agent with deps +const AgentWithDeps = AgentLayer.pipe( + Layer.provide(Layer.mergeAll(AgentDepsWithDeps, ToolEnvLayer, ToolCatalogLayer)) ); -/** ToolExecutor depends on HookLayer + ApprovalLayer. */ -const ExecutorDeps = Layer.mergeAll(HookLayer, ApprovalLayer); -const ExecutorLayer = ToolExecutorService.Default.pipe(Layer.provide(ExecutorDeps)); +// subagent runner (depends on agent) +const SubagentWithDeps = SubagentRunnerLayer.pipe(Layer.provide(AgentWithDeps)); -/** Agent depends on ToolExecutor + HookLayer + ApprovalLayer + ApprovalWaitLayer + Session + Checkpoint + ProjectRuntime + Skill + LLMFactory + Todo + Rules + Context + Memory. */ -const AgentDeps = Layer.mergeAll( - ExecutorLayer, - ApprovalLayer, - ApprovalWaitLayer, - SessionLayer, - CheckpointLayer, - McpLayer, - SkillLayer, - LLMFactoryLayer, - HookLayer, - ProjectRuntimeLayer, - TodoLayer, - RulesLayer, - ContextLayer, - MemoryLayer -); -const AgentWithDeps = AgentService.Default.pipe(Layer.provide(AgentDeps)); - -/** SubagentRunnerService delegates to AgentService.runStream. */ -const SubagentRunnerLayer = Layer.effect( - SubagentRunnerService, - Effect.gen(function* () { - const agent = yield* AgentService; - return SubagentRunnerService.make({ runStream: agent.runStream }); - }) -).pipe(Layer.provide(AgentWithDeps)); - -/** Final application layer — all services merged. */ export const AppLayer = Layer.mergeAll( - AgentWithDeps, - SubagentRunnerLayer, - ExecutorLayer, + InfraLayer, + LlmWithDeps, + ApprovalWithDeps, SessionLayer, - HookLayer, - McpLayer, - SkillLayer, - ApprovalLayer, - ApprovalWaitLayer, + ToolExecutorWithDeps, + ContextWithDeps, + MemoryWithDeps, CheckpointLayer, - ProjectRuntimeLayer, - LLMFactoryLayer, - WorkspaceLayer, - TodoLayer, - RulesLayer, - MemoryLayer, - ContextLayer, + AgentWithDeps, + SubagentWithDeps, SchedulerLayer, - SystemHookLayer ); -/** Create the application ManagedRuntime from AppLayer. */ export const createAppRuntime = () => ManagedRuntime.make(AppLayer as any); - -/** Concrete runtime type for the application. */ export type AppRuntime = ManagedRuntime.ManagedRuntime; diff --git a/packages/codingcode/src/llm/client.ts b/packages/codingcode/src/llm/client.ts index 9783583a..6c90db44 100644 --- a/packages/codingcode/src/llm/client.ts +++ b/packages/codingcode/src/llm/client.ts @@ -1,14 +1,10 @@ import { Effect } from 'effect'; import type { AgentError } from '../core/error.js'; -import type { LLMRequest, LLMResponse, ModelInfo } from './types.js'; - -export interface StreamResult { - stream: AsyncIterable; - response: Promise<{ ok: true; value: LLMResponse } | { ok: false; error: AgentError }>; -} +import type { LLMRequest, LLMResponse, LLMStreamPart, ModelInfo } from './types.js'; export interface LLMClient { complete(req: LLMRequest, signal?: AbortSignal): Effect.Effect; - completeStream(req: LLMRequest, signal?: AbortSignal): StreamResult; + /** 产出 SDK 流部件;失败时在迭代中抛出 AgentError */ + completeStream(req: LLMRequest, signal?: AbortSignal): AsyncIterable; readonly modelInfo: ModelInfo; } diff --git a/packages/codingcode/src/llm/llm-resolver.ts b/packages/codingcode/src/llm/llm-resolver.ts index 21a09d0a..35481ff7 100644 --- a/packages/codingcode/src/llm/llm-resolver.ts +++ b/packages/codingcode/src/llm/llm-resolver.ts @@ -1,6 +1,6 @@ import { Effect } from 'effect'; import { AgentError } from '../core/error.js'; -import { LLMFactoryService } from './factory.js'; +import { LLMFactoryService } from './port.js'; import type { LLMClient } from './client.js'; export function resolveLLM( diff --git a/packages/codingcode/src/llm/factory.ts b/packages/codingcode/src/llm/llm.ts similarity index 98% rename from packages/codingcode/src/llm/factory.ts rename to packages/codingcode/src/llm/llm.ts index 2c94c899..abe8431e 100644 --- a/packages/codingcode/src/llm/factory.ts +++ b/packages/codingcode/src/llm/llm.ts @@ -1,12 +1,13 @@ import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; -import { Effect } from 'effect'; +import { Layer, Effect } from 'effect'; import { AgentError } from '../core/error.js'; import { WorkspaceService } from '../core/workspace.js'; import type { LLMClient } from './client.js'; import { OpenAIProvider } from './providers/openai.js'; import { DeepSeekProvider } from './providers/deepseek.js'; import { updateActiveModel } from '@codingcode/infra/config'; +import { LLMFactoryService } from './port.js'; export interface ModelDescriptor { id: string; @@ -57,8 +58,7 @@ function flattenModels(cat: ProviderCatalog): SelectableModel[] { return result; } -export class LLMFactoryService extends Effect.Service()('LLMFactory', { - effect: Effect.gen(function* () { +export const LlmLayer = Layer.effect(LLMFactoryService, Effect.gen(function* () { const workspace = yield* WorkspaceService; let catalog: ProviderCatalog | null = null; let currentEntry: SelectableModel | null = null; @@ -276,5 +276,4 @@ export class LLMFactoryService extends Effect.Service()('LLMF return currentClient; }), }; - }), -}) {} +})); diff --git a/packages/codingcode/src/llm/port.ts b/packages/codingcode/src/llm/port.ts new file mode 100644 index 00000000..e17ce6ca --- /dev/null +++ b/packages/codingcode/src/llm/port.ts @@ -0,0 +1,19 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { AgentError } from '../core/error.js'; +import type { LLMClient } from './client.js'; + +export interface ModelDescriptor { id: string; name: string; context_window?: number } +export interface ProviderEntry { name: string; driver: string; base_url: string; api_key_env: string; default_model: string; models: ModelDescriptor[] } +export interface SelectableModel { id: string; provider: string; driver: string; name: string; model: string; base_url: string; api_key_env: string; context_window: number } + +export interface LLMFactoryShape { + listModels(): Effect.Effect; + findModel(target: string): Effect.Effect; + getActiveEntry(): Effect.Effect; + switchModel(id: string): Effect.Effect; + createClient(entry: SelectableModel): Effect.Effect; + getLLMClient(): Effect.Effect; +} + +export class LLMFactoryService extends Context.Tag('LLMFactory')() {} diff --git a/packages/codingcode/src/llm/providers/deepseek.ts b/packages/codingcode/src/llm/providers/deepseek.ts index 97ec05c7..001777d8 100644 --- a/packages/codingcode/src/llm/providers/deepseek.ts +++ b/packages/codingcode/src/llm/providers/deepseek.ts @@ -1,12 +1,12 @@ -import { generateText, streamText, stepCountIs, type ModelMessage } from 'ai'; +import { generateText, streamText, stepCountIs } from 'ai'; import type { LanguageModelV3 } from '@ai-sdk/provider'; import { Effect } from 'effect'; -import { AgentError } from '../../core/error.js'; +import type { AgentError } from '../../core/error.js'; import { mapLlmError } from '../errors.js'; import type { LLMClient } from '../client.js'; -import type { LLMRequest, LLMResponse } from '../types.js'; -import type { SelectableModel } from '../factory.js'; -import { convertMessages, convertTools, parseResponseMessages } from './shared.js'; +import type { LLMRequest, LLMResponse, LLMStreamPart } from '../types.js'; +import type { SelectableModel } from '../port.js'; +import { convertMessages, convertTools, toTokenUsage } from './shared.js'; export class DeepSeekProvider implements LLMClient { constructor( @@ -36,22 +36,24 @@ export class DeepSeekProvider implements LLMClient { abortSignal: signal, }); - const response = parseResponseMessages(result.response.messages as ModelMessage[]); - if (result.usage) { - const usage = result.usage as any; - response.usage = { - prompt: usage.promptTokens ?? 0, - completion: usage.completionTokens ?? 0, - total: usage.totalTokens ?? 0, - }; - } - return response; + return { + content: result.text, + toolCalls: + result.toolCalls.length > 0 + ? result.toolCalls.map((tc) => ({ + id: tc.toolCallId, + name: tc.toolName, + arguments: (tc.input ?? {}) as Record, + })) + : undefined, + usage: toTokenUsage(result.usage), + }; }, catch: (e) => mapLlmError('deepseek', e), }); } - completeStream(req: LLMRequest, signal?: AbortSignal): import('../client.js').StreamResult { + completeStream(req: LLMRequest, signal?: AbortSignal): AsyncIterable { const result = streamText({ model: this.model, system: req.system, @@ -61,32 +63,27 @@ export class DeepSeekProvider implements LLMClient { abortSignal: signal, }); - const stream = (async function* () { + return (async function* () { for await (const part of result.fullStream) { - if (part.type === 'text-delta') { - yield part.text; - } - } - })(); - - const response = (async () => { - try { - const resp = await result.response; - const parsed = parseResponseMessages(resp.messages as ModelMessage[]); - if ((resp as any).usage) { - const usage = (resp as any).usage as any; - parsed.usage = { - prompt: usage.promptTokens ?? 0, - completion: usage.completionTokens ?? 0, - total: usage.totalTokens ?? 0, - }; + switch (part.type) { + case 'text-delta': + yield { type: 'text', text: part.text }; + break; + case 'tool-call': + yield { + type: 'tool_call', + id: part.toolCallId, + name: part.toolName, + args: (part.input ?? {}) as Record, + }; + break; + case 'finish': + yield { type: 'end', usage: toTokenUsage(part.totalUsage) }; + break; + case 'error': + throw mapLlmError('deepseek', part.error); } - return { ok: true as const, value: parsed }; - } catch (e) { - return { ok: false as const, error: mapLlmError('deepseek', e) }; } })(); - - return { stream, response }; } } diff --git a/packages/codingcode/src/llm/providers/openai.ts b/packages/codingcode/src/llm/providers/openai.ts index b3479a1c..cdad0862 100644 --- a/packages/codingcode/src/llm/providers/openai.ts +++ b/packages/codingcode/src/llm/providers/openai.ts @@ -1,12 +1,12 @@ -import { generateText, streamText, stepCountIs, type ModelMessage } from 'ai'; +import { generateText, streamText, stepCountIs } from 'ai'; import type { LanguageModelV3 } from '@ai-sdk/provider'; import { Effect } from 'effect'; -import { AgentError } from '../../core/error.js'; +import type { AgentError } from '../../core/error.js'; import { mapLlmError } from '../errors.js'; import type { LLMClient } from '../client.js'; -import type { LLMRequest, LLMResponse } from '../types.js'; -import type { SelectableModel } from '../factory.js'; -import { convertMessages, convertTools, parseResponseMessages } from './shared.js'; +import type { LLMRequest, LLMResponse, LLMStreamPart } from '../types.js'; +import type { SelectableModel } from '../port.js'; +import { convertMessages, convertTools, toTokenUsage } from './shared.js'; export class OpenAIProvider implements LLMClient { constructor( @@ -36,39 +36,37 @@ export class OpenAIProvider implements LLMClient { abortSignal: signal, }); - const response = parseResponseMessages(result.response.messages as ModelMessage[]); - if (result.usage) { - const usage = result.usage as any; - response.usage = { - prompt: usage.promptTokens ?? 0, - completion: usage.completionTokens ?? 0, - total: usage.totalTokens ?? 0, - }; - } - return response; + return { + content: result.text, + toolCalls: + result.toolCalls.length > 0 + ? result.toolCalls.map((tc) => ({ + id: tc.toolCallId, + name: tc.toolName, + arguments: (tc.input ?? {}) as Record, + })) + : undefined, + usage: toTokenUsage(result.usage), + }; }, catch: (e) => mapLlmError('openai', e), }); } - completeStream(req: LLMRequest, signal?: AbortSignal): import('../client.js').StreamResult { + completeStream(req: LLMRequest, signal?: AbortSignal): AsyncIterable { + // sansen 不支持流式工具调用:退回非流式,再拆成同样三种部件 if (this.entry.provider === 'sansen' && req.tools && req.tools.length > 0) { - const response = Effect.runPromise( - this.complete(req, signal).pipe( - Effect.match({ - onSuccess: (value) => ({ ok: true as const, value }), - onFailure: (error) => ({ ok: false as const, error }), - }) - ) - ); - const stream = (async function* () { - const result = await response; - if (result.ok && result.value.content) { - yield result.value.content; + const complete = this.complete(req, signal); + return (async function* () { + const either = await Effect.runPromise(Effect.either(complete)); + if (either._tag === 'Left') throw either.left; + const value = either.right; + if (value.content) yield { type: 'text', text: value.content }; + for (const tc of value.toolCalls ?? []) { + yield { type: 'tool_call', id: tc.id, name: tc.name, args: tc.arguments }; } + yield value.usage ? { type: 'end', usage: value.usage } : { type: 'end' }; })(); - - return { stream, response }; } const result = streamText({ @@ -80,32 +78,27 @@ export class OpenAIProvider implements LLMClient { abortSignal: signal, }); - const stream = (async function* () { + return (async function* () { for await (const part of result.fullStream) { - if (part.type === 'text-delta') { - yield part.text; + switch (part.type) { + case 'text-delta': + yield { type: 'text', text: part.text }; + break; + case 'tool-call': + yield { + type: 'tool_call', + id: part.toolCallId, + name: part.toolName, + args: (part.input ?? {}) as Record, + }; + break; + case 'finish': + yield { type: 'end', usage: toTokenUsage(part.totalUsage) }; + break; + case 'error': + throw mapLlmError('openai', part.error); } } })(); - - const response = (async () => { - try { - const resp = await result.response; - const parsed = parseResponseMessages(resp.messages as ModelMessage[]); - if ((resp as any).usage) { - const usage = (resp as any).usage as any; - parsed.usage = { - prompt: usage.promptTokens ?? 0, - completion: usage.completionTokens ?? 0, - total: usage.totalTokens ?? 0, - }; - } - return { ok: true as const, value: parsed }; - } catch (e) { - return { ok: false as const, error: mapLlmError('openai', e) }; - } - })(); - - return { stream, response }; } } diff --git a/packages/codingcode/src/llm/providers/shared.ts b/packages/codingcode/src/llm/providers/shared.ts index 5a885470..3c9cd674 100644 --- a/packages/codingcode/src/llm/providers/shared.ts +++ b/packages/codingcode/src/llm/providers/shared.ts @@ -1,5 +1,5 @@ -import { jsonSchema, type ModelMessage } from 'ai'; -import type { LLMResponse } from '../types.js'; +import { jsonSchema, type LanguageModelUsage, type ModelMessage } from 'ai'; +import type { TokenUsage } from '../../core/types.js'; export function convertMessages( messages: Array<{ role: string; content: string; tool_calls?: unknown[]; tool_call_id?: string }> @@ -48,33 +48,11 @@ export function convertTools( return result; } -export function parseResponseMessages(responseMessages: ModelMessage[]): LLMResponse { - const lastAssistant = [...responseMessages].reverse().find((m) => m.role === 'assistant'); - if (!lastAssistant) { - return { content: '', finishReason: 'stop' }; - } - - let content = ''; - const toolCalls: LLMResponse['toolCalls'] = []; - - if (typeof lastAssistant.content === 'string') { - content = lastAssistant.content; - } else if (Array.isArray(lastAssistant.content)) { - for (const part of lastAssistant.content as any[]) { - if (part.type === 'text') content += part.text ?? ''; - if (part.type === 'tool-call') { - toolCalls.push({ - id: part.toolCallId ?? 'unknown', - name: part.toolName ?? 'unknown', - arguments: part.input ?? {}, - }); - } - } - } - +/** SDK v6 的 inputTokens 已含 cached,此处不做减法:prompt 同时充当上下文占用 */ +export function toTokenUsage(u: LanguageModelUsage): TokenUsage { return { - content, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop', + prompt: u.inputTokens ?? 0, + completion: u.outputTokens ?? 0, + total: u.totalTokens ?? 0, }; } diff --git a/packages/codingcode/src/llm/types.ts b/packages/codingcode/src/llm/types.ts index 298e733a..c04fce3d 100644 --- a/packages/codingcode/src/llm/types.ts +++ b/packages/codingcode/src/llm/types.ts @@ -1,4 +1,4 @@ -import type { Message, ToolDescription } from '../core/types.js'; +import type { Message, ToolCall, ToolDescription, TokenUsage } from '../core/types.js'; export interface LLMRequest { messages: Message[]; @@ -10,15 +10,21 @@ export interface LLMRequest { export interface LLMResponse { content: string; - toolCalls?: Array<{ - id: string; - name: string; - arguments: Record; - }>; - usage?: { prompt: number; completion: number; total: number }; - finishReason: 'stop' | 'tool_calls' | 'length' | 'error'; + toolCalls?: ToolCall[]; + usage?: TokenUsage; } +/** 一次 LLM 调用的流式部件:内容与终结边界 */ +export type LLMStreamPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'tool_call'; + readonly id: string; + readonly name: string; + readonly args: Record; + } + | { readonly type: 'end'; readonly usage?: TokenUsage }; + export interface ModelInfo { provider: string; model: string; diff --git a/packages/codingcode/src/mcp/index.ts b/packages/codingcode/src/mcp/mcp.ts similarity index 98% rename from packages/codingcode/src/mcp/index.ts rename to packages/codingcode/src/mcp/mcp.ts index 8ea59e54..4a74bffd 100644 --- a/packages/codingcode/src/mcp/index.ts +++ b/packages/codingcode/src/mcp/mcp.ts @@ -1,7 +1,8 @@ -import { Effect } from 'effect'; +import { Effect, Layer } from 'effect'; import { z } from 'zod'; import { resolveMcpConfig, resolveMcpDisabled } from './config.js'; import { McpClient } from './client.js'; +import { McpService } from './port.js'; import type { McpServerConfig, McpStatus } from './types.js'; import type { ToolDefinition } from '../tools/types.js'; import { createLogger } from '@codingcode/infra/logger'; @@ -30,8 +31,7 @@ interface LeaseEntry { type ProjectPath = string; type ServerName = string; -export class McpService extends Effect.Service()('Mcp', { - effect: Effect.sync(() => { +export const McpLayer = Layer.effect(McpService, Effect.sync(() => { const clientsByProject = new Map>(); const leasesBySession = new Map>(); const disabledMcpByProject = new Map>(); @@ -318,8 +318,8 @@ export class McpService extends Effect.Service()('Mcp', { configCache.delete(projectPath); }), }; - }), -}) {} + } +)); function namespacedName(serverName: string, toolName: string): string { return `${serverName}:${toolName}`; diff --git a/packages/codingcode/src/mcp/port.ts b/packages/codingcode/src/mcp/port.ts new file mode 100644 index 00000000..6caac1b7 --- /dev/null +++ b/packages/codingcode/src/mcp/port.ts @@ -0,0 +1,19 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { ToolDefinition } from '../tools/types.js'; +import type { McpStatus } from './types.js'; + +export interface McpShape { + syncConnections(projectPath: string): Effect.Effect; + connectServers(projectPath: string, sessionId: string, names: string[]): Effect.Effect; + disconnectServers(projectPath: string, sessionId: string, names: string[]): Effect.Effect; + getServerToolNames(projectPath: string, name: string): string[]; + listProjectMcpTools(projectPath: string): ToolDefinition[]; + status(projectPath: string): Effect.Effect; + disable(projectPath: string, name: string): Effect.Effect; + enable(projectPath: string, name: string): Effect.Effect; + disposeSession(sessionId: string): Effect.Effect; + disposeProject(projectPath: string): Effect.Effect; +} + +export class McpService extends Context.Tag('Mcp')() {} diff --git a/packages/codingcode/src/memory/config.ts b/packages/codingcode/src/memory/config.ts index 6cf53de8..a11822c9 100644 --- a/packages/codingcode/src/memory/config.ts +++ b/packages/codingcode/src/memory/config.ts @@ -1,76 +1,5 @@ -import { - DEFAULT_MEMORY_TYPES, - loadConfig, - type MemoryConfig, - type MemoryTypeConfig, - updateMemoryEnabled, - updateMemoryDisabledTypes, - updateMemoryExtraTypes, -} from '@codingcode/infra/config'; -import type { MemoryTypeEntry } from './types.js'; +import { loadConfig, type MemoryConfig } from '@codingcode/infra/config'; export function getMemoryConfig(): MemoryConfig { return loadConfig().memory; } - -export function getEffectiveTypes(cfg: MemoryConfig): MemoryTypeConfig[] { - return [...DEFAULT_MEMORY_TYPES, ...cfg.extraTypes].filter( - (t) => t.enabled && !cfg.disabledTypes.includes(t.name) - ); -} - -export function getAllTypesWithStatus(cfg?: MemoryConfig): MemoryTypeEntry[] { - const config = cfg ?? getMemoryConfig(); - const builtIn: MemoryTypeEntry[] = DEFAULT_MEMORY_TYPES.map((t) => ({ - name: t.name, - description: t.description, - isBuiltIn: true, - disabled: config.disabledTypes.includes(t.name), - })); - const custom: MemoryTypeEntry[] = config.extraTypes.map((t) => ({ - name: t.name, - description: t.description, - isBuiltIn: false, - disabled: config.disabledTypes.includes(t.name), - })); - return [...builtIn, ...custom]; -} - -export function setMemoryTypeDisabled(name: string, disabled: boolean, cfg?: MemoryConfig): void { - const config = cfg ?? getMemoryConfig(); - const disabledTypes = disabled - ? [...new Set([...config.disabledTypes, name])] - : config.disabledTypes.filter((n) => n !== name); - updateMemoryDisabledTypes(disabledTypes); -} - -export function addMemoryExtraType(type: MemoryTypeConfig, cfg?: MemoryConfig): void { - const config = cfg ?? getMemoryConfig(); - if (config.extraTypes.some((t) => t.name === type.name)) { - throw new Error(`Memory type '${type.name}' already exists`); - } - const updated = [...config.extraTypes, { ...type, enabled: true }]; - updateMemoryExtraTypes(updated); -} - -export function updateMemoryExtraType( - name: string, - type: MemoryTypeConfig, - cfg?: MemoryConfig -): void { - const config = cfg ?? getMemoryConfig(); - const idx = config.extraTypes.findIndex((t) => t.name === name); - if (idx === -1) throw new Error(`Memory type '${name}' not found`); - const updated = [...config.extraTypes]; - if (type.name !== name && config.extraTypes.some((t) => t.name === type.name)) { - throw new Error(`Memory type '${type.name}' already exists`); - } - updated[idx] = { ...type, enabled: true }; - updateMemoryExtraTypes(updated); -} - -export function deleteMemoryExtraType(name: string, cfg?: MemoryConfig): void { - const config = cfg ?? getMemoryConfig(); - const updated = config.extraTypes.filter((t) => t.name !== name); - updateMemoryExtraTypes(updated); -} diff --git a/packages/codingcode/src/memory/extractor.ts b/packages/codingcode/src/memory/extractor.ts index 195e3a44..40970b17 100644 --- a/packages/codingcode/src/memory/extractor.ts +++ b/packages/codingcode/src/memory/extractor.ts @@ -1,85 +1,44 @@ import type { LLMClient } from '../llm/client.js'; -import type { MemoryTypeConfig } from '@codingcode/infra/config'; -import type { StructuredTranscript } from './types.js'; export async function extractMemory(opts: { - currentAuto: string; - transcript: StructuredTranscript; - types: MemoryTypeConfig[]; + currentMemory: string; + transcript: string; llm: LLMClient; }): Promise { - const { currentAuto, transcript, types, llm } = opts; + const { currentMemory, transcript, llm } = opts; - const typeDescriptions = types.map((t) => `- **${t.name}**: ${t.description}`).join('\n'); - - const typeGuidelineMap: Record = { - user: '- **user**: 从 [user] 标签提取用户角色、技能栈、对 Agent 的工作偏好及纠正', - project: '- **project**: 从 [user] 和 [assistant] 标签提取架构决策、技术选型、部署信息', - reference: '- **reference**: 从 [user] 和 [tool:*] 标签提取外部资源、文档、Dashboard 链接', - }; - - const typeGuidance = types - .map((t) => typeGuidelineMap[t.name]) - .filter(Boolean) - .join('\n'); - - const formatExamples = types - .map((t) => { - switch (t.name) { - case 'user': - return '### user\n- 要点一\n- 要点二'; - case 'project': - return '### project\n- 架构决策'; - case 'reference': - return '### reference\n- [标题](URL)'; - default: - return ''; - } - }) - .filter(Boolean) - .join('\n\n'); - - const systemPrompt = `你是记忆提取器。从对话记录中提取值得长期记忆的内容,输出 ... 块。 -如果没有值得记忆的内容,输出 。 + const systemPrompt = `你是记忆整理器。基于"已有记忆"和"会话记录",输出整份最新版长期记忆,放在 ... 块中,不要输出其它内容。 规则: -- 新信息与已有记忆矛盾时,用新信息替换旧条目 -- 同一会话内前后不一致,以最新出现的为准 -- 只输出有内容的 ### 小节,忽略临时调试、一次性任务、报错堆栈 - -记忆类型及信息来源: -${typeGuidance} +- 只保留值得跨会话记住的信息:用户角色、偏好与对 Agent 的纠正,项目架构决策、技术选型与部署信息,外部资源与链接等。 +- 忽略临时内容:一次性任务、调试过程、报错堆栈、闲聊。 +- 更新哪些内容由你决定:在已有记忆基础上自行增、删、改,输出必须是一份完整、自洽的最新记忆,而不是只输出变动部分。 +- 旧记忆与对话新信息矛盾时以最新为准;同一会话前后不一致时以最后出现为准。 +- 不要编造对话中未出现的信息。 +- 若没有值得记住的新信息且已有记忆为空,输出 。 格式: -${formatExamples}`; +- 纯 Markdown,用 "### 主题" 小节组织,小节下用 "- " 列要点。 +- 条目需具体、自包含,避免"上面提到的那个"这类指代。 +- 内不要带任何解释性文字。`; const userMessage = `已有记忆: -${currentAuto} +${currentMemory || '(空)'} -会话记录: -[user] ${transcript.userOnly} ---- -[user+assistant] ${transcript.userAndAssistant} ---- -[user+tool] ${transcript.userAndTools}`; +会话记录(按 [user]/[assistant]/[tool:名称] 标注): +${transcript || '(空)'}`; try { - const result = llm.completeStream({ + const stream = llm.completeStream({ messages: [{ role: 'user', content: userMessage }], system: systemPrompt, }); - let output = ''; - for await (const chunk of result.stream) { - output += chunk; - } - - const response = await result.response; - if (!response.ok) { - return null; + let fullOutput = ''; + for await (const part of stream) { + if (part.type === 'text') fullOutput += part.text; } - const fullOutput = response.value.content || output; const memoryMatch = fullOutput.match(/([\s\S]*?)<\/memory>/); if (!memoryMatch) { diff --git a/packages/codingcode/src/memory/index.ts b/packages/codingcode/src/memory/memory.ts similarity index 55% rename from packages/codingcode/src/memory/index.ts rename to packages/codingcode/src/memory/memory.ts index 7a4cbee0..27ecdfd2 100644 --- a/packages/codingcode/src/memory/index.ts +++ b/packages/codingcode/src/memory/memory.ts @@ -1,29 +1,23 @@ -import { Effect } from 'effect'; +import { Layer, Effect } from 'effect'; import type { LLMClient } from '../llm/client.js'; -import { sessionJsonlPathFromCwd } from '../core/path.js'; +import { readTranscript } from '../session/file-ops.js'; import type { SessionEvent } from '../session/types.js'; import { readMemoryFile, resolveMemoryPath, - extractAutoBlock, - replaceAutoBlock, - mergeAutoBlocks, enforceMaxBytes, writeMemoryFileAtomic, - stripMarkersForPrompt, } from './storage.js'; import { resolveLLM } from '../llm/llm-resolver.js'; -import { LLMFactoryService } from '../llm/factory.js'; -import { getMemoryConfig, getEffectiveTypes } from './config.js'; +import { LLMFactoryService } from '../llm/port.js'; +import { getMemoryConfig } from './config.js'; import { updateMemoryEnabled } from '@codingcode/infra/config'; import { extractMemory } from './extractor.js'; -import type { StructuredTranscript } from './types.js'; +import { MemoryService } from './port.js'; const MAX_BYTES = 16384; -const PROMPT_MAX_BYTES = 8192; -export class MemoryService extends Effect.Service()('Memory', { - effect: Effect.gen(function* () { +export const MemoryLayer = Layer.effect(MemoryService, Effect.gen(function* () { const factory = yield* LLMFactoryService; let _runtimeEnabled: boolean | null = null; @@ -36,22 +30,6 @@ export class MemoryService extends Effect.Service()('Memory', { updateMemoryEnabled(v); } - function loadMemoryForPrompt(cwd: string): string { - if (!getMemoryEnabled()) return ''; - const cfg = getMemoryConfig(); - - const projectPath = resolveMemoryPath(cwd); - const projectContent = readMemoryFile(projectPath); - const projectAuto = extractAutoBlock(projectContent); - - if (!projectAuto) return ''; - - const stripped = stripMarkersForPrompt(projectAuto); - const truncated = truncateForPrompt(stripped, cfg.promptMaxBytes); - - return truncated ? `## Long-term Memory\n\n${truncated}` : ''; - } - function truncateForPrompt(content: string, maxBytes: number): string { const contentBytes = Buffer.byteLength(content, 'utf-8'); if (contentBytes <= maxBytes) { @@ -71,20 +49,27 @@ export class MemoryService extends Effect.Service()('Memory', { return result; } - function buildStructuredTranscript(events: SessionEvent[]): StructuredTranscript { - const userOnly: string[] = []; - const userAndAssistant: string[] = []; - const userAndTools: string[] = []; + function loadMemoryForPrompt(cwd: string): string { + if (!getMemoryEnabled()) return ''; + const cfg = getMemoryConfig(); + const projectPath = resolveMemoryPath(cwd); + const content = readMemoryFile(projectPath); + if (!content) return ''; + + const truncated = truncateForPrompt(content, cfg.promptMaxBytes); + return truncated ? `## Long-term Memory\n\n${truncated}` : ''; + } + + function buildTranscript(events: SessionEvent[]): string { + const lines: string[] = []; for (const event of events) { switch (event.type) { case 'user': - userOnly.push(`[user] ${event.content}`); - userAndAssistant.push(`[user] ${event.content}`); - userAndTools.push(`[user] ${event.content}`); + lines.push(`[user] ${event.content}`); break; case 'assistant': - userAndAssistant.push(`[assistant] ${event.content}`); + lines.push(`[assistant] ${event.content}`); break; case 'tool_result': if ( @@ -92,17 +77,12 @@ export class MemoryService extends Effect.Service()('Memory', { event.toolName === 'read_file' || event.toolName === 'Read' ) { - userAndTools.push(`[tool:${event.toolName}] ${event.output}`); + lines.push(`[tool:${event.toolName}] ${event.output}`); } break; } } - - return { - userOnly: userOnly.join('\n---\n'), - userAndAssistant: userAndAssistant.join('\n---\n'), - userAndTools: userAndTools.join('\n---\n'), - }; + return lines.join('\n'); } async function flushSessionToMemory( @@ -119,25 +99,20 @@ export class MemoryService extends Effect.Service()('Memory', { let events: SessionEvent[]; try { - const { readFileSync } = await import('node:fs'); - const jsonlPath = sessionJsonlPathFromCwd(sessionCwd, sessionId); - const content = readFileSync(jsonlPath, 'utf-8'); - events = content - .split('\n') - .filter((l) => l.trim() && !l.includes('"type":"session_meta"')) - .map((l) => JSON.parse(l) as SessionEvent); + events = readTranscript(sessionCwd, sessionId).filter((e) => e.type !== 'session_meta'); } catch { return { written: false, bytes: 0 }; } + if (events.length === 0) { + return { written: false, bytes: 0 }; + } const cfg = getMemoryConfig(); const projectPath = resolveMemoryPath(sessionCwd); - const projectContent = readMemoryFile(projectPath); - const currentAuto = extractAutoBlock(projectContent); + const current = readMemoryFile(projectPath); try { - const transcript = buildStructuredTranscript(events); - const types = getEffectiveTypes(cfg); + const transcript = buildTranscript(events); const resolvedLlm = await Effect.runPromise( resolveLLM(cfg.model, llm).pipe(Effect.provideService(LLMFactoryService, factory)) @@ -147,24 +122,25 @@ export class MemoryService extends Effect.Service()('Memory', { } const extracted = await extractMemory({ - currentAuto, + currentMemory: current, transcript, - types, llm: resolvedLlm, }); - if (!extracted) { return { written: false, bytes: 0 }; } - const projectContentFresh = readMemoryFile(projectPath); - const projectAutoFresh = extractAutoBlock(projectContentFresh); - const merged = mergeAutoBlocks(projectAutoFresh, extracted); - const truncated = enforceMaxBytes(merged, MAX_BYTES); - const newProjectContent = replaceAutoBlock(projectContentFresh, truncated); + // 提取期间文件被手动改动则放弃本次写入 + if (readMemoryFile(projectPath) !== current) { + return { written: false, bytes: 0 }; + } - writeMemoryFileAtomic(projectPath, newProjectContent); + const truncated = enforceMaxBytes(extracted, MAX_BYTES); + if (truncated === current) { + return { written: false, bytes: 0 }; + } + writeMemoryFileAtomic(projectPath, truncated); return { written: true, bytes: Buffer.byteLength(truncated, 'utf-8') }; } catch { return { written: false, bytes: 0 }; @@ -177,5 +153,4 @@ export class MemoryService extends Effect.Service()('Memory', { loadMemoryForPrompt, flushSessionToMemory, }; - }), -}) {} +})); diff --git a/packages/codingcode/src/memory/port.ts b/packages/codingcode/src/memory/port.ts new file mode 100644 index 00000000..e8bc034e --- /dev/null +++ b/packages/codingcode/src/memory/port.ts @@ -0,0 +1,11 @@ +import { Context } from 'effect'; +import type { LLMClient } from '../llm/client.js'; + +export interface MemoryShape { + getMemoryEnabled(): boolean; + setMemoryEnabled(v: boolean): void; + loadMemoryForPrompt(cwd: string): string; + flushSessionToMemory(sessionId: string, llm: LLMClient | null, sessionCwd: string): Promise<{ written: boolean; bytes: number }>; +} + +export class MemoryService extends Context.Tag('Memory')() {} diff --git a/packages/codingcode/src/memory/storage.ts b/packages/codingcode/src/memory/storage.ts index a2fd7dd5..5a883920 100644 --- a/packages/codingcode/src/memory/storage.ts +++ b/packages/codingcode/src/memory/storage.ts @@ -5,8 +5,6 @@ export function resolveMemoryPath(cwd: string): string { return path.join(cwd, '.codingcode', 'memory.md'); } -// ── File Read/Write ── - export function readMemoryFile(absPath: string): string { try { return fs.readFileSync(absPath, 'utf-8').trim(); @@ -15,32 +13,13 @@ export function readMemoryFile(absPath: string): string { } } -export function extractAutoBlock(content: string): string { - const match = content.match(/([\s\S]*?)/); - return match ? match[1]!.trim() : ''; -} - -export function replaceAutoBlock(content: string, newAutoInner: string): string { - const marker = ''; - const endMarker = ''; - - if (content.includes(marker) && content.includes(endMarker)) { - return content.replace( - new RegExp( - `${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${endMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}` - ), - `${marker}\n${newAutoInner}\n${endMarker}` - ); - } - - return `${marker}\n${newAutoInner}\n${endMarker}`; -} +export function writeMemoryFileAtomic(absPath: string, content: string): void { + const dir = path.dirname(absPath); + fs.mkdirSync(dir, { recursive: true }); -export function stripMarkersForPrompt(content: string): string { - return content - .replace(/\n?/g, '') - .replace(/\n?/g, '') - .trim(); + const tmpFile = absPath + '.tmp'; + fs.writeFileSync(tmpFile, content, 'utf-8'); + fs.renameSync(tmpFile, absPath); } export function enforceMaxBytes(content: string, maxBytes: number): string { @@ -50,56 +29,34 @@ export function enforceMaxBytes(content: string, maxBytes: number): string { } const sections = content.split(/^### /m).filter(Boolean); - const namedSections = sections.map((s) => { - const lines = s.split('\n'); - const name = lines[0]!; - const body = lines.slice(1).join('\n'); - return { name, body, full: `### ${s}` }; - }); + if (sections.length === 0) { + return truncateByLines(content, maxBytes); + } let result = ''; - for (const section of namedSections) { - if (Buffer.byteLength(result + section.full + '\n', 'utf-8') <= maxBytes) { - result += (result ? '\n' : '') + section.full; + for (const section of sections) { + const candidate = result ? `${result}\n### ${section}` : `### ${section}`; + if (Buffer.byteLength(candidate, 'utf-8') <= maxBytes) { + result = candidate; + } else { + break; } } - - return result; + // 首个小节即超限时退化为按行截断,避免整份清空 + if (!result) { + return truncateByLines(content, maxBytes); + } + return result.trim(); } -export function mergeAutoBlocks(base: string, incoming: string): string { - const extractH3Sections = (content: string): Record => { - const sections: Record = {}; - const parts = content.split(/^### /m).filter(Boolean); - for (const part of parts) { - const lines = part.split('\n'); - const name = lines[0]!; - const body = lines.slice(1).join('\n').trim(); - sections[name] = body; +function truncateByLines(content: string, maxBytes: number): string { + let result = ''; + for (const line of content.split('\n')) { + const candidate = result ? `${result}\n${line}` : line; + if (Buffer.byteLength(candidate, 'utf-8') > maxBytes) { + break; } - return sections; - }; - - const baseSections = extractH3Sections(base); - const incomingSections = extractH3Sections(incoming); - - const merged: Record = { ...baseSections }; - for (const [name, body] of Object.entries(incomingSections)) { - merged[name] = body; + result = candidate; } - - const result = Object.entries(merged) - .map(([name, body]) => `### ${name}\n${body}`) - .join('\n\n'); - return result; } - -export function writeMemoryFileAtomic(absPath: string, content: string): void { - const dir = path.dirname(absPath); - fs.mkdirSync(dir, { recursive: true }); - - const tmpFile = absPath + '.tmp'; - fs.writeFileSync(tmpFile, content, 'utf-8'); - fs.renameSync(tmpFile, absPath); -} diff --git a/packages/codingcode/src/memory/types.ts b/packages/codingcode/src/memory/types.ts deleted file mode 100644 index 15b6f7a7..00000000 --- a/packages/codingcode/src/memory/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface MemoryTypeEntry { - name: string; - description: string; - isBuiltIn: boolean; - disabled: boolean; -} - -export interface StructuredTranscript { - userOnly: string; - userAndAssistant: string; - userAndTools: string; -} diff --git a/packages/codingcode/src/rules/port.ts b/packages/codingcode/src/rules/port.ts new file mode 100644 index 00000000..06d4e9b8 --- /dev/null +++ b/packages/codingcode/src/rules/port.ts @@ -0,0 +1,8 @@ +import { Context } from 'effect'; + +export interface RulesShape { + getAllRules(projectPath?: string): string; + evictProjectRules(projectPath: string): void; +} + +export class RulesService extends Context.Tag('Rules')() {} diff --git a/packages/codingcode/src/rules/index.ts b/packages/codingcode/src/rules/rules.ts similarity index 57% rename from packages/codingcode/src/rules/index.ts rename to packages/codingcode/src/rules/rules.ts index e0570f95..4be63ee3 100644 --- a/packages/codingcode/src/rules/index.ts +++ b/packages/codingcode/src/rules/rules.ts @@ -1,8 +1,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { spawn } from 'node:child_process'; -import { Effect } from 'effect'; +import { Layer, Effect } from 'effect'; +import { RulesService } from './port.js'; // ── Paths ── @@ -14,8 +14,7 @@ function getProjectRulesPath(projectPath?: string): string { return path.join(projectPath ?? process.cwd(), 'AGENTS.md'); } -export class RulesService extends Effect.Service()('Rules', { - sync: () => { +export const RulesLayer = Layer.effect(RulesService, Effect.sync(() => { let _globalRules: string | null = null; const _projectRulesCache = new Map(); const _allRulesCache = new Map(); @@ -67,66 +66,4 @@ export class RulesService extends Effect.Service()('Rules', { _allRulesCache.delete(projectPath); }, }; - }, -}) {} - -// ── Clear ── - -export function clearGlobalRules(): void { - try { - fs.unlinkSync(getGlobalRulesPath()); - } catch { - // file may not exist - } -} - -export function clearProjectRules(projectPath?: string): void { - try { - fs.unlinkSync(getProjectRulesPath(projectPath)); - } catch { - // file may not exist - } -} - -// ── Edit ── - -export function editInEditor(filePath: string): boolean { - const editor = - process.env.EDITOR || process.env.VISUAL || (process.platform === 'win32' ? 'notepad' : 'vim'); - - try { - if (process.platform === 'win32') { - spawn('cmd.exe', ['/c', 'start', '', editor, filePath], { - detached: true, - stdio: 'ignore', - windowsHide: true, - }).unref(); - } else { - spawn(editor, [filePath], { - detached: true, - stdio: 'ignore', - }).unref(); - } - return true; - } catch { - return false; - } -} - -export function editGlobalRules(): boolean { - const p = getGlobalRulesPath(); - const dir = path.dirname(p); - fs.mkdirSync(dir, { recursive: true }); - if (!fs.existsSync(p)) { - fs.writeFileSync(p, '', 'utf-8'); - } - return editInEditor(p); -} - -export function editProjectRules(projectPath?: string): boolean { - const p = getProjectRulesPath(projectPath); - if (!fs.existsSync(p)) { - fs.writeFileSync(p, '', 'utf-8'); - } - return editInEditor(p); -} +})); diff --git a/packages/codingcode/src/runtime/project-runtime.ts b/packages/codingcode/src/runtime/project-runtime.ts deleted file mode 100644 index dfaf174a..00000000 --- a/packages/codingcode/src/runtime/project-runtime.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Effect } from 'effect'; -import type { AgentProfile, AgentProfileName } from '../subagent/types.js'; -import type { ToolVisibilityPolicy } from '../tools/types.js'; -import { HookService } from '../hooks/registry.js'; -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 type { PermissionMode } from '../approval/types.js'; -import { readCurrentIndex } from '../session/file-ops.js'; -import { computePaths } from '../core/path.js'; -import { - BUILD_PROFILE, - PLAN_PROFILE, - isPlanProfile, - PLAN_PROFILE_ALLOWED_TOOLS, -} from '../agent/profile.js'; - -function isAgentProfileName(name: string): name is AgentProfileName { - return name === PLAN_PROFILE.name || name === BUILD_PROFILE.name; -} - -function profileByName(name: AgentProfileName): AgentProfile { - return name === PLAN_PROFILE.name ? PLAN_PROFILE : BUILD_PROFILE; -} - -export class ProjectRuntimeService extends Effect.Service()( - 'ProjectRuntime', - { - effect: Effect.gen(function* () { - const hooks = yield* HookService; - const mcp = yield* McpService; - const rules = yield* RulesService; - const session = yield* SessionService; - const prepared = new Set(); - - return { - prepareProject: (projectPath: string): Effect.Effect => - Effect.gen(function* () { - const norm = normalizePath(projectPath); - if (prepared.has(norm)) return; - prepared.add(norm); - rules.evictProjectRules(norm); - yield* hooks.reloadUserHooks(norm).pipe(Effect.catchAll(() => Effect.void)); - yield* mcp.syncConnections(norm).pipe(Effect.catchAll(() => Effect.void)); - }), - - resolveMainAgentProfile: ( - projectPath: string, - sessionId: string - ): AgentProfile | undefined => { - const idx = readCurrentIndex(computePaths(projectPath, sessionId).indexPath); - const name = idx?.activeProfile; - return name ? profileByName(name) : undefined; - }, - - resolveSubagentProfile: (_projectPath: string, name: string): AgentProfile | undefined => - isAgentProfileName(name) ? profileByName(name) : undefined, - - getToolPolicy: (profile: AgentProfile | undefined): ToolVisibilityPolicy => ({ - allowedTools: isPlanProfile(profile) ? new Set(PLAN_PROFILE_ALLOWED_TOOLS) : undefined, - allowedMcpServers: undefined, - }), - - setSessionProfile: ( - projectPath: string, - sessionId: string, - profile: AgentProfile, - permissionModeOverride?: PermissionMode - ): Effect.Effect => - Effect.gen(function* () { - const effectivePerm: PermissionMode = permissionModeOverride ?? 'default'; - yield* session.setPermissionModeOnDisk(projectPath, sessionId, effectivePerm); - yield* session.setActiveProfile(projectPath, sessionId, profile.name); - }), - - getSessionProfile: ( - sessionId: string, - projectPath: string - ): Effect.Effect => - Effect.gen(function* () { - const name = yield* session.getActiveProfile(projectPath, sessionId); - return profileByName(name); - }), - - getSessionPermissionMode: ( - sessionId: string, - projectPath: string - ): Effect.Effect => - session.getPermissionModeFromDisk(projectPath, sessionId), - - restoreSessionProfile: ( - projectPath: string, - sessionId: string, - profileName: AgentProfileName, - permissionModeOverride?: PermissionMode - ): Effect.Effect => - Effect.gen(function* () { - const profile = profileByName(profileName); - const effectivePerm: PermissionMode = permissionModeOverride ?? 'default'; - yield* session.setPermissionModeOnDisk(projectPath, sessionId, effectivePerm); - yield* session.setActiveProfile(projectPath, sessionId, profile.name); - }), - - disposeSession: (_sessionId: string): Effect.Effect => Effect.void, - - disposeProject: (projectPath: string): Effect.Effect => - Effect.sync(() => { - const norm = normalizePath(projectPath); - prepared.delete(norm); - rules.evictProjectRules(norm); - }), - }; - }), - } -) {} diff --git a/packages/codingcode/src/sandbox/index.ts b/packages/codingcode/src/sandbox/index.ts deleted file mode 100644 index d200b587..00000000 --- a/packages/codingcode/src/sandbox/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Sandbox module — reserved for future OS-level runtime isolation. - -export interface SandboxConfig { - allowedDomains?: string[]; - deniedDomains?: string[]; - allowReadPaths?: string[]; - allowWritePaths?: string[]; - denyReadPaths?: string[]; - denyWritePaths?: string[]; - allowUnixSockets?: string[]; - defaultTimeoutMs?: number; -} - -export interface ExecResult { - stdout: string; - stderr: string; - exitCode: number; -} - -export interface ExecuteOptions { - command: string; - timeoutMs?: number; -} - -/** Stub service — re-implement here when a real sandbox runtime is integrated. */ -export class SandboxService {} diff --git a/packages/codingcode/src/scheduler/port.ts b/packages/codingcode/src/scheduler/port.ts new file mode 100644 index 00000000..ba765339 --- /dev/null +++ b/packages/codingcode/src/scheduler/port.ts @@ -0,0 +1,16 @@ +import { Context } from 'effect'; +import type { ManagedRuntime } from 'effect'; +import type { Automation, CreateAutomationInput, UpdateAutomationInput } from './types.js'; + +export interface SchedulerShape { + setRuntime(rt: ManagedRuntime.ManagedRuntime): void; + initialize(): void; + list(): Automation[]; + add(input: CreateAutomationInput): Automation; + update(id: string, patch: UpdateAutomationInput): Automation | null; + remove(id: string): boolean; + runOnce(id: string): Promise; + stopAll(): void; +} + +export class SchedulerService extends Context.Tag('Scheduler')() {} diff --git a/packages/codingcode/src/scheduler/service.ts b/packages/codingcode/src/scheduler/scheduler.ts similarity index 73% rename from packages/codingcode/src/scheduler/service.ts rename to packages/codingcode/src/scheduler/scheduler.ts index 981d95ab..60dacfb8 100644 --- a/packages/codingcode/src/scheduler/service.ts +++ b/packages/codingcode/src/scheduler/scheduler.ts @@ -1,21 +1,18 @@ -import { Effect, ManagedRuntime } from 'effect'; +import { Layer, Effect, ManagedRuntime } from 'effect'; import { CronJob } from 'cron'; import { randomUUID } from 'crypto'; import { createLogger } from '@codingcode/infra/logger'; import type { Automation, CreateAutomationInput, UpdateAutomationInput } from './types.js'; import { 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 { AgentService } from '../agent/port.js'; import { AgentError } from '../core/error.js'; +import { SchedulerService } from './port.js'; const logger = createLogger(); const TIMEOUT_MS = 5 * 60 * 1000; -export class SchedulerService extends Effect.Service()('Scheduler', { - sync: () => { +export const SchedulerLayer = Layer.effect(SchedulerService, Effect.sync(() => { const jobs = new Map(); let _rt: ManagedRuntime.ManagedRuntime | null = null; @@ -39,40 +36,32 @@ export class SchedulerService extends Effect.Service()('Schedu if (!_rt) return; logger.info(`Running automation: ${auto.name} (${auto.id})`); - const llm = await _rt.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getLLMClient(); - }) - ); - 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: approval, - activeProfile: 'build', - permissionMode: 'bypass', - model: llm.modelInfo.model, + Effect.gen(function* () { + const agent = yield* AgentService; + return yield* agent.runTurn(auto.description, { + cwd: auto.projectCwd, + signal: controller.signal, + activeProfile: 'build', + permissionMode: 'bypass', + }); }) ); let lastContent = ''; - for await (const event of stream) { - if (event._tag === 'Done') { - lastContent = event.content; - } else if (event._tag === 'Error') { - logger.error(`Automation ${auto.id} agent error:`, event.error); + for await (const body of stream) { + if (body.family === 'event' && body.event.type === 'text_delta') { + lastContent += body.event.text; + } else if ( + body.family === 'transition' && + body.transition.to === 'end' && + body.transition.reason === 'error' + ) { + logger.error(`Automation ${auto.id} agent error:`, body.transition.error); } } @@ -178,37 +167,29 @@ export class SchedulerService extends Effect.Service()('Schedu const auto = automations.find((a) => a.id === id); if (!auto) return null; - const llm = await _rt.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getLLMClient(); - }) - ); - 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: approval, - activeProfile: 'build', - permissionMode: 'bypass', - model: llm.modelInfo.model, + Effect.gen(function* () { + const agent = yield* AgentService; + return yield* agent.runTurn(auto.description, { + cwd: auto.projectCwd, + signal: controller.signal, + activeProfile: 'build', + permissionMode: 'bypass', + }); }) ); - for await (const event of stream) { - if (event._tag === 'Error') { - logger.error(`Manual run for ${id} agent error:`, event.error); + for await (const body of stream) { + if ( + body.family === 'transition' && + body.transition.to === 'end' && + body.transition.reason === 'error' + ) { + logger.error(`Manual run for ${id} agent error:`, body.transition.error); } } @@ -234,5 +215,4 @@ export class SchedulerService extends Effect.Service()('Schedu jobs.clear(); }, }; - }, -}) {} +})); diff --git a/packages/codingcode/src/server/adapter.ts b/packages/codingcode/src/server/adapter.ts deleted file mode 100644 index 1a07b880..00000000 --- a/packages/codingcode/src/server/adapter.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { AgentEvent } from '../agent/types.js'; -import type { SseEvent } from './handler.js'; - -export function agentEventToSseEvent(event: AgentEvent): SseEvent | null { - switch (event._tag) { - case 'Step': - return { type: 'step', step: event.step }; - case 'TurnId': - return { type: 'turn_id', turnId: event.turnId }; - case 'ToolStart': - return { type: 'tool_start', id: event.id, name: event.name, args: event.args }; - case 'ToolResult': - return { - type: 'tool_result', - id: event.id, - name: event.name, - output: event.output, - ok: event.ok, - }; - case 'ToolDenied': - return { type: 'tool_denied', id: event.id, name: event.name, reason: event.reason }; - case 'Error': - return { - type: 'error', - message: event.error.message ?? String(event.error), - code: event.error.code, - }; - case 'Done': - return { type: 'done' }; - case 'TodoUpdate': - return { type: 'todo_update', items: event.items as unknown as Record[] }; - case 'Usage': - return { - type: 'usage', - prompt: event.prompt, - completion: event.completion, - total: event.total, - }; - case 'LlmChunk': - case 'Assistant': - case 'ReactiveCompact': - return null; - default: - return null; - } -} - -export async function* toSseEvents( - source: AsyncGenerator -): AsyncGenerator { - let currentStep = 0; - for await (const event of source) { - if (event._tag === 'Step') { - currentStep = event.step; - yield { type: 'step', step: event.step }; - continue; - } - if (event._tag === 'TurnId') { - yield { type: 'turn_id', turnId: event.turnId }; - continue; - } - if (event._tag === 'LlmChunk') { - yield { type: 'text', text: event.text, messageId: currentStep }; - continue; - } - if (event._tag === 'Assistant') { - yield { type: 'message', id: currentStep, content: event.content, partial: false }; - continue; - } - const sse = agentEventToSseEvent(event); - if (sse !== null) yield sse; - } -} diff --git a/packages/codingcode/src/server/handler.ts b/packages/codingcode/src/server/handler.ts index 01574b7f..486ca5c6 100644 --- a/packages/codingcode/src/server/handler.ts +++ b/packages/codingcode/src/server/handler.ts @@ -1,24 +1,25 @@ import type { Context } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; -import { ApprovalWaitService } from '../approval/async-confirm.js'; -import { HookService } from '../hooks/registry.js'; +import { ApprovalWaitService } from '../approval/wait-port.js'; import { AgentError } from '../core/error.js'; - -export type SseEvent = { type: string; [key: string]: unknown }; +import type { FrameBody } from '../core/frame.js'; +import { createFrameAssembler, encodeFrame } from '../core/frame-io.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; export function createSseHandler(rt: ManagedRt) { return function sseHandler( - createGenerator: () => AsyncGenerator, - opts?: { initialEvents?: SseEvent[]; sessionId?: string; onDone?: () => void } + createGenerator: () => AsyncGenerator, + opts?: { sessionId?: string; onDone?: () => void } ): (c: Context) => Promise { return async (c) => { const sessionId = opts?.sessionId ?? c.req.param('id') ?? 'default'; const stream = new ReadableStream({ async start(controller) { - const enqueue = (data: SseEvent) => { - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`)); + const assembler = createFrameAssembler({ sessionId }); + const emit = (body: FrameBody) => { + const frame = assembler.stamp(body); + controller.enqueue(new TextEncoder().encode(`data: ${encodeFrame(frame)}\n\n`)); }; const waitService = await rt.runPromise( @@ -26,55 +27,30 @@ export function createSseHandler(rt: ManagedRt) { return yield* ApprovalWaitService; }) ); - const hookService = await rt.runPromise( - Effect.gen(function* () { - return yield* HookService; - }) - ); Effect.runSync( waitService.registerEmitter( sessionId, (id: string, tool: string, args: Record) => { - enqueue({ type: 'approval_request', id, tool, args }); + emit({ family: 'event', event: { 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 !== sessionId) return; - enqueue({ - type: 'plan_ready', - sessionId: p.sessionId, - title: p.title ?? '', - }); - }) - ); - try { - if (opts?.initialEvents) { - for (const ev of opts.initialEvents) enqueue(ev); - } - const generator = createGenerator(); - for await (const event of generator) { - enqueue(event); + for await (const body of generator) { + emit(body); } - - enqueue({ type: 'complete' }); } catch (e) { - enqueue({ - type: 'error', - message: e instanceof Error ? e.message : String(e), - ...(e instanceof AgentError ? { code: e.code } : {}), + emit({ + family: 'fatal', + fatal: { + message: e instanceof Error ? e.message : String(e), + code: e instanceof AgentError ? e.code : 'INTERNAL_ERROR', + }, }); } finally { - unregisterPlanReady(); Effect.runSync(waitService.unregisterEmitter(sessionId)); opts?.onDone?.(); } diff --git a/packages/codingcode/src/server/routes/approval.ts b/packages/codingcode/src/server/routes/approval.ts index b6867914..aa10ec96 100644 --- a/packages/codingcode/src/server/routes/approval.ts +++ b/packages/codingcode/src/server/routes/approval.ts @@ -1,7 +1,7 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; -import { ApprovalWaitService } from '../../approval/async-confirm.js'; -import { parseApprovalResponse } from '../../approval/response.js'; +import { ApprovalWaitService } from '../../approval/wait-port.js'; +import { parseApprovalResponse } from '../../approval/confirmation.js'; import { errorResponse } from '../util.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; diff --git a/packages/codingcode/src/server/routes/automations.ts b/packages/codingcode/src/server/routes/automations.ts index c0245efe..996e178b 100644 --- a/packages/codingcode/src/server/routes/automations.ts +++ b/packages/codingcode/src/server/routes/automations.ts @@ -1,6 +1,6 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; -import { SchedulerService } from '../../scheduler/service.js'; +import { SchedulerService } from '../../scheduler/port.js'; import { errorResponse } from '../util.js'; import { NotFoundError } from '../../core/error.js'; import type { CreateAutomationInput, UpdateAutomationInput } from '../../scheduler/types.js'; diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index bf257099..5f883aad 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -1,14 +1,7 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; -import { sendMessage } from '../../agent/agent.js'; +import { AgentService } from '../../agent/port.js'; 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/path.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'; @@ -27,54 +20,24 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void { }) ); - const llmEither = await rt.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* Effect.either(factory.getLLMClient()); - }) - ); - if (llmEither._tag === 'Left') { - const { status, body } = errorResponse(llmEither.left); - return c.json(body, status as any); - } - const llm = llmEither.right; - - // Read session permissionMode if session exists - let approvalOverride: any = undefined; - if (sessionId !== '_') { - 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({ permissionMode: mode }); - }) - ); - approvalOverride = forked; - } - } - const isNew = sessionId === '_' || !sessionId; - const sendOptions: Parameters[4] = { + const runOpts: any = { + cwd: normalizedCwd, signal: c.req.raw.signal, - approvalOverride, }; if (isNew) { - sendOptions.activeProfile = 'build'; - sendOptions.permissionMode = 'default'; - sendOptions.model = llm.modelInfo.model; + runOpts.activeProfile = 'build'; + runOpts.permissionMode = 'default'; } - const program = sendMessage( - isNew ? undefined : sessionId, - input, - normalizedCwd, - llm, - sendOptions - ); const result = await rt.runPromise( - program.pipe( + Effect.gen(function* () { + const agent = yield* AgentService; + return yield* agent.runTurn(input, { + sessionId: isNew ? undefined : sessionId, + ...runOpts, + }); + }).pipe( Effect.catchAllDefect((defect) => Effect.fail(new Error(`Unexpected error: ${String(defect)}`)) ), @@ -94,12 +57,9 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void { return sseHandler( async function* () { - yield* toSseEvents(stream); + yield* stream; }, - { - initialEvents: [{ type: 'session_id', sessionId }], - sessionId, - } + { sessionId } )(c); }); } diff --git a/packages/codingcode/src/server/routes/models.ts b/packages/codingcode/src/server/routes/models.ts index b6f375f1..69393b67 100644 --- a/packages/codingcode/src/server/routes/models.ts +++ b/packages/codingcode/src/server/routes/models.ts @@ -1,6 +1,6 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; -import { LLMFactoryService } from '../../llm/factory.js'; +import { LLMFactoryService } from '../../llm/port.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index b1068133..ed6d55af 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -3,19 +3,18 @@ import { Effect, ManagedRuntime } from 'effect'; import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; import type { SessionStoreState } from '../../session/types.js'; -import type { AgentProfileName } from '../../subagent/types.js'; -import { SessionService } from '../../session/store.js'; -import { getPermissionMode, deleteSession } from '../../session/file-ops.js'; +import type { ProfileName } from '../../core/types.js'; +import { SessionService } from '../../session/port.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'; +import { ContextService } from '../../context/port.js'; +import { estimatePromptTokensFrom } from '../../context/context.js'; +import { CheckpointService } from '../../checkpoint/port.js'; import { WorkspaceService } from '../../core/workspace.js'; -import { LLMFactoryService } from '../../llm/factory.js'; +import { LLMFactoryService } from '../../llm/port.js'; import type { LLMClient } from '../../llm/client.js'; import { errorResponse } from '../util.js'; import { encodeProjectPath, getProjectBaseDir } from '../../core/path.js'; -import { BUILD_PROFILE, PLAN_PROFILE } from '../../agent/profile.js'; +import { AVAILABLE_PROFILES, isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode, type PermissionMode } from '../../approval/types.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -58,11 +57,11 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions', async (c) => { const body = (await c.req.json()) as { cwd: string; - activeProfile: AgentProfileName; + activeProfile: ProfileName; permissionMode: PermissionMode; model: string; }; - if (body.activeProfile !== 'plan' && body.activeProfile !== 'build') { + if (!isAgentProfileName(body.activeProfile)) { return c.json({ error: `Invalid activeProfile: ${body.activeProfile}` }, 400); } if (!isPermissionMode(body.permissionMode)) { @@ -144,7 +143,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const maxTokens = llm?.modelInfo.maxTokens ?? 128000; return yield* Effect.promise(() => - context.compactWithLLM(session.getTranscriptPath(state), maxTokens, llm) + context.compactWithLLM(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath, maxTokens, llm) ); }) ); @@ -159,7 +158,12 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); if (!cwd) return c.json({ error: 'cwd required' }, 400); - deleteSession(sessionId, cwd); + await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.deleteSession(sessionId, cwd); + }) as any + ); return c.json({ ok: true }); }); @@ -167,8 +171,17 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); if (!cwd) return c.json({ error: 'cwd required' }, 400); - const turns = readUIHistory(sessionId, cwd); - return c.json(turns); + const result = await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.readUITurns(sessionId, cwd); + }) as any + ); + if (!result.ok) { + const { status, body: errBody } = errorResponse(result.error); + return c.json(errBody, status as any); + } + return c.json(result.value); }); // ---- Plan file: read the current plan document for a session ---- @@ -247,13 +260,13 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { return c.json({ ...result.value, cwd, - available: [{ name: PLAN_PROFILE.name }, { name: BUILD_PROFILE.name }], + available: AVAILABLE_PROFILES, }); }); router.post('/api/sessions/:id/profile', async (c) => { const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd?: string; activeProfile: AgentProfileName }; + const body = (await c.req.json()) as { cwd?: string; activeProfile: ProfileName }; const cwd = await rt.runPromise( Effect.gen(function* () { const ws = yield* WorkspaceService; @@ -261,7 +274,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { }) ); const activeProfile = body.activeProfile; - if (activeProfile !== 'plan' && activeProfile !== 'build') { + if (!isAgentProfileName(activeProfile)) { return c.json({ error: `Invalid activeProfile: ${activeProfile}` }, 400); } const result = await runWithLayer( @@ -286,10 +299,18 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); if (!cwd) return c.json({ mode: 'default' }); - const idxPath = computePaths(cwd, sessionId).indexPath; - if (!existsSync(idxPath)) return c.json({ mode: 'default' }); - const mode = getPermissionMode(idxPath); - return c.json({ mode }); + const result = await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + return { mode: state.permissionMode }; + }) as any + ); + if (!result.ok) { + const { status, body: errBody } = errorResponse(result.error); + return c.json(errBody, status as any); + } + return c.json(result.value); }); router.put('/api/sessions/:id/permission-mode', async (c) => { @@ -302,7 +323,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const setResult = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; - yield* session.setPermissionModeOnDisk(cwd, sessionId, mode); + yield* session.setPermissionMode(cwd, sessionId, mode); return { ok: true }; }) as any ); @@ -313,36 +334,6 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { return c.json({ ok: true }); }); - router.get('/api/sessions/:id/rollback-state', async (c) => { - const sessionId = c.req.param('id'); - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(c.req.query('cwd')); - }) - ); - const result = await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - const entry = yield* checkpoint.getLatestRestoreEntry(cwd, sessionId); - return { - context: { active: false, currentThroughTurnId: null }, - code: { - canUndoLast: entry !== null, - lastEntry: entry, - revertedFiles: entry?.selectedFiles ?? [], - lastEntryId: entry?.id ?? null, - }, - }; - }) - ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); - } - return c.json(result.value); - }); - router.get('/api/sessions/:id/checkpoints/latest/diff', async (c) => { const sessionId = c.req.param('id'); const cwd = await rt.runPromise( @@ -402,17 +393,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const result = await runWithLayer( Effect.gen(function* () { const checkpoint = yield* CheckpointService; - const completedTurns = yield* checkpoint.getCompletedTurns(cwd, sessionId); - if (completedTurns.length === 0) - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - const latestTurnId = completedTurns[completedTurns.length - 1]!; - return yield* checkpoint.revertCheckpointFiles(cwd, sessionId, latestTurnId, [body.file]); + return yield* checkpoint.revertCheckpointFiles(cwd, sessionId, undefined, [body.file]); }) ); if (!result.ok) { @@ -434,17 +415,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const result = await runWithLayer( Effect.gen(function* () { const checkpoint = yield* CheckpointService; - const completedTurns = yield* checkpoint.getCompletedTurns(cwd, sessionId); - if (completedTurns.length === 0) - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }; - const latestTurnId = completedTurns[completedTurns.length - 1]!; - return yield* checkpoint.revertCheckpointFiles(cwd, sessionId, latestTurnId, body.files); + return yield* checkpoint.revertCheckpointFiles(cwd, sessionId, undefined, body.files); }) ); if (!result.ok) { @@ -511,12 +482,11 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); - const rolledBackMessage = findUserMessageForTurn(sessionId, body.throughTurnId, cwd); yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); - const turns = readUIHistory(sessionId, cwd); - const promptEstimate = estimatePromptTokens(session.getTranscriptPath(state)); + const turns = yield* session.readUITurns(sessionId, cwd); + const promptEstimate = estimatePromptTokensFrom(yield* session.readHistory(state)); const usage = state.usage; - return { ok: true, turns, rolledBackMessage, promptEstimate, usage }; + return { ok: true, turns, promptEstimate, usage }; }) as any ); if (!result.ok) { @@ -541,16 +511,14 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const checkpoint = yield* CheckpointService; const codeResult = yield* checkpoint.rollbackCodeToTurn(cwd, sessionId, body.throughTurnId); const state = yield* session.load(cwd, sessionId); - const rolledBackMessage = findUserMessageForTurn(sessionId, body.throughTurnId, cwd); yield* session.rollbackToTurn(state, body.throughTurnId, 'user rollback'); - const turns = readUIHistory(sessionId, cwd); - const promptEstimate = estimatePromptTokens(session.getTranscriptPath(state)); + const turns = yield* session.readUITurns(sessionId, cwd); + const promptEstimate = estimatePromptTokensFrom(yield* session.readHistory(state)); const usage = state.usage; return { ok: true, turns, codeResult, - rolledBackMessage, promptEstimate, usage, }; @@ -563,31 +531,6 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { return c.json(result.value); }); - router.post('/api/sessions/:id/undo-code-rollback', async (c) => { - const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string; force?: boolean; files?: string[] }; - const cwd = await rt.runPromise( - Effect.gen(function* () { - const ws = yield* WorkspaceService; - return ws.resolveWorkspaceCwd(body.cwd); - }) - ); - const result = await runWithLayer( - Effect.gen(function* () { - const checkpoint = yield* CheckpointService; - return yield* checkpoint.undoLastCodeRollback(cwd, sessionId, { - force: body.force, - files: body.files, - }); - }) - ); - if (!result.ok) { - const { status, body: errBody } = errorResponse(result.error); - return c.json(errBody, status as any); - } - return c.json({ ok: true, result: result.value }); - }); - router.post('/api/sessions/:id/fork', async (c) => { const sessionId = c.req.param('id'); const body = (await c.req.json()) as { cwd: string; atTurnId?: number }; @@ -603,9 +546,9 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); const newSessionId = yield* session.forkSession(state, atTurnId); - const turns = readUIHistory(newSessionId, cwd); + const turns = yield* session.readUITurns(newSessionId, cwd); const newJsonlPath = computePaths(cwd, newSessionId).transcriptPath; - const promptEstimate = estimatePromptTokens(newJsonlPath); + const promptEstimate = estimatePromptTokensFrom(session.readEvents(newJsonlPath)); return { sessionId: newSessionId, turns, promptEstimate }; }) as any ); diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index 2348cc55..29e98fca 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -1,6 +1,6 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; -import { SkillService } from '../../skills/service.js'; +import { SkillService } from '../../skills/port.js'; import { WorkspaceService, isGlobalCwd } from '../../core/workspace.js'; import { AlreadyExistsError, NotFoundError } from '../../core/error.js'; import type { McpServerConfig } from '../../mcp/types.js'; @@ -30,14 +30,7 @@ import { } from '../../hooks/config.js'; import { setHookRuntimeEnabled } from '../../hooks/executor.js'; import { discoverGlobalSkillDirs, discoverProjectSkillDirs } from '../../skills/source.js'; -import { - getMemoryConfig, - getAllTypesWithStatus, - setMemoryTypeDisabled, - addMemoryExtraType as _addMemoryExtraType, - updateMemoryExtraType as _updateMemoryExtraType, - deleteMemoryExtraType as _deleteMemoryExtraType, -} from '../../memory/config.js'; +import { getMemoryConfig } from '../../memory/config.js'; import { loadConfig, updateMaxSteps, @@ -45,7 +38,7 @@ import { updateContextCompactionModel, updateMemoryModel, } from '@codingcode/infra/config'; -import { MemoryService } from '../../memory/index.js'; +import { MemoryService } from '../../memory/port.js'; import { createRunWithLayer } from '../util.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -128,7 +121,6 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi const cfg = getMemoryConfig(); return c.json({ enabled: cfg.enabled, - types: getAllTypesWithStatus(cfg), model: cfg.model, }); }); @@ -150,51 +142,6 @@ export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promi return c.json({ enabled }); }); - router.post('/api/settings/memory/type-disabled', async (c) => { - const body = (await c.req.json()) as { name: string; disabled: boolean }; - setMemoryTypeDisabled(body.name, body.disabled); - return c.json({ ok: true }); - }); - - router.post('/api/settings/memory/extra-type', async (c) => { - const body = (await c.req.json()) as { name: string; description: string }; - try { - _addMemoryExtraType({ name: body.name, description: body.description, enabled: true }); - return c.json({ ok: true }); - } catch (e: any) { - if (e.message?.includes('already exists')) return c.json({ error: e.message }, 409); - throw e; - } - }); - - router.put('/api/settings/memory/extra-type/:name', async (c) => { - const name = c.req.param('name'); - const body = (await c.req.json()) as { name: string; description: string }; - try { - _updateMemoryExtraType(name, { - name: body.name, - description: body.description, - enabled: true, - }); - return c.json({ ok: true }); - } catch (e: any) { - if (e.message?.includes('not found')) return c.json({ error: e.message }, 404); - if (e.message?.includes('already exists')) return c.json({ error: e.message }, 409); - throw e; - } - }); - - router.delete('/api/settings/memory/extra-type/:name', async (c) => { - const name = c.req.param('name'); - try { - _deleteMemoryExtraType(name); - return c.json({ ok: true }); - } catch (e: any) { - if (e.message?.includes('not found')) return c.json({ error: e.message }, 404); - throw e; - } - }); - router.post('/api/settings/memory/model', async (c) => { const body = (await c.req.json()) as { model: string }; updateMemoryModel(body.model); diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index 2e65357f..335e0eed 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -14,11 +14,10 @@ import { import { homedir } from 'os'; import { join, dirname } from 'path'; import { getProjectBaseDir } from '../core/path.js'; -import { computePaths, projectSessionsDir, sessionJsonlPathFromCwd } from '../core/path.js'; +import { sessionJsonlPathFromCwd } from '../core/path.js'; +import type { PermissionMode } from '../approval/types.js'; import type { SessionEvent, SessionMetaEvent, SessionIndex } from './types.js'; -export { computePaths, projectSessionsDir, sessionJsonlPathFromCwd }; - export function ensureDirs(transcriptPath: string): void { const codingcodeDir = join(homedir(), '.codingcode'); if (!existsSync(codingcodeDir)) mkdirSync(codingcodeDir, { recursive: true }); @@ -131,6 +130,10 @@ export function readCurrentIndex(indexPath: string): Partial | nul } } +export function readTranscript(cwd: string, sessionId: string): SessionEvent[] { + return readHistory(sessionJsonlPathFromCwd(cwd, sessionId)); +} + export function writeIndexAtomic(indexPath: string, patch: Partial): void { let current: Partial = {}; if (existsSync(indexPath)) { @@ -147,7 +150,7 @@ export function writeIndexAtomic(indexPath: string, patch: Partial export function setPermissionMode( sessionId: string, indexPath: string, - mode: import('../approval/types.js').PermissionMode + mode: PermissionMode ): void { let index: SessionIndex | null = null; if (existsSync(indexPath)) { @@ -163,16 +166,6 @@ export function setPermissionMode( writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf8'); } -export function getPermissionMode(indexPath: string): string { - if (!existsSync(indexPath)) return 'default'; - try { - const index = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; - return index.permissionMode ?? 'default'; - } catch { - return 'default'; - } -} - export function deleteSession(sessionId: string, cwd: string): void { const dir = dirname(sessionJsonlPathFromCwd(cwd, sessionId)); if (!dir) return; diff --git a/packages/codingcode/src/session/port.ts b/packages/codingcode/src/session/port.ts new file mode 100644 index 00000000..e6ee3c35 --- /dev/null +++ b/packages/codingcode/src/session/port.ts @@ -0,0 +1,92 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { AgentError } from '../core/error.js'; +import type { + AssistantEvent, + RollbackEvent, + SessionEvent, + SessionIndex, + SessionStoreState, + SummaryEvent, + TokenUsage, + ToolResultEvent, + UserEvent, +} from './types.js'; +import type { ProfileName } from '../core/types.js'; +import type { PermissionMode } from '../approval/types.js'; + +export type UITurnItem = + | { id: string; type: 'message'; role: 'user' | 'assistant'; content: string; partial?: boolean } + | { + id: string; + type: 'tool_call'; + name: string; + args: Record; + status: 'pending' | 'approved' | 'rejected' | 'running'; + } + | { + id: string; + type: 'tool_result'; + callId: string; + name: string; + output: string; + exitCode?: number; + filePath?: string; + diff?: string; + insertions?: number; + deletions?: number; + } + | { + id: string; + type: 'summary'; + content: string; + startTurnId: number; + endTurnId: number; + } + | { id: string; type: 'reasoning'; content: string; isVisible: boolean } + | { id: string; type: 'error'; message: string; code?: string }; + +export interface UITurn { + id: string; + items: UITurnItem[]; + status: 'running' | 'completed' | 'error'; +} + +export interface SessionShape { + create(cwd: string, options: { model: string; activeProfile: ProfileName; permissionMode: PermissionMode }, opts?: { parentSessionId?: string; agentName?: string }): Effect.Effect; + load(cwd: string, sessionId: string): Effect.Effect; + deleteSession(sessionId: string, cwd: string): Effect.Effect; + forkSession(state: SessionStoreState, atTurnId: number): Effect.Effect; + renameSession(state: SessionStoreState, text: string): Effect.Effect; + listSessions(cwd?: string): Effect.Effect; + readHistory(state: SessionStoreState): Effect.Effect; + recordUser(state: SessionStoreState, content: string): Effect.Effect; + recordSystem(state: SessionStoreState, content: string): Effect.Effect; + recordAssistant(state: SessionStoreState, content: string, toolCalls: AssistantEvent['toolCalls'], usage?: TokenUsage): Effect.Effect; + recordToolResult(state: SessionStoreState, toolName: string, toolCallId: string, output: string): Effect.Effect; + appendSummary(state: SessionStoreState, summaryText: string, startTurnId: number, endTurnId: number): Effect.Effect; + rollbackToTurn(state: SessionStoreState, throughTurnId: number, reason: string): Effect.Effect; + readEvents(transcriptPath: string): SessionEvent[]; + appendEvent(transcriptPath: string, event: SessionEvent): void; + readUITurns(sessionId: string, cwd: string): Effect.Effect; + setPermissionMode(cwd: string, sessionId: string, mode: PermissionMode): Effect.Effect; + setActiveProfile(cwd: string, sessionId: string, profile: ProfileName): Effect.Effect; +} + +export class SessionService extends Context.Tag('Session')() {} + +// direct/sessions.ts 实际使用的消费视图,编译期锁定真实耦合面 +export type SessionStorePort = Pick< + SessionShape, + | 'create' + | 'load' + | 'deleteSession' + | 'forkSession' + | 'listSessions' + | 'readUITurns' + | 'setActiveProfile' + | 'setPermissionMode' +>; + +// direct/agent-runtime.ts 与 direct/settings.ts 只读/只写权限模式 +export type SessionStatePort = Pick; diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/session.ts similarity index 70% rename from packages/codingcode/src/session/store.ts rename to packages/codingcode/src/session/session.ts index fdf66b6e..9c6bffb9 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/session.ts @@ -1,10 +1,9 @@ -import { Effect } from 'effect'; +import { Effect, Layer } from 'effect'; import { randomUUID } from 'crypto'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join, dirname } from 'path'; import { AgentError } from '../core/error.js'; import { encodeProjectPath } from '../core/path.js'; -import type { PermissionMode } from '../approval/types.js'; import type { SessionMetaEvent, UserEvent, @@ -16,22 +15,26 @@ import type { TokenUsage, SessionEvent, SessionStoreState, + CompactEvent, } from './types.js'; +import type { ProfileName } from '../core/types.js'; +import type { PermissionMode } from '../approval/types.js'; +import { SessionService } from './port.js'; +import type { UITurn } from './port.js'; import { ensureDirs, readHistory, appendLine, listSessions, setPermissionMode, - getPermissionMode, readCurrentIndex, writeIndexAtomic, countNonMetaEvents, truncateTitle, findFirstUserContent, + deleteSession as deleteSessionImpl, } from './file-ops.js'; import { computePaths, sessionJsonlPathFromCwd } from '../core/path.js'; -import type { AgentProfileName } from '../subagent/types.js'; function pathsFromState(state: SessionStoreState) { return computePaths(state.cwd, state.sessionId, state.parentSessionId); @@ -42,8 +45,131 @@ function assertResumeWorkspace(cwd: string, sessionId: string): void { if (!existsSync(expectedPath)) throw AgentError.sessionNotFound(sessionId); } -export class SessionService extends Effect.Service()('Session', { - effect: Effect.gen(function* () { +// --- UI history (moved from ui-history.ts) --- + +export function filterForUI(events: SessionEvent[]): SessionEvent[] { + const rollbackHiddenTurnIds = new Set(); + const rollbackHiddenOpUuids = new Set(); + + for (const ev of events) { + if (ev.type !== 'rollback') continue; + for (const prior of events) { + if (prior === ev) break; + if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { + rollbackHiddenTurnIds.add(prior.turnId); + } + if (prior.type === 'summary' || prior.type === 'compact') { + if ((prior as SummaryEvent | CompactEvent).endTurnId >= ev.throughTurnId) { + rollbackHiddenOpUuids.add((prior as SummaryEvent | CompactEvent).uuid); + } + } + } + } + + return events.filter((ev) => { + if (ev.type === 'rollback') return false; + if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; + if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as CompactEvent).uuid)) return false; + if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; + return true; + }) as SessionEvent[]; +} + +function createTurnScopedIdGenerator() { + const counters = new Map(); + return (prefix: string, turnId: number): string => { + const key = `${prefix}:${turnId}`; + const next = (counters.get(key) ?? 0) + 1; + counters.set(key, next); + return `${prefix}-${turnId}-${next}`; + }; +} + +export function sessionEventsToTurns(events: SessionEvent[]): UITurn[] { + const turnsMap = new Map(); + const nextId = createTurnScopedIdGenerator(); + + for (const event of events) { + if (event.type === 'session_meta') continue; + if (event.type === 'compact' || event.type === 'rollback') continue; + + if (event.type === 'summary') { + let turn = turnsMap.get(event.endTurnId); + if (!turn) { + turn = { id: String(event.endTurnId), items: [], status: 'completed' }; + turnsMap.set(event.endTurnId, turn); + } + turn.items.push({ + id: `summary-${event.uuid}`, + type: 'summary', + content: event.summaryText, + startTurnId: event.startTurnId, + endTurnId: event.endTurnId, + }); + continue; + } + + let turn = turnsMap.get(event.turnId); + if (!turn) { + turn = { id: String(event.turnId), items: [], status: 'completed' }; + turnsMap.set(event.turnId, turn); + } + switch (event.type) { + case 'user': + if (event.source === 'system') break; + turn.items.push({ + id: nextId('user', event.turnId), + type: 'message', + role: 'user', + content: event.content, + }); + break; + case 'assistant': + if (event.content) { + turn.items.push({ + id: nextId('assistant', event.turnId), + type: 'message', + role: 'assistant', + content: event.content, + }); + } + for (const tc of event.toolCalls ?? []) { + const args = tc.arguments ?? {}; + turn.items.push({ + id: tc.id, + type: 'tool_call', + name: tc.name, + args, + status: 'approved', + }); + } + break; + case 'tool_result': { + turn.items.push({ + id: `result-${event.toolCallId}`, + type: 'tool_result', + callId: event.toolCallId, + name: event.toolName, + output: event.output, + }); + break; + } + } + } + return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); +} + +function readUIHistory(sessionId: string, cwd: string): UITurn[] { + const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); + if (!existsSync(jsonlPath)) return []; + const events = readHistory(jsonlPath); + const visibleEvents = filterForUI(events); + return sessionEventsToTurns(visibleEvents); +} + +export const SessionLayer = Layer.effect( + SessionService, + Effect.gen(function* () { function updateIndex(state: SessionStoreState): void { if (!state.sessionMeta) return; const paths = pathsFromState(state); @@ -69,7 +195,7 @@ export class SessionService extends Effect.Service()('Session', cwd: string, options: { model: string; - activeProfile: AgentProfileName; + activeProfile: ProfileName; permissionMode: PermissionMode; }, opts?: { parentSessionId?: string; agentName?: string } @@ -166,10 +292,12 @@ export class SessionService extends Effect.Service()('Session', ): Effect.Effect => Effect.try({ try: () => { + state.currentTurnId += 1; const event: UserEvent = { type: 'user', turnId: state.currentTurnId, content, + source: 'user', }; if (state.title === state.sessionId.slice(0, 8)) { state.title = truncateTitle(content); @@ -185,6 +313,29 @@ export class SessionService extends Effect.Service()('Session', : new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e), }); + const recordSystem = ( + state: SessionStoreState, + content: string + ): Effect.Effect => + Effect.try({ + try: () => { + const event: UserEvent = { + type: 'user', + turnId: state.currentTurnId, + content, + source: 'system', + }; + appendLine(pathsFromState(state).transcriptPath, event); + state.messageCount++; + updateIndex(state); + return event; + }, + catch: (e) => + e instanceof AgentError + ? e + : new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e), + }); + const recordAssistant = ( state: SessionStoreState, content: string, @@ -326,61 +477,10 @@ export class SessionService extends Effect.Service()('Session', const listSessionsFromCwd = (cwd?: string): Effect.Effect => Effect.sync(() => listSessions(cwd ? encodeProjectPath(cwd) : undefined)); - const getSessionId = (state: SessionStoreState): string => state.sessionId; - - const getTranscriptPath = (state: SessionStoreState): string => - pathsFromState(state).transcriptPath; - - const getMessageCount = (state: SessionStoreState): number => state.messageCount; - - const setPermissionModeFromState = ( - state: SessionStoreState, - mode: PermissionMode - ): Effect.Effect => - Effect.sync(() => { - setPermissionMode(state.sessionId, pathsFromState(state).indexPath, mode); - }); - - const getPermissionModeFromState = (state: SessionStoreState): Effect.Effect => - Effect.sync(() => { - const raw = getPermissionMode(pathsFromState(state).indexPath); - if (raw === 'default' || raw === 'acceptEdits' || raw === 'bypass') return raw; - return 'default'; - }); - - const updateActiveProfile = ( - state: SessionStoreState, - profileName: AgentProfileName - ): Effect.Effect => - Effect.sync(() => { - const index: SessionIndex = { - sessionId: state.sessionId, - cwd: state.cwd, - model: state.model, - createdAt: state.sessionMeta?.createdAt ?? new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: state.messageCount, - title: state.title, - currentTurnId: state.currentTurnId, - usage: state.usage, - permissionMode: state.permissionMode, - memorySnapshot: state.memorySnapshot, - activeProfile: profileName, - }; - state.activeProfile = profileName; - writeFileSync(pathsFromState(state).indexPath, JSON.stringify(index, null, 2), 'utf8'); - }); - - const incrementTurn = (state: SessionStoreState): number => { - state.currentTurnId += 1; - updateIndex(state); - return state.currentTurnId; - }; - - const setPermissionModeOnDisk = ( + const setPermissionModeByAddress = ( cwd: string, sessionId: string, - mode: import('../approval/types.js').PermissionMode + mode: PermissionMode ): Effect.Effect => Effect.sync(() => { const paths = computePaths(cwd, sessionId); @@ -390,67 +490,50 @@ export class SessionService extends Effect.Service()('Session', const setActiveProfile = ( cwd: string, sessionId: string, - profile: AgentProfileName + profile: ProfileName ): Effect.Effect => Effect.sync(() => { const paths = computePaths(cwd, sessionId); writeIndexAtomic(paths.indexPath, { activeProfile: profile }); }); - 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); - if (!idx?.activeProfile) throw new Error('Session index missing activeProfile'); - return idx.activeProfile; - }); - return { create, load, + deleteSession: (sessionId: string, cwd: string): Effect.Effect => + Effect.sync(() => { + deleteSessionImpl(sessionId, cwd); + }), + forkSession, + renameSession, + listSessions: listSessionsFromCwd, + + readHistory: readHistoryFromState, recordUser, + recordSystem, recordAssistant, recordToolResult, appendSummary, rollbackToTurn, - forkSession, - renameSession, - readHistory: readHistoryFromState, - listSessions: listSessionsFromCwd, - getSessionId, - getTranscriptPath, - getMessageCount, - setPermissionMode: setPermissionModeFromState, - getPermissionMode: getPermissionModeFromState, - updateActiveProfile, - incrementTurn, - readHistoryFile: (path: string): SessionEvent[] => readHistory(path), - appendLineProxy: (path: string, event: object): void => appendLine(path, event), - setPermissionModeOnDisk, + + readEvents: (transcriptPath: string): SessionEvent[] => readHistory(transcriptPath), + appendEvent: (transcriptPath: string, event: SessionEvent): void => + appendLine(transcriptPath, event), + + readUITurns: (sessionId: string, cwd: string) => + Effect.sync(() => readUIHistory(sessionId, cwd)), + + setPermissionMode: setPermissionModeByAddress, setActiveProfile, - getPermissionModeFromDisk, - getActiveProfile, }; - }), -}) {} + }) +); function forkSessionImpl(sourceJsonlPath: string, atTurnId: number): string { const events = readHistory(sourceJsonlPath); - const atIdx = events.findIndex((e) => e.type === 'user' && (e as any).turnId === atTurnId); + const atIdx = events.findIndex( + (e) => e.type === 'user' && (e as any).source !== 'system' && (e as any).turnId === atTurnId + ); const chain = atIdx >= 0 ? events.slice(0, atIdx + 1) : events; const newSessionId = randomUUID(); @@ -459,24 +542,11 @@ function forkSessionImpl(sourceJsonlPath: string, atTurnId: number): string { const newJsonlPath = join(sessionsDir, `${newSessionId}.jsonl`); const newIndexPath = join(sessionsDir, `${newSessionId}.index.json`); - const toolCallIdMap = new Map(); let turnId = 0; for (const ev of chain) { const cloned: any = { ...ev }; - if (cloned.type === 'assistant' && Array.isArray(cloned.toolCalls)) { - for (const tc of cloned.toolCalls) { - const newId = randomUUID(); - toolCallIdMap.set(tc.id, newId); - tc.id = newId; - } - } - - if (cloned.type === 'tool_result' && cloned.toolCallId) { - cloned.toolCallId = toolCallIdMap.get(cloned.toolCallId) ?? cloned.toolCallId; - } - if (cloned.type === 'session_meta') { cloned.sessionId = newSessionId; } diff --git a/packages/codingcode/src/session/types.ts b/packages/codingcode/src/session/types.ts index 28dc72fd..0797c67b 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -1,12 +1,15 @@ -import type { AgentProfileName } from '../subagent/types.js'; +import type { ProfileName, TokenUsage, ToolCall } from '../core/types.js'; +import type { PermissionMode } from '../approval/types.js'; + +export type { TokenUsage }; export interface SessionMetaEvent { type: 'session_meta'; sessionId: string; cwd: string; createdAt: string; - activeProfile: AgentProfileName; - permissionMode: import('../approval/types.js').PermissionMode; + activeProfile: ProfileName; + permissionMode: PermissionMode; parentSessionId?: string; agentName?: string; } @@ -15,13 +18,14 @@ export interface UserEvent { type: 'user'; turnId: number; content: string; + source?: 'user' | 'system'; } export interface AssistantEvent { type: 'assistant'; turnId: number; content: string; - toolCalls: Array<{ id: string; name: string; arguments: Record }>; + toolCalls: ToolCall[]; usage?: TokenUsage; } @@ -63,12 +67,6 @@ export type SessionEvent = | RollbackEvent | CompactEvent; -export interface TokenUsage { - prompt: number; - completion: number; - total: number; -} - export interface SessionIndex { sessionId: string; cwd: string; @@ -79,8 +77,8 @@ export interface SessionIndex { title: string; currentTurnId: number; usage: TokenUsage | undefined; - activeProfile: AgentProfileName; - permissionMode: import('../approval/types.js').PermissionMode; + activeProfile: ProfileName; + permissionMode: PermissionMode; memorySnapshot?: string; parentSessionId?: string; } @@ -91,8 +89,8 @@ export interface SessionStoreState { messageCount: number; sessionMeta: SessionMetaEvent | null; model: string; - activeProfile: AgentProfileName; - permissionMode: import('../approval/types.js').PermissionMode; + activeProfile: ProfileName; + permissionMode: PermissionMode; title: string; currentTurnId: number; usage: TokenUsage | undefined; diff --git a/packages/codingcode/src/session/ui-history.ts b/packages/codingcode/src/session/ui-history.ts deleted file mode 100644 index 4e91c0c4..00000000 --- a/packages/codingcode/src/session/ui-history.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { existsSync } from 'fs'; -import { readHistory } from './file-ops.js'; -import { sessionJsonlPathFromCwd } from '../core/path.js'; -import type { SessionEvent, SummaryEvent, CompactEvent } from './types.js'; - -export function filterForUI(events: SessionEvent[]): SessionEvent[] { - const rollbackHiddenTurnIds = new Set(); - const rollbackHiddenOpUuids = new Set(); - - for (const ev of events) { - if (ev.type !== 'rollback') continue; - for (const prior of events) { - if (prior === ev) break; - if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { - rollbackHiddenTurnIds.add(prior.turnId); - } - if (prior.type === 'summary' || prior.type === 'compact') { - if ((prior as SummaryEvent | CompactEvent).endTurnId >= ev.throughTurnId) { - rollbackHiddenOpUuids.add((prior as SummaryEvent | CompactEvent).uuid); - } - } - } - } - - return events.filter((ev) => { - if (ev.type === 'rollback') return false; - if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; - if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as CompactEvent).uuid)) return false; - if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; - return true; - }) as SessionEvent[]; -} - -function createTurnScopedIdGenerator() { - const counters = new Map(); - return (prefix: string, turnId: number): string => { - const key = `${prefix}:${turnId}`; - const next = (counters.get(key) ?? 0) + 1; - counters.set(key, next); - return `${prefix}-${turnId}-${next}`; - }; -} - -export function sessionEventsToTurns( - events: SessionEvent[] -): Array<{ id: string; items: object[]; status: string }> { - const turnsMap = new Map(); - const nextId = createTurnScopedIdGenerator(); - - for (const event of events) { - if (event.type === 'session_meta') continue; - if (event.type === 'compact' || event.type === 'rollback') continue; - - if (event.type === 'summary') { - let turn = turnsMap.get(event.endTurnId); - if (!turn) { - turn = { id: String(event.endTurnId), items: [], status: 'completed' }; - turnsMap.set(event.endTurnId, turn); - } - turn.items.push({ - id: `summary-${event.uuid}`, - type: 'summary', - content: event.summaryText, - startTurnId: event.startTurnId, - endTurnId: event.endTurnId, - }); - continue; - } - - let turn = turnsMap.get(event.turnId); - if (!turn) { - turn = { id: String(event.turnId), items: [], status: 'completed' }; - turnsMap.set(event.turnId, turn); - } - switch (event.type) { - case 'user': - turn.items.push({ - id: nextId('user', event.turnId), - type: 'message', - role: 'user', - content: event.content, - }); - break; - case 'assistant': - if (event.content) { - turn.items.push({ - id: nextId('assistant', event.turnId), - type: 'message', - role: 'assistant', - content: event.content, - }); - } - for (const tc of event.toolCalls ?? []) { - const args = tc.arguments ?? {}; - turn.items.push({ - id: tc.id, - type: 'tool_call', - name: tc.name, - args, - status: 'approved', - }); - } - break; - case 'tool_result': { - const item: Record = { - id: `result-${event.toolCallId}`, - type: 'tool_result', - callId: event.toolCallId, - name: event.toolName, - output: event.output, - }; - turn.items.push(item); - break; - } - } - } - return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); -} - -export function readUIHistory( - sessionId: string, - cwd: string -): Array<{ id: string; items: object[]; status: string }> { - const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); - if (!existsSync(jsonlPath)) return []; - const events = readHistory(jsonlPath); - const visibleEvents = filterForUI(events); - return sessionEventsToTurns(visibleEvents); -} - -export function findUserMessageForTurn(sessionId: string, turnId: number, cwd: string): string { - const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); - if (!existsSync(jsonlPath)) return ''; - const rawEvents = readHistory(jsonlPath); - for (const ev of rawEvents) { - if (ev.type === 'user' && (ev as any).turnId === turnId) { - return (ev as any).content ?? ''; - } - } - return ''; -} diff --git a/packages/codingcode/src/skills/port.ts b/packages/codingcode/src/skills/port.ts new file mode 100644 index 00000000..38be6e3c --- /dev/null +++ b/packages/codingcode/src/skills/port.ts @@ -0,0 +1,10 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { Skill } from './types.js'; + +export interface SkillShape { + getAll(projectPath: string): Effect.Effect; + extractSkill(projectPath: string, query: string): Effect.Effect<[Skill | undefined, string]>; +} + +export class SkillService extends Context.Tag('Skill')() {} diff --git a/packages/codingcode/src/skills/service.ts b/packages/codingcode/src/skills/skills.ts similarity index 50% rename from packages/codingcode/src/skills/service.ts rename to packages/codingcode/src/skills/skills.ts index 7a3c3b93..608a49fc 100644 --- a/packages/codingcode/src/skills/service.ts +++ b/packages/codingcode/src/skills/skills.ts @@ -1,10 +1,10 @@ -import { Effect } from 'effect'; +import { Layer, Effect } from 'effect'; import { discoverSkillDirs } from './source.js'; import { loadSkill } from './loader.js'; import type { Skill } from './types.js'; +import { SkillService } from './port.js'; -export class SkillService extends Effect.Service()('Skill', { - effect: Effect.gen(function* () { +export const SkillLayer = Layer.effect(SkillService, Effect.gen(function* () { const cachedByProject = new Map(); function readAll(projectPath: string): Skill[] { @@ -23,29 +23,6 @@ export class SkillService extends Effect.Service()('Skill', { return { getAll: (projectPath: string) => Effect.sync(() => readAll(projectPath)), - findByName: (projectPath: string, name: string) => - Effect.sync(() => readAll(projectPath).find((s) => s.name === name)), - - select: (projectPath: string, query: string) => - Effect.sync(() => { - const match = query.match(/^@([a-zA-Z0-9-]+)(?:\s+|$)/); - if (!match) return undefined; - const name = match[1]!; - return readAll(projectPath).find((s) => s.name === name); - }), - - selectImplicit: ( - projectPath: string, - query: string, - matcher: (all: readonly Skill[], q: string) => Effect.Effect - ): Effect.Effect => - Effect.gen(function* () { - const all = readAll(projectPath); - const name = yield* matcher(all, query); - if (!name) return undefined; - return all.find((s) => s.name === name); - }), - extractSkill: (projectPath: string, query: string) => Effect.sync(() => { const match = query.match(/^@([a-zA-Z0-9-]+)(?:\s+|$)/); @@ -57,11 +34,5 @@ export class SkillService extends Effect.Service()('Skill', { const actualQuery = query.replace(/^@[a-zA-Z0-9-]+\s*/, ''); return [skill, actualQuery] as [Skill | undefined, string]; }), - - evictProject: (projectPath: string) => - Effect.sync(() => { - cachedByProject.delete(projectPath); - }), }; - }), -}) {} +})); diff --git a/packages/codingcode/src/skills/types.ts b/packages/codingcode/src/skills/types.ts index 354a830b..17d4fcd1 100644 --- a/packages/codingcode/src/skills/types.ts +++ b/packages/codingcode/src/skills/types.ts @@ -4,16 +4,3 @@ export interface Skill { /** Absolute path to the skill's SKILL.md file. */ readonly skillPath: string; } - -export interface SkillServiceApi { - readonly getAll: import('effect').Effect.Effect; - readonly findByName: (name: string) => import('effect').Effect.Effect; - readonly select: (query: string) => import('effect').Effect.Effect; - readonly selectImplicit: ( - query: string, - matcher: ( - skills: readonly Skill[], - query: string - ) => import('effect').Effect.Effect - ) => import('effect').Effect.Effect; -} diff --git a/packages/codingcode/src/subagent/port.ts b/packages/codingcode/src/subagent/port.ts new file mode 100644 index 00000000..27d0bb1e --- /dev/null +++ b/packages/codingcode/src/subagent/port.ts @@ -0,0 +1,25 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { FrameBody } from '../core/frame.js'; +import type { AgentError } from '../core/error.js'; +import type { Result } from '../core/result.js'; + +export interface RunSubagentOptions { + sessionId?: string; + cwd: string; + signal?: AbortSignal; + activeProfile?: import('../core/types.js').ProfileName; + permissionMode?: import('../approval/types.js').PermissionMode; + model?: string; + parentSessionId?: string; + agentName?: string; +} + +export interface SubagentRunnerShape { + runSubagent(input: string, opts: RunSubagentOptions): Effect.Effect<{ + stream: AsyncGenerator, unknown>; + sessionId: string; + }>; +} + +export class SubagentRunnerService extends Context.Tag('SubagentRunner')() {} diff --git a/packages/codingcode/src/subagent/runner-service.ts b/packages/codingcode/src/subagent/runner-service.ts deleted file mode 100644 index 66d36b35..00000000 --- a/packages/codingcode/src/subagent/runner-service.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Effect } from 'effect'; -import type { AgentEvent } from '../agent/types.js'; -import type { AgentError } from '../core/error.js'; -import type { Result } from '../core/result.js'; -import type { RunStreamOptions } from '../agent/types.js'; - -export interface SubagentRunner { - runStream( - opts: RunStreamOptions - ): AsyncGenerator, unknown>; -} - -export class SubagentRunnerService extends Effect.Service()( - 'SubagentRunner', - { - effect: Effect.gen(function* () { - // Placeholder — the real implementation is provided by AgentService's Layer - return {} as SubagentRunner; - }), - } -) {} diff --git a/packages/codingcode/src/subagent/subagent.ts b/packages/codingcode/src/subagent/subagent.ts new file mode 100644 index 00000000..5372e278 --- /dev/null +++ b/packages/codingcode/src/subagent/subagent.ts @@ -0,0 +1,31 @@ +import { Layer, Effect } from 'effect'; +import { SubagentRunnerService } from './port.js'; +import type { RunSubagentOptions } from './port.js'; +import { AgentService } from '../agent/port.js'; +import type { FrameBody } from '../core/frame.js'; +import type { Result } from '../core/result.js'; + +export const SubagentRunnerLayer = Layer.effect( + SubagentRunnerService, + Effect.gen(function* () { + const agent = yield* AgentService; + + const runSubagent = (input: string, opts: RunSubagentOptions) => + Effect.gen(function* () { + const result = yield* agent.runTurn(input, { + sessionId: opts.sessionId, + cwd: opts.cwd, + signal: opts.signal, + activeProfile: opts.activeProfile, + permissionMode: opts.permissionMode, + model: opts.model, + }); + return { + stream: result.stream as AsyncGenerator, unknown>, + sessionId: result.sessionId, + }; + }); + + return { runSubagent }; + }) +); diff --git a/packages/codingcode/src/subagent/types.ts b/packages/codingcode/src/subagent/types.ts deleted file mode 100644 index b088a147..00000000 --- a/packages/codingcode/src/subagent/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type AgentProfileName = 'plan' | 'build'; - -export interface AgentProfile { - name: AgentProfileName; - systemPrompt?: string; - maxSteps?: number; -} diff --git a/packages/codingcode/src/todo/port.ts b/packages/codingcode/src/todo/port.ts new file mode 100644 index 00000000..cf7341b7 --- /dev/null +++ b/packages/codingcode/src/todo/port.ts @@ -0,0 +1,28 @@ +import { Context } from 'effect'; +import type { TodoItem } from '../core/types.js'; + +export type { TodoItem }; +export type Todo = TodoItem; + +export interface TodoCounts { + pending: number; + in_progress: number; + completed: number; +} + +export interface TodoShape { + read(sessionId: string): Todo[]; + write(sessionId: string, plan: Todo[]): void; + reset(): void; +} + +export class TodoService extends Context.Tag('Todo')() {} + +export const TODO_MAX_ITEMS = 20; +export const TODO_MAX_STEP_LEN = 60; + +export function countByStatus(plan: Todo[]): TodoCounts { + const c: TodoCounts = { pending: 0, in_progress: 0, completed: 0 }; + for (const t of plan) c[t.status]++; + return c; +} diff --git a/packages/codingcode/src/todo/todo.ts b/packages/codingcode/src/todo/todo.ts new file mode 100644 index 00000000..fffa8450 --- /dev/null +++ b/packages/codingcode/src/todo/todo.ts @@ -0,0 +1,11 @@ +import { Layer, Effect } from 'effect'; +import { TodoService } from './port.js'; + +export const TodoLayer = Layer.effect(TodoService, Effect.sync(() => { + const store = new Map(); + return { + read: (sessionId: string) => store.get(sessionId) ?? [], + write: (sessionId: string, plan: import('./port.js').Todo[]) => { store.set(sessionId, plan); }, + reset: () => { store.clear(); }, + }; +})); diff --git a/packages/codingcode/src/tools/builtin-tools.ts b/packages/codingcode/src/tools/builtin-tools.ts deleted file mode 100644 index 642983b8..00000000 --- a/packages/codingcode/src/tools/builtin-tools.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Effect } from 'effect'; -import type { ToolDefinition } from './types.js'; -import { ToolRegistry } from './registry.js'; -import { readFileTool } from './domains/fs/read.js'; -import { writeFileTool } from './domains/fs/write.js'; -import { editFileTool } from './domains/fs/edit.js'; -import { bashTool } from './domains/bash/exec.js'; -import { searchTool } from './domains/fs/grep.js'; -import { globTool } from './domains/fs/glob.js'; -import { webFetchTool } from './domains/web/fetch.js'; -import { webSearchTool } from './domains/web/search.js'; -import { createTodoWriteTool } from './domains/self/todo-write.js'; -import { TodoService } from '../agent/todo.js'; - -const STATELESS_BUILTIN_TOOLS: ToolDefinition[] = [ - readFileTool, - writeFileTool, - editFileTool, - bashTool, - searchTool, - globTool, - webFetchTool, - webSearchTool, -]; - -export function registerBuiltinTools( - registry: ToolRegistry -): Effect.Effect { - return Effect.gen(function* () { - const todoTool = yield* createTodoWriteTool(); - registry.register(...STATELESS_BUILTIN_TOOLS, todoTool); - }); -} diff --git a/packages/codingcode/src/tools/catalog.ts b/packages/codingcode/src/tools/catalog.ts new file mode 100644 index 00000000..509bd0db --- /dev/null +++ b/packages/codingcode/src/tools/catalog.ts @@ -0,0 +1,52 @@ +import type { ToolDefinition } from './types.js'; +import type { ToolDescription } from '../core/types.js'; +import type { ToolLookup } from './port.js'; +import { ToolRegistry } from './registry.js'; +import { readFileTool } from './domains/fs/read.js'; +import { writeFileTool } from './domains/fs/write.js'; +import { editFileTool } from './domains/fs/edit.js'; +import { bashTool } from './domains/bash/exec.js'; +import { searchTool } from './domains/fs/grep.js'; +import { globTool } from './domains/fs/glob.js'; +import { webFetchTool } from './domains/web/fetch.js'; +import { webSearchTool } from './domains/web/search.js'; +import { todoWriteTool } from './domains/self/todo-write.js'; +import { dispatchAgentTool } from './domains/subagent/dispatch.js'; +import { submitPlanTool } from './domains/subagent/submit-plan.js'; + +// 全量静态工具表:名字 -> 工具定义。agent 只传名字名单,这里按名查表装配, +// 不感知 profile / allowedTools 的取舍(取舍由 agent 侧的名单本身决定)。 +const ALL_TOOLS: ToolDefinition[] = [ + readFileTool, + writeFileTool, + editFileTool, + bashTool, + searchTool, + globTool, + webFetchTool, + webSearchTool, + todoWriteTool, + dispatchAgentTool, + submitPlanTool, +]; + +const TOOLS_BY_NAME = new Map(ALL_TOOLS.map((tool) => [tool.name, tool])); + +export function createToolCatalog( + toolNames: readonly string[], + mcpTools: ToolDefinition[] = [] +): { tools: ToolDescription[]; lookup: ToolLookup } { + const registry = new ToolRegistry(); + for (const name of toolNames) { + const definition = TOOLS_BY_NAME.get(name); + if (!definition) throw new Error(`Unknown tool: ${name}`); + registry.register(definition); + } + registry.register(...mcpTools); + return { + tools: registry.describe(), + lookup: (name) => registry.get(name), + }; +} + +export { TOOLS_BY_NAME }; diff --git a/packages/codingcode/src/tools/domains/self/todo-write.ts b/packages/codingcode/src/tools/domains/self/todo-write.ts index f1c7b3c7..12942003 100644 --- a/packages/codingcode/src/tools/domains/self/todo-write.ts +++ b/packages/codingcode/src/tools/domains/self/todo-write.ts @@ -7,8 +7,8 @@ import { countByStatus, TODO_MAX_ITEMS, TODO_MAX_STEP_LEN, -} from '../../../agent/todo.js'; -import type { Todo } from '../../../agent/types.js'; +} from '../../../todo/port.js'; +import type { Todo } from '../../../todo/port.js'; const todoSchema = z.object({ plan: z @@ -21,28 +21,22 @@ const todoSchema = z.object({ .max(TODO_MAX_ITEMS), }); -export function createTodoWriteTool(): Effect.Effect { - return Effect.gen(function* () { - const todoSvc = yield* TodoService; - - return { - name: 'todo_write', - description: - 'Replace the current task list. Use for multi-step work to track plan and progress. Pass the full updated plan; previous list is replaced entirely.', - parameters: todoSchema, - execute: (args, ctx) => { - const sessionId = ctx?.sessionId; - if (!sessionId) - return Effect.fail( - new AgentError('TOOL_EXECUTION_FAILED', 'todo_write requires sessionId') - ); - const { plan } = args as { plan: Todo[] }; - todoSvc.write(sessionId, plan); - const c = countByStatus(plan); - return Effect.succeed( - `pending=${c.pending} in_progress=${c.in_progress} completed=${c.completed}` +export const todoWriteTool: ToolDefinition = { + name: 'todo_write', + description: + 'Replace the current task list. Use for multi-step work to track plan and progress. Pass the full updated plan; previous list is replaced entirely.', + parameters: todoSchema, + execute: (args, ctx) => + Effect.gen(function* () { + const todoSvc = yield* TodoService; + const sessionId = ctx?.sessionId; + if (!sessionId) + return yield* Effect.fail( + new AgentError('TOOL_EXECUTION_FAILED', 'todo_write requires sessionId') ); - }, - }; - }); -} + const { plan } = args as { plan: Todo[] }; + todoSvc.write(sessionId, plan); + const c = countByStatus(plan); + return `pending=${c.pending} in_progress=${c.in_progress} completed=${c.completed}`; + }), +}; diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index 7983ff47..acdf5e5e 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -2,219 +2,95 @@ import { z } from 'zod'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; import type { ToolDefinition } from '../../types.js'; -import { SessionService } from '../../../session/store.js'; -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 { BUILD_PROFILE } from '../../../agent/profile.js'; -import { RulesService } from '../../../rules/index.js'; -import { ProjectRuntimeService } from '../../../runtime/project-runtime.js'; -import { SubagentRunnerService } from '../../../subagent/runner-service.js'; -import type { PermissionMode } from '../../../approval/types.js'; - -export function createDispatchAgentTool(): Effect.Effect< - ToolDefinition, - never, - | SessionService - | ApprovalService - | HookService - | McpService - | ProjectRuntimeService - | LLMFactoryService - | RulesService - | SubagentRunnerService -> { - return Effect.gen(function* () { - const session = yield* SessionService; - const approval = yield* ApprovalService; - const hooks = yield* HookService; - const mcp = yield* McpService; - const runtime = yield* ProjectRuntimeService; - const factory = yield* LLMFactoryService; - const rulesService = yield* RulesService; - const runner = yield* SubagentRunnerService; - - return { - name: 'dispatch_agent', - description: - 'Spawn an isolated subagent to handle specialized tasks. See "Available Subagents" in the system prompt for available profiles and their capabilities.', - parameters: z.object({ - agent: z.string().describe('subagent profile name'), - prompt: z.string().min(1).describe('task description for the subagent'), - }), - execute: (args, ctx) => - Effect.gen(function* () { - const { agent: agentName, prompt } = args as { agent: string; prompt: string }; - - const projectPath = ctx?.projectPath || process.cwd(); - - // Get profile - const profile = runtime.resolveSubagentProfile(projectPath, agentName); - if (!profile) { - return yield* Effect.fail( - new AgentError('TOOL_EXECUTION_FAILED', `Unknown subagent: ${agentName}`) - ); - } - - let llm = yield* factory.getLLMClient(); - - // Emit spawn.before hook (decision hook, can deny) - const parentSessionId = ctx?.sessionId; - const spawnDecision = yield* hooks.emitDecision('agent.subagent.spawn.before', { - profile: agentName, - prompt, - parentSessionId, - }); - if (spawnDecision && spawnDecision.decision === 'deny') { - return yield* Effect.fail( - new AgentError( - 'TOOL_NOT_ALLOWED', - `Subagent spawn denied: ${spawnDecision.reason ?? 'no reason provided'}` - ) - ); - } - - // Create subagent transcript nested under parent session - const subagentProfile = runtime.resolveSubagentProfile(projectPath, agentName); - - // 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 = parentPermissionMode ?? 'default'; - const childModel: string = llm.modelInfo.model; - - const childState = yield* session.create( - projectPath, - { - model: childModel, - activeProfile: (subagentProfile ?? BUILD_PROFILE).name, - permissionMode: childPermissionMode, - }, - { - parentSessionId: ctx?.sessionId, - agentName: agentName, - } - ); - const childUuid = childState.sessionId; - session.incrementTurn(childState); - yield* session.recordUser(childState, prompt); - - // Approval: always fork with permissionMode closure (no longer omitted for readonly) - const childApproval = yield* approval.fork({ - permissionMode: childPermissionMode, - }); - - // Build the plan-only tool policy from the active profile. - const childPolicy = runtime.getToolPolicy(profile); - - // Get MCP tools for subagent - const mcpTools = mcp.listProjectMcpTools(projectPath); - - // Run subagent - const rulesText = rulesService.getAllRules(projectPath); - const systemOverride = buildSubagentPrompt(profile, projectPath, rulesText); - const stream = runner.runStream({ - state: childState, - llm, - systemOverride, - toolPolicy: childPolicy, - mcpTools, - abortSignal: ctx?.signal, - parentSessionId: ctx?.sessionId, - agentName: agentName, - maxStepsOverride: profile.maxSteps, - approvalOverride: childApproval, - }); - - // Emit spawn.after hook - yield* hooks.emit('agent.subagent.spawn.after', { - childSessionId: childUuid, - profile: agentName, - }); - - let didComplete = false; - const finalContent = yield* Effect.async((resume) => { - let content = ''; - (async () => { - try { - for await (const event of stream) { - if (event._tag === 'Done') { - content = event.content; - } else if (event._tag === 'Error') { - resume( - Effect.fail( - new AgentError( - 'TOOL_EXECUTION_FAILED', - `Subagent failed: ${event.error.message}` - ) - ) - ); - return; - } - } - - // Cleanup (pure sync Effects — no service context required) - await Effect.runPromise(mcp.disposeSession(childUuid)); - await Effect.runPromise(hooks.disposeSession(childUuid)); - - didComplete = true; - resume(Effect.succeed(content || '(subagent completed without output)')); - } catch (e) { - // Cleanup on unexpected error - try { - await Effect.runPromise(mcp.disposeSession(childUuid)); - await Effect.runPromise(hooks.disposeSession(childUuid)); - } catch { - /* ignore cleanup errors */ - } - const msg = e instanceof Error ? e.message : String(e); - resume(Effect.fail(new AgentError('TOOL_EXECUTION_FAILED', msg))); +import { HookService } from '../../../hooks/port.js'; +import { McpService } from '../../../mcp/port.js'; +import { SubagentRunnerService } from '../../../subagent/port.js'; +import { resolveSubagentProfile } from '../../../agent/profile.js'; + +export const dispatchAgentTool: ToolDefinition< + HookService | McpService | SubagentRunnerService +> = { + name: 'dispatch_agent', + description: + 'Spawn an isolated subagent to handle specialized tasks. See "Available Subagents" in the system prompt for available profiles and their capabilities.', + parameters: z.object({ + agent: z.string().describe('subagent profile name'), + prompt: z.string().min(1).describe('task description for the subagent'), + }), + execute: (args, ctx) => + Effect.gen(function* () { + const hooks = yield* HookService; + const mcp = yield* McpService; + const runner = yield* SubagentRunnerService; + + const { agent: agentName, prompt } = args as { agent: string; prompt: string }; + const projectPath = ctx?.projectPath || process.cwd(); + + const profile = resolveSubagentProfile(agentName); + if (!profile) { + return yield* Effect.fail( + new AgentError('TOOL_EXECUTION_FAILED', `Unknown subagent: ${agentName}`) + ); + } + + const parentSessionId = ctx?.sessionId; + const spawnDecision = yield* hooks.emitDecision('agent.subagent.spawn.before', { + profile: agentName, prompt, parentSessionId, + }); + if (spawnDecision && spawnDecision.decision === 'deny') { + return yield* Effect.fail( + new AgentError('TOOL_NOT_ALLOWED', `Subagent spawn denied: ${spawnDecision.reason ?? 'no reason'}`) + ); + } + + const { stream, sessionId: childUuid } = yield* runner.runSubagent(prompt, { + cwd: projectPath, + signal: ctx?.signal, + activeProfile: profile.name as any, + parentSessionId: ctx?.sessionId, + agentName, + }); + + yield* hooks.emit('agent.subagent.spawn.after', { childSessionId: childUuid, profile: agentName }); + + let didComplete = false; + const finalContent = yield* Effect.async((resume) => { + let content = ''; + (async () => { + try { + for await (const body of stream) { + if (body.family === 'event') { + if (body.event.type === 'text_delta') content += body.event.text; + continue; } - })(); - }); - - if (didComplete) { - yield* hooks - .emit('agent.subagent.complete', { - childSessionId: childUuid, - profile: agentName, - status: 'done', - }) - .pipe(Effect.ignore); + if ( + body.family === 'transition' && + body.transition.to === 'end' && + body.transition.reason === 'error' + ) { + resume(Effect.fail(new AgentError('TOOL_EXECUTION_FAILED', `Subagent failed: ${body.transition.error.message}`))); + return; + } + } + await Effect.runPromise(mcp.disposeSession(childUuid)); + await Effect.runPromise(hooks.disposeSession(childUuid)); + didComplete = true; + resume(Effect.succeed(content || '(subagent completed without output)')); + } catch (e) { + try { + await Effect.runPromise(mcp.disposeSession(childUuid)); + await Effect.runPromise(hooks.disposeSession(childUuid)); + } catch { /* ignore */ } + const msg = e instanceof Error ? e.message : String(e); + resume(Effect.fail(new AgentError('TOOL_EXECUTION_FAILED', msg))); } + })(); + }); - return finalContent; - }) as Effect.Effect, - }; - }); -} - -function buildSubagentPrompt( - profile: { systemPrompt?: string }, - projectPath: string, - rules?: string -): string { - const parts: string[] = []; - - if (profile.systemPrompt) { - parts.push(profile.systemPrompt); - } - - parts.push(`## Environment -- Working directory: ${projectPath} -- Operating system: ${process.platform} -- Shell: ${process.env.SHELL || process.env.ComSpec || 'bash'}`); - - if (rules) { - parts.push( - `## User-defined Rules\n\nThe following rules MUST be followed at all times. They override any conflicting instructions above.\n\n${rules}` - ); - } + if (didComplete) { + yield* hooks.emit('agent.subagent.complete', { childSessionId: childUuid, profile: agentName, status: 'done' }).pipe(Effect.ignore); + } - return parts.filter(Boolean).join('\n\n'); -} + return finalContent; + }), +}; diff --git a/packages/codingcode/src/tools/port.ts b/packages/codingcode/src/tools/port.ts new file mode 100644 index 00000000..a161f753 --- /dev/null +++ b/packages/codingcode/src/tools/port.ts @@ -0,0 +1,22 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { ToolCall } from '../core/types.js'; +import type { ToolDefinition } from './types.js'; + +export type ToolResultUnion = + | { type: 'ok'; id: string; name: string; output: string } + | { type: 'denied'; id: string; name: string; reason: string } + | { type: 'error'; id: string; name: string; output: string }; + +export type ToolLookup = (name: string) => ToolDefinition | undefined; + +export interface ToolExecutorShape { + executeBatch(toolCalls: ToolCall[], sessionId?: string, opts?: { + turnId?: number; + projectPath?: string; + signal?: AbortSignal; + toolLookup?: ToolLookup; + }): Effect.Effect; +} + +export class ToolExecutorService extends Context.Tag('ToolExecutor')() {} diff --git a/packages/codingcode/src/tools/registry.ts b/packages/codingcode/src/tools/registry.ts index b48a0fa3..e16fc6b6 100644 --- a/packages/codingcode/src/tools/registry.ts +++ b/packages/codingcode/src/tools/registry.ts @@ -3,9 +3,9 @@ import type { ToolDefinition, ToolDescription } from './types.js'; import { canonicalizeSchema } from './utils/canonicalize-schema.js'; export class ToolRegistry { - private readonly tools = new Map(); + private readonly tools = new Map>(); - register(...definitions: ToolDefinition[]): void { + register(...definitions: ToolDefinition[]): void { for (const definition of definitions) { if (this.tools.has(definition.name)) { throw new Error(`Tool already registered: ${definition.name}`); @@ -14,7 +14,7 @@ export class ToolRegistry { } } - get(name: string, allowedTools?: ReadonlySet): ToolDefinition | undefined { + get(name: string, allowedTools?: ReadonlySet): ToolDefinition | undefined { if (allowedTools && !allowedTools.has(name)) return undefined; return this.tools.get(name); } diff --git a/packages/codingcode/src/tools/executor.ts b/packages/codingcode/src/tools/tools.ts similarity index 77% rename from packages/codingcode/src/tools/executor.ts rename to packages/codingcode/src/tools/tools.ts index 5109bf91..414ea780 100644 --- a/packages/codingcode/src/tools/executor.ts +++ b/packages/codingcode/src/tools/tools.ts @@ -1,9 +1,9 @@ -import { Effect } from 'effect'; +import { Layer, Effect } from 'effect'; import { AgentError } from '../core/error.js'; -import { HookService } from '../hooks/registry.js'; -import { ApprovalService } from '../approval/index.js'; +import { HookService } from '../hooks/port.js'; import type { ToolDefinition } from './types.js'; import type { ToolCall } from '../core/types.js'; +import { ToolExecutorService } from './port.js'; export type ToolResultUnion = | { type: 'ok'; id: string; name: string; output: string } @@ -12,10 +12,8 @@ export type ToolResultUnion = export type ToolLookup = (name: string) => ToolDefinition | undefined; -export class ToolExecutorService extends Effect.Service()('ToolExecutor', { - effect: Effect.gen(function* () { +export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(function* () { const hooks = yield* HookService; - const approval = yield* ApprovalService; function execute( name: string, @@ -25,42 +23,17 @@ export class ToolExecutorService extends Effect.Service()(' sessionId?: string; turnId?: number; projectPath?: string; - approval?: import('../approval/index.js').ApprovalService; callId?: string; toolLookup?: ToolLookup; } - ): Effect.Effect< - { output: string; diff?: string; filePath?: string; insertions?: number; deletions?: number }, - AgentError, - any - > { + ): any { return Effect.gen(function* () { const tool = opts?.toolLookup?.(name); if (!tool) return yield* Effect.fail(AgentError.toolNotFound(name)); - // 1. Approval pipeline (Layers 1-6) - const decisionApproval: typeof approval = opts?.approval ?? approval; - const decision = yield* decisionApproval.evaluate({ - tool: name, - input: args as Record, - callId: opts?.callId, - sessionId: opts?.sessionId ?? 'default', - projectPath: opts?.projectPath, - }); - - if (decision.type === 'deny') { - yield* hooks.emit('tool.execute.denied', { - toolName: name, - args: args as Record, - reason: decision.reason, - source: decision.source, - }); - return yield* Effect.fail(new AgentError('TOOL_NOT_ALLOWED', decision.reason)); - } - const finalArgs = args as Record; - // 2. Notification hook — use callId for consistent pairing + // Notification hook — use callId for consistent pairing const callId = opts?.callId; yield* hooks.emit('tool.execute.before', { toolName: name, @@ -78,18 +51,17 @@ export class ToolExecutorService extends Effect.Service()(' const ctx = { signal: opts?.signal, sessionId: opts?.sessionId, - turnId: opts?.turnId, projectPath: opts?.projectPath, }; - // Race tool execution against abort signal for immediate cancellation + let toolEffect = tool.execute(parsedArgs, ctx); if (opts?.signal) { if (opts.signal.aborted) { return yield* Effect.fail(new AgentError('TOOL_NOT_ALLOWED', 'Tool execution aborted')); } - toolEffect = Effect.race( + toolEffect = Effect.raceFirst( toolEffect, Effect.async((resume) => { const onAbort = () => @@ -132,13 +104,12 @@ export class ToolExecutorService extends Effect.Service()(' turnId?: number; projectPath?: string; signal?: AbortSignal; - approval?: import('../approval/index.js').ApprovalService; toolLookup?: ToolLookup; } ): Effect.Effect { return execute(tc.name, tc.arguments ?? {}, { sessionId, callId: tc.id, ...opts }).pipe( Effect.matchEffect({ - onSuccess: (result): Effect.Effect => + onSuccess: (result: any): Effect.Effect => Effect.succeed({ type: 'ok' as const, id: tc.id, @@ -182,7 +153,6 @@ export class ToolExecutorService extends Effect.Service()(' turnId?: number; projectPath?: string; signal?: AbortSignal; - approval?: import('../approval/index.js').ApprovalService; toolLookup?: ToolLookup; } ): Effect.Effect { @@ -238,6 +208,5 @@ export class ToolExecutorService extends Effect.Service()(' }); } - return { execute, executeBatch }; - }), -}) {} + return { executeBatch }; +} as any)); diff --git a/packages/codingcode/src/tools/types.ts b/packages/codingcode/src/tools/types.ts index e07b1457..a132f345 100644 --- a/packages/codingcode/src/tools/types.ts +++ b/packages/codingcode/src/tools/types.ts @@ -6,18 +6,12 @@ export type { ToolDescription } from '../core/types.js'; export interface ToolExecCtx { signal?: AbortSignal; sessionId?: string; - turnId?: number; projectPath?: string; } -export interface ToolDefinition { +export interface ToolDefinition { name: string; description: string; parameters: z.ZodTypeAny; - execute: (args: unknown, ctx?: ToolExecCtx) => Effect.Effect; -} - -export interface ToolVisibilityPolicy { - allowedTools?: Set; - allowedMcpServers?: Set; + execute: (args: unknown, ctx?: ToolExecCtx) => Effect.Effect; } diff --git a/packages/codingcode/src/tools/utils/canonicalize-schema.ts b/packages/codingcode/src/tools/utils/canonicalize-schema.ts index 452e33a3..26b4048b 100644 --- a/packages/codingcode/src/tools/utils/canonicalize-schema.ts +++ b/packages/codingcode/src/tools/utils/canonicalize-schema.ts @@ -1,18 +1,3 @@ -/** - * Recursively sort object keys to produce deterministic JSON serialization. - * - * Used to canonicalize tool JSON Schema so that consecutive calls with - * structurally identical schemas produce byte-identical strings — necessary - * for LLM provider prompt cache prefix stability. - * - * Special handling for JSON Schema: when an object has `properties` and - * `required`, the `required` array is reordered to follow the same key order - * as `properties` (which is sorted alphabetically). Without this, two - * structurally identical zod schemas declared in different field order - * would still produce different serialized output, since zod's `required` - * mirrors the declaration order rather than the canonicalized `properties` - * order. - */ export function canonicalizeSchema(value: unknown): unknown { if (Array.isArray(value)) { return value.map(canonicalizeSchema); diff --git a/packages/codingcode/test/agent-event.test.ts b/packages/codingcode/test/agent-event.test.ts deleted file mode 100644 index bf4f52fb..00000000 --- a/packages/codingcode/test/agent-event.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { AgentEvent } from '../src/agent/types.js'; -import { AgentError } from '../src/core/error.js'; - -describe('AgentEvent type', () => { - it('should accept an LlmChunk event', () => { - const ev: AgentEvent = { _tag: 'LlmChunk', text: 'hello' }; - expect(ev._tag).toBe('LlmChunk'); - if (ev._tag === 'LlmChunk') expect(ev.text).toBe('hello'); - }); - - it('should accept a Done event', () => { - const ev: AgentEvent = { _tag: 'Done', content: 'result' }; - expect(ev._tag).toBe('Done'); - if (ev._tag === 'Done') expect(ev.content).toBe('result'); - }); - - it('should accept a Usage event', () => { - const ev: AgentEvent = { _tag: 'Usage', prompt: 1000, completion: 500, total: 1500 }; - expect(ev._tag).toBe('Usage'); - if (ev._tag === 'Usage') { - expect(ev.prompt).toBe(1000); - expect(ev.completion).toBe(500); - expect(ev.total).toBe(1500); - } - }); - - it('should narrow correctly via discriminated union switch', () => { - const err = AgentError.maxStepsReached(5); - const ev: AgentEvent = { - _tag: 'Error', - error: err, - }; - switch (ev._tag) { - case 'Error': - expect(ev.error).toBeInstanceOf(AgentError); - break; - default: - // Should not reach here for this test - break; - } - }); -}); diff --git a/packages/codingcode/test/agent/abort.test.ts b/packages/codingcode/test/agent/abort.test.ts new file mode 100644 index 00000000..5294ab0b --- /dev/null +++ b/packages/codingcode/test/agent/abort.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest'; +import { makeState, runAgentTurn, textDeltas } from '../helpers/agent-harness.js'; +import type { FrameBody, Transition } from '../../src/core/frame.js'; + +vi.mock('@codingcode/infra/config', () => ({ + loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, + context: { compactionModel: '' }, + memory: { enabled: false }, + server: { port: 8080 }, + }), +})); + +const state = makeState({ sessionId: 'abort-sid', cwd: '/tmp', title: 'abort' }); + +type EndTransition = Extract; + +function endsOf(events: readonly FrameBody[]): EndTransition[] { + const out: EndTransition[] = []; + for (const b of events) { + if (b.family === 'transition' && b.transition.to === 'end') out.push(b.transition); + } + return out; +} + +// 长流:abort 前尽量多产内容,制造 producer 仍在跑时被中断的竞态 +function makeLongLlm() { + return { + completeStream: () => + (async function* () { + for (let i = 0; i < 500; i++) yield { type: 'text' as const, text: 'x' }; + yield { type: 'end' as const }; + })(), + modelInfo: { maxTokens: 1000 }, + } as any; +} + +describe('abort race (end frame is always produced exactly once)', () => { + it('terminates with exactly one end frame when aborted mid-stream', async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 5); + + const { events } = await runAgentTurn( + { llm: makeLongLlm(), state }, + { sessionId: state.sessionId, cwd: '/tmp', signal: controller.signal } + ); + + const ends = endsOf(events); + expect(ends).toHaveLength(1); + // 中断路径要么给出 aborted,要么在检查点收尾为 done;绝不会漏帧或补成兜底错误 + expect(['aborted', 'done']).toContain(ends[0]!.reason); + // 确实在流中途被截断,而不是跑完 500 段 + expect(textDeltas(events).length).toBeLessThan(500); + }, 20000); + + it('produces exactly one aborted end frame when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + + const { events } = await runAgentTurn( + { llm: makeLongLlm(), state }, + { sessionId: state.sessionId, cwd: '/tmp', signal: controller.signal } + ); + + const ends = endsOf(events); + expect(ends).toHaveLength(1); + expect(ends[0]!.reason).toBe('aborted'); + }, 20000); +}); diff --git a/packages/codingcode/test/agent/agent-cache-stability.test.ts b/packages/codingcode/test/agent/agent-cache-stability.test.ts index 60cbab09..858a51a8 100644 --- a/packages/codingcode/test/agent/agent-cache-stability.test.ts +++ b/packages/codingcode/test/agent/agent-cache-stability.test.ts @@ -1,13 +1,10 @@ import { describe, it, expect, vi } from 'vitest'; -import { Effect, Layer, Queue } from 'effect'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { TodoService } from '../../src/agent/todo.js'; -import { ContextService } from '../../src/context/service.js'; -import { MemoryService } from '../../src/memory/index.js'; +import { makeState, runAgentTurn, llmStream, pEnd } from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,107 +13,33 @@ vi.mock('@codingcode/infra/config', () => ({ model: '', maxBytes: 16384, promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], }, server: { port: 8080 }, }), })); -import { agentLoop } from '../../src/agent/agent.js'; -import { Result } from '../../src/core/result.js'; -import { SessionService } from '../../src/session/store.js'; - -const AllMockLayer = Layer.mergeAll( - Layer.succeed(CheckpointService, { - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, - } as any), - Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', - recordAssistant: () => Effect.succeed({}), - recordUser: () => Effect.succeed({}), - recordToolResult: () => Effect.succeed({}), - } as any), - Layer.succeed(ProjectRuntimeService, { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: () => undefined, - listAgentProfiles: () => [], - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, - }), - setSessionProfile: () => {}, - restoreSessionProfile: () => Effect.void, - getSessionProfile: () => undefined, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, - } as any), - Layer.succeed(TodoService, { - read: () => [], - write: () => {}, - reset: () => {}, - } as any), - Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [{ role: 'user' as const, content: 'hi' }], - compactedEvents: [], - promptEstimate: 10, - currentTurnId: 1, - compactedTurnIds: new Set(), - }), - compactIfNeeded: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - } as any), - Layer.succeed(MemoryService, { - getMemoryEnabled: () => false, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), - } as any) -); - -const mockHooks = { - emit: () => Effect.succeed(undefined), - emitDecision: () => Effect.succeed(null), -} as any; - -const mockState = { +const mockState = makeState({ sessionId: 'cache-test-sid', cwd: '/tmp/cache-test', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, - model: 'test-model', title: 'cache-stability', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - usage: undefined, - memorySnapshot: '', -}; +}); function makeCapturingLlm() { const captured: { system?: string } = {}; const llm = { - completeStream: (params: any) => { + completeStream: vi.fn((params: any) => { captured.system = params.system; - return { - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: '' })), - }; - }, + return llmStream(pEnd()); + }), modelInfo: { maxTokens: 1000 }, } as any; return { llm, captured }; } async function runOnce(llm: any) { - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop(null as any, mockHooks, 1, 0, { state: mockState, llm }, q).pipe( - Effect.provide(AllMockLayer) - ) as any + return runAgentTurn( + { llm, state: mockState }, + { sessionId: 'cache-test-sid', cwd: '/tmp/cache-test' } ); } diff --git a/packages/codingcode/test/agent/agent-concurrent.test.ts b/packages/codingcode/test/agent/agent-concurrent.test.ts index 4d62bc36..4179c066 100644 --- a/packages/codingcode/test/agent/agent-concurrent.test.ts +++ b/packages/codingcode/test/agent/agent-concurrent.test.ts @@ -1,13 +1,19 @@ import { describe, it, expect, vi } from 'vitest'; -import { Effect, Layer, Queue, Chunk } from 'effect'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { TodoService } from '../../src/agent/todo.js'; -import { ContextService } from '../../src/context/service.js'; -import { MemoryService } from '../../src/memory/index.js'; +import { Effect } from 'effect'; +import { + makeState, + runAgentTurn, + llmStream, + pText, + pToolCall, + pEnd, + toolResults, +} from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,170 +22,106 @@ vi.mock('@codingcode/infra/config', () => ({ model: '', maxBytes: 16384, promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], }, server: { port: 8080 }, }), })); -import { agentLoop } from '../../src/agent/agent.js'; -import { Result } from '../../src/core/result.js'; -import { SessionService } from '../../src/session/store.js'; - -const AllMockLayer = Layer.mergeAll( - Layer.succeed(CheckpointService, { - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, - } as any), - Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', - recordAssistant: () => Effect.succeed({}), - recordUser: () => Effect.succeed({}), - recordToolResult: () => Effect.succeed({}), - } as any), - Layer.succeed(ProjectRuntimeService, { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: () => undefined, - listAgentProfiles: () => [], - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, - }), - setSessionProfile: () => {}, - restoreSessionProfile: () => Effect.void, - getSessionProfile: () => undefined, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, - } as any), - Layer.succeed(TodoService, { - read: () => [], - write: () => {}, - reset: () => {}, - } as any), - Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [{ role: 'user' as const, content: 'hi' }], - compactedEvents: [], - promptEstimate: 10, - currentTurnId: 1, - compactedTurnIds: new Set(), +const mockState = makeState({ sessionId: 'test-sid', cwd: '/tmp', title: 'concurrent' }); + +// 每个工具独立执行并把顺序记录到 executionOrder;由 executeBatch 并发驱动。 +function makeConcurrentExecutor(opts: { barrierPromise?: Promise; failTool?: string }) { + const executionOrder: string[] = []; + const executor = { + execute: (name: string, _args: Record) => { + if (opts.failTool && name === opts.failTool) { + return Effect.fail(new Error('Simulated failure') as any); + } + if (name === 'tool_a') { + return Effect.gen(function* () { + executionOrder.push('tool_a_start'); + yield* Effect.promise(() => opts.barrierPromise as Promise); + executionOrder.push('tool_a'); + return `result-${name}`; + }); + } + return Effect.sync(() => { + executionOrder.push(name); + return `result-${name}`; + }); + }, + executeBatch: (toolCalls: any[]) => + Effect.all( + toolCalls.map((tc: any) => + executor.execute(tc.name, tc.arguments ?? {}).pipe( + (Effect.matchEffect as any)({ + onSuccess: (output: any) => + Effect.succeed({ type: 'ok' as const, id: tc.id, name: tc.name, output }), + onFailure: (err: any) => + Effect.succeed({ + type: 'error' as const, + id: tc.id, + name: tc.name, + output: String(err), + }), + }), + (Effect.catchAllDefect as any)((defect: any) => + Effect.succeed({ + type: 'error' as const, + id: tc.id, + name: tc.name, + output: String(defect), + }) + ) + ) + ), + { concurrency: 'unbounded' } + ), + }; + return { executor, executionOrder }; +} + +function makeToolSequenceLlm(firstToolCalls: any[]) { + let callCount = 0; + const llm = { + completeStream: vi.fn(() => { + callCount++; + if (callCount === 1) { + return llmStream( + ...firstToolCalls.map((tc) => pToolCall(tc.id, tc.name, tc.arguments ?? {})), + pEnd() + ); + } + return llmStream(pText('done'), pEnd()); }), - compactIfNeeded: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - } as any), - Layer.succeed(MemoryService, { - getMemoryEnabled: () => false, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), - } as any) -); - -const mockHooks = { - emit: () => Effect.succeed(undefined), - emitDecision: () => Effect.succeed(null), -} as any; + modelInfo: { maxTokens: 1000 }, + } as any; + return llm; +} -const mockState = { - sessionId: 'test-sid', - cwd: '/tmp', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, - model: 'test-model', - title: 'concurrent', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - usage: undefined, - memorySnapshot: '', -}; - -describe('agentLoop concurrent tool execution', () => { +describe('agent runTurn concurrent tool execution', () => { it('should execute multiple tool calls concurrently', async () => { - const executionOrder: string[] = []; let releaseBarrier!: () => void; const barrierPromise = new Promise((r) => { releaseBarrier = r; }); + const { executor, executionOrder } = makeConcurrentExecutor({ barrierPromise }); - const mockLlm = { - completeStream: (_params: any) => ({ - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ - content: '', - toolCalls: [ - { id: 'tc1', name: 'tool_a', arguments: {} }, - { id: 'tc2', name: 'tool_b', arguments: {} }, - { id: 'tc3', name: 'tool_c', arguments: {} }, - ], - }) - ), - }), - }; - - const mockExecutor = { - execute: (name: string, _args: Record, _opts?: any) => - name === 'tool_a' - ? Effect.gen(function* () { - executionOrder.push('tool_a_start'); - yield* Effect.promise(() => barrierPromise); - executionOrder.push(name); - return `result-${name}`; - }) - : Effect.gen(function* () { - executionOrder.push(name); - return `result-${name}`; - }), - executeBatch: (toolCalls: any[], _sessionId?: string) => - Effect.all( - toolCalls.map((tc: any) => - mockExecutor.execute(tc.name, tc.arguments ?? {}).pipe( - (Effect.matchEffect as any)({ - onSuccess: (output: any) => - Effect.succeed({ type: 'ok' as const, id: tc.id, name: tc.name, output }), - onFailure: (err: any) => - Effect.succeed({ - type: 'error' as const, - id: tc.id, - name: tc.name, - output: String(err), - }), - }), - (Effect.catchAllDefect as any)((defect: any) => - Effect.succeed({ - type: 'error' as const, - id: tc.id, - name: tc.name, - output: String(defect), - }) - ) - ) - ), - { concurrency: 'unbounded' } - ), - }; + const llm = makeToolSequenceLlm([ + { id: 'tc1', name: 'tool_a', arguments: {} }, + { id: 'tc2', name: 'tool_b', arguments: {} }, + { id: 'tc3', name: 'tool_c', arguments: {} }, + ]); - const q = Effect.runSync(Queue.unbounded()); - const runPromise = Effect.runPromise( - agentLoop( - mockExecutor as any, - mockHooks, - 1, - 2, - { state: mockState, llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any }, - q - ).pipe(Effect.provide(AllMockLayer)) as any + const runPromise = runAgentTurn( + { llm, state: mockState, executor }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - // Wait for tool_a to start, then immediately release barrier. - // tool_b and tool_c finish synchronously, so they must appear first. - await vi.waitFor(() => executionOrder.includes('tool_a_start'), { timeout: 5000 }); + // 等 tool_a 真正开始并阻塞在屏障后,再放行 —— tool_b/tool_c 同步完成必须先于 tool_a。 + await vi.waitFor(() => expect(executionOrder).toContain('tool_a_start'), { timeout: 5000 }); releaseBarrier(); - await runPromise; - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); + const { events } = await runPromise; expect(executionOrder).toHaveLength(4); expect(executionOrder[0]).toBe('tool_a_start'); @@ -187,78 +129,27 @@ describe('agentLoop concurrent tool execution', () => { expect(executionOrder.indexOf('tool_c')).toBeLessThan(executionOrder.indexOf('tool_a')); expect(executionOrder[executionOrder.length - 1]).toBe('tool_a'); - const toolResults = events.filter((e: any) => e._tag === 'ToolResult'); - expect(toolResults).toHaveLength(3); + expect(toolResults(events)).toHaveLength(3); }); it('should isolate tool failures', async () => { - const mockLlm = { - completeStream: (_params: any) => ({ - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ - content: '', - toolCalls: [ - { id: 'tc1', name: 'good_tool', arguments: {} }, - { id: 'tc2', name: 'bad_tool', arguments: {} }, - { id: 'tc3', name: 'good_tool2', arguments: {} }, - ], - }) - ), - }), - }; + const { executor } = makeConcurrentExecutor({ failTool: 'bad_tool' }); - const mockExecutor = { - execute: (name: string, _args: Record, _opts?: any) => - name === 'bad_tool' - ? Effect.fail(new Error('Simulated failure') as any) - : Effect.succeed(`result-${name}`), - executeBatch: (toolCalls: any[], _sessionId?: string) => - Effect.all( - toolCalls.map((tc: any) => - mockExecutor.execute(tc.name, tc.arguments ?? {}).pipe( - (Effect.matchEffect as any)({ - onSuccess: (output: any) => - Effect.succeed({ type: 'ok' as const, id: tc.id, name: tc.name, output }), - onFailure: (err: any) => - Effect.succeed({ - type: 'error' as const, - id: tc.id, - name: tc.name, - output: String(err), - }), - }), - (Effect.catchAllDefect as any)((defect: any) => - Effect.succeed({ - type: 'error' as const, - id: tc.id, - name: tc.name, - output: String(defect), - }) - ) - ) - ), - { concurrency: 'unbounded' } - ), - }; + const llm = makeToolSequenceLlm([ + { id: 'tc1', name: 'good_tool', arguments: {} }, + { id: 'tc2', name: 'bad_tool', arguments: {} }, + { id: 'tc3', name: 'good_tool2', arguments: {} }, + ]); - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop( - mockExecutor as any, - mockHooks, - 1, - 2, - { state: mockState, llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any }, - q - ).pipe(Effect.provide(AllMockLayer)) as any + const { events } = await runAgentTurn( + { llm, state: mockState, executor }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - const toolResults = events.filter((e: any) => e._tag === 'ToolResult'); - expect(toolResults).toHaveLength(3); - expect(toolResults.find((r: any) => r.name === 'good_tool')?.ok).toBe(true); - expect(toolResults.find((r: any) => r.name === 'good_tool2')?.ok).toBe(true); - expect(toolResults.find((r: any) => r.name === 'bad_tool')?.ok).toBe(false); + const results = toolResults(events); + expect(results).toHaveLength(3); + expect(results.find((r) => r.name === 'good_tool')?.outcome.status).toBe('ok'); + expect(results.find((r) => r.name === 'good_tool2')?.outcome.status).toBe('ok'); + expect(results.find((r) => r.name === 'bad_tool')?.outcome.status).toBe('error'); }); }); diff --git a/packages/codingcode/test/agent/agent-on-interrupt-emit.test.ts b/packages/codingcode/test/agent/agent-on-interrupt-emit.test.ts index 911cee2a..ab2d0901 100644 --- a/packages/codingcode/test/agent/agent-on-interrupt-emit.test.ts +++ b/packages/codingcode/test/agent/agent-on-interrupt-emit.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { Effect, Fiber } from 'effect'; -import { HookService } from '../../src/hooks/registry.js'; +import { HookService } from '../../src/hooks/port.js'; +import { HookLayer } from '../../src/hooks/hooks.js'; // This file pins the fix to `Effect.onInterrupt` callback in agent.ts // (around the `agent.turn.end` emit on abort). The old code wrapped the @@ -22,7 +23,7 @@ describe('Effect.onInterrupt callback can yield* emit (agent.ts abort hook fix)' let observerRan = false; let serviceResolved = false; - const AppLayer = HookService.Default; + const AppLayer = HookLayer; const program = Effect.gen(function* () { const hooks = yield* HookService; diff --git a/packages/codingcode/test/agent/agent-todo-event.test.ts b/packages/codingcode/test/agent/agent-todo-event.test.ts index c6b3083d..ad63a980 100644 --- a/packages/codingcode/test/agent/agent-todo-event.test.ts +++ b/packages/codingcode/test/agent/agent-todo-event.test.ts @@ -1,13 +1,20 @@ import { describe, it, expect, vi } from 'vitest'; -import { Effect, Layer, Queue, Chunk } from 'effect'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { TodoService } from '../../src/agent/todo.js'; -import { ContextService } from '../../src/context/service.js'; -import { MemoryService } from '../../src/memory/index.js'; +import { Effect } from 'effect'; +import { + makeState, + runAgentTurn, + llmStream, + pText, + pToolCall, + pEnd, + todoResults, + type HarnessMocks, +} from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,174 +23,75 @@ vi.mock('@codingcode/infra/config', () => ({ model: '', maxBytes: 16384, promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], }, server: { port: 8080 }, }), })); -import { agentLoop } from '../../src/agent/agent.js'; -import { Result } from '../../src/core/result.js'; -import { SessionService } from '../../src/session/store.js'; - -/** Mutable todo store for testing - backs the TodoService mock. */ -const todoStore = new Map(); - -const AllMockLayer = Layer.mergeAll( - Layer.succeed(CheckpointService, { - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, - } as any), - Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', - recordAssistant: () => Effect.succeed({}), - recordUser: () => Effect.succeed({}), - recordToolResult: () => Effect.succeed({}), - } as any), - Layer.succeed(ProjectRuntimeService, { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: () => undefined, - listAgentProfiles: () => [], - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, - }), - setSessionProfile: () => {}, - restoreSessionProfile: () => Effect.void, - getSessionProfile: () => undefined, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, - } as any), - Layer.succeed(TodoService, { - read: (sessionId: string) => todoStore.get(sessionId) ?? [], - write: (sessionId: string, items: any[]) => { - todoStore.set(sessionId, items); - }, - reset: () => { - todoStore.clear(); - }, - } as any), - Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [{ role: 'user' as const, content: 'hi' }], - compactedEvents: [], - promptEstimate: 10, - currentTurnId: 1, - compactedTurnIds: new Set(), +function makeLlm(firstToolName: string) { + let callCount = 0; + const llm = { + completeStream: vi.fn(() => { + callCount++; + if (callCount === 1) { + return llmStream(pToolCall('tc1', firstToolName, {}), pEnd()); + } + return llmStream(pText('done'), pEnd()); }), - compactIfNeeded: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - } as any), - Layer.succeed(MemoryService, { - getMemoryEnabled: () => false, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), - } as any) -); + modelInfo: { maxTokens: 1000 }, + } as any; + return llm; +} -const mockHooks = { - emit: () => Effect.succeed(undefined), - emitDecision: () => Effect.succeed(null), -} as any; +function makeExecutor(output: string) { + return { + executeBatch: (calls: any[]) => + Effect.succeed( + calls.map((c: any) => ({ + type: 'ok' as const, + id: c.id, + name: c.name, + output, + })) + ), + } as any; +} -const mockState = { - sessionId: 'test-todo-sid', - cwd: '/tmp', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, - model: 'test-model', - title: 'test', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - usage: undefined, - memorySnapshot: '', -}; - -const mockLlm = { - completeStream: (_params: any) => ({ - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ - content: '', - toolCalls: [{ id: 'tc1', name: 'execute_command', arguments: { command: 'echo hi' } }], - }) - ), - }), -}; - -describe('TodoUpdate event', () => { - it('should yield TodoUpdate when todo_write tool is called', async () => { - todoStore.set('test-todo-sid', [ +describe('todo_write tool result', () => { + it('should carry todos on the tool_result when todo_write is called', async () => { + const todo = new Map>(); + todo.set('test-todo-sid', [ { step: 'setup', status: 'pending' }, { step: 'test', status: 'completed' }, ]); - - const mockExecutor = { - execute: () => Effect.succeed('done'), - executeBatch: () => - Effect.succeed([ - { - type: 'ok' as const, - id: 'tc1', - name: 'todo_write', - output: 'pending=1 completed=1 in_progress=0', - }, - ]), + const mocks: HarnessMocks = { + llm: makeLlm('todo_write'), + state: makeState({ sessionId: 'test-todo-sid', cwd: '/tmp' }), + todo, + executor: makeExecutor('pending=1 completed=1 in_progress=0'), }; - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop( - mockExecutor as any, - mockHooks, - 1, - 2, - { state: mockState, llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any }, - q - ).pipe(Effect.provide(AllMockLayer)) as any - ); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); + const { events } = await runAgentTurn(mocks, { sessionId: 'test-todo-sid', cwd: '/tmp' }); - const todoUpdates = events.filter((e: any) => e._tag === 'TodoUpdate'); - expect(todoUpdates).toHaveLength(1); - expect(todoUpdates[0].items).toEqual([ + const results = todoResults(events); + expect(results).toHaveLength(1); + expect(results[0]!.todos).toEqual([ { step: 'setup', status: 'pending' }, { step: 'test', status: 'completed' }, ]); }); - it('should not yield TodoUpdate when non-todo tools are called', async () => { - todoStore.set('non-todo', []); - - const mockExecutor = { - execute: () => Effect.succeed('done'), - executeBatch: () => - Effect.succeed([ - { type: 'ok' as const, id: 'tc1', name: 'read_file', output: 'file content' }, - ]), + it('should not carry todos when non-todo tools are called', async () => { + const todo = new Map>(); + const mocks: HarnessMocks = { + llm: makeLlm('read_file'), + state: makeState({ sessionId: 'non-todo', cwd: '/tmp' }), + todo, + executor: makeExecutor('file content'), }; - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop( - mockExecutor as any, - mockHooks, - 1, - 2, - { - state: { ...mockState, sessionId: 'non-todo' }, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - }, - q - ).pipe(Effect.provide(AllMockLayer)) as any - ); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); + const { events } = await runAgentTurn(mocks, { sessionId: 'non-todo', cwd: '/tmp' }); - const todoUpdates = events.filter((e: any) => e._tag === 'TodoUpdate'); - expect(todoUpdates).toHaveLength(0); + expect(todoResults(events)).toHaveLength(0); }); }); diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index dca1276c..f2a9d032 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -1,394 +1,139 @@ import { describe, it, expect, vi } from 'vitest'; -import { Effect, Layer, Queue, Chunk } from 'effect'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; -import { SessionService } from '../../src/session/store.js'; -import { agentLoop } from '../../src/agent/agent.js'; -import type { AgentEvent } from '../../src/agent/types.js'; -import { Result } from '../../src/core/result.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { ToolExecutorService } from '../../src/tools/executor.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { TodoService } from '../../src/agent/todo.js'; -import { ContextService } from '../../src/context/service.js'; -import { MemoryService } from '../../src/memory/index.js'; +import { Effect } from 'effect'; +import { + makeState, + runAgentTurn, + llmStream, + pText, + pToolCall, + pEnd, + texts, + toolResults, + endReason, +} from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ - context: { - compactionModel: '', - }, - memory: { - enabled: false, - model: '', - maxBytes: 16384, - promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], - }, + maxSteps: 5, + maxStopContinuations: 2, + context: { compactionModel: '' }, + memory: { enabled: false }, server: { port: 8080 }, }), })); -const mockAgentService = { - runStream: () => { - throw new Error('not implemented'); - }, -}; - -const mockState = { - sessionId: 'test-sid', - cwd: '/tmp', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, - model: 'test-model', - title: 'test', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - usage: undefined, - memorySnapshot: '', -}; +const mockState = makeState({ sessionId: 'test-sid', cwd: '/tmp', title: 'test' }); -function makeDeps(overrides?: Record) { - return { - maxSteps: 25, - maxStopContinuations: 2, - executor: null as any, - runtime: { listAgentProfiles: () => [] } as any, - agentService: mockAgentService as any, - hooks: { - emit: () => Effect.succeed(undefined), - emitDecision: () => Effect.succeed(null), - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), - reloadUserHooks: () => Effect.succeed(undefined), - } as unknown as HookService, - ...overrides, - }; +function makeCapturingLlm(parts: () => AsyncIterable) { + const llm = { + completeStream: vi.fn(() => parts()), + modelInfo: { maxTokens: 1000 }, + } as any; + return llm; } -const AllMockLayer = Layer.mergeAll( - Layer.succeed(CheckpointService, { - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, - getCompletedTurns: () => Effect.succeed([]), - getCheckpoints: () => Effect.succeed([]), - getCheckpointDiff: () => Effect.succeed({ turnId: 0, files: [] }), - revertCheckpointFiles: () => - Effect.succeed({ - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }), - previewRollbackDiff: () => Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }), - rollbackCodeToTurn: () => - Effect.succeed({ - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }), - undoLastCodeRollback: () => - Effect.succeed({ - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], - }), - getLatestRestoreEntry: () => Effect.succeed(null), - } as any), - Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', - recordAssistant: () => Effect.succeed({}), - recordUser: () => Effect.succeed({}), - recordToolResult: () => Effect.succeed({}), - } as any), - Layer.succeed(HookService, { - emit: () => Effect.succeed(undefined), - emitDecision: () => Effect.succeed(null), - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), - reloadUserHooks: () => Effect.succeed(undefined), - } as any), - Layer.succeed(ToolExecutorService, { - execute: () => Effect.succeed(''), - executeBatch: (tcs: any[]) => - Effect.succeed( - tcs.map((tc: any) => ({ type: 'ok' as const, id: tc.id, name: tc.name, output: '' })) - ), - } as any), - Layer.succeed(ProjectRuntimeService, { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: () => undefined, - listAgentProfiles: () => [], - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, - }), - setSessionProfile: () => {}, - restoreSessionProfile: () => Effect.void, - getSessionProfile: () => undefined, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, - } as any), - Layer.succeed(TodoService, { - read: () => [], - write: () => {}, - reset: () => {}, - } as any), - Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [{ role: 'user' as const, content: 'hi' }], - compactedEvents: [], - promptEstimate: 10, - currentTurnId: 1, - compactedTurnIds: new Set(), - }), - compactIfNeeded: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - } as any), - Layer.succeed(MemoryService, { - getMemoryEnabled: () => false, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), - } as any) -); - -describe('agentLoop', () => { +describe('agent runTurn loop', () => { it('should yield text chunks from LLM stream', async () => { - const mockLlm = { - completeStream: (_params: any) => ({ - stream: (async function* () { - yield 'Hello'; - yield ' '; - yield 'world'; - })(), - response: Promise.resolve(Result.ok({ content: 'Hello world' })), - }), - }; - - const deps = makeDeps(); - const opts = { state: mockState, llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any }; - const q = Effect.runSync(Queue.unbounded()); - const effect = agentLoop( - deps.executor, - deps.hooks, - deps.maxSteps, - deps.maxStopContinuations, - opts, - q + const llm = makeCapturingLlm(() => llmStream(pText('Hello'), pText(' '), pText('world'), pEnd())); + const { events } = await runAgentTurn( + { llm, state: mockState }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - await Effect.runPromise(effect.pipe(Effect.provide(AllMockLayer))); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - const textEvents = events.filter((e: any) => e._tag === 'LlmChunk'); - expect(textEvents.map((e: any) => e.text)).toEqual(['Hello', ' ', 'world']); + expect(texts(events)).toEqual(['Hello', ' ', 'world']); }); it('should handle empty LLM stream gracefully', async () => { - const mockLlm = { - completeStream: (_params: any) => ({ - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: '' })), - }), - }; - - const deps = makeDeps(); - const opts = { state: mockState, llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any }; - const q = Effect.runSync(Queue.unbounded()); - const effect = agentLoop( - deps.executor, - deps.hooks, - deps.maxSteps, - deps.maxStopContinuations, - opts, - q + const llm = makeCapturingLlm(() => llmStream(pEnd())); + const { events } = await runAgentTurn( + { llm, state: mockState }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - await Effect.runPromise(effect.pipe(Effect.provide(AllMockLayer))); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - const textEvents = events.filter((e: any) => e._tag === 'LlmChunk'); - expect(textEvents).toHaveLength(0); + expect(texts(events)).toHaveLength(0); + expect(endReason(events)).toBe('done'); }); - it('should feed bash tool results back to LLM', async () => { - const mockLlm = { - completeStream: (_params: any) => ({ - stream: (async function* () { - yield '\n[Using: execute_command]\n'; - })(), - response: Promise.resolve( - Result.ok({ - content: '', - toolCalls: [ - { id: 'tc1', name: 'execute_command', arguments: { command: 'git status' } }, - ], - }) - ), + it('should surface tool results as tool_result events', async () => { + let callCount = 0; + const llm = { + completeStream: vi.fn(() => { + callCount++; + if (callCount === 1) { + return llmStream(pToolCall('tc1', 'execute_command', { command: 'git status' }), pEnd()); + } + return llmStream(pText('done'), pEnd()); }), - }; - - const mockExecutor = { - execute: (_name: string, _args: Record, _opts?: any) => - Effect.succeed('On branch main\nnothing to commit'), - executeBatch: (_toolCalls: any[]) => + modelInfo: { maxTokens: 1000 }, + } as any; + const executor = { + executeBatch: (calls: any[]) => Effect.succeed( - _toolCalls.map((tc: any) => ({ + calls.map((tc: any) => ({ type: 'ok' as const, id: tc.id, name: tc.name, output: 'On branch main\nnothing to commit', })) ), - }; - - const deps = makeDeps({ - maxSteps: 1, - runtime: { listAgentProfiles: () => [] } as any, - executor: mockExecutor as any, - }); - const opts = { state: mockState, llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any }; - const q = Effect.runSync(Queue.unbounded()); - const effect = agentLoop( - deps.executor, - deps.hooks, - deps.maxSteps, - deps.maxStopContinuations, - opts, - q + } as any; + const { events } = await runAgentTurn( + { llm, state: mockState, executor }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - await Effect.runPromise(effect.pipe(Effect.provide(AllMockLayer))); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - const toolResults = events.filter( - (e: AgentEvent): e is Extract => e._tag === 'ToolResult' - ); - expect(toolResults).toHaveLength(1); - expect(toolResults[0]!.output).toBe('On branch main\nnothing to commit'); - expect(toolResults[0]!.ok).toBe(true); + const results = toolResults(events); + expect(results).toHaveLength(1); + expect(results[0]!.outcome).toEqual({ + status: 'ok', + output: 'On branch main\nnothing to commit', + }); }); - it('should forward tool-call markers from LLM stream', async () => { - const mockLlm = { - completeStream: (_params: any) => ({ - stream: (async function* () { - yield '\n[Using: readFile]\n'; - })(), - response: Promise.resolve( - Result.ok({ - content: '', - toolCalls: [{ id: 'tc1', name: 'readFile', arguments: { path: 'test.txt' } }], - }) - ), + it('should forward text markers from LLM stream', async () => { + let callCount = 0; + const llm = { + completeStream: vi.fn(() => { + callCount++; + if (callCount === 1) { + return llmStream( + pText('\n[Using: readFile]\n'), + pToolCall('tc1', 'readFile', { path: 'test.txt' }), + pEnd() + ); + } + return llmStream(pEnd()); }), - }; - - const mockExecutor = { - execute: (_name: string, _args: Record, _opts?: any) => - Effect.succeed('file content'), - executeBatch: (_toolCalls: any[]) => - Effect.succeed( - _toolCalls.map((tc: any) => ({ - type: 'ok' as const, - id: tc.id, - name: tc.name, - output: 'file content', - })) - ), - }; - - const deps = makeDeps({ - maxSteps: 1, - runtime: { listAgentProfiles: () => [] } as any, - executor: mockExecutor as any, - }); - const opts = { state: mockState, llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any }; - const q = Effect.runSync(Queue.unbounded()); - const effect = agentLoop( - deps.executor, - deps.hooks, - deps.maxSteps, - deps.maxStopContinuations, - opts, - q + modelInfo: { maxTokens: 1000 }, + } as any; + const { events } = await runAgentTurn( + { llm, state: mockState }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - await Effect.runPromise(effect.pipe(Effect.provide(AllMockLayer))); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - const textEvents = events.filter((e: any) => e._tag === 'LlmChunk'); - expect(textEvents.map((e: any) => e.text)).toEqual(['\n[Using: readFile]\n']); + expect(texts(events)).toEqual(['\n[Using: readFile]\n']); }); - it('should yield a single maxSteps error and a single turn.end hook when maxSteps is exhausted', async () => { - const mockLlm = { - completeStream: (_params: any) => ({ - stream: (async function* () { - yield 'calling tool'; - })(), - response: Promise.resolve( - Result.ok({ - content: '', - toolCalls: [{ id: 'tc1', name: 'read_file', arguments: { path: 'x' } }], - }) - ), - }), - }; - - const mockExecutor = { - executeBatch: (_toolCalls: any[]) => - Effect.succeed( - _toolCalls.map((tc: any) => ({ - type: 'ok' as const, - id: tc.id, - name: tc.name, - output: 'file content', - })) - ), - }; - + it('should end with maxSteps and emit a single turn.end hook when maxSteps is exhausted', async () => { + // LLM always requests a tool call → the loop never reaches a natural stop. + const llm = { + completeStream: vi.fn(() => llmStream(pText('calling tool'), pToolCall('tc1', 'read_file', { path: 'x' }), pEnd())), + modelInfo: { maxTokens: 1000 }, + } as any; const turnEndCalls: any[] = []; - const trackingHooks = { - emit: (eventName: string, payload?: any) => { - if (eventName === 'agent.turn.end') { - turnEndCalls.push(payload); - } + const hooks = { + emit: vi.fn((point: string, payload: any) => { + if (point === 'agent.turn.end') turnEndCalls.push(payload); return Effect.succeed(undefined); - }, + }), emitDecision: () => Effect.succeed(null), - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), - reloadUserHooks: () => Effect.succeed(undefined), - }; - - const deps = makeDeps({ - maxSteps: 1, - runtime: { listAgentProfiles: () => [] } as any, - executor: mockExecutor as any, - hooks: trackingHooks as unknown as HookService, - }); - const opts = { state: mockState, llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any }; - const q = Effect.runSync(Queue.unbounded()); - const effect = agentLoop( - deps.executor, - deps.hooks, - deps.maxSteps, - deps.maxStopContinuations, - opts, - q + } as any; + const { events } = await runAgentTurn( + { llm, state: mockState, hooks }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - await Effect.runPromise(effect.pipe(Effect.provide(AllMockLayer))); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - const maxStepErrors = events.filter( - (e: any) => e._tag === 'Error' && e.error?.code === 'MAX_STEPS_REACHED' - ); - expect(maxStepErrors).toHaveLength(1); + expect(endReason(events)).toBe('maxSteps'); expect(turnEndCalls).toHaveLength(1); expect(turnEndCalls[0].status).toBe('maxSteps'); }); diff --git a/packages/codingcode/test/agent/build-system-prompt.test.ts b/packages/codingcode/test/agent/build-system-prompt.test.ts index 0fd5c9e5..24e83b2c 100644 --- a/packages/codingcode/test/agent/build-system-prompt.test.ts +++ b/packages/codingcode/test/agent/build-system-prompt.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { BUILD_PROMPT, PLAN_PROMPT, buildSystemPrompt } from '../../src/agent/prompt.js'; -import { PLAN_PROFILE } from '../../src/agent/profile.js'; +import { BUILD_PROMPT, PLAN_PROMPT, PLAN_PROFILE } from '../../src/agent/profile.js'; +import { buildSystemPrompt } from '../../src/agent/prompt.js'; describe('buildSystemPrompt', () => { it('uses the build prompt when profileSystemPrompt is not provided', () => { diff --git a/packages/codingcode/test/agent/config.test.ts b/packages/codingcode/test/agent/config.test.ts deleted file mode 100644 index 684f4f30..00000000 --- a/packages/codingcode/test/agent/config.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { resolveConfig } from '../../src/agent/config.js'; - -vi.mock('@codingcode/infra/config', () => ({ - loadConfig: () => ({ - context: { - compactionModel: '', - }, - memory: { - enabled: false, - model: '', - maxBytes: 16384, - promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], - }, - server: { port: 8080 }, - }), -})); - -describe('resolveConfig', () => { - it('returns maxStopContinuations defaulting to 3 when no config file is present', () => { - const cfg = resolveConfig(); - expect(cfg.maxStopContinuations).toBe(3); - }); - - it('returns maxSteps defaulting to 50 when no config file is present', () => { - const cfg = resolveConfig(); - expect(cfg.maxSteps).toBe(250); - }); -}); diff --git a/packages/codingcode/test/agent/context-compressed.test.ts b/packages/codingcode/test/agent/context-compressed.test.ts new file mode 100644 index 00000000..de676058 --- /dev/null +++ b/packages/codingcode/test/agent/context-compressed.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { makeState, runAgentTurn, llmStream, pText, pEnd, hasCompress } from '../helpers/agent-harness.js'; + +function makePlainLlm(content = 'ok') { + return { + completeStream: () => llmStream(pText(content), pEnd()), + modelInfo: { provider: 'mock', model: 'mock', maxTokens: 1000 }, + } as any; +} + +function phaseOrder(events: readonly unknown[]): string[] { + return (events as any[]).flatMap((b) => (b.family === 'transition' ? [b.transition.to] : [])); +} + +describe('compaction transition', () => { + it('emits the compress signal when willCompact is true', async () => { + const { events } = await runAgentTurn( + { + llm: makePlainLlm(), + state: makeState(), + contextWillCompact: async () => true, + }, + { sessionId: 'sid', cwd: '/tmp' } + ); + + expect(hasCompress(events)).toBe(true); + }); + + it('returns to executing right after compress (compress is an enter/return pair)', async () => { + const { events } = await runAgentTurn( + { + llm: makePlainLlm(), + state: makeState(), + contextWillCompact: async () => true, + }, + { sessionId: 'sid', cwd: '/tmp' } + ); + + const tos = phaseOrder(events); + expect(tos[tos.indexOf('compress') + 1]).toBe('executing'); + }); + + it('emits no compress signal when willCompact is false', async () => { + const { events } = await runAgentTurn( + { + llm: makePlainLlm(), + state: makeState(), + }, + { sessionId: 'sid', cwd: '/tmp' } + ); + + expect(hasCompress(events)).toBe(false); + }); +}); diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index 62f4fb1d..5c9ddc74 100644 --- a/packages/codingcode/test/agent/hooks-deps-type.test.ts +++ b/packages/codingcode/test/agent/hooks-deps-type.test.ts @@ -1,13 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; -import { Effect, Layer, Queue } from 'effect'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { TodoService } from '../../src/agent/todo.js'; -import { ContextService } from '../../src/context/service.js'; -import { MemoryService } from '../../src/memory/index.js'; +import { Effect } from 'effect'; +import { makeState, runAgentTurn, llmStream, pText, pEnd, endReason } from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,111 +14,36 @@ vi.mock('@codingcode/infra/config', () => ({ model: '', maxBytes: 16384, promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], }, server: { port: 8080 }, }), })); -import { agentLoop } from '../../src/agent/agent.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { Result } from '../../src/core/result.js'; -import { SessionService } from '../../src/session/store.js'; +const mockState = makeState({ sessionId: 'type-test', cwd: '/tmp', title: 'type-test' }); -const AllMockLayer = Layer.mergeAll( - Layer.succeed(CheckpointService, { - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, - } as any), - Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', - recordAssistant: () => Effect.succeed({}), - recordUser: () => Effect.succeed({}), - recordToolResult: () => Effect.succeed({}), - } as any), - Layer.succeed(ProjectRuntimeService, { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: () => undefined, - listAgentProfiles: () => [], - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, - }), - setSessionProfile: () => {}, - restoreSessionProfile: () => Effect.void, - getSessionProfile: () => undefined, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, - } as any), - Layer.succeed(TodoService, { - read: () => [], - write: () => {}, - reset: () => {}, - } as any), - Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [{ role: 'user' as const, content: 'hi' }], - compactedEvents: [], - promptEstimate: 10, - currentTurnId: 1, - compactedTurnIds: new Set(), - }), - compactIfNeeded: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - } as any), - Layer.succeed(MemoryService, { - getMemoryEnabled: () => false, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), - } as any) -); +describe('agent runTurn smoke (hooks deps wiring)', () => { + it('should build & run via AgentService.runTurn with mocked deps', async () => { + const llm = { + completeStream: vi.fn(() => llmStream(pText('Hello'), pEnd())), + modelInfo: { maxTokens: 1000 }, + } as any; -describe('agentLoop hooks type', () => { - it('should accept a properly typed HookService mock', async () => { - const mockHooks = { - emit: (_point: any, _payload: any) => Effect.succeed(undefined), - emitDecision: (_point: any, _payload: any) => Effect.succeed(null), - register: (_point: any, _handler: any, _opts?: any) => Effect.succeed(() => {}), - registerDecision: (_point: any, _handler: any, _opts?: any) => Effect.succeed(() => {}), - reloadUserHooks: (_cwd: string) => Effect.succeed(undefined), - } as unknown as HookService; - - const mockLlm = { - completeStream: () => ({ - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: '' })), + const turnEndCalls: any[] = []; + const hooks = { + emit: vi.fn((point: string, payload: any) => { + if (point === 'agent.turn.end') turnEndCalls.push(payload); + return Effect.succeed(undefined); }), - }; - - const mockState = { - sessionId: 'type-test', - cwd: '/tmp', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, - model: 'test-model', - title: 'type-test', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - usage: undefined, - memorySnapshot: '', - }; + emitDecision: () => Effect.succeed(null), + } as any; - const q = Effect.runSync(Queue.unbounded()); - const result = await Effect.runPromise( - agentLoop( - null as any, - mockHooks, - 1, - 2, - { state: mockState, llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any }, - q - ).pipe(Effect.provide(AllMockLayer)) as any + const { events } = await runAgentTurn( + { llm, state: mockState, hooks }, + { sessionId: 'type-test', cwd: '/tmp' } ); - expect(result).toBeDefined(); + expect(endReason(events)).toBe('done'); + expect(turnEndCalls).toHaveLength(1); + expect(turnEndCalls[0].status).toBe('done'); }); }); diff --git a/packages/codingcode/test/agent/loop-options.test.ts b/packages/codingcode/test/agent/loop-options.test.ts index 057c2cdb..620ff5b9 100644 --- a/packages/codingcode/test/agent/loop-options.test.ts +++ b/packages/codingcode/test/agent/loop-options.test.ts @@ -1,13 +1,11 @@ import { expect, it, describe, vi } from 'vitest'; -import { Effect, Layer, Queue, Chunk } from 'effect'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { TodoService } from '../../src/agent/todo.js'; -import { ContextService } from '../../src/context/service.js'; -import { MemoryService } from '../../src/memory/index.js'; +import { Effect } from 'effect'; +import { makeState, runAgentTurn, llmStream, pText, pEnd, endReason } from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,283 +14,35 @@ vi.mock('@codingcode/infra/config', () => ({ model: '', maxBytes: 16384, promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], }, server: { port: 8080 }, }), })); -import { agentLoop } from '../../src/agent/agent'; -import { Result } from '../../src/core/result'; -import type { RunStreamOptions } from '../../src/agent/types'; -import { SessionService } from '../../src/session/store.js'; - -const AllMockLayer = Layer.mergeAll( - Layer.succeed(CheckpointService, { - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, - } as any), - Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', - recordAssistant: () => Effect.succeed({}), - recordUser: () => Effect.succeed({}), - recordToolResult: () => Effect.succeed({}), - } as any), - Layer.succeed(ProjectRuntimeService, { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: () => undefined, - listAgentProfiles: () => [], - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, - }), - setSessionProfile: () => {}, - restoreSessionProfile: () => Effect.void, - getSessionProfile: () => undefined, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, - } as any), - Layer.succeed(TodoService, { - read: () => [], - write: () => {}, - reset: () => {}, - } as any), - Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [{ role: 'user' as const, content: 'hi' }], - compactedEvents: [], - promptEstimate: 10, - currentTurnId: 1, - compactedTurnIds: new Set(), - }), - compactIfNeeded: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - } as any), - Layer.succeed(MemoryService, { - getMemoryEnabled: () => false, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), - } as any) -); - -describe('agentLoop loop options', () => { - const mockState = { - sessionId: 'test-session', - cwd: process.cwd(), - currentTurnId: 0, - sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, - model: 'test-model', - title: 'test', - usage: undefined, - activeProfile: 'build' as const, - permissionMode: 'default' as const, - messageCount: 0, - memorySnapshot: '', - }; - - function mockHooks() { - return { - emit: vi.fn(() => Effect.succeed(undefined)), - emitDecision: vi.fn(() => Effect.succeed(null)), - } as any; - } - - it('should accept systemOverride to replace base prompt', async () => { - const mockLlm = { - completeStream: vi.fn(() => ({ - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ - content: 'Done', - toolCalls: [], - }) - ), - })), - }; - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - systemOverride: 'Custom system prompt', - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks(), 1, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any - ); - - expect(mockLlm.completeStream).toHaveBeenCalled(); - const lastCall = (mockLlm.completeStream as any).mock?.calls?.[0]?.[0]; - expect(lastCall?.system).toBe('Custom system prompt'); - }); - - it('should respect abortSignal to terminate early', async () => { - const controller = new AbortController(); - - const mockLlm = { - completeStream: vi.fn(() => ({ - stream: (async function* () {})(), - response: new Promise((r) => - setTimeout(() => r(Result.ok({ content: 'Response', toolCalls: [] })), 100) - ), - })), - }; - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - abortSignal: controller.signal, - }; - - const q = Effect.runSync(Queue.unbounded()); - controller.abort(); - await Effect.runPromise( - agentLoop({} as any, mockHooks(), 10, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any - ); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - - // abortSignal is forwarded to llm.completeStream; agentLoop itself does not - // short-circuit on abort — that is handled at AgentService.runStream level - expect(events.some((e: any) => e._tag === 'Done')).toBe(true); - }); - - it('should support coreAllowlist to filter available tools', async () => { - const mockLlm = { - completeStream: vi.fn(() => ({ - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ - content: 'Done', - toolCalls: [], - }) - ), - })), - }; - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - coreAllowlist: new Set(['allowed_tool']), - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks(), 1, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any - ); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - - expect(events.some((e: any) => e._tag === 'Done')).toBe(true); - }); - - it('should accept maxStepsOverride', async () => { - const mockLlm = { - completeStream: vi.fn(() => ({ - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ - content: 'Done', - toolCalls: [], - }) - ), - })), - }; - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - maxStepsOverride: 5, - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks(), 100, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any - ); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - - const stepEvents = events.filter((e: any) => e._tag === 'Step'); - expect(stepEvents.some((e: any) => e.max === 5)).toBe(true); - }); - - it('should support approvalOverride', async () => { - const mockLlm = { - completeStream: vi.fn(() => ({ - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ - content: 'Done', - toolCalls: [], - }) - ), - })), - }; - - const mockApproval = { - evaluate: () => Effect.succeed({ decision: 'allow' }), - }; - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - approvalOverride: mockApproval as any, - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks(), 1, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any - ); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - - expect(events.some((e: any) => e._tag === 'Done')).toBe(true); - }); - - it('should use maxStopContinuations from deps when opts does not override', async () => { - const mockLlm = { - completeStream: vi.fn(() => ({ - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: 'Done', toolCalls: [] })), - })), - }; - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks(), 1, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any - ); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - - expect(events.some((e: any) => e._tag === 'Done')).toBe(true); - }); - - it('should emit turn hooks', async () => { - const mockLlm = { - completeStream: vi.fn(() => ({ - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ - content: 'Done', - toolCalls: [], - }) - ), - })), - }; - +const mockState = makeState({ sessionId: 'test-sid', cwd: '/tmp', title: 'test' }); + +function makeCapturingLlm(opts: { content?: string } = {}) { + const llm = { + completeStream: vi.fn(() => llmStream(pText(opts.content ?? 'Done'), pEnd())), + modelInfo: { maxTokens: 1000 }, + } as any; + return llm; +} + +function mockHooks() { + return { + emit: vi.fn(() => Effect.succeed(undefined)), + emitDecision: vi.fn(() => Effect.succeed(null)), + } as any; +} + +describe('agent runTurn loop options', () => { + it('should emit turn hooks agent.turn.start / agent.turn.end after stopping', async () => { + const llm = makeCapturingLlm(); const hooks = mockHooks(); - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, hooks, 1, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any + await runAgentTurn( + { llm, state: mockState, hooks }, + { sessionId: 'test-sid', cwd: '/tmp' } ); expect(hooks.emit).toHaveBeenCalledWith( @@ -301,7 +51,20 @@ describe('agentLoop loop options', () => { ); expect(hooks.emit).toHaveBeenCalledWith( 'agent.turn.end', - expect.objectContaining({ status: 'done' }) + expect.objectContaining({ sessionId: mockState.sessionId, status: 'done' }) ); }); + + it('should not end with done when a pre-aborted signal is passed', async () => { + const controller = new AbortController(); + controller.abort(); + + const llm = makeCapturingLlm({ content: 'Response' }); + const { events } = await runAgentTurn( + { llm, state: mockState }, + { sessionId: 'test-sid', cwd: '/tmp', signal: controller.signal } + ); + + expect(endReason(events)).not.toBe('done'); + }); }); diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index 4103033d..fe956d60 100644 --- a/packages/codingcode/test/agent/memory-snapshot.test.ts +++ b/packages/codingcode/test/agent/memory-snapshot.test.ts @@ -1,13 +1,10 @@ import { describe, it, expect, vi } from 'vitest'; -import { Effect, Layer, Queue } from 'effect'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { TodoService } from '../../src/agent/todo.js'; -import { ContextService } from '../../src/context/service.js'; -import { MemoryService } from '../../src/memory/index.js'; +import { makeState, runAgentTurn, llmStream, pEnd } from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,175 +13,63 @@ vi.mock('@codingcode/infra/config', () => ({ model: '', maxBytes: 16384, promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], }, server: { port: 8080 }, }), })); -import { Result } from '../../src/core/result.js'; +const MEMORY = '## Long-term Memory\n\nFrozen content'; -import { agentLoop } from '../../src/agent/agent.js'; -import { SessionService } from '../../src/session/store.js'; - -/** Create a MemoryService mock layer with a controllable loadMemoryForPrompt. */ -function makeMemoryLayer(loadMemoryForPromptFn: (cwd: string) => string) { - return Layer.succeed(MemoryService, { - getMemoryEnabled: () => false, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: loadMemoryForPromptFn, - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), - } as any); -} - -const BaseMockLayer = Layer.mergeAll( - Layer.succeed(CheckpointService, { - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, - } as any), - Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', - recordAssistant: () => Effect.succeed({}), - recordUser: () => Effect.succeed({}), - recordToolResult: () => Effect.succeed({}), - } as any), - Layer.succeed(ProjectRuntimeService, { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: () => undefined, - listAgentProfiles: () => [], - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, - }), - setSessionProfile: () => {}, - restoreSessionProfile: () => Effect.void, - getSessionProfile: () => undefined, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, - } as any), - Layer.succeed(TodoService, { - read: () => [], - write: () => {}, - reset: () => {}, - } as any), - Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [{ role: 'user' as const, content: 'hi' }], - compactedEvents: [], - promptEstimate: 10, - currentTurnId: 1, - compactedTurnIds: new Set(), - }), - compactIfNeeded: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - } as any) -); - -const mockHooks = { - emit: () => Effect.succeed(undefined), - emitDecision: () => Effect.succeed(null), -} as any; - -function makeState(memorySnapshot: string = '') { - return { - sessionId: 'memory-test-sid', - cwd: '/tmp/memory-test', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, - model: 'test-model', - title: 'memory-test', - usage: undefined, - activeProfile: 'build' as const, - permissionMode: 'default' as const, - memorySnapshot, - }; +function makeStateForMemory() { + return makeState({ sessionId: 'memory-test-sid', cwd: '/tmp/memory-test', title: 'memory-test' }); } function makeCapturingLlm() { const captured: { system?: string; messages?: any[] } = {}; const llm = { - completeStream: (params: any) => { + completeStream: vi.fn((params: any) => { captured.system = params.system; captured.messages = params.messages; - return { - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: '' })), - }; - }, + return llmStream(pEnd()); + }), modelInfo: { maxTokens: 1000 }, } as any; return { llm, captured }; } -async function runOnce(llm: any, memorySnapshot: string = '', diskMemory: string = '') { - const state = makeState(memorySnapshot); - const q = Effect.runSync(Queue.unbounded()); - const memoryLayer = makeMemoryLayer(() => diskMemory); - const fullLayer = Layer.mergeAll(BaseMockLayer, memoryLayer); - await Effect.runPromise( - agentLoop(null as any, mockHooks, 1, 0, { state, llm }, q).pipe( - Effect.provide(fullLayer) - ) as any +async function runOnce(llm: any, memorySnapshot: string = '') { + return runAgentTurn( + { llm, state: makeStateForMemory(), memorySnapshot }, + { sessionId: 'memory-test-sid', cwd: '/tmp/memory-test' } ); } -describe('Memory snapshot stability', () => { - it('system prompt uses state.memorySnapshot instead of loadMemoryForPrompt', async () => { +describe('Memory snapshot semantics', () => { + it('loads memory via MemoryPort and includes it in the system prompt', async () => { const { llm, captured } = makeCapturingLlm(); - await runOnce( - llm, - '## Long-term Memory\n\nOriginal snapshot', - '## Long-term Memory\n\nNew content from disk' - ); - expect(captured.system).toContain('Original snapshot'); - expect(captured.system).not.toContain('New content from disk'); + await runOnce(llm, MEMORY); + expect(captured.system).toContain('## Session Memory'); + expect(captured.system).toContain('Frozen content'); }); - it('system prompt is byte-identical across consecutive turns with same snapshot', async () => { + it('system prompt is byte-identical across consecutive turns with the same memory snapshot', async () => { const { llm, captured } = makeCapturingLlm(); - await runOnce(llm, '## Long-term Memory\n\nFrozen', '## Long-term Memory\n\nSame content'); + await runOnce(llm, MEMORY); const first = captured.system; expect(first).toBeDefined(); - await runOnce(llm, '## Long-term Memory\n\nFrozen', '## Long-term Memory\n\nSame content'); + await runOnce(llm, MEMORY); const second = captured.system; expect(second).toBe(first); }); - it('does not inject when memory changed since snapshot', async () => { - const { llm, captured } = makeCapturingLlm(); - await runOnce( - llm, - '## Long-term Memory\n\nOriginal snapshot', - '## Long-term Memory\n\nUpdated on disk' - ); - expect(captured.system).toContain('Original snapshot'); - const lastUserMsg = [...(captured.messages ?? [])] - .reverse() - .find((m: any) => m.role === 'user'); - expect(lastUserMsg).toBeDefined(); - expect(lastUserMsg.content).not.toContain(''); - }); - - it('does not inject when memory matches snapshot', async () => { - const { llm, captured } = makeCapturingLlm(); - await runOnce(llm, '## Long-term Memory\n\nSame', '## Long-term Memory\n\nSame'); - const lastUserMsg = [...(captured.messages ?? [])] - .reverse() - .find((m: any) => m.role === 'user'); - expect(lastUserMsg).toBeDefined(); - expect(lastUserMsg.content).not.toContain(''); - }); - - it('does not inject when both snapshot and current are empty', async () => { + it('appends memory verbatim and does not inject into messages', async () => { const { llm, captured } = makeCapturingLlm(); - await runOnce(llm, '', ''); - const lastUserMsg = [...(captured.messages ?? [])] - .reverse() - .find((m: any) => m.role === 'user'); - expect(lastUserMsg).toBeDefined(); - expect(lastUserMsg.content).not.toContain(''); + await runOnce(llm, MEMORY); + // memory 块原样拼在 "## Session Memory" 标题之后,中间无注入的 reminder 包装 + expect(captured.system).toContain('## Session Memory\n\n## Long-term Memory\n\nFrozen content'); + const allContents = (captured.messages ?? []) + .map((m: any) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))) + .join('\n'); + expect(allContents).not.toContain(''); }); }); diff --git a/packages/codingcode/test/agent/reactive-compact.test.ts b/packages/codingcode/test/agent/reactive-compact.test.ts deleted file mode 100644 index 583600ea..00000000 --- a/packages/codingcode/test/agent/reactive-compact.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import type { AgentEvent } from '../../src/agent/types.js'; -import { AgentError } from '../../src/core/error.js'; - -describe('reactive compact event', () => { - it('should create ReactiveCompact event with attempt and released count', () => { - const event: AgentEvent = { - _tag: 'ReactiveCompact', - attempt: 1, - released: 5000, - promptEstimate: 0, - }; - - expect(event._tag).toBe('ReactiveCompact'); - expect(event.attempt).toBe(1); - expect(event.released).toBe(5000); - }); - - it('should distinguish ReactiveCompact from other events in switch', () => { - const event: AgentEvent = { - _tag: 'ReactiveCompact', - attempt: 2, - released: 3000, - promptEstimate: 0, - }; - - let matched = false; - switch (event._tag) { - case 'ReactiveCompact': - matched = true; - expect(event.released).toBeGreaterThan(0); - break; - default: - matched = false; - } - expect(matched).toBe(true); - }); - - it('should require both attempt and released fields', () => { - // Type check: omitting either field should fail at compile time - // This is a compile-time test, so we just verify the type shape - const event: AgentEvent = { - _tag: 'ReactiveCompact', - attempt: 1, - released: 100, - promptEstimate: 0, - }; - - expect(Object.keys(event)).toContain('attempt'); - expect(Object.keys(event)).toContain('released'); - }); - - it('should handle CONTEXT_OVERFLOW error code detection', () => { - const err = AgentError.contextOverflow('openai', new Error('prompt too long')); - expect(err.code).toBe('CONTEXT_OVERFLOW'); - - const isOverflow = err.code === 'CONTEXT_OVERFLOW'; - expect(isOverflow).toBe(true); - }); - - it('should not trigger reactive compact for other error codes', () => { - const err = new AgentError('LLM_FAILED', 'Some other LLM error'); - expect(err.code).not.toBe('CONTEXT_OVERFLOW'); - - const shouldRetry = err.code === 'CONTEXT_OVERFLOW'; - expect(shouldRetry).toBe(false); - }); - - it('should respect max retries limit', () => { - let reactiveRetries = 0; - const MAX_REACTIVE = 1; - - // Simulate first overflow - if (reactiveRetries < MAX_REACTIVE) { - reactiveRetries += 1; - } - expect(reactiveRetries).toBe(1); - - // Simulate second overflow - should not retry - if (reactiveRetries < MAX_REACTIVE) { - reactiveRetries += 1; - } - expect(reactiveRetries).toBe(1); // Unchanged - }); - - it('should yield ReactiveCompact event before retrying step', () => { - const events: AgentEvent[] = []; - - // Simulate yielding reactive compact event - const compactEvent: AgentEvent = { - _tag: 'ReactiveCompact', - attempt: 1, - released: 2000, - promptEstimate: 0, - }; - events.push(compactEvent); - - expect(events.length).toBe(1); - expect(events[0]!._tag).toBe('ReactiveCompact'); - if (events[0]!._tag === 'ReactiveCompact') { - expect(events[0]!.attempt).toBe(1); - expect(events[0]!.released).toBe(2000); - } - }); - - it('should use aggressive keepTurns config for reactive L5', () => { - const defaultKeepTurns = 10; - const aggressiveKeepTurns = 3; - - expect(aggressiveKeepTurns).toBeLessThan(defaultKeepTurns); - expect(aggressiveKeepTurns).toBeGreaterThan(0); - }); -}); diff --git a/packages/codingcode/test/agent/send-message-optional-profile.test.ts b/packages/codingcode/test/agent/send-message-optional-profile.test.ts deleted file mode 100644 index 74494296..00000000 --- a/packages/codingcode/test/agent/send-message-optional-profile.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; - -describe('sendMessage options are optional with guard', () => { - it('agent.ts sendMessage options make activeProfile/permissionMode/model optional', () => { - const src = readFileSync(new URL('../../src/agent/agent.ts', import.meta.url), 'utf8'); - expect(src).toMatch(/activeProfile\?:\s*AgentProfileName/); - expect(src).toMatch(/permissionMode\?:\s*PermissionMode/); - expect(src).toMatch(/model\?:\s*string/); - }); - - it('agent.ts guards new-session branch against missing activeProfile/permissionMode/model', () => { - const src = readFileSync(new URL('../../src/agent/agent.ts', import.meta.url), 'utf8'); - expect(src).toMatch(/SESSION_CONFIG_REQUIRED|new session requires activeProfile/); - }); - - it('messages.ts conditionally builds options (no hardcoded profile on existing-session path)', () => { - const src = readFileSync( - new URL('../../src/server/routes/messages.ts', import.meta.url), - '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(new URL('../../src/direct/agent-runtime.ts', import.meta.url), '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( - new URL('../../src/client/http/agent-runtime.ts', import.meta.url), - 'utf8' - ); - expect(src).toMatch(/sendMessage\(input,/); - }); -}); diff --git a/packages/codingcode/test/agent/stop-decision-type.test.ts b/packages/codingcode/test/agent/stop-decision-type.test.ts index c56cc697..b6cd5eca 100644 --- a/packages/codingcode/test/agent/stop-decision-type.test.ts +++ b/packages/codingcode/test/agent/stop-decision-type.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; import { Result } from '../../src/core/result'; -import { HookService } from '../../src/hooks/registry.js'; +import { HookService } from '../../src/hooks/port.js'; import type { HookDecision } from '../../src/hooks/types.js'; +import { HookLayer } from '../../src/hooks/hooks.js'; describe('agent.turn.stop decision type inference', () => { it('should infer HookDecision from emitDecision without any cast', async () => { @@ -25,7 +26,7 @@ describe('agent.turn.stop decision type inference', () => { }); const result = await Effect.runPromise( - program.pipe(Effect.provide(HookService.Default) as any) + program.pipe(Effect.provide(HookLayer) as any) ); expect(result).toBe('(test continue)'); }); diff --git a/packages/codingcode/test/agent/stop-hook.test.ts b/packages/codingcode/test/agent/stop-hook.test.ts index 0a5cfedc..ab599dd5 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.test.ts @@ -1,13 +1,19 @@ import { expect, it, describe, vi } from 'vitest'; -import { Effect, Layer, Queue, Chunk } from 'effect'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { TodoService } from '../../src/agent/todo.js'; -import { ContextService } from '../../src/context/service.js'; -import { MemoryService } from '../../src/memory/index.js'; +import { Effect } from 'effect'; +import { + makeState, + runAgentTurn, + llmStream, + pText, + pEnd, + endOf, + endReason, +} from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 100, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,281 +22,124 @@ vi.mock('@codingcode/infra/config', () => ({ model: '', maxBytes: 16384, promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], }, server: { port: 8080 }, }), })); -import { agentLoop } from '../../src/agent/agent'; -import { Result } from '../../src/core/result'; -import type { RunStreamOptions } from '../../src/agent/types'; -import { SessionService } from '../../src/session/store.js'; +const mockState = makeState({ sessionId: 'test-sid', cwd: '/tmp', title: 'test' }); -const AllMockLayer = Layer.mergeAll( - Layer.succeed(CheckpointService, { - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, - } as any), - Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', - recordAssistant: () => Effect.succeed({}), - recordUser: () => Effect.succeed({}), - recordToolResult: () => Effect.succeed({}), - } as any), - Layer.succeed(ProjectRuntimeService, { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: () => undefined, - listAgentProfiles: () => [], - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, +/** LLM 每次只返回纯文本、无工具调用;记录每次收到 messages 参数。 */ +function makeContentOnlyLlm() { + const seenMessages: any[][] = []; + const llm = { + completeStream: vi.fn((params: any) => { + seenMessages.push(params.messages ?? []); + return llmStream(pText('Response'), pEnd()); }), - setSessionProfile: () => {}, - restoreSessionProfile: () => Effect.void, - getSessionProfile: () => undefined, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, - } as any), - Layer.succeed(TodoService, { - read: () => [], - write: () => {}, - reset: () => {}, - } as any), - Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [{ role: 'user' as const, content: 'hi' }], - compactedEvents: [], - promptEstimate: 10, - currentTurnId: 1, - compactedTurnIds: new Set(), - }), - compactIfNeeded: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - } as any), - Layer.succeed(MemoryService, { - getMemoryEnabled: () => false, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), - } as any) -); - -describe('agentLoop stop hook', () => { - const mockState = { - sessionId: 'test-session', - cwd: process.cwd(), - currentTurnId: 0, - sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, - model: 'test-model', - title: 'test', - usage: undefined, - activeProfile: 'build' as const, - permissionMode: 'default' as const, - messageCount: 0, - memorySnapshot: '', - }; - + modelInfo: { maxTokens: 1000 }, + } as any; + return { llm, seenMessages }; +} + +function makeStopDecision(decision: any) { + const emitDecision = vi.fn((point: string) => + point === 'agent.turn.stop' ? Effect.succeed(decision) : Effect.succeed(null) + ); + return emitDecision; +} + +describe('agent runTurn stop hook', () => { it('should continue iteration when stop hook returns continue decision', async () => { - let callCount = 0; - const mockLlm = { - completeStream: vi.fn(() => { - callCount++; - return { - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: `Response ${callCount}`, toolCalls: [] })), - }; - }), - }; - - const emitDecisionFn = vi.fn((point: string) => { - if (point === 'agent.turn.stop') { - return Effect.succeed({ decision: 'continue', injection: 'Run again' }); - } - return Effect.succeed(null); - }); - - const mockHooks = { - emit: vi.fn(() => Effect.succeed(undefined)), - emitDecision: emitDecisionFn, - } as any; + const { llm, seenMessages } = makeContentOnlyLlm(); + const emitDecision = makeStopDecision({ decision: 'continue', injection: 'Run again' }); + const hooks = { emit: vi.fn(() => Effect.succeed(undefined)), emitDecision } as any; - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks, 5, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any + const { events } = await runAgentTurn( + { llm, state: mockState, hooks }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - expect(emitDecisionFn).toHaveBeenCalledWith( + expect(emitDecision).toHaveBeenCalledWith( 'agent.turn.stop', expect.objectContaining({ sessionId: mockState.sessionId }) ); + // 限制为 2 次续跑:continue 三次后触发 AGENT_LOOP_DETECTED(共 3 次 LLM 调用) + expect(seenMessages).toHaveLength(3); + expect(endReason(events)).not.toBe('done'); }); - it('should respect maxStopContinuations limit', async () => { - const mockLlm = { - completeStream: vi.fn(() => ({ - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: 'Response', toolCalls: [] })), - })), - }; - - const mockHooks = { + it('should respect maxStopContinuations limit from global config', async () => { + const { llm } = makeContentOnlyLlm(); + const emitDecision = makeStopDecision({ decision: 'continue', injection: 'Continue' }); + const hooks = { emit: vi.fn(() => Effect.succeed(undefined)), - emitDecision: vi.fn((point: string) => { - if (point === 'agent.turn.stop') { - return Effect.succeed({ decision: 'continue', injection: 'Continue' }); - } - return Effect.succeed(null); - }), + emitDecision, } as any; - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - maxStopContinuations: 2, - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks, 10, 10, opts, q).pipe(Effect.provide(AllMockLayer)) as any + const { events } = await runAgentTurn( + { llm, state: mockState, hooks }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - - const errorEvent = events.find((e: any) => e._tag === 'Error'); - expect(errorEvent).toBeDefined(); - expect((errorEvent as any)?.error?.code).toBe('AGENT_LOOP_DETECTED'); - }); - - it('should use default maxStopContinuations of 2', async () => { - const mockLlm = { - completeStream: vi.fn(() => ({ - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: 'Response', toolCalls: [] })), - })), - }; - - let continueCount = 0; - const mockHooks = { - emit: vi.fn(() => Effect.succeed(undefined)), - emitDecision: vi.fn((point: string) => { - if (point === 'agent.turn.stop') { - continueCount++; - return Effect.succeed({ decision: 'continue', injection: 'Continue' }); - } - return Effect.succeed(null); - }), - } as any; - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - }; - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks, 10, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any + const end = endOf(events); + expect(end?.reason).toBe('error'); + if (end?.reason === 'error') { + expect(end.error.code).toBe('AGENT_LOOP_DETECTED'); + } + expect(endReason(events)).not.toBe('done'); + expect(hooks.emit).toHaveBeenCalledWith( + 'agent.turn.end', + expect.objectContaining({ status: 'error' }) ); - - expect(continueCount).toBeGreaterThanOrEqual(2); }); it('should not continue if stop hook returns null', async () => { - let llmCalls = 0; - const mockLlm = { - completeStream: vi.fn(() => { - llmCalls++; - return { - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: 'Response', toolCalls: [] })), - }; - }), - }; - - const mockHooks = { + const { llm, seenMessages } = makeContentOnlyLlm(); + const hooks = { emit: vi.fn(() => Effect.succeed(undefined)), emitDecision: vi.fn(() => Effect.succeed(null)), } as any; - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks, 5, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any + const { events } = await runAgentTurn( + { llm, state: mockState, hooks }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); - expect(llmCalls).toBe(1); - const doneEvent = events.find((e: any) => e._tag === 'Done'); - expect(doneEvent).toBeDefined(); + expect(seenMessages).toHaveLength(1); + expect(endReason(events)).toBe('done'); }); - it('should use injection message to record user event', async () => { - const mockLlm = { - completeStream: vi.fn(() => ({ - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: 'Response', toolCalls: [] })), - })), - }; - - const mockHooks = { - emit: vi.fn(() => Effect.succeed(undefined)), - emitDecision: vi.fn((point: string) => { - if (point === 'agent.turn.stop') { - return Effect.succeed({ decision: 'continue', injection: 'Custom injection message' }); - } - return Effect.succeed(null); - }), - } as any; - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - maxStopContinuations: 1, - }; + it('should record the injection message from the stop decision', async () => { + const { llm } = makeContentOnlyLlm(); + const recordSystem = vi.fn(() => Effect.succeed({})); + const emitDecision = makeStopDecision({ + decision: 'continue', + injection: 'Custom injection message', + }); + const hooks = { emit: vi.fn(() => Effect.succeed(undefined)), emitDecision } as any; - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks, 5, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any + await runAgentTurn( + { llm, state: mockState, hooks, sessionPort: { recordSystem } }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - }); - - it('should use default injection if not provided', async () => { - const mockLlm = { - completeStream: vi.fn(() => ({ - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: 'Response', toolCalls: [] })), - })), - }; - const mockHooks = { - emit: vi.fn(() => Effect.succeed(undefined)), - emitDecision: vi.fn((point: string) => { - if (point === 'agent.turn.stop') { - return Effect.succeed({ decision: 'continue' }); - } - return Effect.succeed(null); - }), - } as any; + const contents = recordSystem.mock.calls.map((c: any) => c[1] as string); + expect(contents.some((c) => c === 'Custom injection message')).toBe(true); + }); - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - maxStopContinuations: 1, - }; + it('should use default injection if stop decision does not provide one', async () => { + const { llm } = makeContentOnlyLlm(); + const recordSystem = vi.fn(() => Effect.succeed({})); + const emitDecision = makeStopDecision({ decision: 'continue' }); + const hooks = { emit: vi.fn(() => Effect.succeed(undefined)), emitDecision } as any; - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks, 5, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any + await runAgentTurn( + { llm, state: mockState, hooks, sessionPort: { recordSystem } }, + { sessionId: 'test-sid', cwd: '/tmp' } ); + + const contents = recordSystem.mock.calls.map((c: any) => c[1] as string); + expect(contents.some((c) => c === '(continue)')).toBe(true); }); }); 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 7e2c352d..ebc1b672 100644 --- a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts +++ b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts @@ -1,292 +1,110 @@ import { describe, it, expect, vi } from 'vitest'; -import { Effect, Layer, Queue, Chunk } from 'effect'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { TodoService } from '../../src/agent/todo.js'; -import { ContextService } from '../../src/context/service.js'; -import { MemoryService } from '../../src/memory/index.js'; +import { Effect } from 'effect'; +import { + makeState, + runAgentTurn, + llmStream, + pText, + pToolCall, + pEnd, + endReason, +} from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '' }, memory: { enabled: false, model: '', maxBytes: 16384, promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], }, server: { port: 8080 }, }), })); -import { agentLoop } from '../../src/agent/agent'; -import { Result } from '../../src/core/result'; -import type { RunStreamOptions } from '../../src/agent/types'; -import { SessionService } from '../../src/session/store.js'; - -const AllMockLayer = Layer.mergeAll( - Layer.succeed(CheckpointService, { - snapshotBaseline: () => Effect.void, - snapshotFinal: () => Effect.void, - } as any), - Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', - recordAssistant: () => Effect.succeed({}), - recordUser: () => Effect.succeed({}), - recordToolResult: () => Effect.succeed({}), - } as any), - Layer.succeed(ProjectRuntimeService, { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: () => undefined, - listAgentProfiles: () => [], - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, +const mockState = makeState({ sessionId: 'test-session', cwd: '/tmp', title: 'test' }); + +/** LLM 先调用一次 submit_plan,再以纯文本收尾。 */ +function makeSubmitPlanLlm() { + let callCount = 0; + const llm = { + completeStream: vi.fn(() => { + callCount++; + if (callCount === 1) { + return llmStream( + pToolCall('tc-1', 'submit_plan', { + title: 'My Plan', + plan_content: '## Goal\nfix bug', + }), + pEnd() + ); + } + return llmStream(pText('Plan is ready for your review.'), pEnd()); }), - setSessionProfile: () => {}, - restoreSessionProfile: () => Effect.void, - getSessionProfile: () => undefined, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, - } as any), - Layer.succeed(TodoService, { - read: () => [], - write: () => {}, - reset: () => {}, - } as any), - Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [{ role: 'user' as const, content: 'hi' }], - compactedEvents: [], - promptEstimate: 10, - currentTurnId: 1, - compactedTurnIds: new Set(), + modelInfo: { maxTokens: 1000 }, + } as any; + return llm; +} + +function makeOkExecutor() { + return { + executeBatch: (calls: any[]) => + Effect.succeed( + calls.map((tc: any) => ({ + type: 'ok' as const, + id: tc.id, + name: tc.name, + output: 'Plan written to /tmp/plans/my-plan.md', + })) + ), + } as any; +} + +function makeCapturingHooks() { + const emittedPoints: string[] = []; + const hooks = { + emit: vi.fn((point: string, _payload: any) => { + emittedPoints.push(point); + return Effect.succeed(undefined); }), - compactIfNeeded: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), - } as any), - Layer.succeed(MemoryService, { - getMemoryEnabled: () => false, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), - } as any) -); - -const mockState = { - sessionId: 'test-session', - cwd: process.cwd(), - currentTurnId: 1, - sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, - model: 'test-model', - title: 'test', - usage: undefined, - activeProfile: 'build' as const, - permissionMode: 'default' as const, - messageCount: 0, - memorySnapshot: '', -}; - -describe('agentLoop plan.ready emission on turn-end', () => { - it('emits plan.ready when turn ends naturally after submit_plan tool call', async () => { - let callCount = 0; - const mockLlm = { - completeStream: vi.fn(() => { - callCount++; - if (callCount === 1) { - // First call: LLM emits submit_plan tool call - return { - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ - content: '', - toolCalls: [ - { - id: 'tc-1', - name: 'submit_plan', - arguments: { title: 'My Plan', plan_content: '## Goal\nfix bug' }, - }, - ], - }) - ), - }; - } - // Second call: LLM emits pure content, turn ends - return { - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ content: 'Plan is ready for your review.', toolCalls: [] }) - ), - }; - }), - }; - - const planReadyEmits: any[] = []; - const mockHooks = { - emit: vi.fn((point: string, payload: any) => { - if (point === 'plan.ready') planReadyEmits.push(payload); - return Effect.succeed(undefined); - }), - emitDecision: vi.fn(() => Effect.succeed(null)), - } as any; - - const executor = { - execute: () => Effect.succeed({ output: '' }), - executeBatch: (tcs: any[]) => - Effect.succeed( - tcs.map((tc: any) => { - if (tc.name === 'submit_plan') { - return { - type: 'ok' as const, - id: tc.id, - name: tc.name, - output: 'Plan written to /tmp/plans/my-plan.md', - }; - } - return { type: 'ok' as const, id: tc.id, name: tc.name, output: '' }; - }) - ), - } as any; - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop(executor, mockHooks, 5, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any + emitDecision: vi.fn(() => Effect.succeed(null)), + } as any; + return { hooks, emittedPoints }; +} + +describe('agent treats submit_plan as an ordinary tool', () => { + it('runs submit_plan and ends the turn without any plan-specific hook events', async () => { + const { hooks, emittedPoints } = makeCapturingHooks(); + const { events } = await runAgentTurn( + { llm: makeSubmitPlanLlm(), state: mockState, hooks, executor: makeOkExecutor() }, + { sessionId: 'test-session', cwd: '/tmp' } ); - // Exactly one plan.ready emitted, at turn-end (after the second LLM call) - expect(planReadyEmits).toHaveLength(1); - expect(planReadyEmits[0]).toEqual({ - sessionId: mockState.sessionId, - projectPath: mockState.cwd, - title: 'My Plan', - }); + expect(endReason(events)).toBe('done'); + // plan.ready hook point has been removed — the agent no longer announces submit_plan. + expect(emittedPoints.includes('plan.ready')).toBe(false); + expect(emittedPoints.filter((p) => p.startsWith('plan.'))).toHaveLength(0); }); - it('does NOT emit plan.ready when no submit_plan was called this turn', async () => { - let callCount = 0; - const mockLlm = { - completeStream: vi.fn(() => { - callCount++; - return { - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ content: 'Just a regular response', toolCalls: [] }) - ), - }; - }), - }; - - const planReadyEmits: any[] = []; - const mockHooks = { - emit: vi.fn((point: string, payload: any) => { - if (point === 'plan.ready') planReadyEmits.push(payload); - return Effect.succeed(undefined); - }), - emitDecision: vi.fn(() => Effect.succeed(null)), - } as any; - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop({} as any, mockHooks, 5, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any - ); - - expect(planReadyEmits).toHaveLength(0); - }); - - it('does NOT switch profile after plan.ready (profile change is UI responsibility)', async () => { - let callCount = 0; - const setProfileCalls: any[] = []; - const mockLlm = { - completeStream: vi.fn(() => { - callCount++; - if (callCount === 1) { - return { - stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ - content: '', - toolCalls: [ - { - id: 'tc-1', - name: 'submit_plan', - arguments: { title: 'My Plan', plan_content: 'x' }, - }, - ], - }) - ), - }; - } - return { - stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: 'done', toolCalls: [] })), - }; - }), - }; - - const mockRuntime = { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: () => undefined, - listAgentProfiles: () => [], - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, - }), - setSessionProfile: (...args: any[]) => { - setProfileCalls.push(args); - return {}; + it('does NOT switch profile after submit_plan (profile change is UI responsibility)', async () => { + const setActiveProfile = vi.fn(() => Effect.void); + const { hooks } = makeCapturingHooks(); + + const { events } = await runAgentTurn( + { + llm: makeSubmitPlanLlm(), + state: mockState, + hooks, + executor: makeOkExecutor(), + sessionPort: { setActiveProfile }, }, - getSessionProfile: () => undefined, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, - }; - - const layer = AllMockLayer.pipe( - Layer.provide(Layer.succeed(ProjectRuntimeService, mockRuntime as any)) - ); - - const mockHooks = { - emit: () => Effect.succeed(undefined), - emitDecision: () => Effect.succeed(null), - } as any; - - const executor = { - execute: () => Effect.succeed({ output: '' }), - executeBatch: (tcs: any[]) => - Effect.succeed( - tcs.map((tc: any) => - tc.name === 'submit_plan' - ? { type: 'ok' as const, id: tc.id, name: tc.name, output: 'Plan written to /x' } - : { type: 'ok' as const, id: tc.id, name: tc.name, output: '' } - ) - ), - } as any; - - const opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - }; - - const q = Effect.runSync(Queue.unbounded()); - await Effect.runPromise( - agentLoop(executor, mockHooks, 5, 2, opts, q).pipe(Effect.provide(layer)) as any + { sessionId: 'test-session', cwd: '/tmp' } ); - // Profile must NOT be switched as a side effect of plan submission - // (UI button drives the switch) - expect(setProfileCalls).toHaveLength(0); + expect(endReason(events)).toBe('done'); + expect(setActiveProfile).not.toHaveBeenCalled(); }); }); diff --git a/packages/codingcode/test/agent/system-prompt-cwd.test.ts b/packages/codingcode/test/agent/system-prompt-cwd.test.ts deleted file mode 100644 index a7763906..00000000 --- a/packages/codingcode/test/agent/system-prompt-cwd.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { resolve } from 'path'; - -function sourceContent(relativePath: string): string { - return readFileSync(resolve(__dirname, '..', '..', 'src', relativePath), 'utf-8'); -} - -describe('system prompt cwd correctness', () => { - const agentSource = sourceContent('agent/agent.ts'); - - it('buildSystemPrompt should use projectPath (state.cwd), not getWorkspaceCwd()', () => { - // Verify the buildSystemPrompt call uses projectPath variable - // (line 143: const projectPath = state.cwd;) - // NOT getWorkspaceCwd() which is a module-level stale value - - // The import of getWorkspaceCwd should not exist (we removed it) - expect(agentSource).not.toMatch(/import.*getWorkspaceCwd.*from/); - - // The buildSystemPrompt call site should reference projectPath - // Search for the call pattern: buildSystemPrompt({...cwd: projectPath...}) - const hasCorrectCwd = /cwd:\s*projectPath/.test(agentSource); - expect(hasCorrectCwd).toBe(true); - }); - - it('projectPath is derived from state.cwd (not module-level)', () => { - // Verify the projectPath declaration correctly reads from state - const projectPathDeclared = /const projectPath = state\.cwd/.test(agentSource); - expect(projectPathDeclared).toBe(true); - }); -}); diff --git a/packages/codingcode/test/approval/async-confirm.test.ts b/packages/codingcode/test/approval/async-confirm.test.ts index 41b54e14..cf038f63 100644 --- a/packages/codingcode/test/approval/async-confirm.test.ts +++ b/packages/codingcode/test/approval/async-confirm.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from 'vitest'; import { Effect, Layer } from 'effect'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import type { ConfirmResult } from '../../src/approval/confirmation.js'; +import { ApprovalWaitLayer } from '../../src/approval/wait.js'; -const TestLayer = ApprovalWaitService.Default; +const TestLayer = ApprovalWaitLayer; function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(TestLayer) as any)); @@ -41,40 +42,21 @@ describe('ApprovalWaitService', () => { expect(result).toBe(false); }); - it('resolveConfirm succeeds even when sessionId arg differs from stored sessionId', async () => { - const result = run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - const id = 'cross-session-id'; - - yield* Effect.fork( - Effect.gen(function* () { - yield* Effect.sleep('10 millis'); - // resolve using a DIFFERENT sessionId than what was stored - yield* svc.resolveConfirm(id, 'parent-session', { type: 'allow' }); - }) - ); - - // wait was registered with child session id - return yield* svc.waitForConfirm(id, 'child-session-uuid'); - }) - ); - - await expect(result).resolves.toEqual({ type: 'allow' }); - }); - - it('getPending should list pending approval ids', async () => { + it('resolveConfirm returns false when sessionId does not match stored sessionId', async () => { const result = await run( Effect.gen(function* () { const svc = yield* ApprovalWaitService; + const id = 'cross-session-id'; - yield* Effect.fork(svc.waitForConfirm('pending-1', 'test-session')); + // register a pending approval under the child session id + yield* Effect.fork(svc.waitForConfirm(id, 'child-session-uuid')); yield* Effect.sleep('5 millis'); - return yield* svc.getPending(); + // resolving with a different session id must fail (no cross-session resolve) + return yield* svc.resolveConfirm(id, 'parent-session', { type: 'allow' }); }) ); - expect(result).toContain('pending-1'); + expect(result).toBe(false); }); }); diff --git a/packages/codingcode/test/approval/fork-permission-mode.test.ts b/packages/codingcode/test/approval/fork-permission-mode.test.ts deleted file mode 100644 index 7be6645e..00000000 --- a/packages/codingcode/test/approval/fork-permission-mode.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { Effect, Layer } from 'effect'; -import { ApprovalService } from '../../src/approval/index.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; - -const mockHookService = { - register: () => Effect.succeed(() => {}), - registerDecision: () => Effect.succeed(() => {}), - emit: () => Effect.succeed(undefined), - emitDecision: () => Effect.succeed(null), - reloadUserHooks: () => Effect.succeed(undefined), - attachSessionHooks: () => Effect.succeed(undefined), - disableHook: () => Effect.succeed(undefined), - enableHook: () => Effect.succeed(undefined), - disposeSession: () => Effect.succeed(undefined), - disposeProject: () => Effect.succeed(undefined), -}; - -const mockApprovalWaitService = { - 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 TestLayer = ApprovalService.Default.pipe( - Layer.provide(Layer.succeed(HookService, mockHookService as any)), - Layer.provide(Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any)) -); - -let _service: ApprovalService | null = null; -async function getService(): Promise { - if (!_service) { - _service = await Effect.runPromise( - Effect.gen(function* () { - return yield* ApprovalService; - }).pipe(Effect.provide(TestLayer) as any) - ); - } - return _service!; -} - -function run(eff: (svc: ApprovalService) => Promise): Promise { - return getService().then(eff); -} - -describe('approval.fork({ permissionMode }) closure', () => { - beforeEach(async () => { - _service = null; - }); - - 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('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('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/pipeline.test.ts b/packages/codingcode/test/approval/pipeline.test.ts index 628fba27..96a84fc4 100644 --- a/packages/codingcode/test/approval/pipeline.test.ts +++ b/packages/codingcode/test/approval/pipeline.test.ts @@ -1,13 +1,10 @@ import { describe, it, expect } from 'vitest'; import { Effect, Layer } from 'effect'; -import { runPipeline } from '../../src/approval/pipeline.js'; +import { runPipeline } from '../../src/approval/approval.js'; import { createRuleEngine } from '../../src/approval/rule-engine.js'; -import type { PermissionRule, ApprovalDecision } from '../../src/approval/types.js'; -import { READONLY_TOOL_NAMES } from '../../src/approval/presets.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { HookService } from '../../src/hooks/registry.js'; - -const readonlyTools = new Set(READONLY_TOOL_NAMES); +import type { PermissionRule } from '../../src/approval/types.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.js'; +import { HookService } from '../../src/hooks/port.js'; const mockHookService = { register: () => Effect.succeed(() => {}), @@ -25,7 +22,6 @@ const mockHookService = { 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), @@ -41,8 +37,8 @@ function runWithLayer(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(TestLayer) as any)); } -describe('Approval Pipeline', () => { - it('Layer 1: Rule Engine deny should short-circuit', async () => { +describe('Approval Pipeline — PermissionMode auto-allow (merged from ReadonlyWhitelist + acceptEdits)', () => { + it('Rule Engine deny short-circuits regardless of mode', async () => { const rules: PermissionRule[] = [ { id: 'deny', action: 'deny', toolPattern: '*', argPattern: 'rm -rf *', reason: 'Blocked' }, ]; @@ -51,7 +47,6 @@ describe('Approval Pipeline', () => { { tool: 'Bash', input: { command: 'rm -rf /var' } }, { ruleEngine: createRuleEngine(rules), - readonlyTools: readonlyTools, destructiveTools: new Set(), permissionMode: 'default', sessionId: 'test', @@ -62,143 +57,36 @@ describe('Approval Pipeline', () => { expect((decision as any).source).toContain('rule:'); }); - it('Layer 2: Read-only whitelist should auto-allow', async () => { + it('default mode does NOT auto-allow read-only tools (no UI → system deny)', async () => { const decision = await runWithLayer( runPipeline( { tool: 'read_file', input: { path: '/safe/file.txt' } }, { ruleEngine: createRuleEngine(), - readonlyTools: readonlyTools, destructiveTools: new Set(), permissionMode: 'default', sessionId: 'test', } ) ); - expect((decision as any).type).toBe('allow'); - expect((decision as any).source).toBe('readonly-whitelist'); - }); - - it('Layer 3: Bypass mode should allow everything', async () => { - const decision = await runWithLayer( - runPipeline( - { tool: 'Bash', input: { command: 'rm -rf /' } }, - { - ruleEngine: createRuleEngine(), - readonlyTools: readonlyTools, - destructiveTools: new Set(['Bash']), - permissionMode: 'bypass', - sessionId: 'test', - } - ) - ); - expect((decision as any).type).toBe('allow'); - expect((decision as any).source).toBe('permission-mode'); - }); - - it('Layer 3: AcceptEdits mode should auto-allow non-destructive tools', async () => { - const decision = await runWithLayer( - runPipeline( - { tool: 'write_file', input: { path: '/test.txt' } }, - { - ruleEngine: createRuleEngine(), - readonlyTools: readonlyTools, - destructiveTools: new Set(['Bash', 'execute_command']), - permissionMode: 'acceptEdits', - sessionId: 'test', - } - ) - ); - expect((decision as any).type).toBe('allow'); + expect((decision as any).type).toBe('deny'); + expect((decision as any).source).toBe('system'); + expect((decision as any).reason).toBe('Approval required but no UI available'); }); - it('Layer 3: AcceptEdits should NOT auto-allow destructive tools', async () => { + it('acceptEdits mode auto-allows read-only tools (read-only merged into non-destructive)', async () => { const decision = await runWithLayer( runPipeline( - { tool: 'Bash', input: { command: 'rm file' } }, + { tool: 'read_file', input: { path: '/safe/file.txt' } }, { ruleEngine: createRuleEngine(), - readonlyTools: readonlyTools, destructiveTools: new Set(['Bash', 'execute_command']), permissionMode: 'acceptEdits', sessionId: 'test', } ) ); - // Destructive tool in acceptEdits mode with no UI available → system deny - expect((decision as any).type).toBe('deny'); - expect((decision as any).source).toBe('system'); - }); - - it('Layer 4: PreToolUse hook can deny (non-readonly tool)', async () => { - const hooksWithDeny = { - ...mockHookService, - emitDecision: () => Effect.succeed({ decision: 'deny' as const, reason: 'Hook denied' }), - }; - const layer = Layer.mergeAll(Layer.succeed(HookService, hooksWithDeny as any), WaitTestLayer); - const decision = await Effect.runPromise( - runPipeline( - { tool: 'Bash', input: { command: 'ls' } }, - { - ruleEngine: createRuleEngine(), - readonlyTools: readonlyTools, - destructiveTools: new Set(['Bash']), - permissionMode: 'default', - sessionId: 'test', - } - ).pipe(Effect.provide(layer) as any) - ); - expect((decision as any).type).toBe('deny'); - expect((decision as any).source).toBe('hook'); - }); - - it('Layer 4: PreToolUse hook can allow (skiping user confirmation)', async () => { - const hooksWithAllow = { - ...mockHookService, - emitDecision: () => Effect.succeed({ decision: 'allow' as const }), - }; - const layer = Layer.mergeAll(Layer.succeed(HookService, hooksWithAllow as any), WaitTestLayer); - const decision = await Effect.runPromise( - runPipeline( - { tool: 'Bash', input: { command: 'ls' } }, - { - ruleEngine: createRuleEngine(), - readonlyTools: readonlyTools, - destructiveTools: new Set(['Bash']), - permissionMode: 'default', - sessionId: 'test', - } - ).pipe(Effect.provide(layer) as any) - ); expect((decision as any).type).toBe('allow'); - expect((decision as any).source).toBe('hook'); - }); - - it('Layer 6: Audit log is recorded for every decision', async () => { - let auditPayload: any = null; - const hooksWithAudit = { - ...mockHookService, - emit: (_point: string, payload: Record) => - Effect.sync(() => { - auditPayload = payload; - }), - }; - const layer = Layer.mergeAll(Layer.succeed(HookService, hooksWithAudit as any), WaitTestLayer); - await Effect.runPromise( - runPipeline( - { tool: 'read_file', input: { path: '/test.txt' } }, - { - ruleEngine: createRuleEngine(), - readonlyTools: readonlyTools, - destructiveTools: new Set(), - permissionMode: 'default', - sessionId: 'test', - } - ).pipe(Effect.provide(layer) as any) - ); - expect(auditPayload).not.toBeNull(); - expect(auditPayload.tool).toBe('read_file'); - expect(auditPayload.layers).toContain('AuditLog'); - expect((auditPayload.decision as any).type).toBe('allow'); + expect((decision as any).source).toBe('permission-mode'); }); }); diff --git a/packages/codingcode/test/approval/presets.test.ts b/packages/codingcode/test/approval/presets.test.ts deleted file mode 100644 index a214afb2..00000000 --- a/packages/codingcode/test/approval/presets.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createRuleEngine } from '../../src/approval/rule-engine.js'; -import { - DEFAULT_DENY_RULES, - READONLY_TOOL_NAMES, - DANGEROUS_TOOL_NAMES, -} from '../../src/approval/presets.js'; - -describe('Presets', () => { - it('should have system-source rules', () => { - expect(DEFAULT_DENY_RULES.length).toBeGreaterThan(0); - for (const rule of DEFAULT_DENY_RULES) { - expect(rule.source).toBe('system'); - } - }); - - it('should deny rm -rf /', () => { - const engine = createRuleEngine(DEFAULT_DENY_RULES); - const result = engine.evaluate('*', { command: 'rm -rf /var/log' }); - expect(result).not.toBeNull(); - expect(result!.type).toBe('deny'); - }); - - it('should deny sudo commands', () => { - const engine = createRuleEngine(DEFAULT_DENY_RULES); - const result = engine.evaluate('*', { command: 'sudo apt install' }); - expect(result).not.toBeNull(); - expect(result!.type).toBe('deny'); - }); - - it('should fall through (null) for SSH key reads so Layer 5 prompts the user', () => { - const engine = createRuleEngine(DEFAULT_DENY_RULES); - const result = engine.evaluate('read_file', { path: '/home/user/.ssh/id_rsa' }); - // 'ask' is a pass-through — the rule matches, but the engine returns - // null so the pipeline reaches the user confirmation layer. - expect(result).toBeNull(); - }); - - it('should fall through (null) for .env file reads so Layer 5 prompts the user', () => { - const engine = createRuleEngine(DEFAULT_DENY_RULES); - const result = engine.evaluate('read_file', { path: '/project/.env.production' }); - expect(result).toBeNull(); - }); - - it('should define read-only tools', () => { - expect(READONLY_TOOL_NAMES).toContain('read_file'); - expect(READONLY_TOOL_NAMES).toContain('search_code'); - expect(READONLY_TOOL_NAMES).toContain('search_files'); - expect(READONLY_TOOL_NAMES).toContain('fetch_url'); - expect(READONLY_TOOL_NAMES).toContain('web_search'); - expect(READONLY_TOOL_NAMES).toContain('dispatch_agent'); - expect(READONLY_TOOL_NAMES).toContain('todo_write'); - }); - - it('should define destructive tools', () => { - expect(DANGEROUS_TOOL_NAMES).toContain('execute_command'); - expect(DANGEROUS_TOOL_NAMES).not.toContain('Bash'); - }); -}); diff --git a/packages/codingcode/test/approval/response.test.ts b/packages/codingcode/test/approval/response.test.ts index 3b6dff39..49c9e8c9 100644 --- a/packages/codingcode/test/approval/response.test.ts +++ b/packages/codingcode/test/approval/response.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { parseApprovalResponse } from '../../src/approval/response.js'; +import { parseApprovalResponse } from '../../src/approval/confirmation.js'; describe('parseApprovalResponse', () => { it('maps single-use approval responses', () => { diff --git a/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts b/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts index 01afe14c..e09f64a9 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts @@ -1,40 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; -import { randomUUID } from 'crypto'; -import { spawnSync } from 'child_process'; import { useTempProjectBase } from '../helpers/project-base.js'; +import { CheckpointLayer } from '../../src/checkpoint/checkpoint.js'; useTempProjectBase(); -function setupTempRepo(): { projectPath: string; slug: string } { - const slug = `test-${randomUUID()}`; - const projectPath = join(homedir(), '.codingcode-test', slug); - mkdirSync(projectPath, { recursive: true }); - - // Initialize git repo - spawnSync('git', ['init'], { cwd: projectPath, encoding: 'utf-8' }); - spawnSync('git', ['config', 'user.name', 'test'], { cwd: projectPath, encoding: 'utf-8' }); - spawnSync('git', ['config', 'user.email', 'test@test.com'], { - cwd: projectPath, - encoding: 'utf-8', - }); - - return { projectPath, slug }; -} - -function cleanupTempRepo(projectPath: string) { - rmSync(projectPath, { recursive: true, force: true }); -} - -function writeFile(projectPath: string, filename: string, content: string) { - const filePath = join(projectPath, filename); - const dir = join(filePath, '..'); - mkdirSync(dir, { recursive: true }); - writeFileSync(filePath, content, 'utf8'); -} - describe('toGitPath', () => { it('converts absolute to relative', async () => { const { toGitPath } = await import('../../src/checkpoint/utils.js'); @@ -51,9 +20,9 @@ describe('toGitPath', () => { describe('CheckpointService class', () => { it('CheckpointService class is exported', async () => { - const mod = await import('../../src/checkpoint/checkpoint-service.js'); + const mod = await import('../../src/checkpoint/port.js'); expect(mod.CheckpointService).toBeDefined(); - }); + }, 60000); }); describe('CheckpointDiff type with insertions/deletions', () => { @@ -76,152 +45,10 @@ describe('CheckpointDiff type with insertions/deletions', () => { }); }); -describe('ShadowGit commit and findCommitByMessage flow', () => { - it('creates commits that can be found by message pattern', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - - const projectPath = setupTempRepo().projectPath; - - try { - writeFile(projectPath, 'src/main.ts', 'console.log("hello")'); - - const sg = new ShadowGit(projectPath); - sg.init(); - - // First commit (baseline) - const baselineMsg = 'turn-abc123-1-baseline'; - sg.commit(baselineMsg); - - // Modify file - writeFile(projectPath, 'src/main.ts', 'console.log("world")'); - - // Second commit (final) - const finalMsg = 'turn-abc123-1-final'; - sg.commit(finalMsg); - - // Verify commits can be found - const baselineHash = sg.findCommitByMessage(baselineMsg); - const finalHash = sg.findCommitByMessage(finalMsg); - - expect(baselineHash).not.toBeNull(); - expect(finalHash).not.toBeNull(); - expect(baselineHash).not.toBe(finalHash); - - // Verify diff between commits - const changes = sg.diffFiles(baselineHash!, finalHash!); - expect(changes.length).toBeGreaterThan(0); - expect(changes[0]!.file).toContain('main.ts'); - } finally { - cleanupTempRepo(projectPath); - } - }, 15000); - - it('returns empty diff when no changes between commits', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - - const projectPath = setupTempRepo().projectPath; - - try { - writeFile(projectPath, 'src/main.ts', 'console.log("hello")'); - - const sg = new ShadowGit(projectPath); - sg.init(); - - const msg1 = 'turn-abc123-1-baseline'; - sg.commit(msg1); - - // No file changes - const msg2 = 'turn-abc123-1-final'; - sg.commit(msg2); - - const hash1 = sg.findCommitByMessage(msg1); - const hash2 = sg.findCommitByMessage(msg2); - - expect(hash1).not.toBeNull(); - expect(hash2).not.toBeNull(); - - const changes = sg.diffFiles(hash1!, hash2!); - expect(changes.length).toBe(0); - } finally { - cleanupTempRepo(projectPath); - } - }, 15000); - - it('correctly handles Chinese filenames in commits and diffs', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - - const projectPath = setupTempRepo().projectPath; - - try { - writeFile( - projectPath, - '\u8d5e\u988c\u7956\u56fd\u4eba_\u7b2c\u4e00\u7bc7.md', - 'initial content' - ); - - const sg = new ShadowGit(projectPath); - sg.init(); - - const baselineMsg = 'turn-cn-test-1-baseline'; - sg.commit(baselineMsg); - - writeFile( - projectPath, - '\u8d5e\u988c\u7956\u56fd\u4eba_\u7b2c\u4e00\u7bc7.md', - 'modified content' - ); - - const finalMsg = 'turn-cn-test-1-final'; - sg.commit(finalMsg); - - const baselineHash = sg.findCommitByMessage(baselineMsg); - const finalHash = sg.findCommitByMessage(finalMsg); - - expect(baselineHash).not.toBeNull(); - expect(finalHash).not.toBeNull(); - expect(baselineHash).not.toBe(finalHash); - - // Verify the diff actually detects the file change (non-empty tree) - const changes = sg.diffFiles(baselineHash!, finalHash!); - expect(changes.length).toBe(1); - expect(changes[0]!.file).toContain('\u8d5e\u988c\u7956\u56fd\u4eba'); - expect(changes[0]!.status).toBe('M'); - } finally { - cleanupTempRepo(projectPath); - } - }, 15000); - - it('throws when git add -A fails', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - - const projectPath = setupTempRepo().projectPath; - - try { - writeFile(projectPath, 'normal.md', 'content'); - - const sg = new ShadowGit(projectPath); - sg.init(); - - // Patch run() to simulate a failing add - const originalRun = (sg as any).run.bind(sg); - (sg as any).run = function (...args: string[]) { - if (args[0] === 'add' && args[1] === '-A') { - return { stdout: '', stderr: 'fatal: unable to add files', status: 128 }; - } - return originalRun(...args); - }; - - expect(() => sg.commit('turn-fail-1-baseline')).toThrow('ShadowGit add failed'); - } finally { - cleanupTempRepo(projectPath); - } - }); -}); - describe('CheckpointService', () => { it('should export a Default layer', async () => { - const { CheckpointService } = await import('../../src/checkpoint/checkpoint-service.js'); + const { CheckpointService } = await import('../../src/checkpoint/port.js'); expect(CheckpointService).toBeDefined(); - expect((CheckpointService as any).Default).toBeDefined(); + expect((CheckpointLayer as any)).toBeDefined(); }); }); diff --git a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts index 1acdbbc1..6ce9f093 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts @@ -1,48 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { - existsSync, - mkdirSync, - writeFileSync, - rmSync, - readFileSync, - readFileSync as fsReadFileSync, -} from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; -import { randomUUID } from 'crypto'; -import { spawnSync } from 'child_process'; -import { Effect } from 'effect'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; import { useTempProjectBase } from '../helpers/project-base.js'; +import { CheckpointLayer } from '../../src/checkpoint/checkpoint.js'; useTempProjectBase(); -function setupTempRepo(): { projectPath: string; slug: string } { - const slug = `test-${randomUUID()}`; - const projectPath = join(homedir(), '.codingcode-test', slug); - mkdirSync(projectPath, { recursive: true }); - - spawnSync('git', ['init'], { cwd: projectPath, encoding: 'utf-8' }); - spawnSync('git', ['config', 'user.name', 'test'], { cwd: projectPath, encoding: 'utf-8' }); - spawnSync('git', ['config', 'user.email', 'test@test.com'], { - cwd: projectPath, - encoding: 'utf-8', - }); - - return { projectPath, slug }; -} - -function cleanupTempRepo(projectPath: string) { - rmSync(projectPath, { recursive: true, force: true }); -} - -function writeFile(projectPath: string, filename: string, content: string) { - const filePath = join(projectPath, filename); - const dir = join(filePath, '..'); - mkdirSync(dir, { recursive: true }); - writeFileSync(filePath, content, 'utf8'); -} - describe('toGitPath case-insensitive matching', () => { it('handles Windows case-mismatched projectPath and file path', async () => { const { toGitPath } = await import('../../src/checkpoint/utils.js'); @@ -74,220 +35,6 @@ describe('toGitPath case-insensitive matching', () => { }); }); -describe('findCommitByMessage single-match guarantee', () => { - it('returns only one hash even when multiple commits share a substring', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - - const { projectPath } = setupTempRepo(); - - try { - writeFile(projectPath, 'a.txt', 'v1'); - - const sg = new ShadowGit(projectPath); - sg.init(); - - // Commit with a message that shares a common prefix with another - sg.commit('turn-abc123-1-baseline hello'); - writeFile(projectPath, 'a.txt', 'v2'); - sg.commit('turn-abc123-1-baseline world'); - - // Both messages contain 'turn-abc123-1-baseline' as substring - const hash = sg.findCommitByMessage('turn-abc123-1-baseline'); - - expect(hash).not.toBeNull(); - // Must be a single 40-char hex hash, not multi-line - expect(hash).toMatch(/^[a-f0-9]{40}$/); - } finally { - cleanupTempRepo(projectPath); - } - }, 15000); -}); - -describe('checkoutFiles error propagation', () => { - it('throws when restore receives an invalid commit hash', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - - const { projectPath } = setupTempRepo(); - - try { - writeFile(projectPath, 'a.txt', 'content'); - - const sg = new ShadowGit(projectPath); - sg.init(); - sg.commit('baseline'); - - // Invalid commit hash (multi-line or non-existent) - const invalidCommit = - 'deadbeef00000000000000000000000000000000\n0000000000000000000000000000000000000000'; - - expect(() => sg.checkoutFiles(invalidCommit, ['a.txt'])).toThrow('ShadowGit restore failed'); - } finally { - cleanupTempRepo(projectPath); - } - }, 15000); -}); - -describe('undoLastCodeRollback end-to-end via ShadowGit', () => { - it('restores files from safety commit after revert and undo', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - const { createHash } = await import('crypto'); - const { dirname, join: pathJoin } = await import('path'); - - const { projectPath } = setupTempRepo(); - - try { - // Setup: create a file, commit baseline, modify, commit final - writeFile(projectPath, 'src/main.ts', 'console.log("baseline")'); - const sg = new ShadowGit(projectPath); - sg.init(); - const baselineHash = sg.commit('turn-sess-1-baseline'); - - writeFile(projectPath, 'src/main.ts', 'console.log("final")'); - const finalHash = sg.commit('turn-sess-1-final'); - - expect(baselineHash).not.toBeNull(); - expect(finalHash).not.toBeNull(); - expect(baselineHash).not.toBe(finalHash); - - // Simulate revert: save current state as safety, checkout to baseline - const safetyHash = sg.commit('turn-sess-1-revert-safety'); - sg.checkoutFiles(baselineHash, [join(projectPath, 'src/main.ts')]); - - // Verify reverted state - expect(fsReadFileSync(join(projectPath, 'src/main.ts'), 'utf8')).toBe( - 'console.log("baseline")' - ); - - // Write restore entry manually (mimicking checkpoint-service internal format) - const sessionId = 'sess'; - const shortSid = createHash('sha256').update(sessionId).digest('hex').slice(0, 8); - const restorePath = pathJoin(dirname(sg.gitDir), `last-restore-${shortSid}.json`); - const entry = { - id: 'test123', - sessionId, - action: 'checkpoint-files', - throughTurnId: 1, - affectedTurns: [], - selectedFiles: [join(projectPath, 'src/main.ts')], - safetyCommit: safetyHash, - }; - writeFileSync(restorePath, JSON.stringify(entry, null, 2), 'utf8'); - - // Read back and simulate undo: checkout from safety commit - const storedEntry = JSON.parse(fsReadFileSync(restorePath, 'utf8')); - expect(storedEntry).not.toBeNull(); - expect(storedEntry.safetyCommit).toBe(safetyHash); - sg.checkoutFiles(storedEntry.safetyCommit, storedEntry.selectedFiles); - - // Verify restored to final state - expect(fsReadFileSync(join(projectPath, 'src/main.ts'), 'utf8')).toBe('console.log("final")'); - } finally { - cleanupTempRepo(projectPath); - } - }, 15000); -}); - -describe('rollbackCodeToTurn uses inclusive target turn', () => { - it('previews the first turn diff when rolling back a single-turn session', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - const { createHash } = await import('crypto'); - const { projectPath } = setupTempRepo(); - - try { - const sessionId = 'sess-single-preview'; - const shortSid = createHash('sha256').update(sessionId).digest('hex').slice(0, 8); - const sg = new ShadowGit(projectPath); - sg.init(); - sg.commit(`turn-${shortSid}-1-baseline`); - writeFile(projectPath, 'articles/one.md', '# one'); - sg.commit(`turn-${shortSid}-1-final`); - - const preview = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* CheckpointService; - return yield* svc.previewRollbackDiff(projectPath, sessionId, 1); - }).pipe(Effect.provide(CheckpointService.Default)) - ); - - expect(preview.affectedTurns).toEqual([1]); - expect(preview.diff).toContain('articles/one.md'); - } finally { - cleanupTempRepo(projectPath); - } - }, 15000); - - it('rolls back files created by the first turn in a single-turn session', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - const { createHash } = await import('crypto'); - const { projectPath } = setupTempRepo(); - - try { - const sessionId = 'sess-single-rollback'; - const shortSid = createHash('sha256').update(sessionId).digest('hex').slice(0, 8); - const sg = new ShadowGit(projectPath); - sg.init(); - sg.commit(`turn-${shortSid}-1-baseline`); - writeFile(projectPath, 'articles/one.md', '# one'); - sg.commit(`turn-${shortSid}-1-final`); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* CheckpointService; - return yield* svc.rollbackCodeToTurn(projectPath, sessionId, 1); - }).pipe(Effect.provide(CheckpointService.Default)) - ); - - expect(result.reverted).toBe(true); - expect(result.affectedTurns).toEqual([1]); - expect( - result.selectedFiles.some((f: string) => f.replace(/\\/g, '/').endsWith('articles/one.md')) - ).toBe(true); - expect(existsSync(join(projectPath, 'articles/one.md'))).toBe(false); - } finally { - cleanupTempRepo(projectPath); - } - }, 15000); - - it('includes the target and later turns when rolling back a multi-turn session', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - const { createHash } = await import('crypto'); - const { projectPath } = setupTempRepo(); - - try { - const sessionId = 'sess-multi-rollback'; - const shortSid = createHash('sha256').update(sessionId).digest('hex').slice(0, 8); - const sg = new ShadowGit(projectPath); - sg.init(); - - writeFile(projectPath, 'one.txt', 'one'); - sg.commit(`turn-${shortSid}-1-baseline`); - writeFile(projectPath, 'one.txt', 'one-final'); - sg.commit(`turn-${shortSid}-1-final`); - - sg.commit(`turn-${shortSid}-2-baseline`); - writeFile(projectPath, 'two.txt', 'two-final'); - sg.commit(`turn-${shortSid}-2-final`); - - sg.commit(`turn-${shortSid}-3-baseline`); - writeFile(projectPath, 'three.txt', 'three-final'); - sg.commit(`turn-${shortSid}-3-final`); - - const preview = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* CheckpointService; - return yield* svc.previewRollbackDiff(projectPath, sessionId, 2); - }).pipe(Effect.provide(CheckpointService.Default)) - ); - - expect(preview.affectedTurns).toEqual([2, 3]); - expect(preview.diff).toContain('two.txt'); - expect(preview.diff).toContain('three.txt'); - } finally { - cleanupTempRepo(projectPath); - } - }, 15000); -}); - describe('toGitPath preserves original casing for git paths', () => { it('returns relative path with original casing from git diff', async () => { const { toGitPath } = await import('../../src/checkpoint/utils.js'); @@ -300,121 +47,10 @@ describe('toGitPath preserves original casing for git paths', () => { }); }); -describe('undoLastCodeRollback case-insensitive path matching', () => { - it('restores file when opts.files casing differs from entry.selectedFiles', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - const { createHash } = await import('crypto'); - const { dirname, join: pathJoin } = await import('path'); - - const { projectPath } = setupTempRepo(); - - try { - writeFile(projectPath, 'src/main.ts', 'console.log("baseline")'); - const sg = new ShadowGit(projectPath); - sg.init(); - const shortSid = createHash('sha256').update('sess').digest('hex').slice(0, 8); - const baselineHash = sg.commit(`turn-${shortSid}-1-baseline`); - - writeFile(projectPath, 'src/main.ts', 'console.log("final")'); - sg.commit(`turn-${shortSid}-1-final`); - - const safetyHash = sg.commit(`turn-${shortSid}-1-revert-safety`); - sg.checkoutFiles(baselineHash, [join(projectPath, 'src/main.ts')]); - - // Verify reverted state - expect(readFileSync(join(projectPath, 'src/main.ts'), 'utf8')).toBe( - 'console.log("baseline")' - ); - - // Write restore entry with lowercase path (simulating old data) - const sessionId = 'sess'; - const restorePath = pathJoin(dirname(sg.gitDir), `last-restore-${shortSid}.json`); - const entry = { - id: 'test123', - sessionId, - action: 'checkpoint-files', - throughTurnId: 1, - affectedTurns: [], - selectedFiles: [join(projectPath, 'src/main.ts').toLowerCase()], - safetyCommit: safetyHash, - }; - writeFileSync(restorePath, JSON.stringify(entry, null, 2), 'utf8'); - - // Call undo with original casing (mixed case) - const result = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* CheckpointService; - return yield* svc.undoLastCodeRollback(projectPath, sessionId, { - files: [join(projectPath, 'src/main.ts')], - }); - }).pipe(Effect.provide(CheckpointService.Default)) - ); - - expect(result.restored).toBe(true); - expect(result.restoredFiles.length).toBeGreaterThan(0); - - // Verify restored to final state - expect(readFileSync(join(projectPath, 'src/main.ts'), 'utf8')).toBe('console.log("final")'); - } finally { - cleanupTempRepo(projectPath); - } - }, 15000); -}); - -describe('revertFilesImpl case-insensitive deduplication', () => { - it('merges existing entry without duplicate paths when casing differs', async () => { - const { ShadowGit } = await import('../../src/checkpoint/shadow-git.js'); - const { createHash } = await import('crypto'); - const { dirname, join: pathJoin } = await import('path'); - - const { projectPath } = setupTempRepo(); - - try { - writeFile(projectPath, 'src/main.ts', 'console.log("baseline")'); - const sg = new ShadowGit(projectPath); - sg.init(); - const shortSid = createHash('sha256').update('sess').digest('hex').slice(0, 8); - const baselineHash = sg.commit(`turn-${shortSid}-1-baseline`); - - writeFile(projectPath, 'src/main.ts', 'console.log("final")'); - sg.commit(`turn-${shortSid}-1-final`); - - const filePath = join(projectPath, 'src/main.ts'); - - // First revert with lowercase path - const result1 = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* CheckpointService; - return yield* svc.revertCheckpointFiles(projectPath, 'sess', 1, [filePath.toLowerCase()]); - }).pipe(Effect.provide(CheckpointService.Default)) - ); - - expect(result1.reverted).toBe(true); - expect(result1.restoreEntry).not.toBeNull(); - expect(result1.restoreEntry!.selectedFiles.length).toBe(1); - - // Second revert with original casing (simulating different path source) - const result2 = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* CheckpointService; - return yield* svc.revertCheckpointFiles(projectPath, 'sess', 1, [filePath]); - }).pipe(Effect.provide(CheckpointService.Default)) - ); - - expect(result2.reverted).toBe(true); - expect(result2.restoreEntry).not.toBeNull(); - // Should still be 1 file, not 2, because casing difference is ignored - expect(result2.restoreEntry!.selectedFiles.length).toBe(1); - } finally { - cleanupTempRepo(projectPath); - } - }, 15000); -}); - describe('CheckpointService', () => { it('should export a Default layer', async () => { - const { CheckpointService } = await import('../../src/checkpoint/checkpoint-service.js'); + const { CheckpointService } = await import('../../src/checkpoint/port.js'); expect(CheckpointService).toBeDefined(); - expect((CheckpointService as any).Default).toBeDefined(); + expect((CheckpointLayer as any)).toBeDefined(); }); }); diff --git a/packages/codingcode/test/checkpoint/turn-title-removal.test.ts b/packages/codingcode/test/checkpoint/turn-title-removal.test.ts index 072b1558..b38cbb3e 100644 --- a/packages/codingcode/test/checkpoint/turn-title-removal.test.ts +++ b/packages/codingcode/test/checkpoint/turn-title-removal.test.ts @@ -4,10 +4,11 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { randomUUID, createHash } from 'crypto'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; +import { CheckpointService } from '../../src/checkpoint/port.js'; import { ShadowGit } from '../../src/checkpoint/shadow-git.js'; import { normalizePath } from '../../src/core/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; +import { CheckpointLayer } from '../../src/checkpoint/checkpoint.js'; useTempProjectBase(); @@ -25,17 +26,15 @@ describe('checkpoint turn title removal', () => { yield* checkpoint.snapshotBaseline(projectPath, sessionId, 1); writeFileSync(join(projectPath, 'after.txt'), 'after', 'utf8'); yield* checkpoint.snapshotFinal(projectPath, sessionId, 1); - return yield* checkpoint.getCheckpoints(projectPath, sessionId); - }).pipe(Effect.provide(CheckpointService.Default)) - ); + return yield* checkpoint.getCheckpointDiff(projectPath, sessionId); + }).pipe(Effect.provide(CheckpointLayer) as any) + ) as { turnId: number; files: Array<{ path: string }> }; - expect(checkpoints).toEqual([ - { - turnId: 1, - files: [normalizePath(join(projectPath, 'after.txt'))], - }, + expect(checkpoints.turnId).toBe(1); + expect(checkpoints.files.map((f) => f.path)).toEqual([ + normalizePath(join(projectPath, 'after.txt')), ]); - expect(checkpoints[0]).not.toHaveProperty('title'); + expect(checkpoints).not.toHaveProperty('title'); const shortSid = createHash('sha256').update(sessionId).digest('hex').slice(0, 8); const shadowGit = new ShadowGit(projectPath); @@ -47,5 +46,5 @@ describe('checkpoint turn title removal', () => { } finally { rmSync(projectPath, { recursive: true, force: true }); } - }, 15000); + }, 60000); }); diff --git a/packages/codingcode/test/ci/tooling-scripts.test.ts b/packages/codingcode/test/ci/tooling-scripts.test.ts deleted file mode 100644 index 3833b54c..00000000 --- a/packages/codingcode/test/ci/tooling-scripts.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { execSync } from 'child_process'; -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; - -describe('CI tooling configuration', () => { - const root = join(__dirname, '../../../..'); - - it('eslint config exists and is parseable', () => { - const configPath = join(root, 'eslint.config.mjs'); - expect(existsSync(configPath)).toBe(true); - }); - - it('prettier config exists and is valid JSON', () => { - const configPath = join(root, '.prettierrc'); - expect(existsSync(configPath)).toBe(true); - const content = readFileSync(configPath, 'utf8'); - expect(() => JSON.parse(content)).not.toThrow(); - }); - - it('package.json has required CI scripts', () => { - const pkgPath = join(root, 'package.json'); - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); - expect(pkg.scripts.lint).toBeDefined(); - expect(pkg.scripts['lint:fix']).toBeDefined(); - expect(pkg.scripts.format).toBeDefined(); - expect(pkg.scripts['format:check']).toBeDefined(); - expect(pkg.scripts.typecheck).toBeDefined(); - expect(pkg.scripts.test).toBeDefined(); - }); - - it('GitHub Actions workflow exists with required jobs', () => { - const workflowPath = join(root, '.github/workflows/pr-check.yml'); - expect(existsSync(workflowPath)).toBe(true); - const content = readFileSync(workflowPath, 'utf8'); - expect(content).toContain('jobs:'); - expect(content).toContain('lint:'); - expect(content).toContain('typecheck:'); - expect(content).toContain('test:'); - expect(content).toContain('build-desktop:'); - }); - - it('GitHub Actions release workflow exists and is triggered by tags', () => { - const workflowPath = join(root, '.github/workflows/release.yml'); - expect(existsSync(workflowPath)).toBe(true); - const content = readFileSync(workflowPath, 'utf8'); - expect(content).toContain('tags:'); - expect(content).toContain("- 'v*'"); - expect(content).toContain('permissions:'); - expect(content).toContain('contents: write'); - expect(content).toContain('GH_TOKEN'); - expect(content).toContain('--publish never'); - expect(content).toContain('gh release create'); - expect(content).toContain('needs: build'); - }); - - it('electron-builder.yml has publish config for GitHub Releases', () => { - const configPath = join(root, 'packages/desktop/electron-builder.yml'); - expect(existsSync(configPath)).toBe(true); - const content = readFileSync(configPath, 'utf8'); - expect(content).toContain('publish:'); - expect(content).toContain('provider: github'); - expect(content).toContain('releaseType: draft'); - }); - - it('desktop package.json has release script', () => { - const pkgPath = join(root, 'packages/desktop/package.json'); - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); - expect(pkg.scripts.release).toBeDefined(); - expect(pkg.scripts.release).toContain('--publish always'); - }); - - it('pnpm run lint exits successfully', () => { - expect(() => execSync('pnpm run lint', { cwd: root, stdio: 'pipe' })).not.toThrow(); - }, 60000); - - it('pnpm run format:check exits successfully', () => { - expect(() => execSync('pnpm run format:check', { cwd: root, stdio: 'pipe' })).not.toThrow(); - }, 60000); -}); diff --git a/packages/codingcode/test/client/contracts.test.ts b/packages/codingcode/test/client/contracts.test.ts new file mode 100644 index 00000000..85c570e8 --- /dev/null +++ b/packages/codingcode/test/client/contracts.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import type { + AgentRuntimeClient, + SessionClient, + ModelClient, + SettingsClient, +} from '../../src/client/contracts.js'; +import { createHttpAgentClient } from '../../src/client/http/agent-runtime.js'; +import { createHttpSessionClient } from '../../src/client/http/sessions.js'; +import { createHttpModelClient } from '../../src/client/http/models.js'; +import { createHttpSettingsClient } from '../../src/client/http/settings.js'; +import { createRequestHelpers } from '../../src/client/http/request.js'; + +type AssertNotAny = 0 extends 1 & T ? never : T; + +const request = createRequestHelpers('http://localhost:1'); + +// 编译期断言:http 实现必须精确满足 contracts 定义的接口 +const httpAgent: AgentRuntimeClient = createHttpAgentClient('http://localhost:1', request); +const httpSessions: SessionClient = createHttpSessionClient(request); +const httpModels: ModelClient = createHttpModelClient(request); +const httpSettings: SettingsClient = createHttpSettingsClient(request); + +type _HttpAgentNotAny = AssertNotAny; +type _HttpSessionsNotAny = AssertNotAny; +type _HttpModelsNotAny = AssertNotAny; +type _HttpSettingsNotAny = AssertNotAny; + +describe('client contracts', () => { + it('http agent 只暴露 sendMessage / sendApprovalResponse / compact', () => { + expect(Object.keys(httpAgent).sort()).toEqual([ + 'compact', + 'sendApprovalResponse', + 'sendMessage', + ]); + }); + + it('agent client 不再包含 checkpoint / rollback / fork 死方法', () => { + const keys = Object.keys(httpAgent); + for (const dead of [ + 'getCheckpointDiff', + 'revertCheckpointFiles', + 'previewRollbackDiff', + 'rollbackCodeToTurn', + 'rollbackContext', + 'rollbackBothToTurn', + 'forkSession', + ]) { + expect(keys).not.toContain(dead); + } + }); + + it('session client 承载全部 checkpoint / rollback / fork 能力', () => { + const keys = Object.keys(httpSessions); + for (const required of [ + 'getCheckpointDiff', + 'revertCheckpointFiles', + 'previewRollbackDiff', + 'rollbackCodeToTurn', + 'rollbackContext', + 'rollbackBothToTurn', + 'forkSession', + ]) { + expect(keys).toContain(required); + } + }); + + it('model / settings client 形状稳定', () => { + expect(Object.keys(httpModels).sort()).toEqual(['listModels', 'switchModel']); + expect(Object.keys(httpSettings)).toContain('getGlobalPermissionMode'); + expect(Object.keys(httpSettings)).toContain('setGlobalPermissionMode'); + }); +}); diff --git a/packages/codingcode/test/client/direct-todo.test.ts b/packages/codingcode/test/client/direct-todo.test.ts deleted file mode 100644 index f03ac7ee..00000000 --- a/packages/codingcode/test/client/direct-todo.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { agentEventToStreamChunk } from '../../src/agent/stream-adapter.js'; - -describe('agentEventToStreamChunk with TodoUpdate', () => { - it('should map TodoUpdate to todo_update chunk', async () => { - async function* source() { - yield { _tag: 'TodoUpdate' as const, items: [{ step: 'a', status: 'pending' as const }] }; - } - - const gen = agentEventToStreamChunk(source() as any); - const chunks: any[] = []; - for await (const c of gen) chunks.push(c); - - expect(chunks).toHaveLength(1); - expect(chunks[0]).toEqual({ type: 'todo_update', items: [{ step: 'a', status: 'pending' }] }); - }); - - it('should skip TodoUpdate when mapping with non-matching events', async () => { - async function* source() { - yield { _tag: 'LlmChunk' as const, text: 'hello' }; - yield { _tag: 'Step' as const, step: 1, max: 5 }; - } - - const gen = agentEventToStreamChunk(source() as any); - const chunks: any[] = []; - for await (const c of gen) chunks.push(c); - - const todoChunks = chunks.filter((c: any) => typeof c === 'object' && c.type === 'todo_update'); - expect(todoChunks).toHaveLength(0); - }); -}); diff --git a/packages/codingcode/test/client/direct-types.test.ts b/packages/codingcode/test/client/direct-types.test.ts index 37207a67..c5009fbb 100644 --- a/packages/codingcode/test/client/direct-types.test.ts +++ b/packages/codingcode/test/client/direct-types.test.ts @@ -7,10 +7,11 @@ 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 { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { WorkspaceService } from '../../src/core/workspace.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; +import { LLMFactoryService } from '../../src/llm/port.js'; import { AgentError } from '../../src/core/error.js'; +import { ApprovalWaitLayer } from '../../src/approval/wait.js'; type AssertNotAny = 0 extends 1 & T ? never : T; @@ -38,7 +39,7 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { } as any); const TestLayer = Layer.mergeAll( - ApprovalWaitService.Default, + ApprovalWaitLayer, MockWorkspaceLayer, MockLLMFactoryLayer ); @@ -46,12 +47,18 @@ const TestLayer = Layer.mergeAll( const rt = ManagedRuntime.make(TestLayer); 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, + completeStream: () => + (async function* () { + yield { type: 'end' as const }; + })(), + complete: () => Effect.succeed({ content: '' }), + modelInfo: { + provider: 'test', + model: 'test-model', + maxTokens: 128000, + supportsToolCalling: true, + supportsStreaming: true, + }, }; describe('type replacements: AppRuntime and LLMClient', () => { diff --git a/packages/codingcode/test/client/direct.test.ts b/packages/codingcode/test/client/direct.test.ts index 9b651cf9..016719e3 100644 --- a/packages/codingcode/test/client/direct.test.ts +++ b/packages/codingcode/test/client/direct.test.ts @@ -2,12 +2,11 @@ import { describe, expect, it, vi } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; 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 { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { AgentError } from '../../src/core/error.js'; import { WorkspaceService } from '../../src/core/workspace.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; +import { LLMFactoryService } from '../../src/llm/port.js'; +import { ApprovalWaitLayer } from '../../src/approval/wait.js'; const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { getWorkspaceCwd: () => '/tmp/test', @@ -36,33 +35,13 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { } as any); const TestLayer = Layer.mergeAll( - ApprovalWaitService.Default, + ApprovalWaitLayer, MockWorkspaceLayer, MockLLMFactoryLayer ); const rt = ManagedRuntime.make(TestLayer); -const noopLlm: LLMClient = { - completeStream: () => ({ - stream: (async function* () {})(), - response: Promise.resolve({ ok: true, value: { content: '', finishReason: 'stop' as const } }), - }), - complete: () => - Effect.succeed({ - content: '', - finishReason: 'stop' as const, - usage: { prompt: 0, completion: 0, total: 0 }, - }), - modelInfo: { - provider: 'test', - model: 'test-model', - maxTokens: 128000, - supportsToolCalling: true, - supportsStreaming: true, - }, -}; - describe('createDirectModelClient operations', () => { it('lists models from the local model catalog without HTTP', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); @@ -91,47 +70,6 @@ describe('createDirectModelClient operations', () => { }); }); -describe('agentEventToStreamChunk', () => { - it('yields usage chunks', async () => { - async function* source() { - yield { _tag: 'Step' as const, step: 1, max: 10 }; - yield { _tag: 'Assistant' as const, content: 'ok' }; - yield { _tag: 'Usage' as const, prompt: 1000, completion: 500, total: 1500 }; - } - - const chunks: any[] = []; - for await (const chunk of agentEventToStreamChunk(source())) { - chunks.push(chunk); - } - - expect(chunks).toEqual([ - { type: 'message', id: 1, content: 'ok', partial: false }, - { type: 'usage', prompt: 1000, completion: 500, total: 1500 }, - ]); - }); - - it('yields error chunk with code from AgentError', async () => { - async function* source() { - yield { _tag: 'Error' as const, error: AgentError.toolExecutionFailed('bash', 'EACCES') }; - yield { _tag: 'Done' as const, content: '' }; - } - - const chunks: any[] = []; - for await (const chunk of agentEventToStreamChunk(source())) { - chunks.push(chunk); - } - - expect(chunks).toEqual([ - { - type: 'error', - message: expect.stringContaining('bash'), - code: 'TOOL_EXECUTION_FAILED', - }, - { type: 'done' }, - ]); - }); -}); - describe('approval buffering - race condition fix', () => { const run = (eff: Effect.Effect): Promise => rt.runPromise(eff); diff --git a/packages/codingcode/test/client/get-session-plan.test.ts b/packages/codingcode/test/client/get-session-plan.test.ts index b2ecdaec..b869b786 100644 --- a/packages/codingcode/test/client/get-session-plan.test.ts +++ b/packages/codingcode/test/client/get-session-plan.test.ts @@ -1,9 +1,8 @@ -import { describe, it, expect, vi } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; +import { describe, it, expect } from 'vitest'; +import { 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 { SessionLayer } from '../../src/session/session.js'; import { readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -34,11 +33,7 @@ describe('getSessionPlan: http + direct both implement', () => { writeFileSync(join(projectDir, 'second.md'), '# second'); setProjectBaseDir(base); try { - const TestLayer = Layer.mergeAll( - SessionService.Default, - ProjectRuntimeService.Default - ) as Layer.Layer; - const rt = ManagedRuntime.make(TestLayer); + const rt = ManagedRuntime.make(SessionLayer); const c = createDirectSessionClient(rt as any); const res = await c.getSessionPlan({ sessionId: 's1', cwd: '/my/cwd' }); expect(res.exists).toBe(true); @@ -46,8 +41,5 @@ describe('getSessionPlan: http + direct both implement', () => { } 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 deleted file mode 100644 index 24c8ca1a..00000000 --- a/packages/codingcode/test/client/http-direct-parity.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -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(new URL('../../src/client/http.ts', import.meta.url), 'utf8'); - expect(src).toMatch(/sendMessage\(input: string, cwd\?: string\)/); - }); - - it('direct agent-runtime.ts exports AgentRuntimeClient with sendMessage', () => { - const src = readFileSync(new URL('../../src/direct/agent-runtime.ts', import.meta.url), 'utf8'); - expect(src).toMatch(/sendMessage\(input,/); - }); - - it('direct agent-runtime.ts no longer uses targetCwd rename', () => { - const src = readFileSync(new URL('../../src/direct/agent-runtime.ts', import.meta.url), 'utf8'); - expect(src).not.toMatch(/targetCwd/); - }); -}); diff --git a/packages/codingcode/test/client/http-session-contracts.test.ts b/packages/codingcode/test/client/http-session-contracts.test.ts new file mode 100644 index 00000000..279405f9 --- /dev/null +++ b/packages/codingcode/test/client/http-session-contracts.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { createHttpSessionClient } from '../../src/client/http/sessions.js'; +import { createRequestHelpers } from '../../src/client/http/request.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function stubFetch(payload: unknown) { + const calls: Array<[string, RequestInit | undefined]> = []; + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + calls.push([url, init]); + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + return calls; +} + +describe('http session client 与 contracts 对齐', () => { + it('getSessionHistory 返回 UITurn[]', async () => { + const turns = [{ id: '1', items: [], status: 'completed' }]; + stubFetch(turns); + const client = createHttpSessionClient(createRequestHelpers('http://localhost:1')); + + const result = await client.getSessionHistory({ sessionId: 's1', cwd: '/tmp/p' }); + + expect(result).toEqual(turns); + }); + + it('resumeSession 走 resume 端点', async () => { + stubFetch([{ id: '2', items: [], status: 'completed' }]); + const client = createHttpSessionClient(createRequestHelpers('http://localhost:1')); + + const result = await client.resumeSession({ sessionId: 's1', cwd: '/tmp/p' }); + + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe('2'); + }); + + it('revertCheckpointFiles 解包 server 的 { ok, result } 信封', async () => { + const codeResult = { + reverted: true, + throughTurnId: 3, + affectedTurns: [3], + selectedFiles: ['a.ts'], + }; + stubFetch({ ok: true, result: codeResult }); + const client = createHttpSessionClient(createRequestHelpers('http://localhost:1')); + + const result = await client.revertCheckpointFiles({ + sessionId: 's1', + cwd: '/tmp/p', + files: ['a.ts'], + }); + + expect(result).toEqual(codeResult); + }); + + it('rollbackCodeToTurn 解包 server 的 { ok, result } 信封', async () => { + const codeResult = { + reverted: true, + throughTurnId: 2, + affectedTurns: [2], + selectedFiles: [], + }; + stubFetch({ ok: true, result: codeResult }); + const client = createHttpSessionClient(createRequestHelpers('http://localhost:1')); + + const result = await client.rollbackCodeToTurn({ + sessionId: 's1', + cwd: '/tmp/p', + throughTurnId: 2, + }); + + expect(result).toEqual(codeResult); + }); + + it('getCheckpointDiff 把 turnId 编进路径,缺省用 latest', async () => { + const calls = stubFetch({ turnId: 0, files: [] }); + const client = createHttpSessionClient(createRequestHelpers('http://localhost:1')); + + await client.getCheckpointDiff({ sessionId: 's1', cwd: '/tmp/p' }); + expect(calls[0]?.[0]).toContain('/checkpoints/latest/diff'); + + await client.getCheckpointDiff({ sessionId: 's1', cwd: '/tmp/p', turnId: 7 }); + expect(calls[1]?.[0]).toContain('/checkpoints/7/diff'); + }); + + it('forkSession 把 cwd 与 atTurnId 放进请求体', async () => { + const calls = stubFetch({ sessionId: 'new', turns: [] }); + const client = createHttpSessionClient(createRequestHelpers('http://localhost:1')); + + await client.forkSession({ sessionId: 's1', cwd: '/tmp/p', atTurnId: 4 }); + + const body = JSON.parse(String(calls[0]?.[1]?.body)); + expect(body).toEqual({ cwd: '/tmp/p', atTurnId: 4 }); + }); +}); diff --git a/packages/codingcode/test/client/http/agent-runtime.test.ts b/packages/codingcode/test/client/http/agent-runtime.test.ts index eee85924..4a068499 100644 --- a/packages/codingcode/test/client/http/agent-runtime.test.ts +++ b/packages/codingcode/test/client/http/agent-runtime.test.ts @@ -1,13 +1,14 @@ import { describe, it, expect, vi } from 'vitest'; import { createHttpAgentClient } from '../../../src/client/http/agent-runtime.js'; import { createRequestHelpers } from '../../../src/client/http/request.js'; +import type { Frame } from '../../../src/core/frame.js'; -function createSseResponse(lines: string[]) { +function createSseResponse(lines: unknown[]) { const encoder = new TextEncoder(); const body = new ReadableStream({ start(controller) { for (const line of lines) { - controller.enqueue(encoder.encode(`data: ${line}\n\n`)); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(line)}\n\n`)); } controller.close(); }, @@ -17,41 +18,51 @@ function createSseResponse(lines: string[]) { }); } +const ENVELOPE = { sessionId: 'sess-123', turnId: 42 }; + +const STREAM: Frame[] = [ + { + ...ENVELOPE, + seq: 1, + family: 'transition', + transition: { to: 'start', turnId: 42 }, + }, + { ...ENVELOPE, seq: 2, family: 'transition', transition: { to: 'executing' } }, + { ...ENVELOPE, seq: 3, family: 'event', event: { type: 'text_delta', text: 'hello' } }, + { + ...ENVELOPE, + seq: 4, + family: 'event', + event: { type: 'tool_call', id: 'tc-1', name: 'bash', args: { command: 'ls' } }, + }, + { + ...ENVELOPE, + seq: 5, + family: 'transition', + transition: { to: 'executing', responded: { usage: { prompt: 1, completion: 1, total: 2 } } }, + }, + { + ...ENVELOPE, + seq: 6, + family: 'event', + event: { type: 'tool_result', id: 'tc-1', name: 'bash', outcome: { status: 'ok', output: 'file.txt' } }, + }, + { ...ENVELOPE, seq: 7, family: 'transition', transition: { to: 'end', reason: 'done' } }, +]; + describe('createHttpAgentClient.sendMessage', () => { - it('parses session_id, text, tool_start, tool_result, turn_id events', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - createSseResponse([ - JSON.stringify({ type: 'session_id', sessionId: 'sess-123' }), - JSON.stringify({ type: 'turn_id', turnId: 42 }), - JSON.stringify({ type: 'text', text: 'hello', messageId: 1 }), - JSON.stringify({ type: 'tool_start', id: 'tc-1', name: 'bash', args: { command: 'ls' } }), - JSON.stringify({ - type: 'tool_result', - id: 'tc-1', - name: 'bash', - output: 'file.txt', - ok: true, - }), - JSON.stringify({ type: 'done' }), - JSON.stringify({ type: 'complete' }), - ]) - ); + it('decodes frame envelopes from the SSE stream', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(createSseResponse(STREAM)); const request = createRequestHelpers('http://localhost:8080'); const client = createHttpAgentClient('http://localhost:8080', request); - const chunks: any[] = []; - for await (const chunk of client.sendMessage('hi', { sessionId: 'sess-123', cwd: '/tmp' })) { - chunks.push(chunk); + const frames: Frame[] = []; + for await (const frame of client.sendMessage('hi', { sessionId: 'sess-123', cwd: '/tmp' })) { + frames.push(frame); } - expect(chunks).toEqual([ - { type: 'session_id', sessionId: 'sess-123' }, - { type: 'turn_id', turnId: 42 }, - { type: 'text', text: 'hello', messageId: 1 }, - { type: 'tool_start', id: 'tc-1', name: 'bash', args: { command: 'ls' } }, - { type: 'tool_result', id: 'tc-1', name: 'bash', output: 'file.txt', ok: true }, - ]); + expect(frames).toEqual(STREAM); expect(fetchSpy).toHaveBeenCalledWith( 'http://localhost:8080/api/sessions/sess-123/messages', @@ -64,25 +75,45 @@ describe('createHttpAgentClient.sendMessage', () => { fetchSpy.mockRestore(); }); + it('drops malformed frames but keeps the valid ones', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + createSseResponse([ + { type: 'complete' }, // legacy / unknown shape → dropped + { sessionId: 'sess-123', turnId: 1, seq: 1, family: 'transition', transition: { to: 'end', reason: 'done' } }, + ]) + ); + + const request = createRequestHelpers('http://localhost:8080'); + const client = createHttpAgentClient('http://localhost:8080', request); + + const frames: Frame[] = []; + for await (const frame of client.sendMessage('hi', { sessionId: 'sess-123', cwd: '/tmp' })) { + frames.push(frame); + } + + expect(frames).toHaveLength(1); + expect(frames[0]!.family).toBe('transition'); + + fetchSpy.mockRestore(); + }); + it('uses "_" placeholder when sessionId is undefined', async () => { - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue( - createSseResponse([ - JSON.stringify({ type: 'session_id', sessionId: 'new-sess' }), - JSON.stringify({ type: 'complete' }), - ]) - ); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + createSseResponse([ + { sessionId: 'new-sess', turnId: null, seq: 1, family: 'fatal', fatal: { message: 'boom', code: 'X' } }, + ]) + ); const request = createRequestHelpers('http://localhost:8080'); const client = createHttpAgentClient('http://localhost:8080', request); - const chunks: any[] = []; - for await (const chunk of client.sendMessage('hi', { cwd: '/tmp' })) { - chunks.push(chunk); + const frames: Frame[] = []; + for await (const frame of client.sendMessage('hi', { cwd: '/tmp' })) { + frames.push(frame); } - expect(chunks).toEqual([{ type: 'session_id', sessionId: 'new-sess' }]); + expect(frames).toHaveLength(1); + expect(frames[0]!.sessionId).toBe('new-sess'); expect(fetchSpy).toHaveBeenCalledWith( 'http://localhost:8080/api/sessions/_/messages', expect.any(Object) @@ -91,23 +122,32 @@ describe('createHttpAgentClient.sendMessage', () => { fetchSpy.mockRestore(); }); - it('yields error event instead of throwing', async () => { - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue( - createSseResponse([ - JSON.stringify({ type: 'error', message: 'something broke', code: 'LLM_FAILED' }), - ]) - ); + it('yields a fatal frame instead of throwing', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + createSseResponse([ + { + sessionId: 's', + turnId: null, + seq: 1, + family: 'fatal', + fatal: { message: 'something broke', code: 'LLM_FAILED' }, + }, + ]) + ); const request = createRequestHelpers('http://localhost:8080'); const client = createHttpAgentClient('http://localhost:8080', request); - const chunks: Array<{ type: string }> = []; + const frames: Frame[] = []; for await (const c of client.sendMessage('hi', { sessionId: 's', cwd: '/tmp' })) { - chunks.push(c); + frames.push(c); + } + + expect(frames).toHaveLength(1); + expect(frames[0]!.family).toBe('fatal'); + if (frames[0]!.family === 'fatal') { + expect(frames[0]!.fatal).toEqual({ message: 'something broke', code: 'LLM_FAILED' }); } - expect(chunks).toEqual([{ type: 'error', message: 'something broke', code: 'LLM_FAILED' }]); fetchSpy.mockRestore(); }); diff --git a/packages/codingcode/test/client/missing-methods.test.ts b/packages/codingcode/test/client/missing-methods.test.ts deleted file mode 100644 index 01d138aa..00000000 --- a/packages/codingcode/test/client/missing-methods.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -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, '']), - 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 as Layer.Layer< - SkillService | HookService | ApprovalService | McpService | ApprovalWaitService | MemoryService - > -); - -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: string, b?: unknown) => { - calls.push({ path: p, body: b }); - return { model: (b as { model: string }).model } as T; - }, - 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: string) => { - calls.push(p); - return { maxSteps: 100, maxStopContinuations: 3 } as T; - }, - 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: string, b?: unknown) => { - calls.push({ path: p, body: b }); - return { compactionModel: (b as { compactionModel: string }).compactionModel } as T; - }, - 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' }) as T, - 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/append-turn-end.test.ts b/packages/codingcode/test/context/append-turn-end.test.ts index a206702f..008bd59c 100644 --- a/packages/codingcode/test/context/append-turn-end.test.ts +++ b/packages/codingcode/test/context/append-turn-end.test.ts @@ -15,8 +15,6 @@ vi.mock('@codingcode/infra/config', () => ({ model: '', maxBytes: 16384, promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], }, server: { port: 8080 }, }), diff --git a/packages/codingcode/test/context/budget-integration.test.ts b/packages/codingcode/test/context/budget-integration.test.ts index 3306ee5e..2713c053 100644 --- a/packages/codingcode/test/context/budget-integration.test.ts +++ b/packages/codingcode/test/context/budget-integration.test.ts @@ -3,16 +3,19 @@ import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect, Layer } from 'effect'; -import { ContextService } from '../../src/context/service.js'; -import { SessionService } from '../../src/session/store.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; +import { ContextService } from '../../src/context/port.js'; +import type { ContextShape } from '../../src/context/port.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; +import { LLMFactoryService } from '../../src/llm/port.js'; import type { SessionEvent } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; +import { ContextLayer } from '../../src/context/context.js'; const base = useTempProjectBase(); const TestLayer = Layer.merge( - SessionService.Default, + SessionLayer, Layer.succeed(LLMFactoryService, { listModels: () => Effect.succeed([]), findModel: () => Effect.succeed(null), @@ -23,11 +26,11 @@ const TestLayer = Layer.merge( } as any) ); -async function getCtxService(): Promise { +async function getCtxService(): Promise { return Effect.runPromise( Effect.gen(function* () { return yield* ContextService; - }).pipe(Effect.provide(ContextService.Default), Effect.provide(TestLayer)) + }).pipe(Effect.provide(ContextLayer), Effect.provide(TestLayer)) ); } @@ -103,19 +106,18 @@ describe('assemblePayload integration', () => { if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); }); - it('returns messages and compactedEvents', async () => { + it('returns messages assembled from the transcript', async () => { const ctx = await getCtxService(); - const result = ctx.assemblePayload(jsonlPath, 128000); + const messages = await ctx.assemblePayload(jsonlPath, 128000, null); - expect(result.messages.length).toBeGreaterThan(0); - expect(Array.isArray(result.compactedEvents)).toBe(true); - expect(result.currentTurnId).toBe(1); - expect(result.promptEstimate).toBeGreaterThan(0); + expect(messages.length).toBeGreaterThan(0); }); - it('returns currentTurnId from session index', async () => { + it('returns an empty message list when the transcript is empty', async () => { + const emptyJsonl = join(sessionDir, `${sessionId}-empty.jsonl`); + writeFileSync(emptyJsonl, '', 'utf8'); const ctx = await getCtxService(); - const result = ctx.assemblePayload(jsonlPath, 128000); - expect(result.currentTurnId).toBe(1); + const messages = await ctx.assemblePayload(emptyJsonl, 128000, null); + expect(messages).toEqual([]); }); }); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index e9026df5..2336f209 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -3,16 +3,18 @@ import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect, Layer } from 'effect'; -import { ContextService } from '../../../src/context/service.js'; -import { SessionService } from '../../../src/session/store.js'; -import { LLMFactoryService } from '../../../src/llm/factory.js'; +import { ContextService } from '../../../src/context/port.js'; +import type { ContextShape } from '../../../src/context/port.js'; +import { SessionService } from '../../../src/session/port.js'; +import { SessionLayer } from '../../../src/session/session.js'; +import { LLMFactoryService } from '../../../src/llm/port.js'; import type { LLMClient } from '../../../src/llm/client.js'; -import { Result } from '../../../src/core/result.js'; import type { SessionIndex, SessionEvent, SummaryEvent } from '../../../src/session/types.js'; -import { filterForContext, buildContextMessages } from '../../../src/context/service.js'; +import { filterForContext, buildContextMessages } from '../../../src/context/context.js'; import { readHistory } from '../../../src/session/file-ops.js'; import { estimateTokens } from '../../../src/core/util.js'; import { useTempProjectBase } from '../../helpers/project-base.js'; +import { ContextLayer } from '../../../src/context/context.js'; const base = useTempProjectBase(); @@ -98,13 +100,12 @@ function readSummaryEvents(jsonlPath: string): SummaryEvent[] { function makeMockLLM(content: string): LLMClient { return { - complete: () => Effect.succeed({ content, finishReason: 'stop' as const }), - completeStream: () => ({ - stream: (async function* () { - yield content; + complete: () => Effect.succeed({ content }), + completeStream: () => + (async function* () { + yield { type: 'text' as const, text: content }; + yield { type: 'end' as const }; })(), - response: Promise.resolve(Result.ok({ content, finishReason: 'stop' as const })), - }), modelInfo: { provider: 'mock', model: 'mock', @@ -116,7 +117,7 @@ function makeMockLLM(content: string): LLMClient { } const TestLayer = Layer.merge( - SessionService.Default, + SessionLayer, Layer.succeed(LLMFactoryService, { listModels: () => Effect.succeed([]), findModel: () => Effect.succeed(null), @@ -127,11 +128,11 @@ const TestLayer = Layer.merge( } as any) ); -async function getCtxService(): Promise { +async function getCtxService(): Promise { return Effect.runPromise( Effect.gen(function* () { return yield* ContextService; - }).pipe(Effect.provide(ContextService.Default), Effect.provide(TestLayer)) + }).pipe(Effect.provide(ContextLayer), Effect.provide(TestLayer)) ); } @@ -161,7 +162,6 @@ describe('compressor behavior', () => { const ctx = await getCtxService(); const result = await ctx.compactWithLLM(fx.transcriptPath, 1000, null); expect(result.didCompress).toBe(false); - expect(result.messages).toBeUndefined(); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries).toHaveLength(0); } finally { @@ -207,8 +207,38 @@ describe('compressor behavior', () => { expect(result.promptEstimate).toBeGreaterThan(0); expect(result.promptEstimate).toBeLessThan(before); expect(result.released).toBeGreaterThan(0); - expect(result.messages).toBeDefined(); - expect(result.messages!.length).toBeGreaterThan(0); + } finally { + cleanup(fx.slug); + } + }); + }); + + describe('assemblePayload compaction', () => { + const SUMMARY = + '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne'; + + it('folds history into a compacted summary message when it exceeds the window', async () => { + const fx = makeFixture({ numTurns: 3, toolContentSize: 8000 }); + try { + const ctx = await getCtxService(); + const messages = await ctx.assemblePayload(fx.transcriptPath, 1000, makeMockLLM(SUMMARY)); + expect(messages.length).toBeGreaterThan(0); + expect(messages.some((m) => m.name === 'compacted_history')).toBe(true); + } finally { + cleanup(fx.slug); + } + }); + + it('leaves history uncompacted when it fits the window', async () => { + const fx = makeFixture({ numTurns: 2, toolContentSize: 20 }); + try { + const ctx = await getCtxService(); + const messages = await ctx.assemblePayload( + fx.transcriptPath, + 2_000_000, + makeMockLLM(SUMMARY) + ); + expect(messages.some((m) => m.name === 'compacted_history')).toBe(false); } finally { cleanup(fx.slug); } diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts deleted file mode 100644 index 96bc3382..00000000 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Effect, Layer } from 'effect'; -import { ContextService } from '../../../src/context/service.js'; -import { SessionService } from '../../../src/session/store.js'; -import { LLMFactoryService } from '../../../src/llm/factory.js'; -import { useTempProjectBase } from '../../helpers/project-base.js'; - -useTempProjectBase(); - -const { mockLLM } = vi.hoisted(() => ({ - mockLLM: { - complete: vi.fn(() => Effect.succeed({ content: 'compacted' })), - completeStream: () => ({ - stream: (async function* () {})(), - response: Promise.resolve({ - ok: true as const, - value: { content: 'compacted' }, - }), - }), - modelInfo: { - provider: 'mock', - model: 'mock', - maxTokens: 100000, - supportsToolCalling: false, - supportsStreaming: true, - }, - }, -})); - -vi.mock('../../../src/session/file-ops.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...(actual as any), - readHistory: vi.fn(() => [ - { type: 'user', content: 'a'.repeat(200), turnId: 1 }, - { type: 'assistant', content: 'b'.repeat(200), turnId: 1 }, - ]), - }; -}); - -vi.mock('../../../src/llm/llm-resolver.js', async (importOriginal) => { - const actual: any = await importOriginal(); - return { - ...actual, - resolveLLM: vi.fn(() => Effect.succeed(mockLLM)), - }; -}); - -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...(actual as any), - appendFileSync: vi.fn(), - writeFileSync: vi.fn(), - existsSync: vi.fn((p: string) => { - if (p.endsWith('.index.json') || p.endsWith('.jsonl')) return true; - return (actual as any).existsSync(p); - }), - readFileSync: vi.fn((p: string, encoding: BufferEncoding) => { - if (p.endsWith('.index.json')) - return JSON.stringify({ currentTurnId: p.includes('ttl-session') ? 0 : 10 }); - return (actual as any).readFileSync(p, encoding); - }), - }; -}); - -vi.mock('../../../src/core/util.js', () => ({ - estimateTokens: vi.fn(), - estimateMessageTokens: vi.fn(), - estimateTokensForContent: vi.fn(), -})); - -import { estimateTokens, estimateMessageTokens } from '../../../src/core/util.js'; - -const TestLayer = Layer.merge( - SessionService.Default, - Layer.succeed(LLMFactoryService, { - listModels: () => Effect.succeed([]), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new Error('no active model')), - switchModel: () => Effect.fail(new Error('no models')), - createClient: () => Effect.fail(new Error('no client')), - getLLMClient: () => Effect.fail(new Error('no client')), - } as any) -); - -async function getCtxService(): Promise { - return Effect.runPromise( - Effect.gen(function* () { - return yield* ContextService; - }).pipe(Effect.provide(ContextService.Default), Effect.provide(TestLayer)) - ); -} - -describe('compactIfNeeded', () => { - beforeEach(() => { - (estimateTokens as any).mockReturnValue(0); - (estimateMessageTokens as any).mockReturnValue(50); - }); - - it('returns didCompress=false when promptEstimate is below threshold', async () => { - (estimateTokens as any).mockReturnValue(100); - const ctx = await getCtxService(); - const result = await ctx.compactIfNeeded('/tmp/s1.jsonl', [], 10000, null); - expect(result.didCompress).toBe(false); - expect(result.released).toBe(0); - expect(result.promptEstimate).toBe(100); - }); - - it('returns didCompress=false when promptEstimate equals threshold', async () => { - (estimateTokens as any).mockReturnValue(5000); - const ctx = await getCtxService(); - const result = await ctx.compactIfNeeded('/tmp/s1.jsonl', [], 10000, null); - expect(result.didCompress).toBe(false); - expect(result.released).toBe(0); - }); - - it('returns didCompress=true when promptEstimate exceeds threshold', async () => { - (estimateTokens as any).mockReturnValue(10000); - (estimateMessageTokens as any).mockReturnValue(50); - const ctx = await getCtxService(); - const result = await ctx.compactIfNeeded( - '/tmp/s1.jsonl', - [ - { type: 'user', content: 'a'.repeat(200), turnId: 1 }, - { type: 'assistant', content: 'b'.repeat(200), turnId: 1 }, - { - type: 'tool_result', - output: 'c'.repeat(5000), - turnId: 1, - toolName: 'read_file', - toolCallId: 'tc1', - }, - ] as any, - 10000, - null - ); - expect(result.didCompress).toBe(true); - expect(result.released).toBeGreaterThan(0); - expect(result.promptEstimate).toBeGreaterThanOrEqual(0); - }); - - it('does not return restoredFiles field (removed)', async () => { - (estimateTokens as any).mockReturnValue(10000); - const ctx = await getCtxService(); - const result = await ctx.compactIfNeeded('/tmp/s1.jsonl', [], 10000, null); - expect('restoredFiles' in result).toBe(false); - }); -}); diff --git a/packages/codingcode/test/context/compressor/llm-resolver.test.ts b/packages/codingcode/test/context/compressor/llm-resolver.test.ts index 13083484..302c44a6 100644 --- a/packages/codingcode/test/context/compressor/llm-resolver.test.ts +++ b/packages/codingcode/test/context/compressor/llm-resolver.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { Effect } from 'effect'; -import { LLMFactoryService } from '../../../src/llm/factory.js'; +import { LLMFactoryService } from '../../../src/llm/port.js'; import { AgentError } from '../../../src/core/error.js'; import type { LLMClient } from '../../../src/llm/client.js'; -import type { SelectableModel } from '../../../src/llm/factory.js'; +import type { SelectableModel } from '../../../src/llm/port.js'; const { mockFindModel, mockCreateClient } = vi.hoisted(() => ({ mockFindModel: vi.fn(() => Effect.succeed(null)), @@ -22,11 +22,11 @@ const mockFactory = { import { resolveLLM } from '../../../src/llm/llm-resolver.js'; const fakeFallback: LLMClient = { - complete: () => Effect.succeed({ content: '', finishReason: 'stop' }), - completeStream: () => ({ - stream: (async function* () {})(), - response: Promise.resolve({ ok: true as const, value: { content: '', finishReason: 'stop' } }), - }), + complete: () => Effect.succeed({ content: '' }), + completeStream: () => + (async function* () { + yield { type: 'end' as const }; + })(), modelInfo: { provider: 'fake', model: 'fake', diff --git a/packages/codingcode/test/context/organizer.test.ts b/packages/codingcode/test/context/organizer.test.ts index deb981d4..54666da3 100644 --- a/packages/codingcode/test/context/organizer.test.ts +++ b/packages/codingcode/test/context/organizer.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from 'vitest'; import { Effect, Layer } from 'effect'; -import { ContextService } from '../../src/context/service.js'; -import { SessionService } from '../../src/session/store.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; +import { ContextService } from '../../src/context/port.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; +import { LLMFactoryService } from '../../src/llm/port.js'; import type { SessionEvent, ToolResultEvent } from '../../src/session/types.js'; +import { ContextLayer } from '../../src/context/context.js'; const baseConfig = { compactionModel: '', @@ -38,7 +40,7 @@ function makeToolResult( } const TestLayer = Layer.merge( - SessionService.Default, + SessionLayer, Layer.succeed(LLMFactoryService, { listModels: () => Effect.succeed([]), findModel: () => Effect.succeed(null), @@ -55,7 +57,7 @@ describe('assemblePayload', () => { Effect.gen(function* () { const ctx = yield* ContextService; return ctx; - }).pipe(Effect.provide(ContextService.Default), Effect.provide(TestLayer)) + }).pipe(Effect.provide(ContextLayer), Effect.provide(TestLayer)) ); expect(typeof svc.assemblePayload).toBe('function'); }); diff --git a/packages/codingcode/test/core/frame-io.test.ts b/packages/codingcode/test/core/frame-io.test.ts new file mode 100644 index 00000000..44c914c2 --- /dev/null +++ b/packages/codingcode/test/core/frame-io.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { createFrameAssembler, decodeFrame, encodeFrame } from '../../src/core/frame-io.js'; +import type { FrameBody } from '../../src/core/frame.js'; + +// ---- body builders ---- + +const start = (turnId: number): FrameBody => ({ + family: 'transition', + transition: { to: 'start', turnId }, +}); +const enterExecuting = (): FrameBody => ({ family: 'transition', transition: { to: 'executing' } }); +const fatal = (): FrameBody => ({ family: 'fatal', fatal: { message: 'x', code: 'Y' } }); +const text = (t = 'hi'): FrameBody => ({ family: 'event', event: { type: 'text_delta', text: t } }); + +// ---- envelope ---- + +describe('createFrameAssembler — envelope', () => { + it('stamps sessionId, a monotonically increasing seq and the current turnId', () => { + const a = createFrameAssembler({ sessionId: 'sess-1' }); + const f1 = a.stamp(start(7)); + const f2 = a.stamp(enterExecuting()); + const f3 = a.stamp(text()); + + expect(f1).toMatchObject({ sessionId: 'sess-1', turnId: 7, seq: 1 }); + expect(f2).toMatchObject({ sessionId: 'sess-1', turnId: 7, seq: 2 }); + expect(f3).toMatchObject({ sessionId: 'sess-1', turnId: 7, seq: 3 }); + }); + + it('keeps turnId null for frames emitted before start', () => { + const a = createFrameAssembler({ sessionId: 's' }); + expect(a.stamp(fatal())).toMatchObject({ turnId: null, seq: 1, family: 'fatal' }); + }); + + it('stamps unconditionally — the assembler enforces no ordering', () => { + const a = createFrameAssembler({ sessionId: 's' }); + expect(a.stamp(text())).toMatchObject({ seq: 1, family: 'event' }); + expect(a.stamp(start(2))).toMatchObject({ seq: 2, turnId: 2 }); + }); +}); + +// ---- codec ---- + +describe('frame codec', () => { + it('round-trips an assembled frame through encode/decode', () => { + const f = createFrameAssembler({ sessionId: 's' }); + const stamped = f.stamp(start(3)); + const decoded = decodeFrame(JSON.parse(encodeFrame(stamped))); + expect(decoded.ok).toBe(true); + if (decoded.ok) expect(decoded.frame).toEqual(stamped); + }); + + it('rejects a non-object payload', () => { + expect(decodeFrame('nope')).toMatchObject({ ok: false, reason: 'shape' }); + }); + + it('rejects a payload missing envelope fields', () => { + expect(decodeFrame({ family: 'event' })).toMatchObject({ ok: false, reason: 'shape' }); + }); + + it('rejects a non-numeric, non-null turnId', () => { + expect( + decodeFrame({ sessionId: 's', turnId: 'x', seq: 1, family: 'fatal', fatal: { message: 'm', code: 'c' } }) + ).toMatchObject({ ok: false, reason: 'shape' }); + }); + + it('rejects an unknown family', () => { + expect(decodeFrame({ sessionId: 's', turnId: null, seq: 1, family: 'nope' })).toMatchObject({ + ok: false, + reason: 'unknown-family', + }); + }); + + it('rejects an unknown transition target', () => { + expect( + decodeFrame({ sessionId: 's', turnId: 1, seq: 1, family: 'transition', transition: { to: 'zzz' } }) + ).toMatchObject({ ok: false, reason: 'unknown-transition' }); + }); + + it('accepts a compress frame', () => { + const decoded = decodeFrame({ + sessionId: 's', + turnId: 1, + seq: 1, + family: 'transition', + transition: { to: 'compress' }, + }); + expect(decoded.ok).toBe(true); + }); + + it('rejects an unknown event type', () => { + expect( + decodeFrame({ sessionId: 's', turnId: 1, seq: 1, family: 'event', event: { type: 'zzz' } }) + ).toMatchObject({ ok: false, reason: 'unknown-event' }); + }); + + it('rejects an end(error) frame without a well-formed error', () => { + expect( + decodeFrame({ + sessionId: 's', + turnId: 1, + seq: 1, + family: 'transition', + transition: { to: 'end', reason: 'error' }, + }) + ).toMatchObject({ ok: false, reason: 'shape' }); + }); + + it('rejects a tool_result with a malformed outcome', () => { + expect( + decodeFrame({ + sessionId: 's', + turnId: 1, + seq: 1, + family: 'event', + event: { type: 'tool_result', id: 't1', name: 'bash', outcome: { status: 'ok' } }, + }) + ).toMatchObject({ ok: false, reason: 'shape' }); + }); + + it('accepts a denied outcome carrying a reason', () => { + const decoded = decodeFrame({ + sessionId: 's', + turnId: 1, + seq: 1, + family: 'event', + event: { type: 'tool_result', id: 't1', name: 'bash', outcome: { status: 'denied', reason: 'no' } }, + }); + expect(decoded.ok).toBe(true); + }); +}); diff --git a/packages/codingcode/test/core/frame-protocol.test.ts b/packages/codingcode/test/core/frame-protocol.test.ts new file mode 100644 index 00000000..eb37cfff --- /dev/null +++ b/packages/codingcode/test/core/frame-protocol.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { isTurnEnd } from '../../src/core/frame.js'; +import type { Frame, FrameBody } from '../../src/core/frame.js'; + +const turnId = 1; + +describe('isTurnEnd', () => { + it('recognizes every end reason', () => { + const reasons: Array = [ + { family: 'transition', transition: { to: 'end', reason: 'done' } }, + { family: 'transition', transition: { to: 'end', reason: 'maxSteps' } }, + { family: 'transition', transition: { to: 'end', reason: 'aborted' } }, + { + family: 'transition', + transition: { to: 'end', reason: 'error', error: { message: 'boom', code: 'X' } }, + }, + ]; + for (const body of reasons) expect(isTurnEnd(body)).toBe(true); + }); + + it('rejects non-end frames', () => { + const others: FrameBody[] = [ + { family: 'transition', transition: { to: 'start', turnId } }, + { family: 'transition', transition: { to: 'executing' } }, + { family: 'transition', transition: { to: 'compress' } }, + { family: 'event', event: { type: 'text_delta', text: 'hi' } }, + { family: 'fatal', fatal: { message: 'x', code: 'Y' } }, + ]; + for (const body of others) expect(isTurnEnd(body)).toBe(false); + }); + + it('narrows to the end transition so reason/error are readable', () => { + const body: FrameBody = { + family: 'transition', + transition: { to: 'end', reason: 'error', error: { message: 'boom', code: 'LLM_FAILED' } }, + }; + if (isTurnEnd(body) && body.transition.reason === 'error') { + expect(body.transition.error.code).toBe('LLM_FAILED'); + } else { + throw new Error('isTurnEnd failed to narrow'); + } + }); +}); + +describe('Frame envelope composition', () => { + it('is an Envelope intersected with a FrameBody', () => { + const frame: Frame = { + sessionId: 'sess-1', + turnId, + seq: 3, + family: 'event', + event: { type: 'text_delta', text: 'hi' }, + }; + expect(frame.sessionId).toBe('sess-1'); + expect(frame.seq).toBe(3); + expect(frame.family).toBe('event'); + }); + + it('allows a pre-start fatal frame to carry a null turnId', () => { + const frame: Frame = { + sessionId: 'sess-1', + turnId: null, + seq: 1, + family: 'fatal', + fatal: { message: 'transport down', code: 'TRANSPORT' }, + }; + expect(frame.turnId).toBeNull(); + }); +}); diff --git a/packages/codingcode/test/core/paths.test.ts b/packages/codingcode/test/core/paths.test.ts deleted file mode 100644 index c49e2344..00000000 --- a/packages/codingcode/test/core/paths.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { computePaths, projectSessionsDir, sessionJsonlPathFromCwd } from '../../src/core/path.js'; - -describe('core/path.ts contains path computation functions', () => { - it('does not import from session/types — no core→session dependency', () => { - const src = readFileSync(new URL('../../src/core/path.ts', import.meta.url), '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(new URL('../../src/session/file-ops.ts', import.meta.url), 'utf8'); - expect(src).not.toMatch(/export function computePaths\s*\(/); - expect(src).not.toMatch(/export function projectSessionsDir\s*\(/); - expect(src).toMatch(/from\s+['"]\.\.\/core\/path\.js['"]/); - }); -}); diff --git a/packages/codingcode/test/helpers/agent-harness.ts b/packages/codingcode/test/helpers/agent-harness.ts new file mode 100644 index 00000000..69fb813a --- /dev/null +++ b/packages/codingcode/test/helpers/agent-harness.ts @@ -0,0 +1,371 @@ +// Agent 循环测试基座:通过公开的 AgentService.runTurn 驱动 agent, +// 替代已删除的 agentLoop 自由函数。所有 agent 内部服务均以窄端口 mock 注入。 +// +// 自 frame 协议重构后,runTurn 产出 FrameBody(信封由装配器另盖), +// 本文件同时提供从 FrameBody[] 中抽取内容的纯函数,供各测试断言使用。 +import { Effect, Layer } from 'effect'; +import { AgentLayer } from '../../src/agent/agent.js'; +import { ToolEnvLayer } from '../../src/agent/tool-env.js'; +import { ToolCatalogLayer } from '../../src/agent/tool-catalog.js'; +import { AgentService } from '../../src/agent/port.js'; +import { + ApprovalPort, + CheckpointPort, + ContextPort, + HookPort, + LlmPort, + McpPort, + MemoryPort, + RulesPort, + SessionPort, + SkillPort, + TodoPort, + ToolExecutorPort, +} from '../../src/agent/deps.js'; +import { HookService } from '../../src/hooks/port.js'; +import { McpService } from '../../src/mcp/port.js'; +import { SubagentRunnerService } from '../../src/subagent/port.js'; +import { TodoService } from '../../src/todo/port.js'; +import type { FrameBody, RuntimeEvent, Transition } from '../../src/core/frame.js'; +import type { TokenUsage } from '../../src/core/types.js'; +import type { LLMStreamPart } from '../../src/llm/types.js'; +import type { SessionStoreState } from '../../src/session/types.js'; + +// ---- LLM 部件构造器 ---- + +export function llmStream(...parts: LLMStreamPart[]): AsyncIterable { + return (async function* () { + for (const p of parts) yield p; + })(); +} + +export function pText(text: string): LLMStreamPart { + return { type: 'text', text }; +} + +export function pToolCall( + id: string, + name: string, + args: Record = {} +): LLMStreamPart { + return { type: 'tool_call', id, name, args }; +} + +export function pEnd(usage?: TokenUsage): LLMStreamPart { + return usage ? { type: 'end', usage } : { type: 'end' }; +} + +// ---- FrameBody 抽取器 ---- + +export type EventOf = Extract; +export type TransitionOf = Extract; + +function eventOf( + events: readonly FrameBody[], + type: T +): EventOf[] { + const out: EventOf[] = []; + for (const b of events) { + if (b.family === 'event' && b.event.type === type) out.push(b.event as EventOf); + } + return out; +} + +export function textDeltas(events: readonly FrameBody[]): EventOf<'text_delta'>[] { + return eventOf(events, 'text_delta'); +} + +export function texts(events: readonly FrameBody[]): string[] { + return textDeltas(events).map((e) => e.text); +} + +export function toolCalls(events: readonly FrameBody[]): EventOf<'tool_call'>[] { + return eventOf(events, 'tool_call'); +} + +export function approvalRequests(events: readonly FrameBody[]): EventOf<'approval_request'>[] { + return eventOf(events, 'approval_request'); +} + +export function toolResults(events: readonly FrameBody[]): EventOf<'tool_result'>[] { + return eventOf(events, 'tool_result'); +} + +/** 含 todos 的 tool_result(todo_write 回执) */ +export function todoResults(events: readonly FrameBody[]): EventOf<'tool_result'>[] { + return toolResults(events).filter((e) => e.todos !== undefined); +} + +export function endOf(events: readonly FrameBody[]): TransitionOf<'end'> | undefined { + for (const b of events) { + if (b.family === 'transition' && b.transition.to === 'end') return b.transition; + } + return undefined; +} + +export function hasEnd(events: readonly FrameBody[]): boolean { + return endOf(events) !== undefined; +} + +export function endReason(events: readonly FrameBody[]): TransitionOf<'end'>['reason'] | undefined { + return endOf(events)?.reason; +} + +export function fatalOf(events: readonly FrameBody[]): { message: string; code: string } | undefined { + for (const b of events) { + if (b.family === 'fatal') return b.fatal; + } + return undefined; +} + +/** 本次流中是否发出了压缩信号(compress 转移) */ +export function hasCompress(events: readonly FrameBody[]): boolean { + return events.some((b) => b.family === 'transition' && b.transition.to === 'compress'); +} + +// ---- 依赖 mock ---- + +export interface HarnessMocks { + llm: { + completeStream: (params: any, signal?: AbortSignal) => AsyncIterable; + modelInfo: { maxTokens: number }; + }; + state?: Partial; + hooks?: { + emit: (point: string, payload: any) => Effect.Effect; + emitDecision: (point: string, payload: any) => Effect.Effect; + }; + executor?: { + executeBatch: (calls: any[], sessionId?: string, opts?: any) => Effect.Effect; + }; + todo?: Map>; + memorySnapshot?: string; + /** 可选:覆盖 ContextPort.assemblePayload 的返回(默认一条 user 消息)。 */ + contextAssemble?: () => Promise>; + /** 可选:覆盖 ContextPort.willCompact(默认 false)。 */ + contextWillCompact?: () => Promise; + /** 可选:覆盖 SessionPort 窄端口的个别方法(默认实现见 makeAgentLayer)。 */ + sessionPort?: Partial<{ + load: (cwd: string, sid: string) => any; + create: (cwd: string, opts: any, extra?: any) => any; + recordUser: (state: any, content: string) => any; + recordSystem: (state: any, content: string) => any; + recordAssistant: (state: any, content: string, toolCalls: any[], usage?: any) => any; + recordToolResult: (state: any, name: string, id: string, output: string) => any; + setPermissionMode: (cwd: string, sid: string, mode: any) => any; + setActiveProfile: (cwd: string, sid: string, profile: any) => any; + }>; +} + +export function makeState(partial: Partial = {}): SessionStoreState { + return { + sessionId: 'test-sid', + cwd: '/tmp', + messageCount: 0, + sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, + model: 'test-model', + title: 'test', + currentTurnId: 1, + usage: undefined, + activeProfile: 'build', + permissionMode: 'default', + memorySnapshot: '', + ...partial, + } as SessionStoreState; +} + +export function makeDefaultMocks(overrides: Partial = {}): HarnessMocks { + const llm = + overrides.llm ?? + ({ + completeStream: () => llmStream(), + modelInfo: { maxTokens: 1000 }, + } as any); + const todo = overrides.todo ?? new Map>(); + const hooks = overrides.hooks ?? { + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + }; + return { + llm, + state: overrides.state, + hooks, + executor: overrides.executor, + todo, + memorySnapshot: overrides.memorySnapshot ?? '', + sessionPort: overrides.sessionPort, + }; +} + +export interface RunAgentOptions { + input?: string; + sessionId?: string; + cwd?: string; + signal?: AbortSignal; + activeProfile?: 'plan' | 'build'; + permissionMode?: string; +} + +export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { + const state = makeState(mocks.state); + const store = mocks.todo ?? new Map>(); + const hooks = mocks.hooks ?? { + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + }; + const executor = + mocks.executor ?? + ({ + executeBatch: (calls: any[]) => + Effect.succeed( + calls.map((c: any) => ({ + type: 'ok' as const, + id: c.id, + name: c.name, + output: '', + })) + ), + } as any); + + const session: Record = { + load: (_cwd: string, sid: string) => Effect.succeed({ ...state, sessionId: sid }), + create: (_cwd: string, opts: any) => + Effect.succeed({ + ...state, + sessionId: opts.sessionId ?? 'created-sid', + activeProfile: opts.activeProfile ?? 'build', + }), + recordUser: () => Effect.succeed({}), + recordSystem: () => Effect.succeed({}), + recordAssistant: () => Effect.succeed({}), + recordToolResult: () => Effect.succeed({}), + setPermissionMode: () => Effect.void, + setActiveProfile: () => Effect.void, + ...(mocks.sessionPort ?? {}), + }; + + const mcpPort = { + syncConnections: () => Effect.void, + listProjectMcpTools: () => [], + }; + const skills = { + extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), + }; + const context = { + willCompact: async () => (mocks.contextWillCompact ? mocks.contextWillCompact() : false), + assemblePayload: async () => + mocks.contextAssemble ? mocks.contextAssemble() : [{ role: 'user' as const, content: 'hi' }], + }; + const memory = { + loadMemoryForPrompt: () => mocks.memorySnapshot ?? '', + flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + }; + + const services = Layer.mergeAll( + Layer.succeed(SessionPort, session as any), + Layer.succeed(ToolExecutorPort, executor as any), + Layer.succeed(CheckpointPort, { + snapshotBaseline: () => Effect.void, + snapshotFinal: () => Effect.void, + } as any), + Layer.succeed(HookPort, { + emit: hooks.emit, + emitDecision: hooks.emitDecision, + disposeSession: () => Effect.void, + } as any), + Layer.succeed(ApprovalPort, { + evaluate: () => Effect.succeed({ type: 'allow', source: 'test' }), + } as any), + Layer.succeed(SkillPort, skills as any), + Layer.succeed(McpPort, mcpPort as any), + Layer.succeed(ContextPort, context as any), + Layer.succeed(MemoryPort, memory as any), + Layer.succeed(LlmPort, { getLLMClient: () => Effect.succeed(mocks.llm) } as any), + Layer.succeed(RulesPort, { + getAllRules: () => '', + evictProjectRules: () => {}, + } as any), + Layer.succeed(TodoPort, { read: (sid: string) => store.get(sid) ?? [] } as any), + // todo_write 工具 execute 执行时 yield* TodoService(完整 tag),窄端口 TodoPort 不可替代 + Layer.succeed(TodoService, { + read: (sid: string) => store.get(sid) ?? [], + write: (sid: string, items: any[]) => { + store.set(sid, items); + }, + reset: () => store.clear(), + } as any), + // dispatch_agent 工具 execute 执行时 yield* 这三个完整服务 + Layer.succeed(HookService, { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: hooks.emit, + emitDecision: hooks.emitDecision, + reloadUserHooks: () => Effect.void, + disposeSession: () => Effect.void, + } as any), + Layer.succeed(McpService, { + syncConnections: () => Effect.void, + listProjectMcpTools: () => [], + } as any), + Layer.succeed(SubagentRunnerService, {} as any), + // ToolEnvPort:把上面的具体服务适配成 agent 所需的工具执行期注入能力(同 layer.ts) + ToolEnvLayer, + // ToolCatalogPort:静态内置工具 + profile 工具 + MCP 工具的装配(同 layer.ts) + ToolCatalogLayer, + ); + return services; +} + +function tick(): Promise { + return new Promise((r) => setTimeout(r, 0)); +} + +// 模拟真实 LLM 延迟:每个部件后让出宏任务,避免 producer fiber 在微任务队列中 +// 一口气跑完导致队列事件被丢弃。 +function paceLlm(llm: any): any { + const completeStream = llm.completeStream.bind(llm); + llm.completeStream = (params: any, signal?: AbortSignal) => { + const raw = completeStream(params, signal) as AsyncIterable; + return (async function* () { + for await (const part of raw) { + yield part; + await tick(); + } + })(); + }; + return llm; +} + +export async function runAgentTurn( + mocks: HarnessMocks, + opts: RunAgentOptions = {} +): Promise<{ events: FrameBody[]; sessionId: string }> { + const llm = paceLlm(mocks.llm); + const services = makeAgentLayer({ ...mocks, llm }); + const appLayer = Layer.mergeAll(services, AgentLayer.pipe(Layer.provide(services))) as any; + const program = Effect.gen(function* () { + const agent = yield* AgentService; + const runOpts: any = { cwd: opts.cwd ?? '/tmp' }; + if (opts.sessionId) runOpts.sessionId = opts.sessionId; + if (opts.signal) runOpts.signal = opts.signal; + if (opts.activeProfile) runOpts.activeProfile = opts.activeProfile; + if (opts.permissionMode) runOpts.permissionMode = opts.permissionMode; + return yield* agent.runTurn(opts.input ?? 'test', runOpts); + }); + let runRes: { stream: AsyncGenerator; sessionId: string }; + try { + runRes = await Effect.runPromise(Effect.provide(program, appLayer) as any); + } catch (err) { + console.error('HARNESS-RUN-ERROR', err); + throw err; + } + const { stream, sessionId } = runRes; + const events: FrameBody[] = []; + try { + for await (const e of stream) events.push(e); + } catch (err) { + console.error('HARNESS-STREAM-ERROR', err); + throw err; + } + return { events, sessionId }; +} diff --git a/packages/codingcode/test/hooks/config-merge.test.ts b/packages/codingcode/test/hooks/config-merge.test.ts index cee1b2d1..41b98c11 100644 --- a/packages/codingcode/test/hooks/config-merge.test.ts +++ b/packages/codingcode/test/hooks/config-merge.test.ts @@ -108,7 +108,6 @@ describe('Hook disabled state', () => { _setGlobalConfigDir(undefined); rmSync(projectDir, { recursive: true, force: true }); rmSync(globalDir, { recursive: true, force: true }); - setGlobalHookDisabledState(testHook, false); }); it('should default to not disabled globally', () => { diff --git a/packages/codingcode/test/hooks/decision.test.ts b/packages/codingcode/test/hooks/decision.test.ts index 1d99ef5a..98df5bdc 100644 --- a/packages/codingcode/test/hooks/decision.test.ts +++ b/packages/codingcode/test/hooks/decision.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect } from 'vitest'; import { Effect, Layer } from 'effect'; -import { HookService } from '../../src/hooks/registry.js'; +import { HookService } from '../../src/hooks/port.js'; +import { HookLayer } from '../../src/hooks/hooks.js'; -const TestLayer = HookService.Default; +const TestLayer = HookLayer; function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(TestLayer) as any)); diff --git a/packages/codingcode/test/hooks/registry.test.ts b/packages/codingcode/test/hooks/registry.test.ts index 0cf3b98b..b122028a 100644 --- a/packages/codingcode/test/hooks/registry.test.ts +++ b/packages/codingcode/test/hooks/registry.test.ts @@ -3,8 +3,9 @@ import { Effect } from 'effect'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join, resolve } from 'path'; import { tmpdir } from 'os'; -import { HookService } from '../../src/hooks/registry.js'; -const AppLayer = HookService.Default; +import { HookService } from '../../src/hooks/port.js'; +import { HookLayer } from '../../src/hooks/hooks.js'; +const AppLayer = HookLayer; function runWithLayer(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(AppLayer) as any)); diff --git a/packages/codingcode/test/layer/system-hook-layer.test.ts b/packages/codingcode/test/layer/system-hook-layer.test.ts deleted file mode 100644 index 8c464512..00000000 --- a/packages/codingcode/test/layer/system-hook-layer.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 { computePaths } from '../../src/core/path.js'; - -describe('SystemHookLayer', () => { - it('builds without "Service not found: HookService" (regression: was a self-referential Layer.effect)', async () => { - const program = Effect.gen(function* () { - const hooks = yield* HookService; - return typeof hooks.register; - }); - - const result = await Effect.runPromise(program.pipe(Effect.provide(SystemHookLayer) as any)); - expect(result).toBe('function'); - }); - - it('registers the remaining plan-profile system hooks', async () => { - const cwd = mkdtempSync(join(tmpdir(), 'codingcode-syshook-')); - try { - const paths = computePaths(cwd, 's'); - mkdirSync(paths.transcriptPath.replace(/\.jsonl$/, ''), { recursive: true }); - const idx = { - sessionId: 's', - cwd: paths.cwd, - model: 'test', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 0, - title: 's', - currentTurnId: 0, - usage: undefined, - activeProfile: 'plan', - permissionMode: 'default', - }; - writeFileSync(paths.indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - const program = Effect.gen(function* () { - const hooks = yield* HookService; - - // (1) planProfileGateHook denies write tools in plan profile - 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 profile/i); - - // (2) planProfileGateHook 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)); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/codingcode/test/llm/deepseek-provider.test.ts b/packages/codingcode/test/llm/deepseek-provider.test.ts index 2013288e..3845cae2 100644 --- a/packages/codingcode/test/llm/deepseek-provider.test.ts +++ b/packages/codingcode/test/llm/deepseek-provider.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { LLMStreamPart } from '../../src/llm/types.js'; const streamText = vi.fn(); const stepCountIs = vi.fn((count: number) => ({ count })); @@ -11,12 +12,14 @@ vi.mock('ai', () => ({ jsonSchema, })); -async function collect(stream: AsyncIterable): Promise { - const chunks: string[] = []; - for await (const chunk of stream) { - chunks.push(chunk); +const USAGE = { inputTokens: 200, outputTokens: 100, totalTokens: 300 }; + +async function collect(stream: AsyncIterable): Promise { + const parts: LLMStreamPart[] = []; + for await (const part of stream) { + parts.push(part); } - return chunks; + return parts; } function entry() { @@ -47,27 +50,21 @@ describe('DeepSeekProvider completeStream', () => { streamText.mockReturnValue({ fullStream: (async function* () { yield { type: 'text-delta', text: 'streamed' }; + yield { type: 'finish', totalUsage: USAGE }; })(), - response: Promise.resolve({ - messages: [{ role: 'assistant', content: 'streamed' }], - usage: { promptTokens: 200, completionTokens: 100, totalTokens: 300 }, - }), }); }); - it('streams text and extracts usage from response', async () => { + it('streams text and extracts usage from the finish part', async () => { const { DeepSeekProvider } = await import('../../src/llm/providers/deepseek.js'); const provider = new DeepSeekProvider({} as any, entry()); - const result = provider.completeStream(request() as any); - await expect(collect(result.stream)).resolves.toEqual(['streamed']); - - const resp = await result.response; - expect(resp.ok).toBe(true); - if (resp.ok) { - expect(resp.value.usage).toEqual({ prompt: 200, completion: 100, total: 300 }); - } + const parts = await collect(provider.completeStream(request() as any)); + expect(parts).toEqual([ + { type: 'text', text: 'streamed' }, + { type: 'end', usage: { prompt: 200, completion: 100, total: 300 } }, + ]); expect(streamText).toHaveBeenCalledTimes(1); - }); + }, 30000); }); diff --git a/packages/codingcode/test/llm/factory.test.ts b/packages/codingcode/test/llm/factory.test.ts index 3f73d319..6cac767b 100644 --- a/packages/codingcode/test/llm/factory.test.ts +++ b/packages/codingcode/test/llm/factory.test.ts @@ -58,13 +58,14 @@ describe('switchModel - persists to config', () => { }); mockFs(); - const { LLMFactoryService } = await import('../../src/llm/factory.js'); + const { LLMFactoryService } = await import('../../src/llm/port.js'); const { WorkspaceService } = await import('../../src/core/workspace.js'); const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { model: 'model-x', apiKeyEnv: 'API_KEY_A', }); - const factoryLayer = LLMFactoryService.Default.pipe(Layer.provide(workspaceLayer)); + const { LlmLayer } = await import('../../src/llm/llm.js'); + const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const result = await Effect.runPromise( Effect.gen(function* () { @@ -87,13 +88,14 @@ describe('switchModel - persists to config', () => { }); mockFs(); - const { LLMFactoryService } = await import('../../src/llm/factory.js'); + const { LLMFactoryService } = await import('../../src/llm/port.js'); const { WorkspaceService } = await import('../../src/core/workspace.js'); const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { model: 'model-x', apiKeyEnv: 'API_KEY_A', }); - const factoryLayer = LLMFactoryService.Default.pipe(Layer.provide(workspaceLayer)); + const { LlmLayer } = await import('../../src/llm/llm.js'); + const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const result = await Effect.runPromise( Effect.gen(function* () { @@ -117,13 +119,14 @@ describe('getActiveEntry - activeModel priority', () => { it('uses activeModel from config when it matches a catalog entry', async () => { mockFs(); - const { LLMFactoryService } = await import('../../src/llm/factory.js'); + const { LLMFactoryService } = await import('../../src/llm/port.js'); const { WorkspaceService } = await import('../../src/core/workspace.js'); const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { model: 'model-y', apiKeyEnv: 'API_KEY_A', }); - const factoryLayer = LLMFactoryService.Default.pipe(Layer.provide(workspaceLayer)); + const { LlmLayer } = await import('../../src/llm/llm.js'); + const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const result = await Effect.runPromise( Effect.gen(function* () { @@ -138,10 +141,11 @@ describe('getActiveEntry - activeModel priority', () => { }); it('returns error when activeModel is not set in config', async () => { - const { LLMFactoryService } = await import('../../src/llm/factory.js'); + const { LLMFactoryService } = await import('../../src/llm/port.js'); const { WorkspaceService } = await import('../../src/core/workspace.js'); const workspaceLayer = makeWorkspaceLayer(WorkspaceService, undefined); - const factoryLayer = LLMFactoryService.Default.pipe(Layer.provide(workspaceLayer)); + const { LlmLayer } = await import('../../src/llm/llm.js'); + const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const result = await Effect.runPromise( Effect.gen(function* () { @@ -159,13 +163,14 @@ describe('getActiveEntry - activeModel priority', () => { it('returns error when activeModel does not match any catalog entry', async () => { mockFs(); - const { LLMFactoryService } = await import('../../src/llm/factory.js'); + const { LLMFactoryService } = await import('../../src/llm/port.js'); const { WorkspaceService } = await import('../../src/core/workspace.js'); const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { model: 'nonexistent', apiKeyEnv: 'UNKNOWN_KEY', }); - const factoryLayer = LLMFactoryService.Default.pipe(Layer.provide(workspaceLayer)); + const { LlmLayer } = await import('../../src/llm/llm.js'); + const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const result = await Effect.runPromise( Effect.gen(function* () { @@ -189,13 +194,14 @@ describe('createClient - API key validation', () => { it('returns CONFIG_MISSING when API key env is not set', async () => { mockFs(); - const { LLMFactoryService } = await import('../../src/llm/factory.js'); + const { LLMFactoryService } = await import('../../src/llm/port.js'); const { WorkspaceService } = await import('../../src/core/workspace.js'); const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { model: 'model-x', apiKeyEnv: 'API_KEY_A', }); - const factoryLayer = LLMFactoryService.Default.pipe(Layer.provide(workspaceLayer)); + const { LlmLayer } = await import('../../src/llm/llm.js'); + const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const entryResult = await Effect.runPromise( Effect.gen(function* () { @@ -225,13 +231,14 @@ describe('createClient - API key validation', () => { it('succeeds when OPENAI_API_KEY fallback is set', async () => { mockFs(); - const { LLMFactoryService } = await import('../../src/llm/factory.js'); + const { LLMFactoryService } = await import('../../src/llm/port.js'); const { WorkspaceService } = await import('../../src/core/workspace.js'); const workspaceLayer = makeWorkspaceLayer(WorkspaceService, { model: 'model-x', apiKeyEnv: 'API_KEY_A', }); - const factoryLayer = LLMFactoryService.Default.pipe(Layer.provide(workspaceLayer)); + const { LlmLayer } = await import('../../src/llm/llm.js'); + const factoryLayer = LlmLayer.pipe(Layer.provide(workspaceLayer)); const entryResult = await Effect.runPromise( Effect.gen(function* () { diff --git a/packages/codingcode/test/llm/openai-provider.test.ts b/packages/codingcode/test/llm/openai-provider.test.ts index 3771577c..7c750c17 100644 --- a/packages/codingcode/test/llm/openai-provider.test.ts +++ b/packages/codingcode/test/llm/openai-provider.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { LLMStreamPart } from '../../src/llm/types.js'; const generateText = vi.fn(); const streamText = vi.fn(); @@ -12,12 +13,15 @@ vi.mock('ai', () => ({ jsonSchema, })); -async function collect(stream: AsyncIterable): Promise { - const chunks: string[] = []; - for await (const chunk of stream) { - chunks.push(chunk); +const USAGE = { inputTokens: 100, outputTokens: 50, totalTokens: 150 }; +const EXPECTED_USAGE = { prompt: 100, completion: 50, total: 150 }; + +async function collect(stream: AsyncIterable): Promise { + const parts: LLMStreamPart[] = []; + for await (const part of stream) { + parts.push(part); } - return chunks; + return parts; } function entry(provider: string) { @@ -47,19 +51,12 @@ function request(withTools: boolean) { describe('OpenAIProvider completeStream', () => { beforeEach(() => { vi.clearAllMocks(); - generateText.mockResolvedValue({ - response: { - messages: [{ role: 'assistant', content: 'done' }], - }, - }); + generateText.mockResolvedValue({ text: 'done', toolCalls: [], usage: USAGE }); streamText.mockReturnValue({ fullStream: (async function* () { yield { type: 'text-delta', text: 'streamed' }; + yield { type: 'finish', totalUsage: USAGE }; })(), - response: Promise.resolve({ - messages: [{ role: 'assistant', content: 'streamed' }], - usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, - }), }); }); @@ -67,21 +64,26 @@ describe('OpenAIProvider completeStream', () => { const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); const provider = new OpenAIProvider({} as any, entry('sansen')); - const result = provider.completeStream(request(true) as any); - await expect(result.response).resolves.toMatchObject({ ok: true, value: { content: 'done' } }); - await expect(collect(result.stream)).resolves.toEqual(['done']); + const parts = await collect(provider.completeStream(request(true) as any)); + expect(parts).toEqual([ + { type: 'text', text: 'done' }, + { type: 'end', usage: EXPECTED_USAGE }, + ]); expect(generateText).toHaveBeenCalledTimes(1); expect(streamText).not.toHaveBeenCalled(); - }); + }, 30000); it('keeps streaming for sansen requests without tools', async () => { const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); const provider = new OpenAIProvider({} as any, entry('sansen')); - const result = provider.completeStream(request(false) as any); - await expect(collect(result.stream)).resolves.toEqual(['streamed']); + const parts = await collect(provider.completeStream(request(false) as any)); + expect(parts).toEqual([ + { type: 'text', text: 'streamed' }, + { type: 'end', usage: EXPECTED_USAGE }, + ]); expect(streamText).toHaveBeenCalledTimes(1); expect(generateText).not.toHaveBeenCalled(); }); @@ -90,22 +92,43 @@ describe('OpenAIProvider completeStream', () => { const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); const provider = new OpenAIProvider({} as any, entry('openai')); - const result = provider.completeStream(request(true) as any); - await expect(collect(result.stream)).resolves.toEqual(['streamed']); + const parts = await collect(provider.completeStream(request(true) as any)); + expect(parts).toEqual([ + { type: 'text', text: 'streamed' }, + { type: 'end', usage: EXPECTED_USAGE }, + ]); expect(streamText).toHaveBeenCalledTimes(1); expect(generateText).not.toHaveBeenCalled(); }); - it('extracts usage from streamText response', async () => { + it('maps tool-call parts to tool_call stream parts', async () => { + streamText.mockReturnValue({ + fullStream: (async function* () { + yield { type: 'text-delta', text: 'reading' }; + yield { type: 'tool-call', toolCallId: 'tc-1', toolName: 'read_file', input: { path: 'a.ts' } }; + yield { type: 'finish', totalUsage: USAGE }; + })(), + }); const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); const provider = new OpenAIProvider({} as any, entry('openai')); - const result = provider.completeStream(request(false) as any); - const resp = await result.response; - expect(resp.ok).toBe(true); - if (resp.ok) { - expect(resp.value.usage).toEqual({ prompt: 100, completion: 50, total: 150 }); - } + const parts = await collect(provider.completeStream(request(false) as any)); + + expect(parts).toEqual([ + { type: 'text', text: 'reading' }, + { type: 'tool_call', id: 'tc-1', name: 'read_file', args: { path: 'a.ts' } }, + { type: 'end', usage: EXPECTED_USAGE }, + ]); }); + + it('extracts usage from the finish part', async () => { + const { OpenAIProvider } = await import('../../src/llm/providers/openai.js'); + const provider = new OpenAIProvider({} as any, entry('openai')); + + const parts = await collect(provider.completeStream(request(false) as any)); + const end = parts.find((p) => p.type === 'end'); + + expect(end).toEqual({ type: 'end', usage: EXPECTED_USAGE }); + }, 30000); }); diff --git a/packages/codingcode/test/mcp/config-merge.test.ts b/packages/codingcode/test/mcp/config-merge.test.ts index 0a7d4942..eab433ab 100644 --- a/packages/codingcode/test/mcp/config-merge.test.ts +++ b/packages/codingcode/test/mcp/config-merge.test.ts @@ -139,7 +139,6 @@ describe('MCP disabled state', () => { _setGlobalConfigDir(undefined); rmSync(projectDir, { recursive: true, force: true }); rmSync(globalDir, { recursive: true, force: true }); - setGlobalMcpDisabledState(testServer, false); }); it('should default to not disabled globally', () => { diff --git a/packages/codingcode/test/mcp/service.test.ts b/packages/codingcode/test/mcp/service.test.ts index 7da5d816..93aa8095 100644 --- a/packages/codingcode/test/mcp/service.test.ts +++ b/packages/codingcode/test/mcp/service.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Effect, Layer } from 'effect'; import { z } from 'zod'; -import { McpService } from '../../src/mcp/index.js'; -import { HookService } from '../../src/hooks/registry.js'; +import { McpService } from '../../src/mcp/port.js'; +import { HookService } from '../../src/hooks/port.js'; +import { McpLayer } from '../../src/mcp/mcp.js'; // Mock McpClient vi.mock('../../src/mcp/client.js', () => { @@ -59,7 +60,7 @@ const TEST_SESSION = 'test-session'; function run(eff: Effect.Effect): Promise { const testLayer = Layer.mergeAll( makeHookLayer(), - McpService.Default.pipe(Layer.provide(makeHookLayer())) + McpLayer.pipe(Layer.provide(makeHookLayer())) ); return Effect.runPromise(eff.pipe(Effect.provide(testLayer) as any)); } diff --git a/packages/codingcode/test/memory/config.test.ts b/packages/codingcode/test/memory/config.test.ts index cfa7fc7d..580ca52a 100644 --- a/packages/codingcode/test/memory/config.test.ts +++ b/packages/codingcode/test/memory/config.test.ts @@ -1,267 +1,28 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - getEffectiveTypes, - getAllTypesWithStatus, - setMemoryTypeDisabled, - addMemoryExtraType, - updateMemoryExtraType, - deleteMemoryExtraType, -} from '../../src/memory/config.js'; -import type { MemoryConfig, MemoryTypeConfig } from '@codingcode/infra/config'; +import { describe, it, expect, vi } from 'vitest'; +import { getMemoryConfig } from '../../src/memory/config.js'; -// mock the infra persistence functions (hoisted to top so vi.mock factory can access them) -const { mockUpdateDisabledTypes, mockUpdateExtraTypes } = vi.hoisted(() => ({ - mockUpdateDisabledTypes: vi.fn(), - mockUpdateExtraTypes: vi.fn(), +vi.mock('@codingcode/infra/config', () => ({ + loadConfig: vi.fn(() => ({ + memory: { enabled: true, model: 'memory-model', promptMaxBytes: 4096 }, + })), })); -vi.mock('@codingcode/infra/config', async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - updateMemoryDisabledTypes: mockUpdateDisabledTypes, - updateMemoryExtraTypes: mockUpdateExtraTypes, - }; -}); - -function makeCfg(overrides?: Partial): MemoryConfig { - return { - enabled: true, - model: '', - extraTypes: [], - disabledTypes: [], - promptMaxBytes: 8192, - ...overrides, - }; -} - -describe('Memory Config', () => { - beforeEach(() => { - mockUpdateDisabledTypes.mockClear(); - mockUpdateExtraTypes.mockClear(); - }); - - describe('getEffectiveTypes', () => { - it('includes default types when enabled', () => { - const cfg: MemoryConfig = makeCfg(); - - const types = getEffectiveTypes(cfg); - expect(types).toHaveLength(3); - expect(types.map((t) => t.name)).toContain('user'); - expect(types.map((t) => t.name)).toContain('project'); - expect(types.map((t) => t.name)).toContain('reference'); - }); - - it('appends extra types', () => { - const extra: MemoryTypeConfig[] = [ - { - name: 'custom', - description: 'Custom type', - enabled: true, - }, - ]; - const cfg: MemoryConfig = makeCfg({ extraTypes: extra }); - - const types = getEffectiveTypes(cfg); - expect(types).toHaveLength(4); - expect(types.map((t) => t.name)).toContain('custom'); - }); - - it('filters disabled types', () => { - const cfg: MemoryConfig = makeCfg({ disabledTypes: ['user', 'project'] }); - - const types = getEffectiveTypes(cfg); - expect(types).toHaveLength(1); - expect(types[0]!.name).toBe('reference'); - }); - - it('filters disabled extra types', () => { - const extra: MemoryTypeConfig[] = [ - { - name: 'custom', - description: 'Custom type', - enabled: true, - }, - ]; - const cfg: MemoryConfig = makeCfg({ extraTypes: extra, disabledTypes: ['custom'] }); - - const types = getEffectiveTypes(cfg); - expect(types.map((t) => t.name)).not.toContain('custom'); - }); - - it('respects type.enabled flag', () => { - const extra: MemoryTypeConfig[] = [ - { - name: 'disabled_custom', - description: 'Disabled type', - enabled: false, - }, - ]; - const cfg: MemoryConfig = makeCfg({ extraTypes: extra }); - - const types = getEffectiveTypes(cfg); - expect(types.map((t) => t.name)).not.toContain('disabled_custom'); - }); - }); - - describe('getAllTypesWithStatus', () => { - it('returns built-in types with isBuiltIn true', () => { - const types = getAllTypesWithStatus(makeCfg()); - const builtIn = types.filter((t) => t.isBuiltIn); - expect(builtIn).toHaveLength(3); - expect(builtIn.map((t) => t.name)).toEqual(['user', 'project', 'reference']); - }); - - it('marks types in disabledTypes as disabled', () => { - const cfg = makeCfg({ disabledTypes: ['user'] }); - const types = getAllTypesWithStatus(cfg); - expect(types.find((t) => t.name === 'user')?.disabled).toBe(true); - expect(types.find((t) => t.name === 'project')?.disabled).toBe(false); - }); - - it('includes extra types with isBuiltIn false', () => { - const extra: MemoryTypeConfig[] = [ - { name: 'custom', description: 'Custom type', enabled: true }, - ]; - const cfg = makeCfg({ extraTypes: extra }); - const types = getAllTypesWithStatus(cfg); - expect(types).toHaveLength(4); - const custom = types.find((t) => t.name === 'custom'); - expect(custom?.isBuiltIn).toBe(false); - expect(custom?.description).toBe('Custom type'); - }); - - it('marks disabled extra types correctly', () => { - const extra: MemoryTypeConfig[] = [ - { name: 'custom', description: 'Custom type', enabled: true }, - ]; - const cfg = makeCfg({ extraTypes: extra, disabledTypes: ['custom'] }); - const types = getAllTypesWithStatus(cfg); - expect(types.find((t) => t.name === 'custom')?.disabled).toBe(true); - }); - }); - - describe('setMemoryTypeDisabled', () => { - it('adds name to disabledTypes when disabling', () => { - const cfg = makeCfg(); - setMemoryTypeDisabled('user', true, cfg); - expect(mockUpdateDisabledTypes).toHaveBeenCalledWith(['user']); - }); - - it('removes name from disabledTypes when enabling', () => { - const cfg = makeCfg({ disabledTypes: ['user', 'project'] }); - setMemoryTypeDisabled('user', false, cfg); - expect(mockUpdateDisabledTypes).toHaveBeenCalledWith(['project']); - }); - - it('deduplicates when adding existing entry', () => { - const cfg = makeCfg({ disabledTypes: ['user'] }); - setMemoryTypeDisabled('user', true, cfg); - expect(mockUpdateDisabledTypes).toHaveBeenCalledWith(['user']); - }); - - it('is no-op when enabling an already-enabled type', () => { - const cfg = makeCfg(); - setMemoryTypeDisabled('user', false, cfg); - expect(mockUpdateDisabledTypes).toHaveBeenCalledWith([]); - }); - }); - - describe('addMemoryExtraType', () => { - it('adds type to extraTypes with enabled: true', () => { - const cfg = makeCfg(); - addMemoryExtraType({ name: 'custom', description: 'Custom', enabled: true }, cfg); - expect(mockUpdateExtraTypes).toHaveBeenCalledWith([ - { name: 'custom', description: 'Custom', enabled: true }, - ]); - }); - - it('appends to existing extraTypes', () => { - const extra: MemoryTypeConfig[] = [ - { name: 'existing', description: 'Existing', enabled: true }, - ]; - const cfg = makeCfg({ extraTypes: extra }); - addMemoryExtraType({ name: 'new_type', description: 'New', enabled: true }, cfg); - expect(mockUpdateExtraTypes).toHaveBeenCalledWith([ - { name: 'existing', description: 'Existing', enabled: true }, - { name: 'new_type', description: 'New', enabled: true }, - ]); - }); - - it('throws on duplicate name', () => { - const extra: MemoryTypeConfig[] = [ - { name: 'custom', description: 'Existing', enabled: true }, - ]; - const cfg = makeCfg({ extraTypes: extra }); - expect(() => - addMemoryExtraType({ name: 'custom', description: 'Dupe', enabled: true }, cfg) - ).toThrow('already exists'); - }); - }); - - describe('updateMemoryExtraType', () => { - it('updates existing extra type', () => { - const extra: MemoryTypeConfig[] = [{ name: 'custom', description: 'Old', enabled: true }]; - const cfg = makeCfg({ extraTypes: extra }); - updateMemoryExtraType( - 'custom', - { name: 'custom', description: 'Updated', enabled: true }, - cfg - ); - expect(mockUpdateExtraTypes).toHaveBeenCalledWith([ - { name: 'custom', description: 'Updated', enabled: true }, - ]); - }); - - it('renames an extra type', () => { - const extra: MemoryTypeConfig[] = [{ name: 'old_name', description: 'Desc', enabled: true }]; - const cfg = makeCfg({ extraTypes: extra }); - updateMemoryExtraType( - 'old_name', - { name: 'new_name', description: 'Desc', enabled: true }, - cfg - ); - expect(mockUpdateExtraTypes).toHaveBeenCalledWith([ - { name: 'new_name', description: 'Desc', enabled: true }, - ]); - }); - - it('throws if not found', () => { - const cfg = makeCfg(); - expect(() => - updateMemoryExtraType('nonexistent', { name: 'x', description: 'x', enabled: true }, cfg) - ).toThrow('not found'); - }); - it('throws on rename conflict', () => { - const extra: MemoryTypeConfig[] = [ - { name: 'a', description: 'A', enabled: true }, - { name: 'b', description: 'B', enabled: true }, - ]; - const cfg = makeCfg({ extraTypes: extra }); - expect(() => - updateMemoryExtraType('a', { name: 'b', description: 'Overwrite', enabled: true }, cfg) - ).toThrow('already exists'); - }); +describe('getMemoryConfig', () => { + it('returns memory section of loaded config', () => { + const cfg = getMemoryConfig(); + expect(cfg.enabled).toBe(true); + expect(cfg.model).toBe('memory-model'); + expect(cfg.promptMaxBytes).toBe(4096); }); - describe('deleteMemoryExtraType', () => { - it('removes the named extra type', () => { - const extra: MemoryTypeConfig[] = [ - { name: 'keep', description: '', enabled: true }, - { name: 'remove', description: '', enabled: true }, - ]; - const cfg = makeCfg({ extraTypes: extra }); - deleteMemoryExtraType('remove', cfg); - expect(mockUpdateExtraTypes).toHaveBeenCalledWith([ - { name: 'keep', description: '', enabled: true }, - ]); - }); + it('reflects updated loadConfig result', async () => { + const { loadConfig } = await import('@codingcode/infra/config'); + vi.mocked(loadConfig).mockReturnValue({ + memory: { enabled: false, model: '', promptMaxBytes: 8192 }, + } as any); - it('is no-op if type not found', () => { - const extra: MemoryTypeConfig[] = [{ name: 'a', description: '', enabled: true }]; - const cfg = makeCfg({ extraTypes: extra }); - deleteMemoryExtraType('nonexistent', cfg); - expect(mockUpdateExtraTypes).toHaveBeenCalledWith(extra); - }); + const cfg = getMemoryConfig(); + expect(cfg.enabled).toBe(false); + expect(cfg.model).toBe(''); }); }); diff --git a/packages/codingcode/test/memory/extractor.test.ts b/packages/codingcode/test/memory/extractor.test.ts index 5ece8cb7..ed1fe886 100644 --- a/packages/codingcode/test/memory/extractor.test.ts +++ b/packages/codingcode/test/memory/extractor.test.ts @@ -1,21 +1,16 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; import { extractMemory } from '../../src/memory/extractor.js'; -import type { StructuredTranscript } from '../../src/memory/types.js'; -import type { MemoryTypeConfig } from '@codingcode/infra/config'; describe('Memory Extractor', () => { const createMockLlm = (response: string) => ({ - complete: vi.fn(() => Effect.succeed({ content: response, finishReason: 'stop' as const })), - completeStream: vi.fn(() => ({ - stream: (async function* () { - yield response; - })(), - response: Promise.resolve({ - ok: true as const, - value: { content: response, finishReason: 'stop' as const }, - }), - })), + complete: vi.fn(() => Effect.succeed({ content: response })), + completeStream: vi.fn(() => + (async function* () { + yield { type: 'text' as const, text: response }; + yield { type: 'end' as const }; + })() + ), modelInfo: { provider: 'mock', model: 'mock', @@ -25,66 +20,35 @@ describe('Memory Extractor', () => { }, }); - const defaultTypes: MemoryTypeConfig[] = [ - { name: 'user', description: 'User info', enabled: true }, - { name: 'project', description: 'Project info', enabled: true }, - { name: 'reference', description: 'References', enabled: true }, - ]; - - it('extracts memory from transcript', async () => { - const response = `### user -- User is a TypeScript developer`; - - const transcript: StructuredTranscript = { - userOnly: 'I like TypeScript', - userAndAssistant: 'I like TypeScript\n---\nTypeScript is great', - userAndTools: 'I like TypeScript', - }; + it('returns memory inside tags', async () => { + const response = `### 主题 +- 用户是 TypeScript 开发者`; const result = await extractMemory({ - currentAuto: '', - transcript, - types: defaultTypes, + currentMemory: '', + transcript: '[user] I like TypeScript', llm: createMockLlm(response), }); - expect(result).toContain('### user'); - expect(result).toContain('User is a TypeScript developer'); + expect(result).toContain('### 主题'); + expect(result).toContain('用户是 TypeScript 开发者'); }); it('returns null when memory tags are empty', async () => { - const response = ''; - - const transcript: StructuredTranscript = { - userOnly: 'Some text', - userAndAssistant: 'Some text', - userAndTools: 'Some text', - }; - const result = await extractMemory({ - currentAuto: '', - transcript, - types: defaultTypes, - llm: createMockLlm(response), + currentMemory: '', + transcript: '[user] Some text', + llm: createMockLlm(''), }); expect(result).toBeNull(); }); it('returns null when memory tags not found', async () => { - const response = 'No memory tags here'; - - const transcript: StructuredTranscript = { - userOnly: 'Some text', - userAndAssistant: 'Some text', - userAndTools: 'Some text', - }; - const result = await extractMemory({ - currentAuto: '', - transcript, - types: defaultTypes, - llm: createMockLlm(response), + currentMemory: '', + transcript: '[user] Some text', + llm: createMockLlm('No memory tags here'), }); expect(result).toBeNull(); @@ -93,15 +57,11 @@ describe('Memory Extractor', () => { it('handles LLM call failure gracefully', async () => { const llm = { complete: vi.fn(() => Effect.fail({ code: 'LLM_ERROR', message: 'Stream error' } as any)), - completeStream: vi.fn(() => ({ - stream: (async function* () { + completeStream: vi.fn(() => + (async function* () { throw new Error('Stream error'); - })(), - response: Promise.resolve({ - ok: false, - value: { content: '' }, - } as any), - })), + })() + ), modelInfo: { provider: 'mock', model: 'mock', @@ -111,134 +71,43 @@ describe('Memory Extractor', () => { }, }; - const transcript: StructuredTranscript = { - userOnly: '', - userAndAssistant: '', - userAndTools: '', - }; - const result = await extractMemory({ - currentAuto: '', - transcript, - types: defaultTypes, + currentMemory: '', + transcript: '', llm, }); expect(result).toBeNull(); }); - it('includes currentAuto in system prompt', async () => { + it('passes currentMemory to the model as existing memory', async () => { const mockLlm = createMockLlm(''); - const response = ''; - - const transcript: StructuredTranscript = { - userOnly: 'text', - userAndAssistant: 'text', - userAndTools: 'text', - }; - - const currentAuto = '### user\n- Old info'; await extractMemory({ - currentAuto, - transcript, - types: defaultTypes, + currentMemory: '### project\n- 旧信息', + transcript: '[user] 新对话', llm: mockLlm, }); const callArgs = (mockLlm.completeStream.mock.calls as any)[0][0] as any; expect(callArgs.messages[0].content).toContain('已有记忆'); - expect(callArgs.messages[0].content).toContain('Old info'); + expect(callArgs.messages[0].content).toContain('旧信息'); + expect(callArgs.messages[0].content).toContain('新对话'); }); - it('includes transcript in system prompt with labels', async () => { + it('keeps instructions in system and transcript data in messages', async () => { const mockLlm = createMockLlm(''); - const transcript: StructuredTranscript = { - userOnly: 'user text', - userAndAssistant: 'user text\nassistant response', - userAndTools: 'user text\ntool output', - }; - - await extractMemory({ - currentAuto: '', - transcript, - types: defaultTypes, - llm: mockLlm, - }); - - const callArgs = (mockLlm.completeStream.mock.calls as any)[0][0] as any; - expect(callArgs.messages[0].content).toContain('[user]'); - expect(callArgs.messages[0].content).toContain('[user+assistant]'); - expect(callArgs.messages[0].content).toContain('[user+tool]'); - }); - - it('only calls system prompt with specified types', async () => { - const mockLlm = createMockLlm(''); - const twoTypes: MemoryTypeConfig[] = [defaultTypes[0]!, defaultTypes[1]!]; - - const transcript: StructuredTranscript = { - userOnly: 'text', - userAndAssistant: 'text', - userAndTools: 'text', - }; - - await extractMemory({ - currentAuto: '', - transcript, - types: twoTypes, - llm: mockLlm, - }); - - const callArgs = (mockLlm.completeStream.mock.calls as any)[0][0] as any; - // Should not mention reference guidance - expect(callArgs.system).not.toContain('reference'); - }); - - it('passes non-empty messages array with role user', async () => { - const mockLlm = createMockLlm(''); - - const transcript: StructuredTranscript = { - userOnly: 'text', - userAndAssistant: 'text', - userAndTools: 'text', - }; - - await extractMemory({ - currentAuto: '', - transcript, - types: defaultTypes, - llm: mockLlm, - }); - - const callArgs = (mockLlm.completeStream.mock.calls as any)[0][0] as any; - expect(callArgs.messages).toHaveLength(1); - expect(callArgs.messages[0].role).toBe('user'); - expect(callArgs.messages[0].content).toBeTruthy(); - }); - - it('separates instruction in system and data in messages', async () => { - const mockLlm = createMockLlm(''); - - const transcript: StructuredTranscript = { - userOnly: 'I use Python', - userAndAssistant: 'I use Python', - userAndTools: 'I use Python', - }; - await extractMemory({ - currentAuto: '### user\n- Likes TypeScript', - transcript, - types: defaultTypes, + currentMemory: '### project\n- Likes TypeScript', + transcript: '[user] I use Python', llm: mockLlm, }); const callArgs = (mockLlm.completeStream.mock.calls as any)[0][0] as any; - // system contains instructions, not transcript data expect(callArgs.system).toContain('规则'); - expect(callArgs.system).toContain('记忆类型'); + expect(callArgs.system).toContain('整份'); expect(callArgs.system).not.toContain('I use Python'); - // messages contains transcript data, not instructions expect(callArgs.messages[0].content).toContain('I use Python'); expect(callArgs.messages[0].content).toContain('Likes TypeScript'); }); diff --git a/packages/codingcode/test/memory/index.test.ts b/packages/codingcode/test/memory/index.test.ts index af9b972c..4e0b1df6 100644 --- a/packages/codingcode/test/memory/index.test.ts +++ b/packages/codingcode/test/memory/index.test.ts @@ -3,10 +3,12 @@ import { Effect, Layer } from 'effect'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { MemoryService } from '../../src/memory/index.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; +import { MemoryService } from '../../src/memory/port.js'; +import { LLMFactoryService } from '../../src/llm/port.js'; +import { MemoryLayer } from '../../src/memory/memory.js'; const tmpDir = path.join(os.tmpdir(), 'memory-index-test'); +const memFile = path.join(tmpDir, '.codingcode', 'memory.md'); const mockFactory = { findModel: vi.fn(() => Effect.succeed(null)), @@ -17,7 +19,7 @@ const mockFactory = { getLLMClient: vi.fn(() => Effect.succeed({})), } as any; -const testLayer = MemoryService.Default.pipe( +const testLayer = MemoryLayer.pipe( Layer.provide(Layer.succeed(LLMFactoryService, mockFactory)) ); @@ -29,6 +31,57 @@ function cleanup() { } } +function writeMemory(content: string) { + fs.mkdirSync(path.dirname(memFile), { recursive: true }); + fs.writeFileSync(memFile, content); +} + +vi.mock('../../src/memory/config.js', () => ({ + getMemoryConfig: vi.fn(() => ({ + enabled: false, + model: '', + promptMaxBytes: 8192, + })), +})); + +// setMemoryEnabled persists via the infra config store, which writes the real +// ~/.codingcode/config.yaml. Stub the writer so the suite never touches user config. +vi.mock('@codingcode/infra/config', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + updateMemoryEnabled: vi.fn(), + }; +}); + +vi.mock('../../src/session/file-ops.js', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + readTranscript: vi.fn(() => []), + }; +}); + +function createMockLlm(response: string, beforeYield?: () => void) { + return { + complete: vi.fn(() => Effect.succeed({ content: response })), + completeStream: vi.fn(() => + (async function* () { + beforeYield?.(); + yield { type: 'text' as const, text: response }; + yield { type: 'end' as const }; + })() + ), + modelInfo: { + provider: 'mock', + model: 'mock', + maxTokens: 4096, + supportsToolCalling: true, + supportsStreaming: true, + }, + }; +} + beforeEach(async () => { cleanup(); fs.mkdirSync(tmpDir, { recursive: true }); @@ -37,9 +90,9 @@ beforeEach(async () => { enabled: false, model: '', promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], }); + const { readTranscript } = await import('../../src/session/file-ops.js'); + vi.mocked(readTranscript).mockImplementation(() => []); service = await Effect.runPromise( Effect.gen(function* () { return yield* MemoryService; @@ -51,171 +104,172 @@ afterEach(() => { cleanup(); }); -vi.mock('../../src/memory/config.js', () => ({ - getMemoryConfig: vi.fn(() => ({ - enabled: false, +async function enableConfig() { + const { getMemoryConfig } = await import('../../src/memory/config.js'); + vi.mocked(getMemoryConfig).mockReturnValue({ + enabled: true, model: '', promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], - })), - getEffectiveTypes: vi.fn(() => [ - { name: 'user', description: 'User info', enabled: true }, - { name: 'project', description: 'Project info', enabled: true }, - { name: 'reference', description: 'References', enabled: true }, - ]), - updateMemoryEnabled: vi.fn(), -})); + }); +} -describe('Memory Index', () => { - describe('loadMemoryForPrompt', () => { - it('returns empty string when memory is disabled', () => { - const result = service.loadMemoryForPrompt(tmpDir); - expect(result).toBe(''); - }); +describe('loadMemoryForPrompt', () => { + it('returns empty string when memory is disabled', () => { + const result = service.loadMemoryForPrompt(tmpDir); + expect(result).toBe(''); + }); - it('returns empty string when no memory files exist', async () => { - const { getMemoryConfig } = await import('../../src/memory/config.js'); - vi.mocked(getMemoryConfig).mockReturnValue({ - enabled: true, - model: '', - promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], - } as any); - - const result = service.loadMemoryForPrompt(tmpDir); - expect(result).toBe(''); - }); + it('returns empty string when no memory file exists', async () => { + await enableConfig(); + const result = service.loadMemoryForPrompt(tmpDir); + expect(result).toBe(''); + }); - it('loads memory from project file', async () => { - const { getMemoryConfig } = await import('../../src/memory/config.js'); - vi.mocked(getMemoryConfig).mockReturnValue({ - enabled: true, - model: '', - promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], - } as any); - - const projectMemFile = path.join(tmpDir, '.codingcode/memory.md'); - fs.mkdirSync(path.dirname(projectMemFile), { recursive: true }); - fs.writeFileSync( - projectMemFile, - ` -### project -- Architecture decision 1 -` - ); - - const result = service.loadMemoryForPrompt(tmpDir); - expect(result).toContain('## Long-term Memory'); - expect(result).toContain('### project'); - expect(result).toContain('Architecture decision 1'); - expect(result).not.toContain(''); - }); + it('loads whole memory file', async () => { + await enableConfig(); + writeMemory('### project\n- Architecture decision 1'); - it('truncates memory when exceeds promptMaxBytes', async () => { - const { getMemoryConfig } = await import('../../src/memory/config.js'); - vi.mocked(getMemoryConfig).mockReturnValue({ - enabled: true, - model: '', - promptMaxBytes: 100, - extraTypes: [], - disabledTypes: [], - } as any); - - const projectMemFile = path.join(tmpDir, '.codingcode/memory.md'); - fs.mkdirSync(path.dirname(projectMemFile), { recursive: true }); - fs.writeFileSync( - projectMemFile, - ` -### project -- Very long content that should be truncated ${' x'.repeat(200)} -` - ); - - const result = service.loadMemoryForPrompt(tmpDir); - const bytes = Buffer.byteLength(result.replace('## Long-term Memory\n\n', ''), 'utf-8'); - expect(bytes).toBeLessThanOrEqual(100); - }); + const result = service.loadMemoryForPrompt(tmpDir); + expect(result).toContain('## Long-term Memory'); + expect(result).toContain('### project'); + expect(result).toContain('Architecture decision 1'); }); - describe('flushSessionToMemory', () => { - it('returns early when memory disabled', async () => { - const result = await service.flushSessionToMemory('fake-session-id', null, tmpDir); - expect(result.written).toBe(false); + it('truncates memory when exceeds promptMaxBytes', async () => { + const { getMemoryConfig } = await import('../../src/memory/config.js'); + vi.mocked(getMemoryConfig).mockReturnValue({ + enabled: true, + model: '', + promptMaxBytes: 100, }); + writeMemory(`### project +- Very long content that should be truncated ${' x'.repeat(200)}`); - it('returns early when session not found', async () => { - const { getMemoryConfig } = await import('../../src/memory/config.js'); - vi.mocked(getMemoryConfig).mockReturnValue({ - enabled: true, - model: '', - promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], - } as any); - - const result = await service.flushSessionToMemory('nonexistent-session', null, tmpDir); - expect(result.written).toBe(false); - }); + const result = service.loadMemoryForPrompt(tmpDir); + const bytes = Buffer.byteLength(result.replace('## Long-term Memory\n\n', ''), 'utf-8'); + expect(bytes).toBeLessThanOrEqual(100); + }); +}); - it('gracefully handles missing LLM', async () => { - const { getMemoryConfig } = await import('../../src/memory/config.js'); - vi.mocked(getMemoryConfig).mockReturnValue({ - enabled: true, - model: '', - promptMaxBytes: 8192, - extraTypes: [], - disabledTypes: [], - } as any); - - const result = await service.flushSessionToMemory('session', null, tmpDir); - expect(result.written).toBe(false); - }); +describe('flushSessionToMemory', () => { + it('returns early when memory disabled', async () => { + const result = await service.flushSessionToMemory('fake-session-id', null, tmpDir); + expect(result.written).toBe(false); }); - describe('runtime memory toggle', () => { - afterEach(() => { - service.setMemoryEnabled(false); - }); + it('returns early when session has no events', async () => { + await enableConfig(); + const result = await service.flushSessionToMemory('empty-session', null, tmpDir); + expect(result.written).toBe(false); + }); - it('setMemoryEnabled(true) makes getMemoryEnabled return true', () => { - service.setMemoryEnabled(true); - expect(service.getMemoryEnabled()).toBe(true); - }); + it('gracefully handles missing LLM', async () => { + await enableConfig(); + const { readTranscript } = await import('../../src/session/file-ops.js'); + vi.mocked(readTranscript).mockImplementation(() => [ + { type: 'user', content: 'hello' }, + ] as any); + const result = await service.flushSessionToMemory('session', null, tmpDir); + expect(result.written).toBe(false); + }); - it('setMemoryEnabled(false) makes getMemoryEnabled return false', () => { - service.setMemoryEnabled(false); - expect(service.getMemoryEnabled()).toBe(false); - }); + it('replaces the whole memory file with extracted content', async () => { + await enableConfig(); + writeMemory('### 旧主题\n- 旧内容'); + const { readTranscript } = await import('../../src/session/file-ops.js'); + vi.mocked(readTranscript).mockImplementation(() => [ + { type: 'user', content: '记住新架构决策' }, + { type: 'assistant', content: '好的' }, + ] as any); + const llm = createMockLlm('### 项目\n- 新的架构决策'); - it('toggle sequence works correctly', () => { - service.setMemoryEnabled(true); - expect(service.getMemoryEnabled()).toBe(true); - service.setMemoryEnabled(false); - expect(service.getMemoryEnabled()).toBe(false); - }); + const result = await service.flushSessionToMemory('session', llm, tmpDir); - it('loadMemoryForPrompt returns empty when runtime disabled', () => { - service.setMemoryEnabled(false); - const result = service.loadMemoryForPrompt(tmpDir); - expect(result).toBe(''); - }); + expect(result.written).toBe(true); + expect(result.bytes).toBeGreaterThan(0); + expect(fs.readFileSync(memFile, 'utf-8')).toBe('### 项目\n- 新的架构决策'); + }); - it('loadMemoryForPrompt does not short-circuit when runtime enabled', () => { - service.setMemoryEnabled(true); - expect(service.getMemoryEnabled()).toBe(true); - // No memory files → still empty, but NOT because of disabled check - const result = service.loadMemoryForPrompt(tmpDir); - expect(result).toBe(''); - }); + it('keeps file unchanged when model returns empty memory', async () => { + await enableConfig(); + writeMemory('### 旧主题\n- 旧内容'); + const { readTranscript } = await import('../../src/session/file-ops.js'); + vi.mocked(readTranscript).mockImplementation(() => [ + { type: 'user', content: 'hello' }, + ] as any); + + const result = await service.flushSessionToMemory('session', createMockLlm(''), tmpDir); + + expect(result.written).toBe(false); + expect(fs.readFileSync(memFile, 'utf-8')).toBe('### 旧主题\n- 旧内容'); + }); + + it('skips rewrite when extracted content equals current file', async () => { + await enableConfig(); + writeMemory('### 主题\n- 不变的内容'); + const { readTranscript } = await import('../../src/session/file-ops.js'); + vi.mocked(readTranscript).mockImplementation(() => [ + { type: 'user', content: '无新信息' }, + ] as any); - it('flushSessionToMemory returns early when runtime disabled', async () => { - service.setMemoryEnabled(false); - const result = await service.flushSessionToMemory('any-session', null, tmpDir); - expect(result.written).toBe(false); + const result = await service.flushSessionToMemory( + 'session', + createMockLlm('### 主题\n- 不变的内容'), + tmpDir + ); + + expect(result.written).toBe(false); + }); + + it('does not overwrite a memory file manually edited during extraction', async () => { + await enableConfig(); + writeMemory('### 旧主题\n- 旧内容'); + const { readTranscript } = await import('../../src/session/file-ops.js'); + vi.mocked(readTranscript).mockImplementation(() => [ + { type: 'user', content: 'hello' }, + ] as any); + const llm = createMockLlm('### 自动\n- 新记忆', () => { + writeMemory('### 手动\n- 用户并发编辑'); }); + + const result = await service.flushSessionToMemory('session', llm, tmpDir); + + expect(result.written).toBe(false); + expect(fs.readFileSync(memFile, 'utf-8')).toBe('### 手动\n- 用户并发编辑'); + }); +}); + +describe('runtime memory toggle', () => { + afterEach(() => { + service.setMemoryEnabled(false); + }); + + it('setMemoryEnabled(true) makes getMemoryEnabled return true', () => { + service.setMemoryEnabled(true); + expect(service.getMemoryEnabled()).toBe(true); + }); + + it('setMemoryEnabled(false) makes getMemoryEnabled return false', () => { + service.setMemoryEnabled(false); + expect(service.getMemoryEnabled()).toBe(false); + }); + + it('toggle sequence works correctly', () => { + service.setMemoryEnabled(true); + expect(service.getMemoryEnabled()).toBe(true); + service.setMemoryEnabled(false); + expect(service.getMemoryEnabled()).toBe(false); + }); + + it('loadMemoryForPrompt returns empty when runtime disabled', () => { + service.setMemoryEnabled(false); + const result = service.loadMemoryForPrompt(tmpDir); + expect(result).toBe(''); + }); + + it('flushSessionToMemory returns early when runtime disabled', async () => { + service.setMemoryEnabled(false); + const result = await service.flushSessionToMemory('any-session', null, tmpDir); + expect(result.written).toBe(false); }); }); diff --git a/packages/codingcode/test/memory/llm-resolver.test.ts b/packages/codingcode/test/memory/llm-resolver.test.ts index fd43a48e..cccb2b95 100644 --- a/packages/codingcode/test/memory/llm-resolver.test.ts +++ b/packages/codingcode/test/memory/llm-resolver.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { Effect } from 'effect'; import { resolveLLM } from '../../src/llm/llm-resolver.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; +import { LLMFactoryService } from '../../src/llm/port.js'; import { AgentError } from '../../src/core/error.js'; import type { LLMClient } from '../../src/llm/client.js'; -import type { SelectableModel } from '../../src/llm/factory.js'; +import type { SelectableModel } from '../../src/llm/port.js'; const { mockFindModel, mockCreateClient } = vi.hoisted(() => ({ mockFindModel: vi.fn(), diff --git a/packages/codingcode/test/memory/storage.test.ts b/packages/codingcode/test/memory/storage.test.ts index 66e7ae1c..d6963dd4 100644 --- a/packages/codingcode/test/memory/storage.test.ts +++ b/packages/codingcode/test/memory/storage.test.ts @@ -4,12 +4,9 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { readMemoryFile, - extractAutoBlock, - replaceAutoBlock, + resolveMemoryPath, enforceMaxBytes, - mergeAutoBlocks, writeMemoryFileAtomic, - stripMarkersForPrompt, } from '../../src/memory/storage.js'; const tmpDir = path.join(os.tmpdir(), 'memory-test'); @@ -29,7 +26,13 @@ afterEach(() => { cleanup(); }); -describe('File Operations', () => { +describe('resolveMemoryPath', () => { + it('points to .codingcode/memory.md under cwd', () => { + expect(resolveMemoryPath('/proj')).toBe(path.join('/proj', '.codingcode', 'memory.md')); + }); +}); + +describe('readMemoryFile', () => { it('reads non-existent file as empty string', () => { const result = readMemoryFile(path.join(tmpDir, 'nonexistent.md')); expect(result).toBe(''); @@ -42,119 +45,58 @@ describe('File Operations', () => { const result = readMemoryFile(file); expect(result).toBe(content); }); +}); - it('extracts auto block', () => { - const content = `Some text - -### user -- Item 1 - -More text`; - const result = extractAutoBlock(content); - expect(result).toContain('### user'); - expect(result).toContain('- Item 1'); - expect(result).not.toContain(''); - }); - - it('extracts empty auto block when markers absent', () => { - const content = 'No markers here'; - const result = extractAutoBlock(content); - expect(result).toBe(''); - }); - - it('replaces auto block in existing content', () => { - const content = `Before - -Old content - -After`; - const newAuto = '### new\n- content'; - const result = replaceAutoBlock(content, newAuto); - expect(result).toContain('Before'); - expect(result).toContain('After'); - expect(result).toContain(newAuto); - expect(result).not.toContain('Old content'); - }); - - it('creates auto block when markers absent', () => { - const content = 'Just text'; - const newAuto = '### user\n- item'; - const result = replaceAutoBlock(content, newAuto); - expect(result).toContain(''); - expect(result).toContain(''); - expect(result).toContain(newAuto); +describe('writeMemoryFileAtomic', () => { + it('writes file atomically', () => { + const file = path.join(tmpDir, 'atomic.md'); + const content = 'Test content'; + writeMemoryFileAtomic(file, content); + expect(fs.existsSync(file)).toBe(true); + expect(fs.readFileSync(file, 'utf-8')).toBe(content); }); - it('strips markers for prompt injection', () => { - const content = ` -### user -- Item 1 -`; - const result = stripMarkersForPrompt(content); - expect(result).not.toContain(''); - expect(result).not.toContain(''); - expect(result).toContain('### user'); + it('creates parent directories', () => { + const file = path.join(tmpDir, 'deep/nested/dir/file.md'); + const content = 'Nested content'; + writeMemoryFileAtomic(file, content); + expect(fs.existsSync(file)).toBe(true); + expect(fs.readFileSync(file, 'utf-8')).toBe(content); }); }); describe('enforceMaxBytes', () => { it('returns content unchanged if under limit', () => { - const content = '### user\n- Item 1'; + const content = '### 主题\n- Item 1'; const result = enforceMaxBytes(content, 1000); expect(result).toBe(content); }); - it('truncates content by dropping H3 sections from oldest', () => { - const content = `### user -- Very long content here ${' x'.repeat(100)} + it('drops H3 sections from the end until under limit', () => { + const content = `### first +- ${'a'.repeat(100)} -### project -- Another section ${' y'.repeat(100)} +### second +- ${'b'.repeat(100)} -### reference -- Third section`; +### third +- ${'c'.repeat(100)}`; const result = enforceMaxBytes(content, 200); - // Should drop oldest sections first - expect(result.length).toBeLessThanOrEqual(200); + expect(Buffer.byteLength(result, 'utf-8')).toBeLessThanOrEqual(200); + expect(result).toContain('### first'); }); -}); -describe('mergeAutoBlocks', () => { - it('merges H3 sections with incoming overriding base', () => { - const base = `### user -- Old role - -### project -- Existing decision`; - const incoming = `### user -- New role - -### reference -- New resource`; - const result = mergeAutoBlocks(base, incoming); - expect(result).toContain('### user'); - expect(result).toContain('- New role'); - expect(result).toContain('### project'); - expect(result).toContain('- Existing decision'); - expect(result).toContain('### reference'); - expect(result).toContain('- New resource'); + it('falls back to line truncation when a single H3 section exceeds limit', () => { + const content = `### huge +- ${'x'.repeat(500)}`; + const result = enforceMaxBytes(content, 100); + expect(Buffer.byteLength(result, 'utf-8')).toBeLessThanOrEqual(100); + expect(result.length).toBeGreaterThan(0); }); -}); -describe('writeMemoryFileAtomic', () => { - it('writes file atomically', () => { - const file = path.join(tmpDir, 'atomic.md'); - const content = 'Test content'; - writeMemoryFileAtomic(file, content); - expect(fs.existsSync(file)).toBe(true); - expect(fs.readFileSync(file, 'utf-8')).toBe(content); - }); - - it('creates parent directories', () => { - const file = path.join(tmpDir, 'deep/nested/dir/file.md'); - const content = 'Nested content'; - writeMemoryFileAtomic(file, content); - expect(fs.existsSync(file)).toBe(true); - expect(fs.readFileSync(file, 'utf-8')).toBe(content); + it('falls back to line truncation when content has no H3 sections', () => { + const content = `${'l'.repeat(50)}\n${'m'.repeat(200)}`; + const result = enforceMaxBytes(content, 100); + expect(Buffer.byteLength(result, 'utf-8')).toBeLessThanOrEqual(100); }); }); diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index 846e4dab..cd1d79c0 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -1,362 +1,54 @@ import { describe, it, expect, vi } from 'vitest'; -import { Context, Effect, Layer } from 'effect'; -import { HookService } from '../src/hooks/registry.js'; -import { SessionService } from '../src/session/store.js'; -import { SkillService } from '../src/skills/service.js'; -import { CheckpointService } from '../src/checkpoint/checkpoint-service.js'; -import { ProjectRuntimeService } from '../src/runtime/project-runtime.js'; -import { TodoService } from '../src/agent/todo.js'; -import { ContextService } from '../src/context/service.js'; -import { MemoryService } from '../src/memory/index.js'; -import { RulesService } from '../src/rules/index.js'; -import { LLMFactoryService } from '../src/llm/factory.js'; -import { SubagentRunnerService } from '../src/subagent/runner-service.js'; - -vi.mock('../src/checkpoint/checkpoint-service.js', () => { - const tag = Context.GenericTag('Checkpoint'); - return { - CheckpointService: tag, - snapshotBaseline: vi.fn(), - snapshotFinal: vi.fn(), - getCompletedTurns: vi.fn(() => []), - getCheckpoints: vi.fn(() => []), - getCheckpointDiff: vi.fn(() => ({ turnId: 0, files: [] })), - revertCheckpointFiles: vi.fn(() => ({ - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - })), - previewRollbackDiff: vi.fn(() => ({ throughTurnId: 0, affectedTurns: [], diff: '' })), - rollbackCodeToTurn: vi.fn(() => ({ - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - })), - undoLastCodeRollback: vi.fn(() => ({ - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], - })), - getLatestRestoreEntry: vi.fn(() => null), - }; -}); - -const mockState = { - sessionId: 'test-session', - cwd: '/tmp/test', - messageCount: 0, - currentTurnId: 0, - sessionMeta: null, - model: 'test', - title: 'test-sess', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - usage: undefined, - memorySnapshot: '', -}; - -const MockCheckpointLayer = Layer.succeed(CheckpointService, { - _tag: 'Checkpoint' as const, - snapshotBaseline: vi.fn(() => Effect.void), - snapshotFinal: vi.fn(() => Effect.void), - getCompletedTurns: vi.fn(() => Effect.succeed([])), - getCheckpoints: vi.fn(() => Effect.succeed([])), - getCheckpointDiff: vi.fn(() => Effect.succeed({ turnId: 0, files: [] })), - revertCheckpointFiles: vi.fn(() => - Effect.succeed({ - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }) - ), - previewRollbackDiff: vi.fn(() => - Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }) - ), - rollbackCodeToTurn: vi.fn(() => - Effect.succeed({ - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }) - ), - undoLastCodeRollback: vi.fn(() => - Effect.succeed({ - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], - }) - ), - getLatestRestoreEntry: vi.fn(() => Effect.succeed(null)), -} as any); - -const MockSkillLayer = Layer.succeed(SkillService, { - _tag: 'Skill' as const, - getAll: vi.fn(() => Effect.succeed([])), - findByName: vi.fn(() => Effect.succeed(undefined)), - select: vi.fn(() => Effect.succeed(undefined)), - selectImplicit: vi.fn(() => Effect.succeed(undefined)), - extractSkill: vi.fn((_p: string, q: string) => - Effect.sync(() => [undefined, q] as [undefined, string]) - ), - evictProject: vi.fn(() => Effect.void), -} as any); - -import { sendMessage } from '../src/agent/agent.js'; -import { ToolExecutorService } from '../src/tools/executor.js'; -import { Result } from '../src/core/result.js'; -import { McpService } from '../src/mcp/index.js'; - -const mockLlm = { - modelInfo: { - provider: 'mock', - model: 'mock-model', - maxTokens: 1000, - supportsToolCalling: true, - supportsStreaming: true, - }, - complete: () => Effect.succeed({ content: 'Hello world', finishReason: 'stop' as const }), - completeStream: (_params: any) => { - const stream = (async function* () { - yield 'Hello'; - yield ' '; - yield 'world'; - })(); - return { - stream, - response: Promise.resolve( - Result.ok({ content: 'Hello world', finishReason: 'stop' as const }) - ), - }; - }, -}; - -const MockToolExecutorLayer = Layer.succeed( - ToolExecutorService, - ToolExecutorService.of({ - _tag: 'ToolExecutor' as const, - execute: () => Effect.succeed({ output: 'done' }), - executeBatch: (toolCalls: any[]) => - Effect.succeed( - toolCalls.map((tc: any) => ({ type: 'ok' as const, id: tc.id, name: tc.name, output: '' })) - ), - }) -); - -const AgentService = Context.GenericTag('Agent'); -const AgentLayer = Layer.succeed(AgentService, { - runStream: async function* (opts: any) { - const messages = [{ role: 'user' as const, content: 'hi' }]; - yield { _tag: 'TurnId', turnId: 0 }; - yield { _tag: 'Step', step: 1, max: opts.maxStepsOverride ?? 10 }; - const { stream: rawStream, response } = opts.llm.completeStream({ - messages, - system: '', - tools: [], - }); - for await (const chunk of rawStream) { - yield { _tag: 'LlmChunk', text: chunk }; - } - const resp = await response; - const content = (resp as any).ok ? ((resp as any).value?.content ?? '') : ''; - const toolCalls = (resp as any).ok ? (resp as any).value?.toolCalls : undefined; - yield { _tag: 'Assistant', content, toolCalls }; - yield { _tag: 'Done', content }; - }, -}); - -const MockMcpLayer = Layer.succeed(McpService, { - syncConnections: (_: string) => Effect.void, - status: (_: string) => Effect.succeed([]), - listProjectMcpTools: (_: string) => [], -} as any); - -vi.mock('../src/runtime/project-runtime.js', () => ({ - ProjectRuntimeService: Context.GenericTag('ProjectRuntime'), - prepareProject: vi.fn(() => Effect.void), - resolveMainAgentProfile: vi.fn((_p: string, _s: string) => undefined), - resolveSubagentProfile: vi.fn((_p: string, _n: string) => undefined), - listAgentProfiles: vi.fn((_p: string) => []), - getToolPolicy: vi.fn(() => ({ - allowedTools: undefined, - allowedMcpServers: undefined, - })), - setSessionProfile: vi.fn(() => Effect.void), - restoreSessionProfile: vi.fn(() => Effect.void), - getSessionProfile: vi.fn(() => undefined), - disposeSession: vi.fn(() => Effect.void), - disposeProject: vi.fn(() => Effect.void), -})); - -const MockSessionLayer = Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', - create: (_cwd: string, _options: any) => Effect.succeed({ ...mockState }), - load: (_cwd: string, _sid: string) => Effect.succeed({ ...mockState }), - recordUser: () => - Effect.succeed({ - type: 'user' as const, - content: '', - turnId: 0, - }), - recordAssistant: () => - Effect.succeed({ - type: 'assistant' as const, - content: '', - toolCalls: [], - - turnId: 0, - }), - recordToolResult: () => - Effect.succeed({ - type: 'tool_result' as const, - toolName: 'test', - toolCallId: 'tc1', - output: '', - turnId: 0, - }), - incrementTurn: () => 0, -} as any); - -const { ApprovalWaitService } = await import('../src/approval/async-confirm.js'); -const { ApprovalService } = await import('../src/approval/index.js'); -const MockApprovalWaitLayer = ApprovalWaitService.Default; -const HookLayer = HookService.Default; -const MockApprovalLayer = ApprovalService.Default.pipe( - Layer.provide(Layer.mergeAll(HookLayer, MockApprovalWaitLayer)) -); - -const MockProjectRuntimeLayer = Layer.succeed(ProjectRuntimeService, { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: () => undefined, - listAgentProfiles: () => [], - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, - }), - setSessionProfile: () => {}, - restoreSessionProfile: () => Effect.void, - getSessionProfile: () => undefined, - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, -} as any); - -const MockTodoLayer = Layer.succeed(TodoService, { - read: () => [], - write: () => {}, - reset: () => {}, -} as any); - -const MockContextLayer = Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [{ role: 'user' as const, content: 'hi' }], - compactedEvents: [], - promptEstimate: 0, - currentTurnId: 0, - compactedTurnIds: new Set(), +import { + makeState, + runAgentTurn, + llmStream, + pText, + pEnd, + texts, + endReason, +} from './helpers/agent-harness.js'; + +vi.mock('@codingcode/infra/config', () => ({ + loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, + context: { compactionModel: '' }, + memory: { enabled: false }, + server: { port: 8080 }, }), - compactIfNeeded: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 0 }), - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 0 }), -} as any); - -const MockMemoryLayer = Layer.succeed(MemoryService, { - getMemoryEnabled: () => false, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), -} as any); - -const MockRulesLayer = Layer.succeed(RulesService, { - getAllRules: () => '', - evictProjectRules: () => {}, -} as any); - -const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { - listModels: () => Effect.succeed([]), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new Error('no active model')), - setActiveEntry: () => Effect.void, - createClient: () => Effect.fail(new Error('no factory')), -} as any); - -const MockSubagentRunnerLayer = Layer.succeed(SubagentRunnerService, { - runStream: async function* () { - yield { _tag: 'Done' as const, content: '' }; - }, -} as any); - -const AllDeps = Layer.mergeAll( - MockToolExecutorLayer, - HookLayer, - MockMcpLayer, - MockSessionLayer, - MockApprovalLayer, - MockApprovalWaitLayer, - MockCheckpointLayer, - MockSkillLayer, - MockProjectRuntimeLayer, - MockTodoLayer, - MockContextLayer, - MockMemoryLayer, - MockRulesLayer, - MockLLMFactoryLayer, - MockSubagentRunnerLayer -); - -const TestLayer = Layer.mergeAll(AgentLayer, AllDeps); +})); -describe('sendMessage stream', () => { - async function setupSession(): Promise { - return Effect.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.create('/tmp/test', { - model: 'mock-model', - activeProfile: 'build', - permissionMode: 'default', - }); - return state.sessionId; - }).pipe(Effect.provide(TestLayer) as any) +const state = makeState({ sessionId: 'test-session', cwd: '/tmp/test', title: 'test-sess' }); + +function makeLlm() { + const llm = { + completeStream: () => llmStream(pText('Hello'), pText(' '), pText('world'), pEnd()), + modelInfo: { maxTokens: 1000 }, + } as any; + return llm; +} + +describe('runTurn event stream', () => { + it('should yield text_delta events from LLM stream', async () => { + const { events } = await runAgentTurn( + { llm: makeLlm(), state }, + { sessionId: 'test-session', cwd: '/tmp/test' } ); - } - - it('should yield AgentEvent chunks from LLM', async () => { - const sessionId = await setupSession(); - const program = sendMessage(sessionId, 'hi', '/tmp/test', mockLlm, {}); - const { stream } = (await Effect.runPromise( - program.pipe(Effect.provide(TestLayer) as any) - )) as any; - const events: any[] = []; - for await (const event of stream) events.push(event); - - const textChunks = events.filter((e: any) => e._tag === 'LlmChunk').map((e: any) => e.text); - expect(textChunks).toContain('Hello'); - expect(textChunks).toContain(' '); - expect(textChunks).toContain('world'); + const chunks = texts(events); + expect(chunks).toContain('Hello'); + expect(chunks).toContain(' '); + expect(chunks).toContain('world'); }); - it('should not return empty event stream for normal LLM response', async () => { - const sessionId = await setupSession(); - const program = sendMessage(sessionId, 'hi', '/tmp/test', mockLlm, {}); - const { stream } = (await Effect.runPromise( - program.pipe(Effect.provide(TestLayer) as any) - )) as any; - - const events: any[] = []; - for await (const event of stream) events.push(event); + it('should produce a non-empty event stream for a normal LLM response', async () => { + const { events } = await runAgentTurn( + { llm: makeLlm(), state }, + { sessionId: 'test-session', cwd: '/tmp/test' } + ); expect(events.length).toBeGreaterThan(0); + expect(endReason(events)).toBe('done'); }); }); diff --git a/packages/codingcode/test/plan/allowed-tools.test.ts b/packages/codingcode/test/plan/allowed-tools.test.ts new file mode 100644 index 00000000..e1303286 --- /dev/null +++ b/packages/codingcode/test/plan/allowed-tools.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { + PLAN_PROFILE, + BUILD_PROFILE, + getToolNames, + PLAN_TOOL_NAMES, + BUILD_TOOL_NAMES, +} from '../../src/agent/profile.js'; + +describe('getToolNames (profile tool name list)', () => { + it('plan profile includes submit_plan and excludes write tools', () => { + const names = getToolNames(PLAN_PROFILE); + expect(names).toContain('submit_plan'); + expect(names).toContain('read_file'); + expect(names).not.toContain('write_file'); + expect(names).not.toContain('execute_command'); + }); + + it('build profile includes write tools and excludes submit_plan', () => { + const names = getToolNames(BUILD_PROFILE); + expect(names).toContain('write_file'); + expect(names).toContain('execute_command'); + expect(names).not.toContain('submit_plan'); + }); + + it('undefined profile falls back to the build tool list', () => { + expect(getToolNames(undefined)).toEqual(BUILD_TOOL_NAMES); + }); + + it('plan and build lists differ on the write/submit_plan axis', () => { + expect(PLAN_TOOL_NAMES).not.toContain('write_file'); + expect(BUILD_TOOL_NAMES).not.toContain('submit_plan'); + }); +}); diff --git a/packages/codingcode/test/plan/gate-pipeline.test.ts b/packages/codingcode/test/plan/gate-pipeline.test.ts index 489f1212..81bc3022 100644 --- a/packages/codingcode/test/plan/gate-pipeline.test.ts +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -1,39 +1,22 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer } from 'effect'; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; +import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { runPipeline } from '../../src/approval/pipeline.js'; +import { runPipeline } from '../../src/approval/approval.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 { planProfileGateHook } from '../../src/agent/profile.js'; -import { computePaths } from '../../src/core/path.js'; -import type { DecisionHandler } from '../../src/hooks/types.js'; +import { HookService } from '../../src/hooks/port.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.js'; +import type { ProfileName } from '../../src/core/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; -const base = useTempProjectBase(); - -const decisionHandlers: DecisionHandler[] = []; +useTempProjectBase(); const mockHookService = { register: () => Effect.succeed(() => {}), - registerDecision: (_point: string, handler: DecisionHandler, _opts?: any) => - Effect.sync(() => { - decisionHandlers.push(handler); - }), + registerDecision: () => Effect.succeed(() => {}), emit: () => Effect.succeed(undefined), - emitDecision: (point: string, payload: any) => - Effect.sync(() => { - if (point === 'tool.approval.pre') { - for (const h of decisionHandlers) { - const result = h(payload); - if (result) return result; - } - } - return null; - }), + emitDecision: () => Effect.succeed(null), reloadUserHooks: () => Effect.succeed(undefined), attachSessionHooks: () => Effect.succeed(undefined), disableHook: () => Effect.succeed(undefined), @@ -48,7 +31,6 @@ function makeMockApprovalWait() { return { waitForConfirm: () => Effect.succeed({ type: 'deny' }) as any, resolveConfirm: () => Effect.succeed(false), - getPending: () => Effect.succeed([]), emitApprovalRequest: (sessionId: string, id: string, tool: string, args: any) => Effect.sync(() => { capturedApproval = { sessionId, id, tool, args }; @@ -60,39 +42,14 @@ function makeMockApprovalWait() { }; } -function makeIndex(cwd: string, sessionId: string, activeProfile: 'plan' | 'build') { - const paths = computePaths(cwd, sessionId); - mkdirSync(paths.transcriptPath.replace(/\.jsonl$/, ''), { recursive: true }); - const idx = { - sessionId, - cwd: paths.cwd, - model: 'test', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 0, - title: sessionId.slice(0, 8), - currentTurnId: 0, - usage: undefined, - activeProfile, - permissionMode: 'default', - }; - writeFileSync(paths.indexPath, JSON.stringify(idx, null, 2), 'utf8'); -} - function runPipelineWithMock(opts: { tool: string; input: any; permissionMode: 'default' | 'acceptEdits' | 'bypass'; sessionId: string; - planProfile: boolean; - cwd: string; + profile: ProfileName; }) { capturedApproval = null; - decisionHandlers.length = 0; - decisionHandlers.push(planProfileGateHook); - - if (opts.planProfile) makeIndex(opts.cwd, opts.sessionId, 'plan'); - else makeIndex(opts.cwd, opts.sessionId, 'build'); const mockWait = makeMockApprovalWait(); const HookTestLayer = Layer.succeed(HookService, mockHookService as any); @@ -103,91 +60,89 @@ function runPipelineWithMock(opts: { { tool: opts.tool, input: opts.input }, { ruleEngine: createRuleEngine([]), - readonlyTools: new Set(READONLY_TOOL_NAMES), destructiveTools: new Set(), permissionMode: opts.permissionMode, + profile: opts.profile, sessionId: opts.sessionId, - projectPath: opts.cwd, } ).pipe(Effect.provide(TestLayer) as any) ); } -describe('Plan profile gate hook integration', () => { +describe('plan profile permission mode (Layer 2)', () => { 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 profile + write_file: gate denies before reaching user confirmation', async () => { + it('plan profile + write_file: denied before reaching user confirmation', async () => { const decision: any = await runPipelineWithMock({ tool: 'write_file', input: { path: '/tmp/x', content: 'foo' }, permissionMode: 'default', sessionId: 's2', - planProfile: true, - cwd, + profile: 'plan', }); expect(decision.type).toBe('deny'); + expect(decision.source).toBe('permission-mode'); expect(decision.reason).toMatch(/plan profile/i); expect(capturedApproval).toBeNull(); }); - it('plan profile + execute_command: gate denies with plan-profile reason', async () => { + it('plan profile + execute_command: denied with plan-profile reason', async () => { const decision: any = await runPipelineWithMock({ tool: 'execute_command', input: { command: 'rm -rf /' }, permissionMode: 'default', sessionId: 's3', - planProfile: true, - cwd, + profile: 'plan', }); expect(decision.type).toBe('deny'); + expect(decision.source).toBe('permission-mode'); expect(decision.reason).toMatch(/plan profile/i); expect(capturedApproval).toBeNull(); }); - it('plan profile + dispatch_agent: readonly approval remains unchanged', async () => { + it('plan profile + dispatch_agent: denied by plan mode', async () => { const decision: any = await runPipelineWithMock({ tool: 'dispatch_agent', input: { agent: 'build', prompt: 'do something' }, permissionMode: 'default', sessionId: 's4', - planProfile: true, - cwd, + profile: 'plan', }); - expect(decision.type).toBe('allow'); + expect(decision.type).toBe('deny'); + expect(decision.source).toBe('permission-mode'); + expect(decision.reason).toMatch(/plan profile/i); + expect(capturedApproval).toBeNull(); }); - it('build profile + write_file: gate does not fire, pipeline falls through normally', async () => { + it('build profile + write_file: falls through to user confirmation', async () => { const decision: any = await runPipelineWithMock({ tool: 'write_file', input: { path: '/tmp/x', content: 'foo' }, permissionMode: 'default', sessionId: 's5', - planProfile: false, - cwd, + profile: 'build', }); expect(capturedApproval).not.toBeNull(); expect(decision.source).toBe('user-confirm'); }); - it('submit_plan: pipeline short-circuits at Layer 5', async () => { + it('plan profile + submit_plan: allowed by plan allow-list', async () => { const decision: any = await runPipelineWithMock({ tool: 'submit_plan', input: { plan_content: '# plan' }, permissionMode: 'default', sessionId: 's6', - planProfile: true, - cwd, + profile: 'plan', }); expect(decision.type).toBe('allow'); - expect(decision.source).toBe('system-plan-self-handles'); + expect(decision.source).toBe('permission-mode'); expect(capturedApproval).toBeNull(); }); }); diff --git a/packages/codingcode/test/plan/gate.test.ts b/packages/codingcode/test/plan/gate.test.ts deleted file mode 100644 index 9603c284..00000000 --- a/packages/codingcode/test/plan/gate.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { planProfileGateHook, isSessionUsingPlanProfile } from '../../src/agent/profile.js'; -import { computePaths } from '../../src/core/path.js'; -import { useTempProjectBase } from '../helpers/project-base.js'; - -const base = useTempProjectBase(); - -function makeSessionIndex(cwd: string, sessionId: string, activeProfile: 'plan' | 'build') { - const paths = computePaths(cwd, sessionId); - mkdirSync(paths.transcriptPath.replace(/\.jsonl$/, ''), { recursive: true }); - const idx = { - sessionId, - cwd: paths.cwd, - model: 'test', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 0, - title: sessionId.slice(0, 8), - currentTurnId: 0, - usage: undefined, - activeProfile, - permissionMode: 'default', - }; - writeFileSync(paths.indexPath, JSON.stringify(idx, null, 2), 'utf8'); - return paths; -} - -describe('planProfileGateHook', () => { - let cwd: string; - let sessionId: string; - - beforeEach(() => { - cwd = join(base.dir, 'gate'); - mkdirSync(cwd, { recursive: true }); - sessionId = 'sess-gate'; - }); - - afterEach(() => { - rmSync(cwd, { recursive: true, force: true }); - }); - - it('returns null when no sessionId is present', () => { - expect(planProfileGateHook({ toolName: 'write_file' } as any)).toBeNull(); - }); - - it('returns null when the session is not in plan profile', () => { - makeSessionIndex(cwd, sessionId, 'build'); - expect( - planProfileGateHook({ toolName: 'write_file', sessionId, projectPath: cwd } as any) - ).toBeNull(); - }); - - it('returns null when the tool is not provided', () => { - makeSessionIndex(cwd, sessionId, 'plan'); - expect(planProfileGateHook({ sessionId, projectPath: cwd } as any)).toBeNull(); - }); - - it('allows submit_plan in plan profile', () => { - makeSessionIndex(cwd, sessionId, 'plan'); - expect( - planProfileGateHook({ toolName: 'submit_plan', sessionId, projectPath: cwd } as any) - ).toBeNull(); - }); - - it('denies dispatch_agent in plan profile', () => { - makeSessionIndex(cwd, sessionId, 'plan'); - expect( - planProfileGateHook({ toolName: 'dispatch_agent', sessionId, projectPath: cwd } as any) - ).toMatchObject({ decision: 'deny' }); - }); - - it('denies write_file in plan profile with the plan-profile reason', () => { - makeSessionIndex(cwd, sessionId, 'plan'); - const result = planProfileGateHook({ - toolName: 'write_file', - sessionId, - projectPath: cwd, - } as any); - expect(result).toEqual({ - decision: 'deny', - reason: 'Write operations denied in plan profile. Use submit_plan to submit a plan.', - }); - }); - - it('denies execute_command in plan profile', async () => { - makeSessionIndex(cwd, sessionId, 'plan'); - const result = await planProfileGateHook({ - toolName: 'execute_command', - sessionId, - projectPath: cwd, - } as any); - expect(result?.decision).toBe('deny'); - expect(result?.reason).toMatch(/plan profile/i); - }); - - it('denies edit_file in plan profile', async () => { - makeSessionIndex(cwd, sessionId, 'plan'); - const result = await planProfileGateHook({ - toolName: 'edit_file', - sessionId, - projectPath: cwd, - } as any); - expect(result?.decision).toBe('deny'); - }); -}); - -describe('isSessionUsingPlanProfile', () => { - 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(isSessionUsingPlanProfile('s-plan', cwd)).toBe(true); - }); - - it('returns false when index has mode=build', () => { - makeSessionIndex(cwd, 's-build', 'build'); - expect(isSessionUsingPlanProfile('s-build', cwd)).toBe(false); - }); - - it('returns false when index file does not exist', () => { - expect(isSessionUsingPlanProfile('s-missing', cwd)).toBe(false); - }); -}); diff --git a/packages/codingcode/test/plan/policy.test.ts b/packages/codingcode/test/plan/policy.test.ts index abd191e9..22a5c858 100644 --- a/packages/codingcode/test/plan/policy.test.ts +++ b/packages/codingcode/test/plan/policy.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { PLAN_PROFILE_ALLOWED_TOOLS } from '../../src/agent/profile.js'; +import { PLAN_ALLOWED_TOOLS } from '../../src/approval/types.js'; -describe('PLAN_PROFILE_ALLOWED_TOOLS', () => { +describe('PLAN_ALLOWED_TOOLS', () => { it('contains only read tools and submit_plan', () => { - expect(PLAN_PROFILE_ALLOWED_TOOLS).toEqual( + expect(PLAN_ALLOWED_TOOLS).toEqual( new Set(['read_file', 'search_files', 'search_code', 'fetch_url', 'submit_plan']) ); }); it('does not expose write tools', () => { - expect(PLAN_PROFILE_ALLOWED_TOOLS.has('write_file')).toBe(false); - expect(PLAN_PROFILE_ALLOWED_TOOLS.has('edit_file')).toBe(false); - expect(PLAN_PROFILE_ALLOWED_TOOLS.has('execute_command')).toBe(false); + expect(PLAN_ALLOWED_TOOLS.has('write_file')).toBe(false); + expect(PLAN_ALLOWED_TOOLS.has('edit_file')).toBe(false); + expect(PLAN_ALLOWED_TOOLS.has('execute_command')).toBe(false); }); }); diff --git a/packages/codingcode/test/runtime/set-session-profile.test.ts b/packages/codingcode/test/runtime/set-session-profile.test.ts deleted file mode 100644 index e7fca2c5..00000000 --- a/packages/codingcode/test/runtime/set-session-profile.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -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 { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { SessionService } from '../../src/session/store.js'; -import { computePaths } from '../../src/core/path.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { McpService } from '../../src/mcp/index.js'; -import { RulesService } from '../../src/rules/index.js'; -import { BUILD_PROFILE, PLAN_PROFILE } from '../../src/agent/profile.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() { - const HookTestLayer = Layer.succeed(HookService, mockHookService as any); - const McpTestLayer = Layer.succeed(McpService, mockMcpService); - const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); - const SessionTestLayer = SessionService.Default; - const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookTestLayer, McpTestLayer, RulesTestLayer, SessionTestLayer)) - ); - return Layer.mergeAll(ProjectRuntimeTestLayer, SessionTestLayer); -} - -describe('ProjectRuntimeService.setSessionProfile (disk-only)', () => { - let cwd: string; - let sessionId: string; - let indexPath: string; - let rt: ManagedRuntime.ManagedRuntime; - - beforeEach(async () => { - cwd = join(base.dir, 'set-session-profile'); - mkdirSync(cwd, { recursive: true }); - rt = ManagedRuntime.make(makeLayer() as any); - const result = 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', - activeProfile: 'build', - permissionMode: 'default', - }); - return { - sessionId: state.sessionId, - indexPath: computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, - }; - }) - ); - sessionId = result.sessionId; - indexPath = result.indexPath; - }); - - afterEach(async () => { - await rt.dispose(); - }); - - it('writes activeProfile + permissionMode 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')); - expect(idx.activeProfile).toBe('plan'); - expect(idx).not.toHaveProperty('mode'); - expect(idx.permissionMode).toBe('default'); - expect(idx.activeProfile).toBe('plan'); - }); - - it('writes activeProfile + permissionMode 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.activeProfile).toBe('build'); - expect(idx).not.toHaveProperty('mode'); - expect(idx.permissionMode).toBe('bypass'); - expect(idx.activeProfile).toBe('build'); - }); -}); diff --git a/packages/codingcode/test/scheduler/approval-bypass.test.ts b/packages/codingcode/test/scheduler/approval-bypass.test.ts deleted file mode 100644 index c4895e14..00000000 --- a/packages/codingcode/test/scheduler/approval-bypass.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -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(new URL('../../src/scheduler/service.ts', import.meta.url), 'utf8'); - expect(src).not.toMatch(/approvalOverride:\s*\{\s*permissionMode:\s*['"]bypass['"]\s*\}/); - }); - - it('scheduler imports ApprovalService', () => { - const src = readFileSync(new URL('../../src/scheduler/service.ts', import.meta.url), 'utf8'); - expect(src).toMatch( - /import\s*\{[^}]*ApprovalService[^}]*\}\s*from\s*['"]\.\.\/approval\/index\.js['"]/ - ); - }); - - it('scheduler resolves ApprovalService and forks with bypass', () => { - const src = readFileSync(new URL('../../src/scheduler/service.ts', import.meta.url), '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-profile-restart.test.ts b/packages/codingcode/test/security/plan-profile-restart.test.ts index c8bdcbf4..7942adeb 100644 --- a/packages/codingcode/test/security/plan-profile-restart.test.ts +++ b/packages/codingcode/test/security/plan-profile-restart.test.ts @@ -3,40 +3,23 @@ import { Effect, Layer, ManagedRuntime } from 'effect'; import { mkdtempSync, rmSync, readFileSync } 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 { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { McpService } from '../../src/mcp/index.js'; -import { RulesService } from '../../src/rules/index.js'; -import { ApprovalService } from '../../src/approval/index.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { planProfileGateHook, isSessionUsingPlanProfile } from '../../src/agent/profile.js'; -import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/agent/profile.js'; -import type { DecisionHandler } from '../../src/hooks/types.js'; +import { HookService } from '../../src/hooks/port.js'; +import { ApprovalService } from '../../src/approval/port.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.js'; +import type { ProfileName } from '../../src/core/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; +import { ApprovalLayer } from '../../src/approval/approval.js'; useTempProjectBase(); -const decisionHandlers: DecisionHandler[] = []; - const mockHookService = { register: () => Effect.succeed(() => {}), - registerDecision: (_point: string, handler: DecisionHandler, _opts?: any) => - Effect.sync(() => { - decisionHandlers.push(handler); - }), + registerDecision: () => Effect.succeed(() => {}), emit: () => Effect.succeed(undefined), - emitDecision: (point: string, payload: any) => - Effect.sync(() => { - if (point === 'tool.approval.pre') { - for (const h of decisionHandlers) { - const result = h(payload); - if (result) return result; - } - } - return null; - }), + emitDecision: () => Effect.succeed(null), reloadUserHooks: () => Effect.succeed(undefined), attachSessionHooks: () => Effect.succeed(undefined), disableHook: () => Effect.succeed(undefined), @@ -45,22 +28,9 @@ const mockHookService = { 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), @@ -70,13 +40,7 @@ const mockApprovalWaitService = { function makeLayer() { const HookTestLayer = Layer.succeed(HookService, mockHookService as any); - const McpTestLayer = Layer.succeed(McpService, mockMcpService); - const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); - const SessionTestLayer = SessionService.Default; - const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookTestLayer, McpTestLayer, RulesTestLayer, SessionTestLayer)) - ); - const ApprovalTestLayer = ApprovalService.Default.pipe( + const ApprovalTestLayer = ApprovalLayer.pipe( Layer.provide( Layer.mergeAll( HookTestLayer, @@ -84,17 +48,22 @@ function makeLayer() { ) ) ); - const TestLayer = Layer.mergeAll( - ProjectRuntimeTestLayer, - SessionTestLayer, + return Layer.mergeAll( + SessionLayer, HookTestLayer, ApprovalTestLayer, Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) ); - return TestLayer; } -describe('plan profile security boundary (cross-restart, disk only)', () => { +function setProfileEffect(cwd: string, sessionId: string, profile: 'plan' | 'build') { + return Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setActiveProfile(cwd, sessionId, profile); + }); +} + +describe('plan profile security boundary (permission-mode, disk-persisted profile)', () => { let cwd: string; let sessionId: string; let indexPath: string; @@ -102,8 +71,6 @@ describe('plan profile security boundary (cross-restart, disk only)', () => { beforeEach(async () => { cwd = mkdtempSync(join(tmpdir(), 'codingcode-security-test-')); - decisionHandlers.length = 0; - decisionHandlers.push(planProfileGateHook); rt = ManagedRuntime.make(makeLayer() as any); const result = await rt.runPromise( Effect.gen(function* () { @@ -128,116 +95,67 @@ describe('plan profile security boundary (cross-restart, disk only)', () => { rmSync(cwd, { recursive: true, force: true }); }); - async function evaluateAsSession(tool: string, input: any): Promise { + async function evaluateAsProfile( + tool: string, + input: any, + profile: ProfileName + ): Promise { return rt.runPromise( Effect.gen(function* () { const approval = yield* ApprovalService; - 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({ + return yield* approval.evaluate({ tool, input, sessionId, projectPath: cwd, + permissionMode: 'default', + profile, }); }) ); } - it('scenario 1: switch to plan, write_file is denied by the plan-profile gate hook', async () => { - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - }) - ); - expect(isSessionUsingPlanProfile(sessionId, cwd)).toBe(true); - - const decision = await evaluateAsSession('write_file', { path: '/tmp/x', content: 'foo' }); + it('plan profile: write_file is denied by permission-mode', async () => { + const decision = await evaluateAsProfile('write_file', { path: '/tmp/x', content: 'foo' }, 'plan'); expect(decision.type).toBe('deny'); expect(decision.reason).toMatch(/plan profile/i); - expect(decision.source).toBe('hook'); + expect(decision.source).toBe('permission-mode'); }); - it('scenario 2: switch to plan, execute_command is denied by the plan-profile gate hook', async () => { - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - }) - ); - - const decision = await evaluateAsSession('execute_command', { command: 'echo hello' }); + it('plan profile: execute_command is denied by permission-mode', async () => { + const decision = await evaluateAsProfile('execute_command', { command: 'echo hello' }, 'plan'); expect(decision.type).toBe('deny'); expect(decision.reason).toMatch(/plan profile/i); - expect(decision.source).toBe('hook'); + expect(decision.source).toBe('permission-mode'); }); - 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; - yield* runtime.prepareProject(cwd); - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - }) - ); - - const decision: any = await evaluateAsSession('submit_plan', { plan_content: 'do things' }); + it('plan profile: submit_plan is allowed by the plan allow-list', async () => { + const decision: any = await evaluateAsProfile('submit_plan', { plan_content: 'do things' }, 'plan'); expect(decision.type).toBe('allow'); - expect(decision.source).toBe('system-plan-self-handles'); + expect(decision.source).toBe('permission-mode'); }); - it('scenario 4: after restart (state reloaded from disk), plan profile still enforced', async () => { - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - }) - ); + it('after restart (state reloaded from disk), plan profile persists', async () => { + await rt.runPromise(setProfileEffect(cwd, sessionId, 'plan')); const idx = JSON.parse(readFileSync(indexPath, 'utf8')); expect(idx.activeProfile).toBe('plan'); - expect(idx).not.toHaveProperty('mode'); await rt.dispose(); - decisionHandlers.length = 0; - decisionHandlers.push(planProfileGateHook); rt = ManagedRuntime.make(makeLayer() as any); await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); expect(state.activeProfile).toBe('plan'); - expect(state).not.toHaveProperty('mode'); - expect(isSessionUsingPlanProfile(sessionId, cwd)).toBe(true); }) ); - - const decision = await evaluateAsSession('write_file', { path: '/tmp/x', content: 'foo' }); - expect(decision.type).toBe('deny'); - expect(decision.reason).toMatch(/plan profile/i); }); - it('scenario 5: plan profile → switch to build → write_file is no longer denied by plan profile', async () => { - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); - yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE); - }) - ); - expect(isSessionUsingPlanProfile(sessionId, cwd)).toBe(false); - - const decision: any = await evaluateAsSession('write_file', { path: '/tmp/x', content: 'foo' }); + it('build profile: write_file is not denied by plan permission-mode', async () => { + const decision: any = await evaluateAsProfile('write_file', { path: '/tmp/x', content: 'foo' }, 'build'); if (decision.type === 'deny') { - expect(decision.source).not.toBe('hook'); + expect(decision.source).not.toBe('permission-mode'); expect(decision.reason).not.toMatch(/plan profile/i); } }); diff --git a/packages/codingcode/test/self/todo/service.test.ts b/packages/codingcode/test/self/todo/service.test.ts index bc8fdc8a..b8fb0e66 100644 --- a/packages/codingcode/test/self/todo/service.test.ts +++ b/packages/codingcode/test/self/todo/service.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest'; import { Effect } from 'effect'; -import { TodoService, countByStatus } from '../../../src/agent/todo.js'; -import type { Todo } from '../../../src/agent/types.js'; +import { TodoService, countByStatus } from '../../../src/todo/port.js'; +import type { Todo } from '../../../src/todo/port.js'; +import { TodoLayer } from '../../../src/todo/todo.js'; describe('TodoService', () => { it('write then read returns full list', async () => { @@ -16,7 +17,7 @@ describe('TodoService', () => { const svc = yield* TodoService; svc.write('agent-a', plan); return svc.read('agent-a'); - }).pipe(Effect.provide(TodoService.Default)) + }).pipe(Effect.provide(TodoLayer)) ); expect(got).toEqual(plan); @@ -32,7 +33,7 @@ describe('TodoService', () => { readA: svc.read('agent-a'), readB: svc.read('agent-b'), }; - }).pipe(Effect.provide(TodoService.Default)) + }).pipe(Effect.provide(TodoLayer)) ); expect(readA).toHaveLength(1); @@ -48,7 +49,7 @@ describe('TodoService', () => { svc.write('agent-r', [{ step: 'first', status: 'pending' }]); svc.write('agent-r', [{ step: 'second', status: 'completed' }]); return svc.read('agent-r'); - }).pipe(Effect.provide(TodoService.Default)) + }).pipe(Effect.provide(TodoLayer)) ); expect(got).toHaveLength(1); @@ -60,7 +61,7 @@ describe('TodoService', () => { Effect.gen(function* () { const svc = yield* TodoService; return svc.read('unknown'); - }).pipe(Effect.provide(TodoService.Default)) + }).pipe(Effect.provide(TodoLayer)) ); expect(result).toEqual([]); @@ -86,7 +87,7 @@ describe('TodoService', () => { svc.reset(); return svc.read('agent-x'); - }).pipe(Effect.provide(TodoService.Default)) + }).pipe(Effect.provide(TodoLayer)) ); expect(result).toEqual([]); diff --git a/packages/codingcode/test/server/adapter-todo.test.ts b/packages/codingcode/test/server/adapter-todo.test.ts deleted file mode 100644 index a21a9028..00000000 --- a/packages/codingcode/test/server/adapter-todo.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { agentEventToSseEvent } from '../../src/server/adapter.js'; - -describe('agentEventToSseEvent with TodoUpdate', () => { - it('should serialize TodoUpdate as structured object', () => { - const items = [ - { step: 'install deps', status: 'pending' as const }, - { step: 'write tests', status: 'completed' as const }, - ]; - const result = agentEventToSseEvent({ - _tag: 'TodoUpdate', - items, - }); - - expect(result).toEqual({ - type: 'todo_update', - items, - }); - }); - - it('should handle empty items array', () => { - const result = agentEventToSseEvent({ - _tag: 'TodoUpdate', - items: [], - }); - - expect(result).toEqual({ type: 'todo_update', items: [] }); - }); - - it('should handle in_progress status', () => { - const result = agentEventToSseEvent({ - _tag: 'TodoUpdate', - items: [{ step: 'deploy', status: 'in_progress' }], - }); - - expect(result).toEqual({ - type: 'todo_update', - items: [{ step: 'deploy', status: 'in_progress' }], - }); - }); - - it('should return structured event for Step, null for LlmChunk (handled by toSseEvents)', () => { - expect(agentEventToSseEvent({ _tag: 'Step', step: 1, max: 5 })).toEqual({ - type: 'step', - step: 1, - }); - expect(agentEventToSseEvent({ _tag: 'LlmChunk', text: 'hi' })).toBeNull(); - }); -}); diff --git a/packages/codingcode/test/server/adapter.test.ts b/packages/codingcode/test/server/adapter.test.ts deleted file mode 100644 index 1583ed5a..00000000 --- a/packages/codingcode/test/server/adapter.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { agentEventToSseEvent, toSseEvents } from '../../src/server/adapter.js'; -import type { AgentEvent } from '../../src/agent/types.js'; -import { AgentError } from '../../src/core/error.js'; - -describe('agentEventToSseEvent', () => { - it('maps LlmChunk to null (handled by toSseEvents)', () => { - expect(agentEventToSseEvent({ _tag: 'LlmChunk', text: 'hello' })).toBeNull(); - }); - - it('maps Step to structured step event', () => { - expect(agentEventToSseEvent({ _tag: 'Step', step: 3, max: 10 })).toEqual({ - type: 'step', - step: 3, - }); - }); - - it('maps ToolStart to structured tool_start event', () => { - expect( - agentEventToSseEvent({ _tag: 'ToolStart', id: 'tc-1', name: 'readFile', args: {} }) - ).toEqual({ type: 'tool_start', id: 'tc-1', name: 'readFile', args: {} }); - }); - - it('maps ToolDenied to tool_denied event', () => { - expect( - agentEventToSseEvent({ _tag: 'ToolDenied', id: 'tc-1', name: 'bash', reason: 'not allowed' }) - ).toEqual({ type: 'tool_denied', id: 'tc-1', name: 'bash', reason: 'not allowed' }); - }); - - it('maps ToolResult to tool_result event', () => { - expect( - agentEventToSseEvent({ _tag: 'ToolResult', id: 'x', name: 't', output: 'ok', ok: true }) - ).toEqual({ type: 'tool_result', id: 'x', name: 't', output: 'ok', ok: true }); - }); - - it('maps Error to error event', () => { - const err = AgentError.llmFailed('test'); - expect(agentEventToSseEvent({ _tag: 'Error', error: err })).toEqual({ - type: 'error', - message: err.message, - code: 'LLM_FAILED', - }); - }); - - it('maps Done to done event', () => { - expect(agentEventToSseEvent({ _tag: 'Done', content: 'final' })).toEqual({ type: 'done' }); - }); - - it('maps TodoUpdate to todo_update event', () => { - const items = [{ step: 'test', status: 'completed' as const }]; - expect(agentEventToSseEvent({ _tag: 'TodoUpdate', items })).toEqual({ - type: 'todo_update', - items, - }); - }); - - it('maps Usage to usage event', () => { - expect( - agentEventToSseEvent({ _tag: 'Usage', prompt: 1000, completion: 500, total: 1500 }) - ).toEqual({ type: 'usage', prompt: 1000, completion: 500, total: 1500 }); - }); - - it('returns null for Assistant and ReactiveCompact', () => { - expect(agentEventToSseEvent({ _tag: 'Assistant', content: 'ok' })).toBeNull(); - expect( - agentEventToSseEvent({ - _tag: 'ReactiveCompact', - attempt: 1, - released: 100, - promptEstimate: 0, - }) - ).toBeNull(); - }); -}); - -describe('toSseEvents with Usage', () => { - it('Usage events flow through toSseEvents', async () => { - async function* source(): AsyncGenerator { - yield { _tag: 'Step', step: 1, max: 10 }; - yield { _tag: 'Assistant', content: 'ok' }; - yield { _tag: 'Usage', prompt: 1000, completion: 500, total: 1500 }; - } - const result: any[] = []; - for await (const s of toSseEvents(source())) result.push(s); - expect(result).toEqual([ - { type: 'step', step: 1 }, - { type: 'message', id: 1, content: 'ok', partial: false }, - { type: 'usage', prompt: 1000, completion: 500, total: 1500 }, - ]); - }); -}); - -describe('toSseEvents', () => { - it('text chunks carry messageId from preceding Step', async () => { - async function* source(): AsyncGenerator { - yield { _tag: 'Step', step: 1, max: 10 }; - yield { _tag: 'LlmChunk', text: 'Hello' }; - yield { _tag: 'LlmChunk', text: ' world' }; - yield { _tag: 'Done', content: 'final' }; - } - const result: any[] = []; - for await (const s of toSseEvents(source())) result.push(s); - expect(result).toEqual([ - { type: 'step', step: 1 }, - { type: 'text', text: 'Hello', messageId: 1 }, - { type: 'text', text: ' world', messageId: 1 }, - { type: 'done' }, - ]); - }); - - it('step changes update messageId', async () => { - async function* source(): AsyncGenerator { - yield { _tag: 'Step', step: 1, max: 10 }; - yield { _tag: 'LlmChunk', text: 'a' }; - yield { _tag: 'Step', step: 2, max: 10 }; - yield { _tag: 'LlmChunk', text: 'b' }; - } - const result: any[] = []; - for await (const s of toSseEvents(source())) result.push(s); - expect(result).toEqual([ - { type: 'step', step: 1 }, - { type: 'text', text: 'a', messageId: 1 }, - { type: 'step', step: 2 }, - { type: 'text', text: 'b', messageId: 2 }, - ]); - }); - - it('Assistant yields final message event with partial=false after chunk text', async () => { - async function* source(): AsyncGenerator { - yield { _tag: 'Step', step: 1, max: 10 }; - yield { _tag: 'LlmChunk', text: 'Hello' }; - yield { _tag: 'LlmChunk', text: ' world' }; - yield { _tag: 'Assistant', content: 'Hello world' }; - yield { _tag: 'Done', content: 'Hello world' }; - } - const result: any[] = []; - for await (const s of toSseEvents(source())) result.push(s); - expect(result).toEqual([ - { type: 'step', step: 1 }, - { type: 'text', text: 'Hello', messageId: 1 }, - { type: 'text', text: ' world', messageId: 1 }, - { type: 'message', id: 1, content: 'Hello world', partial: false }, - { type: 'done' }, - ]); - }); - - it('Assistant with toolCalls yields message event with content only', async () => { - async function* source(): AsyncGenerator { - yield { _tag: 'Step', step: 2, max: 10 }; - yield { _tag: 'LlmChunk', text: 'calling tool' }; - yield { - _tag: 'Assistant', - content: 'calling tool', - toolCalls: [{ id: 'tc-1', name: 'list_dir', arguments: {} }], - }; - } - const result: any[] = []; - for await (const s of toSseEvents(source())) result.push(s); - expect(result).toContainEqual({ - type: 'message', - id: 2, - content: 'calling tool', - partial: false, - }); - }); - - it('text before first Step uses messageId 0', async () => { - async function* source(): AsyncGenerator { - yield { _tag: 'LlmChunk', text: 'early' }; - yield { _tag: 'Step', step: 1, max: 10 }; - yield { _tag: 'LlmChunk', text: 'late' }; - } - const result: any[] = []; - for await (const s of toSseEvents(source())) result.push(s); - expect(result[0]).toEqual({ type: 'text', text: 'early', messageId: 0 }); - expect(result[2]).toEqual({ type: 'text', text: 'late', messageId: 1 }); - }); -}); diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index c394eaaa..4907c976 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -2,17 +2,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { createServer } from '../../src/server/index.js'; import { WorkspaceService } from '../../src/core/workspace.js'; -import { SessionService } from '../../src/session/store.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; -import { ApprovalService } from '../../src/approval/index.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { SkillService } from '../../src/skills/service.js'; -import { McpService } from '../../src/mcp/index.js'; -import { MemoryService } from '../../src/memory/index.js'; -import { SchedulerService } from '../../src/scheduler/service.js'; -import { ContextService } from '../../src/context/service.js'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; +import { SessionService } from '../../src/session/port.js'; +import { LLMFactoryService } from '../../src/llm/port.js'; +import { ApprovalService } from '../../src/approval/port.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.js'; +import { HookService } from '../../src/hooks/port.js'; +import { SkillService } from '../../src/skills/port.js'; +import { McpService } from '../../src/mcp/port.js'; +import { MemoryService } from '../../src/memory/port.js'; +import { SchedulerService } from '../../src/scheduler/port.js'; +import { ContextService } from '../../src/context/port.js'; +import { CheckpointService } from '../../src/checkpoint/port.js'; +import { HookLayer } from '../../src/hooks/hooks.js'; +import { ApprovalWaitLayer } from '../../src/approval/wait.js'; +import { ApprovalLayer } from '../../src/approval/approval.js'; const mockCompactWithLLM = vi.fn(); @@ -22,7 +25,6 @@ const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { } as any); const MockSessionLayer = Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', create: () => Effect.succeed({ sessionId: 'test-sid', @@ -55,7 +57,6 @@ const MockSessionLayer = Layer.succeed(SessionService, { output: '', turnId: 0, }), - incrementTurn: () => 0, } as any); const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { @@ -98,18 +99,14 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { switchModel: () => Effect.fail(new Error('no models')), } as any); -const MockApprovalLayer = ApprovalService.Default.pipe( - Layer.provide(Layer.mergeAll(HookService.Default, ApprovalWaitService.Default)) +const MockApprovalLayer = ApprovalLayer.pipe( + Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer)) ); const MockSkillLayer = Layer.succeed(SkillService, { _tag: 'Skill' as const, getAll: () => Effect.succeed([]), - findByName: () => Effect.succeed(undefined), - select: () => Effect.succeed(undefined), - selectImplicit: () => Effect.succeed(undefined), extractSkill: (_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]), - evictProject: () => Effect.void, } as any); const MockMcpLayer = Layer.succeed(McpService, { @@ -138,13 +135,7 @@ const MockSchedulerLayer = Layer.succeed(SchedulerService, { } as any); const MockContextLayer = Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [], - compactedEvents: [], - promptEstimate: 0, - currentTurnId: 0, - compactedTurnIds: new Set(), - }), + assemblePayload: async () => [], compactWithLLM: mockCompactWithLLM, } as any); @@ -152,8 +143,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { _tag: 'Checkpoint' as const, snapshotBaseline: () => Effect.void, snapshotFinal: () => Effect.void, - getCompletedTurns: () => Effect.succeed([]), - getCheckpoints: () => Effect.succeed([]), getCheckpointDiff: () => Effect.succeed({ turnId: 0, files: [] }), revertCheckpointFiles: () => Effect.succeed({ @@ -161,7 +150,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { throughTurnId: 0, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }), previewRollbackDiff: () => Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }), rollbackCodeToTurn: () => @@ -170,17 +158,7 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { throughTurnId: 0, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }), - undoLastCodeRollback: () => - Effect.succeed({ - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], - }), - getLatestRestoreEntry: () => Effect.succeed(null), } as any); const TestLayer = Layer.mergeAll( @@ -188,8 +166,8 @@ const TestLayer = Layer.mergeAll( MockSessionLayer, MockLLMFactoryLayer, MockApprovalLayer, - HookService.Default, - ApprovalWaitService.Default, + HookLayer, + ApprovalWaitLayer, MockSkillLayer, MockMcpLayer, MockMemoryLayer, @@ -198,7 +176,7 @@ const TestLayer = Layer.mergeAll( MockCheckpointLayer ); -const rt = ManagedRuntime.make(TestLayer); +const rt = ManagedRuntime.make(TestLayer as any); describe('POST /api/sessions/:id/compact (manual compact)', () => { beforeEach(() => { @@ -265,8 +243,8 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { MockSessionLayer, FailingFactoryLayer, MockApprovalLayer, - HookService.Default, - ApprovalWaitService.Default, + HookLayer, + ApprovalWaitLayer, MockSkillLayer, MockMcpLayer, MockMemoryLayer, @@ -274,7 +252,7 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { MockContextLayer, MockCheckpointLayer ); - const failRt = ManagedRuntime.make(FailLayer); + const failRt = ManagedRuntime.make(FailLayer as any); const app = await createServer(failRt); const res = await app.request('/api/sessions/test-sid/compact', { method: 'POST', 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 7c06272e..332d5ff2 100644 --- a/packages/codingcode/test/server/create-session-active-profile.test.ts +++ b/packages/codingcode/test/server/create-session-active-profile.test.ts @@ -3,61 +3,17 @@ import { Effect, Layer, ManagedRuntime } from 'effect'; import { Hono } from 'hono'; import { readFileSync, mkdirSync } from 'fs'; import { join } from 'path'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { McpService } from '../../src/mcp/index.js'; -import { RulesService } from '../../src/rules/index.js'; import { WorkspaceService } from '../../src/core/workspace.js'; import { registerSessionsRoutes } from '../../src/server/routes/sessions.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), -} as any; - -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() { - const HookTestLayer = Layer.succeed(HookService, mockHookService); - const McpTestLayer = Layer.succeed(McpService, mockMcpService); - const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); - const SessionTestLayer = SessionService.Default; - const WorkspaceTestLayer = WorkspaceService.Default; - const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( - Layer.provide( - Layer.mergeAll( - HookTestLayer, - McpTestLayer, - RulesTestLayer, - SessionTestLayer, - WorkspaceTestLayer - ) - ) - ); - return Layer.mergeAll(ProjectRuntimeTestLayer, SessionTestLayer, WorkspaceTestLayer); + return Layer.mergeAll(SessionLayer, WorkspaceService.Default); } describe('POST /api/sessions — atomic mode + permissionMode + model', () => { @@ -71,12 +27,6 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { rt = ManagedRuntime.make(makeLayer() as any); app = new Hono(); registerSessionsRoutes(app, rt); - await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - }) - ); }); afterEach(async () => { diff --git a/packages/codingcode/test/server/index.test.ts b/packages/codingcode/test/server/index.test.ts index 4eaf30c3..e9b24123 100644 --- a/packages/codingcode/test/server/index.test.ts +++ b/packages/codingcode/test/server/index.test.ts @@ -2,17 +2,20 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { createServer } from '../../src/server/index.js'; import { WorkspaceService } from '../../src/core/workspace.js'; -import { SessionService } from '../../src/session/store.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; -import { ApprovalService } from '../../src/approval/index.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { SkillService } from '../../src/skills/service.js'; -import { McpService } from '../../src/mcp/index.js'; -import { MemoryService } from '../../src/memory/index.js'; -import { SchedulerService } from '../../src/scheduler/service.js'; -import { ContextService } from '../../src/context/service.js'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; +import { SessionService } from '../../src/session/port.js'; +import { LLMFactoryService } from '../../src/llm/port.js'; +import { ApprovalService } from '../../src/approval/port.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.js'; +import { HookService } from '../../src/hooks/port.js'; +import { SkillService } from '../../src/skills/port.js'; +import { McpService } from '../../src/mcp/port.js'; +import { MemoryService } from '../../src/memory/port.js'; +import { SchedulerService } from '../../src/scheduler/port.js'; +import { ContextService } from '../../src/context/port.js'; +import { CheckpointService } from '../../src/checkpoint/port.js'; +import { HookLayer } from '../../src/hooks/hooks.js'; +import { ApprovalWaitLayer } from '../../src/approval/wait.js'; +import { ApprovalLayer } from '../../src/approval/approval.js'; const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { getWorkspaceCwd: () => '/tmp/test', @@ -20,7 +23,6 @@ const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { } as any); const MockSessionLayer = Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', create: () => Effect.succeed({ sessionId: 'test', cwd: '/tmp/test' }), recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), recordAssistant: () => @@ -38,25 +40,20 @@ const MockSessionLayer = Layer.succeed(SessionService, { output: '', turnId: 0, }), - incrementTurn: () => 0, } as any); const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { getLLMClient: () => Effect.succeed(null), } as any); -const MockApprovalLayer = ApprovalService.Default.pipe( - Layer.provide(Layer.mergeAll(HookService.Default, ApprovalWaitService.Default)) +const MockApprovalLayer = ApprovalLayer.pipe( + Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer)) ); const MockSkillLayer = Layer.succeed(SkillService, { _tag: 'Skill' as const, getAll: () => Effect.succeed([]), - findByName: () => Effect.succeed(undefined), - select: () => Effect.succeed(undefined), - selectImplicit: () => Effect.succeed(undefined), extractSkill: (_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]), - evictProject: () => Effect.void, } as any); const MockMcpLayer = Layer.succeed(McpService, { @@ -90,8 +87,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { _tag: 'Checkpoint' as const, snapshotBaseline: () => Effect.void, snapshotFinal: () => Effect.void, - getCompletedTurns: () => Effect.succeed([]), - getCheckpoints: () => Effect.succeed([]), getCheckpointDiff: () => Effect.succeed({ turnId: 0, files: [] }), revertCheckpointFiles: () => Effect.succeed({ @@ -99,7 +94,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { throughTurnId: 0, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }), previewRollbackDiff: () => Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }), rollbackCodeToTurn: () => @@ -108,17 +102,7 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { throughTurnId: 0, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }), - undoLastCodeRollback: () => - Effect.succeed({ - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], - }), - getLatestRestoreEntry: () => Effect.succeed(null), } as any); const TestLayer = Layer.mergeAll( @@ -126,8 +110,8 @@ const TestLayer = Layer.mergeAll( MockSessionLayer, MockLLMFactoryLayer, MockApprovalLayer, - HookService.Default, - ApprovalWaitService.Default, + HookLayer, + ApprovalWaitLayer, MockSkillLayer, MockMcpLayer, MockMemoryLayer, @@ -136,7 +120,7 @@ const TestLayer = Layer.mergeAll( MockCheckpointLayer ); -const rt = ManagedRuntime.make(TestLayer); +const rt = ManagedRuntime.make(TestLayer as any); describe('createServer', () => { it('creates server without LLM client initialization', async () => { diff --git a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts index 79a93652..55bddbd7 100644 --- a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts +++ b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts @@ -5,15 +5,12 @@ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { registerMessagesRoutes } from '../../src/server/routes/messages.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { McpService } from '../../src/mcp/index.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 { HookService } from '../../src/hooks/port.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.js'; +import { AgentService } from '../../src/agent/port.js'; import { WorkspaceService } from '../../src/core/workspace.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -30,24 +27,11 @@ const mockHookService = { 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), @@ -55,43 +39,36 @@ const mockApprovalWaitService = { 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'), +const mockWorkspace = { + resolveWorkspaceCwd: (cwd: string | undefined) => cwd || '/tmp', } as any; -const mockWorkspace = { - resolveWorkspaceCwd: (cwd: string | undefined) => Effect.succeed(cwd || '/tmp'), +// The message-send path now lives in AgentService.runTurn. A real runTurn loads +// the persisted session (which reads permissionMode from the on-disk index) +// before streaming. We mirror that seam here so the test keeps validating that +// the fork/send path starts from the persisted session state. +const loadedPermissionModes: string[] = []; + +const mockAgentService = { + runTurn: (_input: string, opts: any) => + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(opts.cwd, opts.sessionId); + loadedPermissionModes.push(state.permissionMode); + return { + stream: (async function* () {})() as any, + sessionId: state.sessionId, + }; + }), } as any; function makeLayer() { return Layer.mergeAll( - ProjectRuntimeService.Default.pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed(HookService, mockHookService as any), - Layer.succeed(McpService, mockMcpService), - Layer.succeed(RulesService, mockRulesService), - SessionService.Default - ) - ) - ), - SessionService.Default, - Layer.succeed(HookService, mockHookService as any), + Layer.succeed(HookService, mockHookService), 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) + Layer.succeed(WorkspaceService, mockWorkspace), + Layer.succeed(AgentService, mockAgentService), + SessionLayer ); } @@ -120,6 +97,8 @@ describe('POST /api/sessions/:id/messages — reads permissionMode from disk', ( idx.permissionMode = 'bypass'; writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); + loadedPermissionModes.length = 0; + app = new Hono(); registerMessagesRoutes(app, rt); }); @@ -129,12 +108,13 @@ describe('POST /api/sessions/:id/messages — reads permissionMode from disk', ( rmSync(cwd, { recursive: true, force: true }); }); - it('does not crash and reaches the sendMessage path (fork uses disk permissionMode)', async () => { + it('does not crash and the message path loads the persisted session (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); + expect(loadedPermissionModes[0]).toBe('bypass'); }); }); diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts index fcbaa0bf..85448a22 100644 --- a/packages/codingcode/test/server/plan-file-route.test.ts +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -8,21 +8,23 @@ import { join } from 'path'; import { Hono } from 'hono'; import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; import { WorkspaceService } from '../../src/core/workspace.js'; -import { SessionService } from '../../src/session/store.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; -import { ApprovalService } from '../../src/approval/index.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { SkillService } from '../../src/skills/service.js'; -import { McpService } from '../../src/mcp/index.js'; -import { MemoryService } from '../../src/memory/index.js'; -import { SchedulerService } from '../../src/scheduler/service.js'; -import { ContextService } from '../../src/context/service.js'; -import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { SessionService } from '../../src/session/port.js'; +import { LLMFactoryService } from '../../src/llm/port.js'; +import { ApprovalService } from '../../src/approval/port.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.js'; +import { HookService } from '../../src/hooks/port.js'; +import { SkillService } from '../../src/skills/port.js'; +import { McpService } from '../../src/mcp/port.js'; +import { MemoryService } from '../../src/memory/port.js'; +import { SchedulerService } from '../../src/scheduler/port.js'; +import { ContextService } from '../../src/context/port.js'; +import { CheckpointService } from '../../src/checkpoint/port.js'; import { setProjectBaseDir } from '../../src/core/path.js'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; +import { HookLayer } from '../../src/hooks/hooks.js'; +import { ApprovalWaitLayer } from '../../src/approval/wait.js'; +import { ApprovalLayer } from '../../src/approval/approval.js'; const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { getWorkspaceCwd: () => '/tmp/test', @@ -30,7 +32,6 @@ const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { } as any); const MockSessionLayer = Layer.succeed(SessionService, { - getTranscriptPath: () => '/tmp/test.jsonl', create: () => Effect.succeed({ sessionId: 'test-sid', @@ -58,7 +59,6 @@ const MockSessionLayer = Layer.succeed(SessionService, { output: '', turnId: 0, }), - incrementTurn: () => 0, } as any); const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { @@ -101,18 +101,14 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { switchModel: () => Effect.fail(new Error('no models')), } as any); -const MockApprovalLayer = ApprovalService.Default.pipe( - Layer.provide(Layer.mergeAll(HookService.Default, ApprovalWaitService.Default)) +const MockApprovalLayer = ApprovalLayer.pipe( + Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer)) ); const MockSkillLayer = Layer.succeed(SkillService, { _tag: 'Skill' as const, getAll: () => Effect.succeed([]), - findByName: () => Effect.succeed(undefined), - select: () => Effect.succeed(undefined), - selectImplicit: () => Effect.succeed(undefined), extractSkill: (_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]), - evictProject: () => Effect.void, } as any); const MockMcpLayer = Layer.succeed(McpService, { @@ -141,13 +137,7 @@ const MockSchedulerLayer = Layer.succeed(SchedulerService, { } as any); const MockContextLayer = Layer.succeed(ContextService, { - assemblePayload: () => ({ - messages: [], - compactedEvents: [], - promptEstimate: 0, - currentTurnId: 0, - compactedTurnIds: new Set(), - }), + assemblePayload: async () => [], compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 0 }), } as any); @@ -155,8 +145,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { _tag: 'Checkpoint' as const, snapshotBaseline: () => Effect.void, snapshotFinal: () => Effect.void, - getCompletedTurns: () => Effect.succeed([]), - getCheckpoints: () => Effect.succeed([]), getCheckpointDiff: () => Effect.succeed({ turnId: 0, files: [] }), revertCheckpointFiles: () => Effect.succeed({ @@ -164,7 +152,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { throughTurnId: 0, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }), previewRollbackDiff: () => Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }), rollbackCodeToTurn: () => @@ -173,27 +160,7 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { throughTurnId: 0, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }), - undoLastCodeRollback: () => - Effect.succeed({ - restored: false, - conflict: false, - conflictFiles: [], - restoredFiles: [], - remainingRolledBack: [], - }), - getLatestRestoreEntry: () => Effect.succeed(null), -} as any); - -const MockProjectRuntimeLayer = Layer.succeed(ProjectRuntimeService, { - getSessionProfile: () => 'plan', - setSessionProfile: () => Effect.void, - resolveSubagentProfile: () => undefined, - registerActiveSession: () => Effect.void, - unregisterActiveSession: () => Effect.void, - getActiveSessions: () => [], - clearActiveSessions: () => Effect.void, } as any); const TestLayer = Layer.mergeAll( @@ -201,15 +168,14 @@ const TestLayer = Layer.mergeAll( MockSessionLayer, MockLLMFactoryLayer, MockApprovalLayer, - HookService.Default, - ApprovalWaitService.Default, + HookLayer, + ApprovalWaitLayer, MockSkillLayer, MockMcpLayer, MockMemoryLayer, MockSchedulerLayer, MockContextLayer, - MockCheckpointLayer, - MockProjectRuntimeLayer + MockCheckpointLayer ); let tempBase = ''; @@ -231,7 +197,7 @@ afterEach(() => { describe('GET /api/sessions/:id/plan', () => { it('returns exists:false with empty content when no .md file is present', async () => { - const rt = ManagedRuntime.make(TestLayer); + const rt = ManagedRuntime.make(TestLayer as any); const app = new Hono(); registerSessionsRoutes(app, rt); const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); @@ -258,7 +224,7 @@ describe('GET /api/sessions/:id/plan', () => { utimesSync(oldPath, olderDate, olderDate); utimesSync(newPath, newerDate, newerDate); - const rt = ManagedRuntime.make(TestLayer); + const rt = ManagedRuntime.make(TestLayer as any); const app = new Hono(); registerSessionsRoutes(app, rt); const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); @@ -278,7 +244,7 @@ describe('GET /api/sessions/:id/plan', () => { writeFileSync(mdPath, '# ONLY-MD', 'utf8'); writeFileSync(join(plansDir, 'notes.txt'), 'should be ignored', 'utf8'); - const rt = ManagedRuntime.make(TestLayer); + const rt = ManagedRuntime.make(TestLayer as any); const app = new Hono(); registerSessionsRoutes(app, rt); const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); diff --git a/packages/codingcode/test/server/routes-use-compute-paths.test.ts b/packages/codingcode/test/server/routes-use-compute-paths.test.ts deleted file mode 100644 index a52bfc92..00000000 --- a/packages/codingcode/test/server/routes-use-compute-paths.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -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( - new URL('../../src/server/routes/sessions.ts', import.meta.url), - 'utf8' - ); - expect(src).not.toMatch(/sessionJsonlPathFromCwd\([^)]+\)\.replace\(['"]\.jsonl['"]/); - }); - - it('server/routes/messages.ts uses computePaths(cwd, sessionId).indexPath', () => { - const src = readFileSync( - new URL('../../src/server/routes/messages.ts', import.meta.url), - '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 76b6ceb3..3f40296c 100644 --- a/packages/codingcode/test/session/compute-paths.test.ts +++ b/packages/codingcode/test/session/compute-paths.test.ts @@ -3,7 +3,8 @@ import { rmSync, existsSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { computePaths, sessionJsonlPathFromCwd, projectSessionsDir } from '../../src/core/path.js'; import { normalizePath, encodeProjectPath } from '../../src/core/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -11,7 +12,7 @@ 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)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } describe('computePaths', () => { diff --git a/packages/codingcode/test/session/create-active-profile.test.ts b/packages/codingcode/test/session/create-active-profile.test.ts index 7c371d44..a8c35e1a 100644 --- a/packages/codingcode/test/session/create-active-profile.test.ts +++ b/packages/codingcode/test/session/create-active-profile.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'fs'; import { Effect } from 'effect'; import { computePaths } from '../../src/core/path.js'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase(); function run(effect: Effect.Effect): Promise { - return Effect.runPromise(effect.pipe(Effect.provide(SessionService.Default) as any)); + return Effect.runPromise(effect.pipe(Effect.provide(SessionLayer) as any)); } describe('session activeProfile persistence', () => { @@ -51,15 +52,15 @@ describe('session activeProfile persistence', () => { await run( Effect.gen(function* () { const session = yield* SessionService; - yield* session.updateActiveProfile(state, 'plan'); - yield* session.recordUser(state, 'hello'); + yield* session.setActiveProfile(state.cwd, state.sessionId, 'plan'); + const reloaded = yield* session.load(state.cwd, state.sessionId); + yield* session.recordUser(reloaded, 'hello'); }) ); const paths = computePaths(state.cwd, state.sessionId, state.parentSessionId); const index = JSON.parse(readFileSync(paths.indexPath, 'utf8')); - expect(state.activeProfile).toBe('plan'); expect(index.activeProfile).toBe('plan'); expect(index).not.toHaveProperty('mode'); }); diff --git a/packages/codingcode/test/session/create-session-profile.test.ts b/packages/codingcode/test/session/create-session-profile.test.ts index 4a53df7d..ab3bbc5f 100644 --- a/packages/codingcode/test/session/create-session-profile.test.ts +++ b/packages/codingcode/test/session/create-session-profile.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase(); function run(effect: Effect.Effect): Promise { - return Effect.runPromise(effect.pipe(Effect.provide(SessionService.Default) as any)); + return Effect.runPromise(effect.pipe(Effect.provide(SessionLayer) as any)); } describe('SessionService.create profile', () => { diff --git a/packages/codingcode/test/session/disk-setters.test.ts b/packages/codingcode/test/session/disk-setters.test.ts index 05c55cbb..dc87d664 100644 --- a/packages/codingcode/test/session/disk-setters.test.ts +++ b/packages/codingcode/test/session/disk-setters.test.ts @@ -2,11 +2,12 @@ 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 { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { McpService } from '../../src/mcp/index.js'; -import { RulesService } from '../../src/rules/index.js'; +import { HookService } from '../../src/hooks/port.js'; +import { McpService } from '../../src/mcp/port.js'; +import { RulesService } from '../../src/rules/port.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -37,7 +38,7 @@ const mockRulesService = { } as any; function makeLayer() { - return SessionService.Default.pipe( + return SessionLayer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed(HookService, mockHookService as any), @@ -74,36 +75,71 @@ describe('SessionService disk setter/getter consistency', () => { await rt.dispose(); }); - it('setPermissionModeOnDisk + getPermissionModeFromDisk are consistent', async () => { + it('setPermissionMode persists to loaded state', async () => { await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - yield* session.setPermissionModeOnDisk(cwd, sessionId, 'bypass'); + yield* session.setPermissionMode(cwd, sessionId, 'bypass'); }) ); - const mode = await rt.runPromise( + const state = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.load(cwd, sessionId); + }) + ); + expect(state.permissionMode).toBe('bypass'); + }); + + it('setActiveProfile persists to loaded state', async () => { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setActiveProfile(cwd, sessionId, 'plan'); + }) + ); + const state = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.load(cwd, sessionId); + }) + ); + expect(state.activeProfile).toBe('plan'); + }); + + it('setActiveProfile to plan leaves permissionMode untouched', async () => { + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setActiveProfile(cwd, sessionId, 'plan'); + }) + ); + const state = await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - return yield* session.getPermissionModeFromDisk(cwd, sessionId); + return yield* session.load(cwd, sessionId); }) ); - expect(mode).toBe('bypass'); + expect(state.activeProfile).toBe('plan'); + expect(state.permissionMode).toBe('default'); }); - it('setActiveProfile + getActiveProfile are consistent', async () => { + it('setActiveProfile to build leaves permissionMode untouched', async () => { await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; yield* session.setActiveProfile(cwd, sessionId, 'plan'); + yield* session.setActiveProfile(cwd, sessionId, 'build'); }) ); - const profile = await rt.runPromise( + const state = await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - return yield* session.getActiveProfile(cwd, sessionId); + return yield* session.load(cwd, sessionId); }) ); - expect(profile).toBe('plan'); + expect(state.activeProfile).toBe('build'); + expect(state.permissionMode).toBe('default'); }); it('setActiveProfile is durable across reload (file exists on disk)', async () => { diff --git a/packages/codingcode/test/session/facade-surface.test.ts b/packages/codingcode/test/session/facade-surface.test.ts new file mode 100644 index 00000000..a5bb1e22 --- /dev/null +++ b/packages/codingcode/test/session/facade-surface.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; + +describe('session service surface', () => { + it('service shape exposes exactly the contract method set', async () => { + const service = await Effect.runPromise( + Effect.gen(function* () { + return yield* SessionService; + }).pipe(Effect.provide(SessionLayer)) + ); + const methods = Object.keys(service).sort(); + expect(methods).toEqual([ + 'appendEvent', + 'appendSummary', + 'create', + 'deleteSession', + 'forkSession', + 'listSessions', + 'load', + 'readEvents', + 'readHistory', + 'readUITurns', + 'recordAssistant', + 'recordSystem', + 'recordToolResult', + 'recordUser', + 'renameSession', + 'rollbackToTurn', + 'setActiveProfile', + 'setPermissionMode', + ]); + }); + + it('does not leak file-level operations through the service', async () => { + const service = await Effect.runPromise( + Effect.gen(function* () { + return yield* SessionService; + }).pipe(Effect.provide(SessionLayer)) + ); + for (const leaked of [ + 'readCurrentIndex', + 'appendLine', + 'writeIndexAtomic', + 'ensureDirs', + 'truncateTitle', + 'countNonMetaEvents', + 'findFirstUserContent', + 'readTranscript', + 'readUIHistory', + 'filterForUI', + 'sessionEventsToTurns', + 'forkSessionImpl', + ]) { + expect( + (service as unknown as Record)[leaked], + `service must not expose ${leaked}` + ).toBeUndefined(); + } + }); +}); diff --git a/packages/codingcode/test/session/filter-ui.test.ts b/packages/codingcode/test/session/filter-ui.test.ts index 7ae9fb2f..ed21a905 100644 --- a/packages/codingcode/test/session/filter-ui.test.ts +++ b/packages/codingcode/test/session/filter-ui.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { SessionEvent } from '../../src/session/types.js'; -import { filterForUI, sessionEventsToTurns } from '../../src/session/ui-history.js'; +import { filterForUI, sessionEventsToTurns } from '../../src/session/session.js'; function makeBaseEvents(extra: SessionEvent[] = []): SessionEvent[] { const base: SessionEvent[] = [ diff --git a/packages/codingcode/test/session/fork.test.ts b/packages/codingcode/test/session/fork.test.ts index e060d1ca..10cf121c 100644 --- a/packages/codingcode/test/session/fork.test.ts +++ b/packages/codingcode/test/session/fork.test.ts @@ -3,8 +3,9 @@ import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; -import { filterForContext, buildContextMessages } from '../../src/context/service.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; +import { filterForContext, buildContextMessages } from '../../src/context/context.js'; import { readHistory } from '../../src/session/file-ops.js'; import type { SessionIndex, SessionEvent } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -101,7 +102,7 @@ function collectToolCallIds(events: SessionEvent[]): Set { } function run(eff: Effect.Effect): Promise { - return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } describe('forkSession', () => { @@ -148,7 +149,7 @@ describe('forkSession', () => { } }); - it('forked session has regenerated toolCallIds', async () => { + it('fork preserves toolCallIds and keeps tool_result mapping', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug); @@ -182,11 +183,9 @@ describe('forkSession', () => { const originalToolCallIds = collectToolCallIds(originalEvents); const newToolCallIds = collectToolCallIds(newEvents); - // No toolCallId overlap - for (const id of newToolCallIds) { - expect(originalToolCallIds.has(id)).toBe(false); - } - // Tool result still maps to the regenerated assistant toolCall id + // toolCallIds are preserved unchanged (no regeneration) + expect([...newToolCallIds].sort()).toEqual([...originalToolCallIds].sort()); + // Tool result still maps to the preserved assistant toolCall id const forkedAssistant = newEvents.find((e) => e.type === 'assistant' && e.turnId === 2) as | { toolCalls: Array<{ id: string }> } | undefined; diff --git a/packages/codingcode/test/session/index-write-error.test.ts b/packages/codingcode/test/session/index-write-error.test.ts index c93d0a23..f85ef745 100644 --- a/packages/codingcode/test/session/index-write-error.test.ts +++ b/packages/codingcode/test/session/index-write-error.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, vi } from 'vitest'; import { appendFileSync, mkdirSync } from 'fs'; import { dirname } from 'path'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; import { AgentError } from '../../src/core/error.js'; import * as fs from 'fs'; @@ -46,7 +47,7 @@ describe('SessionService — index write error propagation', () => { Effect.gen(function* () { const svc = yield* SessionService; return yield* svc.recordUser(state, 'hello'); - }).pipe(Effect.provide(SessionService.Default)) + }).pipe(Effect.provide(SessionLayer)) ); expect(exit._tag).toBe('Failure'); @@ -88,7 +89,7 @@ describe('SessionService — index write error propagation', () => { Effect.gen(function* () { const svc = yield* SessionService; return yield* svc.recordAssistant(state, 'hi', []); - }).pipe(Effect.provide(SessionService.Default)) + }).pipe(Effect.provide(SessionLayer)) ); expect(exit._tag).toBe('Failure'); diff --git a/packages/codingcode/test/session/index-write-sync.test.ts b/packages/codingcode/test/session/index-write-sync.test.ts index c0058dac..d03b8582 100644 --- a/packages/codingcode/test/session/index-write-sync.test.ts +++ b/packages/codingcode/test/session/index-write-sync.test.ts @@ -3,7 +3,8 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { encodeProjectPath, computePaths } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; @@ -12,7 +13,7 @@ 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)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } describe('index write is synchronous', () => { diff --git a/packages/codingcode/test/session/io-error.test.ts b/packages/codingcode/test/session/io-error.test.ts index f5749e98..0dfbc591 100644 --- a/packages/codingcode/test/session/io-error.test.ts +++ b/packages/codingcode/test/session/io-error.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { AgentError } from '../../src/core/error.js'; import * as fs from 'fs'; @@ -39,7 +40,7 @@ describe('SessionService — SESSION_IO_ERROR', () => { Effect.gen(function* () { const svc = yield* SessionService; return yield* svc.recordUser(state, 'hello'); - }).pipe(Effect.provide(SessionService.Default)) + }).pipe(Effect.provide(SessionLayer)) ); expect(exit._tag).toBe('Failure'); @@ -77,7 +78,7 @@ describe('SessionService — SESSION_IO_ERROR', () => { Effect.gen(function* () { const svc = yield* SessionService; return yield* svc.recordAssistant(state, 'hi', []); - }).pipe(Effect.provide(SessionService.Default)) + }).pipe(Effect.provide(SessionLayer)) ); expect(exit._tag).toBe('Failure'); @@ -113,7 +114,7 @@ describe('SessionService — SESSION_IO_ERROR', () => { const program = Effect.gen(function* () { const session = yield* SessionService; return yield* session.recordUser(state, 'hello'); - }).pipe(Effect.provide(SessionService.Default)); + }).pipe(Effect.provide(SessionLayer)); const exit = await Effect.runPromiseExit(program); diff --git a/packages/codingcode/test/session/load-create.test.ts b/packages/codingcode/test/session/load-create.test.ts index 405956de..72f5f351 100644 --- a/packages/codingcode/test/session/load-create.test.ts +++ b/packages/codingcode/test/session/load-create.test.ts @@ -3,7 +3,8 @@ import { mkdirSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { AgentError } from '../../src/core/error.js'; import { encodeProjectPath, computePaths } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; @@ -12,7 +13,7 @@ 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)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } function cleanup(dir: string) { @@ -118,7 +119,7 @@ describe('load — restores model from disk, not overwritten', () => { Effect.gen(function* () { const svc = yield* SessionService; return yield* svc.load(dir, 'nonexistent-session-id'); - }).pipe(Effect.provide(SessionService.Default)) + }).pipe(Effect.provide(SessionLayer)) ); expect(exit._tag).toBe('Failure'); @@ -154,7 +155,7 @@ describe('load — restores model from disk, not overwritten', () => { Effect.gen(function* () { const svc = yield* SessionService; return yield* svc.load(otherDir, created.sessionId); - }).pipe(Effect.provide(SessionService.Default)) + }).pipe(Effect.provide(SessionLayer)) ); expect(exit._tag).toBe('Failure'); @@ -274,7 +275,6 @@ describe('load restores persisted fields', () => { Effect.gen(function* () { const svc = yield* SessionService; const state = yield* svc.load(dir, sid); - svc.incrementTurn(state); yield* svc.recordUser(state, 'first'); }) ); @@ -282,7 +282,6 @@ describe('load restores persisted fields', () => { Effect.gen(function* () { const svc = yield* SessionService; const state = yield* svc.load(dir, sid); - svc.incrementTurn(state); yield* svc.recordUser(state, 'second'); }) ); diff --git a/packages/codingcode/test/session/load-restore-profile.test.ts b/packages/codingcode/test/session/load-restore-profile.test.ts index 40c82fa7..4284170e 100644 --- a/packages/codingcode/test/session/load-restore-profile.test.ts +++ b/packages/codingcode/test/session/load-restore-profile.test.ts @@ -1,64 +1,33 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; +import { Effect, ManagedRuntime } from 'effect'; import { mkdirSync, writeFileSync, readFileSync } from 'fs'; import { join } from 'path'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; -import { BUILD_PROFILE } from '../../src/agent/profile.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { McpService } from '../../src/mcp/index.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() { - const HookTestLayer = Layer.succeed(HookService, mockHookService as any); - const McpTestLayer = Layer.succeed(McpService, mockMcpService); - const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); - const SessionTestLayer = SessionService.Default; - const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookTestLayer, McpTestLayer, RulesTestLayer, SessionTestLayer)) - ); - return Layer.mergeAll(ProjectRuntimeTestLayer, SessionTestLayer); -} - describe('SessionStoreState.activeProfile persistence (disk only)', () => { let cwd: string; let sessionId: string; let indexPath: string; let rt: ManagedRuntime.ManagedRuntime; + function loadState() { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.load(cwd, sessionId); + }) + ); + } + beforeEach(async () => { cwd = join(base.dir, 'load-restore-profile'); mkdirSync(cwd, { recursive: true }); - rt = ManagedRuntime.make(makeLayer() as any); + rt = ManagedRuntime.make(SessionLayer as any); const result = await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; @@ -82,30 +51,20 @@ describe('SessionStoreState.activeProfile persistence (disk only)', () => { }); it('state.activeProfile is restored for new sessions', async () => { - const stateBefore = await rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - return yield* session.load(cwd, sessionId); - }) - ); + const stateBefore = await loadState(); expect(stateBefore.activeProfile).toBe('build'); }); - it('state.activeProfile is set when setSessionProfile writes to disk', async () => { + it('state.activeProfile is set when setActiveProfile writes to disk', async () => { await rt.runPromise( - Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE); - }) - ); - - const stateAfter = await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - return yield* session.load(cwd, sessionId); + yield* session.setActiveProfile(cwd, sessionId, 'plan'); }) ); - expect(stateAfter.activeProfile).toBe('build'); + + const stateAfter = await loadState(); + expect(stateAfter.activeProfile).toBe('plan'); }); it('state.activeProfile is set when index file has activeProfile field', async () => { @@ -114,21 +73,15 @@ describe('SessionStoreState.activeProfile persistence (disk only)', () => { idx.permissionMode = 'default'; writeFileSync(indexPath, JSON.stringify(idx, null, 2)); - const state = await rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - return yield* session.load(cwd, sessionId); - }) - ); + const state = await loadState(); expect(state.activeProfile).toBe('plan'); }); - it('restoreSessionProfile writes the profile to disk', async () => { + it('setActiveProfile writes the profile to disk', async () => { await rt.runPromise( Effect.gen(function* () { - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); - yield* runtime.restoreSessionProfile(cwd, sessionId, 'plan'); + const session = yield* SessionService; + yield* session.setActiveProfile(cwd, sessionId, 'plan'); }) ); const idx = JSON.parse(readFileSync(indexPath, 'utf8')); diff --git a/packages/codingcode/test/session/parent-session-id.test.ts b/packages/codingcode/test/session/parent-session-id.test.ts index 1ff52611..7ce7a47f 100644 --- a/packages/codingcode/test/session/parent-session-id.test.ts +++ b/packages/codingcode/test/session/parent-session-id.test.ts @@ -2,14 +2,15 @@ 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 { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { encodeProjectPath, computePaths } 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)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } describe('parentSessionId in index.json', () => { diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index 18dce58f..1d543a04 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -3,9 +3,11 @@ import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; -import { estimatePromptTokens } from '../../src/context/service.js'; +import { estimatePromptTokensFrom } from '../../src/context/context.js'; +import { readHistory } from '../../src/session/file-ops.js'; import { estimateTokensForContent } from '../../src/core/util.js'; import { encodeProjectPath, computePaths } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; @@ -85,7 +87,7 @@ function makeFixture( } function run(eff: Effect.Effect): Promise { - return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } describe('promptEstimate', () => { @@ -149,7 +151,9 @@ describe('promptEstimate', () => { const newIndexPath = join(fx.dir, `${newSessionId}.index.json`); const idx = JSON.parse(readFileSync(newIndexPath, 'utf8')) as SessionIndex; expect(idx.sessionId).toBe(newSessionId); - expect(estimatePromptTokens(join(fx.dir, `${newSessionId}.jsonl`))).toBeGreaterThan(0); + expect( + estimatePromptTokensFrom(readHistory(join(fx.dir, `${newSessionId}.jsonl`))) + ).toBeGreaterThan(0); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } diff --git a/packages/codingcode/test/session/record-tool-result-persist.test.ts b/packages/codingcode/test/session/record-tool-result-persist.test.ts index 5c1ff9ec..3d4aeab2 100644 --- a/packages/codingcode/test/session/record-tool-result-persist.test.ts +++ b/packages/codingcode/test/session/record-tool-result-persist.test.ts @@ -1,13 +1,14 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; 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(SessionLayer) as any)); } describe('recordToolResult', () => { diff --git a/packages/codingcode/test/session/rollback.test.ts b/packages/codingcode/test/session/rollback.test.ts index 646adc50..5295ace5 100644 --- a/packages/codingcode/test/session/rollback.test.ts +++ b/packages/codingcode/test/session/rollback.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, appendFileSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; -import { filterForContext, buildContextMessages } from '../../src/context/service.js'; +import { filterForContext, buildContextMessages } from '../../src/context/context.js'; import { readHistory } from '../../src/session/file-ops.js'; import type { SessionIndex } from '../../src/session/types.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 65d19678..ee181602 100644 --- a/packages/codingcode/test/session/session-jsonl-path.test.ts +++ b/packages/codingcode/test/session/session-jsonl-path.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect } from 'vitest'; import { rmSync, existsSync } from 'fs'; import { join } from 'path'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { deleteSession } from '../../src/session/file-ops.js'; import { sessionJsonlPathFromCwd, computePaths } from '../../src/core/path.js'; @@ -11,7 +12,7 @@ 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)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } describe('sessionJsonlPathFromCwd', () => { diff --git a/packages/codingcode/test/session/store-compact-usage.test.ts b/packages/codingcode/test/session/store-compact-usage.test.ts index b95da902..86a0890c 100644 --- a/packages/codingcode/test/session/store-compact-usage.test.ts +++ b/packages/codingcode/test/session/store-compact-usage.test.ts @@ -3,7 +3,8 @@ import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; @@ -12,7 +13,7 @@ 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)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } function makeFixture( diff --git a/packages/codingcode/test/session/store-diff-rebuild.test.ts b/packages/codingcode/test/session/store-diff-rebuild.test.ts index 4c8162f7..64f4a761 100644 --- a/packages/codingcode/test/session/store-diff-rebuild.test.ts +++ b/packages/codingcode/test/session/store-diff-rebuild.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { SessionEvent } from '../../src/session/types.js'; -import { sessionEventsToTurns } from '../../src/session/ui-history.js'; +import { sessionEventsToTurns } from '../../src/session/session.js'; describe('sessionEventsToTurns', () => { it('parses edit_file tool_result without diff (diff is computed on frontend)', () => { diff --git a/packages/codingcode/test/session/store-rollback-usage.test.ts b/packages/codingcode/test/session/store-rollback-usage.test.ts index 7a80cb41..9a1b9936 100644 --- a/packages/codingcode/test/session/store-rollback-usage.test.ts +++ b/packages/codingcode/test/session/store-rollback-usage.test.ts @@ -3,7 +3,8 @@ import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; @@ -12,7 +13,7 @@ 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)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } function makeFixture( diff --git a/packages/codingcode/test/session/ui-history-rollback.test.ts b/packages/codingcode/test/session/ui-history-rollback.test.ts index ce619034..0d303d27 100644 --- a/packages/codingcode/test/session/ui-history-rollback.test.ts +++ b/packages/codingcode/test/session/ui-history-rollback.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; -import { filterForContext, buildContextMessages } from '../../src/context/service.js'; +import { filterForContext, buildContextMessages } from '../../src/context/context.js'; import { readHistory } from '../../src/session/file-ops.js'; -import { filterForUI } from '../../src/session/ui-history.js'; +import { filterForUI } from '../../src/session/session.js'; import type { SessionEvent, SessionIndex } from '../../src/session/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; diff --git a/packages/codingcode/test/session/update-index-dedup.test.ts b/packages/codingcode/test/session/update-index-dedup.test.ts index 16b77c0e..b44310d4 100644 --- a/packages/codingcode/test/session/update-index-dedup.test.ts +++ b/packages/codingcode/test/session/update-index-dedup.test.ts @@ -3,7 +3,8 @@ import { mkdirSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; -import { SessionService } from '../../src/session/store.js'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; import { encodeProjectPath } from '../../src/core/path.js'; import * as fileOps from '../../src/session/file-ops.js'; @@ -12,7 +13,7 @@ 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)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } describe('updateIndex writes from state without rereading the index', () => { diff --git a/packages/codingcode/test/session/view-assembly.test.ts b/packages/codingcode/test/session/view-assembly.test.ts index d2babcbd..fdc4d9a2 100644 --- a/packages/codingcode/test/session/view-assembly.test.ts +++ b/packages/codingcode/test/session/view-assembly.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { filterForContext, buildContextMessages } from '../../src/context/service.js'; +import { filterForContext, buildContextMessages } from '../../src/context/context.js'; import type { SessionEvent } from '../../src/session/types.js'; function toMessages(events: SessionEvent[]) { diff --git a/packages/codingcode/test/skills/index.test.ts b/packages/codingcode/test/skills/index.test.ts index a2937c5f..4d012a47 100644 --- a/packages/codingcode/test/skills/index.test.ts +++ b/packages/codingcode/test/skills/index.test.ts @@ -1,15 +1,18 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; -import { Effect, Layer } from 'effect'; -import { SkillService } from '../../src/skills/service.js'; +import { Context, Effect, Layer } from 'effect'; +import { SkillService } from '../../src/skills/port.js'; +import { SkillLayer } from '../../src/skills/skills.js'; const TEST_ROOT = process.cwd(); const TEST_CODINGCODE_DIR = join(TEST_ROOT, '.codingcode'); -const SkillTestLayer = SkillService.Default; +type SkillSvc = Context.Tag.Service; -const runWithSkill = (f: (skill: SkillService) => Effect.Effect): A => +const SkillTestLayer = SkillLayer; + +const runWithSkill = (f: (skill: SkillSvc) => Effect.Effect): A => Effect.runSync( Effect.gen(function* () { const skill = yield* SkillService; @@ -19,7 +22,7 @@ const runWithSkill = (f: (skill: SkillService) => Effect.Effect): A => /** Run multiple operations against the same SkillService instance (shared cache). */ const runWithSharedSkill = ( - ...ops: Array<(skill: SkillService) => Effect.Effect> + ...ops: Array<(skill: SkillSvc) => Effect.Effect> ): A[] => Effect.runSync( Effect.gen(function* () { @@ -34,9 +37,12 @@ const runWithSharedSkill = ( describe('SkillService', () => { beforeEach(() => { - if (existsSync(TEST_CODINGCODE_DIR)) - rmSync(TEST_CODINGCODE_DIR, { recursive: true, force: true }); - runWithSkill((s) => s.evictProject(TEST_ROOT)); + try { + if (existsSync(TEST_CODINGCODE_DIR)) + rmSync(TEST_CODINGCODE_DIR, { recursive: true, force: true }); + } catch { + /* best-effort cleanup */ + } const dir = join(TEST_CODINGCODE_DIR, 'skills', 'test-basic'); mkdirSync(dir, { recursive: true }); writeFileSync( @@ -57,9 +63,20 @@ Test the skill system. }); afterEach(() => { - if (existsSync(TEST_CODINGCODE_DIR)) - rmSync(TEST_CODINGCODE_DIR, { recursive: true, force: true }); - runWithSkill((s) => s.evictProject(TEST_ROOT)); + try { + if (existsSync(TEST_CODINGCODE_DIR)) + rmSync(TEST_CODINGCODE_DIR, { recursive: true, force: true }); + } catch { + /* best-effort cleanup */ + } + }); + + afterAll(() => { + try { + if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true, force: true }); + } catch { + /* temp dir cleanup is best-effort */ + } }); it('should load skills from .codingcode/skills/ on demand', () => { @@ -89,8 +106,9 @@ Test the skill system. writeFileSync(join(skillDir, 'scripts', 'run.sh'), 'secret script'); writeFileSync(join(skillDir, 'assets', 'image.bin'), Buffer.from([0, 1, 2, 3])); - runWithSkill((s) => s.evictProject(TEST_ROOT)); - const skill = runWithSkill((s) => s.findByName(TEST_ROOT, 'metadata-only')); + const skill = runWithSkill((s) => s.getAll(TEST_ROOT)).find( + (s) => s.name === 'metadata-only' + ); expect(skill).toEqual({ name: 'metadata-only', @@ -122,10 +140,13 @@ Dynamic skill body. expect((after as any[]).length).toBe((before as any[]).length); }); - it('should parse @skill-name prefix and return matching skill', () => { - const matched = runWithSkill((s) => s.select(TEST_ROOT, '@test-basic do something')); + it('should extract skill and return clean query', () => { + const [matched, cleanQuery] = runWithSkill((s) => + s.extractSkill(TEST_ROOT, '@test-basic do the refactoring work') + ); expect(matched).toBeDefined(); expect(matched!.name).toBe('test-basic'); + expect(cleanQuery).toBe('do the refactoring work'); }); it('should support kebab-case skill names in @ prefix', () => { @@ -141,34 +162,21 @@ description: "Kebab case test" Testing kebab-case name parsing. ` ); - runWithSkill((s) => s.evictProject(TEST_ROOT)); - const matched = runWithSkill((s) => s.select(TEST_ROOT, '@my-kebab-skill run tests')); + const [matched] = runWithSkill((s) => s.extractSkill(TEST_ROOT, '@my-kebab-skill run tests')); expect(matched).toBeDefined(); expect(matched!.name).toBe('my-kebab-skill'); }); - it('should return undefined when @ prefix does not match any skill', () => { - const matched = runWithSkill((s) => s.select(TEST_ROOT, '@nonexistent do something')); - expect(matched).toBeUndefined(); - }); - - it('should return undefined when no @ prefix in query', () => { - const matched = runWithSkill((s) => s.select(TEST_ROOT, 'just a normal message')); + it('should return undefined skill when @ prefix does not match any skill', () => { + const [matched] = runWithSkill((s) => s.extractSkill(TEST_ROOT, '@nonexistent do something')); expect(matched).toBeUndefined(); }); - it('should find skill by name', () => { - const found = runWithSkill((s) => s.findByName(TEST_ROOT, 'test-basic')); - expect(found).toBeDefined(); - expect(found!.name).toBe('test-basic'); - }); - - it('should extract skill and return clean query', () => { + it('should return undefined skill and keep query when no @ prefix', () => { const [matched, cleanQuery] = runWithSkill((s) => - s.extractSkill(TEST_ROOT, '@test-basic do the refactoring work') + s.extractSkill(TEST_ROOT, 'just a normal message') ); - expect(matched).toBeDefined(); - expect(matched!.name).toBe('test-basic'); - expect(cleanQuery).toBe('do the refactoring work'); + expect(matched).toBeUndefined(); + expect(cleanQuery).toBe('just a normal message'); }); }); diff --git a/packages/codingcode/test/skills/layout.test.ts b/packages/codingcode/test/skills/layout.test.ts deleted file mode 100644 index 5cd8ba1c..00000000 --- a/packages/codingcode/test/skills/layout.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readdirSync, readFileSync, statSync, existsSync } from 'fs'; -import { join, relative } from 'path'; - -const REPO_ROOT = join(process.cwd(), 'packages', 'codingcode'); -const SKILLS_SRC_DIR = join(REPO_ROOT, 'src', 'skills'); -const SEARCH_ROOTS = [join(REPO_ROOT, 'src'), join(REPO_ROOT, 'test')]; - -function walk(dir: string, out: string[] = []): string[] { - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - const st = statSync(full); - if (st.isDirectory()) walk(full, out); - else if (/\.(ts|tsx)$/.test(entry)) out.push(full); - } - return out; -} - -function collectAllFiles(): string[] { - return SEARCH_ROOTS.flatMap((root) => walk(root)); -} - -describe('skills module file layout', () => { - it('exposes source.ts (not config.ts) as the on-disk layer', () => { - expect(existsSync(join(SKILLS_SRC_DIR, 'source.ts'))).toBe(true); - expect(existsSync(join(SKILLS_SRC_DIR, 'config.ts'))).toBe(false); - }); - - it('does not import the renamed-away "skills/config" path anywhere', () => { - const stale: Array<{ file: string; line: number; text: string }> = []; - for (const file of collectAllFiles()) { - if (file.endsWith('layout.test.ts')) continue; - const text = readFileSync(file, 'utf8'); - const lines = text.split(/\r?\n/); - lines.forEach((line, i) => { - if (/['"][^'"]*skills[\\/]+config(\.js)?['"]/.test(line)) { - stale.push({ file: relative(REPO_ROOT, file), line: i + 1, text: line.trim() }); - } - }); - } - expect( - stale, - `stale "skills/config" imports found:\n${JSON.stringify(stale, null, 2)}` - ).toEqual([]); - }); -}); diff --git a/packages/codingcode/test/subagent/approval-fork.test.ts b/packages/codingcode/test/subagent/approval-fork.test.ts deleted file mode 100644 index d021aa12..00000000 --- a/packages/codingcode/test/subagent/approval-fork.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { expect, it, describe } from 'vitest'; -import { Effect, Layer } from 'effect'; -import { ApprovalService } from '../../src/approval/index.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; - -const ApprovalLayer = ApprovalService.Default.pipe( - Layer.provide(Layer.mergeAll(HookService.Default, ApprovalWaitService.Default)) -); - -describe('ApprovalService.fork', () => { - async function makeApproval(): Promise { - return await Effect.runPromise( - Effect.gen(function* () { - return yield* ApprovalService; - }).pipe(Effect.provide(ApprovalLayer)) - ); - } - - it('should create a forked approval service', async () => { - const parent = await makeApproval(); - const forkEffect = (parent as any).fork(); - - const child = (await Effect.runPromise(forkEffect)) as ApprovalService; - expect(child).toBeDefined(); - expect(child.evaluate).toBeDefined(); - expect(child.fork).toBeDefined(); - }); - - it('should have independent permission mode', async () => { - const parent = await makeApproval(); - const forkEffect = (parent as any).fork(); - - const child = (await Effect.runPromise(forkEffect)) as ApprovalService; - - const parentMode = parent.getPermissionMode(); - const childMode = child.getPermissionMode(); - - expect(parentMode).toBe('default'); - expect(childMode).toBe('default'); - - await Effect.runPromise(child.setPermissionMode('acceptEdits')); - - expect(parent.getPermissionMode()).toBe('default'); - expect(child.getPermissionMode()).toBe('acceptEdits'); - }); - - it('should inherit parent rules', async () => { - const parent = await makeApproval(); - - await Effect.runPromise( - parent.addRule({ - id: 'parent-rule', - action: 'deny', - toolPattern: 'dangerous_tool', - }) - ); - - const forkEffect = (parent as any).fork(); - const child = (await Effect.runPromise(forkEffect)) as ApprovalService; - - expect(child).toBeDefined(); - }); - - it('should support readonly mode to deny destructive operations', async () => { - const parent = await makeApproval(); - const forkEffect = (parent as any).fork({ readonly: true }); - - const child = (await Effect.runPromise(forkEffect)) as ApprovalService; - - expect(child).toBeDefined(); - expect(child.evaluate).toBeDefined(); - }); - - it('should support extra deny rules on fork', async () => { - const parent = await makeApproval(); - const forkEffect = (parent as any).fork({ - extraDenyRules: [ - { - id: 'fork-deny', - action: 'deny', - toolPattern: 'custom_tool', - }, - ], - }); - - const child = (await Effect.runPromise(forkEffect)) as ApprovalService; - - expect(child).toBeDefined(); - }); - - it('should support nested fork', async () => { - const parent = await makeApproval(); - - const forkEffect1 = (parent as any).fork(); - const child1 = (await Effect.runPromise(forkEffect1)) as ApprovalService; - - const forkEffect2 = (child1 as any).fork(); - const child2 = (await Effect.runPromise(forkEffect2)) as ApprovalService; - - expect(child1).toBeDefined(); - expect(child2).toBeDefined(); - - await Effect.runPromise(child1.setPermissionMode('acceptEdits')); - await Effect.runPromise(child2.setPermissionMode('bypass')); - - expect(child1.getPermissionMode()).toBe('acceptEdits'); - expect(child2.getPermissionMode()).toBe('bypass'); - }); - - it('should preserve parent rules in fork', async () => { - const parent = await makeApproval(); - - await Effect.runPromise( - parent.addRule({ - id: 'rule1', - action: 'allow', - toolPattern: 'safe_tool', - }) - ); - - await Effect.runPromise( - parent.addRule({ - id: 'rule2', - action: 'ask', - toolPattern: 'maybe_tool', - }) - ); - - const forkEffect = (parent as any).fork(); - const child = (await Effect.runPromise(forkEffect)) as ApprovalService; - - expect(child).toBeDefined(); - }); - - it('should isolate rule changes', async () => { - const parent = await makeApproval(); - const forkEffect = (parent as any).fork(); - const child = (await Effect.runPromise(forkEffect)) as ApprovalService; - - await Effect.runPromise( - child.addRule({ - id: 'child-rule', - action: 'deny', - toolPattern: 'child_only_tool', - }) - ); - - expect(parent).toBeDefined(); - expect(child).toBeDefined(); - }); - - it('should combine readonly and extra deny rules', async () => { - const parent = await makeApproval(); - - const forkEffect = (parent as any).fork({ - readonly: true, - extraDenyRules: [ - { - id: 'extra', - action: 'deny', - toolPattern: 'special_tool', - }, - ], - }); - - const child = (await Effect.runPromise(forkEffect)) as ApprovalService; - - expect(child).toBeDefined(); - }); -}); diff --git a/packages/codingcode/test/subagent/builtin-profiles.test.ts b/packages/codingcode/test/subagent/builtin-profiles.test.ts index fe84caaa..06b5c233 100644 --- a/packages/codingcode/test/subagent/builtin-profiles.test.ts +++ b/packages/codingcode/test/subagent/builtin-profiles.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/agent/profile.js'; -import { PLAN_PROFILE_ALLOWED_TOOLS } from '../../src/agent/profile.js'; +import { PLAN_PROFILE, BUILD_PROFILE, PLAN_TOOL_NAMES } from '../../src/agent/profile.js'; describe('built-in subagent profiles', () => { it('keeps only build and plan as built-in names', () => { @@ -10,8 +9,12 @@ describe('built-in subagent profiles', () => { it('keeps plan tools independent from profile tool lists', () => { expect('tools' in PLAN_PROFILE).toBe(false); expect('tools' in BUILD_PROFILE).toBe(false); - expect(PLAN_PROFILE_ALLOWED_TOOLS).toEqual( - new Set(['read_file', 'search_files', 'search_code', 'fetch_url', 'submit_plan']) - ); + expect(PLAN_TOOL_NAMES).toEqual([ + 'read_file', + 'search_files', + 'search_code', + 'fetch_url', + 'submit_plan', + ]); }); }); 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 480ad417..e1c52723 100644 --- a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts +++ b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts @@ -1,36 +1,46 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer } from 'effect'; -import { existsSync, readdirSync, mkdtempSync, rmSync } from 'fs'; +import { existsSync, mkdtempSync, readdirSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; -import { createDispatchAgentTool } from '../../src/tools/domains/subagent/dispatch.js'; -import { AppLayer } from '../../src/layer.js'; -import { SessionService } from '../../src/session/store.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; -import { LLMFactoryService } from '../../src/llm/factory.js'; +import { AgentLayer } from '../../src/agent/agent.js'; +import { ToolEnvLayer } from '../../src/agent/tool-env.js'; +import { ToolCatalogLayer } from '../../src/agent/tool-catalog.js'; +import { AgentService } from '../../src/agent/port.js'; +import { + SessionPort, + ToolExecutorPort, + CheckpointPort, + HookPort, + ApprovalPort, + SkillPort, + McpPort, + ContextPort, + MemoryPort, + LlmPort, + RulesPort, + TodoPort, +} from '../../src/agent/deps.js'; +import { SessionLayer } from '../../src/session/session.js'; +import { SessionService } from '../../src/session/port.js'; +import { HookService } from '../../src/hooks/port.js'; +import { McpService } from '../../src/mcp/port.js'; +import { SubagentRunnerService } from '../../src/subagent/port.js'; +import { TodoService } from '../../src/todo/port.js'; import { readHistory } from '../../src/session/file-ops.js'; -import { encodeProjectPath, normalizePath, setProjectBaseDir } from '../../src/core/path.js'; +import { encodeProjectPath, normalizePath, setProjectBaseDir, computePaths } from '../../src/core/path.js'; +import type { Message } from '../../src/core/types.js'; 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); +import type { FrameBody } from '../../src/core/frame.js'; function makeMockLLM(content: string): LLMClient { return { - complete: () => Effect.succeed({ content, finishReason: 'stop' as const }), - completeStream: () => ({ - stream: (async function* () { - yield content; + complete: () => Effect.succeed({ content }), + completeStream: () => + (async function* () { + yield { type: 'text' as const, text: content }; + yield { type: 'end' as const }; })(), - response: Promise.resolve(Result.ok({ content, finishReason: 'stop' as const })), - }), modelInfo: { provider: 'mock', model: 'mock', @@ -41,13 +51,146 @@ function makeMockLLM(content: string): LLMClient { }; } +/** Read events back into the message list an LLM would see (like context.assemblePayload). */ +function readMessages(transcriptPath: string): Message[] { + return readHistory(transcriptPath).flatMap((e) => { + if (e.type === 'user') return [{ role: 'user', content: e.content }] as Message[]; + if (e.type === 'assistant') + return [{ role: 'assistant', content: e.content, tool_calls: e.toolCalls }] as Message[]; + if (e.type === 'tool_result') + return [ + { + role: 'tool', + content: e.output ?? '', + tool_call_id: e.toolCallId, + tool_name: e.toolName, + } as Message, + ]; + return []; + }); +} + +/** + * Self-contained runtime used by the end-to-end tests: real AgentLayer + + * real file-backed SessionLayer, everything else mocked. This mirrors how + * the app is wired in layer.ts while keeping each dependency explicit. + */ +// Real SessionService narrowed to the Agent's SessionPort (mirrors layer.ts's adapter). +const SessionPortLayer = Layer.effect(SessionPort, Effect.gen(function* () { + const svc = yield* SessionService; + return { + load: svc.load.bind(svc), + create: svc.create.bind(svc), + recordUser: svc.recordUser.bind(svc), + recordSystem: svc.recordSystem.bind(svc), + recordAssistant: svc.recordAssistant.bind(svc), + recordToolResult: svc.recordToolResult.bind(svc), + setPermissionMode: svc.setPermissionMode.bind(svc), + setActiveProfile: svc.setActiveProfile.bind(svc), + }; +})).pipe(Layer.provide(SessionLayer)); + +// Narrow agent ports + TodoService required to build the real AgentLayer. +const AgentDeps = Layer.mergeAll( + SessionPortLayer, + Layer.succeed(ToolExecutorPort, { executeBatch: () => Effect.succeed([]) } as any), + Layer.succeed(CheckpointPort, { + snapshotBaseline: () => Effect.void, + snapshotFinal: () => Effect.void, + } as any), + Layer.succeed(HookPort, { + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + disposeSession: () => Effect.void, + } as any), + Layer.succeed(ApprovalPort, { + evaluate: () => Effect.succeed({ type: 'allow' }), + } as any), + Layer.succeed(SkillPort, { + extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), + } as any), + Layer.succeed(McpPort, { + syncConnections: () => Effect.void, + listProjectMcpTools: () => [], + } as any), + Layer.succeed(ContextPort, { + willCompact: async () => false, + assemblePayload: async (transcriptPath: string) => readMessages(transcriptPath), + } as any), + Layer.succeed(MemoryPort, { + loadMemoryForPrompt: () => '', + flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + } as any), + Layer.succeed(LlmPort, { + getLLMClient: () => Effect.succeed(makeMockLLM('subagent final answer') as LLMClient), + } as any), + Layer.succeed(RulesPort, { + getAllRules: () => '', + evictProjectRules: () => {}, + } as any), + Layer.succeed(TodoPort, { read: () => [] } as any), + // ToolEnvPort 在 getToolEnv 运行时从外层 Runtime 解析具体服务(见 Runtime 定义) + ToolEnvLayer, + // ToolCatalogPort:静态内置 + profile 工具的装配(同 layer.ts) + ToolCatalogLayer +); + +// Real AgentService built on the real SessionPort + stubbed narrow ports. +const AgentWired = AgentLayer.pipe(Layer.provide(AgentDeps as any)); + +// Runtime exposed to the tests: real AgentService + SessionService, plus the +// full services the dispatch_agent tool's execute pulls from the environment. +const Runtime = Layer.mergeAll( + AgentWired, + SessionLayer, + Layer.succeed(HookService, { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + disposeSession: () => Effect.void, + } as any), + Layer.succeed(McpService, { + syncConnections: () => Effect.void, + listProjectMcpTools: () => [], + } as any), + Layer.succeed(SubagentRunnerService, {} as any), + // ToolEnvLayer.getToolEnv 运行时从外层解析 TodoService(工具执行期依赖) + Layer.succeed(TodoService, { read: () => [], write: () => {}, reset: () => {} } as any) +); + function run(eff: Effect.Effect): Promise { - return Effect.runPromise( - eff.pipe(Effect.provide(TestLLMLayer), Effect.provide(AppLayer as any)) as any - ); + return Effect.runPromise(eff.pipe(Effect.provide(Runtime as any)) as any); } -describe('dispatch_agent end-to-end (subagent reads its own jsonl)', () => { +/** Consume a frame stream to completion (mirrors what dispatch.ts does). */ +function drainStream(stream: AsyncGenerator): Effect.Effect { + return Effect.async((resume) => { + (async () => { + let content = ''; + try { + for await (const body of stream) { + if (body.family === 'event' && body.event.type === 'text_delta') { + content += body.event.text; + } else if ( + body.family === 'transition' && + body.transition.to === 'end' && + body.transition.reason === 'error' + ) { + resume(Effect.fail(new Error(`subagent failed: ${body.transition.error.message}`))); + return; + } + } + resume(Effect.succeed(content)); + } catch (e) { + resume(Effect.fail(e instanceof Error ? e : new Error(String(e)))); + } + })(); + }); +} + +describe('subagent run end-to-end (session transcript is read by the agent loop)', () => { let projectBase: string; let cwd: string; @@ -62,88 +205,71 @@ describe('dispatch_agent end-to-end (subagent reads its own jsonl)', () => { if (existsSync(cwd)) rmSync(cwd, { recursive: true, force: true }); }); - it('subagent transcriptPath is /subagents/.jsonl and agentLoop reads it', async () => { + it('runSubagent drives the agent loop and persists the transcript it reads', async () => { const result = await run( Effect.gen(function* () { + const agent = yield* AgentService; const session = yield* SessionService; - const runtime = yield* ProjectRuntimeService; - - yield* runtime.prepareProject(cwd); - const parent = yield* session.create(cwd, { - model: 'parent-model', + const { stream, sessionId } = yield* agent.runTurn('analyze this code', { + cwd, activeProfile: 'build', permissionMode: 'default', }); - - const dispatchTool = yield* createDispatchAgentTool(); - const output = yield* dispatchTool.execute( - { agent: 'build', prompt: 'analyze this code' }, - { projectPath: cwd, sessionId: parent.sessionId } as any - ); - return { output, parentId: parent.sessionId }; + const content = yield* drainStream(stream); + const state = yield* session.load(normalizePath(cwd), sessionId); + return { content, sessionId, transcriptPath: computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath }; }) ); - expect(typeof result.output).toBe('string'); - expect(result.output.length).toBeGreaterThan(0); + expect(typeof result.sessionId).toBe('string'); + expect(result.content.length).toBeGreaterThan(0); + expect(existsSync(result.transcriptPath)).toBe(true); - const sessionsRoot = join(projectBase, encodeProjectPath(normalizePath(cwd)), 'sessions'); - const subagentDir = join(sessionsRoot, result.parentId, 'subagents'); - expect(existsSync(subagentDir)).toBe(true); - - const files = readdirSync(subagentDir).filter((f) => f.endsWith('.jsonl')); - expect(files.length).toBeGreaterThan(0); + const events = readHistory(result.transcriptPath); - const childTranscriptPath = join(subagentDir, files[0]!); - const events = readHistory(childTranscriptPath); - - // First event: session_meta (written by session.create in dispatch.ts) + // First event: session_meta (written by session.create in the runner path) expect(events[0]!.type).toBe('session_meta'); - // The user prompt recorded by dispatch.ts BEFORE invoking the runner. - // If agentLoop reads the wrong path, this event is invisible to the LLM, - // and the assistant response never lands. + // The user prompt recorded before the agentLoop started. If agentLoop + // read the wrong path, this event is invisible to the LLM and the + // assistant response never lands. const userEv = events.find((e) => e.type === 'user'); expect(userEv).toBeDefined(); if (userEv && userEv.type === 'user') { expect(userEv.content).toBe('analyze this code'); } - // The LLM's reply lands on disk — proof that agentLoop read the jsonl, - // saw the user event, and emitted a real response. + // The LLM's reply lands on disk — proof that agentLoop read the jsonl. const assistantEv = events.find((e) => e.type === 'assistant'); expect(assistantEv).toBeDefined(); }, 30_000); - it('child session id does NOT produce a flat /.jsonl (old bug regression)', async () => { + it('child session created under a parent does NOT produce a flat /.jsonl (old bug regression)', async () => { const result = await run( Effect.gen(function* () { const session = yield* SessionService; - const runtime = yield* ProjectRuntimeService; - yield* runtime.prepareProject(cwd); const parent = yield* session.create(cwd, { model: 'parent-model', activeProfile: 'build', permissionMode: 'default', }); - const dispatchTool = yield* createDispatchAgentTool(); - yield* dispatchTool.execute({ agent: 'build', prompt: 'p' }, { - projectPath: cwd, - sessionId: parent.sessionId, - } as any); - return { parentId: parent.sessionId }; + const child = yield* session.create( + cwd, + { model: 'child-model', activeProfile: 'build', permissionMode: 'default' }, + { parentSessionId: parent.sessionId, agentName: 'build' } + ); + return { parentId: parent.sessionId, childId: child.sessionId }; }) ); 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', ''); + const nestedFiles = readdirSync(subagentDir).filter((f) => f.endsWith('.jsonl')); + expect(nestedFiles).toContain(`${result.childId}.jsonl`); - // The wrong-path location (the bug from 3d493e4) MUST NOT contain the - // child's jsonl. If it did, some code constructed the path without - // parentSessionId. - const flatChildPath = join(sessionsRoot, `${childId}.jsonl`); + // The wrong-path location MUST NOT contain the child's jsonl. If it did, + // some code constructed the path without parentSessionId. + const flatChildPath = join(sessionsRoot, `${result.childId}.jsonl`); expect(existsSync(flatChildPath)).toBe(false); }, 30_000); }); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 7c0a54a6..6d802fab 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -1,220 +1,137 @@ -import { expect, it, describe, vi } from 'vitest'; +import { expect, it, describe, beforeEach, vi } from 'vitest'; import { Effect, Layer } from 'effect'; -import { createDispatchAgentTool } from '../../src/tools/domains/subagent/dispatch.js'; -import { SessionService } from '../../src/session/store.js'; -import { ApprovalService } from '../../src/approval/index.js'; -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 { BUILD_PROFILE } from '../../src/agent/profile.js'; -import { SubagentRunnerService } from '../../src/subagent/runner-service.js'; -import { ProjectRuntimeService } from '../../src/runtime/project-runtime.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 mockLlm: Partial = { - modelInfo: { - model: 'test-model', - provider: 'test', - maxTokens: 8192, - supportsToolCalling: true, - supportsStreaming: true, - }, -}; - -function makeMockSession(parentPermissionMode: 'default' | 'bypass' | 'acceptEdits' = 'default') { - const createImpl = ( - _cwd: string, - options: { model: string; activeProfile: 'plan' | 'build'; permissionMode: any } - ) => - Effect.succeed({ - sessionId: 'child-1', - cwd: '/test', - messageCount: 0, - currentTurnId: 0, - sessionMeta: null, - model: options.model, - activeProfile: options.activeProfile, - permissionMode: options.permissionMode, - title: 'child', - usage: undefined, - memorySnapshot: '', - }); - return { - create: createImpl, - load: (_cwd: string, _sid: string) => - Effect.succeed({ - sessionId: 'parent-1', - cwd: '/test', - messageCount: 0, - currentTurnId: 0, - sessionMeta: null, - model: 'parent-model', - activeProfile: '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, - setPermissionModeOnDisk: () => Effect.void, - }; -} - -const mockApproval = { - evaluate: () => Effect.succeed({ type: 'allow' as const, source: 'system' }), - addRule: () => Effect.void, - removeRule: () => Effect.void, - setPermissionMode: () => Effect.void, - getPermissionMode: () => 'default' as any, - fork: (opts?: { permissionMode?: any; readonly?: boolean }) => - Effect.succeed(mockApproval as any), -}; +import { dispatchAgentTool } from '../../src/tools/domains/subagent/dispatch.js'; +import { HookService } from '../../src/hooks/port.js'; +import { McpService } from '../../src/mcp/port.js'; +import { SubagentRunnerService } from '../../src/subagent/port.js'; +import type { ToolExecCtx } from '../../src/tools/types.js'; +import type { FrameBody } from '../../src/core/frame.js'; const mockHooks = { register: () => Effect.succeed(() => {}), registerDecision: () => Effect.succeed(() => {}), - emit: () => Effect.succeed(undefined), - emitDecision: () => Effect.succeed(null), + emit: vi.fn(() => Effect.succeed(undefined)), + emitDecision: vi.fn(() => 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), + disposeSession: vi.fn(() => Effect.succeed(undefined)), }; const mockMcp = { connectServers: () => Effect.void, syncConnections: () => Effect.void, listProjectMcpTools: () => [], - disposeSession: () => Effect.void, + disposeSession: vi.fn(() => Effect.succeed(undefined)), }; -const mockLlmFactory = { - getLLMClient: () => Effect.succeed(mockLlm as LLMClient), - findModel: () => Effect.succeed(null), - createClient: () => Effect.succeed(mockLlm as LLMClient), +const mockRunner = { + runSubagent: vi.fn(() => + Effect.succeed({ stream: makeRunStream(), sessionId: 'child-1' }) + ), }; -const mockRules = { - getAllRules: () => '', - evictProjectRules: () => undefined, -}; - -const mockSubagent = { - registerGlobal: () => undefined, - get: (_p: string, name: string) => { - if (name === 'build') return BUILD_PROFILE; - if (name === 'custom') { - return { name: 'custom' } as any; - } - if (name === 'custom-default') return { name } as any; - return undefined; - }, - list: () => [BUILD_PROFILE], -}; - -const mockProjectRuntime = { - prepareProject: () => Effect.void, - resolveMainAgentProfile: () => undefined, - resolveSubagentProfile: (_p: string, name: string) => mockSubagent.get(_p, name), - getToolPolicy: () => ({ - allowedTools: undefined, - allowedMcpServers: undefined, - }), - setSessionProfile: () => Effect.void, - restoreSessionProfile: () => Effect.void, - getSessionProfile: () => Effect.succeed(undefined), - getSessionPermissionMode: () => Effect.succeed('default' as any), - disposeSession: () => Effect.void, - disposeProject: () => Effect.void, -}; - -function makeRunStream(): AsyncGenerator { +function makeRunStream(): AsyncGenerator { return (async function* () { - yield { _tag: 'Done', content: 'done' } as AgentEvent; + yield { family: 'event', event: { type: 'text_delta', text: 'done' } }; + yield { family: 'transition', transition: { to: 'end', reason: 'done' } }; })(); } -function makeLayers(parentPermissionMode: 'default' | 'bypass' | 'acceptEdits' = 'default') { - const subagentRunner = { runStream: vi.fn().mockReturnValue(makeRunStream()) }; +function makeLayers() { 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(ProjectRuntimeService, ProjectRuntimeService.make(mockProjectRuntime as any)), - Layer.succeed(SubagentRunnerService, subagentRunner as any) + Layer.succeed(HookService, mockHooks as any), + Layer.succeed(McpService, mockMcp as any), + Layer.succeed(SubagentRunnerService, mockRunner as any) ); } -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(ProjectRuntimeService, ProjectRuntimeService.make(mockProjectRuntime as any)), - Layer.succeed(SubagentRunnerService, { - runStream: vi.fn().mockReturnValue(makeRunStream()), - } as any) +function runTool(args: unknown, ctx: ToolExecCtx): Promise { + return Effect.runPromise( + dispatchAgentTool.execute(args, ctx).pipe(Effect.provide(makeLayers())) ); - 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 permission-mode priority (parent > default)', () => { - it('case 1: child uses default when profile has no permissionMode', async () => { - const perm = await dispatchTool('default', 'custom', { - projectPath: '/test', - sessionId: 'parent-1', - } as ToolExecCtx); - expect(perm).toBe('default'); +describe('dispatch_agent (runner-based subagent spawn)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('case 1: dispatches build subagent and returns the runner output', async () => { + const out = await runTool( + { agent: 'build', prompt: 'go' }, + { projectPath: '/test', sessionId: 'parent-1' } + ); + expect(out).toBe('done'); + }); + + it('case 2: forwards prompt, cwd and parent session id to the runner', async () => { + await runTool( + { agent: 'build', prompt: 'analyze this code' }, + { projectPath: '/test', sessionId: 'parent-1' } + ); + + expect(mockRunner.runSubagent).toHaveBeenCalledTimes(1); + expect(mockRunner.runSubagent).toHaveBeenCalledWith( + 'analyze this code', + expect.objectContaining({ + cwd: '/test', + parentSessionId: 'parent-1', + activeProfile: 'build', + agentName: 'build', + }) + ); + }); + + it('case 3: rejects unknown profile (custom subagents removed)', async () => { + const outcome = await Effect.runPromise( + Effect.either( + dispatchAgentTool + .execute( + { agent: 'custom', prompt: 'go' }, + { projectPath: '/test', sessionId: 'parent-1' } + ) + .pipe(Effect.provide(makeLayers())) + ) + ); + expect(outcome._tag).toBe('Left'); + if (outcome._tag === 'Left') { + const err: any = outcome.left; + expect(err.code).toBe('TOOL_EXECUTION_FAILED'); + expect(String(err.message)).toContain('Unknown subagent: custom'); + } }); - it('case 2: profile has no permissionMode + parent has bypass → child uses parent value', async () => { - const perm = await dispatchTool('bypass', 'custom-default', { - projectPath: '/test', - sessionId: 'parent-1', - } as ToolExecCtx); - expect(perm).toBe('bypass'); + it('case 4: spawn.before deny hook blocks the dispatch', async () => { + mockHooks.emitDecision.mockReturnValueOnce( + Effect.succeed({ decision: 'deny' as const, reason: 'policy forbids it' }) as any + ); + const outcome = await Effect.runPromise( + Effect.either( + dispatchAgentTool + .execute( + { agent: 'build', prompt: 'go' }, + { projectPath: '/test', sessionId: 'parent-1' } + ) + .pipe(Effect.provide(makeLayers())) + ) + ); + expect(outcome._tag).toBe('Left'); + if (outcome._tag === 'Left') { + const err: any = outcome.left; + expect(err.code).toBe('TOOL_NOT_ALLOWED'); + } }); - it('case 3: profile has no permissionMode + no parent (top-level) → child uses default', async () => { - const perm = await dispatchTool('default', 'custom-default', { - projectPath: '/test', - } as ToolExecCtx); - expect(perm).toBe('default'); + it('case 5: emits spawn.after and disposes the child session on completion', async () => { + await runTool( + { agent: 'build', prompt: 'go' }, + { projectPath: '/test', sessionId: 'parent-1' } + ); + + expect(mockHooks.emit).toHaveBeenCalledWith( + 'agent.subagent.spawn.after', + expect.objectContaining({ childSessionId: 'child-1', profile: 'build' }) + ); + expect(mockHooks.disposeSession).toHaveBeenCalledWith('child-1'); + expect(mockMcp.disposeSession).toHaveBeenCalledWith('child-1'); }); }); diff --git a/packages/codingcode/test/subagent/runner-service.test.ts b/packages/codingcode/test/subagent/runner-service.test.ts index 0d2bf55b..75b597f0 100644 --- a/packages/codingcode/test/subagent/runner-service.test.ts +++ b/packages/codingcode/test/subagent/runner-service.test.ts @@ -1,18 +1,27 @@ import { expect, it, describe } from 'vitest'; import { Effect, Layer } from 'effect'; -import { SubagentRunnerService } from '../../src/subagent/runner-service.js'; +import { SubagentRunnerService } from '../../src/subagent/port.js'; + +const SAMPLE_FRAME = { + family: 'event', + event: { type: 'text_delta', text: 'test-result' }, +} as const; describe('SubagentRunnerService', () => { it('should be a valid Effect Service with the SubagentRunner tag', () => { expect(SubagentRunnerService.key).toBe('SubagentRunner'); }); - it('should allow creating a Layer with a custom runStream implementation', async () => { - const mockRunStream = async function* () { - yield { _tag: 'Done' as const, content: 'test-result' }; - }; + it('should allow creating a Layer with a custom runSubagent implementation', async () => { + const mockRunSubagent = (_input: string, _opts: { cwd: string }) => + Effect.succeed({ + stream: (async function* () { + yield SAMPLE_FRAME; + })(), + sessionId: 'child-1', + }); - const testLayer = Layer.succeed(SubagentRunnerService, { runStream: mockRunStream } as any); + const testLayer = Layer.succeed(SubagentRunnerService, { runSubagent: mockRunSubagent } as any); const result: any = await Effect.runPromise( ( @@ -23,22 +32,27 @@ describe('SubagentRunnerService', () => { ).pipe(Effect.provide(testLayer as any)) ); - expect(result.runStream).toBe(mockRunStream); + expect(result.runSubagent).toBe(mockRunSubagent); }); - it('should allow runStream to be called and produce events', async () => { + it('should allow runSubagent to be called and produce events', async () => { const events: any[] = []; - const mockRunStream = async function* () { - yield { _tag: 'Done' as const, content: 'test-result' }; - }; + const mockRunSubagent = (_input: string, _opts: { cwd: string }) => + Effect.succeed({ + stream: (async function* () { + yield SAMPLE_FRAME; + })(), + sessionId: 'child-1', + }); - const testLayer = Layer.succeed(SubagentRunnerService, { runStream: mockRunStream } as any); + const testLayer = Layer.succeed(SubagentRunnerService, { runSubagent: mockRunSubagent } as any); const result: any = await Effect.runPromise( ( Effect.gen(function* () { const runner = yield* SubagentRunnerService; - const stream = runner.runStream({} as any); + const { stream, sessionId } = yield* runner.runSubagent('go', { cwd: '/test' }); + expect(sessionId).toBe('child-1'); // Consume the async generator outside the Effect generator return yield* Effect.async((resume) => { (async () => { @@ -53,6 +67,6 @@ describe('SubagentRunnerService', () => { ); expect(result).toHaveLength(1); - expect(result[0]).toEqual({ _tag: 'Done', content: 'test-result' }); + expect(result[0]).toEqual(SAMPLE_FRAME); }); }); diff --git a/packages/codingcode/test/tools/builtin-tools.test.ts b/packages/codingcode/test/tools/builtin-tools.test.ts deleted file mode 100644 index 50dcf480..00000000 --- a/packages/codingcode/test/tools/builtin-tools.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Effect } from 'effect'; -import { TodoService } from '../../src/agent/todo.js'; -import { registerBuiltinTools } from '../../src/tools/builtin-tools.js'; -import { ToolRegistry } from '../../src/tools/registry.js'; - -describe('registerBuiltinTools', () => { - it('registers stateless tools and the TodoService-backed todo tool', async () => { - const registry = new ToolRegistry(); - await Effect.runPromise( - registerBuiltinTools(registry).pipe(Effect.provide(TodoService.Default)) - ); - - expect(registry.describe().map((tool) => tool.name)).toEqual([ - 'read_file', - 'write_file', - 'edit_file', - 'execute_command', - 'search_code', - 'search_files', - 'fetch_url', - 'web_search', - 'todo_write', - ]); - }); -}); diff --git a/packages/codingcode/test/tools/catalog.test.ts b/packages/codingcode/test/tools/catalog.test.ts new file mode 100644 index 00000000..2f46053f --- /dev/null +++ b/packages/codingcode/test/tools/catalog.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { createToolCatalog } from '../../src/tools/catalog.js'; + +const BUILD_NAMES = [ + 'read_file', + 'write_file', + 'edit_file', + 'execute_command', + 'search_code', + 'search_files', + 'fetch_url', + 'web_search', + 'todo_write', + 'dispatch_agent', +]; + +describe('createToolCatalog', () => { + it('assembles tools by name in the order given', () => { + const { tools } = createToolCatalog(BUILD_NAMES); + expect(tools.map((t) => t.name)).toEqual(BUILD_NAMES); + }); + + it('excludes tools not present in the name list', () => { + const { tools } = createToolCatalog(['read_file', 'submit_plan']); + const names = tools.map((t) => t.name); + expect(names).toEqual(['read_file', 'submit_plan']); + expect(names).not.toContain('write_file'); + expect(names).not.toContain('execute_command'); + }); + + it('throws on an unknown tool name', () => { + expect(() => createToolCatalog(['nope'])).toThrow(/Unknown tool/); + }); + + it('lookup resolves registered tools by name only', () => { + const { lookup } = createToolCatalog(BUILD_NAMES); + expect(lookup('write_file')?.name).toBe('write_file'); + expect(lookup('submit_plan')).toBeUndefined(); + }); + + it('merges dynamic MCP tools into the catalog', () => { + const mcp = { + name: 'mcp_thing', + description: 'a thing', + parameters: z.object({}), + execute: () => ({}) as any, + }; + const { tools, lookup } = createToolCatalog(['read_file'], [mcp]); + expect(tools.map((t) => t.name)).toEqual(['read_file', 'mcp_thing']); + expect(lookup('mcp_thing')?.name).toBe('mcp_thing'); + }); +}); diff --git a/packages/codingcode/test/tools/executor-context.test.ts b/packages/codingcode/test/tools/executor-context.test.ts deleted file mode 100644 index 20aaa7a5..00000000 --- a/packages/codingcode/test/tools/executor-context.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Effect, Layer } from 'effect'; -import { z } from 'zod'; -import { ApprovalService } from '../../src/approval/index.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { ToolExecutorService } from '../../src/tools/executor.js'; -import type { ToolDefinition, ToolExecCtx } from '../../src/tools/types.js'; - -const hooks = { - emit: () => Effect.void, -}; - -const approval = { - evaluate: () => Effect.succeed({ type: 'allow' as const }), -}; - -const executorLayer = ToolExecutorService.Default.pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed(HookService, hooks as any), - Layer.succeed(ApprovalService, approval as any) - ) - ) -); - -describe('ToolExecutorService context', () => { - it('passes execution context to a tool without per-tool type annotations', async () => { - let received: ToolExecCtx | undefined; - const tool: ToolDefinition = { - name: 'capture_context', - description: 'Captures execution context for verification.', - parameters: z.object({}), - execute: (_args, ctx) => { - received = ctx; - return Effect.succeed('ok'); - }, - }; - const signal = new AbortController().signal; - - const result = await Effect.runPromise( - Effect.gen(function* () { - const executor = yield* ToolExecutorService; - return yield* executor.execute( - 'capture_context', - {}, - { - signal, - sessionId: 'session-1', - turnId: 2, - projectPath: '/project', - toolLookup: (name) => (name === tool.name ? tool : undefined), - } - ); - }).pipe(Effect.provide(executorLayer) as any) - ); - - expect((result as { output: string }).output).toBe('ok'); - expect(received).toEqual({ - signal, - sessionId: 'session-1', - turnId: 2, - projectPath: '/project', - }); - }); -}); diff --git a/packages/codingcode/test/tools/todo.test.ts b/packages/codingcode/test/tools/todo.test.ts index 4d8d0f28..3661dbb0 100644 --- a/packages/codingcode/test/tools/todo.test.ts +++ b/packages/codingcode/test/tools/todo.test.ts @@ -1,37 +1,34 @@ import { describe, it, expect } from 'vitest'; import { Effect } from 'effect'; -import { TodoService } from '../../src/agent/todo.js'; -import { createTodoWriteTool } from '../../src/tools/domains/self/todo-write.js'; +import { todoWriteTool } from '../../src/tools/domains/self/todo-write.js'; +import { TodoLayer } from '../../src/todo/todo.js'; -async function makeTodoTool() { - return Effect.runPromise(createTodoWriteTool().pipe(Effect.provide(TodoService.Default))); -} +const tool = todoWriteTool; describe('todo_write tool', () => { - it('does not expose a deferred flag', async () => { - const tool = await makeTodoTool(); + it('does not expose a deferred flag', () => { expect('deferred' in tool).toBe(false); }); it('returns pending/in_progress/completed counts', async () => { - const tool = await makeTodoTool(); const result = await Effect.runPromise( - tool.execute( - { - plan: [ - { step: 'first', status: 'pending' }, - { step: 'second', status: 'in_progress' }, - { step: 'third', status: 'completed' }, - ], - }, - { sessionId: 'test-agent' } - ) + tool + .execute( + { + plan: [ + { step: 'first', status: 'pending' }, + { step: 'second', status: 'in_progress' }, + { step: 'third', status: 'completed' }, + ], + }, + { sessionId: 'test-agent' } + ) + .pipe(Effect.provide(TodoLayer)) ); expect(result).toBe('pending=1 in_progress=1 completed=1'); }); it('rejects plan exceeding TODO_MAX_ITEMS (20)', async () => { - const tool = await makeTodoTool(); const plan = Array.from({ length: 21 }, (_, i) => ({ step: `step ${i}`, status: 'pending' as const, @@ -40,7 +37,6 @@ describe('todo_write tool', () => { }); it('rejects step longer than 60 chars', async () => { - const tool = await makeTodoTool(); await expect( tool.parameters.parseAsync({ plan: [{ step: 'x'.repeat(61), status: 'pending' }], @@ -49,7 +45,6 @@ describe('todo_write tool', () => { }); it('rejects invalid status value', async () => { - const tool = await makeTodoTool(); await expect( tool.parameters.parseAsync({ plan: [{ step: 'test', status: 'invalid' }], @@ -58,7 +53,6 @@ describe('todo_write tool', () => { }); it('does not accept cancelled status', async () => { - const tool = await makeTodoTool(); await expect( tool.parameters.parseAsync({ plan: [{ step: 'test', status: 'cancelled' }], @@ -67,9 +61,10 @@ describe('todo_write tool', () => { }); it('fails with AgentError if sessionId is missing', async () => { - const tool = await makeTodoTool(); const exit = await Effect.runPromiseExit( - tool.execute({ plan: [{ step: 'x', status: 'pending' }] }, {}) + tool + .execute({ plan: [{ step: 'x', status: 'pending' }] }, {}) + .pipe(Effect.provide(TodoLayer)) ); expect(exit._tag).toBe('Failure'); }); diff --git a/packages/codingcode/test/types/type-collapse.test.ts b/packages/codingcode/test/types/type-collapse.test.ts new file mode 100644 index 00000000..155b5f17 --- /dev/null +++ b/packages/codingcode/test/types/type-collapse.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import type { UITurn, UITurnItem } from '../../src/session/port.js'; +import type { ForkResult, RollbackContextResult } from '../../src/client/contracts.js'; +import type { TodoItem as CoreTodoItem, TokenUsage as CoreTokenUsage } from '../../src/core/types.js'; +import type { TodoItem as PortTodoItem, Todo as PortTodo } from '../../src/todo/port.js'; +import type { TokenUsage as SessionTokenUsage } from '../../src/session/types.js'; + +type AssertNotAny = 0 extends 1 & T ? never : T; + +type _UITurnNotAny = AssertNotAny; +type _TodoNotAny = AssertNotAny; + +// 同构断言:两侧互相可赋值才通过 +type _ForkTurnsIsUITurn = ForkResult['turns'] extends UITurn[] ? true : never; +type _ContextTurnsIsUITurn = RollbackContextResult['turns'] extends UITurn[] ? true : never; + +type _PortTodoIsCoreTodo = PortTodoItem extends CoreTodoItem ? true : never; +type _CoreTodoIsPortTodo = CoreTodoItem extends PortTodoItem ? true : never; +type _TodoAliasIsItem = PortTodo extends PortTodoItem ? true : never; + +type _SessionUsageIsCoreUsage = SessionTokenUsage extends CoreTokenUsage ? true : never; +type _CoreUsageIsSessionUsage = CoreTokenUsage extends SessionTokenUsage ? true : never; + +describe('类型收口', () => { + it('UITurn.status 不再退化为 string', () => { + const turn: UITurn = { id: '1', items: [], status: 'completed' }; + expect(turn.status).toBe('completed'); + // @ts-expect-error status 只能是三个字面量之一 + const bad: UITurn = { id: '1', items: [], status: 'nope' }; + expect(bad).toBeDefined(); + }); + + it('UITurnItem 覆盖 session 产出的全部变体', () => { + const items: UITurnItem[] = [ + { id: 'a', type: 'message', role: 'user', content: 'hi' }, + { id: 'b', type: 'tool_call', name: 'read_file', args: {}, status: 'approved' }, + { id: 'c', type: 'tool_result', callId: 'b', name: 'read_file', output: 'ok' }, + { id: 'd', type: 'summary', content: 's', startTurnId: 1, endTurnId: 2 }, + { id: 'e', type: 'reasoning', content: 'r', isVisible: false }, + { id: 'f', type: 'error', message: 'e' }, + ]; + expect(items.map((i) => i.type)).toEqual([ + 'message', + 'tool_call', + 'tool_result', + 'summary', + 'reasoning', + 'error', + ]); + }); + + it('TodoItem.status 是字面量联合而非 string', () => { + const todo: PortTodoItem = { step: 'do it', status: 'in_progress' }; + expect(todo.status).toBe('in_progress'); + // @ts-expect-error status 只能是 pending / in_progress / completed + const bad: PortTodoItem = { step: 'x', status: 'whatever' }; + expect(bad).toBeDefined(); + }); +}); diff --git a/packages/desktop/src/agent/ApprovalPanel.tsx b/packages/desktop/src/agent/ApprovalPanel.tsx index 473e8bd5..43c7c8dc 100644 --- a/packages/desktop/src/agent/ApprovalPanel.tsx +++ b/packages/desktop/src/agent/ApprovalPanel.tsx @@ -1,9 +1,9 @@ -import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import type { Item } from '@shared/types'; import { useAgentStore } from '../stores/agent.store'; import { useAgentApproval, useAgentCore, useAgentProfile } from '../hooks/useAgent'; import ToolCallCard from '../shared/ToolCallCard'; -import PlanApprovalModal from '../shared/PlanApprovalModal'; +import PlanDecisionModal from '../shared/PlanDecisionModal'; import { useWorkspaceStore } from '../stores/workspace.store'; interface ApprovalPanelProps { @@ -14,39 +14,12 @@ export default function ApprovalPanel({ threadId }: ApprovalPanelProps) { const [collapsed, setCollapsed] = useState(false); const { approveTool, rejectTool } = useAgentApproval(); const { sendMessage } = useAgentCore(); - const { fetchPlan, switchProfile } = useAgentProfile(); + const { switchProfile } = useAgentProfile(); const workspace = useWorkspaceStore(); const pendingPlan = useAgentStore((s) => s.pendingPlanByThreadId[threadId] ?? null); const clearPendingPlan = useAgentStore((s) => s.clearPendingPlan); - const [planContent, setPlanContent] = useState(''); - const [planPath, setPlanPath] = useState(); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (!pendingPlan) return; - let cancelled = false; - setLoading(true); - fetchPlan(pendingPlan.sessionId, workspace.rootPath ?? '') - .then((snap) => { - if (cancelled) return; - setPlanContent(snap.content); - setPlanPath(snap.path); - }) - .catch(() => { - if (cancelled) return; - setPlanContent(''); - setPlanPath(undefined); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [pendingPlan, fetchPlan, workspace.rootPath]); - const pendingKey = useAgentStore((s) => { const thread = s.threads[threadId]; if (!thread) return ''; @@ -94,11 +67,9 @@ export default function ApprovalPanel({ threadId }: ApprovalPanelProps) { if (pendingPlan) { return ( - void handleImplement()} onSubmitOpinion={(op) => void handleSubmitOpinion(op)} onCancel={() => void handleCancel()} diff --git a/packages/desktop/src/agent/MessageStream.tsx b/packages/desktop/src/agent/MessageStream.tsx index 5ebcda4f..361e4833 100644 --- a/packages/desktop/src/agent/MessageStream.tsx +++ b/packages/desktop/src/agent/MessageStream.tsx @@ -20,8 +20,8 @@ interface TurnDiffPanelProps { uiTurnId: string; isInterrupted?: boolean; threadId: string; - onRevertFile: (uiTurnId: string, file: string, isReverted: boolean) => void; - onRevertTurn: (uiTurnId: string, files: string[], isReverted: boolean) => void; + onRevertFile: (uiTurnId: string, file: string) => void; + onRevertTurn: (uiTurnId: string, files: string[]) => void; } function getCheckpointKey( @@ -125,17 +125,17 @@ function TurnDiffPanel({ onClick={() => onRevertTurn( uiTurnId, - diff.files.map((f: any) => f.path), - isTurnReverted + diff.files.map((f: any) => f.path) ) } + disabled={isTurnReverted} className={`text-[12px] px-3 py-1 rounded ${ isTurnReverted - ? 'bg-[var(--accent-success)] text-[var(--text-inverse)] hover:bg-[var(--accent-success)]/80' + ? 'bg-[var(--accent-success)] text-[var(--text-inverse)]' : 'bg-[var(--bg-hover)] text-[var(--text-secondary)] hover:bg-[var(--bg-active)] border border-[var(--border-strong)]' }`} > - {isTurnReverted ? '撤销回退本轮修改' : '回退本轮修改'} + {isTurnReverted ? '已回退本轮修改' : '回退本轮修改'} @@ -173,15 +173,16 @@ function TurnDiffPanel({ (null); const didScrollToEndRef = useRef(false); const loadedCheckpointRef = useRef(null); - const markFileRestored = useRollbackStore((s) => s.markFileRestored); const setPendingInput = useAgentStore((s) => s.setPendingInput); const [showRollbackPanel, setShowRollbackPanel] = useState<{ @@ -452,32 +451,17 @@ export default function MessageStream({ threadId }: MessageStreamProps) { }, [turnStatusKey, threadId, loadCheckpointDiff]); const handleRevertFile = useCallback( - async (uiTurnId: string, file: string, isReverted: boolean) => { - if (isReverted) { - const result = await undoCodeRollback(threadId, uiTurnId, false, [file]); - if (result.restored) { - markFileRestored(threadId, uiTurnId, file); - } - } else { - await revertFile(threadId, file); - } + async (_uiTurnId: string, file: string) => { + await revertFile(threadId, file); }, - [threadId, revertFile, undoCodeRollback, markFileRestored] + [threadId, revertFile] ); const handleRevertTurn = useCallback( - async (uiTurnId: string, files: string[], isReverted: boolean) => { - if (isReverted) { - const result = await undoCodeRollback(threadId, uiTurnId, false); - if (result.restored) { - const key = `${threadId}:${uiTurnId}`; - delete useRollbackStore.getState().revertedFilesByTurnId[key]; - } - } else { - await revertFiles(threadId, files); - } + async (_uiTurnId: string, files: string[]) => { + await revertFiles(threadId, files); }, - [threadId, revertFiles, undoCodeRollback] + [threadId, revertFiles] ); const rollbackModal = showRollbackPanel && ( diff --git a/packages/desktop/src/agent/ProfileIndicator.tsx b/packages/desktop/src/agent/ProfileIndicator.tsx index c96aa04c..d8829c9e 100644 --- a/packages/desktop/src/agent/ProfileIndicator.tsx +++ b/packages/desktop/src/agent/ProfileIndicator.tsx @@ -2,14 +2,14 @@ import { useState, useEffect } from 'react'; import { Eye, Hammer, Loader2 } from 'lucide-react'; import { useAgentProfile } from '../hooks/useAgent'; import { useAgentStore } from '../stores/agent.store'; -import type { AgentProfileName } from '@codingcode/core/subagent/types'; +import type { ProfileName } from '@codingcode/core/core/types'; interface ProfileIndicatorProps { sessionId: string | null; cwd: string; } -const PROFILE_META: Record = { +const PROFILE_META: Record = { plan: { label: '计划模式', color: 'text-[var(--accent-warning)] bg-[var(--tag-info-bg)]', @@ -83,9 +83,9 @@ export default function ProfileIndicator({ sessionId, cwd }: ProfileIndicatorPro setOptimisticProfileForThread, ]); - const current: AgentProfileName = + const current: ProfileName = sessionId === null ? pendingProfile : (profile?.activeProfile ?? 'build'); - const target: AgentProfileName = current === 'plan' ? 'build' : 'plan'; + const target: ProfileName = current === 'plan' ? 'build' : 'plan'; const handleToggle = async () => { if (busy) return; diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index 0817a72e..0453f182 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -3,8 +3,8 @@ import { useAgentStore, type ModelEntry } from '../stores/agent.store'; import { useWorkspaceStore } from '../stores/workspace.store'; import { useRollbackStore } from '../stores/rollback.store'; import { agentClient } from '../lib/core-api'; -import type { StreamChunk } from '@codingcode/core/client/types'; -import type { AgentProfileName } from '@codingcode/core/subagent/types'; +import { createStreamState, reduceFrame, type StreamEffects } from '../lib/frame-reducer'; +import type { ProfileName } from '@codingcode/core/core/types'; import type { PermissionMode } from '@codingcode/core/approval/types'; import { ApiError } from '../lib/api'; import { @@ -20,8 +20,6 @@ import { rollbackCodeToTurn, rollbackContext, rollbackBothToTurn, - undoLastCodeRollback, - getRollbackState, forkSession, getSessionProfile, setSessionProfile, @@ -30,8 +28,6 @@ import { import type { CheckpointDiff, CodeRollbackResult, - CodeRollbackUndoResult, - SessionRollbackState, } from '../lib/core-api'; import type { Item, Turn, Project } from '@shared/types'; @@ -105,6 +101,7 @@ export function useAgentCore() { const completeTurn = useAgentStore((s) => s.completeTurn); const setPendingInput = useAgentStore((s) => s.setPendingInput); const setPendingPlan = useAgentStore((s) => s.setPendingPlan); + const clearPendingPlan = useAgentStore((s) => s.clearPendingPlan); const clearRunningTurns = useAgentStore((s) => s.clearRunningTurns); const applyTodoUpdate = useAgentStore((s) => s.applyTodoUpdate); const setCurrentThread = useAgentStore((s) => s.setCurrentThread); @@ -115,7 +112,6 @@ export function useAgentCore() { const setModels = useAgentStore((s) => s.setModels); const setContextUsage = useAgentStore((s) => s.setContextUsage); const setThreadUsage = useAgentStore((s) => s.setThreadUsage); - const clearThreadUsage = useAgentStore((s) => s.clearThreadUsage); const workspace = useWorkspaceStore(); const currentThreadId = useAgentStore((s) => s.currentThreadId); const approvalPolicy = useAgentStore((s) => s.approvalPolicy); @@ -188,118 +184,13 @@ export function useAgentCore() { }); }, [currentThreadId, setThreadTurns]); - const streamChunkToItem = useCallback( - ( - event: StreamChunk, - threadId: string, - assistantMessageId: string, - currentTurnId: string - ): Item | null => { - switch (event.type) { - case 'text': - return { - id: assistantMessageId, - type: 'message', - role: 'assistant', - content: event.text, - partial: true, - }; - case 'message': - return { - id: assistantMessageId, - type: 'message', - role: 'assistant', - content: event.content, - partial: false, - }; - case 'turn_id': - updateTurnId(threadId, currentTurnId, String(event.turnId)); - return null; - case 'tool_start': - return { - id: event.id, - type: 'tool_call', - name: event.name, - args: event.args, - status: 'running', - }; - case 'approval_request': - return { - id: event.id, - type: 'tool_call', - name: event.tool, - args: event.args, - status: 'pending', - }; - case 'plan_ready': - // The server's plan.ready SSE event drives the plan-approval - // modal directly. We don't write a tool_call item — the modal - // renders from this payload via useAgentStore's pendingPlan. - return null; - case 'tool_result': - return { - id: randomId(), - type: 'tool_result', - callId: event.id, - name: event.name, - output: event.output, - exitCode: event.ok ? 0 : 1, - }; - case 'tool_denied': - return { - id: event.id, - type: 'tool_call', - name: event.name, - args: {}, - status: 'rejected', - }; - case 'error': - return { id: randomId(), type: 'error', message: event.message, code: event.code }; - case 'todo_update': - applyTodoUpdate(threadId, event.items as any); - return null; - case 'usage': { - setThreadUsage(threadId, { - prompt: event.prompt, - completion: event.completion, - total: event.total, - }); - const agentState = useAgentStore.getState(); - const model = agentState.models.find((m) => m.id === agentState.model); - if (model) { - setContextUsage({ used: event.prompt, contextWindow: model.context_window }); - } - return null; - } - case 'reactive_compact': - { - const contextUsage = useAgentStore.getState().contextUsage; - if (contextUsage) { - setContextUsage({ - used: event.promptEstimate, - contextWindow: contextUsage.contextWindow, - }); - } - clearThreadUsage(threadId); - } - return null; - case 'done': - case 'session_id': - return null; - default: - return null; - } - }, - [applyTodoUpdate, updateTurnId, setThreadUsage, setContextUsage, clearThreadUsage] - ); - const sendMessage = useCallback( async (content: string, cwd?: string) => { const effectiveCwd = cwd || workspace.rootPath || ''; - let threadId = currentThreadId; - if (!threadId) { - const activeProfile: AgentProfileName = pendingProfile; + let resolvedThreadId = currentThreadId; + if (!resolvedThreadId) { + const activeProfile: ProfileName = pendingProfile; const permissionMode: PermissionMode = pendingProfile === 'plan' ? 'default' @@ -313,22 +204,47 @@ export function useAgentCore() { permissionMode, model, }); - threadId = data.sessionId; - setCurrentThreadWithProfile(threadId, { activeProfile, permissionMode, optimistic: true }); + resolvedThreadId = data.sessionId; + setCurrentThreadWithProfile(resolvedThreadId, { + activeProfile, + permissionMode, + optimistic: true, + }); } + const threadId: string = resolvedThreadId; if (inflightControllers.has(threadId)) return; + clearPendingPlan(threadId); - let turnId = randomId(); - let assistantMessageId = randomId(); + let activeTurnId = randomId(); const userItem: Item = { id: randomId(), type: 'message', role: 'user', content }; - const turn: Turn = { id: turnId, items: [userItem], status: 'running' }; - + const turn: Turn = { id: activeTurnId, items: [userItem], status: 'running' }; startTurn(threadId, turn, { cwd: effectiveCwd, title: content.slice(0, 60) }); const controller = new AbortController(); registerInflight(threadId, controller); + const state = createStreamState(randomId()); + const fx: StreamEffects = { + applyItem: (item) => applyChunk(threadId, activeTurnId, item), + applyTodo: (items) => applyTodoUpdate(threadId, items), + setUsage: (usage) => { + setThreadUsage(threadId, usage); + const s = useAgentStore.getState(); + const model = s.models.find((m) => m.id === s.model); + if (model) setContextUsage({ used: usage.prompt, contextWindow: model.context_window }); + }, + setCompacted: () => { + useAgentStore.getState().clearThreadUsage(threadId); + }, + syncTurnId: (turnId) => { + const next = String(turnId); + updateTurnId(threadId, activeTurnId, next); + activeTurnId = next; + }, + newId: () => randomId(), + }; + try { const stream = agentClient.sendMessage(content, { sessionId: threadId, @@ -336,40 +252,18 @@ export function useAgentCore() { signal: controller.signal, }); - let hasError = false; - for await (const event of stream) { - if (event.type === 'session_id') continue; - - if (event.type === 'error') { - hasError = true; - } - - if (event.type === 'plan_ready') { - setPendingPlan(threadId, { - sessionId: event.sessionId, - title: event.title, - }); - } - - const item = streamChunkToItem(event, threadId, assistantMessageId, turnId); - if (item) { - applyChunk(threadId, turnId, item); - } - - if (event.type === 'turn_id') { - turnId = String(event.turnId); - } - - if (event.type === 'tool_start' || event.type === 'approval_request') { - assistantMessageId = randomId(); - } + for await (const frame of stream) { + reduceFrame(frame, state, fx); } - completeTurn(threadId, turnId, hasError ? 'error' : 'completed'); + completeTurn(threadId, activeTurnId, state.hasError ? 'error' : 'completed'); + if (!state.hasError && state.planTitle !== null) { + setPendingPlan(threadId, { sessionId: threadId, title: state.planTitle }); + } } catch (err: any) { const msg = err instanceof ApiError ? (err.body?.message ?? err.message) : String(err); - applyChunk(threadId, turnId, { id: randomId(), type: 'error', message: msg }); - completeTurn(threadId, turnId, 'error'); + applyChunk(threadId, activeTurnId, { id: randomId(), type: 'error', message: msg }); + completeTurn(threadId, activeTurnId, 'error'); } finally { abortAndClear(threadId); } @@ -377,10 +271,14 @@ export function useAgentCore() { [ startTurn, setCurrentThreadWithProfile, - streamChunkToItem, applyChunk, + applyTodoUpdate, completeTurn, setPendingPlan, + clearPendingPlan, + updateTurnId, + setThreadUsage, + setContextUsage, workspace.rootPath, approvalPolicy, pendingProfile, @@ -442,12 +340,9 @@ export function useAgentRollback() { const setThreadUsage = useAgentStore((s) => s.setThreadUsage); // Rollback store const revertedFilesByTurnId = useRollbackStore((s) => s.revertedFilesByTurnId); - const setRollbackState = useRollbackStore((s) => s.setRollbackState); const setCheckpointDiff = useRollbackStore((s) => s.setCheckpointDiff); const markFileReverted = useRollbackStore((s) => s.markFileReverted); - const markFileRestored = useRollbackStore((s) => s.markFileRestored); const setTurnCheckpointMapping = useRollbackStore((s) => s.setTurnCheckpointMapping); - const initRevertedFilesFromState = useRollbackStore((s) => s.initRevertedFilesFromState); const resolveUITurnId = useCallback((threadId: string, checkpointId: number): string => { const mapping = useRollbackStore.getState().turnCheckpointMapping; @@ -526,12 +421,19 @@ export function useAgentRollback() { const rollbackCtx = useCallback( async (threadId: string, throughTurnId: number) => { const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; + const targetTurn = useAgentStore.getState().threads[threadId]?.turns.find( + (t) => t.id === String(throughTurnId) + ); + const userMsg = targetTurn?.items.find( + (i) => i.type === 'message' && (i as any).role === 'user' + ); + const userContent = userMsg && 'content' in userMsg ? (userMsg as any).content : ''; const res = await rollbackContext(threadId, cwd, throughTurnId); clearRunningTurns(threadId); setThreadTurns(threadId, res.turns as Turn[]); setThreadUsage(threadId, res.usage ?? { prompt: 0, completion: 0, total: 0 }); - if (res.rolledBackMessage) { - setPendingInput(res.rolledBackMessage); + if (userContent) { + setPendingInput(userContent); } if (res.promptEstimate != null) { const agentState = useAgentStore.getState(); @@ -556,11 +458,18 @@ export function useAgentRollback() { const rollbackBoth = useCallback( async (threadId: string, throughTurnId: number) => { const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; + const targetTurn = useAgentStore.getState().threads[threadId]?.turns.find( + (t) => t.id === String(throughTurnId) + ); + const userMsg = targetTurn?.items.find( + (i) => i.type === 'message' && (i as any).role === 'user' + ); + const userContent = userMsg && 'content' in userMsg ? (userMsg as any).content : ''; const res = await rollbackBothToTurn(threadId, cwd, throughTurnId); setThreadTurns(threadId, res.turns as Turn[]); setThreadUsage(threadId, res.usage ?? { prompt: 0, completion: 0, total: 0 }); - if (res.rolledBackMessage) { - setPendingInput(res.rolledBackMessage); + if (userContent) { + setPendingInput(userContent); } if (res.promptEstimate != null) { const agentState = useAgentStore.getState(); @@ -575,20 +484,6 @@ export function useAgentRollback() { [workspace.rootPath, setThreadTurns, setThreadUsage, setPendingInput, setContextUsage] ); - const undoCodeRollback = useCallback( - async (threadId: string, uiTurnId: string, force?: boolean, files?: string[]) => { - const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; - const { result } = await undoLastCodeRollback(threadId, cwd, force, files); - if (result.restored) { - for (const f of result.restoredFiles) { - markFileRestored(threadId, uiTurnId, f); - } - } - return result; - }, - [workspace.rootPath, markFileRestored] - ); - const forkThread = useCallback( async (threadId: string, atTurnId?: number) => { const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; @@ -598,20 +493,6 @@ export function useAgentRollback() { [workspace.rootPath] ); - const initRollbackState = useCallback( - async (threadId: string) => { - const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; - try { - const state = await getRollbackState(threadId, cwd); - setRollbackState(threadId, state); - initRevertedFilesFromState(threadId); - } catch { - /* ignore */ - } - }, - [workspace.rootPath, setRollbackState, initRevertedFilesFromState] - ); - const deleteThread = useCallback(async (threadId: string) => { abortAndClear(threadId); const currentCwd = useWorkspaceStore.getState().rootPath; @@ -635,9 +516,7 @@ export function useAgentRollback() { rollbackCode, rollbackCtx, rollbackBoth, - undoCodeRollback, forkThread, - initRollbackState, deleteThread, revertedFilesByTurnId, }; @@ -655,7 +534,7 @@ export function useAgent() { // ---- useAgentProfile: plan/build profile switching + plan file access ---- export type SessionProfileSnapshot = { - activeProfile: AgentProfileName; + activeProfile: ProfileName; permissionMode: PermissionMode; cwd: string; available: Array<{ name: string; description: string }>; @@ -686,9 +565,9 @@ export function useAgentProfile() { const switchProfile = useCallback( async ( sessionId: string, - activeProfile: AgentProfileName, + activeProfile: ProfileName, cwd?: string - ): Promise<{ activeProfile: AgentProfileName; permissionMode: PermissionMode }> => { + ): Promise<{ activeProfile: ProfileName; permissionMode: PermissionMode }> => { return setSessionProfile(sessionId, cwd ?? workspace.rootPath ?? '', activeProfile); }, [workspace.rootPath] diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index f6b36e9a..7fa1df3e 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -1,7 +1,15 @@ import { API_BASE, api } from './api'; -import { createHttpClients, type AgentRuntimeClient } from '@codingcode/core/client/http-clients'; +import { createHttpClients, type AgentRuntimeClient } from '@codingcode/core/client'; import type { PermissionMode } from '@codingcode/core/approval/types'; -import type { AgentProfileName } from '@codingcode/core/subagent/types'; +import type { ProfileName, TokenUsage } from '@codingcode/core/core/types'; +import type { + CheckpointDiff, + CodeRollbackResult, + RollbackPreviewDiff, +} from '@codingcode/core/checkpoint/types'; +import type { UITurn } from '@codingcode/core/session/port'; +import type { McpServerConfig } from '@codingcode/core/mcp/types'; +import type { UserHookConfig } from '@codingcode/core/hooks/types'; const clients = createHttpClients(API_BASE); @@ -30,7 +38,7 @@ export function listSessions(cwd?: string): Promise { export function createSession( cwd: string, - params: { activeProfile: AgentProfileName; permissionMode: PermissionMode; model: string } + params: { activeProfile: ProfileName; permissionMode: PermissionMode; model: string } ): Promise<{ sessionId: string }> { return clients.sessions.createSession({ cwd, ...params }); } @@ -39,16 +47,11 @@ export function deleteSession(sessionId: string, cwd: string): Promise { return clients.sessions.deleteSession({ sessionId, cwd }); } -export function getSessionHistory( - sessionId: string, - cwd: string -): Promise> { - return clients.sessions.getSessionHistory({ sessionId, cwd }) as unknown as Promise< - Array<{ id: string; items: any[]; status: string }> - >; +export function getSessionHistory(sessionId: string, cwd: string): Promise { + return clients.sessions.getSessionHistory({ sessionId, cwd }); } -export function resumeSession(sessionId: string, cwd: string): Promise { +export function resumeSession(sessionId: string, cwd: string): Promise { return clients.sessions.resumeSession({ sessionId, cwd }); } @@ -80,7 +83,7 @@ export function getSessionPlan( // ---- Agent profile switching ---- export type SessionProfileInfo = { - activeProfile: AgentProfileName; + activeProfile: ProfileName; permissionMode: PermissionMode; cwd: string; available: Array<{ name: string; description: string }>; @@ -93,8 +96,8 @@ export function getSessionProfile(sessionId: string, cwd: string): Promise { + activeProfile: ProfileName +): Promise<{ activeProfile: ProfileName; permissionMode: PermissionMode }> { return clients.sessions.setSessionProfile({ sessionId, cwd, activeProfile }); } @@ -102,7 +105,6 @@ export function setSessionProfile( export function getMemoryConfig(): Promise<{ enabled: boolean; - types: Array<{ name: string; description: string; isBuiltIn: boolean; disabled: boolean }>; model: string; }> { return clients.settings.getMemoryConfig(); @@ -112,25 +114,6 @@ export function setMemoryEnabled(enabled: boolean): Promise { return clients.settings.setMemoryEnabled(enabled); } -export function setMemoryTypeDisabled(name: string, disabled: boolean): Promise { - return clients.settings.setMemoryTypeDisabled(name, disabled); -} - -export function createMemoryExtraType(type: { name: string; description: string }): Promise { - return clients.settings.addMemoryExtraType(type); -} - -export function updateMemoryExtraType( - name: string, - type: { name: string; description: string } -): Promise { - return clients.settings.updateMemoryExtraType(name, type); -} - -export function deleteMemoryExtraType(name: string): Promise { - return clients.settings.deleteMemoryExtraType(name); -} - export function setMemoryModel(model: string): Promise<{ model: string }> { return clients.settings.setMemoryModel(model); } @@ -177,19 +160,16 @@ export function resetMcpDisabled(name: string, cwd: string): Promise { return clients.settings.resetMcpDisabled({ name, cwd }); } -export function createMcpServer( - cwd: string | undefined, - server: Record -): Promise { - return clients.settings.createMcpServer({ cwd: cwd ?? '', server: server as any }); +export function createMcpServer(cwd: string | undefined, server: McpServerConfig): Promise { + return clients.settings.createMcpServer({ cwd: cwd ?? '', server }); } export function updateMcpServer( cwd: string | undefined, name: string, - server: Record + server: McpServerConfig ): Promise { - return clients.settings.updateMcpServer({ cwd: cwd ?? '', name, server: server as any }); + return clients.settings.updateMcpServer({ cwd: cwd ?? '', name, server }); } export function deleteMcpServer(cwd: string | undefined, name: string): Promise { @@ -207,25 +187,25 @@ export function listSkills(_cwd?: string): Promise< hasProjectOverride?: boolean; }> > { - return clients.settings.listSkills() as any; + return clients.settings.listSkills(); } // ---- Settings: Hooks ---- -export function listHooks(cwd?: string): Promise { +export function listHooks(cwd?: string): Promise { return clients.settings.listHooks({ cwd: cwd ?? '' }); } -export function createHook(cwd: string | undefined, hook: Record): Promise { - return clients.settings.createHook({ cwd: cwd ?? '', hook: hook as any }); +export function createHook(cwd: string | undefined, hook: UserHookConfig): Promise { + return clients.settings.createHook({ cwd: cwd ?? '', hook }); } export function updateHook( cwd: string | undefined, name: string, - hook: Record + hook: UserHookConfig ): Promise { - return clients.settings.updateHook({ cwd: cwd ?? '', name, hook: hook as any }); + return clients.settings.updateHook({ cwd: cwd ?? '', name, hook }); } export function deleteHook(cwd: string | undefined, name: string): Promise { @@ -246,66 +226,14 @@ export function resetHookDisabled(name: string, cwd: string): Promise { // ---- Rollback / Checkpoint ---- -export interface CheckpointDiff { - turnId: number; - files: Array<{ - path: string; - status: string; - diff: string; - insertions: number; - deletions: number; - }>; -} - -export interface CodeRollbackResult { - reverted: boolean; - throughTurnId: number; - affectedTurns: number[]; - selectedFiles: string[]; - restoreEntry: CodeRestoreEntry | null; -} - -export interface CodeRollbackUndoResult { - restored: boolean; - conflict: boolean; - conflictFiles: string[]; - restoredFiles: string[]; - remainingRolledBack: string[]; -} - -export interface RollbackPreviewDiff { - throughTurnId: number; - affectedTurns: number[]; - diff: string; -} - -export interface CodeRestoreEntry { - id: string; - sessionId: string; - action: string; - throughTurnId: number; - affectedTurns: number[]; - selectedFiles: string[]; - safetyCommit: string; - timestamp: string; -} - -export interface SessionRollbackState { - context: { active: boolean; currentThroughTurnId: number | null }; - code: { - canUndoLast: boolean; - lastEntry: CodeRestoreEntry | null; - revertedFiles: string[]; - lastEntryId: string | null; - }; -} +export type { CheckpointDiff, CodeRollbackResult, RollbackPreviewDiff }; export function getCheckpointDiff( sessionId: string, cwd: string, turnId?: number ): Promise { - return clients.sessions.getCheckpointDiff({ sessionId, cwd, turnId }) as any; + return clients.sessions.getCheckpointDiff({ sessionId, cwd, turnId }); } export function revertCheckpointFiles( @@ -313,7 +241,10 @@ export function revertCheckpointFiles( cwd: string, files: string[] ): Promise<{ ok: boolean; result: CodeRollbackResult }> { - return clients.sessions.revertCheckpointFiles({ sessionId, cwd, files }) as any; + return clients.sessions.revertCheckpointFiles({ sessionId, cwd, files }).then((result) => ({ + ok: true, + result, + })); } export function previewRollbackDiff( @@ -321,7 +252,7 @@ export function previewRollbackDiff( cwd: string, throughTurnId: number ): Promise { - return clients.sessions.previewRollbackDiff({ sessionId, cwd, throughTurnId }) as any; + return clients.sessions.previewRollbackDiff({ sessionId, cwd, throughTurnId }); } export function rollbackCodeToTurn( @@ -329,7 +260,9 @@ export function rollbackCodeToTurn( cwd: string, throughTurnId: number ): Promise<{ ok: boolean; result: CodeRollbackResult }> { - return clients.sessions.rollbackCodeToTurn({ sessionId, cwd, throughTurnId }) as any; + return clients.sessions + .rollbackCodeToTurn({ sessionId, cwd, throughTurnId }) + .then((result) => ({ ok: true, result })); } export function rollbackContext( @@ -338,12 +271,14 @@ export function rollbackContext( throughTurnId: number ): Promise<{ ok: boolean; - turns: any[]; - rolledBackMessage?: string; + turns: UITurn[]; promptEstimate?: number; - usage?: { prompt: number; completion: number; total: number }; + usage?: TokenUsage; }> { - return clients.sessions.rollbackContext({ sessionId, cwd, throughTurnId }) as any; + return clients.sessions.rollbackContext({ sessionId, cwd, throughTurnId }).then((r) => ({ + ok: true, + turns: r.turns, + })); } export function rollbackBothToTurn( @@ -352,33 +287,22 @@ export function rollbackBothToTurn( throughTurnId: number ): Promise<{ ok: boolean; - turns: any[]; + turns: UITurn[]; codeResult: CodeRollbackResult; - rolledBackMessage?: string; promptEstimate?: number; - usage?: { prompt: number; completion: number; total: number }; + usage?: TokenUsage; }> { - return clients.sessions.rollbackBothToTurn({ sessionId, cwd, throughTurnId }) as any; -} - -export function undoLastCodeRollback( - sessionId: string, - cwd: string, - force?: boolean, - files?: string[] -): Promise<{ ok: boolean; result: CodeRollbackUndoResult }> { - return clients.sessions.undoLastCodeRollback({ sessionId, cwd, force, files }) as any; -} - -export function getRollbackState(sessionId: string, cwd: string): Promise { - return clients.sessions.getRollbackState({ sessionId, cwd }) as any; + return clients.sessions.rollbackBothToTurn({ sessionId, cwd, throughTurnId }).then((r) => ({ + ok: true, + ...r, + })); } export function forkSession( sessionId: string, cwd: string, atTurnId?: number -): Promise<{ sessionId: string; turns: any[] }> { +): Promise<{ sessionId: string; turns: UITurn[] }> { return clients.sessions.forkSession({ sessionId, cwd, atTurnId }); } diff --git a/packages/desktop/src/lib/frame-reducer.ts b/packages/desktop/src/lib/frame-reducer.ts new file mode 100644 index 00000000..1949c5b5 --- /dev/null +++ b/packages/desktop/src/lib/frame-reducer.ts @@ -0,0 +1,130 @@ +import type { Frame, ToolOutcome } from '@codingcode/core/core/frame'; +import type { Item, TodoItem } from '@shared/types'; + +export interface StreamState { + assistantMessageId: string; + roundHasText: boolean; + hasError: boolean; + planTitle: string | null; + turnIdSynced: boolean; +} + +export function createStreamState(assistantMessageId: string): StreamState { + return { + assistantMessageId, + roundHasText: false, + hasError: false, + planTitle: null, + turnIdSynced: false, + }; +} + +/** reducer 的外部副作用出口 */ +export interface StreamEffects { + applyItem(item: Item): void; + applyTodo(items: TodoItem[]): void; + setUsage(usage: { prompt: number; completion: number; total: number }): void; + /** 进入压缩:重置该线程的累计用量(下一次 responded.usage 会带来真实值) */ + setCompacted(): void; + syncTurnId(turnId: number): void; + newId(): string; +} + +function toResultItem(outcome: ToolOutcome): { output: string; exitCode: number } { + if (outcome.status === 'denied') return { output: outcome.reason, exitCode: 1 }; + return { output: outcome.output, exitCode: outcome.status === 'ok' ? 0 : 1 }; +} + +/** + * transition 只改 phase 与消息边界;event 只累积内容。 + * 文本段以 responded 收尾——这是服务端给出的消息边界,不再由客户端伪造。 + */ +export function reduceFrame(frame: Frame, state: StreamState, fx: StreamEffects): void { + if (!state.turnIdSynced && frame.turnId !== null) { + state.turnIdSynced = true; + fx.syncTurnId(frame.turnId); + } + + if (frame.family === 'fatal') { + state.hasError = true; + fx.applyItem({ id: fx.newId(), type: 'error', message: frame.fatal.message, code: frame.fatal.code }); + return; + } + + if (frame.family === 'transition') { + const t = frame.transition; + if (t.to === 'end') { + if (t.reason === 'error') { + state.hasError = true; + fx.applyItem({ id: fx.newId(), type: 'error', message: t.error.message, code: t.error.code }); + } + return; + } + if (t.to === 'compress') { + fx.setCompacted(); + return; + } + if (t.to === 'executing') { + if (t.responded) { + if (state.roundHasText) { + fx.applyItem({ + id: state.assistantMessageId, + type: 'message', + role: 'assistant', + content: '', + partial: false, + }); + } + state.roundHasText = false; + state.assistantMessageId = fx.newId(); + if (t.responded.usage) fx.setUsage(t.responded.usage); + } + } + return; + } + + const e = frame.event; + switch (e.type) { + case 'text_delta': + state.roundHasText = true; + fx.applyItem({ + id: state.assistantMessageId, + type: 'message', + role: 'assistant', + content: e.text, + partial: true, + }); + return; + case 'tool_call': + if (e.name === 'submit_plan') { + state.planTitle = String((e.args as Record).title ?? ''); + } + fx.applyItem({ + id: e.id, + type: 'tool_call', + name: e.name, + args: e.args as object, + status: 'running', + }); + return; + case 'approval_request': + fx.applyItem({ + id: e.id, + type: 'tool_call', + name: e.tool, + args: e.args as object, + status: 'pending', + }); + return; + case 'tool_result': { + if (e.name === 'submit_plan' && e.outcome.status !== 'ok') state.planTitle = null; + if (e.outcome.status === 'denied') { + fx.applyItem({ id: e.id, type: 'tool_call', name: e.name, args: {}, status: 'rejected' }); + } + const { output, exitCode } = toResultItem(e.outcome); + fx.applyItem({ id: fx.newId(), type: 'tool_result', callId: e.id, name: e.name, output, exitCode }); + if (e.todos) fx.applyTodo(e.todos as TodoItem[]); + return; + } + } +} diff --git a/packages/desktop/src/settings/HooksPanel.tsx b/packages/desktop/src/settings/HooksPanel.tsx index 0dfb7ef4..35ee794e 100644 --- a/packages/desktop/src/settings/HooksPanel.tsx +++ b/packages/desktop/src/settings/HooksPanel.tsx @@ -9,6 +9,7 @@ import { setHookDisabled, resetHookDisabled, } from '../lib/core-api'; +import type { UserHookConfig, HookPoint } from '@codingcode/core/hooks/types'; interface HookEntry { name: string; @@ -162,9 +163,9 @@ export default function HooksPanel({ global: isGlobal }: { global?: boolean }) { }; const saveForm = async () => { - const hook: Record = { + const hook: UserHookConfig = { name: form.name, - point: form.point, + point: form.point as HookPoint, type: ALL_POINTS.find((p) => p.name === form.point)?.type ?? 'observer', command: form.command, enabled: form.enabled, diff --git a/packages/desktop/src/settings/McpPanel.tsx b/packages/desktop/src/settings/McpPanel.tsx index 71d885f7..9add777d 100644 --- a/packages/desktop/src/settings/McpPanel.tsx +++ b/packages/desktop/src/settings/McpPanel.tsx @@ -9,6 +9,7 @@ import { updateMcpServer, deleteMcpServer, } from '../lib/core-api'; +import type { McpServerConfig } from '@codingcode/core/mcp/types'; interface McpEntry { name: string; @@ -104,7 +105,7 @@ export default function McpPanel({ global: isGlobal }: { global?: boolean }) { }; const saveForm = async () => { - const server: Record = { + const server: McpServerConfig = { name: form.name, concurrency: form.concurrency, autoReconnect: form.autoReconnect, diff --git a/packages/desktop/src/settings/MemoryPanel.tsx b/packages/desktop/src/settings/MemoryPanel.tsx index 446f3630..8dde9a25 100644 --- a/packages/desktop/src/settings/MemoryPanel.tsx +++ b/packages/desktop/src/settings/MemoryPanel.tsx @@ -1,59 +1,31 @@ import { useState, useEffect } from 'react'; import { useAgentStore } from '../stores/agent.store'; import Toggle from './Toggle'; -import { - getMemoryConfig, - setMemoryEnabled, - setMemoryTypeDisabled, - createMemoryExtraType, - updateMemoryExtraType, - deleteMemoryExtraType, - setMemoryModel, -} from '../lib/core-api'; - -interface MemoryTypeEntry { - name: string; - description: string; - isBuiltIn: boolean; - disabled: boolean; -} +import { getMemoryConfig, setMemoryEnabled, setMemoryModel } from '../lib/core-api'; interface MemoryConfig { enabled: boolean; - types: MemoryTypeEntry[]; model: string; } -interface FormType { - name: string; - description: string; -} - -const EMPTY_FORM: FormType = { name: '', description: '' }; - export default function MemoryPanel() { const models = useAgentStore((s) => s.models); const [config, setConfig] = useState({ enabled: false, - types: [], model: '', }); const [loading, setLoading] = useState(true); - const [isCreating, setIsCreating] = useState(false); - const [editingName, setEditingName] = useState(null); - const [deletingName, setDeletingName] = useState(null); - const [form, setForm] = useState(EMPTY_FORM); + const load = async () => { setLoading(true); try { const data = await getMemoryConfig(); setConfig({ enabled: data.enabled ?? false, - types: data.types ?? [], model: data.model ?? '', }); } catch { - setConfig({ enabled: false, types: [], model: '' }); + setConfig({ enabled: false, model: '' }); } finally { setLoading(false); } @@ -68,14 +40,6 @@ export default function MemoryPanel() { setConfig((prev) => ({ ...prev, enabled: v })); }; - const toggleType = async (name: string, disabled: boolean) => { - await setMemoryTypeDisabled(name, disabled); - setConfig((prev) => ({ - ...prev, - types: prev.types.map((t) => (t.name === name ? { ...t, disabled } : t)), - })); - }; - const handleModel = async (model: string) => { setConfig((prev) => ({ ...prev, model })); try { @@ -91,61 +55,8 @@ export default function MemoryPanel() { groups[m.provider]!.push(m); } - const startCreate = () => { - setForm(EMPTY_FORM); - setIsCreating(true); - setEditingName(null); - setDeletingName(null); - }; - - const startEdit = (t: MemoryTypeEntry) => { - setForm({ name: t.name, description: t.description }); - setEditingName(t.name); - setIsCreating(false); - setDeletingName(null); - }; - - const cancelForm = () => { - setIsCreating(false); - setEditingName(null); - }; - - const saveForm = async () => { - try { - if (isCreating) { - await createMemoryExtraType(form); - } else if (editingName) { - await updateMemoryExtraType(editingName, form); - } - cancelForm(); - await load(); - } catch (e: any) { - alert(e.message ?? '操作失败'); - } - }; - - const confirmDelete = async () => { - if (!deletingName) return; - try { - await deleteMemoryExtraType(deletingName); - setDeletingName(null); - await load(); - } catch (e: any) { - alert(e.message ?? '删除失败'); - } - }; - - const inputCls = - 'w-full bg-[var(--bg-hover)] border border-[var(--border-hover)] text-[var(--text-title)] px-3 py-2 rounded text-[13px] focus:outline-none focus:ring-1 focus:ring-[var(--accent-primary)]'; - const labelCls = 'text-[12px] text-[var(--text-placeholder)] mb-1'; const selectCls = 'w-[200px] bg-[var(--bg-hover)] border border-[var(--border-hover)] text-[var(--text-title)] px-3 py-2 rounded text-[13px] focus:outline-none focus:ring-1 focus:ring-[var(--accent-primary)]'; - const btnPrimary = - 'px-4 py-2 rounded text-[13px] bg-[var(--btn-primary-bg)] text-[var(--accent-primary)] hover:bg-[var(--btn-primary-hover)]'; - const btnDanger = - 'px-4 py-2 rounded text-[13px] bg-[var(--btn-danger-bg)] text-[var(--accent-danger)] hover:bg-[var(--btn-danger-hover)]'; - const btnCancel = - 'px-4 py-2 rounded text-[13px] bg-[var(--border-card)] text-[var(--text-tertiary)] border border-[var(--border-hover)] hover:bg-[var(--border-hover)] hover:border-[var(--border-strong)]'; if (loading) { return
加载中…
; @@ -153,230 +64,44 @@ export default function MemoryPanel() { return (
-
-
-
记忆模式
-
- 启用后自动从会话中提取长期记忆 -
-
- -
- - {config.enabled && ( - <> -
-
-
-
记忆模型
-
- 用于提取和汇总记忆的模型,空则使用主对话模型 -
-
- +
+
+
+
记忆模式
+
+ 启用后自动从会话中提取长期记忆
- - )} - -
-
- 记忆类型 +
- {config.enabled && ( - - )}
- {isCreating && ( -
+
+
-
名称
- setForm({ ...form, name: e.target.value })} - /> -
-
-
描述
- setForm({ ...form, description: e.target.value })} - /> -
-
- - +
记忆模型
+
+ 用于提取和汇总记忆的模型,空则使用主对话模型 +
+
- )} - - {!config.enabled ? ( -
- 记忆模式已关闭 -
- 启用后可配置记忆类型 -
- ) : config.types.length === 0 && !isCreating ? ( -
- 未配置记忆类型 -
- - 点击上方按钮添加自定义类型 - -
- ) : ( -
- {config.types.map((t) => { - if (editingName === t.name) { - return ( -
-
-
名称
- setForm({ ...form, name: e.target.value })} - /> -
-
-
描述
- setForm({ ...form, description: e.target.value })} - /> -
-
- - -
-
- ); - } - if (deletingName === t.name) { - return ( -
- - 删除类型 {t.name}? - -
- - -
-
- ); - } - return ( -
-
-
-
- {t.name} - {t.isBuiltIn && ( - - 内置 - - )} - {!t.isBuiltIn && ( - - 自定义 - - )} -
-
- {t.description} -
-
-
- {!t.isBuiltIn && ( - <> - - - - )} - toggleType(t.name, !v)} /> -
-
-
- ); - })} -
- )} +
); } diff --git a/packages/desktop/src/shared/PlanApprovalModal.tsx b/packages/desktop/src/shared/PlanDecisionModal.tsx similarity index 72% rename from packages/desktop/src/shared/PlanApprovalModal.tsx rename to packages/desktop/src/shared/PlanDecisionModal.tsx index 7aba1f9e..0b28a323 100644 --- a/packages/desktop/src/shared/PlanApprovalModal.tsx +++ b/packages/desktop/src/shared/PlanDecisionModal.tsx @@ -1,12 +1,9 @@ import { useState, useCallback } from 'react'; import { X, Check, Pencil, Ban } from 'lucide-react'; -import MarkdownRenderer from './MarkdownRenderer'; -export interface PlanApprovalModalProps { - planContent: string; - planPath?: string; +export interface PlanDecisionModalProps { + title?: string; sessionId?: string; - loading?: boolean; onImplement: () => void; onSubmitOpinion: (opinion: string) => void; onCancel: () => void; @@ -14,15 +11,13 @@ export interface PlanApprovalModalProps { type Submitting = null | 'implement' | 'opinion' | 'cancel'; -export default function PlanApprovalModal({ - planContent, - planPath, +export default function PlanDecisionModal({ + title, sessionId, - loading, onImplement, onSubmitOpinion, onCancel, -}: PlanApprovalModalProps) { +}: PlanDecisionModalProps) { const [opinion, setOpinion] = useState(''); const [submitting, setSubmitting] = useState(null); @@ -46,22 +41,23 @@ export default function PlanApprovalModal({ onCancel(); }, [onCancel, submitting]); - const planPathLabel = planPath ?? ''; - return (
e.stopPropagation()} >
- 计划审批 + 计划已提交 + {title && ( + · {title} + )} {sessionId && ( 会话 {sessionId.slice(0, 8)} @@ -79,30 +75,15 @@ export default function PlanApprovalModal({
- {planPathLabel && ( -
- 计划文件:{planPathLabel} -
- )} - -
- {loading ? ( -
加载中…
- ) : planContent ? ( - - ) : ( -
(计划内容为空)
- )} +
+ 模型已把实现方案通过 submit_plan 保存。计划全文在上方对话中,决定如何处理:
-
+