diff --git a/.gitignore b/.gitignore index 491a887c..3b632596 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ client-tests.txt desktop-test.txt h-test.txt test-fallback.txt +test2.txt +tsc-out.txt +arch-out.txt +vitest-result.json # Compiled JS artifacts leaked into src directories packages/infra/src/*.js @@ -51,3 +55,4 @@ packages/desktop/pnpm-workspace.yaml Thumbs.db .idea/ .vscode/ +.workbuddy/memory/ 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/packages/codingcode/package.json b/packages/codingcode/package.json index 6ad3221d..a715ea62 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -8,44 +8,18 @@ }, "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": "./src/client/http/index.ts", "./client/types": "./src/client/types.ts", - "./client/http": "./src/client/http.ts", - "./client/http-clients": "./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/error": "./src/core/error.ts", + "./core/types": "./src/core/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..bc47e0b9 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -1,246 +1,172 @@ -import { Effect, Queue, Stream, Fiber } from 'effect'; -import type { Message } from '../core/types.js'; +import { Effect, 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 { AgentEvent } from './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); - - 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 effectivePerm = opts.permissionMode ?? state.permissionMode; + const profileName = opts.activeProfile ?? state.activeProfile; + if (opts.permissionMode) { + yield* session.setPermissionMode(normalizedCwd, sessionId, opts.permissionMode); + } + if (opts.activeProfile) { + yield* session.setActiveProfile(normalizedCwd, sessionId, opts.activeProfile); } - 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; - const profile = runtime.resolveMainAgentProfile(normalizedCwd, state.sessionId); - const policy = runtime.getToolPolicy(profile); + state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd); - const dispatchTool = yield* createDispatchAgentTool(); + const profile: AgentProfile | undefined = profileName ? resolveProfile(profileName) : undefined; - const activeLlm = llm; - const effectiveMaxSteps = profile?.maxSteps; - const effectiveApproval: any = options?.approvalOverride; + // get MCP tools + const mcpTools = mcp.listProjectMcpTools(normalizedCwd); - const mcpTools = mcp.listProjectMcpTools(normalizedCwd); + const catalog = toolCatalog.register(getToolNames(profile), mcpTools); - const turnId = session.incrementTurn(state); - const [, actualInput] = yield* skills.extractSkill(state.cwd, input); + const toolEnv = yield* toolEnvPort.getToolEnv(); - yield* session.recordUser(state, actualInput); + // record user (increments turn) + extract skill + const [, actualInput] = yield* skills.extractSkill(state.cwd, input); + const userEvent = yield* session.recordUser(state, actualInput); - yield* checkpoint.snapshotBaseline(state.cwd, sid, turnId); + // checkpoint baseline + yield* checkpoint.snapshotBaseline(state.cwd, sessionId, userEvent.turnId); - const rulesText = rules.getAllRules(state.cwd); + // get rules text + 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.interruptWhen(Fiber.await(fiber))); + for await (const event of Stream.toAsyncIterable(stream) as AsyncIterable) { + yield event; + } + })(); + } + + 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; + + return Effect.gen(function* () { + const basePrompt = buildSystemPrompt({ cwd: projectPath, platform: process.platform, shell: process.env.SHELL || process.env.ComSpec || 'bash', @@ -248,222 +174,98 @@ 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* hooks.emit('agent.turn.start', { sessionId: sid }); yield* q.offer({ _tag: 'TurnId', turnId: state.currentTurnId }); - for (let step = 0; step < effectiveMaxSteps; step++) { - yield* q.offer({ _tag: 'Step', step: step + 1, max: effectiveMaxSteps }); - - const allowedByPolicy = opts.toolPolicy?.allowedTools; - const tools = registry.describe(allowedByPolicy); - const toolLookup: ToolLookup = (name: string) => registry.get(name, allowedByPolicy); - const systemWithCatalog = system; - - const stepBeforePayload = { sessionId, step: step + 1 }; - yield* hooks.emitDecision('agent.step.before', stepBeforePayload); - - const compressResult = yield* Effect.tryPromise({ - try: () => - context.compactIfNeeded( - session.getTranscriptPath(state), - messages, - llm.modelInfo.maxTokens, - llm - ), + for (let step = 0; step < maxSteps; step++) { + yield* q.offer({ _tag: 'Step', step: step + 1, max: maxSteps }); + + yield* hooks.emitDecision('agent.step.before', { sessionId: sid, step: step + 1 }); + + const payload = yield* Effect.tryPromise({ + try: () => context.assemblePayload(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath, llm.modelInfo.maxTokens, llm), catch: (e) => new AgentError('LLM_FAILED', String(e)), }); - if (compressResult.didCompress && compressResult.messages) { + if (payload.compressed) { yield* q.offer({ - _tag: 'ReactiveCompact', - attempt: 1, - released: compressResult.released, - promptEstimate: compressResult.promptEstimate, + _tag: 'ContextCompressed', + released: payload.released, + promptEstimate: payload.promptEstimate, }); - - messages = compressResult.messages; - state.usage = undefined; } - - const llmMessages = [...messages]; - + const llmMessages = [...payload.messages]; const { stream: rawStream, response: respPromise } = llm.completeStream( - { - messages: llmMessages, - system: systemWithCatalog, - tools, - maxSteps: 1, - }, - opts.abortSignal + { messages: llmMessages, system, tools, maxSteps: 1 }, + abortSignal ); yield* Effect.tryPromise({ try: async () => { for await (const chunk of rawStream) { - if (opts.abortSignal?.aborted) break; + if (abortSignal?.aborted) break; Effect.runSync(q.offer({ _tag: 'LlmChunk', text: chunk })); } }, catch: (e) => new AgentError('LLM_FAILED', String(e)), }); - const llmResult = yield* Effect.tryPromise({ + const llmResult: any = 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', - }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error' }); break; } 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, - }); + 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, - }); + yield* session.recordAssistant(state, resp.content, toolCalls || [], resp.usage); + const stopDecision = yield* hooks.emitDecision('agent.turn.stop', { sessionId: sid, content: resp.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') - ); + yield* q.offer({ _tag: 'Error', error: new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded') }); + 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(new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded')); } 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* 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 ?? {}, - }); - } + for (const tc of toolCalls as any[]) { + yield* q.offer({ _tag: 'ToolStart', id: tc.id, name: tc.name, args: tc.arguments ?? {} }); } - const record = yield* session.recordAssistant(state, resp.content, toolCalls!, resp.usage); + 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, + turnId: state.currentTurnId, projectPath, signal: abortSignal, toolLookup, permissionMode, }); let todoPrinted = false; @@ -473,93 +275,41 @@ export function agentLoop( 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 }); + yield* q.offer({ _tag: 'ToolResult', id: r.id, name: r.name, output: resultOut, ok: r.type === 'ok' }); } if (!todoPrinted && r.name === 'todo_write') { - yield* q.offer({ _tag: 'TodoUpdate', items: todo.read(sessionId) }); + yield* q.offer({ _tag: 'TodoUpdate', items: todo.read(sid) as any }); 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 ?? ''); - } } - 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)); - }) - ) - ); -} + yield* q.offer({ _tag: 'Error', error: AgentError.maxStepsReached(maxSteps) }); + yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'maxSteps' }); + return Result.err(AgentError.maxStepsReached(maxSteps)); + }).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: opts.sid, turnId: opts.state.currentTurnId, status: 'aborted' }).pipe(Effect.ignore); + }) + ), + Effect.ensuring( + Effect.gen(function* () { + 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..f2d50c9a --- /dev/null +++ b/packages/codingcode/src/agent/deps.ts @@ -0,0 +1,121 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { AgentError } from '../core/error.js'; +import type { ToolCall, Message, ToolDescription } from '../core/types.js'; +import type { SessionStoreState, SessionEvent, TokenUsage, UserEvent } from '../session/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 { AgentProfileName } from './profile.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: AgentProfileName; 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: any[], 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: AgentProfileName): 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 }): Effect.Effect; +}>() {} + +export class SkillPort extends Context.Tag('AgentSkillPort'); +}>() {} + +export class McpPort extends Context.Tag('AgentMcpPort'); +}>() {} + +export class ContextPort extends Context.Tag('AgentContextPort'); +}>() {} + +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'); +}>() {} + +/** + * 工具执行期依赖的注入能力。 + * + * agent loop 在独立 runtime(Effect.runFork)中执行工具,而工具(todo_write、 + * dispatch_agent 等)的 `execute()` 内部会 `yield*` 具体服务(TodoService、 + * HookService、McpService、SubagentRunnerService)。这些服务是"工具执行期依赖", + * 与 agent 自身无关,agent 不应直接 import 它们的具体 Tag。 + * + * 因此这里只暴露一个抽象能力:`provide` 把一个 effect 包装成"工具执行期服务已就绪"的 + * effect。具体依赖哪些服务、如何注入,由组合根(layer.ts 的 ToolEnvLayer)负责, + * agent 对此一无所知 —— 这才是真正的依赖倒置。 + */ +export interface ToolEnv { + provide(effect: Effect.Effect): Effect.Effect; +} + +export class ToolEnvPort extends Context.Tag('AgentToolEnvPort'); +}>() {} + +/** + * 工具目录抽象:agent 只把"自己需要的工具名字名单"(toolNames)交给工具模块注册, + * 拿到"注册好的成品"(tools 描述列表 + lookup 查找),loop 里直接消费、不再查询。 + * + * agent 不接触任何具体工具定义与注册逻辑 —— 名字名单由 agent 侧按 profile 静态给出 + * (build/plan 各有各的名单),工具模块内部维护"名字 -> 定义"全量表并按名装配。 + * MCP 工具是运行时动态对象(非静态名单可覆盖),故一并传入。 + */ +export interface ToolCatalog { + tools: ToolDescription[]; + lookup: ToolLookup; +} + +export class ToolCatalogPort extends Context.Tag('AgentToolCatalogPort')[]): ToolCatalog; +}>() {} diff --git a/packages/codingcode/src/agent/port.ts b/packages/codingcode/src/agent/port.ts new file mode 100644 index 00000000..189ad53a --- /dev/null +++ b/packages/codingcode/src/agent/port.ts @@ -0,0 +1,26 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { AgentEvent } from './types.js'; +import type { AgentProfileName } from './profile.js'; +import type { PermissionMode } from '../approval/types.js'; + +export interface RunTurnOptions { + sessionId?: string; + cwd: string; + signal?: AbortSignal; + permissionMode?: PermissionMode; + model?: string; + activeProfile?: AgentProfileName; +} + +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..2fb0c544 100644 --- a/packages/codingcode/src/agent/profile.ts +++ b/packages/codingcode/src/agent/profile.ts @@ -1,16 +1,114 @@ -import { readFileSync } from 'fs'; +export type AgentProfileName = 'plan' | 'build'; + +export interface AgentProfile { + name: AgentProfileName; + systemPrompt?: string; +} + +import { readActiveProfileSync } from '../session/file-ops.js'; 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'; 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,28 +116,54 @@ export const BUILD_PROFILE: AgentProfile = { systemPrompt: BUILD_PROMPT, }; -export function isPlanProfile(p: { name: string } | null | undefined): boolean { - return p?.name === PLAN_PROFILE_NAME; -} - -export const PLAN_PROFILE_ALLOWED_TOOLS: ReadonlySet = new Set([ +// 各 profile 的工具名字名单:agent 只把这份名单交给工具模块注册,工具模块按名查表装配。 +// build 含写工具、不含 submit_plan;plan 相反(只读 + submit_plan)。 +export const PLAN_TOOL_NAMES: readonly string[] = [ 'read_file', 'search_files', 'search_code', 'fetch_url', 'submit_plan', -]); +]; + +export const BUILD_TOOL_NAMES: readonly string[] = [ + 'read_file', + 'write_file', + 'edit_file', + 'execute_command', + 'search_code', + 'search_files', + 'fetch_url', + 'web_search', + 'todo_write', + 'dispatch_agent', +]; + +// 运行时审批兜底(plan 模式 deny 非名单工具),从名单派生 +export const PLAN_PROFILE_ALLOWED_TOOLS: ReadonlySet = new Set(PLAN_TOOL_NAMES); + +export function isPlanProfile(p: { name: string } | null | undefined): boolean { + return p?.name === PLAN_PROFILE_NAME; +} + +function isAgentProfileName(name: string): name is AgentProfileName { + return name === PLAN_PROFILE_NAME || name === BUILD_PROFILE_NAME; +} + +export function resolveProfile(name: AgentProfileName): AgentProfile { + return name === PLAN_PROFILE_NAME ? PLAN_PROFILE : BUILD_PROFILE; +} + +export function resolveSubagentProfile(name: string): AgentProfile | undefined { + return isAgentProfileName(name) ? resolveProfile(name) : undefined; +} + +export function getToolNames(profile: AgentProfile | undefined): readonly string[] { + return isPlanProfile(profile) ? PLAN_TOOL_NAMES : BUILD_TOOL_NAMES; +} export 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; - } + return readActiveProfileSync(cwd, sessionId) === PLAN_PROFILE_NAME; } export const planProfileGateHook: DecisionHandler = (payload) => { 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 index c9fd1036..be4119f4 100644 --- a/packages/codingcode/src/agent/stream-adapter.ts +++ b/packages/codingcode/src/agent/stream-adapter.ts @@ -47,6 +47,13 @@ export async function* agentEventToStreamChunk( case 'TodoUpdate': yield { type: 'todo_update', items: event.items as any }; break; + case 'ContextCompressed': + yield { + type: 'context_compressed', + released: event.released, + promptEstimate: event.promptEstimate, + }; + break; case 'Usage': yield { type: 'usage', @@ -55,13 +62,6 @@ export async function* agentEventToStreamChunk( 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..916fe2f6 --- /dev/null +++ b/packages/codingcode/src/agent/tool-catalog.ts @@ -0,0 +1,18 @@ +import { Layer } from 'effect'; +import { ToolCatalogPort } from './deps.js'; +import type { ToolCatalog } from './deps.js'; +import { createToolCatalog } from '../tools/catalog.js'; + +/** + * ToolCatalogPort 的实现(组合根一侧)。 + * + * agent 只 import ToolCatalogPort 并传入名字名单,这里才是唯一接触具体工具装配的地方。 + * register 委托工具模块的 createToolCatalog:按名单从全量静态表查表注册,并合并 MCP 工具, + * 回传成品(tools 描述列表 + lookup 查找闭包)——agent 拿到后直接消费,不再 describe/get。 + */ +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..f4bacfb4 --- /dev/null +++ b/packages/codingcode/src/agent/tool-env.ts @@ -0,0 +1,42 @@ +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'; + +/** + * ToolEnvPort 的实现(组合根一侧)。 + * + * 它把"工具执行期依赖"(TodoService / HookService / McpService / SubagentRunnerService) + * 从具体服务适配成 agent 所需的抽象注入能力。agent 只 import ToolEnvPort,不接触这里列出的 + * 任何具体 Tag,从而完成依赖倒置。 + * + * 关键点:SubagentRunnerService 依赖 AgentService(子代理递归调用主代理),若在 Layer + * 构造期直接 `yield*` 会形成循环。因此这里构造期不静态依赖任何服务 —— 仅返回 + * `getToolEnv` 函数,`yield* SubagentRunnerService` 延迟到 getToolEnv 在运行时(外层 + * AppRuntime 已就绪)才解析,从而打破递归循环。 + */ +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 index 4fb3cfce..c18672a9 100644 --- a/packages/codingcode/src/agent/types.ts +++ b/packages/codingcode/src/agent/types.ts @@ -1,35 +1,5 @@ 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 } @@ -54,12 +24,6 @@ export type AgentEvent = 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 } | { @@ -70,27 +34,14 @@ export type AgentEvent = }>; } | { readonly _tag: 'TurnId'; readonly turnId: number } + | { + readonly _tag: 'ContextCompressed'; + readonly released: number; + readonly promptEstimate: 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/approval.ts b/packages/codingcode/src/approval/approval.ts new file mode 100644 index 00000000..9d056423 --- /dev/null +++ b/packages/codingcode/src/approval/approval.ts @@ -0,0 +1,48 @@ +import { Layer, Effect } from 'effect'; +import { HookService } from '../hooks/port.js'; +import type { PermissionMode } from './types.js'; +import { createRuleEngine, type RuleEngine } from './rule-engine.js'; +import { DEFAULT_DENY_RULES, DANGEROUS_TOOL_NAMES } from './presets.js'; +import { runPipeline } from './pipeline.js'; +import { ApprovalWaitService } from './wait-port.js'; +import { ApprovalService } from './port.js'; + +export const ApprovalLayer = Layer.effect(ApprovalService, 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); + + return { + evaluate: (request: { + tool: string; + input: Record; + context?: Record; + callId?: string; + sessionId: string; + projectPath?: string; + permissionMode?: PermissionMode; + }): any => + runPipeline( + { + tool: request.tool, + input: request.input, + context: request.context, + callId: request.callId, + }, + { + ruleEngine, + destructiveTools, + permissionMode: request.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) + ), + }; +} as any)); diff --git a/packages/codingcode/src/approval/confirmation.ts b/packages/codingcode/src/approval/confirmation.ts index a41bbac1..04b5182f 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' } 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/pipeline.ts b/packages/codingcode/src/approval/pipeline.ts index d15a0921..3739c67e 100644 --- a/packages/codingcode/src/approval/pipeline.ts +++ b/packages/codingcode/src/approval/pipeline.ts @@ -2,12 +2,11 @@ import { Effect } from 'effect'; import type { ApprovalDecision, PermissionMode, PermissionRule, ToolCallRequest } from './types.js'; import 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 { HookService } from '../hooks/port.js'; export interface PipelineOptions { ruleEngine: RuleEngine; - readonlyTools: Set; destructiveTools: Set; permissionMode: PermissionMode; /** Called when user selects Always — allows caller to persist the rule. */ @@ -25,7 +24,6 @@ export interface PipelineOptions { const LAYER_NAMES = [ 'RuleEngine', - 'ReadonlyWhitelist', 'PermissionMode', 'HookPreToolUse', 'UserConfirmation', @@ -35,10 +33,10 @@ const LAYER_NAMES = [ 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 +50,23 @@ 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 — the single auto-allow gate. Read-only tools + // are NOT unconditionally whitelisted; acceptEdits covers them as + // non-destructive. In default mode nothing is auto-allowed here. { const modeResult = applyPermissionMode( request.tool, opts.permissionMode, - opts.readonlyTools, 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 +81,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', @@ -119,9 +105,9 @@ export function runPipeline( } } - // Layer 5: User Confirmation + // Layer 4: User Confirmation { - layers.push(LAYER_NAMES[4]); + layers.push(LAYER_NAMES[3]); if (request.tool === 'submit_plan') { const result: ApprovalDecision = { @@ -176,7 +162,6 @@ export function runPipeline( function applyPermissionMode( tool: string, mode: PermissionMode, - readonlyTools: Set, destructiveTools: Set ): ApprovalDecision | null { switch (mode) { @@ -185,7 +170,8 @@ function applyPermissionMode( return { type: 'allow', source: 'permission-mode' }; case 'acceptEdits': - // Accept edits: read-only + edit tools auto-allow, destructive tools need confirmation + // Accept edits: non-destructive tools (read-only + edit) auto-allow, + // destructive tools need confirmation if (!destructiveTools.has(tool)) { return { type: 'allow', source: 'permission-mode' }; } @@ -198,13 +184,13 @@ function applyPermissionMode( } function recordAuditAndReturn( - hooks: HookService, + hooks: any, request: ToolCallRequest, decision: ApprovalDecision, passedLayers: string[] -): Effect.Effect { +): any { return Effect.gen(function* () { - passedLayers.push(LAYER_NAMES[5]); + passedLayers.push(LAYER_NAMES[4]); yield* hooks.emit('tool.approval.post', { tool: request.tool, input: request.input, diff --git a/packages/codingcode/src/approval/port.ts b/packages/codingcode/src/approval/port.ts new file mode 100644 index 00000000..d792e430 --- /dev/null +++ b/packages/codingcode/src/approval/port.ts @@ -0,0 +1,9 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +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 }): 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 index c611e4f4..40582368 100644 --- a/packages/codingcode/src/approval/presets.ts +++ b/packages/codingcode/src/approval/presets.ts @@ -84,14 +84,4 @@ export const DEFAULT_DENY_RULES: PermissionRule[] = [ }, ]; -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/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/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..28fb6df0 100644 --- a/packages/codingcode/src/client/http/agent-runtime.ts +++ b/packages/codingcode/src/client/http/agent-runtime.ts @@ -15,7 +15,6 @@ export interface AgentRuntimeClient { }): Promise; compact(input: { sessionId: string; cwd: string }): Promise; - getCheckpoints(): Promise>; getCheckpointDiff(turnId?: number): Promise; revertCheckpointFiles( turnId: number, @@ -29,18 +28,11 @@ export interface AgentRuntimeClient { ): 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 }>; @@ -94,13 +86,6 @@ export function createHttpAgentClient( 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', @@ -129,6 +114,13 @@ export function createHttpAgentClient( case 'todo_update': yield { type: 'todo_update', items: data.items as any }; break; + case 'context_compressed': + yield { + type: 'context_compressed', + released: data.released as number, + promptEstimate: data.promptEstimate as number, + }; + break; case 'usage': yield { type: 'usage', @@ -137,13 +129,6 @@ export function createHttpAgentClient( 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; @@ -163,10 +148,6 @@ export function createHttpAgentClient( 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=_`); @@ -195,14 +176,6 @@ export function createHttpAgentClient( return apiPost(`/api/sessions/_/rollback-both-to-turn?cwd=_`, { throughTurnId }); }, - async undoLastCodeRollback(force?: boolean, files?: string[]) { - return apiPost(`/api/sessions/_/undo-code-rollback?cwd=_`, { force, files }); - }, - - async getRollbackState() { - return apiGet('/api/sessions/_/rollback-state?cwd=_'); - }, - async forkSession(atTurnId?: number) { return apiPost('/api/sessions/_/fork?cwd=_', { atTurnId }); }, diff --git a/packages/codingcode/src/client/http/models.ts b/packages/codingcode/src/client/http/models.ts index 416f5df6..1f97d49e 100644 --- a/packages/codingcode/src/client/http/models.ts +++ b/packages/codingcode/src/client/http/models.ts @@ -1,4 +1,4 @@ -import type { SelectableModel } from '../../llm/factory.js'; +import type { SelectableModel } from '../../llm/port.js'; import type { createRequestHelpers } from './request.js'; export interface ModelClient { diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts index 99551462..b5854bd4 100644 --- a/packages/codingcode/src/client/http/sessions.ts +++ b/packages/codingcode/src/client/http/sessions.ts @@ -2,12 +2,10 @@ 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 { AgentProfileName } from '../../agent/profile.js'; import type { createRequestHelpers } from './request.js'; export interface SessionClient { @@ -67,19 +65,11 @@ export interface SessionClient { sessionId: string; cwd: string; throughTurnId: number; - }): Promise<{ turns: SessionEvent[]; rollbackState: RollbackState }>; + }): Promise<{ turns: SessionEvent[] }>; 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; @@ -168,14 +158,6 @@ 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 }); }, diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index 5768be68..18f512cb 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -7,14 +7,9 @@ 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 }>; @@ -74,22 +69,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)}`); }, diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index 63756f83..21746c8b 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -1,16 +1,3 @@ -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 } @@ -22,68 +9,11 @@ export type StreamChunk = 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; -} + | { type: 'context_compressed'; released: number; promptEstimate: number } + | { type: 'usage'; prompt: number; completion: number; total: number }; 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..d3109637 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 { BuildResult, 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 didCompact = applyOldTurnCompaction( - visible, - currentTurnId, - preEstimate, - contextWindow, - jsonlPath - ); + const estimateFor = (s: PayloadState): number => + estimateTokens(buildContextMessages(s.visible, s.compactedTurnIds)); - 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,63 @@ 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)); } + // 组装时内部决定是否需要 LLM 压缩;是否压缩、didCompress 均不对外暴露, + // 仅把释放量作为状态回报给组装结果 + 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 (estimateFor(cur) <= contextWindow * COMPACTION_THRESHOLD) break; + const released = await tryCompaction(cur, llm); + if (released <= 0) break; + releasedTotal += released; + cur = readState(cur.jsonlPath); + } + return { state: cur, released: releasedTotal }; + } + + const assemblePayload = async ( + transcriptPath: string, + contextWindow: number, + llm: LLMClient | null + ): Promise => { + let s = readState(transcriptPath); + s = runMicroCompact(s, contextWindow); + const { state, released } = await summarizeToFit(s, contextWindow, llm); + return { + messages: buildContextMessages(state.visible, state.compactedTurnIds), + compressed: released > 0, + released, + promptEstimate: estimateFor(state), + }; + }; + + 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() @@ -439,8 +417,6 @@ export class ContextService extends Effect.Service()('Context', return { 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..b1efd27b --- /dev/null +++ b/packages/codingcode/src/context/port.ts @@ -0,0 +1,26 @@ +import { Context } from 'effect'; +import type { Message } from '../core/types.js'; +import type { LLMClient } from '../llm/client.js'; + +export interface BuildResult { + messages: Message[]; + /** 本次组装是否发生过 LLM 摘要压缩(决策在 context 内部,仅上报结果) */ + compressed: boolean; + /** 摘要压缩释放的 token 数(未压缩为 0) */ + released: number; + /** 组装后上下文估算 token 数 */ + promptEstimate: number; +} + +export interface CompressResult { + didCompress: boolean; + released: number; + promptEstimate: number; +} + +export interface ContextShape { + 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/direct/agent-runtime.ts b/packages/codingcode/src/direct/agent-runtime.ts index 578be493..5fbdc811 100644 --- a/packages/codingcode/src/direct/agent-runtime.ts +++ b/packages/codingcode/src/direct/agent-runtime.ts @@ -1,13 +1,11 @@ import { Effect } from 'effect'; -import { sendMessage } from '../agent/agent.js'; -import { ApprovalWaitService } from '../approval/async-confirm.js'; +import { AgentService } from '../agent/port.js'; +import { ApprovalWaitService } from '../approval/wait-port.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 { ContextService } from '../context/port.js'; +import { SessionService } from '../session/port.js'; +import { CheckpointService } from '../checkpoint/port.js'; +import { computePaths } from '../core/path.js'; import type { StreamChunk } from '../client/types.js'; import { agentEventToStreamChunk } from '../agent/stream-adapter.js'; import type { AppRuntime } from '../layer.js'; @@ -26,7 +24,6 @@ export interface AgentRuntimeClient { }): Promise; compact(input: { sessionId: string; cwd: string }): Promise; - getCheckpoints(cwd: string): Promise>; getCheckpointDiff( cwd: string, turnId?: number @@ -35,21 +32,20 @@ export interface AgentRuntimeClient { cwd: string, turnId: number, files: string[] - ): Promise; + ): Promise; previewRollbackDiff( cwd: string, throughTurnId: number - ): Promise; + ): Promise; rollbackCodeToTurn( cwd: string, throughTurnId: number - ): Promise; + ): Promise; rollbackContext( cwd: string, throughTurnId: number ): Promise<{ turns: Array<{ id: string; items: object[]; status: string }>; - rollbackState: import('../checkpoint/types.js').RollbackState; }>; rollbackBothToTurn( cwd: string, @@ -57,14 +53,7 @@ export interface AgentRuntimeClient { ): 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 @@ -79,32 +68,27 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu return { async *sendMessage(input, { sessionId, cwd }) { - const opts: Parameters[4] = {}; + const runOpts: any = { 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; + const { stream: agentGen, sessionId: resolvedSessionId } = await rt.runPromise( + Effect.gen(function* () { + const agent = yield* AgentService; + return yield* agent.runTurn(input, { sessionId: sessionId || undefined, ...runOpts }); + }) + ); currentSessionId = resolvedSessionId; yield { type: 'session_id', sessionId: resolvedSessionId }; let notifyApproval: ((req: StreamChunk) => void) | null = null; - let notifyPlan: ((req: StreamChunk) => void) | null = null; const waitService = await rt.runPromise( Effect.gen(function* () { return yield* ApprovalWaitService; }) ); - const hookService = await rt.runPromise( - Effect.gen(function* () { - return yield* HookService; - }) - ); Effect.runSync( waitService.registerEmitter( resolvedSessionId, @@ -113,20 +97,6 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu } ) ); - 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); @@ -134,13 +104,9 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu 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', @@ -150,10 +116,6 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu tag: 'approval', value: req, })), - planPromise.then((req): { tag: 'plan'; value: StreamChunk } => ({ - tag: 'plan', - value: req, - })), ]); if (winner.tag === 'chunk') { @@ -162,24 +124,15 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu currentApprovalPromise = new Promise((resolve) => { notifyApproval = resolve; }); - currentPlanPromise = new Promise((resolve) => { - notifyPlan = resolve; - }); pending = gen.next(); - } else if (winner.tag === 'approval') { + } else { yield winner.value; currentApprovalPromise = new Promise((resolve) => { notifyApproval = resolve; }); - } else { - yield winner.value; - currentPlanPromise = new Promise((resolve) => { - notifyPlan = resolve; - }); } } } finally { - unregisterPlanReady(); Effect.runSync(waitService.unregisterEmitter(resolvedSessionId)); } }, @@ -201,21 +154,12 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu 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* () { @@ -258,22 +202,13 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu 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 }; + const turns = yield* session.readUITurns(currentSessionId, cwd); + return { turns }; }) ); }, - async rollbackBothToTurn(cwd: string, throughTurnId: number) { + async rollbackBothToTurn(cwd: string, throughTurnId: number): Promise { return rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; @@ -285,47 +220,8 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu 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, - }, - }; + const turns = yield* session.readUITurns(currentSessionId, cwd); + return { turns, codeResult }; }) ); }, @@ -336,7 +232,7 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu const session = yield* SessionService; const state = yield* session.load(cwd, currentSessionId); const newSessionId = yield* session.forkSession(state, atTurnId ?? 0); - const turns = readUIHistory(newSessionId, cwd); + const turns = yield* session.readUITurns(newSessionId, cwd); return { sessionId: newSessionId, turns }; }) ); diff --git a/packages/codingcode/src/direct/models.ts b/packages/codingcode/src/direct/models.ts index 57942dc1..b3e30c1d 100644 --- a/packages/codingcode/src/direct/models.ts +++ b/packages/codingcode/src/direct/models.ts @@ -1,6 +1,6 @@ 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 { SelectableModel } from '../llm/port.js'; import type { AppRuntime } from '../layer.js'; export interface ModelClient { diff --git a/packages/codingcode/src/direct/sessions.ts b/packages/codingcode/src/direct/sessions.ts index 6b3d191b..c4c74287 100644 --- a/packages/codingcode/src/direct/sessions.ts +++ b/packages/codingcode/src/direct/sessions.ts @@ -1,19 +1,16 @@ 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 { 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 type { AgentProfileName } from '../agent/profile.js'; import type { AppRuntime } from '../layer.js'; export interface SessionClient { @@ -74,19 +71,11 @@ export interface SessionClient { sessionId: string; cwd: string; throughTurnId: number; - }): Promise<{ turns: SessionEvent[]; rollbackState: RollbackState }>; + }): Promise<{ turns: SessionEvent[] }>; 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; @@ -140,7 +129,12 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { }, async deleteSession({ sessionId, cwd }) { - deleteSession(sessionId, cwd); + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.deleteSession(sessionId, cwd); + }) + ); }, async getSessionProfile({ sessionId, cwd }) { @@ -177,7 +171,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); - return yield* session.getPermissionMode(state); + return state.permissionMode; }) ); return mode as PermissionMode; @@ -187,8 +181,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { return rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - yield* session.setPermissionMode(state, mode); + yield* session.setPermissionMode(cwd, sessionId, mode); }) ); }, @@ -223,7 +216,6 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { throughTurnId: 0, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }; }, async previewRollbackDiff() { @@ -235,21 +227,11 @@ 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, }; }, async rollbackBothToTurn() { @@ -260,32 +242,7 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { 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 }) { diff --git a/packages/codingcode/src/direct/settings.ts b/packages/codingcode/src/direct/settings.ts index f9b282c3..567bc98e 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,20 +35,15 @@ import { updateContextCompactionModel, } from '@codingcode/infra/config'; import type { AppRuntime } from '../layer.js'; -import { SessionService } from '../session/store.js'; +import { SessionService } from '../session/port.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 }>; @@ -238,7 +226,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 +253,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 +394,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 +407,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..6d011a06 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -1,115 +1,166 @@ 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 { 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)) -); -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)) +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 { + 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 ApprovalLayer = ApprovalService.Default.pipe( - Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer)) + +// base layers +const InfraLayer = Layer.mergeAll( + WorkspaceService.Default, HookLayer, RulesLayer, SkillLayer, McpLayer, ApprovalWaitLayer, TodoLayer, ); -export const SystemHookLayer = HookLayer.pipe( +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, ApprovalLayer))); +const ContextWithDeps = ContextLayer.pipe(Layer.provide(Layer.mergeAll(SessionLayer, LlmWithDeps))); +const MemoryWithDeps = MemoryLayer.pipe(Layer.provide(LlmWithDeps)); + +// system hook registration +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', + priority: -1000, source: 'system', }); }) ) ); -/** ToolExecutor depends on HookLayer + ApprovalLayer. */ -const ExecutorDeps = Layer.mergeAll(HookLayer, ApprovalLayer); -const ExecutorLayer = ToolExecutorService.Default.pipe(Layer.provide(ExecutorDeps)); - -/** 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 +// agent with deps +const AgentWithDeps = AgentLayer.pipe( + Layer.provide(Layer.mergeAll(AgentDepsAdapter, ToolEnvLayer, ToolCatalogLayer, InfraLayer, SessionLayer, ToolExecutorWithDeps, ApprovalWithDeps, ContextWithDeps, MemoryWithDeps, CheckpointLayer)) ); -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. */ + +// subagent runner (depends on agent) +const SubagentWithDeps = SubagentRunnerLayer.pipe(Layer.provide(AgentWithDeps)); + 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 + 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/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..c16692c1 100644 --- a/packages/codingcode/src/llm/providers/deepseek.ts +++ b/packages/codingcode/src/llm/providers/deepseek.ts @@ -5,7 +5,7 @@ import { 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 type { SelectableModel } from '../port.js'; import { convertMessages, convertTools, parseResponseMessages } from './shared.js'; export class DeepSeekProvider implements LLMClient { diff --git a/packages/codingcode/src/llm/providers/openai.ts b/packages/codingcode/src/llm/providers/openai.ts index b3479a1c..0cdec077 100644 --- a/packages/codingcode/src/llm/providers/openai.ts +++ b/packages/codingcode/src/llm/providers/openai.ts @@ -5,7 +5,7 @@ import { 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 type { SelectableModel } from '../port.js'; import { convertMessages, convertTools, parseResponseMessages } from './shared.js'; export class OpenAIProvider implements LLMClient { 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..db82d360 100644 --- a/packages/codingcode/src/memory/extractor.ts +++ b/packages/codingcode/src/memory/extractor.ts @@ -1,67 +1,32 @@ 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({ 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 78% rename from packages/codingcode/src/scheduler/service.ts rename to packages/codingcode/src/scheduler/scheduler.ts index 981d95ab..e3725355 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,31 +36,19 @@ 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', + }); }) ); @@ -178,31 +163,19 @@ 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', + }); }) ); @@ -234,5 +207,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 index 1a07b880..8f2ec378 100644 --- a/packages/codingcode/src/server/adapter.ts +++ b/packages/codingcode/src/server/adapter.ts @@ -29,6 +29,12 @@ export function agentEventToSseEvent(event: AgentEvent): SseEvent | null { return { type: 'done' }; case 'TodoUpdate': return { type: 'todo_update', items: event.items as unknown as Record[] }; + case 'ContextCompressed': + return { + type: 'context_compressed', + released: event.released, + promptEstimate: event.promptEstimate, + }; case 'Usage': return { type: 'usage', @@ -38,7 +44,6 @@ export function agentEventToSseEvent(event: AgentEvent): SseEvent | null { }; case 'LlmChunk': case 'Assistant': - case 'ReactiveCompact': return null; default: return null; diff --git a/packages/codingcode/src/server/handler.ts b/packages/codingcode/src/server/handler.ts index 01574b7f..d05bad47 100644 --- a/packages/codingcode/src/server/handler.ts +++ b/packages/codingcode/src/server/handler.ts @@ -1,7 +1,6 @@ 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 }; @@ -26,11 +25,6 @@ export function createSseHandler(rt: ManagedRt) { return yield* ApprovalWaitService; }) ); - const hookService = await rt.runPromise( - Effect.gen(function* () { - return yield* HookService; - }) - ); Effect.runSync( waitService.registerEmitter( sessionId, @@ -40,21 +34,6 @@ export function createSseHandler(rt: ManagedRt) { ) ); - 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); @@ -74,7 +53,6 @@ export function createSseHandler(rt: ManagedRt) { ...(e instanceof AgentError ? { code: e.code } : {}), }); } 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..03edfbaa 100644 --- a/packages/codingcode/src/server/routes/approval.ts +++ b/packages/codingcode/src/server/routes/approval.ts @@ -1,6 +1,6 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; -import { ApprovalWaitService } from '../../approval/async-confirm.js'; +import { ApprovalWaitService } from '../../approval/wait-port.js'; import { parseApprovalResponse } from '../../approval/response.js'; import { errorResponse } from '../util.js'; 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..83148fba 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -1,14 +1,8 @@ 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 +21,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)}`)) ), 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..ab7a76c5 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -3,15 +3,14 @@ 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 { AgentProfileName } from '../../agent/profile.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'; @@ -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 ---- @@ -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..1d8668df 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -15,10 +15,9 @@ import { homedir } from 'os'; import { join, dirname } from 'path'; import { getProjectBaseDir } from '../core/path.js'; import { computePaths, projectSessionsDir, sessionJsonlPathFromCwd } from '../core/path.js'; +import type { ProfileName, PermissionMode } from './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,15 @@ export function readCurrentIndex(indexPath: string): Partial | nul } } +export function readActiveProfileSync(cwd: string, sessionId: string): ProfileName | null { + const idx = readCurrentIndex(computePaths(cwd, sessionId).indexPath); + return idx?.activeProfile ?? null; +} + +export function readTranscript(cwd: string, sessionId: string): SessionEvent[] { + return readHistory(sessionJsonlPathFromCwd(cwd, sessionId)); +} + export function writeIndexAtomic(indexPath: string, patch: Partial): void { let current: Partial = {}; if (existsSync(indexPath)) { @@ -147,7 +155,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 +171,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..d70dd75d --- /dev/null +++ b/packages/codingcode/src/session/port.ts @@ -0,0 +1,45 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { AgentError } from '../core/error.js'; +import type { + ProfileName, + PermissionMode, + AssistantEvent, + RollbackEvent, + SessionEvent, + SessionIndex, + SessionStoreState, + SummaryEvent, + TokenUsage, + ToolResultEvent, + UserEvent, +} from './types.js'; + +export interface UITurn { + id: string; + items: object[]; + status: string; +} + +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')() {} diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/session.ts similarity index 69% rename from packages/codingcode/src/session/store.ts rename to packages/codingcode/src/session/session.ts index fdf66b6e..c658bcf1 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, + ProfileName, + PermissionMode, + CompactEvent, } from './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,132 @@ 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': { + 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)); +} + +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 +196,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 +293,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 +314,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 +478,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 +491,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 +543,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..e1c691a5 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -1,12 +1,14 @@ -import type { AgentProfileName } from '../subagent/types.js'; +export type ProfileName = 'plan' | 'build'; + +export type PermissionMode = 'default' | 'acceptEdits' | 'bypass'; 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,6 +17,7 @@ export interface UserEvent { type: 'user'; turnId: number; content: string; + source?: 'user' | 'system'; } export interface AssistantEvent { @@ -79,8 +82,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 +94,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..60a58e3f --- /dev/null +++ b/packages/codingcode/src/subagent/port.ts @@ -0,0 +1,25 @@ +import { Context } from 'effect'; +import type { Effect } from 'effect'; +import type { AgentEvent } from '../agent/types.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('../agent/profile.js').AgentProfileName; + 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..6694fe3a --- /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 { AgentEvent } from '../agent/types.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..fe479cc2 --- /dev/null +++ b/packages/codingcode/src/todo/port.ts @@ -0,0 +1,29 @@ +import { Context } from 'effect'; + +export interface Todo { + step: string; + status: 'pending' | 'in_progress' | 'completed'; +} + +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..e577b176 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -2,219 +2,88 @@ 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 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; } - })(); - }); - - if (didComplete) { - yield* hooks - .emit('agent.subagent.complete', { - childSessionId: childUuid, - profile: agentName, - status: 'done', - }) - .pipe(Effect.ignore); + } + 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..cb99e665 --- /dev/null +++ b/packages/codingcode/src/tools/port.ts @@ -0,0 +1,24 @@ +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; + approval?: import('../approval/port.js').ApprovalService; + toolLookup?: ToolLookup; + permissionMode?: import('../approval/types.js').PermissionMode; + }): 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 88% rename from packages/codingcode/src/tools/executor.ts rename to packages/codingcode/src/tools/tools.ts index 5109bf91..6cf36738 100644 --- a/packages/codingcode/src/tools/executor.ts +++ b/packages/codingcode/src/tools/tools.ts @@ -1,9 +1,10 @@ -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 { ApprovalService } from '../approval/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,8 +13,7 @@ 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; @@ -25,27 +25,24 @@ export class ToolExecutorService extends Effect.Service()(' sessionId?: string; turnId?: number; projectPath?: string; - approval?: import('../approval/index.js').ApprovalService; + approval?: import('../approval/port.js').ApprovalService; callId?: string; toolLookup?: ToolLookup; + permissionMode?: import('../approval/types.js').PermissionMode; } - ): 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 decisionApproval: any = opts?.approval ?? approval; const decision = yield* decisionApproval.evaluate({ tool: name, input: args as Record, callId: opts?.callId, sessionId: opts?.sessionId ?? 'default', projectPath: opts?.projectPath, + permissionMode: opts?.permissionMode, }); if (decision.type === 'deny') { @@ -132,13 +129,14 @@ export class ToolExecutorService extends Effect.Service()(' turnId?: number; projectPath?: string; signal?: AbortSignal; - approval?: import('../approval/index.js').ApprovalService; + approval?: import('../approval/port.js').ApprovalService; toolLookup?: ToolLookup; + permissionMode?: import('../approval/types.js').PermissionMode; } ): 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,8 +180,9 @@ export class ToolExecutorService extends Effect.Service()(' turnId?: number; projectPath?: string; signal?: AbortSignal; - approval?: import('../approval/index.js').ApprovalService; + approval?: import('../approval/port.js').ApprovalService; toolLookup?: ToolLookup; + permissionMode?: import('../approval/types.js').PermissionMode; } ): Effect.Effect { return Effect.gen(function* () { @@ -238,6 +237,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..0532ee11 100644 --- a/packages/codingcode/src/tools/types.ts +++ b/packages/codingcode/src/tools/types.ts @@ -10,14 +10,9 @@ export interface ToolExecCtx { 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/agent-cache-stability.test.ts b/packages/codingcode/test/agent/agent-cache-stability.test.ts index 60cbab09..ba0fd901 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 } from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,107 +13,36 @@ 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: '' })), + response: Promise.resolve({ ok: true, value: { content: '' } }), }; - }, + }), 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..df739a51 100644 --- a/packages/codingcode/test/agent/agent-concurrent.test.ts +++ b/packages/codingcode/test/agent/agent-concurrent.test.ts @@ -1,13 +1,12 @@ 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 type { AgentEvent } from '../../src/agent/types.js'; +import { makeState, runAgentTurn } from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,170 +15,110 @@ 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' }); + +function okResponse(content: string, toolCalls?: any[]) { + return Promise.resolve({ ok: true, value: { content, toolCalls } }); +} + +// 每个工具独立执行并把顺序记录到 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 { + stream: (async function* () {})(), + response: okResponse('', firstToolCalls), + }; + } + return { stream: (async function* () {})(), response: okResponse('done') }; }), - 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 = { - 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: '', -}; + modelInfo: { maxTokens: 1000 }, + } as any; + return llm; +} -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'); @@ -192,73 +131,25 @@ describe('agentLoop concurrent tool execution', () => { }); 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'); + const toolResults = events.filter( + (e): e is Extract => 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); + expect(toolResults.find((r) => r.name === 'good_tool')?.ok).toBe(true); + expect(toolResults.find((r) => r.name === 'good_tool2')?.ok).toBe(true); + expect(toolResults.find((r) => r.name === 'bad_tool')?.ok).toBe(false); }); }); 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..7e4daebd 100644 --- a/packages/codingcode/test/agent/agent-todo-event.test.ts +++ b/packages/codingcode/test/agent/agent-todo-event.test.ts @@ -1,13 +1,12 @@ 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 type { AgentEvent } from '../../src/agent/types.js'; +import { makeState, runAgentTurn, type HarnessMocks } from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,172 +15,83 @@ 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'; +function okResponse(content: string, toolCalls?: any[]) { + return Promise.resolve({ ok: true, value: { content, toolCalls } }); +} -/** 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 { + stream: (async function* () {})(), + response: okResponse('', [{ id: 'tc1', name: firstToolName, arguments: {} }]), + }; + } + return { stream: (async function* () {})(), response: okResponse('done') }; }), - 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; - -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' } }], - }) - ), - }), -}; +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; +} describe('TodoUpdate event', () => { it('should yield TodoUpdate when todo_write tool is called', async () => { - todoStore.set('test-todo-sid', [ + 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'); + const todoUpdates = events.filter( + (e): e is Extract => e._tag === 'TodoUpdate' + ); expect(todoUpdates).toHaveLength(1); - expect(todoUpdates[0].items).toEqual([ + expect(todoUpdates[0]!.items).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' }, - ]), + 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); diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index dca1276c..8b947cac 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -1,270 +1,91 @@ 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 { Effect } from 'effect'; 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 { makeState, runAgentTurn, type HarnessMocks } 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 okResponse(content: string, toolCalls?: any[]) { + return Promise.resolve({ ok: true, value: { content, toolCalls } }); } -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', () => { - it('should yield text chunks from LLM stream', async () => { - const mockLlm = { - completeStream: (_params: any) => ({ +function makeCapturingLlm(opts: { content?: string; toolCalls?: any[]; stream?: string[] }) { + const calls: any[] = []; + const llm = { + completeStream: vi.fn(() => { + calls.push({}); + return { stream: (async function* () { - yield 'Hello'; - yield ' '; - yield 'world'; + for (const c of opts.stream ?? []) yield c; })(), - response: Promise.resolve(Result.ok({ content: 'Hello world' })), - }), - }; + response: okResponse(opts.content ?? 'Hello world', opts.toolCalls), + }; + }), + modelInfo: { maxTokens: 1000 }, + } as any; + return { llm, calls }; +} - 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 - ); - await Effect.runPromise(effect.pipe(Effect.provide(AllMockLayer))); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); +describe('agent runTurn loop', () => { + it('should yield text chunks from LLM stream', async () => { + const { llm } = makeCapturingLlm({ content: 'Hello world', stream: ['Hello', ' ', 'world'] }); + const { events } = await runAgentTurn({ llm, state: mockState }, { sessionId: 'test-sid', cwd: '/tmp' }); const textEvents = events.filter((e: any) => e._tag === 'LlmChunk'); expect(textEvents.map((e: any) => e.text)).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 - ); - await Effect.runPromise(effect.pipe(Effect.provide(AllMockLayer))); - const events = Chunk.toArray(Effect.runSync(Queue.takeAll(q))); + const { llm } = makeCapturingLlm({ content: '' }); + const { events } = await runAgentTurn({ llm, state: mockState }, { sessionId: 'test-sid', cwd: '/tmp' }); const textEvents = events.filter((e: any) => e._tag === 'LlmChunk'); expect(textEvents).toHaveLength(0); + expect(events.some((e: any) => e._tag === 'Done')).toBe(true); }); - 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: [ + it('should surface tool results as ToolResult events', async () => { + let callCount = 0; + const llm = { + completeStream: vi.fn(() => { + callCount++; + if (callCount === 1) { + return { + stream: (async function* () {})(), + response: okResponse('', [ { id: 'tc1', name: 'execute_command', arguments: { command: 'git status' } }, - ], - }) - ), + ]), + }; + } + return { stream: (async function* () {})(), response: okResponse('done') }; }), - }; - - 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' @@ -274,116 +95,57 @@ describe('agentLoop', () => { expect(toolResults[0]!.ok).toBe(true); }); - 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 { + stream: (async function* () { + yield '\n[Using: readFile]\n'; + })(), + response: okResponse('', [ + { id: 'tc1', name: 'readFile', arguments: { path: 'test.txt' } }, + ]), + }; + } + return { stream: (async function* () {})(), response: okResponse('done') }; }), - }; - - 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']); }); it('should yield a single maxSteps error and a single turn.end hook when maxSteps is exhausted', async () => { - const mockLlm = { - completeStream: (_params: any) => ({ + // LLM always requests a tool call → the loop never reaches a natural stop. + const llm = { + completeStream: vi.fn(() => ({ 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', - })) - ), - }; - + response: okResponse('', [{ id: 'tc1', name: 'read_file', arguments: { path: 'x' } }]), + })), + 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' 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..aa55347f --- /dev/null +++ b/packages/codingcode/test/agent/context-compressed.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { makeState, runAgentTurn } from '../helpers/agent-harness.js'; +import { agentEventToStreamChunk } from '../../src/agent/stream-adapter.js'; +import { agentEventToSseEvent, toSseEvents } from '../../src/server/adapter.js'; + +function makePlainLlm(content = 'ok') { + return { + completeStream: () => ({ + stream: (async function* () { + yield content; + })(), + response: Promise.resolve({ ok: true, value: { content, toolCalls: [] } }), + }), + complete: () => Promise.resolve({ content, toolCalls: [] }), + modelInfo: { provider: 'mock', model: 'mock', maxTokens: 1000 }, + } as any; +} + +describe('ContextCompressed', () => { + it('emits ContextCompressed when assemblePayload reports compression', async () => { + const { events } = await runAgentTurn( + { + llm: makePlainLlm(), + state: makeState(), + contextAssemble: async () => ({ + messages: [{ role: 'user', content: 'hi' }], + compressed: true, + released: 4321, + promptEstimate: 999, + }), + }, + { sessionId: 'sid', cwd: '/tmp' } + ); + const compressed = events.filter((e: any) => e._tag === 'ContextCompressed'); + expect(compressed).toHaveLength(1); + expect(compressed[0]).toMatchObject({ released: 4321, promptEstimate: 999 }); + }); + + it('does not emit ContextCompressed when assemblePayload reports no compression', async () => { + const { events } = await runAgentTurn( + { + llm: makePlainLlm(), + state: makeState(), + }, + { sessionId: 'sid', cwd: '/tmp' } + ); + expect(events.filter((e: any) => e._tag === 'ContextCompressed')).toHaveLength(0); + }); +}); + +describe('ContextCompressed adapters', () => { + const event = { + _tag: 'ContextCompressed', + released: 500, + promptEstimate: 1200, + } as any; + + it('agentEventToStreamChunk maps to context_compressed chunk', async () => { + const chunks: any[] = []; + for await (const c of agentEventToStreamChunk((async function* () { + yield event; + })() as any)) { + chunks.push(c); + } + expect(chunks).toEqual([{ type: 'context_compressed', released: 500, promptEstimate: 1200 }]); + }); + + it('agentEventToSseEvent maps to context_compressed SSE event', () => { + expect(agentEventToSseEvent(event)).toEqual({ + type: 'context_compressed', + released: 500, + promptEstimate: 1200, + }); + }); + + it('toSseEvents forwards context_compressed', async () => { + const out: any[] = []; + for await (const e of toSseEvents((async function* () { + yield event; + })() as any)) { + out.push(e); + } + expect(out).toEqual([{ type: 'context_compressed', released: 500, promptEstimate: 1200 }]); + }); +}); diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index 62f4fb1d..e4da615d 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 } from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,111 +14,39 @@ 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('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: () => ({ +describe('agent runTurn smoke (hooks deps wiring)', () => { + it('should build & run via AgentService.runTurn with mocked deps', async () => { + const llm = { + completeStream: vi.fn(() => ({ stream: (async function* () {})(), - response: Promise.resolve(Result.ok({ content: '' })), + response: Promise.resolve({ ok: true, value: { content: 'Hello' } }), + })), + modelInfo: { maxTokens: 1000 }, + } as any; + + 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(events.some((e: any) => e._tag === 'Done')).toBe(true); + 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..553fadb3 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 } from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,283 +14,55 @@ 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 mockState = makeState({ sessionId: 'test-sid', cwd: '/tmp', title: 'test' }); + +function makeCapturingLlm(opts: { content?: string } = {}) { + const llm = { + completeStream: vi.fn(() => ({ + stream: (async function* () {})(), + response: Promise.resolve({ + ok: true, + value: { content: opts.content ?? 'Done', toolCalls: [] }, + }), + })), + 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('Step events report max from global config maxSteps', async () => { + const llm = makeCapturingLlm(); + const { events } = await runAgentTurn( + { llm, state: mockState }, + { sessionId: 'test-sid', cwd: '/tmp' } ); - 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); + expect(stepEvents.length).toBeGreaterThan(0); + for (const s of stepEvents) { + expect((s as any).max).toBe(5); + } }); - 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: [], - }) - ), - })), - }; - + 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 +71,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 produce 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(events.some((e: any) => e._tag === 'Done')).toBe(false); }); }); diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index 4103033d..24379832 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 } from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,175 +13,66 @@ 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: '' })), + response: Promise.resolve({ ok: true, value: { content: '' } }), }; - }, + }), 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..dd810f80 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.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 } from '../helpers/agent-harness.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ + maxSteps: 100, + maxStopContinuations: 2, context: { compactionModel: '', }, @@ -16,281 +14,136 @@ 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, - }), - 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(), +/** LLM 每次只返回纯文本、无工具调用;记录每次收到 messages 参数。 */ +function makeContentOnlyLlm() { + const seenMessages: any[][] = []; + const llm = { + completeStream: vi.fn((params: any) => { + seenMessages.push(params.messages ?? []); + return { + stream: (async function* () {})(), + response: Promise.resolve({ ok: true, value: { content: 'Response', toolCalls: [] } }), + }; }), - 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; +} + +function allUserContents(seenMessages: any[][]): string[] { + const out: string[] = []; + for (const msgs of seenMessages) { + for (const m of msgs) { + if (m.role === 'user' && typeof m.content === 'string') out.push(m.content); + } + } + return out; +} + +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 opts: RunStreamOptions = { - state: mockState, - llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } 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 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(events.some((e: any) => e._tag === 'Done')).toBe(false); }); - 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 + expect(events.some((e: any) => e._tag === 'Done')).toBe(false); + 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); + expect(seenMessages).toHaveLength(1); const doneEvent = events.find((e: any) => e._tag === 'Done'); expect(doneEvent).toBeDefined(); }); - 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..76a6cb98 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,112 @@ 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 } 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 mockState = makeState({ sessionId: 'test-session', 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, - }), - 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) -); +function okResponse(content: string, toolCalls?: any[]) { + return Promise.resolve({ ok: true, value: { content, toolCalls } }); +} -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 +/** LLM 先调用一次 submit_plan,再以纯文本收尾。 */ +function makeSubmitPlanLlm() { + let callCount = 0; + const llm = { + completeStream: vi.fn(() => { + callCount++; + if (callCount === 1) { return { stream: (async function* () {})(), - response: Promise.resolve( - Result.ok({ content: 'Plan is ready for your review.', toolCalls: [] }) - ), + response: okResponse('', [ + { + id: 'tc-1', + name: 'submit_plan', + arguments: { title: 'My Plan', plan_content: '## Goal\nfix bug' }, + }, + ]), }; - }), - }; - - 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 - ); - - // 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', - }); - }); - - 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 + } + return { + stream: (async function* () {})(), + response: okResponse('Plan is ready for your review.'), + }; + }), + 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); + }), + 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' } ); - expect(planReadyEmits).toHaveLength(0); + expect(events.some((e: any) => e._tag === 'Done')).toBe(true); + // 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 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(events.some((e: any) => e._tag === 'Done')).toBe(true); + 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..5dfd4a93 100644 --- a/packages/codingcode/test/approval/pipeline.test.ts +++ b/packages/codingcode/test/approval/pipeline.test.ts @@ -2,12 +2,9 @@ import { describe, it, expect } from 'vitest'; import { Effect, Layer } from 'effect'; import { runPipeline } from '../../src/approval/pipeline.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 index a214afb2..1c733957 100644 --- a/packages/codingcode/test/approval/presets.test.ts +++ b/packages/codingcode/test/approval/presets.test.ts @@ -1,10 +1,6 @@ -import { describe, it, expect } from 'vitest'; +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'; +import { DEFAULT_DENY_RULES, DANGEROUS_TOOL_NAMES } from '../../src/approval/presets.js'; describe('Presets', () => { it('should have system-source rules', () => { @@ -42,16 +38,6 @@ describe('Presets', () => { 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/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/direct-types.test.ts b/packages/codingcode/test/client/direct-types.test.ts index 37207a67..dff7d418 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 ); diff --git a/packages/codingcode/test/client/direct.test.ts b/packages/codingcode/test/client/direct.test.ts index 9b651cf9..fc8408f2 100644 --- a/packages/codingcode/test/client/direct.test.ts +++ b/packages/codingcode/test/client/direct.test.ts @@ -4,10 +4,11 @@ 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,7 +37,7 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { } as any); const TestLayer = Layer.mergeAll( - ApprovalWaitService.Default, + ApprovalWaitLayer, MockWorkspaceLayer, MockLLMFactoryLayer ); 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/missing-methods.test.ts b/packages/codingcode/test/client/missing-methods.test.ts index 01d138aa..f1d1fe53 100644 --- a/packages/codingcode/test/client/missing-methods.test.ts +++ b/packages/codingcode/test/client/missing-methods.test.ts @@ -1,37 +1,34 @@ 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' }, + memory: { enabled: true, 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 { ApprovalService } from '../../src/approval/port.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.js'; +import { HookService } from '../../src/hooks/port.js'; +import { MemoryService } from '../../src/memory/port.js'; +import { McpService } from '../../src/mcp/port.js'; +import { SkillService } from '../../src/skills/port.js'; import * as infraConfig from '@codingcode/infra/config'; +import { HookLayer } from '../../src/hooks/hooks.js'; +import { ApprovalWaitLayer } from '../../src/approval/wait.js'; +import { ApprovalLayer } from '../../src/approval/approval.js'; 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, @@ -50,9 +47,9 @@ const TestLayer = Layer.mergeAll( disable: () => Effect.void, enable: () => Effect.void, } as any), - ApprovalService.Default, - HookService.Default, - ApprovalWaitService.Default + ApprovalLayer, + HookLayer, + ApprovalWaitLayer ); const rt = ManagedRuntime.make( @@ -140,7 +137,7 @@ describe('setCompactionModel: http + direct both implement', () => { describe('getMemoryConfig returns model field', () => { it('http typed return includes model', async () => { const c = createHttpSettingsClient({ - apiGet: async () => ({ enabled: true, types: [], model: 'm' }) as T, + apiGet: async () => ({ enabled: true, model: 'm' }) as T, apiPost: async () => null as any, apiPut: async () => null as any, apiDelete: async () => undefined, @@ -149,5 +146,3 @@ describe('getMemoryConfig returns model field', () => { 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..9c4380ec 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 result = 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); }); - 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 result = await ctx.assemblePayload(emptyJsonl, 128000, null); + expect(result.messages).toEqual([]); }); }); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index e9026df5..3cf1c52e 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -3,16 +3,19 @@ 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(); @@ -116,7 +119,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 +130,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 +164,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 +209,46 @@ 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 compression status', () => { + 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('reports compressed when history exceeds the window', async () => { + const fx = makeFixture({ numTurns: 3, toolContentSize: 8000 }); + try { + const ctx = await getCtxService(); + const result = await ctx.assemblePayload( + fx.transcriptPath, + 1000, + makeMockLLM(SUMMARY) + ); + expect(result.compressed).toBe(true); + expect(result.released).toBeGreaterThan(0); + expect(result.promptEstimate).toBeGreaterThan(0); + expect(result.messages.length).toBeGreaterThan(0); + } finally { + cleanup(fx.slug); + } + }); + + it('reports not compressed when history fits the window', async () => { + const fx = makeFixture({ numTurns: 2, toolContentSize: 20 }); + try { + const ctx = await getCtxService(); + const result = await ctx.assemblePayload( + fx.transcriptPath, + 2_000_000, + makeMockLLM(SUMMARY) + ); + expect(result.compressed).toBe(false); + expect(result.released).toBe(0); + expect(result.promptEstimate).toBeGreaterThan(0); } 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..64f50702 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)), 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/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..f1978bb6 --- /dev/null +++ b/packages/codingcode/test/helpers/agent-harness.ts @@ -0,0 +1,293 @@ +// Agent 循环测试基座:通过公开的 AgentService.runTurn 驱动 agent, +// 替代已删除的 agentLoop 自由函数。所有 agent 内部服务均以窄端口 mock 注入。 +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 { AgentEvent } from '../../src/agent/types.js'; +import type { SessionStoreState } from '../../src/session/types.js'; + +export interface HarnessMocks { + llm: { + completeStream: (params: any, signal?: AbortSignal) => { + stream: AsyncGenerator; + response: Promise; + }; + 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 的返回(默认不压缩)。 */ + contextAssemble?: () => Promise<{ + messages: Array<{ role: string; content: string }>; + compressed: boolean; + released: number; + promptEstimate: number; + }>; + /** 可选:覆盖 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: () => ({ + stream: (async function* () {})(), + response: Promise.resolve({ ok: true, value: { content: '', toolCalls: [] } }), + }), + 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 = { + assemblePayload: async () => + mocks.contextAssemble + ? mocks.contextAssemble() + : { + messages: [{ role: 'user' as const, content: 'hi' }], + compressed: false, + released: 0, + promptEstimate: 10, + }, + }; + 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({ decision: 'allow' }), + } 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 延迟:将 stream 分块与 response 放在宏任务上推进, +// 避免 producer fiber 在微任务队列中一口气跑完导致队列事件被丢弃。 +function paceLlm(llm: any): any { + const completeStream = llm.completeStream.bind(llm); + llm.completeStream = (params: any, signal?: AbortSignal) => { + const out = completeStream(params, signal); + const rawStream = out.stream as AsyncGenerator; + out.stream = (async function* () { + for await (const c of rawStream) { + yield c; + await tick(); + } + })(); + out.response = Promise.resolve(out.response).then(async (r) => { + await tick(); + return r; + }); + return out; + }; + return llm; +} + +export async function runAgentTurn( + mocks: HarnessMocks, + opts: RunAgentOptions = {} +): Promise<{ events: AgentEvent[]; 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: AgentEvent[] = []; + 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/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..72c02b78 100644 --- a/packages/codingcode/test/llm/deepseek-provider.test.ts +++ b/packages/codingcode/test/llm/deepseek-provider.test.ts @@ -69,5 +69,5 @@ describe('DeepSeekProvider completeStream', () => { } 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..ebd85e18 100644 --- a/packages/codingcode/test/llm/openai-provider.test.ts +++ b/packages/codingcode/test/llm/openai-provider.test.ts @@ -73,7 +73,7 @@ describe('OpenAIProvider completeStream', () => { 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'); @@ -107,5 +107,5 @@ describe('OpenAIProvider completeStream', () => { if (resp.ok) { expect(resp.value.usage).toEqual({ prompt: 100, completion: 50, total: 150 }); } - }); + }, 30000); }); 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..1ee51bf5 100644 --- a/packages/codingcode/test/memory/extractor.test.ts +++ b/packages/codingcode/test/memory/extractor.test.ts @@ -1,8 +1,6 @@ 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) => ({ @@ -25,66 +23,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(); @@ -111,134 +78,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..0056f4a6 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,50 @@ 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, + })), +})); + +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, finishReason: 'stop' as const })), + completeStream: vi.fn(() => ({ + stream: (async function* () { + beforeYield?.(); + yield response; + })(), + response: Promise.resolve({ + ok: true as const, + value: { content: response, finishReason: 'stop' as const }, + }), + })), + modelInfo: { + provider: 'mock', + model: 'mock', + maxTokens: 4096, + supportsToolCalling: true, + supportsStreaming: true, + }, + }; +} + beforeEach(async () => { cleanup(); fs.mkdirSync(tmpDir, { recursive: true }); @@ -37,9 +83,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 +97,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); + + const result = await service.flushSessionToMemory( + 'session', + createMockLlm('### 主题\n- 不变的内容'), + tmpDir + ); + + expect(result.written).toBe(false); + }); - 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); + 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..098abea1 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -1,345 +1,42 @@ 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), +import { makeState, runAgentTurn } from './helpers/agent-harness.js'; + +vi.mock('@codingcode/infra/config', () => ({ + loadConfig: () => ({ + maxSteps: 5, + maxStopContinuations: 2, + context: { compactionModel: '' }, + memory: { enabled: false }, + server: { port: 8080 }, + }), })); -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, +const state = makeState({ sessionId: 'test-session', cwd: '/tmp/test', title: 'test-sess' }); + +function makeLlm() { + const llm = { + completeStream: () => ({ + stream: (async function* () { + yield 'Hello'; + yield ' '; + yield 'world'; + })(), + response: Promise.resolve({ + ok: true, + value: { content: 'Hello world', toolCalls: [] }, + }), }), - 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(), - }), - 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) + modelInfo: { maxTokens: 1000 }, + } as any; + return llm; +} + +describe('runTurn event stream', () => { + it('should yield LlmChunk 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'); @@ -347,16 +44,13 @@ describe('sendMessage stream', () => { expect(textChunks).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(events.some((e: any) => e._tag === 'Done')).toBe(true); }); }); 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..be31859b 100644 --- a/packages/codingcode/test/plan/gate-pipeline.test.ts +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -5,9 +5,8 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { runPipeline } from '../../src/approval/pipeline.js'; import { createRuleEngine } from '../../src/approval/rule-engine.js'; -import { READONLY_TOOL_NAMES } from '../../src/approval/presets.js'; -import { HookService } from '../../src/hooks/registry.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; +import { HookService } from '../../src/hooks/port.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { planProfileGateHook } from '../../src/agent/profile.js'; import { computePaths } from '../../src/core/path.js'; import type { DecisionHandler } from '../../src/hooks/types.js'; @@ -48,7 +47,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 }; @@ -103,7 +101,6 @@ function runPipelineWithMock(opts: { { tool: opts.tool, input: opts.input }, { ruleEngine: createRuleEngine([]), - readonlyTools: new Set(READONLY_TOOL_NAMES), destructiveTools: new Set(), permissionMode: opts.permissionMode, sessionId: opts.sessionId, @@ -152,7 +149,7 @@ describe('Plan profile gate hook integration', () => { expect(capturedApproval).toBeNull(); }); - it('plan profile + dispatch_agent: readonly approval remains unchanged', async () => { + it('plan profile + dispatch_agent: denied by plan gate (no longer auto-allowed)', async () => { const decision: any = await runPipelineWithMock({ tool: 'dispatch_agent', input: { agent: 'build', prompt: 'do something' }, @@ -161,7 +158,9 @@ describe('Plan profile gate hook integration', () => { planProfile: true, cwd, }); - expect(decision.type).toBe('allow'); + expect(decision.type).toBe('deny'); + expect(decision.reason).toMatch(/plan profile/i); + expect(capturedApproval).toBeNull(); }); it('build profile + write_file: gate does not fire, pipeline falls through normally', async () => { 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..a7b6a20f 100644 --- a/packages/codingcode/test/security/plan-profile-restart.test.ts +++ b/packages/codingcode/test/security/plan-profile-restart.test.ts @@ -3,18 +3,16 @@ 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 { HookService } from '../../src/hooks/port.js'; +import { ApprovalService } from '../../src/approval/port.js'; +import { ApprovalWaitService } from '../../src/approval/wait-port.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 { useTempProjectBase } from '../helpers/project-base.js'; +import { ApprovalLayer } from '../../src/approval/approval.js'; useTempProjectBase(); @@ -45,22 +43,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 +55,8 @@ 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 SessionTestLayer = SessionLayer; + const ApprovalTestLayer = ApprovalLayer.pipe( Layer.provide( Layer.mergeAll( HookTestLayer, @@ -84,14 +64,19 @@ function makeLayer() { ) ) ); - const TestLayer = Layer.mergeAll( - ProjectRuntimeTestLayer, + return Layer.mergeAll( SessionTestLayer, HookTestLayer, ApprovalTestLayer, Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) ); - return TestLayer; +} + +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 (cross-restart, disk only)', () => { @@ -136,25 +121,19 @@ describe('plan profile security boundary (cross-restart, disk only)', () => { 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: mode, }); }) ); } 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); - }) - ); + await rt.runPromise(setProfileEffect(cwd, sessionId, 'plan')); expect(isSessionUsingPlanProfile(sessionId, cwd)).toBe(true); const decision = await evaluateAsSession('write_file', { path: '/tmp/x', content: 'foo' }); @@ -164,13 +143,7 @@ describe('plan profile security boundary (cross-restart, disk only)', () => { }); 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); - }) - ); + await rt.runPromise(setProfileEffect(cwd, sessionId, 'plan')); const decision = await evaluateAsSession('execute_command', { command: 'echo hello' }); expect(decision.type).toBe('deny'); @@ -179,13 +152,7 @@ describe('plan profile security boundary (cross-restart, disk only)', () => { }); 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); - }) - ); + await rt.runPromise(setProfileEffect(cwd, sessionId, 'plan')); const decision: any = await evaluateAsSession('submit_plan', { plan_content: 'do things' }); expect(decision.type).toBe('allow'); @@ -193,13 +160,7 @@ describe('plan profile security boundary (cross-restart, disk only)', () => { }); 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); - }) - ); + await rt.runPromise(setProfileEffect(cwd, sessionId, 'plan')); const idx = JSON.parse(readFileSync(indexPath, 'utf8')); expect(idx.activeProfile).toBe('plan'); @@ -226,12 +187,9 @@ describe('plan profile security boundary (cross-restart, disk only)', () => { 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); - }) + setProfileEffect(cwd, sessionId, 'plan').pipe( + Effect.andThen(setProfileEffect(cwd, sessionId, 'build')) + ) ); expect(isSessionUsingPlanProfile(sessionId, cwd)).toBe(false); 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.test.ts b/packages/codingcode/test/server/adapter.test.ts index 1583ed5a..7812e068 100644 --- a/packages/codingcode/test/server/adapter.test.ts +++ b/packages/codingcode/test/server/adapter.test.ts @@ -60,16 +60,8 @@ describe('agentEventToSseEvent', () => { ).toEqual({ type: 'usage', prompt: 1000, completion: 500, total: 1500 }); }); - it('returns null for Assistant and ReactiveCompact', () => { + it('returns null for Assistant', () => { expect(agentEventToSseEvent({ _tag: 'Assistant', content: 'ok' })).toBeNull(); - expect( - agentEventToSseEvent({ - _tag: 'ReactiveCompact', - attempt: 1, - released: 100, - promptEstimate: 0, - }) - ).toBeNull(); }); }); diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index c394eaaa..b7efc3ac 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, { @@ -152,8 +149,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 +156,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { throughTurnId: 0, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }), previewRollbackDiff: () => Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }), rollbackCodeToTurn: () => @@ -170,17 +164,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 +172,8 @@ const TestLayer = Layer.mergeAll( MockSessionLayer, MockLLMFactoryLayer, MockApprovalLayer, - HookService.Default, - ApprovalWaitService.Default, + HookLayer, + ApprovalWaitLayer, MockSkillLayer, MockMcpLayer, MockMemoryLayer, @@ -198,7 +182,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 +249,8 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { MockSessionLayer, FailingFactoryLayer, MockApprovalLayer, - HookService.Default, - ApprovalWaitService.Default, + HookLayer, + ApprovalWaitLayer, MockSkillLayer, MockMcpLayer, MockMemoryLayer, @@ -274,7 +258,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..cbb8dadb 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, { @@ -155,8 +151,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 +158,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { throughTurnId: 0, affectedTurns: [], selectedFiles: [], - restoreEntry: null, }), previewRollbackDiff: () => Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }), rollbackCodeToTurn: () => @@ -173,27 +166,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 +174,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 +203,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 +230,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 +250,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..010adcc3 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,36 @@ 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.getPermissionModeFromDisk(cwd, sessionId); + return yield* session.load(cwd, sessionId); }) ); - expect(mode).toBe('bypass'); + expect(state.permissionMode).toBe('bypass'); }); - it('setActiveProfile + getActiveProfile are consistent', async () => { + it('setActiveProfile persists to loaded state', async () => { await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; yield* session.setActiveProfile(cwd, sessionId, 'plan'); }) ); - 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('plan'); }); 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..4cd23185 --- /dev/null +++ b/packages/codingcode/test/session/facade-surface.test.ts @@ -0,0 +1,63 @@ +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', + 'readActiveProfileSync', + '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/dispatch-end-to-end.test.ts b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts index 480ad417..79d02750 100644 --- a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts +++ b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts @@ -1,27 +1,38 @@ 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); - function makeMockLLM(content: string): LLMClient { return { complete: () => Effect.succeed({ content, finishReason: 'stop' as const }), @@ -41,13 +52,142 @@ 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, { + assemblePayload: async (transcriptPath: string) => ({ + messages: 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); +} + +/** Consume an event 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 event of stream) { + if (event._tag === 'Done') content = event.content; + else if (event._tag === 'Error') { + resume(Effect.fail(new Error(`subagent failed: ${event.error.message}`))); + return; + } + } + resume(Effect.succeed(content)); + } catch (e) { + resume(Effect.fail(e instanceof Error ? e : new Error(String(e)))); + } + })(); + }); } -describe('dispatch_agent end-to-end (subagent reads its own jsonl)', () => { +describe('subagent run end-to-end (session transcript is read by the agent loop)', () => { let projectBase: string; let cwd: string; @@ -62,88 +202,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); - - 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); + expect(typeof result.sessionId).toBe('string'); + expect(result.content.length).toBeGreaterThan(0); + expect(existsSync(result.transcriptPath)).toBe(true); - const childTranscriptPath = join(subagentDir, files[0]!); - const events = readHistory(childTranscriptPath); + const events = readHistory(result.transcriptPath); - // 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..2e3becaf 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -1,138 +1,32 @@ -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 { 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 { 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), -}; 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 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, +const mockRunner = { + runSubagent: vi.fn(() => + Effect.succeed({ stream: makeRunStream(), sessionId: 'child-1' }) + ), }; function makeRunStream(): AsyncGenerator { @@ -141,80 +35,102 @@ function makeRunStream(): AsyncGenerator { })(); } -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..629d4013 100644 --- a/packages/codingcode/test/subagent/runner-service.test.ts +++ b/packages/codingcode/test/subagent/runner-service.test.ts @@ -1,18 +1,22 @@ 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'; 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 { _tag: 'Done' as const, content: 'test-result' }; + })(), + 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 +27,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 { _tag: 'Done' as const, content: 'test-result' }; + })(), + 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 () => { 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/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..aa02a13a 100644 --- a/packages/desktop/src/agent/ProfileIndicator.tsx +++ b/packages/desktop/src/agent/ProfileIndicator.tsx @@ -2,7 +2,7 @@ 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 { AgentProfileName } from '@codingcode/core/agent/profile'; interface ProfileIndicatorProps { sessionId: string | null; diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index 0817a72e..6210a8df 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -4,7 +4,7 @@ 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 type { AgentProfileName } from '@codingcode/core/agent/profile'; 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); @@ -231,11 +227,6 @@ export function useAgentCore() { 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(), @@ -258,6 +249,17 @@ export function useAgentCore() { case 'todo_update': applyTodoUpdate(threadId, event.items as any); return null; + case 'context_compressed': { + const contextUsage = useAgentStore.getState().contextUsage; + if (contextUsage) { + setContextUsage({ + used: event.promptEstimate, + contextWindow: contextUsage.contextWindow, + }); + } + useAgentStore.getState().clearThreadUsage(threadId); + return null; + } case 'usage': { setThreadUsage(threadId, { prompt: event.prompt, @@ -271,18 +273,6 @@ export function useAgentCore() { } 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; @@ -290,7 +280,7 @@ export function useAgentCore() { return null; } }, - [applyTodoUpdate, updateTurnId, setThreadUsage, setContextUsage, clearThreadUsage] + [applyTodoUpdate, updateTurnId, setThreadUsage, setContextUsage] ); const sendMessage = useCallback( @@ -318,6 +308,7 @@ export function useAgentCore() { } if (inflightControllers.has(threadId)) return; + clearPendingPlan(threadId); let turnId = randomId(); let assistantMessageId = randomId(); @@ -337,6 +328,7 @@ export function useAgentCore() { }); let hasError = false; + let submittedPlanTitle: string | null = null; for await (const event of stream) { if (event.type === 'session_id') continue; @@ -344,11 +336,14 @@ export function useAgentCore() { hasError = true; } - if (event.type === 'plan_ready') { - setPendingPlan(threadId, { - sessionId: event.sessionId, - title: event.title, - }); + // Front-end detection: submit_plan is an ordinary tool from the + // agent's perspective; only a successful call shows the decision UI. + if (event.type === 'tool_start' && event.name === 'submit_plan') { + submittedPlanTitle = String((event.args as Record)?.title ?? ''); + } else if (event.type === 'tool_result' && event.name === 'submit_plan') { + if (!event.ok) submittedPlanTitle = null; + } else if (event.type === 'tool_denied' && event.name === 'submit_plan') { + submittedPlanTitle = null; } const item = streamChunkToItem(event, threadId, assistantMessageId, turnId); @@ -366,6 +361,9 @@ export function useAgentCore() { } completeTurn(threadId, turnId, hasError ? 'error' : 'completed'); + if (!hasError && submittedPlanTitle !== null) { + setPendingPlan(threadId, { sessionId: threadId, title: submittedPlanTitle }); + } } catch (err: any) { const msg = err instanceof ApiError ? (err.body?.message ?? err.message) : String(err); applyChunk(threadId, turnId, { id: randomId(), type: 'error', message: msg }); @@ -381,6 +379,7 @@ export function useAgentCore() { applyChunk, completeTurn, setPendingPlan, + clearPendingPlan, workspace.rootPath, approvalPolicy, pendingProfile, @@ -442,12 +441,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 +522,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 +559,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 +585,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 +594,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 +617,7 @@ export function useAgentRollback() { rollbackCode, rollbackCtx, rollbackBoth, - undoCodeRollback, forkThread, - initRollbackState, deleteThread, revertedFilesByTurnId, }; diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index f6b36e9a..b0dab942 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -1,7 +1,7 @@ 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 { AgentProfileName } from '@codingcode/core/agent/profile'; const clients = createHttpClients(API_BASE); @@ -102,7 +102,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 +111,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); } @@ -262,15 +242,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 { @@ -279,27 +250,6 @@ export interface RollbackPreviewDiff { 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 function getCheckpointDiff( sessionId: string, cwd: string, @@ -339,7 +289,6 @@ export function rollbackContext( ): Promise<{ ok: boolean; turns: any[]; - rolledBackMessage?: string; promptEstimate?: number; usage?: { prompt: number; completion: number; total: number }; }> { @@ -354,26 +303,12 @@ export function rollbackBothToTurn( ok: boolean; turns: any[]; codeResult: CodeRollbackResult; - rolledBackMessage?: string; promptEstimate?: number; usage?: { prompt: number; completion: number; total: number }; }> { 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; -} - export function forkSession( sessionId: string, cwd: string, 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 保存。计划全文在上方对话中,决定如何处理:
-
+