diff --git a/AGENTS.md b/AGENTS.md index 46574489..2df137bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,6 @@ Coding Code 是 AI 编程助手。 | 包 | 目录 | 职责 | |---|---|---| | `@codingcode/core` | `packages/codingcode` | 核心引擎:agent loop 与全部编排能力 | -| `@codingcode/infra` | `packages/infra` | 基础设施:配置加载、日志、禁用项存储 | | `@codingcode/desktop` | `packages/desktop` | 桌面端(Electron + React) | `packages/sdk`、`packages/web` 目前只有空 `src/`,尚无实现。 @@ -19,6 +18,7 @@ Coding Code 是 AI 编程助手。 - `agent`:本项目核心,手写 ReAct loop 与编排;不持有 Session、不感知传输协议 - `tools`:工具系统,`domains/` 下分 fs / bash / web / self / subagent 五个域 - `llm`:模型调用与 provider 适配 +- `infra`:应用配置(`config.yaml`)、模型清单(`models.json`)、YAML 存取、日志 - `mcp`:Model Context Protocol 集成 - `context`:上下文预算与压缩 - `memory`:跨会话长期记忆 diff --git a/CLAUDE.md b/CLAUDE.md index 46574489..2df137bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,6 @@ Coding Code 是 AI 编程助手。 | 包 | 目录 | 职责 | |---|---|---| | `@codingcode/core` | `packages/codingcode` | 核心引擎:agent loop 与全部编排能力 | -| `@codingcode/infra` | `packages/infra` | 基础设施:配置加载、日志、禁用项存储 | | `@codingcode/desktop` | `packages/desktop` | 桌面端(Electron + React) | `packages/sdk`、`packages/web` 目前只有空 `src/`,尚无实现。 @@ -19,6 +18,7 @@ Coding Code 是 AI 编程助手。 - `agent`:本项目核心,手写 ReAct loop 与编排;不持有 Session、不感知传输协议 - `tools`:工具系统,`domains/` 下分 fs / bash / web / self / subagent 五个域 - `llm`:模型调用与 provider 适配 +- `infra`:应用配置(`config.yaml`)、模型清单(`models.json`)、YAML 存取、日志 - `mcp`:Model Context Protocol 集成 - `context`:上下文预算与压缩 - `memory`:跨会话长期记忆 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77c94078..aa4764cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,8 @@ coding-code/ ├── packages/ │ ├── codingcode/src/ # @codingcode/core — 核心引擎 │ │ ├── agent/ # ReAct Loop(纯引擎,无副作用) -│ │ ├── llm/ # LLM 客户端工厂(多厂商) +│ │ ├── llm/ # LLM 调用通道(多厂商) +│ │ ├── infra/ # 应用配置、模型清单、YAML 存取、日志 │ │ ├── mcp/ # MCP 服务集成 │ │ ├── context/ # 上下文管理 + 自动压缩 │ │ ├── session/ # JSONL 会话持久化 @@ -46,10 +47,9 @@ coding-code/ │ │ ├── server/ # Hono HTTP 服务 + SSE │ │ ├── cli.ts # CLI 入口 │ │ └── layer.ts # Effect Layer 入口 -│ ├── desktop/ # @codingcode/desktop — Electron 桌面应用 -│ │ ├── electron/ # 主进程(IPC、文件服务、Git 服务) -│ │ └── src/ # React 前端(Agent UI、设置面板) -│ └── infra/src/ # @codingcode/infra — 基础设施 +│ └── desktop/ # @codingcode/desktop — Electron 桌面应用 +│ ├── electron/ # 主进程(IPC、文件服务、Git 服务) +│ └── src/ # React 前端(Agent UI、设置面板) ├── config/ # 模型配置 │ └── models.json # 模型/厂商目录 ├── docs/ # 项目文档 diff --git a/README.md b/README.md index 565d5cbe..61ce8e78 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ pnpm start ### SDK 调用示例 ```typescript -import { createHttpClients } from '@codingcode/core/client'; +import { createHttpClients } from '@codingcode/sdk'; const clients = createHttpClients('http://localhost:8080'); @@ -73,6 +73,7 @@ for await (const frame of clients.agent.sendMessage('帮我写一个快排', { ┌──────────────────────────────────────────────────────────┐ │ 客户端层 │ │ @codingcode/desktop (Electron) │ +│ @codingcode/sdk(AgentClient 契约 + HTTP/SSE 实现) │ └──────────────────────────┬───────────────────────────────┘ │ HTTP / SSE(AgentClient 接口) ┌──────────────────────────┴───────────────────────────────┐ @@ -80,12 +81,7 @@ for await (const frame of clients.agent.sendMessage('帮我写一个快排', { │ @codingcode/core │ │ ReAct Loop · 工具 · MCP · 上下文 · 记忆 · Checkpoint │ │ 钩子 · 子智能体 · 技能 · 审批 · 会话 · 调度 │ -└──────────────────────────┬───────────────────────────────┘ - │ -┌──────────────────────────┴───────────────────────────────┐ -│ 基础设施层 │ -│ @codingcode/infra │ -│ 配置加载 · 日志 · 共享类型 │ +│ 模型清单 · 应用配置 · YAML 存取 · 日志 · 共享类型 │ └──────────────────────────────────────────────────────────┘ ``` diff --git a/docs/context.md b/docs/context.md index 67c3357f..395d0bac 100644 --- a/docs/context.md +++ b/docs/context.md @@ -29,7 +29,7 @@ Coding Code 采用两层压缩策略,在不同阈值下自动触发: |--------|-----|------| | 触发阈值 | `promptEstimate > modelMaxTokens * 0.9` | prompt 估算超过模型最大 token 90% 时触发 | | 保留最近 turn | 1 | 保留最近 1 个 turn 不压缩 | -| 压缩方式 | 调用 LLM 生成摘要 | 输出 `...` 块 | +| 压缩方式 | 调用 LLM 生成摘要 | 整段输出即摘要(全量替换,不做标签抽取) | | 增量压缩 | 是 | 找到已有 SummaryEvent,只压缩 `endTurnId` 之后的事件 | | 失败追踪 | 连续 3 次失败后停止 | 24 小时 TTL 后重置 | @@ -37,44 +37,38 @@ Coding Code 采用两层压缩策略,在不同阈值下自动触发: ## 压缩输出格式 -LLM 压缩的摘要包含 10 个固定小节: +LLM 压缩要求模型按 10 个固定小节输出;**模型的整段输出即摘要文本**,不做标签抽取: ``` - -自由推理区域,分析对话内容和关键信息 - - - -### Primary Request +## 1. Primary Request and Intent 用户的核心请求 -### Key Technical Concepts +## 2. Key Technical Concepts 涉及的关键技术概念 -### Files and Code Sections +## 3. Files and Code Sections 相关文件和代码段 -### Errors and Fixes +## 4. Errors and Fixes 遇到的错误和修复 -### Problem Solving +## 5. Problem Solving 问题解决过程 -### Decision Rationale -决策理由 +## 6. Decision Rationale and Rejected Approaches +决策理由与被否决的方案 -### All User Messages -所有用户消息摘要 +## 7. All User Messages +所有用户消息 -### Pending Tasks +## 8. Pending Tasks 待处理任务 -### Current Work +## 9. Current Work 当前工作内容 -### Optional Next Step +## 10. Optional Next Step 可选的下一步 - ``` --- diff --git a/docs/memory.md b/docs/memory.md index b8b91aad..95aa68d7 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -31,10 +31,10 @@ Coding Code 支持跨会话的长期记忆:自动从对话中提取关键信 1. 读取记忆文件全文作为"已有记忆" 2. 将会话记录(按 `[user]` / `[assistant]` / `[tool:名称]` 标注)与已有记忆一起发送给 LLM -3. LLM 输出整份**最新版记忆**,放在 `...` 块中 +3. LLM 输出整份**最新版记忆**(整段输出即内容,不做标签或片段抽取) 4. 直接用输出内容整体替换记忆文件(受字节上限约束) -模型自行决定更新哪些内容:可以新增条目、修改过时信息、删除不再相关的内容,代码不做"模型只改动哪部分"的任何假设。若模型没有输出有效内容、或输出与当前文件一致,则不写入。 +模型自行决定更新哪些内容:可以新增条目、修改过时信息、删除不再相关的内容,代码不做"模型只改动哪部分"的任何假设。若模型输出为空、或输出与当前文件一致,则不写入。 ### 提取提示词 diff --git a/docs/tools.md b/docs/tools.md index 190c53c2..80187628 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -102,7 +102,7 @@ interface ToolVisibilityPolicy { | 层级 | 名称 | 逻辑 | |------|------|------| | 1 | **RuleEngine** | 规则引擎匹配,支持 glob 模式匹配工具名和参数,按优先级排序 | -| 2 | **PermissionMode** | 权限模式驱动的自动放行:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行,涵盖只读与编辑工具)、`default`(不自动放行,继续下一层)。只读工具不再有无条件的独立白名单层;`plan` Profile 由 `agent/profile.ts` 中的 `planProfileGateHook` 在下一层强制,不在此层处理 | +| 2 | **PermissionMode** | 权限模式驱动的自动放行:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行,涵盖只读与编辑工具)、`ask`(不自动放行,继续下一层)。只读工具不再有无条件的独立白名单层;`plan` Profile 由 `agent/profile.ts` 中的 `planProfileGateHook` 在下一层强制,不在此层处理 | | 3 | **HookPreToolUse** | 钩子决策,可返回 allow/deny/ask/continue,支持 `modifiedInput` 修改参数 | | 4 | **UserConfirmation** | 异步用户确认,支持 allow/deny/always/never 四种响应,always/never 会持久化为规则 | | 5 | **AuditLog** | 每一层决策后记录审计日志,通过 `tool.approval.post` 钩子发出 | @@ -126,17 +126,13 @@ interface ToolVisibilityPolicy { ### 权限模式 ```typescript -type PermissionMode = 'default' | 'acceptEdits' | 'bypass'; +type PermissionMode = 'ask' | 'acceptEdits' | 'bypass'; ``` -- `default`:不自动放行任何工具(含只读工具),全部逐层审批 +- `ask`:不自动放行任何工具(含只读工具),全部逐层审批 - `acceptEdits`:非破坏性工具自动放行(涵盖只读工具与编辑类工具),破坏性工具仍需确认 - `bypass`:全部放行,跳过所有审批(慎用) -> 原独立的 `ReadonlyWhitelist` 层(在 `default` 下也无条件放行只读工具)已废弃,其语义并入 `PermissionMode` 的自动放行判定:`acceptEdits` 视只读工具为非破坏性工具自动放行,`default` 不再自动放行。 - -> `plan` 不再是 `PermissionMode` 的成员。plan Profile 通过 `AgentProfile.name === 'plan'` 结构化识别,由 `agent/profile.ts` 的 `planProfileGateHook` 和 `PLAN_PROFILE_ALLOWED_TOOLS` 共同限制工具。因只读白名单层已删除,`dispatch_agent` 等在 plan 下不再被流水线上层提前放行,统一由 plan gate 拦截。 - ### OS 级沙箱(预留) `packages/codingcode/src/sandbox/` 目前是 stub 实现(`SandboxService` 为空类),尚未集成实际的沙箱运行时。审批流水线已提供基本安全保障,OS 级沙箱将在未来版本中实现。 diff --git a/package.json b/package.json index bdd6f9ca..501b2778 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "packageManager": "pnpm@11.3.0", "type": "module", "workspaces": [ - "packages/infra", "packages/codingcode", + "packages/sdk", "packages/desktop" ], "scripts": { diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index f79d9fac..24b05501 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -8,19 +8,21 @@ }, "exports": { ".": "./src/layer.ts", - "./client": "./src/client/http/index.ts", "./server": "./src/server/index.ts", "./approval/types": "./src/approval/types.ts", "./agent/profile": "./src/agent/profile.ts", "./contracts/frame": "./src/contracts/frame.ts", + "./contracts/frame-io": "./src/contracts/frame-io.ts", "./core/error": "./src/core/error.ts", "./contracts/error": "./src/contracts/error.ts", "./contracts/types": "./src/contracts/types.ts", "./contracts/permission": "./src/contracts/permission.ts", + "./contracts/profile": "./src/contracts/profile.ts", "./contracts/hooks": "./src/contracts/hooks.ts", "./contracts/session": "./src/contracts/session.ts", "./contracts/provider": "./src/contracts/provider.ts", "./contracts/mcp": "./src/contracts/mcp.ts", + "./contracts/automation": "./src/contracts/automation.ts", "./contracts/skill": "./src/contracts/skill.ts", "./contracts/tool": "./src/contracts/tool.ts", "./checkpoint/types": "./src/checkpoint/types.ts", @@ -36,7 +38,9 @@ "cron": "^3.5.0", "effect": "^3.21.2", "hono": "^4.12.19", - "zod": "^4.4.3", - "@codingcode/infra": "workspace:*" + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", + "yaml": "^2.9.0", + "zod": "^4.4.3" } } diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 4669fc1e..21d64ae5 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -7,7 +7,7 @@ import { ApprovalService } from '../approval/port.js'; import { CheckpointService } from '../checkpoint/port.js'; import { ContextService } from '../context/port.js'; import { HookService } from '../hooks/port.js'; -import { LLMFactoryService } from '../llm/port.js'; +import { LLMService } from '../llm/port.js'; import { McpService } from '../mcp/port.js'; import { MemoryService } from '../memory/port.js'; import { RulesService } from '../rules/port.js'; @@ -18,11 +18,12 @@ import { ToolExecutorService } from '../tools/port.js'; import { buildSystemPrompt } from './prompt.js'; import type { FrameBody, FrameError, ResponseMeta, ToolOutcome, Transition } from '../contracts/frame.js'; import { isTurnEnd } from '../contracts/frame.js'; +import type { SessionRef } from '../contracts/session.js'; import type { ToolCatalog, ToolResult } from '../contracts/tool.js'; import type { ToolCall } from '../contracts/types.js'; -import { loadConfig } from '@codingcode/infra/config'; -import { createLogger } from '@codingcode/infra/logger'; -import { normalizePath, computePaths } from '../core/path.js'; +import { loadConfig } from '../infra/config.js'; +import { createLogger } from '../infra/logger.js'; +import { normalizePath } from '../core/path.js'; import { resolveProfile, getToolNames } from './profile.js'; function toolOutcomeOf(result: ToolResult): ToolOutcome { @@ -49,7 +50,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const mcp = yield* McpService; const context = yield* ContextService; const memory = yield* MemoryService; - const llmFactory = yield* LLMFactoryService; + const llm = yield* LLMService; const rules = yield* RulesService; const todo = yield* TodoService; const toolEnvPort = yield* ToolEnvPort; @@ -57,45 +58,59 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const maxSteps = cfg.maxSteps ?? 250; const maxStopContinuations = cfg.maxStopContinuations ?? 3; + const flushMemoryInBackground = (sessionId: string, model: string, cwd: string) => + Effect.forkDaemon( + memory.flushSessionToMemory(sessionId, model, cwd).pipe( + Effect.catchAllCause((cause) => + Effect.sync(() => logger.error('memory flush failed:', cause)) + ) + ) + ); + const runTurn = (input: string, opts: RunTurnOptions) => Effect.gen(function* () { const normalizedCwd = normalizePath(opts.cwd); - rules.evictProjectRules(normalizedCwd); + yield* rules.evictProjectRules(normalizedCwd); yield* hooks.reloadUserHooks(normalizedCwd); yield* hooks.emit('agent.turn.start', { sessionId: '', projectPath: normalizedCwd }); yield* mcp.syncConnections(normalizedCwd); let sessionId = opts.sessionId; - const llm = yield* llmFactory.getLLMClient(); + let parentSessionId = opts.parentSessionId; + const model = opts.model; if (!sessionId) { if (!opts.activeProfile || !opts.permissionMode) { return yield* Effect.fail( new AgentError('CONFIG_MISSING', 'new session requires activeProfile and permissionMode') ); } - const model = opts.model ?? llm.modelInfo.model; - const created = yield* session.create(normalizedCwd, { - model, - activeProfile: opts.activeProfile, - permissionMode: opts.permissionMode, - }); + const created = yield* session.create( + normalizedCwd, + { + model, + title: input, + activeProfile: opts.activeProfile, + permissionMode: opts.permissionMode, + }, + { parentSessionId, agentName: opts.agentName } + ); sessionId = created.sessionId; + parentSessionId = created.parentSessionId; } - const state = yield* session.load(normalizedCwd, sessionId); + const state = yield* session.load(normalizedCwd, sessionId, parentSessionId); - // restore session profile/permission from the frontend request, falling back to persisted values const profileName = opts.activeProfile ?? state.activeProfile; const effectivePerm = opts.permissionMode ?? state.permissionMode; if (opts.permissionMode) { - yield* session.setPermissionMode(normalizedCwd, sessionId, opts.permissionMode); + yield* session.setPermissionMode(normalizedCwd, sessionId, opts.permissionMode, parentSessionId); } if (opts.activeProfile) { - yield* session.setActiveProfile(normalizedCwd, sessionId, opts.activeProfile); + yield* session.setActiveProfile(normalizedCwd, sessionId, opts.activeProfile, parentSessionId); } - state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd); + state.memorySnapshot = yield* memory.loadMemoryForPrompt(state.cwd); const profile: AgentProfile | undefined = profileName ? resolveProfile(profileName) : undefined; @@ -112,11 +127,11 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { yield* checkpoint.snapshotBaseline(state.cwd, sessionId, turnId); // get rules text - const rulesText = rules.getAllRules(state.cwd); + const rulesText = yield* rules.getAllRules(state.cwd); // run agent loop const stream = runAgentLoop({ - state, llm, profile, catalog, + state, model, profile, catalog, systemPrompt: opts.systemPrompt, toolEnv, abortSignal: opts.signal, rulesText, sid: sessionId, projectPath: state.cwd, permissionMode: effectivePerm, @@ -126,16 +141,16 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { }); function runAgentLoop(opts: { - state: any; llm: any; profile: AgentProfile | undefined; + state: any; model: string; profile: AgentProfile | undefined; abortSignal: AbortSignal | undefined; catalog: ToolCatalog; toolEnv: ToolEnv; rulesText: string; sid: string; projectPath: string; permissionMode: PermissionMode; + systemPrompt?: string; }): AsyncGenerator { const q = Effect.runSync(Queue.unbounded()); - // agentLoopInternal 只经闭包引用服务,不消费任何 Tag;工具执行所需的服务由 toolEnv 注入 const program = agentLoopInternal(opts, q); return (async function* () { @@ -157,13 +172,14 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { } function agentLoopInternal(opts: { - state: any; llm: any; profile: AgentProfile | undefined; + state: any; model: string; profile: AgentProfile | undefined; abortSignal: AbortSignal | undefined; catalog: ToolCatalog; rulesText: string; sid: string; projectPath: string; permissionMode: PermissionMode; + systemPrompt?: string; }, q: Queue.Queue): Effect.Effect, AgentError> { - const { state, llm, profile, abortSignal, catalog, rulesText, sid, projectPath, permissionMode } = opts; + const { state, model, profile, abortSignal, catalog, rulesText, sid, projectPath, permissionMode } = opts; const { tools, lookup: toolLookup } = catalog; let ended = false; @@ -180,7 +196,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { platform: process.platform, shell: process.env.SHELL || process.env.ComSpec || 'bash', rules: rulesText, - profileSystemPrompt: profile?.systemPrompt, + profileSystemPrompt: opts.systemPrompt ?? profile?.systemPrompt, }); const memoryBlock = state.memorySnapshot; @@ -202,12 +218,14 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { yield* q.offer({ family: 'transition', transition: { to: 'executing' } }); } - const transcriptPath = computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath; + const sessionRef: SessionRef = { + cwd: state.cwd, + sessionId: state.sessionId, + parentSessionId: state.parentSessionId, + currentTurnId: state.currentTurnId, + }; - const willCompact = yield* Effect.either(Effect.tryPromise({ - try: () => context.willCompact(transcriptPath, llm.modelInfo.maxTokens), - catch: (e) => new AgentError('LLM_FAILED', String(e)), - })); + const willCompact = yield* Effect.either(context.willCompact(sessionRef, model)); if (Either.isLeft(willCompact)) { yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(willCompact.left) }); yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error', projectPath }); @@ -217,10 +235,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { yield* q.offer({ family: 'transition', transition: { to: 'compress' } }); } - const assembled = yield* Effect.either(Effect.tryPromise({ - try: () => context.assemblePayload(transcriptPath, llm.modelInfo.maxTokens, llm), - catch: (e) => new AgentError('LLM_FAILED', String(e)), - })); + const assembled = yield* Effect.either(context.assemblePayload(sessionRef, model)); if (Either.isLeft(assembled)) { yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(assembled.left) }); yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error', projectPath }); @@ -238,16 +253,16 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const streamed = yield* Effect.either(Effect.tryPromise({ try: async () => { - for await (const part of llm.completeStream({ messages: llmMessages, system, tools, maxSteps: 1 }, abortSignal)) { + for await (const part of llm.completeStream({ messages: llmMessages, system, tools, maxSteps: 1 }, model, abortSignal)) { if (abortSignal?.aborted) break; if (part.type === 'text') { content += part.text; Effect.runSync(q.offer({ family: 'event', event: { type: 'text_delta', text: part.text } })); } else if (part.type === 'tool_call') { - toolCalls.push({ id: part.id, name: part.name, arguments: part.args }); + toolCalls.push({ id: part.id, name: part.name, arguments: part.arguments }); Effect.runSync(q.offer({ family: 'event', - event: { type: 'tool_call', id: part.id, name: part.name, args: part.args }, + event: { type: 'tool_call', id: part.id, name: part.name, args: part.arguments }, })); } else { responded = part.usage ? { usage: part.usage } : {}; @@ -275,7 +290,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const loopErr = new AgentError('AGENT_LOOP_DETECTED', 'max stop continuations exceeded'); yield* offerEnd({ to: 'end', reason: 'error', error: toFrameError(loopErr) }); yield* hooks.emit('agent.turn.end', { sessionId: sid, turnId: state.currentTurnId, status: 'error', projectPath }); - memory.flushSessionToMemory(state.sessionId, llm, state.cwd).catch((e) => logger.error('memory flush failed:', e)); + yield* flushMemoryInBackground(state.sessionId, model, state.cwd); return Result.err(loopErr); } stopContinuations++; @@ -314,6 +329,8 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { const approvedResults = approvedCalls.length > 0 ? yield* executor.executeBatch(approvedCalls, state.sessionId, { turnId: state.currentTurnId, projectPath, signal: abortSignal, toolLookup, + activeProfile: profile?.name, + model, }) : []; @@ -342,7 +359,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { } yield* checkpoint.snapshotFinal(projectPath, state.sessionId, state.currentTurnId); - memory.flushSessionToMemory(state.sessionId, llm, state.cwd).catch((e) => logger.error('memory flush failed:', e)); + yield* flushMemoryInBackground(state.sessionId, model, state.cwd); if (lastResult) return lastResult; @@ -366,7 +383,7 @@ export const AgentLayer = Layer.effect(AgentService, Effect.gen(function* () { error: { message: 'agent terminated without end frame', code: 'AGENT_TERMINATED' }, }); yield* checkpoint.snapshotFinal(opts.projectPath, opts.sid, opts.state.currentTurnId).pipe(Effect.ignore); - memory.flushSessionToMemory(opts.state.sessionId, opts.llm, opts.projectPath).catch((e) => logger.error('memory flush failed:', e)); + yield* flushMemoryInBackground(opts.state.sessionId, opts.model, opts.projectPath); }) ) ); diff --git a/packages/codingcode/src/agent/port.ts b/packages/codingcode/src/agent/port.ts index 74fd6658..fbb5930d 100644 --- a/packages/codingcode/src/agent/port.ts +++ b/packages/codingcode/src/agent/port.ts @@ -10,8 +10,11 @@ export interface RunTurnOptions { cwd: string; signal?: AbortSignal; permissionMode?: PermissionMode; - model?: string; + model: string; activeProfile?: ProfileName; + parentSessionId?: string; + agentName?: string; + systemPrompt?: string; } export interface AgentShape { @@ -29,7 +32,6 @@ export interface AgentShape { export class AgentService extends Context.Tag('AgentService')() {} -// agent 自持的装配端口:工具执行期注入能力,不离开 agent/ export interface ToolEnv { provide(effect: Effect.Effect): Effect.Effect; } diff --git a/packages/codingcode/src/agent/profile.ts b/packages/codingcode/src/agent/profile.ts index 948a9dd8..e12e9b21 100644 --- a/packages/codingcode/src/agent/profile.ts +++ b/packages/codingcode/src/agent/profile.ts @@ -27,7 +27,7 @@ export const BUILD_PROMPT = `You are a coding assistant —an AI agent that help 7. For complex or broad tasks (understanding a whole module, cross-file analysis, comprehensive search): a. Briefly assess the task scope using your own reasoning —do not use tools for exploration at this stage, as that would consume your limited context window. b. If you can clearly handle it without extensive file reading or searching, proceed yourself. - c. Otherwise, delegate the discovery task with dispatch_agent when a runtime-configured subagent is available. + c. Otherwise delegate the exploration with dispatch_agent: give the subagent a short agentName and a self-contained prompt. The subagent shares your working directory, so keep the delegated write set disjoint from your own. ## Using your tools - **Prefer dedicated tools over shell commands.** Use read_file instead of cat, edit_file instead of sed, search_code instead of grep. Dedicated tools give the user better visibility into your work. @@ -142,15 +142,6 @@ export function resolveProfile(name: ProfileName): AgentProfile { return name === PLAN_PROFILE_NAME ? PLAN_PROFILE : BUILD_PROFILE; } -export function resolveSubagentProfile(name: string): AgentProfile | undefined { - return isAgentProfileName(name) ? resolveProfile(name) : undefined; -} - export function getToolNames(profile: AgentProfile | undefined): readonly string[] { return isPlanProfile(profile) ? PLAN_TOOL_NAMES : BUILD_TOOL_NAMES; } - -export const AVAILABLE_PROFILES: Array<{ name: ProfileName; description: string }> = [ - { name: PLAN_PROFILE_NAME, description: 'Planning agent' }, - { name: BUILD_PROFILE_NAME, description: 'Build agent' }, -]; diff --git a/packages/codingcode/src/approval/approval.ts b/packages/codingcode/src/approval/approval.ts index 44bd84d1..01eeccc1 100644 --- a/packages/codingcode/src/approval/approval.ts +++ b/packages/codingcode/src/approval/approval.ts @@ -60,7 +60,7 @@ function applyPermissionMode( } return null; - case 'default': + case 'ask': default: return null; } @@ -222,7 +222,7 @@ export const ApprovalLayer = Layer.effect(ApprovalService, Effect.gen(function* { ruleEngine, destructiveTools, - permissionMode: request.permissionMode ?? 'default', + permissionMode: request.permissionMode ?? 'ask', profile: request.profile, onAlways: (rule) => ruleEngine.addRule(rule), onNever: (rule) => ruleEngine.addRule(rule), diff --git a/packages/codingcode/src/checkpoint/project-lock.ts b/packages/codingcode/src/checkpoint/project-lock.ts index 493e88ca..a8afa8e2 100644 --- a/packages/codingcode/src/checkpoint/project-lock.ts +++ b/packages/codingcode/src/checkpoint/project-lock.ts @@ -1,6 +1,7 @@ import { openSync, closeSync, unlinkSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; -import { normalizePath, encodeProjectPath, getProjectBaseDir } from '../core/path.js'; +import { getGlobalDir, normalizePath, encodeProjectPath } from '../core/path.js'; +import { PROJECTS_DIRNAME } from '../contracts/paths.js'; export class ProjectLock { private readonly lockPath: string; @@ -8,7 +9,7 @@ export class ProjectLock { constructor(projectPath: string) { const encoded = encodeProjectPath(normalizePath(projectPath)); - this.lockPath = join(getProjectBaseDir(), encoded, 'checkpoint', 'repo.lock'); + this.lockPath = join(getGlobalDir(), PROJECTS_DIRNAME, encoded, 'checkpoint', 'repo.lock'); } lock(): void { diff --git a/packages/codingcode/src/checkpoint/shadow-git.ts b/packages/codingcode/src/checkpoint/shadow-git.ts index c936310d..92f991d9 100644 --- a/packages/codingcode/src/checkpoint/shadow-git.ts +++ b/packages/codingcode/src/checkpoint/shadow-git.ts @@ -1,7 +1,8 @@ import { spawnSync } from 'child_process'; import { existsSync, mkdirSync, statSync, writeFileSync } from 'fs'; import { join } from 'path'; -import { normalizePath, encodeProjectPath, getProjectBaseDir } from '../core/path.js'; +import { getGlobalDir, normalizePath, encodeProjectPath } from '../core/path.js'; +import { PROJECTS_DIRNAME } from '../contracts/paths.js'; const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null'; @@ -27,10 +28,9 @@ export class ShadowGit { readonly projectPath: string; constructor(projectPath: string) { - // Normalize path so same dir always produces same encoding (forward slash + lowercase drive) this.projectPath = normalizePath(projectPath); const encoded = encodeProjectPath(this.projectPath); - this.gitDir = join(getProjectBaseDir(), encoded, 'checkpoint', 'repo.git'); + this.gitDir = join(getGlobalDir(), PROJECTS_DIRNAME, encoded, 'checkpoint', 'repo.git'); } init(): void { diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index 1361e744..f03cc158 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -1,16 +1,17 @@ import { Effect } from 'effect'; +import { mkdirSync } from 'fs'; import { serve } from '@hono/node-server'; import { createServer } from './server/index.js'; import { createAppRuntime } from './layer.js'; -import { loadConfig, ensureUserConfig } from '@codingcode/infra/config'; -import { ensureTempCwd } from './core/path.js'; +import { loadConfig, ensureUserConfig } from './infra/config.js'; +import { tempCwd } from './server/cwd.js'; import { findAvailablePort } from './server/port-discovery.js'; import { AgentError } from './core/error.js'; import { SchedulerService } from './scheduler/port.js'; async function main() { ensureUserConfig(); - ensureTempCwd(); + mkdirSync(tempCwd(), { recursive: true }); const config = loadConfig(); const basePort = config.server.port; diff --git a/packages/codingcode/src/client/http/index.ts b/packages/codingcode/src/client/http/index.ts deleted file mode 100644 index a3394fad..00000000 --- a/packages/codingcode/src/client/http/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createRequestHelpers } from './request.js'; -import { createHttpAgentClient } from './agent-runtime.js'; -import { createHttpSessionClient } from './sessions.js'; -import { createHttpModelClient } from './models.js'; -import { createHttpSettingsClient } from './settings.js'; -import type { - AgentRuntimeClient, - SessionClient, - ModelClient, - SettingsClient, -} from '../contracts.js'; - -export type { AgentRuntimeClient, SessionClient, ModelClient, SettingsClient }; - -export interface HttpClients { - agent: AgentRuntimeClient; - sessions: SessionClient; - models: ModelClient; - settings: SettingsClient; -} - -export function createHttpClients(baseUrl: string): HttpClients { - const request = createRequestHelpers(baseUrl); - return { - agent: createHttpAgentClient(baseUrl, request), - sessions: createHttpSessionClient(request), - models: createHttpModelClient(request), - settings: createHttpSettingsClient(request), - }; -} diff --git a/packages/codingcode/src/context/compaction-prompt.ts b/packages/codingcode/src/context/compaction-prompt.ts index 55a37c97..2d6b313f 100644 --- a/packages/codingcode/src/context/compaction-prompt.ts +++ b/packages/codingcode/src/context/compaction-prompt.ts @@ -1,12 +1,5 @@ -export const COMPACTION_SYSTEM_PROMPT = `You analyze and then summarize an agent conversation transcript. +export const COMPACTION_SYSTEM_PROMPT = `Summarize an agent conversation transcript into the sections below. -Output exactly two top-level blocks: - - -Free-form notes about the conversation. Identify the user's goal, what was done, what was learned, what remains. This block is for your reasoning — be thorough. - - - ## 1. Primary Request and Intent The user's overall objective and concrete asks. @@ -35,5 +28,4 @@ Work the user explicitly asked for that is not yet done. What was happening at the moment of compaction. ## 10. Optional Next Step -A recommended next action consistent with the user's intent. -`; +A recommended next action consistent with the user's intent.`; diff --git a/packages/codingcode/src/context/context.ts b/packages/codingcode/src/context/context.ts index 88ddea82..c6777fc7 100644 --- a/packages/codingcode/src/context/context.ts +++ b/packages/codingcode/src/context/context.ts @@ -1,18 +1,37 @@ import { Layer, Effect } from 'effect'; import { randomUUID } from 'crypto'; -import { readFileSync, existsSync } from 'fs'; -import { loadConfig } from '@codingcode/infra/config'; +import { join } from 'path'; +import { getGlobalDir, encodeProjectPath } from '../core/path.js'; +import { + PROJECTS_DIRNAME, + SESSIONS_DIRNAME, + SUBAGENTS_DIRNAME, + TRANSCRIPT_SUFFIX, +} from '../contracts/paths.js'; +import { loadConfig } from '../infra/config.js'; import type { Message } from '../contracts/types.js'; import { SessionService } from '../session/port.js'; import { estimateTokens, estimateMessageTokens } from './tokens.js'; -import { resolveLLM } from '../llm/llm-resolver.js'; -import { LLMFactoryService } from '../llm/port.js'; +import { LLMService } from '../llm/port.js'; +import { contextWindowOf } from '../infra/models.js'; import { COMPACTION_SYSTEM_PROMPT } from './compaction-prompt.js'; -import type { SessionEvent, AssistantEvent, ToolResultEvent, CompactEvent, SummaryEvent } from '../contracts/session.js'; -import type { LLMClient } from '../contracts/provider.js'; +import type { SessionEvent, AssistantEvent, ToolResultEvent, CompactEvent, SummaryEvent, SessionRef } from '../contracts/session.js'; +import { AgentError } from '../core/error.js'; import { ContextService } from './port.js'; import type { CompressResult } from './port.js'; +export function transcriptPathFor(ref: SessionRef): string { + const sessionsDir = join( + getGlobalDir(), + PROJECTS_DIRNAME, + encodeProjectPath(ref.cwd), + SESSIONS_DIRNAME + ); + return ref.parentSessionId + ? join(sessionsDir, ref.parentSessionId, SUBAGENTS_DIRNAME, `${ref.sessionId}${TRANSCRIPT_SUFFIX}`) + : join(sessionsDir, `${ref.sessionId}${TRANSCRIPT_SUFFIX}`); +} + const COMPACTABLE_TOOLS = new Set([ 'read_file', 'execute_command', @@ -30,8 +49,6 @@ const COMPACTION_THRESHOLD = 0.85; const KEEP_RECENT_TURNS = 1; const MAX_AUTO_COMPACT_PASSES = 3; -// --- Internal: visibility computation for LLM context --- - function applyVisibilityEvents(events: SessionEvent[]): { hiddenTurnIds: Set; hiddenOpUuids: Set; @@ -69,7 +86,6 @@ function applyVisibilityEvents(events: SessionEvent[]): { return { hiddenTurnIds, hiddenOpUuids, compactedTurnIds }; } -/** Filter events for LLM context building: hide summary-covered turns, apply rollback */ export function filterForContext(events: SessionEvent[]): { visible: SessionEvent[]; compactedTurnIds: Set; @@ -86,7 +102,6 @@ export function filterForContext(events: SessionEvent[]): { return { visible, compactedTurnIds }; } -/** Format filtered events as LLM messages, with micro-compaction for compacted turns */ export function buildContextMessages( events: SessionEvent[], compactedTurnIds?: Set @@ -174,7 +189,6 @@ export function buildContextMessages( return filtered; } -/** Estimate prompt tokens for a filtered event stream */ export function estimatePromptTokensFrom(events: SessionEvent[]): number { const { visible, compactedTurnIds } = filterForContext(events); return estimateTokens(buildContextMessages(visible, compactedTurnIds)); @@ -189,178 +203,144 @@ interface PayloadState { export const ContextLayer = Layer.effect(ContextService, Effect.gen(function* () { const session = yield* SessionService; - const factory = yield* LLMFactoryService; - - const readState = (transcriptPath: string): PayloadState => { - const jsonlPath = transcriptPath; - let currentTurnId = 0; - const idxPath = transcriptPath.replace('.jsonl', '.index.json'); - if (existsSync(idxPath)) { - try { - const idx = JSON.parse(readFileSync(idxPath, 'utf8')); - currentTurnId = idx?.currentTurnId ?? 0; - } catch {} - } - const { visible, compactedTurnIds } = filterForContext(session.readEvents(jsonlPath)); - return { jsonlPath, currentTurnId, visible, compactedTurnIds }; - }; + const llm = yield* LLMService; + + const readState = ( + transcriptPath: string, + currentTurnId: number + ): Effect.Effect => + Effect.gen(function* () { + const events = yield* session.readEvents(transcriptPath); + const { visible, compactedTurnIds } = filterForContext(events); + return { jsonlPath: transcriptPath, currentTurnId, visible, compactedTurnIds }; + }); const estimateFor = (s: PayloadState): number => estimateTokens(buildContextMessages(s.visible, s.compactedTurnIds)); - // 微压缩:确定性截断旧 turn 的长工具输出;是否需要压缩由本模块内部判断 - function runMicroCompact(s: PayloadState, contextWindow: number): PayloadState { - if (estimateFor(s) <= contextWindow * MICRO_COMPACT_THRESHOLD) return s; - if (applyOldTurnCompact(s.visible, s.currentTurnId, s.jsonlPath)) { - return readState(s.jsonlPath); - } - return s; - } - - function applyOldTurnCompact( + const applyOldTurnCompact = ( events: SessionEvent[], currentTurnId: number, jsonlPath: string - ): boolean { - const compactedTurnIds = new Set(); - for (const ev of events) { - if (ev.type === 'compact') { - for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { - compactedTurnIds.add(t); + ): Effect.Effect => + Effect.gen(function* () { + const compactedTurnIds = new Set(); + for (const ev of events) { + if (ev.type === 'compact') { + for (let t = ev.startTurnId; t <= ev.endTurnId; t++) { + compactedTurnIds.add(t); + } } } - } - - const oldResults: ToolResultEvent[] = []; - for (const ev of events) { - if (ev.type !== 'tool_result') continue; - if (ev.turnId >= currentTurnId - 1) continue; - if (compactedTurnIds.has(ev.turnId)) continue; - if (!COMPACTABLE_TOOLS.has(ev.toolName.toLowerCase())) continue; - if (ev.output.length <= MICRO_COMPACT_MIN_CHARS) continue; - oldResults.push(ev); - } - if (oldResults.length === 0) return false; - - const turnIds = [...new Set(oldResults.map((ev) => ev.turnId))].sort((a, b) => a - b); - const startTurnId = turnIds[0]!; - const endTurnId = turnIds[turnIds.length - 1]!; + const oldResults: ToolResultEvent[] = []; + for (const ev of events) { + if (ev.type !== 'tool_result') continue; + if (ev.turnId >= currentTurnId - 1) continue; + if (compactedTurnIds.has(ev.turnId)) continue; + if (!COMPACTABLE_TOOLS.has(ev.toolName.toLowerCase())) continue; + if (ev.output.length <= MICRO_COMPACT_MIN_CHARS) continue; + oldResults.push(ev); + } - const compactEvent: CompactEvent = { - type: 'compact', - uuid: randomUUID(), - startTurnId, - endTurnId, - }; - session.appendEvent(jsonlPath, compactEvent); - return true; - } + if (oldResults.length === 0) return false; - // LLM 摘要压缩(老 turn → summary 事件),失败返回 0 释放量 - async function tryCompaction(s: PayloadState, llm: LLMClient | null): Promise { - const endTurn = s.currentTurnId - KEEP_RECENT_TURNS - 1; - if (endTurn < 1) return 0; + const turnIds = [...new Set(oldResults.map((ev) => ev.turnId))].sort((a, b) => a - b); + const startTurnId = turnIds[0]!; + const endTurnId = turnIds[turnIds.length - 1]!; - const inRange = s.visible.filter((ev) => { - if (ev.type === 'session_meta') return false; - if ('turnId' in ev && (ev as any).turnId >= 1 && (ev as any).turnId <= endTurn) return true; - return false; + const compactEvent: CompactEvent = { + type: 'compact', + uuid: randomUUID(), + startTurnId, + endTurnId, + }; + yield* session.appendEvent(jsonlPath, compactEvent); + return true; }); - if (inRange.length === 0) return 0; - const targetEvents = getIncrementalEvents(inRange); - if (targetEvents.length === 0) return 0; + const runMicroCompact = ( + s: PayloadState, + contextWindow: number + ): Effect.Effect => + Effect.gen(function* () { + if (estimateFor(s) <= contextWindow * MICRO_COMPACT_THRESHOLD) return s; + const applied = yield* applyOldTurnCompact(s.visible, s.currentTurnId, s.jsonlPath); + if (applied) { + return yield* readState(s.jsonlPath, s.currentTurnId); + } + return s; + }); - const msgs = buildContextMessages(targetEvents, s.compactedTurnIds); - const totalTokens = estimateTokens(msgs); + const tryCompaction = ( + s: PayloadState, + model: string + ): Effect.Effect => + Effect.gen(function* () { + const endTurn = s.currentTurnId - KEEP_RECENT_TURNS - 1; + if (endTurn < 1) return 0; + + const inRange = s.visible.filter((ev) => { + if (ev.type === 'session_meta') return false; + if ('turnId' in ev && (ev as any).turnId >= 1 && (ev as any).turnId <= endTurn) return true; + return false; + }); + if (inRange.length === 0) return 0; - let compactionLlm = await Effect.runPromise( - resolveLLM(loadConfig().context.compactionModel, llm).pipe( - Effect.provideService(LLMFactoryService, factory) - ) - ); - if (compactionLlm && compactionLlm.modelInfo.maxTokens < totalTokens + 25000) { - compactionLlm = llm; - } + const targetEvents = getIncrementalEvents(inRange); + if (targetEvents.length === 0) return 0; - const summary = await callLLMForCompaction(msgs, compactionLlm); - if (!summary) return 0; - - const turnIds = targetEvents - .filter((e) => 'turnId' in e) - .map((e) => (e as any).turnId as number); - const startTurnId = Math.min(...turnIds); - const endTurnId = Math.max(...turnIds); - - const summaryEvent: SummaryEvent = { - type: 'summary', - uuid: randomUUID(), - startTurnId, - endTurnId, - summaryText: summary, - }; - session.appendEvent(s.jsonlPath, summaryEvent); - - const summaryMsg: Message = { role: 'system', name: 'compacted_history', content: summary }; - return Math.max(0, totalTokens - estimateMessageTokens(summaryMsg)); - } + const msgs = buildContextMessages(targetEvents, s.compactedTurnIds); + const totalTokens = estimateTokens(msgs); - function needsCompaction(s: PayloadState, contextWindow: number): boolean { - return estimateFor(s) > contextWindow * COMPACTION_THRESHOLD; - } + const configured = loadConfig().context.compactionModel?.trim(); + let compactionModel = configured || model; + if (contextWindowOf(compactionModel) < totalTokens + 25000) { + compactionModel = model; + } - async function summarizeToFit( - s: PayloadState, - contextWindow: number, - llm: LLMClient | null - ): Promise<{ state: PayloadState; released: number }> { - let cur = s; - let releasedTotal = 0; - for (let i = 0; i < MAX_AUTO_COMPACT_PASSES; i++) { - if (!needsCompaction(cur, contextWindow)) break; - const released = await tryCompaction(cur, llm); - if (released <= 0) break; - releasedTotal += released; - cur = readState(cur.jsonlPath); - } - return { state: cur, released: releasedTotal }; - } + const summary = yield* callLLMForCompaction(msgs, compactionModel); + if (!summary) return 0; + + const turnIds = targetEvents + .filter((e) => 'turnId' in e) + .map((e) => (e as any).turnId as number); + const startTurnId = Math.min(...turnIds); + const endTurnId = Math.max(...turnIds); + + const summaryEvent: SummaryEvent = { + type: 'summary', + uuid: randomUUID(), + startTurnId, + endTurnId, + summaryText: summary, + }; + yield* session.appendEvent(s.jsonlPath, summaryEvent); + + const summaryMsg: Message = { role: 'system', name: 'compacted_history', content: summary }; + return Math.max(0, totalTokens - estimateMessageTokens(summaryMsg)); + }); - const willCompact = async ( - transcriptPath: string, - contextWindow: number - ): Promise => { - const s = runMicroCompact(readState(transcriptPath), contextWindow); - return needsCompaction(s, contextWindow); - }; + const needsCompaction = (s: PayloadState, contextWindow: number): boolean => + estimateFor(s) > contextWindow * COMPACTION_THRESHOLD; - const assemblePayload = async ( - transcriptPath: string, + const summarizeToFit = ( + s: PayloadState, contextWindow: number, - llm: LLMClient | null - ): Promise => { - let s = readState(transcriptPath); - s = runMicroCompact(s, contextWindow); - const { state } = await summarizeToFit(s, contextWindow, llm); - return buildContextMessages(state.visible, state.compactedTurnIds); - }; - - const compactWithLLM = async ( - transcriptPath: string, - modelMaxTokens: number, - llm: LLMClient | null, - usage?: number - ): Promise => { - let s = runMicroCompact(readState(transcriptPath), modelMaxTokens); - const preEstimate = usage ?? estimateFor(s); - const released = await tryCompaction(s, llm); - if (released <= 0) { - return { didCompress: false, released: 0, promptEstimate: preEstimate }; - } - s = readState(transcriptPath); - return { didCompress: true, released, promptEstimate: estimateFor(s) }; - }; + model: string + ): Effect.Effect<{ state: PayloadState; released: number }, AgentError> => + Effect.gen(function* () { + let cur = s; + let releasedTotal = 0; + for (let i = 0; i < MAX_AUTO_COMPACT_PASSES; i++) { + if (!needsCompaction(cur, contextWindow)) break; + const released = yield* tryCompaction(cur, model); + if (released <= 0) break; + releasedTotal += released; + cur = yield* readState(cur.jsonlPath, cur.currentTurnId); + } + return { state: cur, released: releasedTotal }; + }); function getIncrementalEvents(inRange: SessionEvent[]): SessionEvent[] { const existingSummary = [...inRange] @@ -373,47 +353,78 @@ export const ContextLayer = Layer.effect(ContextService, Effect.gen(function* () return inRange.filter((e) => 'turnId' in e && (e as any).turnId > lastTurn); } - async function callLLMForCompaction( + const callLLMForCompaction = ( transcript: Message[], - fallbackLlm: LLMClient | null - ): Promise { - const llm = await Effect.runPromise( - resolveLLM(loadConfig().context.compactionModel, fallbackLlm).pipe( - Effect.provideService(LLMFactoryService, factory) - ) - ); - if (!llm) return null; - - const transcriptText = transcript - .map( - (m) => - `[${m.role}${(m as any).tool_name ? ':' + (m as any).tool_name : ''}]\n${m.content}` - ) - .join('\n\n'); - - const system = COMPACTION_SYSTEM_PROMPT; - - const userMsg: Message = { - role: 'user', - content: `Compact the following conversation transcript into the sections above:\n\n${transcriptText}`, - }; - - try { - const result = await Effect.runPromise( - llm.complete({ messages: [userMsg], system }).pipe(Effect.either) - ); + model: string + ): Effect.Effect => + Effect.gen(function* () { + const transcriptText = transcript + .map( + (m) => + `[${m.role}${(m as any).tool_name ? ':' + (m as any).tool_name : ''}]\n${m.content}` + ) + .join('\n\n'); + + const system = COMPACTION_SYSTEM_PROMPT; + + const userMsg: Message = { + role: 'user', + content: `Compact the following conversation transcript into the sections above:\n\n${transcriptText}`, + }; + + const result = yield* llm + .complete({ messages: [userMsg], system }, model) + .pipe(Effect.either); if (result._tag === 'Left') return null; - return extractSummary(result.right.content.trim()); - } catch { - return null; - } - } + return normalizeSummary(result.right.content); + }).pipe(Effect.catchAllCause(() => Effect.succeed(null))); - function extractSummary(raw: string): string { - const m = raw.match(/([\s\S]*?)<\/summary>/); - return (m?.[1] ?? raw).trim(); + function normalizeSummary(raw: string): string { + return raw.trim(); } + const willCompact = ( + ref: SessionRef, + model: string + ): Effect.Effect => + Effect.gen(function* () { + const transcriptPath = transcriptPathFor(ref); + const contextWindow = contextWindowOf(model); + const s = yield* runMicroCompact(yield* readState(transcriptPath, ref.currentTurnId), contextWindow); + return needsCompaction(s, contextWindow); + }); + + const assemblePayload = ( + ref: SessionRef, + model: string + ): Effect.Effect => + Effect.gen(function* () { + const transcriptPath = transcriptPathFor(ref); + const contextWindow = contextWindowOf(model); + let s = yield* readState(transcriptPath, ref.currentTurnId); + s = yield* runMicroCompact(s, contextWindow); + const { state } = yield* summarizeToFit(s, contextWindow, model); + return buildContextMessages(state.visible, state.compactedTurnIds); + }); + + const compactWithLLM = ( + ref: SessionRef, + model: string, + usage?: number + ): Effect.Effect => + Effect.gen(function* () { + const transcriptPath = transcriptPathFor(ref); + const contextWindow = contextWindowOf(model); + let s = yield* runMicroCompact(yield* readState(transcriptPath, ref.currentTurnId), contextWindow); + const preEstimate = usage ?? estimateFor(s); + const released = yield* tryCompaction(s, model); + if (released <= 0) { + return { didCompress: false, released: 0, promptEstimate: preEstimate }; + } + s = yield* readState(transcriptPath, ref.currentTurnId); + return { didCompress: true, released, promptEstimate: estimateFor(s) }; + }); + return { willCompact, assemblePayload, diff --git a/packages/codingcode/src/context/port.ts b/packages/codingcode/src/context/port.ts index d42168a4..8cb49d27 100644 --- a/packages/codingcode/src/context/port.ts +++ b/packages/codingcode/src/context/port.ts @@ -1,6 +1,8 @@ import { Context } from 'effect'; +import type { Effect } from 'effect'; import type { Message } from '../contracts/types.js'; -import type { LLMClient } from '../contracts/provider.js'; +import type { SessionRef } from '../contracts/session.js'; +import type { AgentError } from '../core/error.js'; export interface CompressResult { didCompress: boolean; @@ -9,9 +11,9 @@ export interface CompressResult { } export interface ContextShape { - willCompact(transcriptPath: string, contextWindow: number): Promise; - assemblePayload(transcriptPath: string, contextWindow: number, llm: LLMClient | null): Promise; - compactWithLLM(transcriptPath: string, modelMaxTokens: number, llm: LLMClient | null, usage?: number): Promise; + willCompact(ref: SessionRef, model: string): Effect.Effect; + assemblePayload(ref: SessionRef, model: string): Effect.Effect; + compactWithLLM(ref: SessionRef, model: string, usage?: number): Effect.Effect; } export class ContextService extends Context.Tag('Context')() {} diff --git a/packages/codingcode/src/scheduler/types.ts b/packages/codingcode/src/contracts/automation.ts similarity index 73% rename from packages/codingcode/src/scheduler/types.ts rename to packages/codingcode/src/contracts/automation.ts index cd56c5af..83ba0cd8 100644 --- a/packages/codingcode/src/scheduler/types.ts +++ b/packages/codingcode/src/contracts/automation.ts @@ -4,7 +4,7 @@ export interface Automation { description: string; cron: string; timezone: string; - sandbox: 'readonly' | 'workspace-write'; + sandbox: AutomationSandbox; enabled: boolean; projectCwd: string; runOnce: boolean; @@ -14,12 +14,14 @@ export interface Automation { lastSessionId: string | null; } +export type AutomationSandbox = 'readonly' | 'workspace-write'; + export interface CreateAutomationInput { name: string; description: string; cron: string; timezone?: string; - sandbox?: 'readonly' | 'workspace-write'; + sandbox?: AutomationSandbox; projectCwd: string; runOnce?: boolean; } @@ -29,7 +31,11 @@ export interface UpdateAutomationInput { description?: string; cron?: string; timezone?: string; - sandbox?: 'readonly' | 'workspace-write'; + sandbox?: AutomationSandbox; enabled?: boolean; runOnce?: boolean; } + +export interface RunAutomationResult { + sessionId: string; +} diff --git a/packages/codingcode/src/contracts/error.ts b/packages/codingcode/src/contracts/error.ts index 3dcf8109..0f435604 100644 --- a/packages/codingcode/src/contracts/error.ts +++ b/packages/codingcode/src/contracts/error.ts @@ -9,17 +9,6 @@ export class AlreadyExistsError extends Error { } } -export class ApiError extends Error { - constructor( - public readonly status: number, - public readonly path: string, - public readonly body?: { code: string; message: string } - ) { - super(body?.message ?? `HTTP ${status}: ${path}`); - this.name = 'ApiError'; - } -} - export class NotFoundError extends Error { readonly code = 'NOT_FOUND'; constructor(message: string) { diff --git a/packages/codingcode/src/contracts/frame-io.ts b/packages/codingcode/src/contracts/frame-io.ts index 891a1863..5d6783ba 100644 --- a/packages/codingcode/src/contracts/frame-io.ts +++ b/packages/codingcode/src/contracts/frame-io.ts @@ -1,4 +1,4 @@ -import type { EndReason, Frame, FrameBody, FrameError, ToolOutcome } from './frame.js'; +import type { Frame, FrameBody } from './frame.js'; export interface FrameAssembler { stamp(body: FrameBody): Frame; @@ -23,103 +23,3 @@ export function createFrameAssembler(opts: { readonly sessionId: string }): Fram export function encodeFrame(frame: Frame): string { return JSON.stringify(frame); } - -export type DecodeFailureReason = - | 'shape' - | 'unknown-family' - | 'unknown-transition' - | 'unknown-event'; - -export type DecodeResult = - | { readonly ok: true; readonly frame: Frame } - | { readonly ok: false; readonly reason: DecodeFailureReason; readonly raw: unknown }; - -const TRANSITIONS = new Set(['start', 'executing', 'compress', 'end']); -const EVENTS = new Set(['text_delta', 'tool_call', 'tool_result', 'approval_request']); -const END_REASONS = new Set(['done', 'error', 'maxSteps', 'aborted']); -const OUTCOMES = new Set(['ok', 'error', 'denied']); - -function isRecord(v: unknown): v is Record { - return typeof v === 'object' && v !== null; -} - -function isError(v: unknown): v is FrameError { - return isRecord(v) && typeof v.message === 'string' && typeof v.code === 'string'; -} - -function isOutcome(v: unknown): v is ToolOutcome { - if (!isRecord(v) || typeof v.status !== 'string' || !OUTCOMES.has(v.status)) return false; - if (v.status === 'denied') return typeof v.reason === 'string'; - return typeof v.output === 'string'; -} - -export function decodeFrame(raw: unknown): DecodeResult { - if (!isRecord(raw)) return { ok: false, reason: 'shape', raw }; - if (typeof raw.sessionId !== 'string' || typeof raw.seq !== 'number') { - return { ok: false, reason: 'shape', raw }; - } - const turnId = raw.turnId; - if (turnId !== null && typeof turnId !== 'number') { - return { ok: false, reason: 'shape', raw }; - } - - const family = raw.family; - if (family === 'fatal') { - if (!isError(raw.fatal)) return { ok: false, reason: 'shape', raw }; - return { ok: true, frame: raw as unknown as Frame }; - } - - if (family === 'transition') { - const t = raw.transition; - if (!isRecord(t) || typeof t.to !== 'string' || !TRANSITIONS.has(t.to)) { - return { ok: false, reason: 'unknown-transition', raw }; - } - if (t.to === 'start' && typeof t.turnId !== 'number') { - return { ok: false, reason: 'shape', raw }; - } - if (t.to === 'end') { - if (typeof t.reason !== 'string' || !END_REASONS.has(t.reason as EndReason)) { - return { ok: false, reason: 'shape', raw }; - } - if (t.reason === 'error' && !isError(t.error)) { - return { ok: false, reason: 'shape', raw }; - } - } - if (t.to === 'executing') { - if (t.responded !== undefined && !isRecord(t.responded)) { - return { ok: false, reason: 'shape', raw }; - } - } - return { ok: true, frame: raw as unknown as Frame }; - } - - if (family === 'event') { - const e = raw.event; - if (!isRecord(e) || typeof e.type !== 'string' || !EVENTS.has(e.type)) { - return { ok: false, reason: 'unknown-event', raw }; - } - if (e.type === 'text_delta' && typeof e.text !== 'string') { - return { ok: false, reason: 'shape', raw }; - } - if ( - (e.type === 'tool_call' || e.type === 'approval_request') && - (typeof e.id !== 'string' || !isRecord(e.args)) - ) { - return { ok: false, reason: 'shape', raw }; - } - if (e.type === 'tool_call' && typeof e.name !== 'string') { - return { ok: false, reason: 'shape', raw }; - } - if (e.type === 'tool_result') { - if (typeof e.id !== 'string' || typeof e.name !== 'string' || !isOutcome(e.outcome)) { - return { ok: false, reason: 'shape', raw }; - } - } - if (e.type === 'approval_request' && typeof e.tool !== 'string') { - return { ok: false, reason: 'shape', raw }; - } - return { ok: true, frame: raw as unknown as Frame }; - } - - return { ok: false, reason: 'unknown-family', raw }; -} diff --git a/packages/codingcode/src/contracts/mcp.ts b/packages/codingcode/src/contracts/mcp.ts index b9411fc4..cf06188d 100644 --- a/packages/codingcode/src/contracts/mcp.ts +++ b/packages/codingcode/src/contracts/mcp.ts @@ -21,12 +21,6 @@ export interface McpServerConfig { autoReconnect?: boolean; } -export interface McpStatus { - name: string; - toolCount: number; - transport: 'stdio' | 'http'; -} - /** MCP 远端工具的纯数据描述:zod schema 与 SDK client 等机制形状由实现层持有 */ export interface McpToolSpec { server: string; diff --git a/packages/codingcode/src/contracts/paths.ts b/packages/codingcode/src/contracts/paths.ts new file mode 100644 index 00000000..c720e48d --- /dev/null +++ b/packages/codingcode/src/contracts/paths.ts @@ -0,0 +1,11 @@ +/** `<~/.codingcode>/project` —— 所有工作区的数据根。 */ +export const PROJECTS_DIRNAME = 'project'; + +/** 工作区数据根下存放会话转录的目录名。 */ +export const SESSIONS_DIRNAME = 'sessions'; + +/** 子代理转录所在的子目录名(位于其父会话目录下)。 */ +export const SUBAGENTS_DIRNAME = 'subagents'; + +/** 转录文件后缀。 */ +export const TRANSCRIPT_SUFFIX = '.jsonl'; diff --git a/packages/codingcode/src/contracts/permission.ts b/packages/codingcode/src/contracts/permission.ts index 612b24d9..934c2780 100644 --- a/packages/codingcode/src/contracts/permission.ts +++ b/packages/codingcode/src/contracts/permission.ts @@ -1,5 +1,5 @@ export const PERMISSION_MODES = [ - 'default', + 'ask', 'acceptEdits', 'bypass', ] as const; diff --git a/packages/codingcode/src/contracts/profile.ts b/packages/codingcode/src/contracts/profile.ts new file mode 100644 index 00000000..066337b5 --- /dev/null +++ b/packages/codingcode/src/contracts/profile.ts @@ -0,0 +1,11 @@ +import type { ProfileName } from './types.js'; + +export interface AvailableProfile { + name: ProfileName; + description: string; +} + +export const AVAILABLE_PROFILES: AvailableProfile[] = [ + { name: 'plan', description: 'Planning agent' }, + { name: 'build', description: 'Build agent' }, +]; diff --git a/packages/codingcode/src/contracts/provider.ts b/packages/codingcode/src/contracts/provider.ts index 9b1c5d3b..17707b64 100644 --- a/packages/codingcode/src/contracts/provider.ts +++ b/packages/codingcode/src/contracts/provider.ts @@ -23,7 +23,7 @@ export type LLMStreamPart = readonly type: 'tool_call'; readonly id: string; readonly name: string; - readonly args: Record; + readonly arguments: Record; } | { readonly type: 'end'; readonly usage?: TokenUsage }; @@ -37,7 +37,6 @@ export interface ModelInfo { export interface LLMClient { complete(req: LLMRequest, signal?: AbortSignal): Effect.Effect; - /** 产出 SDK 流部件;失败时在迭代中抛出 AgentError */ completeStream(req: LLMRequest, signal?: AbortSignal): AsyncIterable; readonly modelInfo: ModelInfo; } diff --git a/packages/codingcode/src/contracts/session.ts b/packages/codingcode/src/contracts/session.ts index 4bed2181..67e5a91d 100644 --- a/packages/codingcode/src/contracts/session.ts +++ b/packages/codingcode/src/contracts/session.ts @@ -6,6 +6,8 @@ export interface SessionMetaEvent { sessionId: string; cwd: string; createdAt: string; + model: string; + title: string; activeProfile: ProfileName; permissionMode: PermissionMode; parentSessionId?: string; @@ -65,35 +67,15 @@ export type SessionEvent = | RollbackEvent | CompactEvent; -export interface SessionIndex { - sessionId: string; - cwd: string; - model: string; - createdAt: string; +export interface SessionSummary extends SessionMetaEvent { updatedAt: string; - messageCount: number; - title: string; - currentTurnId: number; - usage: TokenUsage | undefined; - activeProfile: ProfileName; - permissionMode: PermissionMode; - memorySnapshot?: string; - parentSessionId?: string; + usage?: TokenUsage; } -export interface SessionStoreState { - sessionId: string; - cwd: string; - messageCount: number; - sessionMeta: SessionMetaEvent | null; - model: string; - activeProfile: ProfileName; - permissionMode: PermissionMode; - title: string; +export interface SessionStoreState extends SessionMetaEvent { currentTurnId: number; - usage: TokenUsage | undefined; memorySnapshot: string; - parentSessionId?: string; + usage: TokenUsage | undefined; } export type UITurnItem = @@ -135,6 +117,14 @@ export interface UITurn { export interface SessionCreateOptions { model: string; + title?: string; activeProfile: ProfileName; permissionMode: PermissionMode; } + +export interface SessionRef { + cwd: string; + sessionId: string; + parentSessionId?: string; + currentTurnId: number; +} diff --git a/packages/codingcode/src/contracts/tool.ts b/packages/codingcode/src/contracts/tool.ts index 7d2746de..582c0e9b 100644 --- a/packages/codingcode/src/contracts/tool.ts +++ b/packages/codingcode/src/contracts/tool.ts @@ -1,19 +1,20 @@ import type { Effect } from 'effect'; import type { AgentError } from '../core/error.js'; import type { ToolOutcome } from './frame.js'; -import type { ToolDescription } from './types.js'; +import type { ToolDescription, ProfileName } from './types.js'; export interface ToolExecCtx { signal?: AbortSignal; sessionId?: string; projectPath?: string; + activeProfile?: ProfileName; + model: string; } export type ToolResult = { readonly id: string; readonly name: string } & ToolOutcome; export interface ToolRunner { readonly name: string; - /** 是否可与同批其它工具并发执行;「未声明」已由装配处收敛,故此处必填 */ readonly concurrencySafe: boolean; parse(args: unknown): unknown; execute(args: unknown, ctx?: ToolExecCtx): Effect.Effect; @@ -21,7 +22,6 @@ export interface ToolRunner { export type ToolLookup = (name: string) => ToolRunner | undefined; -// 一次装配出的工具集:tools 为纯描述喂模型,lookup 供执行器解析 export interface ToolCatalog { tools: ToolDescription[]; lookup: ToolLookup; diff --git a/packages/codingcode/src/core/path.ts b/packages/codingcode/src/core/path.ts index 2d5a3615..c81e79b8 100644 --- a/packages/codingcode/src/core/path.ts +++ b/packages/codingcode/src/core/path.ts @@ -1,6 +1,5 @@ -import { mkdirSync } from 'fs'; import { homedir } from 'os'; -import { join, resolve } from 'path'; +import { join } from 'path'; export const CODINGCODE_DIRNAME = '.codingcode'; @@ -8,14 +7,6 @@ export function getGlobalDir(): string { return join(homedir(), CODINGCODE_DIRNAME); } -export function getProjectDir(projectRoot: string): string { - return join(projectRoot, CODINGCODE_DIRNAME); -} - -export function isGlobalCwd(cwd: string | undefined): boolean { - return !cwd || cwd === '' || cwd === 'global'; -} - export function normalizePath(p: string): string { let s = p.replaceAll('\\', '/'); s = s.replace(/^\/([a-zA-Z])\//, (_, letter: string) => `${letter.toLowerCase()}:/`); @@ -30,48 +21,3 @@ export function encodeProjectPath(p: string): string { .replace(/^-+|-+$/g, '') .toLowerCase(); } - -export function getProjectBaseDir(): string { - return join(getGlobalDir(), 'project'); -} - -/** 无 cwd 的请求共用的工作目录。 */ -export function getTempCwd(): string { - return join(getGlobalDir(), 'temp'); -} - -export function ensureTempCwd(): void { - mkdirSync(getTempCwd(), { recursive: true }); -} - -/** 请求级 cwd:请求没带 cwd 时落到共用的临时工作目录。 */ -export function resolveCwd(cwd?: string): string { - return cwd ? resolve(cwd) : getTempCwd(); -} - -export interface SessionPaths { - sessionId: string; - cwd: string; - projectPath: string; - transcriptPath: string; - indexPath: string; -} - -export function projectSessionsDir(encodedProjectPath: string): string { - return join(getProjectBaseDir(), encodedProjectPath, 'sessions'); -} - -export function computePaths( - cwd: string, - sessionId: string, - parentSessionId?: string -): SessionPaths { - const normalizedCwd = normalizePath(cwd); - const projectPath = encodeProjectPath(normalizedCwd); - const sessionsDir = projectSessionsDir(projectPath); - const transcriptPath = parentSessionId - ? join(sessionsDir, parentSessionId, 'subagents', `${sessionId}.jsonl`) - : join(sessionsDir, `${sessionId}.jsonl`); - const indexPath = transcriptPath.replace('.jsonl', '.index.json'); - return { sessionId, cwd: normalizedCwd, projectPath, transcriptPath, indexPath }; -} diff --git a/packages/codingcode/src/hooks/config.ts b/packages/codingcode/src/hooks/config.ts index c9d57cf8..643f14a0 100644 --- a/packages/codingcode/src/hooks/config.ts +++ b/packages/codingcode/src/hooks/config.ts @@ -1,11 +1,12 @@ +import { join } from 'path'; import { mergeNamed, patchNamed, readNamedList, writeNamedList, type NamedListFile, -} from '@codingcode/infra/yaml-store'; -import { getGlobalDir, getProjectDir } from '../core/path.js'; +} from '../infra/yaml-store.js'; +import { getGlobalDir, CODINGCODE_DIRNAME } from '../core/path.js'; import type { UserHookConfig } from '../contracts/hooks.js'; const HOOKS_FILE: NamedListFile = { fileName: 'hooks', key: 'hooks' }; @@ -13,11 +14,11 @@ const HOOKS_FILE: NamedListFile = { fileName: 'hooks', key: 'hooks' }; type RawHookConfig = Partial & { name: string }; export function loadHookConfigs(projectRoot: string): UserHookConfig[] { - return readNamedList(getProjectDir(projectRoot), HOOKS_FILE) as UserHookConfig[]; + return readNamedList(join(projectRoot, CODINGCODE_DIRNAME), HOOKS_FILE) as UserHookConfig[]; } export function writeHookConfigs(projectRoot: string, hooks: UserHookConfig[]): void { - writeNamedList(getProjectDir(projectRoot), HOOKS_FILE, hooks); + writeNamedList(join(projectRoot, CODINGCODE_DIRNAME), HOOKS_FILE, hooks); } export function loadGlobalHookConfigs(): UserHookConfig[] { @@ -38,5 +39,5 @@ export function setGlobalHookEnabled(name: string, enabled: boolean): void { } export function setProjectHookEnabled(projectRoot: string, name: string, enabled: boolean): void { - patchNamed(getProjectDir(projectRoot), HOOKS_FILE, name, { enabled }); + patchNamed(join(projectRoot, CODINGCODE_DIRNAME), HOOKS_FILE, name, { enabled }); } diff --git a/packages/codingcode/src/hooks/hooks.ts b/packages/codingcode/src/hooks/hooks.ts index 20332845..7d2ccba7 100644 --- a/packages/codingcode/src/hooks/hooks.ts +++ b/packages/codingcode/src/hooks/hooks.ts @@ -1,7 +1,7 @@ import { Layer, Effect } from 'effect'; import { resolveHookConfigs } from './config.js'; import { executeHookCommand, executeDecisionHookCommand } from './executor.js'; -import { createLogger } from '@codingcode/infra/logger'; +import { createLogger } from '../infra/logger.js'; import { HookService } from './port.js'; import type { HookPoint, HookDecision } from '../contracts/hooks.js'; import type { ObserverHandler, DecisionHandler, HandlerEntry, ProjectPath } from './types.js'; diff --git a/packages/infra/src/config.ts b/packages/codingcode/src/infra/config.ts similarity index 86% rename from packages/infra/src/config.ts rename to packages/codingcode/src/infra/config.ts index 7d124d59..6409bce3 100644 --- a/packages/infra/src/config.ts +++ b/packages/codingcode/src/infra/config.ts @@ -1,20 +1,14 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import { getGlobalDir } from './paths.js'; +import { getGlobalDir } from '../core/path.js'; export interface ContextConfig { - /** Model for context compaction. Empty string falls back to main session LLM. - * Use full id format "model@API_KEY_ENV" to avoid ambiguity (e.g. "deepseek-chat@DEEPSEEK_API_KEY"). - * Can also use bare model id (e.g. "deepseek-chat") or display name, first match wins. */ compactionModel: string; } export interface MemoryConfig { enabled: boolean; - /** Model for memory operations. Empty string falls back to main session LLM. - * Use full id format "model@API_KEY_ENV" to avoid ambiguity (e.g. "deepseek-chat@DEEPSEEK_API_KEY"). - * Can also use bare model id (e.g. "deepseek-chat") or display name, first match wins. */ model: string; promptMaxBytes: number; } @@ -31,6 +25,8 @@ export interface AppConfig { maxSteps: number; maxStopContinuations: number; activeModel?: ActiveModelConfig; + activeProfile: string; + permissionMode: string; context: ContextConfig; memory: MemoryConfig; } @@ -51,6 +47,8 @@ export const DEFAULT_CONFIG: AppConfig = { }, maxSteps: 200, maxStopContinuations: 2, + activeProfile: 'build', + permissionMode: 'ask', context: DEFAULT_CONTEXT, memory: DEFAULT_MEMORY, }; @@ -91,6 +89,20 @@ export function updateActiveModel(model: string, apiKeyEnv: string, configPath?: writeConfig(p, existing); } +export function updateActiveProfile(activeProfile: string, configPath?: string): void { + const p = configPath ?? getUserConfigPath(); + const existing = readExistingConfig(p); + existing.activeProfile = activeProfile; + writeConfig(p, existing); +} + +export function updatePermissionMode(permissionMode: string, configPath?: string): void { + const p = configPath ?? getUserConfigPath(); + const existing = readExistingConfig(p); + existing.permissionMode = permissionMode; + writeConfig(p, existing); +} + export function updateMemoryEnabled(enabled: boolean, configPath?: string): void { const p = configPath ?? getUserConfigPath(); const existing = readExistingConfig(p); diff --git a/packages/infra/src/logger.ts b/packages/codingcode/src/infra/logger.ts similarity index 77% rename from packages/infra/src/logger.ts rename to packages/codingcode/src/infra/logger.ts index a5de68a5..1bb73610 100644 --- a/packages/infra/src/logger.ts +++ b/packages/codingcode/src/infra/logger.ts @@ -1,7 +1,7 @@ import pino from 'pino'; import { join } from 'path'; import { mkdirSync } from 'fs'; -import { getGlobalDir } from './paths.js'; +import { getGlobalDir } from '../core/path.js'; export type Logger = pino.Logger; @@ -13,8 +13,7 @@ export function createLogger(level = process.env.LOG_LEVEL ?? 'info'): Logger { const isDev = process.env.NODE_ENV !== 'production'; if (!isDev) { - // 生产模式:使用 pino.destination 同步写入文件,不依赖 worker 线程 - // 这样可以被 esbuild 打包为单文件,无需额外 node_modules + // 生产模式:同步写文件,不依赖 worker 线程,便于 esbuild 打成单文件 const logDir = join(getGlobalDir(), 'logs'); try { mkdirSync(logDir, { recursive: true }); diff --git a/packages/codingcode/src/infra/models.ts b/packages/codingcode/src/infra/models.ts new file mode 100644 index 00000000..a7eff021 --- /dev/null +++ b/packages/codingcode/src/infra/models.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { AgentError } from '../core/error.js'; +import type { SelectableModel } from '../contracts/provider.js'; +import { loadConfig, updateActiveModel } from './config.js'; + +export interface ModelDescriptor { + id: string; + name: string; + context_window?: number; +} + +export interface ProviderEntry { + name: string; + driver: string; + base_url: string; + api_key_env: string; + default_model: string; + models: ModelDescriptor[]; +} + +interface ProviderCatalog { + providers: ProviderEntry[]; +} + +const DEFAULT_CONTEXT_WINDOW = 128000; + +let cached: ProviderCatalog | null = null; + +function modelsFile(): string { + const projectRoot = process.env.CODINGCODE_PROJECT_ROOT ?? process.cwd(); + return join(projectRoot, 'config', 'models.json'); +} + +function readCatalog(): ProviderCatalog | null { + if (cached) return cached; + const path = modelsFile(); + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as ProviderCatalog; + if (!parsed.providers || parsed.providers.length === 0) return null; + cached = parsed; + return cached; + } catch { + return null; + } +} + +export function flattenModels(cat: ProviderCatalog): SelectableModel[] { + const result: SelectableModel[] = []; + for (const p of cat.providers) { + for (const m of p.models) { + result.push({ + id: `${m.id}@${p.name}`, + provider: p.name, + driver: p.driver, + name: m.name, + model: m.id, + base_url: p.base_url, + api_key_env: p.api_key_env, + context_window: m.context_window ?? DEFAULT_CONTEXT_WINDOW, + }); + } + } + return result; +} + +export function listModels(): SelectableModel[] { + const cat = readCatalog(); + return cat ? flattenModels(cat) : []; +} + +/** 精确复合 id 优先;裸名 / 显示名仅在唯一定位时使用(配置文件允许写裸名) */ +export function findModel(target: string): SelectableModel | null { + const models = listModels(); + const exact = models.find((m) => m.id === target); + if (exact) return exact; + return models.find((m) => m.model === target || m.name === target) ?? null; +} + +/** config.yaml 的 activeModel → 模型条目;未配置或匹配不上时 null */ +export function activeModel(): SelectableModel | null { + const cfg = loadConfig().activeModel; + if (!cfg) return null; + return ( + listModels().find((m) => m.model === cfg.model && m.api_key_env === cfg.apiKeyEnv) ?? null + ); +} + +export function activeModelId(): string { + return activeModel()?.id ?? ''; +} + +/** activeModel 取不到时的说明文字 */ +export function activeModelError(): string { + const cfg = loadConfig().activeModel; + if (!cfg) { + return 'No active model configured. Set activeModel in config.yaml with model and apiKeyEnv fields'; + } + return `Model "${cfg.model}" with apiKeyEnv "${cfg.apiKeyEnv}" not found in models.json`; +} + +export function contextWindowOf(model: string): number { + const target = model?.trim(); + const entry = target ? findModel(target) : activeModel(); + return entry?.context_window ?? DEFAULT_CONTEXT_WINDOW; +} + +export function setGlobalActive(model: string): void { + const found = findModel(model.trim()); + if (!found) { + throw new AgentError('CONFIG_INVALID', `Model "${model}" not found in models.json`); + } + updateActiveModel(found.model, found.api_key_env); +} diff --git a/packages/infra/src/yaml-store.ts b/packages/codingcode/src/infra/yaml-store.ts similarity index 100% rename from packages/infra/src/yaml-store.ts rename to packages/codingcode/src/infra/yaml-store.ts diff --git a/packages/codingcode/src/llm/llm-resolver.ts b/packages/codingcode/src/llm/llm-resolver.ts deleted file mode 100644 index 62778ed1..00000000 --- a/packages/codingcode/src/llm/llm-resolver.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Effect } from 'effect'; -import { AgentError } from '../core/error.js'; -import { LLMFactoryService } from './port.js'; -import type { LLMClient } from '../contracts/provider.js'; - -export function resolveLLM( - target: string | null | undefined, - fallback: LLMClient | null -): Effect.Effect { - const trimmed = target?.trim(); - if (!trimmed) return Effect.succeed(fallback); - return Effect.gen(function* () { - const factory = yield* LLMFactoryService; - const found = yield* factory.findModel(trimmed); - if (!found) return fallback; - const result = yield* factory.createClient(found).pipe(Effect.either); - return result._tag === 'Right' ? result.right : fallback; - }); -} diff --git a/packages/codingcode/src/llm/llm.ts b/packages/codingcode/src/llm/llm.ts index 52dcbb1f..1e1b8d30 100644 --- a/packages/codingcode/src/llm/llm.ts +++ b/packages/codingcode/src/llm/llm.ts @@ -1,270 +1,98 @@ -import { readFileSync, existsSync } from 'fs'; -import { resolve } from 'path'; -import { Layer, Effect } from 'effect'; +import { Effect, Layer } from 'effect'; import { AgentError } from '../core/error.js'; -import type { LLMClient, SelectableModel } from '../contracts/provider.js'; +import type { LLMClient, LLMRequest, LLMResponse, LLMStreamPart, SelectableModel } from '../contracts/provider.js'; import { OpenAIProvider } from './providers/openai.js'; import { DeepSeekProvider } from './providers/deepseek.js'; -import { loadConfig, updateActiveModel } from '@codingcode/infra/config'; -import { LLMFactoryService } from './port.js'; +import { activeModel, activeModelError, findModel } from '../infra/models.js'; +import { LLMService } from './port.js'; -export interface ModelDescriptor { - id: string; - name: string; - context_window?: number; -} - -export interface ProviderEntry { - name: string; - driver: string; - base_url: string; - api_key_env: string; - default_model: string; - models: ModelDescriptor[]; -} - -interface ProviderCatalog { - providers: ProviderEntry[]; -} - -interface ProviderCatalog { - providers: ProviderEntry[]; -} - -function flattenModels(cat: ProviderCatalog): SelectableModel[] { - const result: SelectableModel[] = []; - for (const p of cat.providers) { - for (const m of p.models) { - result.push({ - id: `${m.id}@${p.api_key_env}`, - provider: p.name, - driver: p.driver, - name: m.name, - model: m.id, - base_url: p.base_url, - api_key_env: p.api_key_env, - context_window: m.context_window ?? 128000, - }); +function entryFor(model: string): Effect.Effect { + const target = model?.trim() ?? ''; + if (target) { + const found = findModel(target); + if (!found) { + return Effect.fail( + new AgentError('CONFIG_INVALID', `Model "${target}" not found in models.json`) + ); } + return Effect.succeed(found); + } + const entry = activeModel(); + if (!entry) { + return Effect.fail(new AgentError('CONFIG_INVALID', activeModelError())); } - return result; + return Effect.succeed(entry); } -export const LlmLayer = Layer.effect(LLMFactoryService, Effect.gen(function* () { - let catalog: ProviderCatalog | null = null; - let currentEntry: SelectableModel | null = null; - let currentClient: LLMClient | null = null; - - function modelsFile(): string { - return resolve(process.cwd(), 'config/models.json'); +function clientFor(entry: SelectableModel): Effect.Effect { + return Effect.gen(function* () { + const apiKey = process.env[entry.api_key_env] || process.env.OPENAI_API_KEY || ''; + if (!apiKey) { + return yield* Effect.fail( + new AgentError( + 'CONFIG_MISSING', + `API key not found. Set environment variable "${entry.api_key_env}" or "OPENAI_API_KEY".`, + undefined, + { apiKeyEnv: entry.api_key_env } + ) + ); } - const loadCatalog = (): Effect.Effect => - Effect.gen(function* () { - if (catalog) return catalog; - const path = modelsFile(); - if (!existsSync(path)) { - return yield* Effect.fail(AgentError.configMissing(path)); - } - try { - const raw = readFileSync(path, 'utf-8'); - const parsed = JSON.parse(raw) as ProviderCatalog; - if (!parsed.providers || parsed.providers.length === 0) { - return yield* Effect.fail( - new AgentError('CONFIG_INVALID', 'models.json has no providers defined') - ); - } - catalog = parsed; - return catalog; - } catch (e) { - return yield* Effect.fail( - new AgentError('CONFIG_INVALID', `Failed to parse models.json: ${e}`) - ); - } - }); - - return { - listModels: (): Effect.Effect => - Effect.gen(function* () { - const cat = yield* loadCatalog(); - return flattenModels(cat); - }), - - findModel: (target: string): Effect.Effect => - Effect.gen(function* () { - const cat = yield* loadCatalog().pipe(Effect.either); - if (cat._tag === 'Left') return null; - const models = flattenModels(cat.right); - const exactMatch = models.find((m) => m.id === target); - if (exactMatch) return exactMatch; - return models.find((m) => m.model === target || m.name === target) || null; - }), - - getActiveEntry: (): Effect.Effect => - Effect.gen(function* () { - if (currentEntry) return currentEntry; - const cfg = loadConfig().activeModel; - if (!cfg) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - 'No active model configured. Set activeModel in config.yaml with model and apiKeyEnv fields' - ) - ); - } - const cat = yield* loadCatalog(); - const found = flattenModels(cat).find( - (m) => m.model === cfg.model && m.api_key_env === cfg.apiKeyEnv - ); - if (!found) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - `Model "${cfg.model}" with apiKeyEnv "${cfg.apiKeyEnv}" not found in models.json` - ) - ); - } - currentEntry = found; - return currentEntry; - }), - - switchModel: (id: string): Effect.Effect => - Effect.gen(function* () { - const cat = yield* loadCatalog(); - const all = flattenModels(cat); - const found = all.find((m) => m.id === id); - if (!found) - return yield* Effect.fail( - new AgentError('CONFIG_INVALID', `Model "${id}" not found. Use /model to list.`) - ); - currentEntry = found; - currentClient = null; - updateActiveModel(found.model, found.api_key_env); - return found; - }), + switch (entry.driver) { + case 'openai': { + const { createOpenAI } = yield* Effect.tryPromise({ + try: () => import('@ai-sdk/openai'), + catch: (e) => new AgentError('CONFIG_INVALID', `Failed to import openai driver: ${e}`), + }); + const provider = createOpenAI({ + name: entry.provider, + baseURL: entry.base_url, + apiKey, + }); + return new OpenAIProvider(provider.chat(entry.model), entry); + } + case 'deepseek': { + const { createDeepSeek } = yield* Effect.tryPromise({ + try: () => import('@ai-sdk/deepseek'), + catch: (e) => new AgentError('CONFIG_INVALID', `Failed to import deepseek driver: ${e}`), + }); + const deepseek = createDeepSeek({ + baseURL: entry.base_url, + apiKey, + }); + return new DeepSeekProvider(deepseek(entry.model), entry); + } + default: + return yield* Effect.fail( + new AgentError( + 'CONFIG_INVALID', + `Unknown driver "${entry.driver}" for provider "${entry.provider}"` + ) + ); + } + }); +} - createClient: (entry: SelectableModel): Effect.Effect => - Effect.gen(function* () { - const apiKey = process.env[entry.api_key_env] || process.env.OPENAI_API_KEY || ''; - if (!apiKey) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_MISSING', - `API key not found. Set environment variable "${entry.api_key_env}" or "OPENAI_API_KEY".`, - undefined, - { apiKeyEnv: entry.api_key_env } - ) - ); - } +async function runOrThrow(eff: Effect.Effect): Promise { + const result = await Effect.runPromise(Effect.either(eff)); + if (result._tag === 'Left') throw result.left; + return result.right; +} - switch (entry.driver) { - case 'openai': { - const { createOpenAI } = yield* Effect.tryPromise({ - try: () => import('@ai-sdk/openai'), - catch: (e) => - new AgentError('CONFIG_INVALID', `Failed to import openai driver: ${e}`), - }); - const provider = createOpenAI({ - name: entry.provider, - baseURL: entry.base_url, - apiKey, - }); - return new OpenAIProvider(provider.chat(entry.model), entry); - } - case 'deepseek': { - const { createDeepSeek } = yield* Effect.tryPromise({ - try: () => import('@ai-sdk/deepseek'), - catch: (e) => - new AgentError('CONFIG_INVALID', `Failed to import deepseek driver: ${e}`), - }); - const deepseek = createDeepSeek({ - baseURL: entry.base_url, - apiKey, - }); - return new DeepSeekProvider(deepseek(entry.model), entry); - } - default: - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - `Unknown driver "${entry.driver}" for provider "${entry.provider}"` - ) - ); - } - }), +export const LlmLayer = Layer.succeed(LLMService, { + complete(req: LLMRequest, model: string, signal?: AbortSignal): Effect.Effect { + return Effect.gen(function* () { + const entry = yield* entryFor(model); + const client = yield* clientFor(entry); + return yield* client.complete(req, signal); + }); + }, - getLLMClient: (): Effect.Effect => - Effect.gen(function* () { - if (currentClient) return currentClient; - const cfg = loadConfig().activeModel; - if (!cfg) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - 'No active model configured. Set activeModel in config.yaml with model and apiKeyEnv fields' - ) - ); - } - const cat = yield* loadCatalog(); - const found = flattenModels(cat).find( - (m) => m.model === cfg.model && m.api_key_env === cfg.apiKeyEnv - ); - if (!found) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - `Model "${cfg.model}" with apiKeyEnv "${cfg.apiKeyEnv}" not found in models.json` - ) - ); - } - currentEntry = found; - const apiKey = process.env[found.api_key_env] || process.env.OPENAI_API_KEY || ''; - if (!apiKey) { - return yield* Effect.fail( - new AgentError( - 'CONFIG_MISSING', - `API key not found. Set environment variable "${found.api_key_env}" or "OPENAI_API_KEY".`, - undefined, - { apiKeyEnv: found.api_key_env } - ) - ); - } - let client: LLMClient; - switch (found.driver) { - case 'openai': { - const { createOpenAI } = yield* Effect.tryPromise({ - try: () => import('@ai-sdk/openai'), - catch: (e) => - new AgentError('CONFIG_INVALID', `Failed to import openai driver: ${e}`), - }); - const provider = createOpenAI({ - name: found.provider, - baseURL: found.base_url, - apiKey, - }); - client = new OpenAIProvider(provider.chat(found.model), found); - break; - } - case 'deepseek': { - const { createDeepSeek } = yield* Effect.tryPromise({ - try: () => import('@ai-sdk/deepseek'), - catch: (e) => - new AgentError('CONFIG_INVALID', `Failed to import deepseek driver: ${e}`), - }); - const deepseek = createDeepSeek({ baseURL: found.base_url, apiKey }); - client = new DeepSeekProvider(deepseek(found.model), found); - break; - } - default: - return yield* Effect.fail( - new AgentError( - 'CONFIG_INVALID', - `Unknown driver "${found.driver}" for provider "${found.provider}"` - ) - ); - } - currentClient = client; - return currentClient; - }), - }; -})); + completeStream(req: LLMRequest, model: string, signal?: AbortSignal): AsyncIterable { + return (async function* () { + const entry = await runOrThrow(entryFor(model)); + const client = await runOrThrow(clientFor(entry)); + yield* client.completeStream(req, signal); + })(); + }, +}); diff --git a/packages/codingcode/src/llm/port.ts b/packages/codingcode/src/llm/port.ts index 44a3d35c..9120e986 100644 --- a/packages/codingcode/src/llm/port.ts +++ b/packages/codingcode/src/llm/port.ts @@ -1,15 +1,11 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; import type { AgentError } from '../core/error.js'; -import type { LLMClient, SelectableModel } from '../contracts/provider.js'; +import type { LLMRequest, LLMResponse, LLMStreamPart } from '../contracts/provider.js'; -export interface LLMFactoryShape { - listModels(): Effect.Effect; - findModel(target: string): Effect.Effect; - getActiveEntry(): Effect.Effect; - switchModel(id: string): Effect.Effect; - createClient(entry: SelectableModel): Effect.Effect; - getLLMClient(): Effect.Effect; +export interface LLMShape { + complete(req: LLMRequest, model: string, signal?: AbortSignal): Effect.Effect; + completeStream(req: LLMRequest, model: string, signal?: AbortSignal): AsyncIterable; } -export class LLMFactoryService extends Context.Tag('LLMFactory')() {} +export class LLMService extends Context.Tag('LLM')() {} diff --git a/packages/codingcode/src/llm/providers/deepseek.ts b/packages/codingcode/src/llm/providers/deepseek.ts index 4b26e4a0..82cba781 100644 --- a/packages/codingcode/src/llm/providers/deepseek.ts +++ b/packages/codingcode/src/llm/providers/deepseek.ts @@ -74,7 +74,7 @@ export class DeepSeekProvider implements LLMClient { type: 'tool_call', id: part.toolCallId, name: part.toolName, - args: (part.input ?? {}) as Record, + arguments: (part.input ?? {}) as Record, }; break; case 'finish': diff --git a/packages/codingcode/src/llm/providers/openai.ts b/packages/codingcode/src/llm/providers/openai.ts index b33faaa7..425e901a 100644 --- a/packages/codingcode/src/llm/providers/openai.ts +++ b/packages/codingcode/src/llm/providers/openai.ts @@ -54,7 +54,6 @@ export class OpenAIProvider implements LLMClient { } completeStream(req: LLMRequest, signal?: AbortSignal): AsyncIterable { - // sansen 不支持流式工具调用:退回非流式,再拆成同样三种部件 if (this.entry.provider === 'sansen' && req.tools && req.tools.length > 0) { const complete = this.complete(req, signal); return (async function* () { @@ -63,7 +62,7 @@ export class OpenAIProvider implements LLMClient { const value = either.right; if (value.content) yield { type: 'text', text: value.content }; for (const tc of value.toolCalls ?? []) { - yield { type: 'tool_call', id: tc.id, name: tc.name, args: tc.arguments }; + yield { type: 'tool_call', id: tc.id, name: tc.name, arguments: tc.arguments }; } yield value.usage ? { type: 'end', usage: value.usage } : { type: 'end' }; })(); @@ -89,7 +88,7 @@ export class OpenAIProvider implements LLMClient { type: 'tool_call', id: part.toolCallId, name: part.toolName, - args: (part.input ?? {}) as Record, + arguments: (part.input ?? {}) as Record, }; break; case 'finish': diff --git a/packages/codingcode/src/mcp/client.ts b/packages/codingcode/src/mcp/client.ts index 9024f17e..c0e3af15 100644 --- a/packages/codingcode/src/mcp/client.ts +++ b/packages/codingcode/src/mcp/client.ts @@ -44,10 +44,6 @@ export class McpClient { } } - get transportType(): 'stdio' | 'http' { - return this.config.command ? 'stdio' : 'http'; - } - async connect(): Promise { await this.client.connect(this.transport); this.reconnectAttempts = 0; diff --git a/packages/codingcode/src/mcp/config.ts b/packages/codingcode/src/mcp/config.ts index ccfe05d4..22109c7f 100644 --- a/packages/codingcode/src/mcp/config.ts +++ b/packages/codingcode/src/mcp/config.ts @@ -1,11 +1,12 @@ +import { join } from 'path'; import { mergeNamed, patchNamed, readNamedList, writeNamedList, type NamedListFile, -} from '@codingcode/infra/yaml-store'; -import { getGlobalDir, getProjectDir } from '../core/path.js'; +} from '../infra/yaml-store.js'; +import { getGlobalDir, CODINGCODE_DIRNAME } from '../core/path.js'; import type { McpServerConfig } from '../contracts/mcp.js'; /** mcp 的落盘形状:`/.codingcode/mcp.yaml` 的 `servers:` */ @@ -28,13 +29,13 @@ function resolveEnvVars(value: unknown): unknown { } export function loadMcpConfig(projectRoot: string): McpServerConfig[] { - return readNamedList(getProjectDir(projectRoot), MCP_FILE).map( + return readNamedList(join(projectRoot, CODINGCODE_DIRNAME), MCP_FILE).map( (s) => resolveEnvVars(s) as McpServerConfig ); } export function writeMcpConfig(projectRoot: string, servers: McpServerConfig[]): void { - writeNamedList(getProjectDir(projectRoot), MCP_FILE, servers); + writeNamedList(join(projectRoot, CODINGCODE_DIRNAME), MCP_FILE, servers); } export function loadGlobalMcpConfig(): McpServerConfig[] { @@ -60,5 +61,5 @@ export function setProjectMcpServerEnabled( name: string, enabled: boolean ): void { - patchNamed(getProjectDir(projectRoot), MCP_FILE, name, { enabled }); + patchNamed(join(projectRoot, CODINGCODE_DIRNAME), MCP_FILE, name, { enabled }); } diff --git a/packages/codingcode/src/mcp/mcp.ts b/packages/codingcode/src/mcp/mcp.ts index e6dd7a42..27254441 100644 --- a/packages/codingcode/src/mcp/mcp.ts +++ b/packages/codingcode/src/mcp/mcp.ts @@ -2,13 +2,12 @@ import { Effect, Layer } from 'effect'; import { resolveMcpConfig } from './config.js'; import { McpClient } from './client.js'; import { McpService } from './port.js'; -import type { McpServerConfig, McpStatus, McpToolSpec } from '../contracts/mcp.js'; -import { createLogger } from '@codingcode/infra/logger'; +import type { McpServerConfig, McpToolSpec } from '../contracts/mcp.js'; +import { createLogger } from '../infra/logger.js'; import { AgentError } from '../core/error.js'; const logger = createLogger(); -/** 客户端 listTools() 的原始返回形状(SDK 类型在 client.ts 内部收口) */ interface McpRawTool { name: string; description: string; @@ -136,17 +135,6 @@ export const McpLayer = Layer.effect(McpService, Effect.sync(() => { } return specs; }), - - status: (projectPath: string): Effect.Effect => - Effect.sync(() => { - const projectClients = clientsByProject.get(projectPath); - if (!projectClients) return []; - return Array.from(projectClients.entries()).map(([name, entry]) => ({ - name, - toolCount: entry.rawTools.length, - transport: entry.client.transportType, - })); - }), }; } )); diff --git a/packages/codingcode/src/mcp/port.ts b/packages/codingcode/src/mcp/port.ts index d5b69a71..0a1059e4 100644 --- a/packages/codingcode/src/mcp/port.ts +++ b/packages/codingcode/src/mcp/port.ts @@ -1,11 +1,10 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; -import type { McpStatus, McpToolSpec } from '../contracts/mcp.js'; +import type { McpToolSpec } from '../contracts/mcp.js'; export interface McpShape { syncConnections(projectPath: string): Effect.Effect; listProjectMcpTools(projectPath: string): Effect.Effect; - status(projectPath: string): Effect.Effect; } export class McpService extends Context.Tag('Mcp')() {} diff --git a/packages/codingcode/src/memory/config.ts b/packages/codingcode/src/memory/config.ts index a11822c9..da85cc11 100644 --- a/packages/codingcode/src/memory/config.ts +++ b/packages/codingcode/src/memory/config.ts @@ -1,4 +1,4 @@ -import { loadConfig, type MemoryConfig } from '@codingcode/infra/config'; +import { loadConfig, type MemoryConfig } from '../infra/config.js'; export function getMemoryConfig(): MemoryConfig { return loadConfig().memory; diff --git a/packages/codingcode/src/memory/extractor.ts b/packages/codingcode/src/memory/extractor.ts index b3743c63..51e0b5d4 100644 --- a/packages/codingcode/src/memory/extractor.ts +++ b/packages/codingcode/src/memory/extractor.ts @@ -1,13 +1,7 @@ -import type { LLMClient } from '../contracts/provider.js'; +import { Effect } from 'effect'; +import type { LLMShape } from '../llm/port.js'; -export async function extractMemory(opts: { - currentMemory: string; - transcript: string; - llm: LLMClient; -}): Promise { - const { currentMemory, transcript, llm } = opts; - - const systemPrompt = `你是记忆整理器。基于"已有记忆"和"会话记录",输出整份最新版长期记忆,放在 ... 块中,不要输出其它内容。 +const SYSTEM_PROMPT = `你是记忆整理器。基于"已有记忆"和"会话记录",输出整份最新版长期记忆。 规则: - 只保留值得跨会话记住的信息:用户角色、偏好与对 Agent 的纠正,项目架构决策、技术选型与部署信息,外部资源与链接等。 @@ -15,12 +9,22 @@ export async function extractMemory(opts: { - 更新哪些内容由你决定:在已有记忆基础上自行增、删、改,输出必须是一份完整、自洽的最新记忆,而不是只输出变动部分。 - 旧记忆与对话新信息矛盾时以最新为准;同一会话前后不一致时以最后出现为准。 - 不要编造对话中未出现的信息。 -- 若没有值得记住的新信息且已有记忆为空,输出 。 格式: - 纯 Markdown,用 "### 主题" 小节组织,小节下用 "- " 列要点。 -- 条目需具体、自包含,避免"上面提到的那个"这类指代。 -- 内不要带任何解释性文字。`; +- 条目需具体、自包含,避免"上面提到的那个"这类指代。`; + +function normalizeMemoryOutput(content: string): string | null { + return content.trim() || null; +} + +export function extractMemory(opts: { + currentMemory: string; + transcript: string; + llm: LLMShape; + model: string; +}): Effect.Effect { + const { currentMemory, transcript, llm, model } = opts; const userMessage = `已有记忆: ${currentMemory || '(空)'} @@ -28,26 +32,16 @@ ${currentMemory || '(空)'} 会话记录(按 [user]/[assistant]/[tool:名称] 标注): ${transcript || '(空)'}`; - try { - const stream = llm.completeStream({ - messages: [{ role: 'user', content: userMessage }], - system: systemPrompt, - }); - - let fullOutput = ''; - for await (const part of stream) { - if (part.type === 'text') fullOutput += part.text; - } - - const memoryMatch = fullOutput.match(/([\s\S]*?)<\/memory>/); - - if (!memoryMatch) { - return null; - } - - const extracted = memoryMatch[1]!.trim(); - return extracted || null; - } catch { - return null; - } + return llm + .complete( + { + messages: [{ role: 'user', content: userMessage }], + system: SYSTEM_PROMPT, + }, + model + ) + .pipe( + Effect.map((res) => normalizeMemoryOutput(res.content)), + Effect.catchAllCause(() => Effect.succeed(null)) + ); } diff --git a/packages/codingcode/src/memory/memory.ts b/packages/codingcode/src/memory/memory.ts index 1626cd83..af38f71f 100644 --- a/packages/codingcode/src/memory/memory.ts +++ b/packages/codingcode/src/memory/memory.ts @@ -1,5 +1,4 @@ import { Layer, Effect } from 'effect'; -import type { LLMClient } from '../contracts/provider.js'; import { readTranscript } from '../session/file-ops.js'; import type { SessionEvent } from '../contracts/session.js'; import { @@ -8,28 +7,26 @@ import { enforceMaxBytes, writeMemoryFileAtomic, } from './storage.js'; -import { resolveLLM } from '../llm/llm-resolver.js'; -import { LLMFactoryService } from '../llm/port.js'; +import { LLMService } from '../llm/port.js'; import { getMemoryConfig } from './config.js'; -import { updateMemoryEnabled } from '@codingcode/infra/config'; +import { updateMemoryEnabled } from '../infra/config.js'; +import { createLogger } from '../infra/logger.js'; import { extractMemory } from './extractor.js'; import { MemoryService } from './port.js'; const MAX_BYTES = 16384; +const NOT_WRITTEN = { written: false, bytes: 0 } as const; + +const logger = createLogger(); export const MemoryLayer = Layer.effect(MemoryService, Effect.gen(function* () { - const factory = yield* LLMFactoryService; + const llm = yield* LLMService; let _runtimeEnabled: boolean | null = null; - function getMemoryEnabled(): boolean { + function isEnabled(): boolean { return _runtimeEnabled ?? getMemoryConfig().enabled; } - function setMemoryEnabled(v: boolean): void { - _runtimeEnabled = v; - updateMemoryEnabled(v); - } - function truncateForPrompt(content: string, maxBytes: number): string { const contentBytes = Buffer.byteLength(content, 'utf-8'); if (contentBytes <= maxBytes) { @@ -49,8 +46,8 @@ export const MemoryLayer = Layer.effect(MemoryService, Effect.gen(function* () { return result; } - function loadMemoryForPrompt(cwd: string): string { - if (!getMemoryEnabled()) return ''; + function loadMemoryForPromptImpl(cwd: string): string { + if (!isEnabled()) return ''; const cfg = getMemoryConfig(); const projectPath = resolveMemoryPath(cwd); @@ -85,72 +82,56 @@ export const MemoryLayer = Layer.effect(MemoryService, Effect.gen(function* () { return lines.join('\n'); } - async function flushSessionToMemory( - sessionId: string, - llm: LLMClient | null, - sessionCwd: string - ): Promise<{ written: boolean; bytes: number }> { - if (!getMemoryEnabled()) { - return { written: false, bytes: 0 }; - } - if (!sessionCwd) { - return { written: false, bytes: 0 }; - } - - let events: SessionEvent[]; - try { - events = readTranscript(sessionCwd, sessionId).filter((e) => e.type !== 'session_meta'); - } catch { - return { written: false, bytes: 0 }; - } - if (events.length === 0) { - return { written: false, bytes: 0 }; - } - - const cfg = getMemoryConfig(); - const projectPath = resolveMemoryPath(sessionCwd); - const current = readMemoryFile(projectPath); - - try { - const transcript = buildTranscript(events); - - const resolvedLlm = await Effect.runPromise( - resolveLLM(cfg.model, llm).pipe(Effect.provideService(LLMFactoryService, factory)) - ); - if (!resolvedLlm) { - return { written: false, bytes: 0 }; - } - - const extracted = await extractMemory({ - currentMemory: current, - transcript, - llm: resolvedLlm, - }); - if (!extracted) { - return { written: false, bytes: 0 }; - } - - // 提取期间文件被手动改动则放弃本次写入 - if (readMemoryFile(projectPath) !== current) { - return { written: false, bytes: 0 }; - } - - const truncated = enforceMaxBytes(extracted, MAX_BYTES); - if (truncated === current) { - return { written: false, bytes: 0 }; - } - - writeMemoryFileAtomic(projectPath, truncated); - return { written: true, bytes: Buffer.byteLength(truncated, 'utf-8') }; - } catch { - return { written: false, bytes: 0 }; - } - } - return { - getMemoryEnabled, - setMemoryEnabled, - loadMemoryForPrompt, - flushSessionToMemory, + getMemoryEnabled: (): Effect.Effect => Effect.sync(() => isEnabled()), + + setMemoryEnabled: (v: boolean): Effect.Effect => + Effect.sync(() => { + _runtimeEnabled = v; + try { + updateMemoryEnabled(v); + } catch (e) { + logger.error('memory: failed to persist enabled flag:', e); + } + }), + + loadMemoryForPrompt: (cwd: string): Effect.Effect => + Effect.sync(() => loadMemoryForPromptImpl(cwd)), + + flushSessionToMemory: ( + sessionId: string, + model: string, + sessionCwd: string + ): Effect.Effect<{ written: boolean; bytes: number }> => + Effect.gen(function* () { + if (!isEnabled()) return { ...NOT_WRITTEN }; + if (!sessionCwd) return { ...NOT_WRITTEN }; + + const events = readTranscript(sessionCwd, sessionId).filter( + (e) => e.type !== 'session_meta' + ); + if (events.length === 0) return { ...NOT_WRITTEN }; + + const cfg = getMemoryConfig(); + const projectPath = resolveMemoryPath(sessionCwd); + const current = readMemoryFile(projectPath); + + const transcript = buildTranscript(events); + const extracted = yield* extractMemory({ + currentMemory: current, + transcript, + llm, + model: cfg.model?.trim() || model, + }); + if (!extracted) return { ...NOT_WRITTEN }; + + if (readMemoryFile(projectPath) !== current) return { ...NOT_WRITTEN }; + + const truncated = enforceMaxBytes(extracted, MAX_BYTES); + if (truncated === current) return { ...NOT_WRITTEN }; + + writeMemoryFileAtomic(projectPath, truncated); + return { written: true, bytes: Buffer.byteLength(truncated, 'utf-8') }; + }).pipe(Effect.catchAllCause(() => Effect.succeed({ ...NOT_WRITTEN }))), }; })); diff --git a/packages/codingcode/src/memory/port.ts b/packages/codingcode/src/memory/port.ts index e64faa43..e990c188 100644 --- a/packages/codingcode/src/memory/port.ts +++ b/packages/codingcode/src/memory/port.ts @@ -1,11 +1,11 @@ import { Context } from 'effect'; -import type { LLMClient } from '../contracts/provider.js'; +import type { Effect } from 'effect'; export interface MemoryShape { - getMemoryEnabled(): boolean; - setMemoryEnabled(v: boolean): void; - loadMemoryForPrompt(cwd: string): string; - flushSessionToMemory(sessionId: string, llm: LLMClient | null, sessionCwd: string): Promise<{ written: boolean; bytes: number }>; + getMemoryEnabled(): Effect.Effect; + setMemoryEnabled(v: boolean): Effect.Effect; + loadMemoryForPrompt(cwd: string): Effect.Effect; + flushSessionToMemory(sessionId: string, model: string, sessionCwd: string): Effect.Effect<{ written: boolean; bytes: number }>; } export class MemoryService extends Context.Tag('Memory')() {} diff --git a/packages/codingcode/src/memory/storage.ts b/packages/codingcode/src/memory/storage.ts index 5303b9f8..fece7c41 100644 --- a/packages/codingcode/src/memory/storage.ts +++ b/packages/codingcode/src/memory/storage.ts @@ -1,9 +1,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { getProjectDir } from '../core/path.js'; +import { CODINGCODE_DIRNAME } from '../core/path.js'; export function resolveMemoryPath(cwd: string): string { - return path.join(getProjectDir(cwd), 'memory.md'); + return path.join(cwd, CODINGCODE_DIRNAME, 'memory.md'); } export function readMemoryFile(absPath: string): string { diff --git a/packages/codingcode/src/rules/port.ts b/packages/codingcode/src/rules/port.ts index 06d4e9b8..6ed9d846 100644 --- a/packages/codingcode/src/rules/port.ts +++ b/packages/codingcode/src/rules/port.ts @@ -1,8 +1,9 @@ import { Context } from 'effect'; +import type { Effect } from 'effect'; export interface RulesShape { - getAllRules(projectPath?: string): string; - evictProjectRules(projectPath: string): void; + getAllRules(projectPath?: string): Effect.Effect; + evictProjectRules(projectPath: string): Effect.Effect; } export class RulesService extends Context.Tag('Rules')() {} diff --git a/packages/codingcode/src/rules/rules.ts b/packages/codingcode/src/rules/rules.ts index 8c2946fc..efec4dba 100644 --- a/packages/codingcode/src/rules/rules.ts +++ b/packages/codingcode/src/rules/rules.ts @@ -52,18 +52,20 @@ export const RulesLayer = Layer.effect(RulesService, Effect.sync(() => { } return { - getAllRules(projectPath?: string): string { - const key = projectPath ?? process.cwd(); - const cached = _allRulesCache.get(key); - if (cached !== undefined) return cached; - const result = buildAllRules(projectPath); - _allRulesCache.set(key, result); - return result; - }, + getAllRules: (projectPath?: string): Effect.Effect => + Effect.sync(() => { + const key = projectPath ?? process.cwd(); + const cached = _allRulesCache.get(key); + if (cached !== undefined) return cached; + const result = buildAllRules(projectPath); + _allRulesCache.set(key, result); + return result; + }), - evictProjectRules(projectPath: string): void { - _projectRulesCache.delete(projectPath); - _allRulesCache.delete(projectPath); - }, + evictProjectRules: (projectPath: string): Effect.Effect => + Effect.sync(() => { + _projectRulesCache.delete(projectPath); + _allRulesCache.delete(projectPath); + }), }; })); diff --git a/packages/codingcode/src/scheduler/port.ts b/packages/codingcode/src/scheduler/port.ts index ba765339..068f1a7b 100644 --- a/packages/codingcode/src/scheduler/port.ts +++ b/packages/codingcode/src/scheduler/port.ts @@ -1,6 +1,6 @@ import { Context } from 'effect'; import type { ManagedRuntime } from 'effect'; -import type { Automation, CreateAutomationInput, UpdateAutomationInput } from './types.js'; +import type { Automation, CreateAutomationInput, UpdateAutomationInput } from '../contracts/automation.js'; export interface SchedulerShape { setRuntime(rt: ManagedRuntime.ManagedRuntime): void; diff --git a/packages/codingcode/src/scheduler/scheduler.ts b/packages/codingcode/src/scheduler/scheduler.ts index 60dacfb8..4ae87c8b 100644 --- a/packages/codingcode/src/scheduler/scheduler.ts +++ b/packages/codingcode/src/scheduler/scheduler.ts @@ -1,11 +1,12 @@ import { Layer, Effect, ManagedRuntime } from 'effect'; import { CronJob } from 'cron'; import { randomUUID } from 'crypto'; -import { createLogger } from '@codingcode/infra/logger'; -import type { Automation, CreateAutomationInput, UpdateAutomationInput } from './types.js'; +import { createLogger } from '../infra/logger.js'; +import type { Automation, CreateAutomationInput, UpdateAutomationInput } from '../contracts/automation.js'; import { readAutomations, writeAutomations } from './store.js'; import { AgentService } from '../agent/port.js'; import { AgentError } from '../core/error.js'; +import { activeModelId } from '../infra/models.js'; import { SchedulerService } from './port.js'; const logger = createLogger(); @@ -46,6 +47,8 @@ export const SchedulerLayer = Layer.effect(SchedulerService, Effect.sync(() => { return yield* agent.runTurn(auto.description, { cwd: auto.projectCwd, signal: controller.signal, + // 自动化没有独立的模型配置,统一用 config.yaml 的活动模型 + model: activeModelId(), activeProfile: 'build', permissionMode: 'bypass', }); @@ -177,6 +180,8 @@ export const SchedulerLayer = Layer.effect(SchedulerService, Effect.sync(() => { return yield* agent.runTurn(auto.description, { cwd: auto.projectCwd, signal: controller.signal, + // 自动化没有独立的模型配置,统一用 config.yaml 的活动模型 + model: activeModelId(), activeProfile: 'build', permissionMode: 'bypass', }); diff --git a/packages/codingcode/src/scheduler/store.ts b/packages/codingcode/src/scheduler/store.ts index 5da1d64c..ade1fac7 100644 --- a/packages/codingcode/src/scheduler/store.ts +++ b/packages/codingcode/src/scheduler/store.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { dirname, join } from 'path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { getGlobalDir } from '../core/path.js'; -import type { Automation } from './types.js'; +import type { Automation } from '../contracts/automation.js'; interface AutomationsFile { automations: Automation[]; diff --git a/packages/codingcode/src/server/cwd.ts b/packages/codingcode/src/server/cwd.ts new file mode 100644 index 00000000..2da3c239 --- /dev/null +++ b/packages/codingcode/src/server/cwd.ts @@ -0,0 +1,25 @@ +import { existsSync } from 'fs'; +import { join, resolve } from 'path'; +import { AgentError } from '../core/error.js'; +import { getGlobalDir } from '../core/path.js'; + +export function isGlobalCwd(raw: string | undefined): boolean { + return !raw || raw === '' || raw === 'global'; +} + +export function tempCwd(): string { + return join(getGlobalDir(), 'temp'); +} + +export function resolveCwd(raw?: string): string { + return raw ? resolve(raw) : tempCwd(); +} + +export function resolveWorkspaceCwd(raw?: string): string { + if (isGlobalCwd(raw)) return tempCwd(); + const abs = resolve(raw!); + if (!existsSync(abs)) { + throw new AgentError('CONFIG_INVALID', `Workspace directory does not exist: ${abs}`); + } + return abs; +} diff --git a/packages/codingcode/src/server/index.ts b/packages/codingcode/src/server/index.ts index 8f292908..43eb472c 100644 --- a/packages/codingcode/src/server/index.ts +++ b/packages/codingcode/src/server/index.ts @@ -45,7 +45,7 @@ export async function createServer(rt: ManagedRt): Promise { registerMessagesRoutes(app, rt); registerModelsRoutes(app, rt); registerApprovalRoutes(app, rt); - await registerSettingsRoutes(app, rt); + registerSettingsRoutes(app, rt); registerAutomationsRoutes(app, rt); return app; diff --git a/packages/codingcode/src/server/routes/agent.ts b/packages/codingcode/src/server/routes/agent.ts new file mode 100644 index 00000000..977fdbb8 --- /dev/null +++ b/packages/codingcode/src/server/routes/agent.ts @@ -0,0 +1,64 @@ +import type { Hono } from 'hono'; +import { + loadConfig, + updateMaxSteps, + updateMaxStopContinuations, + updateContextCompactionModel, + updateActiveProfile, + updatePermissionMode, +} from '../../infra/config.js'; +import { isAgentProfileName } from '../../agent/profile.js'; +import { isPermissionMode } from '../../approval/types.js'; +import { errorBody } from '../util.js'; + +/** config.yaml 的 agent 段;交互/权限模式非法时回落到 build / default */ +function readAgentConfig() { + const cfg = loadConfig(); + return { + maxSteps: cfg.maxSteps, + maxStopContinuations: cfg.maxStopContinuations, + activeProfile: isAgentProfileName(cfg.activeProfile) ? cfg.activeProfile : 'build', + permissionMode: isPermissionMode(cfg.permissionMode) ? cfg.permissionMode : 'ask', + }; +} + +export function registerAgentSettingsRoutes(router: Hono): void { + // ---- Agent config ---- + router.get('/api/settings/agent/config', (c) => { + return c.json(readAgentConfig()); + }); + + router.post('/api/settings/agent/config', async (c) => { + const body = (await c.req.json()) as { + maxSteps?: number; + maxStopContinuations?: number; + activeProfile?: string; + permissionMode?: string; + }; + if (body.activeProfile !== undefined && !isAgentProfileName(body.activeProfile)) { + return c.json( + errorBody('CONFIG_INVALID', `Invalid activeProfile: ${body.activeProfile}`), + 400 + ); + } + if (body.permissionMode !== undefined && !isPermissionMode(body.permissionMode)) { + return c.json( + errorBody('CONFIG_INVALID', `Invalid permissionMode: ${body.permissionMode}`), + 400 + ); + } + if (body.maxSteps !== undefined) updateMaxSteps(body.maxSteps); + if (body.maxStopContinuations !== undefined) + updateMaxStopContinuations(body.maxStopContinuations); + if (body.activeProfile !== undefined) updateActiveProfile(body.activeProfile); + if (body.permissionMode !== undefined) updatePermissionMode(body.permissionMode); + return c.json(readAgentConfig()); + }); + + // ---- Context config ---- + router.post('/api/settings/context/compaction-model', async (c) => { + const body = (await c.req.json()) as { compactionModel: string }; + updateContextCompactionModel(body.compactionModel); + return c.json({ compactionModel: body.compactionModel }); + }); +} diff --git a/packages/codingcode/src/server/routes/automations.ts b/packages/codingcode/src/server/routes/automations.ts index 94876938..ec482ae5 100644 --- a/packages/codingcode/src/server/routes/automations.ts +++ b/packages/codingcode/src/server/routes/automations.ts @@ -1,9 +1,9 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { SchedulerService } from '../../scheduler/port.js'; -import { errorResponse } from '../util.js'; +import { errorBody, errorResponse } from '../util.js'; import { NotFoundError } from '../../contracts/error.js'; -import type { CreateAutomationInput, UpdateAutomationInput } from '../../scheduler/types.js'; +import type { CreateAutomationInput, UpdateAutomationInput } from '../../contracts/automation.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -36,7 +36,10 @@ export function registerAutomationsRoutes(router: Hono, rt: ManagedRt): void { const body = (await c.req.json()) as CreateAutomationInput; if (!body.name || !body.description || !body.cron || !body.projectCwd) { - return c.json({ error: 'Missing required fields: name, description, cron, projectCwd' }, 400); + return c.json( + errorBody('CONFIG_MISSING', 'Missing required fields: name, description, cron, projectCwd'), + 400 + ); } const result = await rt.runPromise( diff --git a/packages/codingcode/src/server/routes/hooks.ts b/packages/codingcode/src/server/routes/hooks.ts new file mode 100644 index 00000000..3796b9aa --- /dev/null +++ b/packages/codingcode/src/server/routes/hooks.ts @@ -0,0 +1,138 @@ +import type { Hono } from 'hono'; +import { isGlobalCwd, resolveCwd } from '../cwd.js'; +import { AlreadyExistsError, NotFoundError } from '../../contracts/error.js'; +import type { UserHookConfig } from '../../contracts/hooks.js'; +import { + loadHookConfigs, + writeHookConfigs, + loadGlobalHookConfigs, + writeGlobalHookConfigs, + resolveHookConfigs, + setGlobalHookEnabled, + setProjectHookEnabled, +} from '../../hooks/config.js'; + +function hooksCreate(cwd: string, hook: UserHookConfig): void { + const hooks = loadHookConfigs(cwd); + if (hooks.some((h) => h.name === hook.name)) { + throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); + } + hooks.push(hook); + writeHookConfigs(cwd, hooks); +} + +function hooksUpdate(cwd: string, name: string, hook: UserHookConfig): void { + const hooks = loadHookConfigs(cwd); + const idx = hooks.findIndex((h) => h.name === name); + if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); + if (hook.name !== name && hooks.some((h) => h.name === hook.name)) { + throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); + } + hooks[idx] = hook; + writeHookConfigs(cwd, hooks); +} + +function hooksDelete(cwd: string, name: string): void { + const hooks = loadHookConfigs(cwd); + if (!hooks.some((h) => h.name === name)) { + throw new NotFoundError(`Hook '${name}' not found in project config`); + } + writeHookConfigs( + cwd, + hooks.filter((h) => h.name !== name) + ); +} + +export function registerHooksSettingsRoutes(router: Hono): void { + router.get('/api/settings/hooks', (c) => { + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + return c.json( + loadGlobalHookConfigs().map((h) => ({ + ...h, + enabled: h.enabled !== false, + source: 'global' as const, + })) + ); + } + const cwd = resolveCwd(rawCwd); + const globalHooks = loadGlobalHookConfigs(); + const projectHooks = loadHookConfigs(cwd); + const globalNames = new Set(globalHooks.map((h) => h.name)); + const projectNames = new Set(projectHooks.map((h) => h.name)); + const merged = resolveHookConfigs(cwd); + return c.json( + merged.map((h) => { + const isFromProject = projectNames.has(h.name); + const isFromGlobal = globalNames.has(h.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...h, + enabled: h.enabled !== false, + source: isFromProject ? 'project' : 'global', + hasProjectOverride, + }; + }) + ); + }); + + router.post('/api/settings/hooks', async (c) => { + const rawCwd = c.req.query('cwd'); + const body = (await c.req.json()) as UserHookConfig; + if (isGlobalCwd(rawCwd)) { + const hooks = loadGlobalHookConfigs(); + if (hooks.some((h) => h.name === body.name)) { + throw new AlreadyExistsError(`Hook '${body.name}' already exists`); + } + hooks.push(body); + writeGlobalHookConfigs(hooks); + } else { + hooksCreate(resolveCwd(rawCwd), body); + } + return c.json({ ok: true }); + }); + + router.put('/api/settings/hooks/:name', async (c) => { + const name = c.req.param('name'); + const rawCwd = c.req.query('cwd'); + const body = (await c.req.json()) as UserHookConfig; + if (isGlobalCwd(rawCwd)) { + const hooks = loadGlobalHookConfigs(); + const idx = hooks.findIndex((h) => h.name === name); + if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); + if (body.name !== name && hooks.some((h) => h.name === body.name)) { + throw new AlreadyExistsError(`Hook '${body.name}' already exists`); + } + hooks[idx] = body; + writeGlobalHookConfigs(hooks); + } else { + hooksUpdate(resolveCwd(rawCwd), name, body); + } + return c.json({ ok: true }); + }); + + router.delete('/api/settings/hooks/:name', async (c) => { + const name = c.req.param('name'); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + const hooks = loadGlobalHookConfigs().filter((h) => h.name !== name); + writeGlobalHookConfigs(hooks); + } else { + hooksDelete(resolveCwd(rawCwd), name); + } + return c.json({ ok: true }); + }); + + // 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 hooks.yaml + router.post('/api/settings/hooks/:name/enabled', async (c) => { + const name = c.req.param('name'); + const body = (await c.req.json()) as { enabled: boolean }; + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + setGlobalHookEnabled(name, body.enabled); + } else { + setProjectHookEnabled(resolveCwd(rawCwd), name, body.enabled); + } + return c.json({ ok: true }); + }); +} diff --git a/packages/codingcode/src/server/routes/mcp.ts b/packages/codingcode/src/server/routes/mcp.ts new file mode 100644 index 00000000..e6210b41 --- /dev/null +++ b/packages/codingcode/src/server/routes/mcp.ts @@ -0,0 +1,138 @@ +import type { Hono } from 'hono'; +import { isGlobalCwd, resolveCwd } from '../cwd.js'; +import { AlreadyExistsError, NotFoundError } from '../../contracts/error.js'; +import type { McpServerConfig } from '../../contracts/mcp.js'; +import { + loadMcpConfig, + writeMcpConfig, + loadGlobalMcpConfig, + writeGlobalMcpConfig, + resolveMcpConfig, + setGlobalMcpServerEnabled, + setProjectMcpServerEnabled, +} from '../../mcp/config.js'; + +function mcpCreateServer(cwd: string, server: McpServerConfig): void { + const servers = loadMcpConfig(cwd); + if (servers.some((s) => s.name === server.name)) { + throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); + } + servers.push(server); + writeMcpConfig(cwd, servers); +} + +function mcpUpdateServer(cwd: string, name: string, server: McpServerConfig): void { + const servers = loadMcpConfig(cwd); + const idx = servers.findIndex((s) => s.name === name); + if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); + if (server.name !== name && servers.some((s) => s.name === server.name)) { + throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); + } + servers[idx] = server; + writeMcpConfig(cwd, servers); +} + +function mcpDeleteServer(cwd: string, name: string): void { + const servers = loadMcpConfig(cwd); + if (!servers.some((s) => s.name === name)) { + throw new NotFoundError(`MCP server '${name}' not found in project config`); + } + writeMcpConfig( + cwd, + servers.filter((s) => s.name !== name) + ); +} + +export function registerMcpSettingsRoutes(router: Hono): void { + router.get('/api/settings/mcp', async (c) => { + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + return c.json( + loadGlobalMcpConfig().map((s) => ({ + ...s, + enabled: s.enabled !== false, + source: 'global' as const, + })) + ); + } + const cwd = resolveCwd(rawCwd); + const globalServers = loadGlobalMcpConfig(); + const projectServers = loadMcpConfig(cwd); + const globalNames = new Set(globalServers.map((s) => s.name)); + const projectNames = new Set(projectServers.map((s) => s.name)); + const merged = resolveMcpConfig(cwd); + return c.json( + merged.map((s) => { + const isFromProject = projectNames.has(s.name); + const isFromGlobal = globalNames.has(s.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...s, + enabled: s.enabled !== false, + source: isFromProject ? 'project' : 'global', + hasProjectOverride, + }; + }) + ); + }); + + router.post('/api/settings/mcp', async (c) => { + const rawCwd = c.req.query('cwd'); + const body = (await c.req.json()) as McpServerConfig; + if (isGlobalCwd(rawCwd)) { + const servers = loadGlobalMcpConfig(); + if (servers.some((s) => s.name === body.name)) { + throw new AlreadyExistsError(`MCP server '${body.name}' already exists`); + } + servers.push(body); + writeGlobalMcpConfig(servers); + } else { + mcpCreateServer(resolveCwd(rawCwd), body); + } + return c.json({ ok: true }); + }); + + router.put('/api/settings/mcp/:name', async (c) => { + const name = c.req.param('name'); + const rawCwd = c.req.query('cwd'); + const body = (await c.req.json()) as McpServerConfig; + if (isGlobalCwd(rawCwd)) { + const servers = loadGlobalMcpConfig(); + const idx = servers.findIndex((s) => s.name === name); + if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); + if (body.name !== name && servers.some((s) => s.name === body.name)) { + throw new AlreadyExistsError(`MCP server '${body.name}' already exists`); + } + servers[idx] = body; + writeGlobalMcpConfig(servers); + } else { + mcpUpdateServer(resolveCwd(rawCwd), name, body); + } + return c.json({ ok: true }); + }); + + router.delete('/api/settings/mcp/:name', async (c) => { + const name = c.req.param('name'); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + const servers = loadGlobalMcpConfig().filter((s) => s.name !== name); + writeGlobalMcpConfig(servers); + } else { + mcpDeleteServer(resolveCwd(rawCwd), name); + } + return c.json({ ok: true }); + }); + + // 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 mcp.yaml + router.post('/api/settings/mcp/:name/enabled', async (c) => { + const name = c.req.param('name'); + const rawCwd = c.req.query('cwd'); + const body = (await c.req.json()) as { enabled: boolean }; + if (isGlobalCwd(rawCwd)) { + setGlobalMcpServerEnabled(name, body.enabled); + } else { + setProjectMcpServerEnabled(resolveCwd(rawCwd), name, body.enabled); + } + return c.json({ ok: true }); + }); +} diff --git a/packages/codingcode/src/server/routes/memory.ts b/packages/codingcode/src/server/routes/memory.ts new file mode 100644 index 00000000..1624e778 --- /dev/null +++ b/packages/codingcode/src/server/routes/memory.ts @@ -0,0 +1,40 @@ +import type { Hono } from 'hono'; +import { Effect, ManagedRuntime } from 'effect'; +import { MemoryService } from '../../memory/port.js'; +import { getMemoryConfig } from '../../memory/config.js'; +import { updateMemoryModel } from '../../infra/config.js'; + +type ManagedRt = ManagedRuntime.ManagedRuntime; + +export function registerMemorySettingsRoutes(router: Hono, rt: ManagedRt): void { + router.get('/api/settings/memory/config', (c) => { + const cfg = getMemoryConfig(); + return c.json({ + enabled: cfg.enabled, + model: cfg.model, + }); + }); + + router.post('/api/settings/memory/enabled', async (c) => { + const body = (await c.req.json()) as { enabled: boolean }; + await rt.runPromise( + Effect.gen(function* () { + const m = yield* MemoryService; + yield* m.setMemoryEnabled(body.enabled); + }) + ); + const enabled = await rt.runPromise( + Effect.gen(function* () { + const m = yield* MemoryService; + return yield* m.getMemoryEnabled(); + }) + ); + return c.json({ enabled }); + }); + + router.post('/api/settings/memory/model', async (c) => { + const body = (await c.req.json()) as { model: string }; + updateMemoryModel(body.model); + return c.json({ model: body.model }); + }); +} diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index b3f7d383..86e29080 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -1,8 +1,11 @@ import type { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { AgentService } from '../../agent/port.js'; -import { resolveCwd } from '../../core/path.js'; -import { errorResponse } from '../util.js'; +import { resolveWorkspaceCwd } from '../cwd.js'; +import { isAgentProfileName } from '../../agent/profile.js'; +import { isPermissionMode } from '../../approval/types.js'; +import { loadConfig } from '../../infra/config.js'; +import { errorBody, errorResponse } from '../util.js'; import { createSseHandler } from '../handler.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -12,17 +15,27 @@ export function registerMessagesRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/messages', async (c) => { let sessionId = c.req.param('id'); - const { input, cwd } = await c.req.json<{ input: string; cwd: string }>(); - const normalizedCwd = resolveCwd(cwd); + const { input, cwd, model } = await c.req.json<{ input: string; cwd: string; model?: string }>(); + // 模型是回合的必要输入,缺失即拒绝,不允许在 agent 层兜底成空串 + if (!model?.trim()) { + return c.json(errorBody('CONFIG_MISSING', 'model is required'), 400); + } + // 工作区目录必须存在,否则拒绝(不允许带着不存在的路径开回合) + const normalizedCwd = resolveWorkspaceCwd(cwd); const isNew = sessionId === '_' || !sessionId; const runOpts: any = { cwd: normalizedCwd, signal: c.req.raw.signal, + model, }; if (isNew) { - runOpts.activeProfile = 'build'; - runOpts.permissionMode = 'default'; + // 新会话的交互/权限模式取自 config.yaml;会话一旦建立就以会话头为准 + const cfg = loadConfig(); + runOpts.activeProfile = isAgentProfileName(cfg.activeProfile) ? cfg.activeProfile : 'build'; + runOpts.permissionMode = isPermissionMode(cfg.permissionMode) + ? cfg.permissionMode + : 'ask'; } const result = await rt.runPromise( diff --git a/packages/codingcode/src/server/routes/models.ts b/packages/codingcode/src/server/routes/models.ts index 69393b67..a8852c58 100644 --- a/packages/codingcode/src/server/routes/models.ts +++ b/packages/codingcode/src/server/routes/models.ts @@ -1,36 +1,11 @@ import type { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; -import { LLMFactoryService } from '../../llm/port.js'; +import type { ManagedRuntime } from 'effect'; +import { activeModelId, listModels } from '../../infra/models.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; -export function registerModelsRoutes(router: Hono, rt: ManagedRt): void { - router.get('/api/models', async (c) => { - const result = await rt.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - const modelsResult = yield* Effect.either(factory.listModels()); - const activeResult = yield* Effect.either(factory.getActiveEntry()); - return { - models: modelsResult._tag === 'Right' ? modelsResult.right : [], - activeId: activeResult._tag === 'Right' ? activeResult.right.id : '', - }; - }) - ); - return c.json(result); - }); - - router.post('/api/models/switch', async (c) => { - const { modelId } = (await c.req.json()) as { modelId: string }; - const result = await rt.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* Effect.either(factory.switchModel(modelId)); - }) - ); - return c.json({ - ok: result._tag === 'Right', - error: result._tag === 'Left' ? result.left.message : undefined, - }); +export function registerModelsRoutes(router: Hono, _rt: ManagedRt): void { + router.get('/api/models', (c) => { + return c.json({ models: listModels(), activeId: activeModelId() }); }); } diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index ac43e37d..baa49952 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -5,15 +5,17 @@ import { join } from 'path'; import type { SessionStoreState } from '../../contracts/session.js'; import type { ProfileName } from '../../contracts/types.js'; import { SessionService } from '../../session/port.js'; -import { computePaths, resolveCwd } from '../../core/path.js'; +import { computePaths } from '../../session/paths.js'; import { ContextService } from '../../context/port.js'; import { estimatePromptTokensFrom } from '../../context/context.js'; import { CheckpointService } from '../../checkpoint/port.js'; -import { LLMFactoryService } from '../../llm/port.js'; -import type { LLMClient } from '../../contracts/provider.js'; -import { errorResponse } from '../util.js'; -import { encodeProjectPath, getProjectBaseDir } from '../../core/path.js'; -import { AVAILABLE_PROFILES, isAgentProfileName } from '../../agent/profile.js'; +import { activeModelId, setGlobalActive } from '../../infra/models.js'; +import { errorBody, errorResponse } from '../util.js'; +import { resolveCwd, resolveWorkspaceCwd } from '../cwd.js'; +import { getGlobalDir, encodeProjectPath } from '../../core/path.js'; +import { PROJECTS_DIRNAME } from '../../contracts/paths.js'; +import { AVAILABLE_PROFILES } from '../../contracts/profile.js'; +import { isAgentProfileName } from '../../agent/profile.js'; import { isPermissionMode } from '../../approval/types.js'; import type { PermissionMode } from '../../contracts/permission.js'; @@ -57,15 +59,19 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { model: string; }; if (!isAgentProfileName(body.activeProfile)) { - return c.json({ error: `Invalid activeProfile: ${body.activeProfile}` }, 400); + return c.json(errorBody('CONFIG_INVALID', `Invalid activeProfile: ${body.activeProfile}`), 400); } if (!isPermissionMode(body.permissionMode)) { - return c.json({ error: `Invalid permissionMode: ${body.permissionMode}` }, 400); + return c.json( + errorBody('CONFIG_INVALID', `Invalid permissionMode: ${body.permissionMode}`), + 400 + ); } if (!body.model) { - return c.json({ error: 'model required' }, 400); + return c.json(errorBody('CONFIG_MISSING', 'model required'), 400); } - const normalizedCwd = resolveCwd(body.cwd); + // 建会话即建立工作区:目录必须存在 + const normalizedCwd = resolveWorkspaceCwd(body.cwd); const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -104,26 +110,21 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.post('/api/sessions/:id/compact', async (c) => { const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string }; + const body = (await c.req.json()) as { cwd: string; model?: string }; const normalizedCwd = resolveCwd(body.cwd); const result = await runWithLayer( Effect.gen(function* () { const context = yield* ContextService; - const factory = yield* LLMFactoryService; const session = yield* SessionService; const state = yield* session.load(normalizedCwd, sessionId); - - let llm: LLMClient | null = null; - const entry = yield* factory.getActiveEntry().pipe(Effect.either); - if (entry._tag === 'Right') { - const client = yield* factory.createClient(entry.right).pipe(Effect.either); - if (client._tag === 'Right') llm = client.right; - } - - const maxTokens = llm?.modelInfo.maxTokens ?? 128000; - - return yield* Effect.promise(() => - context.compactWithLLM(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath, maxTokens, llm) + return yield* context.compactWithLLM( + { + cwd: state.cwd, + sessionId: state.sessionId, + parentSessionId: state.parentSessionId, + currentTurnId: state.currentTurnId, + }, + body.model ?? '' ); }) ); @@ -137,7 +138,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.delete('/api/sessions/:id', async (c) => { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); - if (!cwd) return c.json({ error: 'cwd required' }, 400); + if (!cwd) return c.json(errorBody('CONFIG_MISSING', 'cwd required'), 400); await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -150,7 +151,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.get('/api/sessions/:id/history', async (c) => { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); - if (!cwd) return c.json({ error: 'cwd required' }, 400); + if (!cwd) return c.json(errorBody('CONFIG_MISSING', 'cwd required'), 400); const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -170,7 +171,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { // project's plan directory. router.get('/api/sessions/:id/plan', async (c) => { const cwd = resolveCwd(c.req.query('cwd')); - const planDir = join(getProjectBaseDir(), encodeProjectPath(cwd)); + const planDir = join(getGlobalDir(), PROJECTS_DIRNAME, encodeProjectPath(cwd)); if (!existsSync(planDir)) { return c.json({ content: '', @@ -205,7 +206,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { exists: true, }); } catch (e) { - return c.json({ error: `Failed to read plan: ${String(e)}` }, 500); + return c.json(errorBody('SESSION_IO_ERROR', `Failed to read plan: ${String(e)}`), 500); } }); @@ -240,7 +241,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const cwd = resolveCwd(body.cwd); const activeProfile = body.activeProfile; if (!isAgentProfileName(activeProfile)) { - return c.json({ error: `Invalid activeProfile: ${activeProfile}` }, 400); + return c.json(errorBody('CONFIG_INVALID', `Invalid activeProfile: ${activeProfile}`), 400); } const result = await runWithLayer( Effect.gen(function* () { @@ -263,7 +264,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.get('/api/sessions/:id/permission-mode', async (c) => { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); - if (!cwd) return c.json({ mode: 'default' }); + if (!cwd) return c.json({ mode: 'ask' }); const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; @@ -281,9 +282,9 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { router.put('/api/sessions/:id/permission-mode', async (c) => { const sessionId = c.req.param('id'); const { cwd, mode } = await c.req.json<{ cwd: string; mode: PermissionMode }>(); - if (!cwd) return c.json({ error: 'cwd required' }, 400); + if (!cwd) return c.json(errorBody('CONFIG_MISSING', 'cwd required'), 400); if (!isPermissionMode(mode)) { - return c.json({ error: `Invalid permissionMode: ${mode}` }, 400); + return c.json(errorBody('CONFIG_INVALID', `Invalid permissionMode: ${mode}`), 400); } const setResult = await runWithLayer( Effect.gen(function* () { @@ -299,6 +300,60 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { return c.json({ ok: true }); }); + // ---- Model switching ---- + // :id 为 '_' 时切全局默认模型(写 config.yaml);否则只写该会话头文件 + router.put('/api/sessions/:id/model', async (c) => { + const sessionId = c.req.param('id'); + const body = (await c.req.json()) as { cwd?: string; model?: string }; + const model = body.model?.trim(); + if (!model) return c.json(errorBody('CONFIG_MISSING', 'model required'), 400); + + if (sessionId === '_' || !sessionId) { + try { + setGlobalActive(model); + } catch (e) { + const { status, body: errBody } = errorResponse(e); + return c.json(errBody, status as any); + } + return c.json({ ok: true, activeId: activeModelId() }); + } + + const cwd = resolveCwd(body.cwd); + const result = await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setModel(cwd, sessionId, model); + return { ok: true }; + }) as any + ); + if (!result.ok) { + const { status, body: errBody } = errorResponse(result.error); + return c.json(errBody, status as any); + } + return c.json(result.value); + }); + + router.put('/api/sessions/:id/title', async (c) => { + const sessionId = c.req.param('id'); + const body = (await c.req.json()) as { cwd?: string; title?: string }; + const title = body.title?.replace(/\n/g, ' ').trim(); + if (!title) return c.json(errorBody('CONFIG_MISSING', 'title required'), 400); + const cwd = resolveCwd(body.cwd); + const result = await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + yield* session.renameSession(state, title); + return { ok: true }; + }) as any + ); + if (!result.ok) { + const { status, body: errBody } = errorResponse(result.error); + return c.json(errBody, status as any); + } + return c.json(result.value); + }); + router.get('/api/sessions/:id/checkpoints/latest/diff', async (c) => { const sessionId = c.req.param('id'); const cwd = resolveCwd(c.req.query('cwd')); @@ -468,7 +523,7 @@ export function registerSessionsRoutes(router: Hono, rt: ManagedRt): void { const newSessionId = yield* session.forkSession(state, atTurnId); const turns = yield* session.readUITurns(newSessionId, cwd); const newJsonlPath = computePaths(cwd, newSessionId).transcriptPath; - const promptEstimate = estimatePromptTokensFrom(session.readEvents(newJsonlPath)); + const promptEstimate = estimatePromptTokensFrom(yield* session.readEvents(newJsonlPath)); return { sessionId: newSessionId, turns, promptEstimate }; }) as any ); diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index 590bff41..54ab0328 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -1,413 +1,17 @@ import type { Hono } from 'hono'; -import { Effect, ManagedRuntime } from 'effect'; -import { SkillService } from '../../skills/port.js'; -import { isGlobalCwd, resolveCwd } from '../../core/path.js'; -import { AlreadyExistsError, NotFoundError } from '../../contracts/error.js'; -import type { McpServerConfig } from '../../contracts/mcp.js'; -import type { UserHookConfig } from '../../contracts/hooks.js'; -import { - loadMcpConfig, - writeMcpConfig, - loadGlobalMcpConfig, - writeGlobalMcpConfig, - resolveMcpConfig, - setGlobalMcpServerEnabled, - setProjectMcpServerEnabled, -} from '../../mcp/config.js'; -import { - loadHookConfigs, - writeHookConfigs, - loadGlobalHookConfigs, - writeGlobalHookConfigs, - resolveHookConfigs, - setGlobalHookEnabled, - setProjectHookEnabled, -} from '../../hooks/config.js'; -import { discoverGlobalSkillDirs, discoverProjectSkillDirs } from '../../skills/source.js'; -import { getMemoryConfig } from '../../memory/config.js'; -import { - loadConfig, - updateMaxSteps, - updateMaxStopContinuations, - updateContextCompactionModel, - updateMemoryModel, -} from '@codingcode/infra/config'; -import { MemoryService } from '../../memory/port.js'; -import { createRunWithLayer } from '../util.js'; +import type { ManagedRuntime } from 'effect'; +import { registerMemorySettingsRoutes } from './memory.js'; +import { registerAgentSettingsRoutes } from './agent.js'; +import { registerHooksSettingsRoutes } from './hooks.js'; +import { registerMcpSettingsRoutes } from './mcp.js'; +import { registerSkillsSettingsRoutes } from './skills.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; -export async function registerSettingsRoutes(router: Hono, rt: ManagedRt): Promise { - const runWithLayer = createRunWithLayer(rt); - - // ---- Helpers for CRUD with validation ---- - - function mcpCreateServer(cwd: string, server: McpServerConfig): void { - const servers = loadMcpConfig(cwd); - if (servers.some((s) => s.name === server.name)) { - throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); - } - servers.push(server); - writeMcpConfig(cwd, servers); - } - - function mcpUpdateServer(cwd: string, name: string, server: McpServerConfig): void { - const servers = loadMcpConfig(cwd); - const idx = servers.findIndex((s) => s.name === name); - if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); - if (server.name !== name && servers.some((s) => s.name === server.name)) { - throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); - } - servers[idx] = server; - writeMcpConfig(cwd, servers); - } - - function mcpDeleteServer(cwd: string, name: string): void { - const servers = loadMcpConfig(cwd); - if (!servers.some((s) => s.name === name)) { - throw new NotFoundError(`MCP server '${name}' not found in project config`); - } - writeMcpConfig( - cwd, - servers.filter((s) => s.name !== name) - ); - } - - function hooksCreate(cwd: string, hook: UserHookConfig): void { - const hooks = loadHookConfigs(cwd); - if (hooks.some((h) => h.name === hook.name)) { - throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); - } - hooks.push(hook); - writeHookConfigs(cwd, hooks); - } - - function hooksUpdate(cwd: string, name: string, hook: UserHookConfig): void { - const hooks = loadHookConfigs(cwd); - const idx = hooks.findIndex((h) => h.name === name); - if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); - if (hook.name !== name && hooks.some((h) => h.name === hook.name)) { - throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); - } - hooks[idx] = hook; - writeHookConfigs(cwd, hooks); - } - - function hooksDelete(cwd: string, name: string): void { - const hooks = loadHookConfigs(cwd); - if (!hooks.some((h) => h.name === name)) { - throw new NotFoundError(`Hook '${name}' not found in project config`); - } - writeHookConfigs( - cwd, - hooks.filter((h) => h.name !== name) - ); - } - - // ---- Memory ---- - router.get('/api/settings/memory/config', (c) => { - const cfg = getMemoryConfig(); - return c.json({ - enabled: cfg.enabled, - model: cfg.model, - }); - }); - - router.post('/api/settings/memory/enabled', async (c) => { - const body = (await c.req.json()) as { enabled: boolean }; - await rt.runPromise( - Effect.gen(function* () { - const m = yield* MemoryService; - m.setMemoryEnabled(body.enabled); - }) - ); - const enabled = await rt.runPromise( - Effect.gen(function* () { - const m = yield* MemoryService; - return m.getMemoryEnabled(); - }) - ); - return c.json({ enabled }); - }); - - router.post('/api/settings/memory/model', async (c) => { - const body = (await c.req.json()) as { model: string }; - updateMemoryModel(body.model); - return c.json({ model: body.model }); - }); - - // ---- Agent config ---- - router.get('/api/settings/agent/config', (c) => { - const cfg = loadConfig(); - return c.json({ maxSteps: cfg.maxSteps, maxStopContinuations: cfg.maxStopContinuations }); - }); - - router.post('/api/settings/agent/config', async (c) => { - const body = (await c.req.json()) as { maxSteps?: number; maxStopContinuations?: number }; - if (body.maxSteps !== undefined) updateMaxSteps(body.maxSteps); - if (body.maxStopContinuations !== undefined) - updateMaxStopContinuations(body.maxStopContinuations); - const cfg = loadConfig(); - return c.json({ maxSteps: cfg.maxSteps, maxStopContinuations: cfg.maxStopContinuations }); - }); - - // ---- Context config ---- - router.post('/api/settings/context/compaction-model', async (c) => { - const body = (await c.req.json()) as { compactionModel: string }; - updateContextCompactionModel(body.compactionModel); - return c.json({ compactionModel: body.compactionModel }); - }); - - // ---- Hooks ---- - router.get('/api/settings/hooks', (c) => { - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - return c.json( - loadGlobalHookConfigs().map((h) => ({ - ...h, - enabled: h.enabled !== false, - source: 'global' as const, - })) - ); - } - const cwd = resolveCwd(rawCwd); - const globalHooks = loadGlobalHookConfigs(); - const projectHooks = loadHookConfigs(cwd); - const globalNames = new Set(globalHooks.map((h) => h.name)); - const projectNames = new Set(projectHooks.map((h) => h.name)); - const merged = resolveHookConfigs(cwd); - return c.json( - merged.map((h) => { - const isFromProject = projectNames.has(h.name); - const isFromGlobal = globalNames.has(h.name); - const hasProjectOverride = isFromProject && isFromGlobal; - return { - ...h, - enabled: h.enabled !== false, - source: isFromProject ? 'project' : 'global', - hasProjectOverride, - }; - }) - ); - }); - - router.post('/api/settings/hooks', async (c) => { - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as UserHookConfig; - try { - if (isGlobalCwd(rawCwd)) { - const hooks = loadGlobalHookConfigs(); - if (hooks.some((h) => h.name === body.name)) { - throw new AlreadyExistsError(`Hook '${body.name}' already exists`); - } - hooks.push(body); - writeGlobalHookConfigs(hooks); - } else { - hooksCreate(resolveCwd(rawCwd), body); - } - return c.json({ ok: true }); - } catch (e) { - if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); - throw e; - } - }); - - router.put('/api/settings/hooks/:name', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as UserHookConfig; - try { - if (isGlobalCwd(rawCwd)) { - const hooks = loadGlobalHookConfigs(); - const idx = hooks.findIndex((h) => h.name === name); - if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); - if (body.name !== name && hooks.some((h) => h.name === body.name)) { - throw new AlreadyExistsError(`Hook '${body.name}' already exists`); - } - hooks[idx] = body; - writeGlobalHookConfigs(hooks); - } else { - hooksUpdate(resolveCwd(rawCwd), name, body); - } - return c.json({ ok: true }); - } catch (e) { - if (e instanceof NotFoundError) return c.json({ error: e.message }, 404); - if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); - throw e; - } - }); - - router.delete('/api/settings/hooks/:name', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - const hooks = loadGlobalHookConfigs().filter((h) => h.name !== name); - writeGlobalHookConfigs(hooks); - } else { - hooksDelete(resolveCwd(rawCwd), name); - } - return c.json({ ok: true }); - }); - - // 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 hooks.yaml - router.post('/api/settings/hooks/:name/enabled', async (c) => { - const name = c.req.param('name'); - const body = (await c.req.json()) as { enabled: boolean }; - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - setGlobalHookEnabled(name, body.enabled); - } else { - setProjectHookEnabled(resolveCwd(rawCwd), name, body.enabled); - } - return c.json({ ok: true }); - }); - - // ---- MCP ---- - router.get('/api/settings/mcp', async (c) => { - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - return c.json( - loadGlobalMcpConfig().map((s) => ({ - ...s, - enabled: s.enabled !== false, - source: 'global' as const, - })) - ); - } - const cwd = resolveCwd(rawCwd); - const globalServers = loadGlobalMcpConfig(); - const projectServers = loadMcpConfig(cwd); - const globalNames = new Set(globalServers.map((s) => s.name)); - const projectNames = new Set(projectServers.map((s) => s.name)); - const merged = resolveMcpConfig(cwd); - return c.json( - merged.map((s) => { - const isFromProject = projectNames.has(s.name); - const isFromGlobal = globalNames.has(s.name); - const hasProjectOverride = isFromProject && isFromGlobal; - return { - ...s, - enabled: s.enabled !== false, - source: isFromProject ? 'project' : 'global', - hasProjectOverride, - }; - }) - ); - }); - - router.post('/api/settings/mcp', async (c) => { - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as McpServerConfig; - try { - if (isGlobalCwd(rawCwd)) { - const servers = loadGlobalMcpConfig(); - if (servers.some((s) => s.name === body.name)) { - throw new AlreadyExistsError(`MCP server '${body.name}' already exists`); - } - servers.push(body); - writeGlobalMcpConfig(servers); - } else { - mcpCreateServer(resolveCwd(rawCwd), body); - } - return c.json({ ok: true }); - } catch (e) { - if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); - throw e; - } - }); - - router.put('/api/settings/mcp/:name', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as McpServerConfig; - try { - if (isGlobalCwd(rawCwd)) { - const servers = loadGlobalMcpConfig(); - const idx = servers.findIndex((s) => s.name === name); - if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); - if (body.name !== name && servers.some((s) => s.name === body.name)) { - throw new AlreadyExistsError(`MCP server '${body.name}' already exists`); - } - servers[idx] = body; - writeGlobalMcpConfig(servers); - } else { - mcpUpdateServer(resolveCwd(rawCwd), name, body); - } - return c.json({ ok: true }); - } catch (e) { - if (e instanceof NotFoundError) return c.json({ error: e.message }, 404); - if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); - throw e; - } - }); - - router.delete('/api/settings/mcp/:name', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - const servers = loadGlobalMcpConfig().filter((s) => s.name !== name); - writeGlobalMcpConfig(servers); - } else { - mcpDeleteServer(resolveCwd(rawCwd), name); - } - return c.json({ ok: true }); - }); - - // 开关就是配置里的 enabled 字段:改开关 = 写回对应层的 mcp.yaml - router.post('/api/settings/mcp/:name/enabled', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as { enabled: boolean }; - if (isGlobalCwd(rawCwd)) { - setGlobalMcpServerEnabled(name, body.enabled); - } else { - setProjectMcpServerEnabled(resolveCwd(rawCwd), name, body.enabled); - } - return c.json({ ok: true }); - }); - - // ---- Skills ---- - router.get('/api/settings/skills', async (c) => { - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - const cwd = resolveCwd(rawCwd); - const result = await runWithLayer( - Effect.gen(function* () { - const skill = yield* SkillService; - return yield* skill.getAll(cwd); - }) - ); - const skills = result.ok ? result.value : []; - return c.json( - skills.map((s) => ({ - ...s, - source: 'global' as const, - })) - ); - } - const cwd = resolveCwd(rawCwd); - const globalDirs = discoverGlobalSkillDirs(); - const projectDirs = discoverProjectSkillDirs(cwd); - const globalNames = new Set(globalDirs.map((d) => d.name)); - const projectNames = new Set(projectDirs.map((d) => d.name)); - const result = await runWithLayer( - Effect.gen(function* () { - const skill = yield* SkillService; - return yield* skill.getAll(cwd); - }) - ); - const skills = result.ok ? result.value : []; - return c.json( - skills.map((s) => { - const isFromProject = projectNames.has(s.name); - const isFromGlobal = globalNames.has(s.name); - const hasProjectOverride = isFromProject && isFromGlobal; - return { - ...s, - source: isFromProject ? 'project' : 'global', - hasProjectOverride, - }; - }) - ); - }); +export function registerSettingsRoutes(router: Hono, rt: ManagedRt): void { + registerMemorySettingsRoutes(router, rt); + registerAgentSettingsRoutes(router); + registerHooksSettingsRoutes(router); + registerMcpSettingsRoutes(router); + registerSkillsSettingsRoutes(router, rt); } diff --git a/packages/codingcode/src/server/routes/skills.ts b/packages/codingcode/src/server/routes/skills.ts new file mode 100644 index 00000000..44716a6d --- /dev/null +++ b/packages/codingcode/src/server/routes/skills.ts @@ -0,0 +1,56 @@ +import type { Hono } from 'hono'; +import { Effect, ManagedRuntime } from 'effect'; +import { SkillService } from '../../skills/port.js'; +import { isGlobalCwd, resolveCwd } from '../cwd.js'; +import { discoverGlobalSkillDirs, discoverProjectSkillDirs } from '../../skills/source.js'; +import { createRunWithLayer } from '../util.js'; + +type ManagedRt = ManagedRuntime.ManagedRuntime; + +export function registerSkillsSettingsRoutes(router: Hono, rt: ManagedRt): void { + const runWithLayer = createRunWithLayer(rt); + + router.get('/api/settings/skills', async (c) => { + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + const cwd = resolveCwd(rawCwd); + const result = await runWithLayer( + Effect.gen(function* () { + const skill = yield* SkillService; + return yield* skill.getAll(cwd); + }) + ); + const skills = result.ok ? result.value : []; + return c.json( + skills.map((s) => ({ + ...s, + source: 'global' as const, + })) + ); + } + const cwd = resolveCwd(rawCwd); + const globalDirs = discoverGlobalSkillDirs(); + const projectDirs = discoverProjectSkillDirs(cwd); + const globalNames = new Set(globalDirs.map((d) => d.name)); + const projectNames = new Set(projectDirs.map((d) => d.name)); + const result = await runWithLayer( + Effect.gen(function* () { + const skill = yield* SkillService; + return yield* skill.getAll(cwd); + }) + ); + const skills = result.ok ? result.value : []; + return c.json( + skills.map((s) => { + const isFromProject = projectNames.has(s.name); + const isFromGlobal = globalNames.has(s.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...s, + source: isFromProject ? 'project' : 'global', + hasProjectOverride, + }; + }) + ); + }); +} diff --git a/packages/codingcode/src/server/util.ts b/packages/codingcode/src/server/util.ts index ec1c701c..73e9e5f3 100644 --- a/packages/codingcode/src/server/util.ts +++ b/packages/codingcode/src/server/util.ts @@ -23,17 +23,17 @@ export function createRunWithLayer(rt: ManagedRt) { }; } +/** 错误信封的唯一构造点:`{ error: { code, message } }` */ +export function errorBody(code: string, message: string) { + return { error: { code, message } }; +} + export function errorResponse(err: unknown) { if (err instanceof AgentError) { - return { status: err.httpStatus(), body: { error: { code: err.code, message: err.message } } }; + return { status: err.httpStatus(), body: errorBody(err.code, err.message) }; } return { status: 500, - body: { - error: { - code: 'INTERNAL_ERROR', - message: err instanceof Error ? err.message : 'Internal server error', - }, - }, + body: errorBody('INTERNAL_ERROR', err instanceof Error ? err.message : 'Internal server error'), }; } diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index 41c2d6e9..f14a9495 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -10,15 +10,23 @@ import { closeSync, unlinkSync, rmSync, + statSync, + renameSync, } from 'fs'; import { join, dirname } from 'path'; -import { getGlobalDir, getProjectBaseDir, computePaths } from '../core/path.js'; -import type { PermissionMode } from '../contracts/permission.js'; -import type { SessionEvent, SessionMetaEvent, SessionIndex } from '../contracts/session.js'; +import { getGlobalDir } from '../core/path.js'; +import { PROJECTS_DIRNAME, SESSIONS_DIRNAME } from '../contracts/paths.js'; +import { transcriptPathOf } from './paths.js'; +import type { SessionEvent, SessionMetaEvent, SessionSummary } from '../contracts/session.js'; +import type { TokenUsage } from '../contracts/types.js'; -/** session 的 jsonl 转录路径:由 cwd + sessionId 推出。 */ -export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string { - return computePaths(cwd, sessionId).transcriptPath; +/** 首行最大读取字节数:会话头远小于此值。 */ +const HEAD_BYTES = 8192; +/** 尾部扫描初始窗口,命中不到时按 4 倍扩大。 */ +const TAIL_WINDOW_BYTES = 16 * 1024; + +export function sessionJsonlPathFromCwd(cwd: string, sessionId: string, parentSessionId?: string): string { + return transcriptPathOf(cwd, sessionId, parentSessionId); } export function ensureDirs(transcriptPath: string): void { @@ -28,85 +36,133 @@ export function ensureDirs(transcriptPath: string): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } -function quickReadMeta(path: string): SessionMetaEvent | null { +export function truncateTitle(content: string): string { + const cleaned = content.replace(/\n/g, ' ').trim(); + if (cleaned.length <= 30) return cleaned; + return cleaned.slice(0, 30) + '...'; +} + +/** 读取会话文件首行(会话索引行)。 */ +export function readSessionMeta(transcriptPath: string): SessionMetaEvent | null { try { - const fd = openSync(path, 'r'); - const buffer = Buffer.alloc(4096); - const bytesRead = readSync(fd, buffer, 0, 4096, 0); - closeSync(fd); - const firstLine = buffer.toString('utf8', 0, bytesRead).split('\n')[0]; - if (!firstLine) return null; - return JSON.parse(firstLine) as SessionMetaEvent; + const fd = openSync(transcriptPath, 'r'); + const buffer = Buffer.alloc(HEAD_BYTES); + let bytesRead = 0; + try { + bytesRead = readSync(fd, buffer, 0, HEAD_BYTES, 0); + } finally { + closeSync(fd); + } + const newlineAt = buffer.subarray(0, bytesRead).indexOf(0x0a); + const end = newlineAt === -1 ? bytesRead : newlineAt; + const line = buffer.toString('utf8', 0, end); + if (!line.trim()) return null; + const parsed = JSON.parse(line) as SessionEvent; + return parsed.type === 'session_meta' ? parsed : null; } catch { return null; } } -export function findFirstUserContent(history: SessionEvent[]): string | null { - for (const e of history) { - if (e.type === 'user') return e.content; +/** 原子重写首行(身份字段变更:标题 / 模型 / 权限模式 / profile)。 */ +export function rewriteSessionMeta(transcriptPath: string, patch: Partial): void { + const raw = readFileSync(transcriptPath, 'utf8'); + const newlineAt = raw.indexOf('\n'); + if (newlineAt < 0) return; + const meta = JSON.parse(raw.slice(0, newlineAt)) as SessionEvent; + if (meta.type !== 'session_meta') return; + const next = JSON.stringify({ ...meta, ...patch }); + const tmpPath = `${transcriptPath}.tmp`; + writeFileSync(tmpPath, next + raw.slice(newlineAt), 'utf8'); + renameSync(tmpPath, transcriptPath); +} + +function readWindow(transcriptPath: string, start: number, end: number): SessionEvent[] { + const fd = openSync(transcriptPath, 'r'); + const buffer = Buffer.alloc(end - start); + try { + readSync(fd, buffer, 0, buffer.length, start); + } finally { + closeSync(fd); + } + const rawLines = buffer.toString('utf8').split('\n'); + const lines = (start > 0 ? rawLines.slice(1) : rawLines).filter((l) => l.trim()); + const events: SessionEvent[] = []; + for (const line of lines) { + try { + events.push(JSON.parse(line) as SessionEvent); + } catch { + /* 截断行 */ + } } - return null; + return events; } -export function truncateTitle(content: string): string { - const cleaned = content.replace(/\n/g, ' ').trim(); - if (cleaned.length <= 30) return cleaned; - return cleaned.slice(0, 30) + '...'; +/** 从尾部按窗口 4 倍扩大读取,直到 visit 判定可返回或已覆盖整个文件。 */ +function scanTail(transcriptPath: string, visit: (events: SessionEvent[]) => T | undefined): T | undefined { + const size = statSync(transcriptPath).size; + let window = TAIL_WINDOW_BYTES; + for (;;) { + const start = Math.max(0, size - window); + const decided = visit(readWindow(transcriptPath, start, size)); + if (decided !== undefined || start === 0) return decided; + window *= 4; + } } -export function countNonMetaEvents(history: SessionEvent[]): number { - return history.filter((e) => e.type !== 'session_meta').length; +/** 尾部的最大 turnId;文件里没有带 turnId 的事件时返回 0。 */ +export function readLastTurnId(transcriptPath: string): number { + return ( + scanTail(transcriptPath, (events) => { + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]!; + if ('turnId' in ev && typeof ev.turnId === 'number') return ev.turnId; + } + return undefined; + }) ?? 0 + ); } -function buildIndexFromMeta(meta: SessionMetaEvent, history: SessionEvent[]): SessionIndex { - const firstUser = findFirstUserContent(history); - return { - sessionId: meta.sessionId, - cwd: meta.cwd, - model: 'unknown', - createdAt: meta.createdAt, - updatedAt: meta.createdAt, - messageCount: countNonMetaEvents(history), - title: firstUser ? truncateTitle(firstUser) : meta.sessionId.slice(0, 8), - currentTurnId: 0, - usage: undefined, - activeProfile: meta.activeProfile, - permissionMode: 'default', - }; +/** 尾部最后一个可见助手用量;回滚事件隐藏的 turn 会被跳过。 */ +export function readLastUsage(transcriptPath: string): TokenUsage | undefined { + return scanTail(transcriptPath, (events) => { + let minRollbackThrough = Infinity; + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]!; + if (ev.type === 'rollback') { + if (ev.throughTurnId < minRollbackThrough) minRollbackThrough = ev.throughTurnId; + continue; + } + if (ev.type === 'assistant' && ev.usage) { + if (minRollbackThrough <= ev.turnId) continue; + return ev.usage; + } + } + return undefined; + }); } -export function listSessions(encodedProjectPath?: string): SessionIndex[] { - const results: SessionIndex[] = []; - const projectBase = getProjectBaseDir(); +export function listSessions(encodedProjectPath?: string): SessionSummary[] { + const results: SessionSummary[] = []; + const projectBase = join(getGlobalDir(), PROJECTS_DIRNAME); const encodedDirs = encodedProjectPath ? [encodedProjectPath] : existsSync(projectBase) ? readdirSync(projectBase) : []; for (const encoded of encodedDirs) { - const sessionsDir = join(projectBase, encoded, 'sessions'); + const sessionsDir = join(projectBase, encoded, SESSIONS_DIRNAME); if (!existsSync(sessionsDir)) continue; for (const file of readdirSync(sessionsDir).filter((f) => f.endsWith('.jsonl'))) { const jsonlPath = join(sessionsDir, file); - const idxPath = jsonlPath.replace('.jsonl', '.index.json'); - let index: SessionIndex | null = null; - if (existsSync(idxPath)) { - try { - index = JSON.parse(readFileSync(idxPath, 'utf8')) as SessionIndex; - } catch { - /* corrupt */ - } - } - if (index) { - results.push(index); - } else { - const meta = quickReadMeta(jsonlPath); - if (meta?.cwd && meta?.sessionId) { - const h = readHistory(jsonlPath); - results.push(buildIndexFromMeta(meta, h)); - } + const meta = readSessionMeta(jsonlPath); + if (!meta) continue; + let updatedAt = meta.createdAt; + try { + updatedAt = statSync(jsonlPath).mtime.toISOString(); + } catch { } + results.push({ ...meta, updatedAt, usage: readLastUsage(jsonlPath) }); } } return results; @@ -125,62 +181,18 @@ export function appendLine(path: string, event: object): void { appendFileSync(path, JSON.stringify(event) + '\n', 'utf8'); } -export function readCurrentIndex(indexPath: string): Partial | null { - try { - return JSON.parse(readFileSync(indexPath, 'utf8')); - } catch { - return null; - } -} - export function readTranscript(cwd: string, sessionId: string): SessionEvent[] { return readHistory(sessionJsonlPathFromCwd(cwd, sessionId)); } -export function writeIndexAtomic(indexPath: string, patch: Partial): void { - let current: Partial = {}; - if (existsSync(indexPath)) { - try { - current = JSON.parse(readFileSync(indexPath, 'utf8')); - } catch { - /* corrupt */ - } - } - const merged = { ...current, ...patch, updatedAt: new Date().toISOString() }; - writeFileSync(indexPath, JSON.stringify(merged, null, 2), 'utf8'); -} - -export function setPermissionMode( - sessionId: string, - indexPath: string, - mode: PermissionMode -): void { - let index: SessionIndex | null = null; - if (existsSync(indexPath)) { - try { - index = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; - } catch { - /* corrupt */ - } - } - if (!index) throw new Error(`Session index not found: ${indexPath}`); - index.permissionMode = mode; - index.updatedAt = new Date().toISOString(); - writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf8'); -} - export function deleteSession(sessionId: string, cwd: string): void { const dir = dirname(sessionJsonlPathFromCwd(cwd, sessionId)); if (!dir) return; const jsonlPath = join(dir, `${sessionId}.jsonl`); - const idxPath = join(dir, `${sessionId}.index.json`); const subagentDir = join(dir, sessionId); try { if (existsSync(jsonlPath)) unlinkSync(jsonlPath); } catch {} - try { - if (existsSync(idxPath)) unlinkSync(idxPath); - } catch {} try { if (existsSync(subagentDir)) rmSync(subagentDir, { recursive: true, force: true }); } catch {} diff --git a/packages/codingcode/src/session/paths.ts b/packages/codingcode/src/session/paths.ts new file mode 100644 index 00000000..7ce36674 --- /dev/null +++ b/packages/codingcode/src/session/paths.ts @@ -0,0 +1,45 @@ +import { join } from 'path'; +import { getGlobalDir, normalizePath, encodeProjectPath } from '../core/path.js'; +import { + PROJECTS_DIRNAME, + SESSIONS_DIRNAME, + SUBAGENTS_DIRNAME, + TRANSCRIPT_SUFFIX, +} from '../contracts/paths.js'; + + +export interface SessionPaths { + sessionId: string; + cwd: string; + projectPath: string; + transcriptPath: string; +} + +export function projectDataDir(cwd: string): string { + return join(getGlobalDir(), PROJECTS_DIRNAME, encodeProjectPath(normalizePath(cwd))); +} + +export function projectSessionsDir(cwd: string): string { + return join(projectDataDir(cwd), SESSIONS_DIRNAME); +} + +export function transcriptPathOf(cwd: string, sessionId: string, parentSessionId?: string): string { + const sessionsDir = projectSessionsDir(cwd); + return parentSessionId + ? join(sessionsDir, parentSessionId, SUBAGENTS_DIRNAME, `${sessionId}${TRANSCRIPT_SUFFIX}`) + : join(sessionsDir, `${sessionId}${TRANSCRIPT_SUFFIX}`); +} + +export function computePaths( + cwd: string, + sessionId: string, + parentSessionId?: string +): SessionPaths { + const normalizedCwd = normalizePath(cwd); + return { + sessionId, + cwd: normalizedCwd, + projectPath: encodeProjectPath(normalizedCwd), + transcriptPath: transcriptPathOf(normalizedCwd, sessionId, parentSessionId), + }; +} diff --git a/packages/codingcode/src/session/port.ts b/packages/codingcode/src/session/port.ts index 7a9735dd..7de3840d 100644 --- a/packages/codingcode/src/session/port.ts +++ b/packages/codingcode/src/session/port.ts @@ -1,17 +1,17 @@ import { Context } from 'effect'; import type { Effect } from 'effect'; import type { AgentError } from '../core/error.js'; -import type { AssistantEvent, RollbackEvent, SessionCreateOptions, SessionEvent, SessionIndex, SessionStoreState, SummaryEvent, ToolResultEvent, UITurn, UserEvent } from '../contracts/session.js'; +import type { AssistantEvent, RollbackEvent, SessionCreateOptions, SessionEvent, SessionSummary, SessionStoreState, SummaryEvent, ToolResultEvent, UITurn, UserEvent } from '../contracts/session.js'; import type { TokenUsage, ProfileName } from '../contracts/types.js'; import type { PermissionMode } from '../contracts/permission.js'; export interface SessionShape { create(cwd: string, options: SessionCreateOptions, opts?: { parentSessionId?: string; agentName?: string }): Effect.Effect; - load(cwd: string, sessionId: string): Effect.Effect; + load(cwd: string, sessionId: string, parentSessionId?: string): Effect.Effect; deleteSession(sessionId: string, cwd: string): Effect.Effect; forkSession(state: SessionStoreState, atTurnId: number): Effect.Effect; renameSession(state: SessionStoreState, text: string): Effect.Effect; - listSessions(cwd?: string): Effect.Effect; + listSessions(cwd?: string): Effect.Effect; readHistory(state: SessionStoreState): Effect.Effect; recordUser(state: SessionStoreState, content: string): Effect.Effect; recordSystem(state: SessionStoreState, content: string): Effect.Effect; @@ -19,11 +19,12 @@ export interface SessionShape { recordToolResult(state: SessionStoreState, toolName: string, toolCallId: string, output: string): Effect.Effect; appendSummary(state: SessionStoreState, summaryText: string, startTurnId: number, endTurnId: number): Effect.Effect; rollbackToTurn(state: SessionStoreState, throughTurnId: number, reason: string): Effect.Effect; - readEvents(transcriptPath: string): SessionEvent[]; - appendEvent(transcriptPath: string, event: SessionEvent): void; + readEvents(transcriptPath: string): Effect.Effect; + appendEvent(transcriptPath: string, event: SessionEvent): Effect.Effect; readUITurns(sessionId: string, cwd: string): Effect.Effect; - setPermissionMode(cwd: string, sessionId: string, mode: PermissionMode): Effect.Effect; - setActiveProfile(cwd: string, sessionId: string, profile: ProfileName): Effect.Effect; + setPermissionMode(cwd: string, sessionId: string, mode: PermissionMode, parentSessionId?: string): Effect.Effect; + setActiveProfile(cwd: string, sessionId: string, profile: ProfileName, parentSessionId?: string): Effect.Effect; + setModel(cwd: string, sessionId: string, model: string, parentSessionId?: string): Effect.Effect; } export class SessionService extends Context.Tag('Session')() {} diff --git a/packages/codingcode/src/session/session.ts b/packages/codingcode/src/session/session.ts index 3a1c75c6..aa044366 100644 --- a/packages/codingcode/src/session/session.ts +++ b/packages/codingcode/src/session/session.ts @@ -1,10 +1,11 @@ import { Effect, Layer } from 'effect'; import { randomUUID } from 'crypto'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync } from 'fs'; import { join, dirname } from 'path'; import { AgentError } from '../core/error.js'; import { encodeProjectPath } from '../core/path.js'; -import type { SessionMetaEvent, UserEvent, AssistantEvent, ToolResultEvent, SummaryEvent, RollbackEvent, SessionIndex, SessionEvent, SessionStoreState, CompactEvent, UITurn } from '../contracts/session.js'; +import { computePaths } from './paths.js'; +import type { SessionMetaEvent, UserEvent, AssistantEvent, ToolResultEvent, SummaryEvent, RollbackEvent, SessionEvent, SessionStoreState, SessionSummary, CompactEvent, UITurn } from '../contracts/session.js'; import type { TokenUsage, ProfileName } from '../contracts/types.js'; import type { PermissionMode } from '../contracts/permission.js'; import { SessionService } from './port.js'; @@ -13,23 +14,21 @@ import { readHistory, appendLine, listSessions, - setPermissionMode, - readCurrentIndex, - writeIndexAtomic, - countNonMetaEvents, + readSessionMeta, + rewriteSessionMeta, + readLastTurnId, + readLastUsage, truncateTitle, - findFirstUserContent, deleteSession as deleteSessionImpl, sessionJsonlPathFromCwd, } from './file-ops.js'; -import { computePaths } from '../core/path.js'; function pathsFromState(state: SessionStoreState) { return computePaths(state.cwd, state.sessionId, state.parentSessionId); } -function assertResumeWorkspace(cwd: string, sessionId: string): void { - const expectedPath = sessionJsonlPathFromCwd(cwd, sessionId); +function assertResumeWorkspace(cwd: string, sessionId: string, parentSessionId?: string): void { + const expectedPath = sessionJsonlPathFromCwd(cwd, sessionId, parentSessionId); if (!existsSync(expectedPath)) throw AgentError.sessionNotFound(sessionId); } @@ -158,31 +157,11 @@ function readUIHistory(sessionId: string, cwd: string): UITurn[] { export const SessionLayer = Layer.effect( SessionService, Effect.gen(function* () { - function updateIndex(state: SessionStoreState): void { - if (!state.sessionMeta) return; - const paths = pathsFromState(state); - const index: SessionIndex = { - sessionId: state.sessionId, - cwd: state.cwd, - model: state.model, - createdAt: state.sessionMeta.createdAt, - updatedAt: new Date().toISOString(), - messageCount: state.messageCount, - title: state.title, - currentTurnId: state.currentTurnId, - usage: state.usage, - permissionMode: state.permissionMode, - memorySnapshot: state.memorySnapshot, - activeProfile: state.activeProfile, - parentSessionId: state.parentSessionId, - }; - writeFileSync(paths.indexPath, JSON.stringify(index, null, 2), 'utf8'); - } - const create = ( cwd: string, options: { model: string; + title?: string; activeProfile: ProfileName; permissionMode: PermissionMode; }, @@ -193,36 +172,21 @@ export const SessionLayer = Layer.effect( const paths = computePaths(cwd, randomUUID(), opts?.parentSessionId); ensureDirs(paths.transcriptPath); - const state: SessionStoreState = { - sessionId: paths.sessionId, - cwd: paths.cwd, - messageCount: 0, - sessionMeta: null, - model: options.model, - permissionMode: options.permissionMode, - title: paths.sessionId.slice(0, 8), - currentTurnId: 0, - usage: undefined, - memorySnapshot: '', - activeProfile: options.activeProfile, - parentSessionId: opts?.parentSessionId, - }; - const meta: SessionMetaEvent = { type: 'session_meta', - sessionId: state.sessionId, - cwd: state.cwd, + sessionId: paths.sessionId, + cwd: paths.cwd, createdAt: new Date().toISOString(), + model: options.model, + title: options.title ? truncateTitle(options.title) : '', activeProfile: options.activeProfile, permissionMode: options.permissionMode, ...(opts?.parentSessionId && { parentSessionId: opts.parentSessionId }), ...(opts?.agentName && { agentName: opts.agentName }), }; - state.sessionMeta = meta; appendLine(paths.transcriptPath, meta); - state.messageCount++; - updateIndex(state); - return state; + + return { ...meta, currentTurnId: 0, memorySnapshot: '', usage: undefined }; }, catch: (e) => e instanceof AgentError @@ -230,43 +194,22 @@ export const SessionLayer = Layer.effect( : new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e), }); - const load = (cwd: string, sessionId: string): Effect.Effect => + const load = (cwd: string, sessionId: string, parentSessionId?: string): Effect.Effect => Effect.try({ try: () => { - assertResumeWorkspace(cwd, sessionId); - const paths = computePaths(cwd, sessionId); + assertResumeWorkspace(cwd, sessionId, parentSessionId); + const paths = computePaths(cwd, sessionId, parentSessionId); ensureDirs(paths.transcriptPath); - const idx = readCurrentIndex(paths.indexPath); - if (!idx?.activeProfile) throw new Error('Session index missing activeProfile'); + const meta = readSessionMeta(paths.transcriptPath); + if (!meta) throw new Error('Session file missing session_meta'); - const state: SessionStoreState = { - sessionId: paths.sessionId, - cwd: paths.cwd, - messageCount: 0, - sessionMeta: null, - model: idx?.model ?? '', - permissionMode: idx?.permissionMode ?? 'default', - title: paths.sessionId.slice(0, 8), - currentTurnId: idx?.currentTurnId ?? 0, - usage: idx?.usage ?? undefined, - memorySnapshot: idx?.memorySnapshot ?? '', - activeProfile: idx.activeProfile, + return { + ...meta, + currentTurnId: readLastTurnId(paths.transcriptPath), + memorySnapshot: '', + usage: readLastUsage(paths.transcriptPath), }; - - if (existsSync(paths.transcriptPath)) { - const history = readHistory(paths.transcriptPath); - const meta = history.find((e) => e.type === 'session_meta') as - | SessionMetaEvent - | undefined; - if (meta) { - state.sessionMeta = meta; - state.messageCount = history.filter((e) => e.type !== 'session_meta').length; - } - const firstUser = findFirstUserContent(history); - if (firstUser) state.title = truncateTitle(firstUser); - } - return state; }, catch: (e) => e instanceof AgentError @@ -287,12 +230,15 @@ export const SessionLayer = Layer.effect( content, source: 'user', }; - if (state.title === state.sessionId.slice(0, 8)) { - state.title = truncateTitle(content); + const transcriptPath = pathsFromState(state).transcriptPath; + appendLine(transcriptPath, event); + if (!state.title) { + const derived = truncateTitle(content); + if (derived) { + rewriteSessionMeta(transcriptPath, { title: derived }); + state.title = derived; + } } - appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; - updateIndex(state); return event; }, catch: (e) => @@ -314,8 +260,6 @@ export const SessionLayer = Layer.effect( source: 'system', }; appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; - updateIndex(state); return event; }, catch: (e) => @@ -340,8 +284,6 @@ export const SessionLayer = Layer.effect( usage, }; appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; - updateIndex(state); if (usage) { state.usage = usage; } @@ -369,8 +311,6 @@ export const SessionLayer = Layer.effect( output, }; appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; - updateIndex(state); return event; }, catch: (e) => @@ -395,9 +335,7 @@ export const SessionLayer = Layer.effect( summaryText, }; appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; state.usage = undefined; - updateIndex(state); return event; }, catch: (e) => @@ -417,28 +355,10 @@ export const SessionLayer = Layer.effect( throughTurnId, reason, }; - appendLine(pathsFromState(state).transcriptPath, event); - state.messageCount++; - - const events = readHistory(pathsFromState(state).transcriptPath); - const minRollbackThrough = events.reduce( - (min, ev) => (ev.type === 'rollback' && ev.throughTurnId < min ? ev.throughTurnId : min), - Infinity - ); - let lastUsage: TokenUsage | undefined; - for (let i = events.length - 1; i >= 0; i--) { - const ev = events[i]!; - if ('turnId' in ev && minRollbackThrough <= (ev as { turnId: number }).turnId) { - continue; - } - if (ev.type === 'assistant' && (ev as AssistantEvent).usage) { - lastUsage = (ev as AssistantEvent).usage; - break; - } - } - state.usage = lastUsage; + const transcriptPath = pathsFromState(state).transcriptPath; + appendLine(transcriptPath, event); + state.usage = readLastUsage(transcriptPath); - updateIndex(state); return event; }); @@ -456,33 +376,47 @@ export const SessionLayer = Layer.effect( ): Effect.Effect => Effect.sync(() => { state.title = text; - updateIndex(state); + rewriteSessionMeta(pathsFromState(state).transcriptPath, { title: text }); }); const readHistoryFromState = (state: SessionStoreState): Effect.Effect => Effect.sync(() => readHistory(pathsFromState(state).transcriptPath)); - const listSessionsFromCwd = (cwd?: string): Effect.Effect => + const listSessionsFromCwd = (cwd?: string): Effect.Effect => Effect.sync(() => listSessions(cwd ? encodeProjectPath(cwd) : undefined)); const setPermissionModeByAddress = ( cwd: string, sessionId: string, - mode: PermissionMode + mode: PermissionMode, + parentSessionId?: string ): Effect.Effect => Effect.sync(() => { - const paths = computePaths(cwd, sessionId); - setPermissionMode(sessionId, paths.indexPath, mode); + const paths = computePaths(cwd, sessionId, parentSessionId); + rewriteSessionMeta(paths.transcriptPath, { permissionMode: mode }); }); const setActiveProfile = ( cwd: string, sessionId: string, - profile: ProfileName + profile: ProfileName, + parentSessionId?: string + ): Effect.Effect => + Effect.sync(() => { + const paths = computePaths(cwd, sessionId, parentSessionId); + rewriteSessionMeta(paths.transcriptPath, { activeProfile: profile }); + }); + + // 会话级切模型:只写会话首行,不碰 config.yaml + const setModel = ( + cwd: string, + sessionId: string, + model: string, + parentSessionId?: string ): Effect.Effect => Effect.sync(() => { - const paths = computePaths(cwd, sessionId); - writeIndexAtomic(paths.indexPath, { activeProfile: profile }); + const paths = computePaths(cwd, sessionId, parentSessionId); + rewriteSessionMeta(paths.transcriptPath, { model }); }); return { @@ -504,15 +438,28 @@ export const SessionLayer = Layer.effect( appendSummary, rollbackToTurn, - readEvents: (transcriptPath: string): SessionEvent[] => readHistory(transcriptPath), - appendEvent: (transcriptPath: string, event: SessionEvent): void => - appendLine(transcriptPath, event), + readEvents: (transcriptPath: string): Effect.Effect => + Effect.try({ + try: () => readHistory(transcriptPath), + catch: (e) => + new AgentError('SESSION_IO_ERROR', `Failed to read transcript ${transcriptPath}`, e), + }), + appendEvent: ( + transcriptPath: string, + event: SessionEvent + ): Effect.Effect => + Effect.try({ + try: () => appendLine(transcriptPath, event), + catch: (e) => + new AgentError('SESSION_IO_ERROR', `Failed to append event to ${transcriptPath}`, e), + }), readUITurns: (sessionId: string, cwd: string) => Effect.sync(() => readUIHistory(sessionId, cwd)), setPermissionMode: setPermissionModeByAddress, setActiveProfile, + setModel, }; }) ); @@ -528,9 +475,6 @@ function forkSessionImpl(sourceJsonlPath: string, atTurnId: number): string { const sessionsDir = dirname(sourceJsonlPath); const newJsonlPath = join(sessionsDir, `${newSessionId}.jsonl`); - const newIndexPath = join(sessionsDir, `${newSessionId}.index.json`); - - let turnId = 0; for (const ev of chain) { const cloned: any = { ...ev }; @@ -538,46 +482,9 @@ function forkSessionImpl(sourceJsonlPath: string, atTurnId: number): string { if (cloned.type === 'session_meta') { cloned.sessionId = newSessionId; } - if ('turnId' in cloned) { - turnId = Math.max(turnId, cloned.turnId); - } appendLine(newJsonlPath, cloned); } - const sourceIdxPath = sourceJsonlPath.replace('.jsonl', '.index.json'); - let title = newSessionId.slice(0, 8); - let usage: TokenUsage | undefined = undefined; - let permissionMode: PermissionMode = 'default'; - let srcIdx: SessionIndex | undefined; - if (existsSync(sourceIdxPath)) { - try { - srcIdx = JSON.parse(readFileSync(sourceIdxPath, 'utf8')) as SessionIndex; - title = srcIdx.title; - usage = srcIdx.usage ?? undefined; - permissionMode = srcIdx.permissionMode ?? 'default'; - } catch { - /* corrupt */ - } - } - - const meta = chain[0] as SessionMetaEvent | undefined; - const activeProfile = srcIdx?.activeProfile ?? meta?.activeProfile; - if (!activeProfile) throw new Error('Fork source missing activeProfile'); - const newIdx: SessionIndex = { - sessionId: newSessionId, - cwd: meta?.cwd ?? '', - model: srcIdx?.model ?? '', - createdAt: meta?.createdAt ?? new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: countNonMetaEvents(chain), - title, - currentTurnId: turnId, - usage, - permissionMode, - activeProfile, - }; - writeFileSync(newIndexPath, JSON.stringify(newIdx, null, 2), 'utf8'); - return newSessionId; } diff --git a/packages/codingcode/src/skills/source.ts b/packages/codingcode/src/skills/source.ts index 890ec4ec..6f60c60f 100644 --- a/packages/codingcode/src/skills/source.ts +++ b/packages/codingcode/src/skills/source.ts @@ -1,7 +1,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; import { join, basename } from 'path'; import { parse as parseYaml } from 'yaml'; -import { getGlobalDir, getProjectDir } from '../core/path.js'; +import { getGlobalDir, CODINGCODE_DIRNAME } from '../core/path.js'; interface SkillFrontMatter { name?: string; @@ -59,7 +59,7 @@ export function discoverGlobalSkillDirs(): SkillDirectory[] { /** `/.codingcode/skills/` —— 后加载,优先级更高 */ export function discoverProjectSkillDirs(projectRoot: string): SkillDirectory[] { const dirs: SkillDirectory[] = []; - const projectSkillsDir = join(getProjectDir(projectRoot), 'skills'); + const projectSkillsDir = join(projectRoot, CODINGCODE_DIRNAME, 'skills'); if (existsSync(projectSkillsDir)) { for (const entry of readdirSync(projectSkillsDir)) { const dirPath = join(projectSkillsDir, entry); diff --git a/packages/codingcode/src/subagent/port.ts b/packages/codingcode/src/subagent/port.ts index 282f8c8c..a5895527 100644 --- a/packages/codingcode/src/subagent/port.ts +++ b/packages/codingcode/src/subagent/port.ts @@ -10,7 +10,8 @@ export interface RunSubagentOptions { signal?: AbortSignal; activeProfile?: import('../contracts/types.js').ProfileName; permissionMode?: import('../contracts/permission.js').PermissionMode; - model?: string; + model: string; + systemPrompt?: string; parentSessionId?: string; agentName?: string; } diff --git a/packages/codingcode/src/subagent/subagent.ts b/packages/codingcode/src/subagent/subagent.ts index 5a691270..1150c490 100644 --- a/packages/codingcode/src/subagent/subagent.ts +++ b/packages/codingcode/src/subagent/subagent.ts @@ -17,8 +17,12 @@ export const SubagentRunnerLayer = Layer.effect( cwd: opts.cwd, signal: opts.signal, activeProfile: opts.activeProfile, - permissionMode: opts.permissionMode, + // 子代理不经审批:调用点未给定时固定 bypass,避免继承父会话的审批链路 + permissionMode: opts.permissionMode ?? 'bypass', model: opts.model, + parentSessionId: opts.parentSessionId, + agentName: opts.agentName, + systemPrompt: opts.systemPrompt, }); return { stream: result.stream as AsyncGenerator, unknown>, diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index efbd08b0..e479802d 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -1,38 +1,45 @@ import { z } from 'zod'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; +import { findModel } from '../../../infra/models.js'; import type { ToolDefinition } from '../../types.js'; import { HookService } from '../../../hooks/port.js'; import { SubagentRunnerService } from '../../../subagent/port.js'; -import { resolveSubagentProfile } from '../../../agent/profile.js'; export const dispatchAgentTool: ToolDefinition = { name: 'dispatch_agent', concurrencySafe: false, description: - 'Spawn an isolated subagent to handle specialized tasks. See "Available Subagents" in the system prompt for available profiles and their capabilities.', + 'Delegate a task to a subagent. The subagent runs in the same working directory as you and returns its final output. ' + + 'Keep the delegated write set disjoint from the files you edit yourself.', parameters: z.object({ - agent: z.string().describe('subagent profile name'), + agentName: z.string().min(1).describe('short nickname for the subagent; used for identification and display'), prompt: z.string().min(1).describe('task description for the subagent'), + model: z.string().optional().describe('model id for the subagent; must exist in models.json, otherwise the model of the current turn is used'), + systemPrompt: z.string().optional().describe('replaces the middle section of the subagent system prompt; the environment block and system notes are kept'), }), execute: (args, ctx) => Effect.gen(function* () { const hooks = yield* HookService; const runner = yield* SubagentRunnerService; - const { agent: agentName, prompt } = args as { agent: string; prompt: string }; + const { agentName, prompt, model, systemPrompt } = args as { + agentName: string; prompt: string; model?: string; systemPrompt?: string; + }; const projectPath = ctx?.projectPath || process.cwd(); - const profile = resolveSubagentProfile(agentName); - if (!profile) { + if (!ctx?.activeProfile) { return yield* Effect.fail( - new AgentError('TOOL_EXECUTION_FAILED', `Unknown subagent: ${agentName}`) + new AgentError('CONFIG_MISSING', 'dispatch_agent requires the parent session activeProfile') ); } const parentSessionId = ctx?.sessionId; + // 子代理只跑在模型清单内的模型上,参数空或不在清单里都继承父回合的模型 + const requestedModel = model?.trim(); + const effectiveModel = requestedModel && findModel(requestedModel) ? requestedModel : ctx.model; const spawnDecision = yield* hooks.emitDecision('agent.subagent.spawn.before', { - profile: agentName, prompt, parentSessionId, projectPath, + agentName, prompt, parentSessionId, projectPath, }); if (spawnDecision && spawnDecision.decision === 'deny') { return yield* Effect.fail( @@ -43,12 +50,17 @@ export const dispatchAgentTool: ToolDefinition((resume) => { @@ -79,7 +91,9 @@ export const dispatchAgentTool: ToolDefinition; - executeBatch(toolCalls: ToolCall[], sessionId?: string, opts?: { + executeBatch(toolCalls: ToolCall[], sessionId: string | undefined, opts: { turnId?: number; projectPath?: string; signal?: AbortSignal; toolLookup?: ToolLookup; + activeProfile?: ProfileName; + model: string; }): Effect.Effect; } diff --git a/packages/codingcode/src/tools/tools.ts b/packages/codingcode/src/tools/tools.ts index 6a5b80ac..9ec7a24c 100644 --- a/packages/codingcode/src/tools/tools.ts +++ b/packages/codingcode/src/tools/tools.ts @@ -1,7 +1,7 @@ import { Layer, Effect } from 'effect'; import { AgentError } from '../core/error.js'; import { HookService } from '../hooks/port.js'; -import type { ToolCall } from '../contracts/types.js'; +import type { ToolCall, ProfileName } from '../contracts/types.js'; import type { McpToolSpec } from '../contracts/mcp.js'; import type { ToolCatalog, ToolLookup, ToolResult } from '../contracts/tool.js'; import { ToolExecutorService } from './port.js'; @@ -13,28 +13,30 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu function execute( name: string, args: unknown, - opts?: { + opts: { signal?: AbortSignal; sessionId?: string; turnId?: number; projectPath?: string; callId?: string; toolLookup?: ToolLookup; + activeProfile?: ProfileName; + model: string; } ): Effect.Effect<{ output: string }, AgentError> { return Effect.gen(function* () { - const tool = opts?.toolLookup?.(name); + const tool = opts.toolLookup?.(name); if (!tool) return yield* Effect.fail(AgentError.toolNotFound(name)); const finalArgs = args as Record; - const callId = opts?.callId; + const callId = opts.callId; yield* hooks.emit('tool.execute.before', { toolName: name, args: finalArgs, - sessionId: opts?.sessionId, - turnId: opts?.turnId, - projectPath: opts?.projectPath, + sessionId: opts.sessionId, + turnId: opts.turnId, + projectPath: opts.projectPath, callId, }); @@ -43,15 +45,17 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu // Execute tool — now returns Effect directly const ctx = { - signal: opts?.signal, - sessionId: opts?.sessionId, - projectPath: opts?.projectPath, + signal: opts.signal, + sessionId: opts.sessionId, + projectPath: opts.projectPath, + activeProfile: opts.activeProfile, + model: opts.model, }; let toolEffect = tool.execute(parsedArgs, ctx); - if (opts?.signal) { + if (opts.signal) { if (opts.signal.aborted) { return yield* Effect.fail(new AgentError('TOOL_NOT_ALLOWED', 'Tool execution aborted')); } @@ -73,9 +77,9 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu args: finalArgs, result, durationMs: Date.now() - start, - sessionId: opts?.sessionId, - turnId: opts?.turnId, - projectPath: opts?.projectPath, + sessionId: opts.sessionId, + turnId: opts.turnId, + projectPath: opts.projectPath, callId, }); @@ -86,7 +90,7 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu toolName: name, args: args as Record, error, - projectPath: opts?.projectPath, + projectPath: opts.projectPath, }) ) ); @@ -94,12 +98,14 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu function execSingle( tc: ToolCall, - sessionId?: string, - opts?: { + sessionId: string | undefined, + opts: { turnId?: number; projectPath?: string; signal?: AbortSignal; toolLookup?: ToolLookup; + activeProfile?: ProfileName; + model: string; } ): Effect.Effect { return execute(tc.name, tc.arguments ?? {}, { sessionId, callId: tc.id, ...opts }).pipe( @@ -161,19 +167,21 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu function executeBatch( toolCalls: ToolCall[], - sessionId?: string, - opts?: { + sessionId: string | undefined, + opts: { turnId?: number; projectPath?: string; signal?: AbortSignal; toolLookup?: ToolLookup; + activeProfile?: ProfileName; + model: string; } ): Effect.Effect { return Effect.gen(function* () { const runTool = (tc: ToolCall): Effect.Effect => Effect.suspend(() => - opts?.signal?.aborted + opts.signal?.aborted ? Effect.succeed({ status: 'denied' as const, id: tc.id, @@ -184,7 +192,7 @@ export const ToolExecutorLayer = Layer.effect(ToolExecutorService, Effect.gen(fu ); const waveResults = yield* Effect.forEach( - splitWaves(toolCalls, opts?.toolLookup), + splitWaves(toolCalls, opts.toolLookup), (wave) => Effect.forEach(wave, runTool, { concurrency: 'unbounded' }), { concurrency: 1 } ); diff --git a/packages/codingcode/test/agent/abort.test.ts b/packages/codingcode/test/agent/abort.test.ts index 69c01138..93a78f1d 100644 --- a/packages/codingcode/test/agent/abort.test.ts +++ b/packages/codingcode/test/agent/abort.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { makeState, runAgentTurn, textDeltas } from '../helpers/agent-harness.js'; import type { FrameBody, Transition } from '../../src/contracts/frame.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/agent-cache-stability.test.ts b/packages/codingcode/test/agent/agent-cache-stability.test.ts index 858a51a8..6e83c82a 100644 --- a/packages/codingcode/test/agent/agent-cache-stability.test.ts +++ b/packages/codingcode/test/agent/agent-cache-stability.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { makeState, runAgentTurn, llmStream, pEnd } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/agent-concurrent.test.ts b/packages/codingcode/test/agent/agent-concurrent.test.ts index 42f87510..3161c054 100644 --- a/packages/codingcode/test/agent/agent-concurrent.test.ts +++ b/packages/codingcode/test/agent/agent-concurrent.test.ts @@ -10,7 +10,7 @@ import { toolResults, } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/agent-todo-event.test.ts b/packages/codingcode/test/agent/agent-todo-event.test.ts index e15c2704..3061240b 100644 --- a/packages/codingcode/test/agent/agent-todo-event.test.ts +++ b/packages/codingcode/test/agent/agent-todo-event.test.ts @@ -11,7 +11,7 @@ import { type HarnessMocks, } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index 4331ab62..bd904731 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -12,7 +12,7 @@ import { endReason, } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index 5c9ddc74..01056ba7 100644 --- a/packages/codingcode/test/agent/hooks-deps-type.test.ts +++ b/packages/codingcode/test/agent/hooks-deps-type.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; import { makeState, runAgentTurn, llmStream, pText, pEnd, endReason } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/loop-options.test.ts b/packages/codingcode/test/agent/loop-options.test.ts index 620ff5b9..949eaa7e 100644 --- a/packages/codingcode/test/agent/loop-options.test.ts +++ b/packages/codingcode/test/agent/loop-options.test.ts @@ -2,7 +2,7 @@ import { expect, it, describe, vi } from 'vitest'; import { Effect } from 'effect'; import { makeState, runAgentTurn, llmStream, pText, pEnd, endReason } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index fe956d60..6a46d531 100644 --- a/packages/codingcode/test/agent/memory-snapshot.test.ts +++ b/packages/codingcode/test/agent/memory-snapshot.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { makeState, runAgentTurn, llmStream, pEnd } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/stop-hook.test.ts b/packages/codingcode/test/agent/stop-hook.test.ts index ec854f77..127e64b8 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.test.ts @@ -10,7 +10,7 @@ import { endReason, } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 100, maxStopContinuations: 2, diff --git a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts index 7663fbbe..45460a2f 100644 --- a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts +++ b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts @@ -10,7 +10,7 @@ import { endReason, } from '../helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/approval/pipeline.test.ts b/packages/codingcode/test/approval/pipeline.test.ts index 60197d74..75777fbb 100644 --- a/packages/codingcode/test/approval/pipeline.test.ts +++ b/packages/codingcode/test/approval/pipeline.test.ts @@ -41,7 +41,7 @@ describe('Approval Pipeline — PermissionMode auto-allow (merged from ReadonlyW { ruleEngine: createRuleEngine(rules), destructiveTools: new Set(), - permissionMode: 'default', + permissionMode: 'ask', sessionId: 'test', } ) @@ -50,14 +50,14 @@ describe('Approval Pipeline — PermissionMode auto-allow (merged from ReadonlyW expect((decision as any).source).toContain('rule:'); }); - it('default mode does NOT auto-allow read-only tools (no UI → system deny)', async () => { + it('ask mode does NOT auto-allow read-only tools (no UI → system deny)', async () => { const decision = await runWithLayer( runPipeline( { tool: 'read_file', input: { path: '/safe/file.txt' } }, { ruleEngine: createRuleEngine(), destructiveTools: new Set(), - permissionMode: 'default', + permissionMode: 'ask', sessionId: 'test', } ) diff --git a/packages/codingcode/test/architecture/boundaries.test.ts b/packages/codingcode/test/architecture/boundaries.test.ts index 20b190b4..997929b5 100644 --- a/packages/codingcode/test/architecture/boundaries.test.ts +++ b/packages/codingcode/test/architecture/boundaries.test.ts @@ -4,8 +4,11 @@ import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; // 架构边界回归:把 issue 里的 R1–R4 固化成断言,防止再次出现"半倒置"。 +// 扫描根是两个包:core 引擎(packages/codingcode/src)与接入层(packages/sdk/src)。 const HERE = dirname(fileURLToPath(import.meta.url)); -const SRC = resolve(HERE, '../../src'); +const PACKAGES = resolve(HERE, '../../..'); +const SRC = resolve(PACKAGES, 'codingcode/src'); +const SDK_SRC = resolve(PACKAGES, 'sdk/src'); const norm = (p: string) => p.replace(/\\/g, '/'); @@ -19,7 +22,9 @@ function listTs(dir: string): string[] { return out; } -const FILES = listTs(SRC); +/** R4 的作用域是后端:sdk 是独立的客户端协议层,允许自持同形但同名的类型。 */ +const CORE_FILES = listTs(SRC); +const FILES = [...CORE_FILES, ...listTs(SDK_SRC)]; /** 抽取文件里的模块说明符(静态 import 与动态 import) */ function specifiersOf(file: string): string[] { @@ -37,6 +42,10 @@ function resolveSpec(file: string, spec: string): string | null { } const relSrc = (abs: string) => norm(relative(SRC, abs)); +const relSdk = (abs: string) => norm(relative(SDK_SRC, abs)); +/** 违规信息用:按所属包给出可读路径 */ +const relPkg = (abs: string) => + abs.startsWith(norm(SDK_SRC) + '/') ? `sdk/src/${relSdk(abs)}` : `codingcode/src/${relSrc(abs)}`; const isCore = (abs: string) => abs.startsWith(norm(join(SRC, 'core')) + '/'); const isContracts = (abs: string) => abs.startsWith(norm(join(SRC, 'contracts')) + '/'); @@ -52,7 +61,7 @@ describe('R1 契约不得 import 实现', () => { if (target === null) continue; // 第三方库 const sameDir = dirname(target) === dirname(file); if (!isCore(target) && !isContracts(target) && !sameDir) { - violations.push(`${relSrc(file)} → ${spec}`); + violations.push(`${relPkg(file)} → ${spec}`); } } } @@ -80,7 +89,7 @@ describe('R3 core 零内部依赖', () => { for (const spec of specifiersOf(file)) { const target = resolveSpec(file, spec); if (target === null) continue; // 第三方 / 其他 workspace 包 - if (!isCore(target)) violations.push(`${relSrc(file)} → ${spec}`); + if (!isCore(target)) violations.push(`${relPkg(file)} → ${spec}`); } } expect(violations).toEqual([]); @@ -99,11 +108,11 @@ describe('core/ 准入:只承载通用件', () => { for (const spec of specifiersOf(file)) { if (spec.startsWith('.')) { const target = resolveSpec(file, spec)!; - if (dirname(target) !== dirname(file)) violations.push(`${relSrc(file)} → ${spec}`); + if (dirname(target) !== dirname(file)) violations.push(`${relPkg(file)} → ${spec}`); continue; } if (!NODE_BUILTINS.has(spec.replace(/^node:/, ''))) { - violations.push(`${relSrc(file)} → ${spec}`); + violations.push(`${relPkg(file)} → ${spec}`); } } } @@ -119,7 +128,7 @@ describe('contracts/ 准入:不得依赖领域实现', () => { const target = resolveSpec(file, spec); if (target === null) continue; const sameDir = dirname(target) === dirname(file); - if (!isCore(target) && !sameDir) violations.push(`${relSrc(file)} → ${spec}`); + if (!isCore(target) && !sameDir) violations.push(`${relPkg(file)} → ${spec}`); } } expect(violations).toEqual([]); @@ -150,7 +159,10 @@ describe('R4 一个概念只允许一处类型定义', () => { HookDecision: 'contracts/hooks.ts', Skill: 'contracts/skill.ts', McpServerConfig: 'contracts/mcp.ts', - McpStatus: 'contracts/mcp.ts', + Automation: 'contracts/automation.ts', + AutomationSandbox: 'contracts/automation.ts', + CreateAutomationInput: 'contracts/automation.ts', + UpdateAutomationInput: 'contracts/automation.ts', }; it.each(Object.entries(CANONICAL))('%s 只在 %s 声明一次', (name, expected) => { @@ -158,7 +170,7 @@ describe('R4 一个概念只允许一处类型定义', () => { `^export\\s+(?:declare\\s+)?(?:abstract\\s+)?(?:interface|type|class)\\s+${name}\\b`, 'm' ); - const owners = FILES.filter((f) => re.test(readFileSync(f, 'utf8'))).map(relSrc).sort(); + const owners = CORE_FILES.filter((f) => re.test(readFileSync(f, 'utf8'))).map(relSrc).sort(); expect(owners).toEqual([norm(expected)]); }); }); @@ -173,7 +185,7 @@ describe('相对 import 必须可解析', () => { const ok = ['.ts', '.tsx', '/index.ts', '/index.tsx'].some((ext) => existsSync(target + ext) ); - if (!ok) missing.push(`${relSrc(file)} → ${spec}`); + if (!ok) missing.push(`${relPkg(file)} → ${spec}`); } } expect(missing).toEqual([]); diff --git a/packages/codingcode/test/client/http/sessions.test.ts b/packages/codingcode/test/client/http/sessions.test.ts deleted file mode 100644 index 65b93487..00000000 --- a/packages/codingcode/test/client/http/sessions.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { createHttpSessionClient } from '../../../src/client/http/sessions.js'; -import { createRequestHelpers } from '../../../src/client/http/request.js'; - -describe('createHttpSessionClient.setSessionPermissionMode', () => { - it('calls PUT /api/sessions/:id/permission-mode', async () => { - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); - - const request = createRequestHelpers('http://localhost:8080'); - const client = createHttpSessionClient(request); - - await client.setSessionPermissionMode({ - sessionId: 'sess-123', - cwd: '/test', - mode: 'acceptEdits' as any, - }); - - expect(fetchSpy).toHaveBeenCalledWith( - 'http://localhost:8080/api/sessions/sess-123/permission-mode', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ cwd: '/test', mode: 'acceptEdits' }), - }) - ); - - fetchSpy.mockRestore(); - }); -}); diff --git a/packages/codingcode/test/context/append-turn-end.test.ts b/packages/codingcode/test/context/append-turn-end.test.ts index 2bdd08de..e7ee9459 100644 --- a/packages/codingcode/test/context/append-turn-end.test.ts +++ b/packages/codingcode/test/context/append-turn-end.test.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'crypto'; import { estimateTokensForContent } from '../../src/context/tokens.js'; import { useTempProjectBase } from '../helpers/project-base.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: () => ({ context: { compactionModel: '', diff --git a/packages/codingcode/test/context/budget-integration.test.ts b/packages/codingcode/test/context/budget-integration.test.ts index f80f75a4..a04e56ca 100644 --- a/packages/codingcode/test/context/budget-integration.test.ts +++ b/packages/codingcode/test/context/budget-integration.test.ts @@ -1,28 +1,23 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; -import { join } from 'path'; +import { dirname } from 'path'; import { randomUUID } from 'crypto'; import { Effect, Layer } from 'effect'; 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/contracts/session.js'; +import { LLMService } from '../../src/llm/port.js'; +import type { SessionRef } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; -import { ContextLayer } from '../../src/context/context.js'; +import { ContextLayer, transcriptPathFor } from '../../src/context/context.js'; -const base = useTempProjectBase(); +useTempProjectBase(); const TestLayer = Layer.merge( SessionLayer, - Layer.succeed(LLMFactoryService, { - listModels: () => Effect.succeed([]), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new Error('no active model')), - switchModel: () => Effect.fail(new Error('no models')), - createClient: () => Effect.fail(new Error('no client')), - getLLMClient: () => Effect.fail(new Error('no client')), + Layer.succeed(LLMService, { + complete: () => Effect.fail(new Error('no llm')), + completeStream: () => (async function* () {})(), } as any) ); @@ -34,29 +29,27 @@ async function getCtxService(): Promise { ); } +const CWD = '/tmp/test'; + describe('assemblePayload integration', () => { - const projectSlug = randomUUID(); - let sessionId: string; - let sessionDir: string; - let jsonlPath: string; - let indexPath: string; + let ref: SessionRef; + let transcriptPath: string; beforeEach(() => { - sessionId = randomUUID(); - sessionDir = join(base.dir, projectSlug, 'sessions'); - mkdirSync(sessionDir, { recursive: true }); - jsonlPath = join(sessionDir, `${sessionId}.jsonl`); - indexPath = join(sessionDir, `${sessionId}.index.json`); + ref = { cwd: CWD, sessionId: randomUUID(), currentTurnId: 1 }; + transcriptPath = transcriptPathFor(ref); + mkdirSync(dirname(transcriptPath), { recursive: true }); const lines: any[] = [ { type: 'session_meta', - sessionId, - cwd: '/tmp/test', - + sessionId: ref.sessionId, + cwd: CWD, createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'q1' }, { @@ -83,41 +76,27 @@ describe('assemblePayload integration', () => { output: 'y'.repeat(200), }, ]; - writeFileSync(jsonlPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - - const idx = { - sessionId, - cwd: '/tmp/test', - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: lines.length, - title: 'fixture', - currentTurnId: 1, - usage: undefined, - permissionMode: 'default', - activeProfile: 'build', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); + writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); }); afterEach(() => { - const dir = join(base.dir, projectSlug); + const dir = dirname(transcriptPath); if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); }); it('returns messages assembled from the transcript', async () => { const ctx = await getCtxService(); - const messages = await ctx.assemblePayload(jsonlPath, 128000, null); + const messages = await Effect.runPromise(ctx.assemblePayload(ref, 'test-model')); expect(messages.length).toBeGreaterThan(0); }); it('returns an empty message list when the transcript is empty', async () => { - const emptyJsonl = join(sessionDir, `${sessionId}-empty.jsonl`); - writeFileSync(emptyJsonl, '', 'utf8'); + const emptyRef: SessionRef = { cwd: CWD, sessionId: `${ref.sessionId}-empty`, currentTurnId: 1 }; + const emptyPath = transcriptPathFor(emptyRef); + writeFileSync(emptyPath, '', 'utf8'); const ctx = await getCtxService(); - const messages = await ctx.assemblePayload(emptyJsonl, 128000, null); + const messages = await Effect.runPromise(ctx.assemblePayload(emptyRef, 'test-model')); expect(messages).toEqual([]); }); }); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index e05e900f..dd6ca4eb 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -1,44 +1,52 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; -import { join } from 'path'; +import { dirname } from 'path'; import { randomUUID } from 'crypto'; import { Effect, Layer } from 'effect'; import { ContextService } from '../../../src/context/port.js'; import type { ContextShape } from '../../../src/context/port.js'; import { SessionService } from '../../../src/session/port.js'; import { SessionLayer } from '../../../src/session/session.js'; -import { LLMFactoryService } from '../../../src/llm/port.js'; -import type { LLMClient } from '../../../src/contracts/provider.js'; -import type { SessionIndex, SessionEvent, SummaryEvent } from '../../../src/contracts/session.js'; -import { filterForContext, buildContextMessages } from '../../../src/context/context.js'; +import { LLMService } from '../../../src/llm/port.js'; +import type { SessionEvent, SessionRef, SummaryEvent } from '../../../src/contracts/session.js'; +import { filterForContext, buildContextMessages, transcriptPathFor } from '../../../src/context/context.js'; import { readHistory } from '../../../src/session/file-ops.js'; import { estimateTokens } from '../../../src/context/tokens.js'; import { useTempProjectBase } from '../../helpers/project-base.js'; import { ContextLayer } from '../../../src/context/context.js'; -const base = useTempProjectBase(); +// 上下文窗口现在由 catalog 按模型值现取,测试里钉死成一个可控值 +const windowState = vi.hoisted(() => ({ value: 128000 })); +vi.mock('../../../src/infra/models.js', () => ({ + contextWindowOf: () => windowState.value, +})); + +useTempProjectBase(); interface FixtureOptions { numTurns: number; toolContentSize?: number; toolName?: string; - currentTurnId?: number; } +const CWD = '/tmp/test'; + function makeFixture(opts: FixtureOptions) { const sessionId = randomUUID(); - const slug = randomUUID(); - const dir = join(base.dir, slug, 'sessions'); - mkdirSync(dir, { recursive: true }); - const transcriptPath = join(dir, `${sessionId}.jsonl`); - const indexPath = join(dir, `${sessionId}.index.json`); + const ref: SessionRef = { cwd: CWD, sessionId, currentTurnId: opts.numTurns }; + const transcriptPath = transcriptPathFor(ref); + mkdirSync(dirname(transcriptPath), { recursive: true }); const lines: any[] = [ { type: 'session_meta', sessionId, - cwd: '/tmp/test', + cwd: CWD, createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, ]; @@ -66,26 +74,10 @@ function makeFixture(opts: FixtureOptions) { writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd: '/tmp/test', - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: opts.numTurns * 3, - title: 'fixture', - currentTurnId: opts.currentTurnId ?? opts.numTurns, - usage: undefined, - activeProfile: 'build', - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { sessionId, slug, dir, transcriptPath, indexPath }; + return { ref, sessionId, dir: dirname(transcriptPath), transcriptPath }; } -function cleanup(slug: string) { - const dir = join(base.dir, slug); +function cleanup(dir: string) { if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); } @@ -98,7 +90,7 @@ function readSummaryEvents(jsonlPath: string): SummaryEvent[] { .filter((ev): ev is SummaryEvent => ev.type === 'summary'); } -function makeMockLLM(content: string): LLMClient { +function makeMockLLM(content: string) { return { complete: () => Effect.succeed({ content }), completeStream: () => @@ -106,36 +98,29 @@ function makeMockLLM(content: string): LLMClient { yield { type: 'text' as const, text: content }; yield { type: 'end' as const }; })(), - modelInfo: { - provider: 'mock', - model: 'mock', - maxTokens: 1000, - supportsToolCalling: false, - supportsStreaming: true, - }, - }; + } as any; +} + +const FailingLLM = { + complete: () => Effect.fail(new Error('no llm')), + completeStream: () => (async function* () {})(), +} as any; + +function makeTestLayer(llm: unknown) { + return Layer.merge(SessionLayer, Layer.succeed(LLMService, llm as any)); } -const TestLayer = Layer.merge( - SessionLayer, - Layer.succeed(LLMFactoryService, { - listModels: () => Effect.succeed([]), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new Error('no active model')), - switchModel: () => Effect.fail(new Error('no models')), - createClient: () => Effect.fail(new Error('no client')), - getLLMClient: () => Effect.fail(new Error('no client')), - } as any) -); - -async function getCtxService(): Promise { +async function getCtxService(llm: unknown): Promise { return Effect.runPromise( Effect.gen(function* () { return yield* ContextService; - }).pipe(Effect.provide(ContextLayer), Effect.provide(TestLayer)) + }).pipe(Effect.provide(ContextLayer), Effect.provide(makeTestLayer(llm))) ); } +/** ContextShape 现在返回 Effect,测试统一用 runPromise 驱动 */ +const run = (eff: Effect.Effect) => Effect.runPromise(eff); + describe('compressor behavior', () => { describe('L5 compaction', () => { it('writes summary event with five-section system summary', async () => { @@ -143,29 +128,32 @@ describe('compressor behavior', () => { try { const summary = '## Compacted History\n\n### Goal\nfix bug\n\n### Instructions\nbe careful\n\n### Discoveries\nrace condition\n\n### Accomplished\npatched\n\n### Relevant Files\nsrc/x.ts'; - const llm = makeMockLLM(summary); - const ctx = await getCtxService(); - await ctx.compactWithLLM(fx.transcriptPath, llm.modelInfo.maxTokens, llm); + windowState.value = 1000; + const ctx = await getCtxService(makeMockLLM(summary)); + await run(ctx.compactWithLLM(fx.ref, 'test-model')); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries.length).toBe(1); expect(summaries[0]!.summaryText).toContain('### Goal'); + // 全量替换:模型原文即摘要,不做任何抽取或裁剪 + expect(summaries[0]!.summaryText).toBe(summary); expect(summaries[0]!.startTurnId).toBeLessThanOrEqual(summaries[0]!.endTurnId); expect(summaries[0]!.endTurnId).toBeGreaterThan(0); } finally { - cleanup(fx.slug); + cleanup(fx.dir); } }); it('returns no-op when no LLM available', async () => { const fx = makeFixture({ numTurns: 5 }); try { - const ctx = await getCtxService(); - const result = await ctx.compactWithLLM(fx.transcriptPath, 1000, null); + windowState.value = 1000; + const ctx = await getCtxService(FailingLLM); + const result = await run(ctx.compactWithLLM(fx.ref, 'test-model')); expect(result.didCompress).toBe(false); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries).toHaveLength(0); } finally { - cleanup(fx.slug); + cleanup(fx.dir); } }); }); @@ -174,18 +162,20 @@ describe('compressor behavior', () => { it('appends summary event directly to JSONL after L5', async () => { const fx = makeFixture({ numTurns: 5 }); try { - const llm = makeMockLLM( - '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' + windowState.value = 1000; + const ctx = await getCtxService( + makeMockLLM( + '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' + ) ); - const ctx = await getCtxService(); - await ctx.compactWithLLM(fx.transcriptPath, llm.modelInfo.maxTokens, llm); + await run(ctx.compactWithLLM(fx.ref, 'test-model')); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries).toHaveLength(1); expect(summaries[0]!.startTurnId).toBeLessThanOrEqual(summaries[0]!.endTurnId); expect(summaries[0]!.endTurnId).toBeGreaterThan(0); } finally { - cleanup(fx.slug); + cleanup(fx.dir); } }); }); @@ -198,17 +188,19 @@ describe('compressor behavior', () => { readHistory(fx.transcriptPath) ); const before = estimateTokens(buildContextMessages(bVisible, bCompacted)); - const llm = makeMockLLM( - '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' + windowState.value = 1000; + const ctx = await getCtxService( + makeMockLLM( + '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' + ) ); - const ctx = await getCtxService(); - const result = await ctx.compactWithLLM(fx.transcriptPath, llm.modelInfo.maxTokens, llm); + const result = await run(ctx.compactWithLLM(fx.ref, 'test-model')); expect(result.didCompress).toBe(true); expect(result.promptEstimate).toBeGreaterThan(0); expect(result.promptEstimate).toBeLessThan(before); expect(result.released).toBeGreaterThan(0); } finally { - cleanup(fx.slug); + cleanup(fx.dir); } }); }); @@ -220,27 +212,25 @@ describe('compressor behavior', () => { it('folds history into a compacted summary message when it exceeds the window', async () => { const fx = makeFixture({ numTurns: 3, toolContentSize: 8000 }); try { - const ctx = await getCtxService(); - const messages = await ctx.assemblePayload(fx.transcriptPath, 1000, makeMockLLM(SUMMARY)); + windowState.value = 1000; + const ctx = await getCtxService(makeMockLLM(SUMMARY)); + const messages = await run(ctx.assemblePayload(fx.ref, 'test-model')); expect(messages.length).toBeGreaterThan(0); expect(messages.some((m) => m.name === 'compacted_history')).toBe(true); } finally { - cleanup(fx.slug); + cleanup(fx.dir); } }); it('leaves history uncompacted when it fits the window', async () => { const fx = makeFixture({ numTurns: 2, toolContentSize: 20 }); try { - const ctx = await getCtxService(); - const messages = await ctx.assemblePayload( - fx.transcriptPath, - 2_000_000, - makeMockLLM(SUMMARY) - ); + windowState.value = 2_000_000; + const ctx = await getCtxService(makeMockLLM(SUMMARY)); + const messages = await run(ctx.assemblePayload(fx.ref, 'test-model')); expect(messages.some((m) => m.name === 'compacted_history')).toBe(false); } finally { - cleanup(fx.slug); + cleanup(fx.dir); } }); }); diff --git a/packages/codingcode/test/context/compressor/l5-prompt.test.ts b/packages/codingcode/test/context/compressor/l5-prompt.test.ts index c5c232a7..e076353f 100644 --- a/packages/codingcode/test/context/compressor/l5-prompt.test.ts +++ b/packages/codingcode/test/context/compressor/l5-prompt.test.ts @@ -1,83 +1,35 @@ import { describe, it, expect } from 'vitest'; - -describe('L5 compaction prompt and extraction', () => { - it('should extract summary from dual-tag format', () => { - const raw = ` -This is analysis section with reasoning. -Multiple lines of thinking. - - - -## 1. Primary Request and Intent -User wants to add feature X. - -## 2. Key Technical Concepts -- Concept A -- Concept B - -## 9. Optional Next Step -Consider optimizing Y next. - - -Some trailing text that should be ignored.`; - - const match = raw.match(/([\s\S]*?)<\/summary>/); - expect(match).toBeDefined(); - expect(match?.[1]).toContain('Primary Request'); - expect(match?.[1]).toContain('Optional Next Step'); +import { COMPACTION_SYSTEM_PROMPT } from '../../../src/context/compaction-prompt.js'; + +// 断言直接读真实提示词常量,避免测试与实现各写一份而悄悄漂移。 +describe('L5 compaction prompt contract', () => { + const SECTIONS = [ + '## 1. Primary Request and Intent', + '## 2. Key Technical Concepts', + '## 3. Files and Code Sections', + '## 4. Errors and Fixes', + '## 5. Problem Solving', + '## 6. Decision Rationale and Rejected Approaches', + '## 7. All User Messages', + '## 8. Pending Tasks', + '## 9. Current Work', + '## 10. Optional Next Step', + ]; + + it('requests all ten sections in order', () => { + let cursor = -1; + for (const section of SECTIONS) { + const at = COMPACTION_SYSTEM_PROMPT.indexOf(section); + expect(at, `missing or out-of-order section: ${section}`).toBeGreaterThan(cursor); + cursor = at; + } }); - it('should fallback to raw content if no summary tags', () => { - const raw = 'Just raw content without tags'; - const match = raw.match(/([\s\S]*?)<\/summary>/); - const result = match ? match[1] : raw; - expect(result).toBe(raw); + it('no longer asks for a separate analysis block', () => { + expect(COMPACTION_SYSTEM_PROMPT).not.toMatch(/<\/?analysis>/); }); - it('should validate 9 sections are present in summary structure', () => { - const sections = [ - '## 1. Primary Request and Intent', - '## 2. Key Technical Concepts', - '## 3. Files and Code Sections', - '## 4. Errors and Fixes', - '## 5. Problem Solving', - '## 6. All User Messages', - '## 7. Pending Tasks', - '## 8. Current Work', - '## 9. Optional Next Step', - ]; - - sections.forEach((section) => { - expect(section).toMatch(/^##\s+\d+\./); - }); - expect(sections).toHaveLength(9); - }); - - it('should separate analysis from summary', () => { - const full = ` -Reasoning and thinking process here. -This is how I approached the problem. - - - -The final structured output. -`; - - const analysisMatch = full.match(/([\s\S]*?)<\/analysis>/); - const summaryMatch = full.match(/([\s\S]*?)<\/summary>/); - - expect(analysisMatch?.[1]).toContain('Reasoning'); - expect(summaryMatch?.[1]).toContain('final structured'); - expect(analysisMatch?.[1]).not.toContain('final structured'); - }); - - it('should handle empty sections gracefully', () => { - const raw = ` - -## 1. Primary Request and Intent -`; - - const match = raw.match(/([\s\S]*?)<\/summary>/); - expect(match?.[1]).toBeDefined(); + it('no longer wraps the summary in tags', () => { + expect(COMPACTION_SYSTEM_PROMPT).not.toMatch(/<\/?summary>/); }); }); diff --git a/packages/codingcode/test/context/compressor/llm-resolver.test.ts b/packages/codingcode/test/context/compressor/llm-resolver.test.ts deleted file mode 100644 index 0275977c..00000000 --- a/packages/codingcode/test/context/compressor/llm-resolver.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { Effect } from 'effect'; -import { LLMFactoryService } from '../../../src/llm/port.js'; -import { AgentError } from '../../../src/core/error.js'; -import type { LLMClient } from '../../../src/contracts/provider.js'; -import type { SelectableModel } from '../../../src/contracts/provider.js'; - -const { mockFindModel, mockCreateClient } = vi.hoisted(() => ({ - mockFindModel: vi.fn(() => Effect.succeed(null)), - mockCreateClient: vi.fn(() => Effect.succeed(null)), -})); - -const mockFactory = { - listModels: () => Effect.succeed([]), - findModel: mockFindModel, - getActiveEntry: () => Effect.fail(new AgentError('CONFIG_INVALID', 'no active entry')), - switchModel: (_id: string) => Effect.fail(new AgentError('CONFIG_INVALID', 'not found')), - createClient: mockCreateClient, - getLLMClient: () => Effect.fail(new AgentError('CONFIG_INVALID', 'no client')), -}; - -import { resolveLLM } from '../../../src/llm/llm-resolver.js'; - -const fakeFallback: LLMClient = { - complete: () => Effect.succeed({ content: '' }), - completeStream: () => - (async function* () { - yield { type: 'end' as const }; - })(), - modelInfo: { - provider: 'fake', - model: 'fake', - maxTokens: 1, - supportsToolCalling: false, - supportsStreaming: false, - }, -}; - -async function runResolveLLM(target: string | null | undefined, fallback: LLMClient | null) { - return Effect.runPromise( - resolveLLM(target, fallback).pipe(Effect.provideService(LLMFactoryService, mockFactory as any)) - ); -} - -describe('resolveLLM (compaction)', () => { - afterEach(() => { - vi.resetAllMocks(); - mockFindModel.mockReturnValue(Effect.succeed(null)); - mockCreateClient.mockReturnValue(Effect.succeed(null)); - }); - - it('returns fallback when target is empty', async () => { - const result = await runResolveLLM('', fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns fallback when target is whitespace-only', async () => { - const result = await runResolveLLM(' ', fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns fallback when target is null', async () => { - const result = await runResolveLLM(null, fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns fallback when target is undefined', async () => { - const result = await runResolveLLM(undefined, fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns null when target empty and fallback is null', async () => { - const result = await runResolveLLM('', null); - expect(result).toBeNull(); - }); - - it('returns fallback when model not found', async () => { - mockFindModel.mockReturnValue(Effect.succeed(null)); - const result = await runResolveLLM('definitely-not-a-real-model-xyz', fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns fallback when createClient throws', async () => { - mockFindModel.mockReturnValue(Effect.succeed({ id: 'test-model' } as SelectableModel) as any); - mockCreateClient.mockReturnValue( - Effect.fail(new AgentError('CONFIG_MISSING', 'creation failed')) as any - ); - const result = await runResolveLLM('test-model', fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns fallback when createClient returns error', async () => { - mockFindModel.mockReturnValue(Effect.succeed({ id: 'test-model' } as SelectableModel) as any); - mockCreateClient.mockReturnValue(Effect.fail(new AgentError('CONFIG_INVALID', 'error')) as any); - const result = await runResolveLLM('test-model', fakeFallback); - expect(result).toBe(fakeFallback); - }); - - it('returns created client on success', async () => { - const client = { modelInfo: { maxTokens: 100 } } as LLMClient; - mockFindModel.mockReturnValue(Effect.succeed({ id: 'test-model' } as SelectableModel) as any); - mockCreateClient.mockReturnValue(Effect.succeed(client) as any); - const result = await runResolveLLM('test-model', fakeFallback); - expect(result).toBe(client); - }); -}); diff --git a/packages/codingcode/test/context/organizer.test.ts b/packages/codingcode/test/context/organizer.test.ts index f1373131..c712c4bb 100644 --- a/packages/codingcode/test/context/organizer.test.ts +++ b/packages/codingcode/test/context/organizer.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from 'effect'; import { ContextService } from '../../src/context/port.js'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; +import { LLMService } from '../../src/llm/port.js'; import type { SessionEvent, ToolResultEvent } from '../../src/contracts/session.js'; import { ContextLayer } from '../../src/context/context.js'; @@ -41,13 +41,9 @@ function makeToolResult( const TestLayer = Layer.merge( SessionLayer, - Layer.succeed(LLMFactoryService, { - listModels: () => Effect.succeed([]), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new Error('no active model')), - switchModel: () => Effect.fail(new Error('no models')), - createClient: () => Effect.fail(new Error('no client')), - getLLMClient: () => Effect.fail(new Error('no client')), + Layer.succeed(LLMService, { + complete: () => Effect.fail(new Error('no llm')), + completeStream: () => (async function* () {})(), } as any) ); diff --git a/packages/codingcode/test/context/transcript-path-agreement.test.ts b/packages/codingcode/test/context/transcript-path-agreement.test.ts new file mode 100644 index 00000000..1ea7b470 --- /dev/null +++ b/packages/codingcode/test/context/transcript-path-agreement.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { randomUUID } from 'crypto'; +import { computePaths } from '../../src/session/paths.js'; +import { transcriptPathFor } from '../../src/context/context.js'; +import type { SessionRef } from '../../src/contracts/session.js'; + +/** + * context 与 session 各自「用 core/path 的纯函数格式化入参、再用 contracts 常量拼接」。 + * 两处实现必须产出同一个字符串,否则 context 会去读一个不存在的文件。 + * 这条断言就是防漂移的护栏。 + */ +describe('会话转录路径:context 与 session 各自拼接的结果必须一致', () => { + const cases: Array<{ cwd: string; parentSessionId?: string }> = [ + { cwd: 'c:/Users/me/proj' }, + { cwd: '/home/me/proj' }, + { cwd: 'C:\\Users\\me\\my proj' }, + { cwd: '/home/me/proj', parentSessionId: 'parent-fixed-id' }, + ]; + + for (const { cwd, parentSessionId } of cases) { + it(`cwd=${cwd} parent=${parentSessionId ?? '(none)'}`, () => { + const sessionId = randomUUID(); + const ref: SessionRef = { cwd, sessionId, parentSessionId, currentTurnId: 0 }; + + expect(transcriptPathFor(ref)).toBe( + computePaths(cwd, sessionId, parentSessionId).transcriptPath + ); + }); + } +}); diff --git a/packages/codingcode/test/core/error.test.ts b/packages/codingcode/test/core/error.test.ts index 7e4bd280..8aa5bf5b 100644 --- a/packages/codingcode/test/core/error.test.ts +++ b/packages/codingcode/test/core/error.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest'; import { AgentError } from '../../src/core/error.js'; -import { ApiError } from '../../src/contracts/error.js'; describe('AgentError.httpStatus', () => { it('returns 400 for CONFIG_MISSING', () => { @@ -38,23 +37,3 @@ describe('AgentError.httpStatus', () => { expect(err.httpStatus()).toBe(500); }); }); - -describe('ApiError', () => { - it('formats message from body.message when provided', () => { - const err = new ApiError(404, '/api/agent/permission-mode', { - code: 'NOT_FOUND', - message: 'gone', - }); - expect(err.message).toBe('gone'); - expect(err.status).toBe(404); - expect(err.path).toBe('/api/agent/permission-mode'); - expect(err.body?.code).toBe('NOT_FOUND'); - expect(err.name).toBe('ApiError'); - }); - - it('falls back to "HTTP : " when body missing', () => { - const err = new ApiError(500, '/x'); - expect(err.message).toBe('HTTP 500: /x'); - expect(err.body).toBeUndefined(); - }); -}); diff --git a/packages/codingcode/test/core/frame-io.test.ts b/packages/codingcode/test/core/frame-io.test.ts index 46958f8e..64ab3e35 100644 --- a/packages/codingcode/test/core/frame-io.test.ts +++ b/packages/codingcode/test/core/frame-io.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createFrameAssembler, decodeFrame, encodeFrame } from '../../src/contracts/frame-io.js'; +import { createFrameAssembler } from '../../src/contracts/frame-io.js'; import type { FrameBody } from '../../src/contracts/frame.js'; // ---- body builders ---- @@ -37,94 +37,3 @@ describe('createFrameAssembler — envelope', () => { expect(a.stamp(start(2))).toMatchObject({ seq: 2, turnId: 2 }); }); }); - -// ---- codec ---- - -describe('frame codec', () => { - it('round-trips an assembled frame through encode/decode', () => { - const f = createFrameAssembler({ sessionId: 's' }); - const stamped = f.stamp(start(3)); - const decoded = decodeFrame(JSON.parse(encodeFrame(stamped))); - expect(decoded.ok).toBe(true); - if (decoded.ok) expect(decoded.frame).toEqual(stamped); - }); - - it('rejects a non-object payload', () => { - expect(decodeFrame('nope')).toMatchObject({ ok: false, reason: 'shape' }); - }); - - it('rejects a payload missing envelope fields', () => { - expect(decodeFrame({ family: 'event' })).toMatchObject({ ok: false, reason: 'shape' }); - }); - - it('rejects a non-numeric, non-null turnId', () => { - expect( - decodeFrame({ sessionId: 's', turnId: 'x', seq: 1, family: 'fatal', fatal: { message: 'm', code: 'c' } }) - ).toMatchObject({ ok: false, reason: 'shape' }); - }); - - it('rejects an unknown family', () => { - expect(decodeFrame({ sessionId: 's', turnId: null, seq: 1, family: 'nope' })).toMatchObject({ - ok: false, - reason: 'unknown-family', - }); - }); - - it('rejects an unknown transition target', () => { - expect( - decodeFrame({ sessionId: 's', turnId: 1, seq: 1, family: 'transition', transition: { to: 'zzz' } }) - ).toMatchObject({ ok: false, reason: 'unknown-transition' }); - }); - - it('accepts a compress frame', () => { - const decoded = decodeFrame({ - sessionId: 's', - turnId: 1, - seq: 1, - family: 'transition', - transition: { to: 'compress' }, - }); - expect(decoded.ok).toBe(true); - }); - - it('rejects an unknown event type', () => { - expect( - decodeFrame({ sessionId: 's', turnId: 1, seq: 1, family: 'event', event: { type: 'zzz' } }) - ).toMatchObject({ ok: false, reason: 'unknown-event' }); - }); - - it('rejects an end(error) frame without a well-formed error', () => { - expect( - decodeFrame({ - sessionId: 's', - turnId: 1, - seq: 1, - family: 'transition', - transition: { to: 'end', reason: 'error' }, - }) - ).toMatchObject({ ok: false, reason: 'shape' }); - }); - - it('rejects a tool_result with a malformed outcome', () => { - expect( - decodeFrame({ - sessionId: 's', - turnId: 1, - seq: 1, - family: 'event', - event: { type: 'tool_result', id: 't1', name: 'bash', outcome: { status: 'ok' } }, - }) - ).toMatchObject({ ok: false, reason: 'shape' }); - }); - - it('accepts a denied outcome carrying a reason', () => { - const decoded = decodeFrame({ - sessionId: 's', - turnId: 1, - seq: 1, - family: 'event', - event: { type: 'tool_result', id: 't1', name: 'bash', outcome: { status: 'denied', reason: 'no' } }, - }); - expect(decoded.ok).toBe(true); - }); -}); diff --git a/packages/codingcode/test/core/path.test.ts b/packages/codingcode/test/core/path.test.ts index ccb47039..33db2804 100644 --- a/packages/codingcode/test/core/path.test.ts +++ b/packages/codingcode/test/core/path.test.ts @@ -1,21 +1,16 @@ import { describe, it, expect } from 'vitest'; -import { join, resolve } from 'path'; +import { join } from 'path'; import { homedir, tmpdir } from 'os'; import { normalizePath, encodeProjectPath, - resolveCwd, - getTempCwd, - isGlobalCwd, CODINGCODE_DIRNAME, getGlobalDir, - getProjectDir, } from '../../src/core/path.js'; -import { CODINGCODE_DIRNAME as INFRA_DIRNAME } from '@codingcode/infra/paths'; import { ShadowGit } from '../../src/checkpoint/shadow-git.js'; import { setFakeHome, restoreHome } from '../helpers/temp-home.js'; -describe('core/path', () => { +describe('core/path:纯格式化函数', () => { it('normalizePath unifies Windows path variants', () => { expect(normalizePath('C:\\Users\\proj')).toBe('c:/Users/proj'); expect(normalizePath('/c/Users/proj')).toBe('c:/Users/proj'); @@ -44,41 +39,17 @@ describe('core/path', () => { const sg = new ShadowGit(path); expect(sg.gitDir).toContain(encodeProjectPath(path)); }); - - it('resolveCwd uses the request cwd when present', () => { - const otherDir = join(tmpdir(), 'cc-other'); - expect(resolveCwd(otherDir)).toBe(resolve(otherDir)); - }); - - it('resolveCwd falls back to the shared temp workspace', () => { - expect(resolveCwd()).toBe(getTempCwd()); - expect(resolveCwd('')).toBe(getTempCwd()); - expect(getTempCwd()).not.toBe(process.cwd()); - }); - - it('isGlobalCwd treats missing, empty and "global" as global', () => { - expect(isGlobalCwd(undefined)).toBe(true); - expect(isGlobalCwd('')).toBe(true); - expect(isGlobalCwd('global')).toBe(true); - expect(isGlobalCwd('/some/project')).toBe(false); - }); }); -describe('core/path:全局与工作区共用的私有目录名', () => { - it('目录名字面量被钉住:改它必须显式改这里(含 infra 那份副本)', () => { +describe('core/path:唯一被允许的常量', () => { + it('目录名字面量被钉住:改它必须显式改这里', () => { expect(CODINGCODE_DIRNAME).toBe('.codingcode'); - // infra 是独立 workspace 包、位于更低层,无法 import codingcode,只能各持一份 - expect(INFRA_DIRNAME).toBe(CODINGCODE_DIRNAME); }); it('全局目录落在用户目录下', () => { expect(getGlobalDir()).toBe(join(homedir(), '.codingcode')); }); - it('工作区目录落在 projectRoot 下', () => { - expect(getProjectDir('/some/project')).toBe(join('/some/project', '.codingcode')); - }); - it('每次调用时求值:把 home 指到临时目录立刻生效(测试隔离依赖这一点)', () => { const fake = join(tmpdir(), `codingcode-dirs-${Date.now()}`); const prev = setFakeHome(fake); diff --git a/packages/codingcode/test/helpers/agent-harness.ts b/packages/codingcode/test/helpers/agent-harness.ts index 515290ab..dc0495a9 100644 --- a/packages/codingcode/test/helpers/agent-harness.ts +++ b/packages/codingcode/test/helpers/agent-harness.ts @@ -1,8 +1,3 @@ -// Agent 循环测试基座:通过公开的 AgentService.runTurn 驱动 agent, -// 替代已删除的 agentLoop 自由函数。agent 依赖均以宽服务 mock 注入。 -// -// 自 frame 协议重构后,runTurn 产出 FrameBody(信封由装配器另盖), -// 本文件同时提供从 FrameBody[] 中抽取内容的纯函数,供各测试断言使用。 import { Effect, Layer } from 'effect'; import { AgentLayer } from '../../src/agent/agent.js'; import { ToolEnvLayer } from '../../src/agent/tool-env.js'; @@ -12,7 +7,7 @@ import { ApprovalService } from '../../src/approval/port.js'; import { CheckpointService } from '../../src/checkpoint/port.js'; import { ContextService } from '../../src/context/port.js'; import { HookService } from '../../src/hooks/port.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; +import { LLMService } from '../../src/llm/port.js'; import { McpService } from '../../src/mcp/port.js'; import { MemoryService } from '../../src/memory/port.js'; import { RulesService } from '../../src/rules/port.js'; @@ -43,7 +38,7 @@ export function pToolCall( name: string, args: Record = {} ): LLMStreamPart { - return { type: 'tool_call', id, name, args }; + return { type: 'tool_call', id, name, arguments: args }; } export function pEnd(usage?: TokenUsage): LLMStreamPart { @@ -154,22 +149,22 @@ export interface HarnessMocks { export function makeState(partial: Partial = {}): SessionStoreState { return { + type: 'session_meta', sessionId: 'test-sid', cwd: '/tmp', - messageCount: 0, - sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, + createdAt: new Date().toISOString(), model: 'test-model', title: 'test', - currentTurnId: 1, - usage: undefined, activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', + currentTurnId: 1, memorySnapshot: '', + usage: undefined, ...partial, } as SessionStoreState; } -export function makeDefaultMocks(overrides: Partial = {}): HarnessMocks { +export function makeHarnessMocks(overrides: Partial = {}): HarnessMocks { const llm = overrides.llm ?? ({ @@ -199,6 +194,7 @@ export interface RunAgentOptions { signal?: AbortSignal; activeProfile?: 'plan' | 'build'; permissionMode?: string; + model?: string; } export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { @@ -244,13 +240,16 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), }; const context = { - willCompact: async () => (mocks.contextWillCompact ? mocks.contextWillCompact() : false), - assemblePayload: async () => - mocks.contextAssemble ? mocks.contextAssemble() : [{ role: 'user' as const, content: 'hi' }], + willCompact: () => + Effect.promise(async () => (mocks.contextWillCompact ? mocks.contextWillCompact() : false)), + assemblePayload: () => + Effect.promise(async () => + mocks.contextAssemble ? mocks.contextAssemble() : [{ role: 'user' as const, content: 'hi' }] + ), }; const memory = { - loadMemoryForPrompt: () => mocks.memorySnapshot ?? '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + loadMemoryForPrompt: () => Effect.succeed(mocks.memorySnapshot ?? ''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), }; const mcpLayer = Layer.succeed(McpService, { @@ -271,10 +270,14 @@ export function makeAgentLayer(mocks: HarnessMocks): Layer.Layer { Layer.succeed(SkillService, skills as any), Layer.succeed(ContextService, context as any), Layer.succeed(MemoryService, memory as any), - Layer.succeed(LLMFactoryService, { getLLMClient: () => Effect.succeed(mocks.llm) } as any), + Layer.succeed(LLMService, { + complete: () => Effect.fail(new Error('complete not implemented in harness')), + completeStream: (params: any, model: string, signal?: AbortSignal) => + mocks.llm.completeStream(params, model, signal), + } as any), Layer.succeed(RulesService, { - getAllRules: () => '', - evictProjectRules: () => {}, + getAllRules: () => Effect.succeed(''), + evictProjectRules: () => Effect.void, } as any), // agent 与 todo_write 工具消费同一个 TodoService Layer.succeed(TodoService, { @@ -306,8 +309,8 @@ function tick(): Promise { // 一口气跑完导致队列事件被丢弃。 function paceLlm(llm: any): any { const completeStream = llm.completeStream.bind(llm); - llm.completeStream = (params: any, signal?: AbortSignal) => { - const raw = completeStream(params, signal) as AsyncIterable; + llm.completeStream = (params: any, model: string, signal?: AbortSignal) => { + const raw = completeStream(params, model, signal) as AsyncIterable; return (async function* () { for await (const part of raw) { yield part; @@ -327,7 +330,7 @@ export async function runAgentTurn( const appLayer = Layer.mergeAll(services, AgentLayer.pipe(Layer.provide(services))) as any; const program = Effect.gen(function* () { const agent = yield* AgentService; - const runOpts: any = { cwd: opts.cwd ?? '/tmp' }; + const runOpts: any = { cwd: opts.cwd ?? '/tmp', model: opts.model ?? 'test-model' }; if (opts.sessionId) runOpts.sessionId = opts.sessionId; if (opts.signal) runOpts.signal = opts.signal; if (opts.activeProfile) runOpts.activeProfile = opts.activeProfile; diff --git a/packages/codingcode/test/helpers/project-base.ts b/packages/codingcode/test/helpers/project-base.ts index cc1ac9a8..7ac9fb99 100644 --- a/packages/codingcode/test/helpers/project-base.ts +++ b/packages/codingcode/test/helpers/project-base.ts @@ -1,26 +1,28 @@ import { mkdirSync } from 'fs'; +import { join } from 'path'; import { beforeEach } from 'vitest'; -import { getProjectBaseDir } from '../../src/core/path.js'; +import { getGlobalDir } from '../../src/core/path.js'; +import { PROJECTS_DIRNAME } from '../../src/contracts/paths.js'; import { useTempHome } from './temp-home.js'; export interface TempProjectBase { readonly dir: string; } -/** - * 把 `getProjectBaseDir()`(`~/.codingcode/project`)关进临时目录。 - * - * 它本身没有独立开关(应用不提供「可指定」入口),只能靠把进程 home 指到沙箱, - * 因此本助手 = `useTempHome` + 建出 project 目录。 - */ +/** 工作区数据的公共根:`~/.codingcode/project`。 */ +export function projectBaseDir(): string { + return join(getGlobalDir(), PROJECTS_DIRNAME); +} + + export function useTempProjectBase(prefix = 'codingcode-test-project-base-'): TempProjectBase { useTempHome(prefix); beforeEach(() => { - mkdirSync(getProjectBaseDir(), { recursive: true }); + mkdirSync(projectBaseDir(), { recursive: true }); }); return { get dir() { - return getProjectBaseDir(); + return projectBaseDir(); }, }; } diff --git a/packages/infra/test/config.test.ts b/packages/codingcode/test/infra/config.test.ts similarity index 99% rename from packages/infra/test/config.test.ts rename to packages/codingcode/test/infra/config.test.ts index f3108fe7..6ef034dd 100644 --- a/packages/infra/test/config.test.ts +++ b/packages/codingcode/test/infra/config.test.ts @@ -8,7 +8,7 @@ import { updateMaxStopContinuations, updateContextCompactionModel, updateMemoryModel, -} from '../src/config.js'; +} from '../../src/infra/config.js'; const TMP_DIR = resolve(__dirname, '..', '..', '.tmp-test-config'); diff --git a/packages/infra/test/logger.test.ts b/packages/codingcode/test/infra/logger.test.ts similarity index 92% rename from packages/infra/test/logger.test.ts rename to packages/codingcode/test/infra/logger.test.ts index 1713c323..54a25fe4 100644 --- a/packages/infra/test/logger.test.ts +++ b/packages/codingcode/test/infra/logger.test.ts @@ -46,7 +46,7 @@ describe('createLogger', () => { it('returns sync logger without transport in Electron', async () => { setVersions({ ...process.versions, electron: '35.0.0' }); - const { createLogger } = await import('../src/logger.js'); + const { createLogger } = await import('../../src/infra/logger.js'); const logger = createLogger('debug'); expect(logger).toBeDefined(); @@ -61,7 +61,7 @@ describe('createLogger', () => { setVersions(versions); process.env.NODE_ENV = 'development'; - const { createLogger } = await import('../src/logger.js'); + const { createLogger } = await import('../../src/infra/logger.js'); const logger = createLogger('info'); expect(logger).toBeDefined(); @@ -76,7 +76,7 @@ describe('createLogger', () => { setVersions(versions); process.env.NODE_ENV = 'production'; - const { createLogger } = await import('../src/logger.js'); + const { createLogger } = await import('../../src/infra/logger.js'); const logger = createLogger('warn'); expect(logger).toBeDefined(); diff --git a/packages/codingcode/test/infra/models.test.ts b/packages/codingcode/test/infra/models.test.ts new file mode 100644 index 00000000..bf36ffc0 --- /dev/null +++ b/packages/codingcode/test/infra/models.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Effect } from 'effect'; + +const mockCatalog = { + providers: [ + { + name: 'provider-a', + driver: 'openai', + base_url: 'https://api.a.com', + api_key_env: 'API_KEY_A', + default_model: 'model-x', + models: [ + { id: 'model-x', name: 'Model X', context_window: 32000 }, + { id: 'model-y', name: 'Model Y' }, + ], + }, + { + name: 'provider-b', + driver: 'deepseek', + base_url: 'https://api.b.com', + api_key_env: 'API_KEY_B', + default_model: 'model-x', + models: [{ id: 'model-x', name: 'Model X' }], + }, + ], +}; + +function mockFs() { + vi.doMock('fs', async (importOriginal: any) => { + const orig = await importOriginal(); + return { + ...orig, + existsSync: (p: string) => (p.includes('models.json') ? true : orig.existsSync(p)), + readFileSync: (p: string, enc?: any) => + p.includes('models.json') ? JSON.stringify(mockCatalog) : orig.readFileSync(p, enc), + }; + }); +} + +/** catalog.ts 的 activeModel 来自 loadConfig(),与 updateActiveModel 同模块,一并桩掉。 */ +function mockActiveModel( + activeModel: { model: string; apiKeyEnv: string } | undefined, + extra: Record = {} +) { + vi.doMock('../../src/infra/config.js', async (importOriginal: any) => { + const orig = await importOriginal(); + return { ...orig, loadConfig: () => ({ activeModel }), ...extra }; + }); +} + +/** 消费完整条流;首个 part 前抛出的错误在此暴露 */ +async function drain(iterable: AsyncIterable): Promise { + for await (const _ of iterable) { + // 只是把迭代推进到实际调用点 + } +} + +/** + * 用真实的 LlmLayer 走一次 completeStream,只观察模型解析阶段的失败。 + * Tag 与 Layer 必须取自同一份 import 图,故两者都在调用点动态导入。 + */ +async function streamOnce(model: string) { + const { LLMService } = await import('../../src/llm/port.js'); + const { LlmLayer } = await import('../../src/llm/llm.js'); + return Effect.runPromise( + Effect.gen(function* () { + const llm = yield* LLMService; + yield* Effect.tryPromise({ + try: () => drain(llm.completeStream({ messages: [] }, model)), + catch: (e) => (e instanceof Error ? e : new Error(String(e))), + }); + }).pipe(Effect.provide(LlmLayer), Effect.either) + ); +} + +describe('catalog - listModels / findModel', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('flattens provider x model into {model}@{provider} ids', async () => { + mockFs(); + mockActiveModel(undefined); + const { listModels } = await import('../../src/infra/models.js'); + + const ids = listModels().map((m) => m.id); + expect(ids).toEqual([ + 'model-x@provider-a', + 'model-y@provider-a', + 'model-x@provider-b', + ]); + }); + + it('prefers the exact compound id over a bare-name match', async () => { + mockFs(); + mockActiveModel(undefined); + const { findModel } = await import('../../src/infra/models.js'); + + expect(findModel('model-x@provider-b')?.provider).toBe('provider-b'); + }); + + it('falls back to the first bare-name match when no exact id matches', async () => { + mockFs(); + mockActiveModel(undefined); + const { findModel } = await import('../../src/infra/models.js'); + + expect(findModel('model-x')?.id).toBe('model-x@provider-a'); + expect(findModel('Model Y')?.id).toBe('model-y@provider-a'); + expect(findModel('nope')).toBeNull(); + }); + + it('reports the configured context window, falling back to the default', async () => { + mockFs(); + mockActiveModel(undefined); + const { contextWindowOf } = await import('../../src/infra/models.js'); + + expect(contextWindowOf('model-x@provider-a')).toBe(32000); + expect(contextWindowOf('model-y@provider-a')).toBe(128000); + }); +}); + +describe('activeModel - config priority', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('resolves the configured activeModel to a catalog entry', async () => { + mockFs(); + mockActiveModel({ model: 'model-y', apiKeyEnv: 'API_KEY_A' }); + const { activeModelId } = await import('../../src/infra/models.js'); + + expect(activeModelId()).toBe('model-y@provider-a'); + }); + + it('returns an empty id when activeModel is not set in config', async () => { + mockFs(); + mockActiveModel(undefined); + const { activeModelId } = await import('../../src/infra/models.js'); + + expect(activeModelId()).toBe(''); + }); + + it('returns an empty id when activeModel matches no catalog entry', async () => { + mockFs(); + mockActiveModel({ model: 'nonexistent', apiKeyEnv: 'UNKNOWN_KEY' }); + const { activeModelId } = await import('../../src/infra/models.js'); + + expect(activeModelId()).toBe(''); + }); +}); + +describe('setGlobalActive - persists to config', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('writes model and api_key_env through updateActiveModel', async () => { + const updateActiveModel = vi.fn(); + mockFs(); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }, { updateActiveModel }); + const { setGlobalActive } = await import('../../src/infra/models.js'); + + setGlobalActive('model-y@provider-a'); + + expect(updateActiveModel).toHaveBeenCalledWith('model-y', 'API_KEY_A'); + }); + + it('throws CONFIG_INVALID and does not touch config when the id is unknown', async () => { + const updateActiveModel = vi.fn(); + mockFs(); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }, { updateActiveModel }); + const { setGlobalActive } = await import('../../src/infra/models.js'); + + expect(() => setGlobalActive('nonexistent@provider-a')).toThrow(/not found/); + expect(updateActiveModel).not.toHaveBeenCalled(); + }); +}); + +describe('LlmLayer.completeStream - model resolution', () => { + beforeEach(() => { + vi.resetModules(); + delete (process.env as any).API_KEY_A; + delete (process.env as any).OPENAI_API_KEY; + }); + + afterEach(() => { + delete (process.env as any).API_KEY_A; + delete (process.env as any).OPENAI_API_KEY; + }); + + it('fails with CONFIG_INVALID when the requested model is not in the catalog', async () => { + mockFs(); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }); + + const result = await streamOnce('nonexistent@provider-a'); + + expect(result._tag).toBe('Left'); + if (result._tag === 'Left') { + expect((result.left as any).code).toBe('CONFIG_INVALID'); + expect(result.left.message).toContain('nonexistent@provider-a'); + } + }); + + it('falls back to the configured activeModel when the requested model is empty', async () => { + mockFs(); + mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }); + + const result = await streamOnce(''); + + expect(result._tag).toBe('Left'); + if (result._tag === 'Left') { + // 已经越过模型解析,卡在缺 API key 这一步 + expect((result.left as any).code).toBe('CONFIG_MISSING'); + expect(result.left.message).toContain('API_KEY_A'); + } + }); + + it('fails with CONFIG_INVALID when no activeModel is configured', async () => { + mockFs(); + mockActiveModel(undefined); + + const result = await streamOnce(''); + + expect(result._tag).toBe('Left'); + if (result._tag === 'Left') { + expect((result.left as any).code).toBe('CONFIG_INVALID'); + expect(result.left.message).toContain('activeModel'); + } + }); +}); diff --git a/packages/infra/test/yaml-store.test.ts b/packages/codingcode/test/infra/yaml-store.test.ts similarity index 99% rename from packages/infra/test/yaml-store.test.ts rename to packages/codingcode/test/infra/yaml-store.test.ts index f4f962aa..2829e437 100644 --- a/packages/infra/test/yaml-store.test.ts +++ b/packages/codingcode/test/infra/yaml-store.test.ts @@ -10,7 +10,7 @@ import { writeNamedList, yamlStorePath, type NamedListFile, -} from '../src/yaml-store.js'; +} from '../../src/infra/yaml-store.js'; interface Item { name: string; diff --git a/packages/codingcode/test/llm/factory.test.ts b/packages/codingcode/test/llm/factory.test.ts deleted file mode 100644 index 79f0e117..00000000 --- a/packages/codingcode/test/llm/factory.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Effect } from 'effect'; - -const mockCatalog = { - providers: [ - { - name: 'provider-a', - driver: 'openai', - base_url: 'https://api.a.com', - api_key_env: 'API_KEY_A', - default_model: 'model-x', - models: [ - { id: 'model-x', name: 'Model X' }, - { id: 'model-y', name: 'Model Y' }, - ], - }, - ], -}; - -function mockFs() { - vi.doMock('fs', async (importOriginal: any) => { - const orig = await importOriginal(); - return { - ...orig, - existsSync: (p: string) => (p.includes('models.json') ? true : orig.existsSync(p)), - readFileSync: (p: string, enc?: any) => - p.includes('models.json') ? JSON.stringify(mockCatalog) : orig.readFileSync(p, enc), - }; - }); -} - -/** llm.ts 的 activeModel 来自 loadConfig(),与 updateActiveModel 同模块,一并桩掉。 */ -function mockActiveModel( - activeModel: { model: string; apiKeyEnv: string } | undefined, - extra: Record = {} -) { - vi.doMock('@codingcode/infra/config', async (importOriginal: any) => { - const orig = await importOriginal(); - return { ...orig, loadConfig: () => ({ activeModel }), ...extra }; - }); -} - -describe('switchModel - persists to config', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('calls updateActiveModel with model and api_key_env after switching', async () => { - const updateActiveModel = vi.fn(); - mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }, { updateActiveModel }); - mockFs(); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.switchModel('model-y@API_KEY_A'); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Right'); - if (result._tag === 'Right') { - expect(result.right.id).toBe('model-y@API_KEY_A'); - } - expect(updateActiveModel).toHaveBeenCalledWith('model-y', 'API_KEY_A'); - }); - - it('does not call updateActiveModel when model id is not found', async () => { - const updateActiveModel = vi.fn(); - mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }, { updateActiveModel }); - mockFs(); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.switchModel('nonexistent@API_KEY_A'); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Left'); - if (result._tag === 'Left') { - expect(result.left.message).toContain('not found'); - } - expect(updateActiveModel).not.toHaveBeenCalled(); - }); -}); - -describe('getActiveEntry - activeModel priority', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('uses activeModel from config when it matches a catalog entry', async () => { - mockFs(); - - mockActiveModel({ model: 'model-y', apiKeyEnv: 'API_KEY_A' }); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Right'); - if (result._tag === 'Right') { - expect(result.right.id).toBe('model-y@API_KEY_A'); - } - }); - - it('returns error when activeModel is not set in config', async () => { - mockActiveModel(undefined); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Left'); - if (result._tag === 'Left') { - expect(result.left.code).toBe('CONFIG_INVALID'); - expect(result.left.message).toContain('activeModel'); - } - }); - - it('returns error when activeModel does not match any catalog entry', async () => { - mockFs(); - - mockActiveModel({ model: 'nonexistent', apiKeyEnv: 'UNKNOWN_KEY' }); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Left'); - if (result._tag === 'Left') { - expect(result.left.code).toBe('CONFIG_INVALID'); - expect(result.left.message).toContain('nonexistent'); - } - }); -}); - -describe('createClient - API key validation', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('returns CONFIG_MISSING when API key env is not set', async () => { - mockFs(); - - mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const entryResult = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(entryResult._tag).toBe('Right'); - if (entryResult._tag === 'Left') return; - - delete (process.env as any).API_KEY_A; - delete (process.env as any).OPENAI_API_KEY; - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.createClient(entryResult.right); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Left'); - if (result._tag === 'Left') { - expect(result.left.code).toBe('CONFIG_MISSING'); - expect(result.left.message).toContain('API_KEY_A'); - } - }); - - it('succeeds when OPENAI_API_KEY fallback is set', async () => { - mockFs(); - - mockActiveModel({ model: 'model-x', apiKeyEnv: 'API_KEY_A' }); - - const { LLMFactoryService } = await import('../../src/llm/port.js'); - const { LlmLayer } = await import('../../src/llm/llm.js'); - - const entryResult = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.getActiveEntry(); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(entryResult._tag).toBe('Right'); - if (entryResult._tag === 'Left') return; - - delete (process.env as any).API_KEY_A; - (process.env as any).OPENAI_API_KEY = 'sk-test'; - - const result = await Effect.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - return yield* factory.createClient(entryResult.right); - }).pipe(Effect.provide(LlmLayer), Effect.either) - ); - expect(result._tag).toBe('Right'); - }); -}); diff --git a/packages/codingcode/test/llm/openai-provider.test.ts b/packages/codingcode/test/llm/openai-provider.test.ts index 71f46cec..0f7b5fc9 100644 --- a/packages/codingcode/test/llm/openai-provider.test.ts +++ b/packages/codingcode/test/llm/openai-provider.test.ts @@ -117,7 +117,7 @@ describe('OpenAIProvider completeStream', () => { expect(parts).toEqual([ { type: 'text', text: 'reading' }, - { type: 'tool_call', id: 'tc-1', name: 'read_file', args: { path: 'a.ts' } }, + { type: 'tool_call', id: 'tc-1', name: 'read_file', arguments: { path: 'a.ts' } }, { type: 'end', usage: EXPECTED_USAGE }, ]); }); diff --git a/packages/codingcode/test/mcp/service.test.ts b/packages/codingcode/test/mcp/service.test.ts index 445d2c01..a1c630cf 100644 --- a/packages/codingcode/test/mcp/service.test.ts +++ b/packages/codingcode/test/mcp/service.test.ts @@ -14,7 +14,6 @@ interface MockTool { vi.mock('../../src/mcp/client.js', () => { class MockMcpClient { static instances: MockMcpClient[] = []; - transportType = 'stdio' as const; connectCalls = 0; disconnectCalls = 0; private _tools: MockTool[]; @@ -221,19 +220,4 @@ describe('McpService(syncConnections / listProjectMcpTools / status)', () => }) ); }); - - it('status 返回 name/toolCount/transport,未连接的项目返回空数组', async () => { - mockConfigs = [cfg('db', [tool('query'), tool('schema')])]; - - await run( - Effect.gen(function* () { - const mcp = yield* McpService; - yield* mcp.syncConnections(TEST_PROJECT); - expect(yield* mcp.status(TEST_PROJECT)).toEqual([ - { name: 'db', toolCount: 2, transport: 'stdio' }, - ]); - expect(yield* mcp.status('/other-project')).toEqual([]); - }) - ); - }); }); diff --git a/packages/codingcode/test/memory/config.test.ts b/packages/codingcode/test/memory/config.test.ts index 580ca52a..d7601d4d 100644 --- a/packages/codingcode/test/memory/config.test.ts +++ b/packages/codingcode/test/memory/config.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { getMemoryConfig } from '../../src/memory/config.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../../src/infra/config.js', () => ({ loadConfig: vi.fn(() => ({ memory: { enabled: true, model: 'memory-model', promptMaxBytes: 4096 }, })), @@ -16,7 +16,7 @@ describe('getMemoryConfig', () => { }); it('reflects updated loadConfig result', async () => { - const { loadConfig } = await import('@codingcode/infra/config'); + const { loadConfig } = await import('../../src/infra/config.js'); vi.mocked(loadConfig).mockReturnValue({ memory: { enabled: false, model: '', promptMaxBytes: 8192 }, } as any); diff --git a/packages/codingcode/test/memory/extractor.test.ts b/packages/codingcode/test/memory/extractor.test.ts index ed1fe886..81780e59 100644 --- a/packages/codingcode/test/memory/extractor.test.ts +++ b/packages/codingcode/test/memory/extractor.test.ts @@ -1,114 +1,94 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; import { extractMemory } from '../../src/memory/extractor.js'; +import { AgentError } from '../../src/core/error.js'; +import type { LLMShape } from '../../src/llm/port.js'; +import type { LLMRequest } from '../../src/contracts/provider.js'; -describe('Memory Extractor', () => { - const createMockLlm = (response: string) => ({ - complete: vi.fn(() => Effect.succeed({ content: response })), - completeStream: vi.fn(() => - (async function* () { - yield { type: 'text' as const, text: response }; - yield { type: 'end' as const }; - })() - ), - modelInfo: { - provider: 'mock', - model: 'mock', - maxTokens: 4096, - supportsToolCalling: true, - supportsStreaming: true, - }, - }); +const TEST_MODEL = 'demo-model@demo'; + +/** 记忆提取走非流式通道:断言直接看 complete 的入参。 */ +function createMockLlm(response: string) { + return { + complete: vi.fn((_req: LLMRequest, _model: string) => Effect.succeed({ content: response })), + completeStream: vi.fn(), + }; +} + +function extract(llm: LLMShape, currentMemory: string, transcript: string) { + return Effect.runPromise(extractMemory({ currentMemory, transcript, llm, model: TEST_MODEL })); +} - it('returns memory inside tags', async () => { - const response = `### 主题 -- 用户是 TypeScript 开发者`; +describe('Memory Extractor', () => { + it('takes the whole model output as the new memory', async () => { + const response = `### 主题 +- 用户是 TypeScript 开发者`; - const result = await extractMemory({ - currentMemory: '', - transcript: '[user] I like TypeScript', - llm: createMockLlm(response), - }); + const result = await extract(createMockLlm(response), '', '[user] I like TypeScript'); - expect(result).toContain('### 主题'); - expect(result).toContain('用户是 TypeScript 开发者'); + expect(result).toBe(response); }); - it('returns null when memory tags are empty', async () => { - const result = await extractMemory({ - currentMemory: '', - transcript: '[user] Some text', - llm: createMockLlm(''), - }); + it('keeps a preamble instead of trying to strip it', async () => { + const response = `好的,我整理了一份记忆: - expect(result).toBeNull(); +### 主题 +- 用户偏好简洁回答`; + + const result = await extract(createMockLlm(response), '', '[user] 简洁点'); + + expect(result).toBe(response); }); - it('returns null when memory tags not found', async () => { - const result = await extractMemory({ - currentMemory: '', - transcript: '[user] Some text', - llm: createMockLlm('No memory tags here'), - }); + it('returns null when the model returns blank output', async () => { + const result = await extract(createMockLlm(' \n '), '', '[user] Some text'); expect(result).toBeNull(); }); it('handles LLM call failure gracefully', async () => { const llm = { - complete: vi.fn(() => Effect.fail({ code: 'LLM_ERROR', message: 'Stream error' } as any)), - completeStream: vi.fn(() => - (async function* () { - throw new Error('Stream error'); - })() + complete: vi.fn((_req: LLMRequest, _model: string) => + Effect.fail(new AgentError('LLM_FAILED', 'llm unavailable')) ), - modelInfo: { - provider: 'mock', - model: 'mock', - maxTokens: 4096, - supportsToolCalling: true, - supportsStreaming: true, - }, + completeStream: vi.fn(), }; - const result = await extractMemory({ - currentMemory: '', - transcript: '', - llm, - }); + const result = await extract(llm, '', ''); expect(result).toBeNull(); }); it('passes currentMemory to the model as existing memory', async () => { - const mockLlm = createMockLlm(''); - - await extractMemory({ - 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('旧信息'); - expect(callArgs.messages[0].content).toContain('新对话'); + const mockLlm = createMockLlm(''); + + await extract(mockLlm, '### project\n- 旧信息', '[user] 新对话'); + + const callArgs = mockLlm.complete.mock.calls[0]?.[0]; + expect(callArgs?.messages[0]?.content).toContain('已有记忆'); + expect(callArgs?.messages[0]?.content).toContain('旧信息'); + expect(callArgs?.messages[0]?.content).toContain('新对话'); }); it('keeps instructions in system and transcript data in messages', async () => { - const mockLlm = createMockLlm(''); - - await extractMemory({ - currentMemory: '### project\n- Likes TypeScript', - transcript: '[user] I use Python', - llm: mockLlm, - }); - - const callArgs = (mockLlm.completeStream.mock.calls as any)[0][0] as any; - expect(callArgs.system).toContain('规则'); - expect(callArgs.system).toContain('整份'); - expect(callArgs.system).not.toContain('I use Python'); - expect(callArgs.messages[0].content).toContain('I use Python'); - expect(callArgs.messages[0].content).toContain('Likes TypeScript'); + const mockLlm = createMockLlm(''); + + await extract(mockLlm, '### project\n- Likes TypeScript', '[user] I use Python'); + + const callArgs = mockLlm.complete.mock.calls[0]?.[0]; + expect(callArgs?.system).toContain('规则'); + expect(callArgs?.system).toContain('整份'); + expect(callArgs?.system).not.toContain('I use Python'); + expect(callArgs?.messages[0]?.content).toContain('I use Python'); + expect(callArgs?.messages[0]?.content).toContain('Likes TypeScript'); + }); + + it('passes the target model to the non-streaming channel', async () => { + const mockLlm = createMockLlm(''); + + await extract(mockLlm, '', '[user] hi'); + + expect(mockLlm.complete.mock.calls[0]?.[1]).toBe(TEST_MODEL); + expect(mockLlm.completeStream).not.toHaveBeenCalled(); }); }); diff --git a/packages/codingcode/test/memory/index.test.ts b/packages/codingcode/test/memory/index.test.ts index 4e0b1df6..a647e1eb 100644 --- a/packages/codingcode/test/memory/index.test.ts +++ b/packages/codingcode/test/memory/index.test.ts @@ -3,27 +3,25 @@ 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/port.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; +import { MemoryService, type MemoryShape } from '../../src/memory/port.js'; +import { LLMService } from '../../src/llm/port.js'; import { MemoryLayer } from '../../src/memory/memory.js'; +import { AgentError } from '../../src/core/error.js'; const tmpDir = path.join(os.tmpdir(), 'memory-index-test'); const memFile = path.join(tmpDir, '.codingcode', 'memory.md'); -const mockFactory = { - findModel: vi.fn(() => Effect.succeed(null)), - createClient: vi.fn(() => Effect.succeed({})), - listModels: vi.fn(() => Effect.succeed([])), - getActiveEntry: vi.fn(() => Effect.succeed({})), - switchModel: vi.fn(() => Effect.succeed({})), - getLLMClient: vi.fn(() => Effect.succeed({})), +const mockLlm = { + complete: vi.fn(() => Effect.succeed({ content: '' })), + completeStream: vi.fn(), } as any; -const testLayer = MemoryLayer.pipe( - Layer.provide(Layer.succeed(LLMFactoryService, mockFactory)) -); +const testLayer = MemoryLayer.pipe(Layer.provide(Layer.succeed(LLMService, mockLlm))); -let service: any; +let service: MemoryShape; + +/** MemoryShape 现在返回 Effect,测试统一用 Effect.runPromise 驱动 */ +const run = (eff: Effect.Effect) => Effect.runPromise(eff); function cleanup() { if (fs.existsSync(tmpDir)) { @@ -46,7 +44,7 @@ vi.mock('../../src/memory/config.js', () => ({ // setMemoryEnabled persists via the infra config store, which writes the real // ~/.codingcode/config.yaml. Stub the writer so the suite never touches user config. -vi.mock('@codingcode/infra/config', async (importOriginal) => { +vi.mock('../../src/infra/config.js', async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, @@ -62,29 +60,20 @@ vi.mock('../../src/session/file-ops.js', async (importOriginal) => { }; }); -function createMockLlm(response: string, beforeYield?: () => void) { - return { - complete: vi.fn(() => Effect.succeed({ content: response })), - completeStream: vi.fn(() => - (async function* () { - beforeYield?.(); - yield { type: 'text' as const, text: response }; - yield { type: 'end' as const }; - })() - ), - modelInfo: { - provider: 'mock', - model: 'mock', - maxTokens: 4096, - supportsToolCalling: true, - supportsStreaming: true, - }, - }; +function setLlmResponse(response: string, beforeResolve?: () => void) { + mockLlm.complete.mockImplementation((_req: unknown, _model: string) => { + beforeResolve?.(); + return Effect.succeed({ content: response }); + }); } +const TEST_MODEL = 'demo-model@demo'; + beforeEach(async () => { cleanup(); fs.mkdirSync(tmpDir, { recursive: true }); + mockLlm.complete.mockReset(); + setLlmResponse(''); const { getMemoryConfig } = await import('../../src/memory/config.js'); vi.mocked(getMemoryConfig).mockReturnValue({ enabled: false, @@ -114,14 +103,14 @@ async function enableConfig() { } describe('loadMemoryForPrompt', () => { - it('returns empty string when memory is disabled', () => { - const result = service.loadMemoryForPrompt(tmpDir); + it('returns empty string when memory is disabled', async () => { + const result = await run(service.loadMemoryForPrompt(tmpDir)); expect(result).toBe(''); }); it('returns empty string when no memory file exists', async () => { await enableConfig(); - const result = service.loadMemoryForPrompt(tmpDir); + const result = await run(service.loadMemoryForPrompt(tmpDir)); expect(result).toBe(''); }); @@ -129,7 +118,7 @@ describe('loadMemoryForPrompt', () => { await enableConfig(); writeMemory('### project\n- Architecture decision 1'); - const result = service.loadMemoryForPrompt(tmpDir); + const result = await run(service.loadMemoryForPrompt(tmpDir)); expect(result).toContain('## Long-term Memory'); expect(result).toContain('### project'); expect(result).toContain('Architecture decision 1'); @@ -145,7 +134,7 @@ describe('loadMemoryForPrompt', () => { writeMemory(`### project - Very long content that should be truncated ${' x'.repeat(200)}`); - const result = service.loadMemoryForPrompt(tmpDir); + const result = await run(service.loadMemoryForPrompt(tmpDir)); const bytes = Buffer.byteLength(result.replace('## Long-term Memory\n\n', ''), 'utf-8'); expect(bytes).toBeLessThanOrEqual(100); }); @@ -153,23 +142,26 @@ describe('loadMemoryForPrompt', () => { describe('flushSessionToMemory', () => { it('returns early when memory disabled', async () => { - const result = await service.flushSessionToMemory('fake-session-id', null, tmpDir); + const result = await run(service.flushSessionToMemory('fake-session-id', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); }); it('returns early when session has no events', async () => { await enableConfig(); - const result = await service.flushSessionToMemory('empty-session', null, tmpDir); + const result = await run(service.flushSessionToMemory('empty-session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); }); - it('gracefully handles missing LLM', async () => { + it('gracefully handles an LLM failure', async () => { await enableConfig(); const { readTranscript } = await import('../../src/session/file-ops.js'); vi.mocked(readTranscript).mockImplementation(() => [ { type: 'user', content: 'hello' }, ] as any); - const result = await service.flushSessionToMemory('session', null, tmpDir); + mockLlm.complete.mockImplementation(() => + Effect.fail(new AgentError('LLM_FAILED', 'llm unavailable')) + ); + const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); }); @@ -181,24 +173,26 @@ describe('flushSessionToMemory', () => { { type: 'user', content: '记住新架构决策' }, { type: 'assistant', content: '好的' }, ] as any); - const llm = createMockLlm('### 项目\n- 新的架构决策'); + setLlmResponse('### 项目\n- 新的架构决策'); - const result = await service.flushSessionToMemory('session', llm, tmpDir); + const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); + expect(mockLlm.complete.mock.calls[0]?.[1]).toBe(TEST_MODEL); expect(result.written).toBe(true); expect(result.bytes).toBeGreaterThan(0); expect(fs.readFileSync(memFile, 'utf-8')).toBe('### 项目\n- 新的架构决策'); }); - it('keeps file unchanged when model returns empty memory', async () => { + it('keeps file unchanged when the model returns blank output', async () => { await enableConfig(); writeMemory('### 旧主题\n- 旧内容'); const { readTranscript } = await import('../../src/session/file-ops.js'); vi.mocked(readTranscript).mockImplementation(() => [ { type: 'user', content: 'hello' }, ] as any); + setLlmResponse(''); - const result = await service.flushSessionToMemory('session', createMockLlm(''), tmpDir); + const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); expect(fs.readFileSync(memFile, 'utf-8')).toBe('### 旧主题\n- 旧内容'); @@ -211,12 +205,9 @@ describe('flushSessionToMemory', () => { vi.mocked(readTranscript).mockImplementation(() => [ { type: 'user', content: '无新信息' }, ] as any); + setLlmResponse('### 主题\n- 不变的内容'); - const result = await service.flushSessionToMemory( - 'session', - createMockLlm('### 主题\n- 不变的内容'), - tmpDir - ); + const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); }); @@ -228,11 +219,11 @@ describe('flushSessionToMemory', () => { vi.mocked(readTranscript).mockImplementation(() => [ { type: 'user', content: 'hello' }, ] as any); - const llm = createMockLlm('### 自动\n- 新记忆', () => { + setLlmResponse('### 自动\n- 新记忆', () => { writeMemory('### 手动\n- 用户并发编辑'); }); - const result = await service.flushSessionToMemory('session', llm, tmpDir); + const result = await run(service.flushSessionToMemory('session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); expect(fs.readFileSync(memFile, 'utf-8')).toBe('### 手动\n- 用户并发编辑'); @@ -240,36 +231,36 @@ describe('flushSessionToMemory', () => { }); describe('runtime memory toggle', () => { - afterEach(() => { - service.setMemoryEnabled(false); + afterEach(async () => { + await run(service.setMemoryEnabled(false)); }); - it('setMemoryEnabled(true) makes getMemoryEnabled return true', () => { - service.setMemoryEnabled(true); - expect(service.getMemoryEnabled()).toBe(true); + it('setMemoryEnabled(true) makes getMemoryEnabled return true', async () => { + await run(service.setMemoryEnabled(true)); + expect(await run(service.getMemoryEnabled())).toBe(true); }); - it('setMemoryEnabled(false) makes getMemoryEnabled return false', () => { - service.setMemoryEnabled(false); - expect(service.getMemoryEnabled()).toBe(false); + it('setMemoryEnabled(false) makes getMemoryEnabled return false', async () => { + await run(service.setMemoryEnabled(false)); + expect(await run(service.getMemoryEnabled())).toBe(false); }); - it('toggle sequence works correctly', () => { - service.setMemoryEnabled(true); - expect(service.getMemoryEnabled()).toBe(true); - service.setMemoryEnabled(false); - expect(service.getMemoryEnabled()).toBe(false); + it('toggle sequence works correctly', async () => { + await run(service.setMemoryEnabled(true)); + expect(await run(service.getMemoryEnabled())).toBe(true); + await run(service.setMemoryEnabled(false)); + expect(await run(service.getMemoryEnabled())).toBe(false); }); - it('loadMemoryForPrompt returns empty when runtime disabled', () => { - service.setMemoryEnabled(false); - const result = service.loadMemoryForPrompt(tmpDir); + it('loadMemoryForPrompt returns empty when runtime disabled', async () => { + await run(service.setMemoryEnabled(false)); + const result = await run(service.loadMemoryForPrompt(tmpDir)); expect(result).toBe(''); }); it('flushSessionToMemory returns early when runtime disabled', async () => { - service.setMemoryEnabled(false); - const result = await service.flushSessionToMemory('any-session', null, tmpDir); + await run(service.setMemoryEnabled(false)); + const result = await run(service.flushSessionToMemory('any-session', TEST_MODEL, tmpDir)); expect(result.written).toBe(false); }); }); diff --git a/packages/codingcode/test/memory/llm-resolver.test.ts b/packages/codingcode/test/memory/llm-resolver.test.ts deleted file mode 100644 index 5cf0702a..00000000 --- a/packages/codingcode/test/memory/llm-resolver.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { Effect } from 'effect'; -import { resolveLLM } from '../../src/llm/llm-resolver.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; -import { AgentError } from '../../src/core/error.js'; -import type { LLMClient } from '../../src/contracts/provider.js'; -import type { SelectableModel } from '../../src/contracts/provider.js'; - -const { mockFindModel, mockCreateClient } = vi.hoisted(() => ({ - mockFindModel: vi.fn(), - mockCreateClient: vi.fn(), -})); - -const mockFactory = { - findModel: mockFindModel, - createClient: mockCreateClient, - listModels: vi.fn(() => Effect.succeed([])), - getActiveEntry: vi.fn(() => Effect.succeed({})), - switchModel: vi.fn(() => Effect.succeed({})), - getLLMClient: vi.fn(() => Effect.succeed({})), -} as any; - -const fallbackClient = {} as LLMClient; - -async function runResolveLLM(target: string | null | undefined, fallback: LLMClient | null) { - return Effect.runPromise( - resolveLLM(target, fallback).pipe(Effect.provideService(LLMFactoryService, mockFactory)) - ); -} - -describe('resolveLLM (memory)', () => { - afterEach(() => { - vi.resetAllMocks(); - }); - - it('returns fallback when target is empty', async () => { - const result = await runResolveLLM('', fallbackClient); - expect(result).toBe(fallbackClient); - }); - - it('returns fallback when target is whitespace-only', async () => { - const result = await runResolveLLM(' ', fallbackClient); - expect(result).toBe(fallbackClient); - }); - - it('returns fallback when model not found', async () => { - mockFindModel.mockReturnValue(Effect.succeed(null)); - const result = await runResolveLLM('nonexistent-model', fallbackClient); - expect(result).toBe(fallbackClient); - }); - - it('returns null when fallback is null and create fails', async () => { - mockFindModel.mockReturnValue(Effect.succeed({ id: 'claude-opus-4-7' } as SelectableModel)); - mockCreateClient.mockReturnValue( - Effect.fail(new AgentError('CONFIG_INVALID', 'creation failed')) - ); - const result = await runResolveLLM('claude-opus-4-7', null); - expect(result).toBeNull(); - }); - - it('returns null when fallback is null and create returns error', async () => { - mockFindModel.mockReturnValue(Effect.succeed({ id: 'claude-opus-4-7' } as SelectableModel)); - mockCreateClient.mockReturnValue(Effect.fail(new AgentError('CONFIG_INVALID', 'error'))); - const result = await runResolveLLM('claude-opus-4-7', null); - expect(result).toBeNull(); - }); - - it('creates and returns client when model matches by id', async () => { - const client = { modelInfo: { maxTokens: 4096 } } as LLMClient; - mockFindModel.mockReturnValue( - Effect.succeed({ id: 'claude-opus-4-7@ANTHROPIC_API_KEY' } as SelectableModel) - ); - mockCreateClient.mockReturnValue(Effect.succeed(client)); - const result = await runResolveLLM('claude-opus-4-7@ANTHROPIC_API_KEY', fallbackClient); - expect(result).toBe(client); - }); - - it('creates and returns client when model matches by bare model id', async () => { - const client = { modelInfo: { maxTokens: 4096 } } as LLMClient; - mockFindModel.mockReturnValue( - Effect.succeed({ - id: 'deepseek-chat@DEEPSEEK_API_KEY', - model: 'deepseek-chat', - } as SelectableModel) - ); - mockCreateClient.mockReturnValue(Effect.succeed(client)); - const result = await runResolveLLM('deepseek-chat', fallbackClient); - expect(result).toBe(client); - }); -}); diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index cd1d79c0..b37f2865 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -9,7 +9,7 @@ import { endReason, } from './helpers/agent-harness.js'; -vi.mock('@codingcode/infra/config', () => ({ +vi.mock('../src/infra/config.js', () => ({ loadConfig: () => ({ maxSteps: 5, maxStopContinuations: 2, diff --git a/packages/codingcode/test/plan/gate-pipeline.test.ts b/packages/codingcode/test/plan/gate-pipeline.test.ts index a54290d4..5818d178 100644 --- a/packages/codingcode/test/plan/gate-pipeline.test.ts +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -38,7 +38,7 @@ function makeMockApprovalWait() { function runPipelineWithMock(opts: { tool: string; input: any; - permissionMode: 'default' | 'acceptEdits' | 'bypass'; + permissionMode: 'ask' | 'acceptEdits' | 'bypass'; sessionId: string; profile: ProfileName; }) { @@ -76,7 +76,7 @@ describe('plan profile permission mode (Layer 2)', () => { const decision: any = await runPipelineWithMock({ tool: 'write_file', input: { path: '/tmp/x', content: 'foo' }, - permissionMode: 'default', + permissionMode: 'ask', sessionId: 's2', profile: 'plan', }); @@ -90,7 +90,7 @@ describe('plan profile permission mode (Layer 2)', () => { const decision: any = await runPipelineWithMock({ tool: 'execute_command', input: { command: 'rm -rf /' }, - permissionMode: 'default', + permissionMode: 'ask', sessionId: 's3', profile: 'plan', }); @@ -104,7 +104,7 @@ describe('plan profile permission mode (Layer 2)', () => { const decision: any = await runPipelineWithMock({ tool: 'dispatch_agent', input: { agent: 'build', prompt: 'do something' }, - permissionMode: 'default', + permissionMode: 'ask', sessionId: 's4', profile: 'plan', }); @@ -118,7 +118,7 @@ describe('plan profile permission mode (Layer 2)', () => { const decision: any = await runPipelineWithMock({ tool: 'write_file', input: { path: '/tmp/x', content: 'foo' }, - permissionMode: 'default', + permissionMode: 'ask', sessionId: 's5', profile: 'build', }); @@ -130,7 +130,7 @@ describe('plan profile permission mode (Layer 2)', () => { const decision: any = await runPipelineWithMock({ tool: 'submit_plan', input: { plan_content: '# plan' }, - permissionMode: 'default', + permissionMode: 'ask', sessionId: 's6', profile: 'plan', }); diff --git a/packages/codingcode/test/prompts/system-prompt.test.ts b/packages/codingcode/test/prompts/system-prompt.test.ts index 458d10e8..671eaffe 100644 --- a/packages/codingcode/test/prompts/system-prompt.test.ts +++ b/packages/codingcode/test/prompts/system-prompt.test.ts @@ -30,6 +30,14 @@ describe('buildSystemPrompt', () => { expect(prompt).toContain('dispatch_agent'); }); + it('Rule 7 delegation does not point at a removed subagent catalog', () => { + const prompt = buildSystemPrompt(baseOpts); + expect(prompt).not.toContain('runtime-configured subagent'); + expect(prompt).not.toContain('Available Subagents'); + expect(prompt).toContain('short agentName'); + expect(prompt).toContain('keep the delegated write set disjoint'); + }); + it('includes Using your tools section', () => { const prompt = buildSystemPrompt(baseOpts); expect(prompt).toContain('Using your tools'); diff --git a/packages/codingcode/test/scheduler/store.test.ts b/packages/codingcode/test/scheduler/store.test.ts index f25ec4ab..bc49f98c 100644 --- a/packages/codingcode/test/scheduler/store.test.ts +++ b/packages/codingcode/test/scheduler/store.test.ts @@ -3,7 +3,7 @@ import { existsSync, rmSync, mkdirSync, writeFileSync } from 'fs'; import { resolve, join } from 'path'; import { tmpdir } from 'os'; import { readAutomations, writeAutomations } from '../../src/scheduler/store.js'; -import type { Automation } from '../../src/scheduler/types.js'; +import type { Automation } from '../../src/contracts/automation.js'; const testDir = resolve(tmpdir(), 'codingcode-test-scheduler-store'); const testFile = join(testDir, 'automations.yaml'); diff --git a/packages/codingcode/test/security/plan-profile-restart.test.ts b/packages/codingcode/test/security/plan-profile-restart.test.ts index 54274ec4..c71026b3 100644 --- a/packages/codingcode/test/security/plan-profile-restart.test.ts +++ b/packages/codingcode/test/security/plan-profile-restart.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { mkdtempSync, rmSync, readFileSync } from 'fs'; +import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; import { HookService } from '../../src/hooks/port.js'; import { ApprovalService } from '../../src/approval/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; @@ -59,7 +58,6 @@ function setProfileEffect(cwd: string, sessionId: string, profile: 'plan' | 'bui describe('plan profile security boundary (permission-mode, disk-persisted profile)', () => { let cwd: string; let sessionId: string; - let indexPath: string; let rt: ManagedRuntime.ManagedRuntime; beforeEach(async () => { @@ -71,16 +69,12 @@ describe('plan profile security boundary (permission-mode, disk-persisted profil const state = yield* session.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); - return { - sessionId: state.sessionId, - indexPath: computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, - }; + return { sessionId: state.sessionId }; }) ); sessionId = result.sessionId; - indexPath = result.indexPath; }); afterEach(async () => { @@ -101,7 +95,7 @@ describe('plan profile security boundary (permission-mode, disk-persisted profil input, sessionId, projectPath: cwd, - permissionMode: 'default', + permissionMode: 'ask', profile, }); }) @@ -131,9 +125,6 @@ describe('plan profile security boundary (permission-mode, disk-persisted profil it('after restart (state reloaded from disk), plan profile persists', async () => { await rt.runPromise(setProfileEffect(cwd, sessionId, 'plan')); - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.activeProfile).toBe('plan'); - await rt.dispose(); rt = ManagedRuntime.make(makeLayer() as any); await rt.runPromise( diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index ae851e95..8adc1563 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { createServer } from '../../src/server/index.js'; import { SessionService } from '../../src/session/port.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; import { ApprovalService } from '../../src/approval/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { HookService } from '../../src/hooks/port.js'; @@ -23,17 +22,15 @@ const MockSessionLayer = Layer.succeed(SessionService, { Effect.succeed({ sessionId: 'test-sid', cwd: '/tmp/test', - model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }), load: () => Effect.succeed({ sessionId: 'test-sid', cwd: '/tmp/test', - model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }), recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), recordAssistant: () => @@ -53,46 +50,6 @@ const MockSessionLayer = Layer.succeed(SessionService, { }), } as any); -const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { - findModel: () => - Effect.succeed({ - id: 'deepseek-chat', - model: 'deepseek-chat', - activeProfile: 'build', - permissionMode: 'default', - provider: 'deepseek', - driver: 'openai', - api_key_env: 'DEEPSEEK_API_KEY', - base_url: 'https://api.deepseek.com', - }), - createClient: () => - Effect.succeed({ - modelInfo: { - provider: 'deepseek', - model: 'deepseek-chat', - activeProfile: 'build', - permissionMode: 'default', - maxTokens: 64000, - supportsToolCalling: true, - supportsStreaming: true, - }, - }), - getLLMClient: () => Effect.succeed(null), - listModels: () => Effect.succeed([]), - getActiveEntry: () => - Effect.succeed({ - id: 'deepseek-chat', - model: 'deepseek-chat', - activeProfile: 'build', - permissionMode: 'default', - provider: 'deepseek', - driver: 'openai', - api_key_env: 'DEEPSEEK_API_KEY', - base_url: 'https://api.deepseek.com', - }), - switchModel: () => Effect.fail(new Error('no models')), -} as any); - const MockApprovalLayer = ApprovalLayer.pipe( Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer)) ); @@ -110,10 +67,10 @@ const MockMcpLayer = Layer.succeed(McpService, { } as any); const MockMemoryLayer = Layer.succeed(MemoryService, { - getMemoryEnabled: () => true, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + getMemoryEnabled: () => Effect.succeed(true), + setMemoryEnabled: () => Effect.void, + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), } as any); const MockSchedulerLayer = Layer.succeed(SchedulerService, { @@ -125,7 +82,7 @@ const MockSchedulerLayer = Layer.succeed(SchedulerService, { } as any); const MockContextLayer = Layer.succeed(ContextService, { - assemblePayload: async () => [], + assemblePayload: () => Effect.succeed([]), compactWithLLM: mockCompactWithLLM, } as any); @@ -153,7 +110,6 @@ const MockCheckpointLayer = Layer.succeed(CheckpointService, { const TestLayer = Layer.mergeAll( MockSessionLayer, - MockLLMFactoryLayer, MockApprovalLayer, HookLayer, ApprovalWaitLayer, @@ -170,28 +126,32 @@ const rt = ManagedRuntime.make(TestLayer as any); describe('POST /api/sessions/:id/compact (manual compact)', () => { beforeEach(() => { mockCompactWithLLM.mockReset(); - mockCompactWithLLM.mockResolvedValue({ - didCompress: true, - released: 5000, - promptEstimate: 3000, - }); + mockCompactWithLLM.mockReturnValue( + Effect.succeed({ + didCompress: true, + released: 5000, + promptEstimate: 3000, + }) + ); }); - it('should call compactWithLLM with a non-null llm when session has a valid model', async () => { + it('should pass the requested model through to compactWithLLM', async () => { const app = await createServer(rt); const res = await app.request('/api/sessions/test-sid/compact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cwd: '' }), + body: JSON.stringify({ cwd: '', model: 'deepseek-chat@deepseek' }), }); expect(res.status).toBe(200); expect(mockCompactWithLLM).toHaveBeenCalledTimes(1); const args = mockCompactWithLLM.mock.calls[0]; - // args[2] is the llm parameter — should not be null - expect(args?.[2]).not.toBeNull(); - expect(args?.[2].modelInfo.model).toBe('deepseek-chat'); + // context 现在收的是会话身份(cwd + sessionId),转录路径由它自己拼 + const ref = args?.[0] as { cwd: string; sessionId: string }; + expect(typeof ref.cwd).toBe('string'); + expect(typeof ref.sessionId).toBe('string'); + expect(args?.[1]).toBe('deepseek-chat@deepseek'); }); it('should return CompressResult from the API', async () => { @@ -206,42 +166,8 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { expect(body).toEqual({ didCompress: true, released: 5000, promptEstimate: 3000 }); }); - it('should call compactWithLLM with null llm when getActiveEntry fails', async () => { - const FailingFactoryLayer = Layer.succeed(LLMFactoryService, { - findModel: () => Effect.succeed(null), - createClient: () => - Effect.succeed({ - modelInfo: { - provider: 'deepseek', - model: 'deepseek-chat', - activeProfile: 'build', - permissionMode: 'default', - maxTokens: 64000, - supportsToolCalling: true, - supportsStreaming: true, - }, - }), - getLLMClient: () => Effect.succeed(null), - listModels: () => Effect.succeed([]), - getActiveEntry: () => Effect.fail(new Error('no active model')), - switchModel: () => Effect.fail(new Error('no models')), - } as any); - - const FailLayer = Layer.mergeAll( - MockSessionLayer, - FailingFactoryLayer, - MockApprovalLayer, - HookLayer, - ApprovalWaitLayer, - MockSkillLayer, - MockMcpLayer, - MockMemoryLayer, - MockSchedulerLayer, - MockContextLayer, - MockCheckpointLayer - ); - const failRt = ManagedRuntime.make(FailLayer as any); - const app = await createServer(failRt); + it('should fall back to the global model (empty string) when the request omits a model', async () => { + const app = await createServer(rt); const res = await app.request('/api/sessions/test-sid/compact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -249,9 +175,7 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { }); expect(res.status).toBe(200); - expect(mockCompactWithLLM).toHaveBeenCalledTimes(1); - const args = mockCompactWithLLM.mock.calls[0]; - expect(args?.[2]).toBeNull(); + expect(args?.[1]).toBe(''); }); }); diff --git a/packages/codingcode/test/server/create-session-active-profile.test.ts b/packages/codingcode/test/server/create-session-active-profile.test.ts index 2a9a2671..b64daa9a 100644 --- a/packages/codingcode/test/server/create-session-active-profile.test.ts +++ b/packages/codingcode/test/server/create-session-active-profile.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, ManagedRuntime } from 'effect'; import { Hono } from 'hono'; -import { readFileSync, mkdirSync } from 'fs'; +import { mkdirSync } from 'fs'; import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -28,35 +27,32 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { await rt.dispose(); }); - it('writes idx.activeProfile=plan and idx.permissionMode=default when activeProfile=plan', async () => { + it('persists activeProfile=plan and permissionMode=ask when activeProfile=plan', async () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cwd, activeProfile: 'plan', - permissionMode: 'default', + permissionMode: 'ask', model: 'gpt-4', }), }); expect(res.status).toBe(200); const { sessionId } = await res.json(); - const indexPath = await rt.runPromise( + await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); - return computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; + expect(state.activeProfile).toBe('plan'); + expect(state).not.toHaveProperty('mode'); + expect(state.permissionMode).toBe('ask'); }) ); - - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.activeProfile).toBe('plan'); - expect(idx).not.toHaveProperty('mode'); - expect(idx.permissionMode).toBe('default'); }); - it('writes idx.activeProfile=build and idx.permissionMode=bypass when activeProfile=build+bypass', async () => { + it('persists activeProfile=build and permissionMode=bypass when activeProfile=build+bypass', async () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -70,18 +66,15 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { expect(res.status).toBe(200); const { sessionId } = await res.json(); - const indexPath = await rt.runPromise( + await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); - return computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; + expect(state.activeProfile).toBe('build'); + expect(state).not.toHaveProperty('mode'); + expect(state.permissionMode).toBe('bypass'); }) ); - - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.activeProfile).toBe('build'); - expect(idx).not.toHaveProperty('mode'); - expect(idx.permissionMode).toBe('bypass'); }); it('allows plan profile with any permissionMode (plan no longer overrides perm)', async () => { @@ -97,25 +90,22 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { }); expect(res.status).toBe(200); const { sessionId } = await res.json(); - const indexPath = await rt.runPromise( + await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.load(cwd, sessionId); - return computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; + expect(state.activeProfile).toBe('plan'); + expect(state).not.toHaveProperty('mode'); + expect(state.permissionMode).toBe('bypass'); }) ); - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.activeProfile).toBe('plan'); - expect(idx).not.toHaveProperty('mode'); - expect(idx.permissionMode).toBe('bypass'); - expect(idx.activeProfile).toBe('plan'); }); it('rejects missing model', async () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ cwd, activeProfile: 'build', permissionMode: 'default' }), + body: JSON.stringify({ cwd, activeProfile: 'build', permissionMode: 'ask' }), }); expect(res.status).toBe(400); }); @@ -124,7 +114,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { const res = await app.request('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ cwd, permissionMode: 'default', model: 'gpt-4' }), + body: JSON.stringify({ cwd, permissionMode: 'ask', model: 'gpt-4' }), }); expect(res.status).toBe(400); }); @@ -136,7 +126,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { body: JSON.stringify({ cwd, activeProfile: 'plan', - permissionMode: 'default', + permissionMode: 'ask', model: 'gpt-4', }), }); @@ -149,7 +139,7 @@ describe('POST /api/sessions — atomic mode + permissionMode + model', () => { const state = yield* session.load(cwd, sessionId); expect(state.activeProfile).toBe('plan'); expect(state).not.toHaveProperty('mode'); - expect(state.permissionMode).toBe('default'); + expect(state.permissionMode).toBe('ask'); expect(state.activeProfile).toBe('plan'); }) ); diff --git a/packages/codingcode/test/server/cwd.test.ts b/packages/codingcode/test/server/cwd.test.ts new file mode 100644 index 00000000..9a70a430 --- /dev/null +++ b/packages/codingcode/test/server/cwd.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { join, resolve } from 'path'; +import { tmpdir } from 'os'; +import { isGlobalCwd, tempCwd, resolveCwd, resolveWorkspaceCwd } from '../../src/server/cwd.js'; +import { AgentError } from '../../src/core/error.js'; + +describe('server/cwd:请求入口的工作区解析', () => { + it('isGlobalCwd treats missing, empty and "global" as global', () => { + expect(isGlobalCwd(undefined)).toBe(true); + expect(isGlobalCwd('')).toBe(true); + expect(isGlobalCwd('global')).toBe(true); + expect(isGlobalCwd('/some/project')).toBe(false); + }); + + it('resolveCwd 绝对化请求路径、缺省回落共享 temp,且不校验存在性', () => { + const otherDir = join(tmpdir(), 'cc-other'); + expect(resolveCwd(otherDir)).toBe(resolve(otherDir)); + expect(resolveCwd()).toBe(tempCwd()); + expect(resolveCwd('')).toBe(tempCwd()); + expect(tempCwd()).not.toBe(process.cwd()); + }); + + it('resolveWorkspaceCwd:目录存在则通过;缺省/global 回落 temp', () => { + const dir = mkdtempSync(join(tmpdir(), 'cc-cwd-')); + try { + expect(resolveWorkspaceCwd(dir)).toBe(resolve(dir)); + expect(resolveWorkspaceCwd()).toBe(tempCwd()); + expect(resolveWorkspaceCwd('global')).toBe(tempCwd()); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('resolveWorkspaceCwd:显式给出但目录不存在 → CONFIG_INVALID(400)', () => { + const missing = join(tmpdir(), `cc-missing-${Date.now()}`); + let caught: unknown; + try { + resolveWorkspaceCwd(missing); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(AgentError); + expect((caught as AgentError).code).toBe('CONFIG_INVALID'); + expect((caught as AgentError).httpStatus()).toBe(400); + }); +}); diff --git a/packages/codingcode/test/server/index.test.ts b/packages/codingcode/test/server/index.test.ts index b6dab99f..bd3b9af6 100644 --- a/packages/codingcode/test/server/index.test.ts +++ b/packages/codingcode/test/server/index.test.ts @@ -57,10 +57,10 @@ const MockMcpLayer = Layer.succeed(McpService, { } as any); const MockMemoryLayer = Layer.succeed(MemoryService, { - getMemoryEnabled: () => true, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + getMemoryEnabled: () => Effect.succeed(true), + setMemoryEnabled: () => Effect.void, + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), } as any); const MockSchedulerLayer = Layer.succeed(SchedulerService, { diff --git a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts index 196f4f5c..dbe987de 100644 --- a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts +++ b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; import { Hono } from 'hono'; -import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'fs'; +import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { registerMessagesRoutes } from '../../src/server/routes/messages.js'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; import { HookService } from '../../src/hooks/port.js'; import { ApprovalWaitService } from '../../src/approval/wait-port.js'; import { AgentService } from '../../src/agent/port.js'; @@ -32,7 +31,7 @@ const mockApprovalWaitService = { }; // The message-send path now lives in AgentService.runTurn. A real runTurn loads -// the persisted session (which reads permissionMode from the on-disk index) +// the persisted session (which reads permissionMode from the session head) // before streaming. We mirror that seam here so the test keeps validating that // the fork/send path starts from the persisted session state. const loadedPermissionModes: string[] = []; @@ -74,15 +73,17 @@ describe('POST /api/sessions/:id/messages — reads permissionMode from disk', ( return yield* session.create(cwd, { model: 'm', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); sessionId = state.sessionId; - const indexPath = computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - idx.permissionMode = 'bypass'; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + yield* session.setPermissionMode(cwd, sessionId, 'bypass'); + }) + ); loadedPermissionModes.length = 0; @@ -99,7 +100,7 @@ describe('POST /api/sessions/:id/messages — reads permissionMode from disk', ( const res = await app.request('/api/sessions/' + sessionId + '/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ input: 'hello', cwd }), + body: JSON.stringify({ input: 'hello', cwd, model: 'm' }), }); expect(res.status).not.toBe(404); expect(loadedPermissionModes[0]).toBe('bypass'); diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts index a037a829..dfdcaf9a 100644 --- a/packages/codingcode/test/server/plan-file-route.test.ts +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -18,8 +18,9 @@ import { MemoryService } from '../../src/memory/port.js'; import { SchedulerService } from '../../src/scheduler/port.js'; import { ContextService } from '../../src/context/port.js'; import { CheckpointService } from '../../src/checkpoint/port.js'; -import { encodeProjectPath, getProjectBaseDir } from '../../src/core/path.js'; +import { encodeProjectPath } from '../../src/core/path.js'; import { useTempHome } from '../helpers/temp-home.js'; +import { projectBaseDir } from '../helpers/project-base.js'; import { HookLayer } from '../../src/hooks/hooks.js'; import { ApprovalWaitLayer } from '../../src/approval/wait.js'; import { ApprovalLayer } from '../../src/approval/approval.js'; @@ -31,7 +32,7 @@ const MockSessionLayer = Layer.succeed(SessionService, { cwd: '/tmp/test', model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }), load: () => Effect.succeed({ @@ -39,7 +40,7 @@ const MockSessionLayer = Layer.succeed(SessionService, { cwd: '/tmp/test', model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }), recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), recordAssistant: () => @@ -60,7 +61,7 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { id: 'deepseek-chat', model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', provider: 'deepseek', driver: 'openai', api_key_env: 'DEEPSEEK_API_KEY', @@ -72,7 +73,7 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { provider: 'deepseek', model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', maxTokens: 64000, supportsToolCalling: true, supportsStreaming: true, @@ -85,7 +86,7 @@ const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { id: 'deepseek-chat', model: 'deepseek-chat', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', provider: 'deepseek', driver: 'openai', api_key_env: 'DEEPSEEK_API_KEY', @@ -111,10 +112,10 @@ const MockMcpLayer = Layer.succeed(McpService, { } as any); const MockMemoryLayer = Layer.succeed(MemoryService, { - getMemoryEnabled: () => true, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + getMemoryEnabled: () => Effect.succeed(true), + setMemoryEnabled: () => Effect.void, + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), } as any); const MockSchedulerLayer = Layer.succeed(SchedulerService, { @@ -126,8 +127,8 @@ const MockSchedulerLayer = Layer.succeed(SchedulerService, { } as any); const MockContextLayer = Layer.succeed(ContextService, { - assemblePayload: async () => [], - compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 0 }), + assemblePayload: () => Effect.succeed([]), + compactWithLLM: () => Effect.succeed({ didCompress: false, released: 0, promptEstimate: 0 }), } as any); const MockCheckpointLayer = Layer.succeed(CheckpointService, { @@ -166,15 +167,15 @@ const TestLayer = Layer.mergeAll( MockCheckpointLayer ); -// home 指向临时目录 ⇒ getProjectBaseDir() = /.codingcode/project +// home 指向临时目录 ⇒ projectBaseDir() = /.codingcode/project useTempHome('codingcode-plan-route-'); let plansDir = ''; beforeEach(() => { // The route resolves the query cwd to an absolute path first - // (core/path.resolveCwd -> path.resolve), then encodes it. - plansDir = join(getProjectBaseDir(), encodeProjectPath(resolve('/tmp/test'))); + // (server/cwd.resolveCwd -> path.resolve), then encodes it. + plansDir = join(projectBaseDir(), encodeProjectPath(resolve('/tmp/test'))); mkdirSync(plansDir, { recursive: true }); }); diff --git a/packages/codingcode/test/server/session-title-route.test.ts b/packages/codingcode/test/server/session-title-route.test.ts new file mode 100644 index 00000000..ab887180 --- /dev/null +++ b/packages/codingcode/test/server/session-title-route.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, ManagedRuntime } from 'effect'; +import { Hono } from 'hono'; +import { mkdirSync } from 'fs'; +import { join } from 'path'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; +import { registerSessionsRoutes } from '../../src/server/routes/sessions.js'; +import { computePaths } from '../../src/session/paths.js'; +import { readSessionMeta } from '../../src/session/file-ops.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +describe('PUT /api/sessions/:id/title', () => { + let cwd: string; + let rt: ManagedRuntime.ManagedRuntime; + let app: Hono; + + beforeEach(async () => { + cwd = join(base.dir, 'session-title-route'); + mkdirSync(cwd, { recursive: true }); + rt = ManagedRuntime.make(SessionLayer as any); + app = new Hono(); + registerSessionsRoutes(app, rt); + }); + + afterEach(async () => { + await rt.dispose(); + }); + + async function createSession(): Promise { + const res = await app.request('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + cwd, + activeProfile: 'build', + permissionMode: 'ask', + model: 'gpt-4', + }), + }); + expect(res.status).toBe(200); + return (await res.json()).sessionId; + } + + async function headTitle(sessionId: string): Promise { + return rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + const paths = computePaths(state.cwd, state.sessionId, state.parentSessionId); + return readSessionMeta(paths.transcriptPath)?.title; + }) as any + ); + } + + it('persists a user-provided title to the session head', async () => { + const sessionId = await createSession(); + + const res = await app.request(`/api/sessions/${sessionId}/title`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cwd, title: '我的自定义标题' }), + }); + + expect(res.status).toBe(200); + expect(await headTitle(sessionId)).toBe('我的自定义标题'); + }); + + it('overrides the title auto-derived from the first user message', async () => { + const sessionId = await createSession(); + + await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + yield* session.recordUser(state, 'first message'); + }) + ); + expect(await headTitle(sessionId)).toBe('first message'); + + const res = await app.request(`/api/sessions/${sessionId}/title`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cwd, title: 'renamed by user' }), + }); + + expect(res.status).toBe(200); + expect(await headTitle(sessionId)).toBe('renamed by user'); + }); + + it('collapses newlines in the title', async () => { + const sessionId = await createSession(); + + const res = await app.request(`/api/sessions/${sessionId}/title`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cwd, title: 'line one\nline two' }), + }); + + expect(res.status).toBe(200); + expect(await headTitle(sessionId)).toBe('line one line two'); + }); + + it('rejects a blank title', async () => { + const sessionId = await createSession(); + + const res = await app.request(`/api/sessions/${sessionId}/title`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cwd, title: ' ' }), + }); + + expect(res.status).toBe(400); + }); + + it('returns 404 for an unknown session', async () => { + const res = await app.request('/api/sessions/unknown-id/title', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cwd, title: 'x' }), + }); + + expect(res.status).toBe(404); + }); +}); diff --git a/packages/codingcode/test/session/compute-paths.test.ts b/packages/codingcode/test/session/compute-paths.test.ts index f7109dd9..68d54a79 100644 --- a/packages/codingcode/test/session/compute-paths.test.ts +++ b/packages/codingcode/test/session/compute-paths.test.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { computePaths, projectSessionsDir } from '../../src/core/path.js'; +import { computePaths, projectSessionsDir } from '../../src/session/paths.js'; import { sessionJsonlPathFromCwd } from '../../src/session/file-ops.js'; import { normalizePath, encodeProjectPath } from '../../src/core/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -23,7 +23,6 @@ describe('computePaths', () => { const result = computePaths(cwd, sid); expect(result.transcriptPath).toBe(sessionJsonlPathFromCwd(cwd, sid)); - expect(result.indexPath).toBe(result.transcriptPath.replace('.jsonl', '.index.json')); expect(result.sessionId).toBe(sid); expect(result.cwd).toBe(normalizePath(cwd)); expect(result.projectPath).toBe(encodeProjectPath(normalizePath(cwd))); @@ -35,10 +34,9 @@ describe('computePaths', () => { const parentSid = randomUUID(); const result = computePaths(cwd, sid, parentSid); - const sessionsDir = projectSessionsDir(encodeProjectPath(normalizePath(cwd))); + const sessionsDir = projectSessionsDir(cwd); const expectedTranscript = join(sessionsDir, parentSid, 'subagents', `${sid}.jsonl`); expect(result.transcriptPath).toBe(expectedTranscript); - expect(result.indexPath).toBe(expectedTranscript.replace('.jsonl', '.index.json')); expect(result.sessionId).toBe(sid); }); @@ -50,7 +48,7 @@ describe('computePaths', () => { return yield* svc.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -60,9 +58,6 @@ describe('computePaths', () => { expect(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath).toBe( expected.transcriptPath ); - expect(computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath).toBe( - expected.indexPath - ); expect(computePaths(state.cwd, state.sessionId, state.parentSessionId).projectPath).toBe( expected.projectPath ); @@ -86,7 +81,7 @@ describe('computePaths', () => { return yield* svc.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -100,7 +95,7 @@ describe('computePaths', () => { { model: 'subagent-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { parentSessionId: state.sessionId, @@ -118,7 +113,6 @@ describe('computePaths', () => { ); expect(actual).toEqual(expected); expect(existsSync(actual.transcriptPath)).toBe(true); - expect(existsSync(actual.indexPath)).toBe(true); expect(childState).not.toHaveProperty('projectPath'); expect(childState).not.toHaveProperty('transcriptPath'); expect(childState).not.toHaveProperty('indexPath'); diff --git a/packages/codingcode/test/session/create-active-profile.test.ts b/packages/codingcode/test/session/create-active-profile.test.ts index a8c35e1a..5d591eba 100644 --- a/packages/codingcode/test/session/create-active-profile.test.ts +++ b/packages/codingcode/test/session/create-active-profile.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'fs'; import { Effect } from 'effect'; -import { computePaths } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; +import { readSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase(); @@ -21,22 +21,21 @@ describe('session activeProfile persistence', () => { return yield* session.create(cwd, { model: 'gpt-4o', activeProfile: 'plan', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); const paths = computePaths(state.cwd, state.sessionId, state.parentSessionId); - const index = JSON.parse(readFileSync(paths.indexPath, 'utf8')); + const meta = readSessionMeta(paths.transcriptPath); expect(state.activeProfile).toBe('plan'); - expect(state.sessionMeta?.activeProfile).toBe('plan'); - expect(index.activeProfile).toBe('plan'); + expect(meta?.activeProfile).toBe('plan'); expect(state).not.toHaveProperty('mode'); - expect(index).not.toHaveProperty('mode'); + expect(meta).not.toHaveProperty('mode'); }); - it('keeps an updated profile when later events rewrite the index', async () => { + it('keeps an updated profile when later events rewrite the session head', async () => { const cwd = '/tmp/test-active-profile-update'; const state = await run( Effect.gen(function* () { @@ -44,7 +43,7 @@ describe('session activeProfile persistence', () => { return yield* session.create(cwd, { model: 'gpt-4o', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -59,9 +58,9 @@ describe('session activeProfile persistence', () => { ); const paths = computePaths(state.cwd, state.sessionId, state.parentSessionId); - const index = JSON.parse(readFileSync(paths.indexPath, 'utf8')); + const meta = readSessionMeta(paths.transcriptPath); - expect(index.activeProfile).toBe('plan'); - expect(index).not.toHaveProperty('mode'); + expect(meta?.activeProfile).toBe('plan'); + expect(meta).not.toHaveProperty('mode'); }); }); diff --git a/packages/codingcode/test/session/create-session-profile.test.ts b/packages/codingcode/test/session/create-session-profile.test.ts index ab3bbc5f..81f60c67 100644 --- a/packages/codingcode/test/session/create-session-profile.test.ts +++ b/packages/codingcode/test/session/create-session-profile.test.ts @@ -18,7 +18,7 @@ describe('SessionService.create profile', () => { return yield* session.create('/tmp/test-session-profile', { model: 'gpt-4o', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); diff --git a/packages/codingcode/test/session/disk-setters.test.ts b/packages/codingcode/test/session/disk-setters.test.ts index bd08cc9d..c0bdd95a 100644 --- a/packages/codingcode/test/session/disk-setters.test.ts +++ b/packages/codingcode/test/session/disk-setters.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, Layer, ManagedRuntime } from 'effect'; -import { existsSync, readFileSync, mkdirSync } from 'fs'; +import { mkdirSync } from 'fs'; import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; +import { readSessionMeta } from '../../src/session/file-ops.js'; import { HookService } from '../../src/hooks/port.js'; import { McpService } from '../../src/mcp/port.js'; import { RulesService } from '../../src/rules/port.js'; @@ -25,8 +26,8 @@ const mockMcpService = { } as any; const mockRulesService = { - getAllRules: () => '', - evictProjectRules: () => undefined, + getAllRules: () => Effect.succeed(''), + evictProjectRules: () => Effect.void, } as any; function makeLayer() { @@ -56,7 +57,7 @@ describe('SessionService disk setter/getter consistency', () => { return yield* session.create(cwd, { model: 'm', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -113,7 +114,7 @@ describe('SessionService disk setter/getter consistency', () => { }) ); expect(state.activeProfile).toBe('plan'); - expect(state.permissionMode).toBe('default'); + expect(state.permissionMode).toBe('ask'); }); it('setActiveProfile to build leaves permissionMode untouched', async () => { @@ -131,10 +132,10 @@ describe('SessionService disk setter/getter consistency', () => { }) ); expect(state.activeProfile).toBe('build'); - expect(state.permissionMode).toBe('default'); + expect(state.permissionMode).toBe('ask'); }); - it('setActiveProfile is durable across reload (file exists on disk)', async () => { + it('setActiveProfile is durable across reload (session head on disk)', async () => { await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; @@ -147,16 +148,10 @@ describe('SessionService disk setter/getter consistency', () => { return yield* session.load(cwd, sessionId); }) ); - expect( - existsSync(computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath) - ).toBe(true); - const idx = JSON.parse( - readFileSync( - computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, - 'utf8' - ) + const meta = readSessionMeta( + computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath ); - expect(idx.activeProfile).toBe('plan'); - expect(idx).not.toHaveProperty('mode'); + expect(meta?.activeProfile).toBe('plan'); + expect(meta).not.toHaveProperty('mode'); }); }); diff --git a/packages/codingcode/test/session/facade-surface.test.ts b/packages/codingcode/test/session/facade-surface.test.ts index a5bb1e22..b4593489 100644 --- a/packages/codingcode/test/session/facade-surface.test.ts +++ b/packages/codingcode/test/session/facade-surface.test.ts @@ -29,6 +29,7 @@ describe('session service surface', () => { 'renameSession', 'rollbackToTurn', 'setActiveProfile', + 'setModel', 'setPermissionMode', ]); }); diff --git a/packages/codingcode/test/session/filter-ui.test.ts b/packages/codingcode/test/session/filter-ui.test.ts index 186cf448..d39c6a4d 100644 --- a/packages/codingcode/test/session/filter-ui.test.ts +++ b/packages/codingcode/test/session/filter-ui.test.ts @@ -10,7 +10,7 @@ function makeBaseEvents(extra: SessionEvent[] = []): SessionEvent[] { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, @@ -127,7 +127,7 @@ describe('sessionEventsToTurns with summary', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, diff --git a/packages/codingcode/test/session/fork.test.ts b/packages/codingcode/test/session/fork.test.ts index 9865916f..980017b5 100644 --- a/packages/codingcode/test/session/fork.test.ts +++ b/packages/codingcode/test/session/fork.test.ts @@ -7,9 +7,9 @@ import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { filterForContext, buildContextMessages } from '../../src/context/context.js'; import { readHistory } from '../../src/session/file-ops.js'; -import type { SessionIndex, SessionEvent } from '../../src/contracts/session.js'; +import type { SessionMetaEvent, SessionEvent } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; -import { computePaths } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; const base = useTempProjectBase(); @@ -19,7 +19,6 @@ function makeFixture(sessionId: string, slug: string) { const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = paths.transcriptPath; - const indexPath = paths.indexPath; const lines: any[] = [ { @@ -27,6 +26,10 @@ function makeFixture(sessionId: string, slug: string) { sessionId, cwd, createdAt: new Date().toISOString(), + model: 'test', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'first' }, { @@ -60,22 +63,23 @@ function makeFixture(sessionId: string, slug: string) { writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { + return { cwd, dir, transcriptPath }; +} + +function makeState(sessionId: string, cwd: string, title: string, currentTurnId: number) { + return { + type: 'session_meta' as const, sessionId, cwd, - model: 'test', createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 7, - title: 'fixture', - currentTurnId: 3, - usage: undefined, + model: 'test', + title, activeProfile: 'build' as const, - permissionMode: 'default' as const, + permissionMode: 'ask' as const, + currentTurnId, + usage: undefined, + memorySnapshot: '', }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { cwd, dir, transcriptPath, indexPath }; } function readEvents(jsonlPath: string): SessionEvent[] { @@ -111,25 +115,13 @@ describe('forkSession', () => { const slug = randomUUID(); const fx = makeFixture(sessionId, slug); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 7, - currentTurnId: 3, - sessionMeta: null, - model: 'test', - title: 'fixture', - usage: undefined, - activeProfile: 'build' as const, - permissionMode: 'default' as const, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, 'fixture', 3); // Fork at turn 2 (user message "second") const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 2); + return yield* svc.forkSession(state as any, 2); }) ); @@ -154,25 +146,13 @@ describe('forkSession', () => { const slug = randomUUID(); const fx = makeFixture(sessionId, slug); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 7, - currentTurnId: 3, - sessionMeta: null, - model: 'test', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'fixture', - usage: undefined, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, 'fixture', 3); // Fork at non-existent turnId so chain = all events (including summary + compact) const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 999); + return yield* svc.forkSession(state as any, 999); }) ); @@ -205,24 +185,12 @@ describe('forkSession', () => { const slug = randomUUID(); const fx = makeFixture(sessionId, slug); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 7, - currentTurnId: 3, - sessionMeta: null, - model: 'test', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'fixture', - usage: undefined, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, 'fixture', 3); const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 2); + return yield* svc.forkSession(state as any, 2); }) ); @@ -263,40 +231,28 @@ describe('forkSession', () => { } }); - it('fork creates index.json with correct metadata', async () => { + it('fork writes correct metadata into the forked session head', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 7, - currentTurnId: 3, - sessionMeta: null, - model: 'test', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'fixture', - usage: undefined, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, 'fixture', 3); const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 1); + return yield* svc.forkSession(state as any, 1); }) ); - const newIndexPath = join(fx.dir, `${newSessionId}.index.json`); - expect(existsSync(newIndexPath)).toBe(true); + const newTranscriptPath = join(fx.dir, `${newSessionId}.jsonl`); + expect(existsSync(newTranscriptPath)).toBe(true); - const idx = JSON.parse(readFileSync(newIndexPath, 'utf8')) as SessionIndex; - expect(idx.sessionId).toBe(newSessionId); - expect(idx.title).toBe('fixture'); - expect(idx.permissionMode).toBe('default'); - expect(idx.model).toBe('test'); + const meta = readEvents(newTranscriptPath)[0] as SessionMetaEvent; + expect(meta.sessionId).toBe(newSessionId); + expect(meta.title).toBe('fixture'); + expect(meta.permissionMode).toBe('ask'); + expect(meta.model).toBe('test'); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } @@ -310,7 +266,6 @@ describe('forkSession', () => { const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = paths.transcriptPath; - const indexPath = paths.indexPath; const fixedSummaryUuid = '11111111-1111-1111-1111-111111111111'; const fixedCompactUuid = '22222222-2222-2222-2222-222222222222'; @@ -321,6 +276,10 @@ describe('forkSession', () => { sessionId, cwd, createdAt: new Date().toISOString(), + model: 'test', + title: 'uuid-fixture', + activeProfile: 'build', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'q1' }, { type: 'assistant', turnId: 1, content: 'a1', toolCalls: [] }, @@ -330,40 +289,14 @@ describe('forkSession', () => { { type: 'compact', uuid: fixedCompactUuid, startTurnId: 1, endTurnId: 2 }, ]; writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd, - model: 'test', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: lines.length - 1, - title: 'uuid-fixture', - currentTurnId: 2, - usage: undefined, - activeProfile: 'build' as const, - permissionMode: 'default' as const, - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); try { - const state = { - sessionId, - cwd, - messageCount: lines.length - 1, - currentTurnId: 2, - sessionMeta: null, - model: 'test', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'uuid-fixture', - usage: undefined, - memorySnapshot: '', - }; + const state = makeState(sessionId, cwd, 'uuid-fixture', 2); const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 999); + return yield* svc.forkSession(state as any, 999); }) ); diff --git a/packages/codingcode/test/session/index-write-error.test.ts b/packages/codingcode/test/session/index-write-error.test.ts deleted file mode 100644 index f85ef745..00000000 --- a/packages/codingcode/test/session/index-write-error.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { appendFileSync, mkdirSync } from 'fs'; -import { dirname } from 'path'; -import { Effect } from 'effect'; -import { SessionService } from '../../src/session/port.js'; -import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; -import { AgentError } from '../../src/core/error.js'; -import * as fs from 'fs'; - -vi.mock('fs', async (importOriginal) => ({ - ...(await importOriginal()), - writeFileSync: vi.fn(() => { - throw new Error('index write failed'); - }), -})); - -describe('SessionService — index write error propagation', () => { - it('recordUser propagates SESSION_IO_ERROR when writeFileSync throws', async () => { - const sessionId = 'idx-err-user'; - const cwd = '/tmp'; - const paths = computePaths(cwd, sessionId); - mkdirSync(dirname(paths.transcriptPath), { recursive: true }); - appendFileSync(paths.transcriptPath, '', 'utf8'); - const state: any = { - sessionId, - cwd, - messageCount: 0, - currentTurnId: 1, - sessionMeta: { - type: 'session_meta', - sessionId, - cwd, - createdAt: new Date().toISOString(), - activeProfile: 'build', - permissionMode: 'default', - }, - model: 'test', - activeProfile: 'build', - permissionMode: 'default', - title: 'idx-err', - usage: undefined, - memorySnapshot: '', - }; - - const exit = await Effect.runPromiseExit( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.recordUser(state, 'hello'); - }).pipe(Effect.provide(SessionLayer)) - ); - - expect(exit._tag).toBe('Failure'); - if (exit._tag === 'Failure') { - const msg = String(exit.cause); - expect(msg).toContain('SESSION_IO_ERROR'); - expect(msg).toContain('index write failed'); - } - }); - - it('recordAssistant propagates SESSION_IO_ERROR when writeFileSync throws', async () => { - const sessionId = 'idx-err-asst'; - const cwd = '/tmp'; - const paths = computePaths(cwd, sessionId); - mkdirSync(dirname(paths.transcriptPath), { recursive: true }); - appendFileSync(paths.transcriptPath, '', 'utf8'); - const state: any = { - sessionId, - cwd, - messageCount: 0, - currentTurnId: 1, - sessionMeta: { - type: 'session_meta', - sessionId, - cwd, - createdAt: new Date().toISOString(), - activeProfile: 'build', - permissionMode: 'default', - }, - model: 'test', - activeProfile: 'build', - permissionMode: 'default', - title: 'idx-err', - usage: undefined, - memorySnapshot: '', - }; - - const exit = await Effect.runPromiseExit( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.recordAssistant(state, 'hi', []); - }).pipe(Effect.provide(SessionLayer)) - ); - - expect(exit._tag).toBe('Failure'); - if (exit._tag === 'Failure') { - const msg = String(exit.cause); - expect(msg).toContain('SESSION_IO_ERROR'); - expect(msg).toContain('index write failed'); - } - }); -}); diff --git a/packages/codingcode/test/session/index-write-sync.test.ts b/packages/codingcode/test/session/index-write-sync.test.ts deleted file mode 100644 index cb3af999..00000000 --- a/packages/codingcode/test/session/index-write-sync.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { randomUUID } from 'crypto'; -import { Effect } from 'effect'; -import { SessionService } from '../../src/session/port.js'; -import { SessionLayer } from '../../src/session/session.js'; - -import { encodeProjectPath, computePaths } from '../../src/core/path.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; -import { useTempProjectBase } from '../helpers/project-base.js'; - -const base = useTempProjectBase(); - -function run(eff: Effect.Effect): Promise { - return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); -} - -describe('index write is synchronous', () => { - it('recordUser immediately updates index file', async () => { - const slug = randomUUID(); - const dir = join(base.dir, slug); - mkdirSync(dir, { recursive: true }); - - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, { - model: 'test-model', - activeProfile: 'build', - permissionMode: 'default', - }); - }) - ); - - const indexPath = computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; - - const before = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; - expect(before.messageCount).toBe(1); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordUser(state, 'hello'); - }) - ); - - const after = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; - expect(after.messageCount).toBe(2); - expect(after.title).toBe('hello'); - } finally { - rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('recordAssistant immediately updates index file', async () => { - const slug = randomUUID(); - const dir = join(base.dir, slug); - mkdirSync(dir, { recursive: true }); - - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, { - model: 'test-model', - activeProfile: 'build', - permissionMode: 'default', - }); - }) - ); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordUser(state, 'hello'); - }) - ); - - const indexPath = computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath; - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'reply', [ - { id: 'tc1', name: 'bash', arguments: { cmd: 'echo' } }, - ]); - }) - ); - - const updated = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; - expect(updated.messageCount).toBe(3); - } finally { - rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/codingcode/test/session/io-error.test.ts b/packages/codingcode/test/session/io-error.test.ts index 0dfbc591..b6907d52 100644 --- a/packages/codingcode/test/session/io-error.test.ts +++ b/packages/codingcode/test/session/io-error.test.ts @@ -15,23 +15,15 @@ vi.mock('fs', async (importOriginal) => ({ describe('SessionService — SESSION_IO_ERROR', () => { it('recordUser propagates SESSION_IO_ERROR when appendFileSync throws', async () => { const state: any = { + type: 'session_meta', sessionId: 'io-err-sid', cwd: '/tmp', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { - type: 'session_meta', - sessionId: 'io-err-sid', - cwd: '/tmp', - createdAt: new Date().toISOString(), - activeProfile: 'build', - permissionMode: 'default', - }, + createdAt: new Date().toISOString(), model: 'test', + title: 'io-err-s', activeProfile: 'build', - permissionMode: 'default', - - title: 'io-err-sid'.slice(0, 8), + permissionMode: 'ask', + currentTurnId: 1, usage: undefined, memorySnapshot: '', }; @@ -53,23 +45,15 @@ describe('SessionService — SESSION_IO_ERROR', () => { it('recordAssistant propagates SESSION_IO_ERROR when appendFileSync throws', async () => { const state: any = { + type: 'session_meta', sessionId: 'io-err-asst', cwd: '/tmp', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { - type: 'session_meta', - sessionId: 'io-err-asst', - cwd: '/tmp', - createdAt: new Date().toISOString(), - activeProfile: 'build', - permissionMode: 'default', - }, + createdAt: new Date().toISOString(), model: 'test', + title: 'io-err-a', activeProfile: 'build', - permissionMode: 'default', - - title: 'io-err-asst'.slice(0, 8), + permissionMode: 'ask', + currentTurnId: 1, usage: undefined, memorySnapshot: '', }; @@ -90,23 +74,15 @@ describe('SessionService — SESSION_IO_ERROR', () => { it('Effect.try wraps I/O error as SESSION_IO_ERROR in service method', async () => { const state: any = { + type: 'session_meta', sessionId: 'io-err-eff', cwd: '/tmp', - messageCount: 0, - currentTurnId: 1, - sessionMeta: { - type: 'session_meta', - sessionId: 'io-err-eff', - cwd: '/tmp', - createdAt: new Date().toISOString(), - activeProfile: 'build', - permissionMode: 'default', - }, + createdAt: new Date().toISOString(), model: 'test', + title: 'io-err-e', activeProfile: 'build', - permissionMode: 'default', - - title: 'io-err-eff'.slice(0, 8), + permissionMode: 'ask', + currentTurnId: 1, usage: undefined, memorySnapshot: '', }; diff --git a/packages/codingcode/test/session/load-create.test.ts b/packages/codingcode/test/session/load-create.test.ts index 2bdd9833..4fd334a4 100644 --- a/packages/codingcode/test/session/load-create.test.ts +++ b/packages/codingcode/test/session/load-create.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { mkdirSync, readFileSync, rmSync } from 'fs'; +import { mkdirSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { AgentError } from '../../src/core/error.js'; -import { encodeProjectPath, computePaths } from '../../src/core/path.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; +import { encodeProjectPath } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; +import { readSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -21,8 +21,8 @@ function cleanup(dir: string) { rmSync(dir, { recursive: true, force: true }); } -describe('load — restores model from disk, not overwritten', () => { - it('load restores model from index.json, not overwritten by caller', async () => { +describe('load — keeps the persisted model untouched', () => { + it('load does not overwrite model in the session head', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -34,7 +34,7 @@ describe('load — restores model from disk, not overwritten', () => { return yield* svc.create(dir, { model: 'gpt-4o', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -47,15 +47,18 @@ describe('load — restores model from disk, not overwritten', () => { }) ); - expect(loaded.model).toBe('gpt-4o'); expect(loaded.sessionId).toBe(sid); - expect(loaded.sessionMeta).not.toBeNull(); + + const meta = readSessionMeta( + computePaths(created.cwd, created.sessionId, created.parentSessionId).transcriptPath + ); + expect(meta?.model).toBe('gpt-4o'); } finally { cleanup(dir); } }); - it('load then rollbackToTurn preserves real model in index.json', async () => { + it('load then rollbackToTurn preserves real model in the session head', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -67,7 +70,7 @@ describe('load — restores model from disk, not overwritten', () => { return yield* svc.create(dir, { model: 'claude-3-5-sonnet', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -81,13 +84,12 @@ describe('load — restores model from disk, not overwritten', () => { }) ); - const beforeRollback = JSON.parse( - readFileSync( - computePaths(created.cwd, created.sessionId, created.parentSessionId).indexPath, - 'utf8' - ) - ) as SessionIndex; - expect(beforeRollback.model).toBe('claude-3-5-sonnet'); + const transcriptPath = computePaths( + created.cwd, + created.sessionId, + created.parentSessionId + ).transcriptPath; + expect(readSessionMeta(transcriptPath)?.model).toBe('claude-3-5-sonnet'); await run( Effect.gen(function* () { @@ -97,13 +99,7 @@ describe('load — restores model from disk, not overwritten', () => { }) ); - const afterRollback = JSON.parse( - readFileSync( - computePaths(created.cwd, created.sessionId, created.parentSessionId).indexPath, - 'utf8' - ) - ) as SessionIndex; - expect(afterRollback.model).toBe('claude-3-5-sonnet'); + expect(readSessionMeta(transcriptPath)?.model).toBe('claude-3-5-sonnet'); } finally { cleanup(dir); } @@ -146,7 +142,7 @@ describe('load — restores model from disk, not overwritten', () => { return yield* svc.create(dir, { model: 'gpt-4o', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -183,21 +179,20 @@ describe('create — generates sessionId internally', () => { return yield* svc.create(dir, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); expect(state.sessionId).toBeTruthy(); expect(state.sessionId.length).toBeGreaterThan(8); - expect(state.model).toBe('test-model'); - expect(state.sessionMeta).not.toBeNull(); + expect(state.type).toBe('session_meta'); } finally { cleanup(dir); } }); - it('create writes model to index.json immediately', async () => { + it('create writes model to the session head immediately', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -209,18 +204,15 @@ describe('create — generates sessionId internally', () => { return yield* svc.create(dir, { model: 'my-special-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); - const idx = JSON.parse( - readFileSync( - computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, - 'utf8' - ) - ) as SessionIndex; - expect(idx.model).toBe('my-special-model'); + const meta = readSessionMeta( + computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath + ); + expect(meta?.model).toBe('my-special-model'); } finally { cleanup(dir); } @@ -238,7 +230,7 @@ describe('create — generates sessionId internally', () => { return yield* svc.create(dir, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -253,7 +245,7 @@ describe('create — generates sessionId internally', () => { }); describe('load restores persisted fields', () => { - it('load restores currentTurnId from index.json', async () => { + it('load restores currentTurnId from the transcript tail', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -265,7 +257,7 @@ describe('load restores persisted fields', () => { return yield* svc.create(dir, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -286,14 +278,6 @@ describe('load restores persisted fields', () => { }) ); - const idx = JSON.parse( - readFileSync( - computePaths(created.cwd, created.sessionId, created.parentSessionId).indexPath, - 'utf8' - ) - ) as SessionIndex; - expect(idx.currentTurnId).toBe(2); - const loaded = await run( Effect.gen(function* () { const svc = yield* SessionService; @@ -307,7 +291,7 @@ describe('load restores persisted fields', () => { } }); - it('load restores usage from index.json', async () => { + it('load restores usage from the transcript tail', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -319,7 +303,7 @@ describe('load restores persisted fields', () => { return yield* svc.create(dir, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -343,13 +327,7 @@ describe('load restores persisted fields', () => { }) ); - const idx = JSON.parse( - readFileSync( - computePaths(created.cwd, created.sessionId, created.parentSessionId).indexPath, - 'utf8' - ) - ) as SessionIndex; - expect(idx.usage).toEqual(loaded.usage); + expect(loaded.usage).toBeUndefined(); } finally { cleanup(dir); } diff --git a/packages/codingcode/test/session/load-restore-profile.test.ts b/packages/codingcode/test/session/load-restore-profile.test.ts index 4284170e..16a1ae3f 100644 --- a/packages/codingcode/test/session/load-restore-profile.test.ts +++ b/packages/codingcode/test/session/load-restore-profile.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect, ManagedRuntime } from 'effect'; -import { mkdirSync, writeFileSync, readFileSync } from 'fs'; +import { mkdirSync } from 'fs'; import { join } from 'path'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; +import { readSessionMeta, rewriteSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -12,7 +13,7 @@ const base = useTempProjectBase(); describe('SessionStoreState.activeProfile persistence (disk only)', () => { let cwd: string; let sessionId: string; - let indexPath: string; + let transcriptPath: string; let rt: ManagedRuntime.ManagedRuntime; function loadState() { @@ -34,16 +35,17 @@ describe('SessionStoreState.activeProfile persistence (disk only)', () => { const state = yield* session.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); return { sessionId: state.sessionId, - indexPath: computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, + transcriptPath: computePaths(state.cwd, state.sessionId, state.parentSessionId) + .transcriptPath, }; }) ); sessionId = result.sessionId; - indexPath = result.indexPath; + transcriptPath = result.transcriptPath; }); afterEach(async () => { @@ -67,11 +69,8 @@ describe('SessionStoreState.activeProfile persistence (disk only)', () => { expect(stateAfter.activeProfile).toBe('plan'); }); - it('state.activeProfile is set when index file has activeProfile field', async () => { - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - idx.activeProfile = 'plan'; - idx.permissionMode = 'default'; - writeFileSync(indexPath, JSON.stringify(idx, null, 2)); + it('state.activeProfile is set when the session head carries activeProfile', async () => { + rewriteSessionMeta(transcriptPath, { activeProfile: 'plan', permissionMode: 'ask' }); const state = await loadState(); expect(state.activeProfile).toBe('plan'); @@ -84,7 +83,7 @@ describe('SessionStoreState.activeProfile persistence (disk only)', () => { yield* session.setActiveProfile(cwd, sessionId, 'plan'); }) ); - const idx = JSON.parse(readFileSync(indexPath, 'utf8')); - expect(idx.activeProfile).toBe('plan'); + const meta = readSessionMeta(transcriptPath); + expect(meta?.activeProfile).toBe('plan'); }); }); diff --git a/packages/codingcode/test/session/parent-session-id.test.ts b/packages/codingcode/test/session/parent-session-id.test.ts index 7ce7a47f..41d18128 100644 --- a/packages/codingcode/test/session/parent-session-id.test.ts +++ b/packages/codingcode/test/session/parent-session-id.test.ts @@ -1,20 +1,19 @@ import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { join } from 'path'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { encodeProjectPath, computePaths } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; +import { readSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; -const base = useTempProjectBase(); +useTempProjectBase(); function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); } -describe('parentSessionId in index.json', () => { - it('write parentSessionId to index.json when passed to create opts', async () => { +describe('parentSessionId in session head', () => { + it('writes parentSessionId to the session head when passed to create opts', async () => { const cwd = '/tmp/test-parent-session-id'; const parentId = '00000000-0000-0000-0000-000000000001'; const state = await run( @@ -22,21 +21,16 @@ describe('parentSessionId in index.json', () => { const svc = yield* SessionService; return yield* svc.create( cwd, - { model: 'gpt-4o', activeProfile: 'build', permissionMode: 'default' }, + { model: 'gpt-4o', activeProfile: 'build', permissionMode: 'ask' }, { parentSessionId: parentId } ); }) ); - const idxRaw = readFileSync( - computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, - 'utf8' + const meta = readSessionMeta( + computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath ); - const idx = JSON.parse(idxRaw); - expect(idx.parentSessionId).toBe(parentId); - expect(idx.sessionId).toBe(state.sessionId); - - const projectDir = join(base.dir, encodeProjectPath(cwd)); - void projectDir; + expect(meta?.parentSessionId).toBe(parentId); + expect(meta?.sessionId).toBe(state.sessionId); }); }); diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index 10d80b79..484a6601 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; @@ -7,10 +7,11 @@ import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { estimatePromptTokensFrom } from '../../src/context/context.js'; -import { readHistory } from '../../src/session/file-ops.js'; +import { readHistory, readLastUsage, readSessionMeta } from '../../src/session/file-ops.js'; import { estimateTokensForContent } from '../../src/context/tokens.js'; -import { encodeProjectPath, computePaths } from '../../src/core/path.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; +import { encodeProjectPath } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; +import type { SessionStoreState } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -25,7 +26,6 @@ function makeFixture( const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = paths.transcriptPath; - const indexPath = paths.indexPath; const lines: any[] = [ { @@ -33,6 +33,10 @@ function makeFixture( sessionId, cwd, createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, { type: 'user', @@ -68,22 +72,27 @@ function makeFixture( writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { + return { cwd, dir, transcriptPath }; +} + +function makeState( + sessionId: string, + cwd: string, + usage: { prompt: number; completion: number; total: number } | undefined +): SessionStoreState { + return { + type: 'session_meta', sessionId, cwd, - model: 'test-model', createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 4, + model: 'test-model', title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', currentTurnId: 2, - usage: usage ?? undefined, - activeProfile: 'build' as const, - permissionMode: 'default' as const, + memorySnapshot: '', + usage, }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { cwd, dir, transcriptPath, indexPath }; } function run(eff: Effect.Effect): Promise { @@ -91,69 +100,41 @@ function run(eff: Effect.Effect): Promise { } describe('promptEstimate', () => { - it('forkSession restores usage and promptEstimate from last visible assistant', async () => { + it('forkSession keeps the last visible assistant usage on the forked transcript', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const usage = { prompt: 800, completion: 400, total: 1200 }; const fx = makeFixture(sessionId, slug, usage); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 4, - currentTurnId: 2, - sessionMeta: null, - model: 'test-model', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'fixture', - usage, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, usage); const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; return yield* svc.forkSession(state, 2); }) ); - const newIndexPath = join(fx.dir, `${newSessionId}.index.json`); - const idx = JSON.parse(readFileSync(newIndexPath, 'utf8')) as SessionIndex; - expect(idx.usage).toEqual(usage); + const forkedPath = join(fx.dir, `${newSessionId}.jsonl`); + expect(readLastUsage(forkedPath)).toEqual(usage); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); - it('forkSession falls back to estimateTokens when no assistant usage', async () => { + it('forkSession produces a readable session with a positive prompt estimate when no assistant usage', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug, undefined); try { - const state = { - sessionId, - cwd: fx.cwd, - messageCount: 4, - currentTurnId: 2, - sessionMeta: null, - model: 'test-model', - activeProfile: 'build' as const, - permissionMode: 'default' as const, - title: 'fixture', - usage: undefined, - memorySnapshot: '', - }; + const state = makeState(sessionId, fx.cwd, undefined); const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; return yield* svc.forkSession(state, 2); }) ); - const newIndexPath = join(fx.dir, `${newSessionId}.index.json`); - const idx = JSON.parse(readFileSync(newIndexPath, 'utf8')) as SessionIndex; - expect(idx.sessionId).toBe(newSessionId); - expect( - estimatePromptTokensFrom(readHistory(join(fx.dir, `${newSessionId}.jsonl`))) - ).toBeGreaterThan(0); + const forkedPath = join(fx.dir, `${newSessionId}.jsonl`); + expect(readSessionMeta(forkedPath)?.sessionId).toBe(newSessionId); + expect(estimatePromptTokensFrom(readHistory(forkedPath))).toBeGreaterThan(0); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } @@ -168,7 +149,7 @@ describe('token estimation', () => { }); describe('SessionService create sets model', () => { - it('create sets state.model and persists it to index', async () => { + it('create persists model to the session head', async () => { const slug = randomUUID(); const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); @@ -179,19 +160,15 @@ describe('SessionService create sets model', () => { return yield* svc.create(dir, { model: 'my-test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); - expect(state.model).toBe('my-test-model'); - const idx = JSON.parse( - readFileSync( - computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath, - 'utf8' - ) + const meta = readSessionMeta( + computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath ); - expect(idx.model).toBe('my-test-model'); + expect(meta?.model).toBe('my-test-model'); } finally { await new Promise((r) => setTimeout(r, 50)); rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); diff --git a/packages/codingcode/test/session/record-tool-result-persist.test.ts b/packages/codingcode/test/session/record-tool-result-persist.test.ts index 3d4aeab2..f9cca5b0 100644 --- a/packages/codingcode/test/session/record-tool-result-persist.test.ts +++ b/packages/codingcode/test/session/record-tool-result-persist.test.ts @@ -19,7 +19,7 @@ describe('recordToolResult', () => { return yield* svc.create('/tmp/persist-test', { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -52,7 +52,7 @@ describe('recordToolResult', () => { return yield* svc.create('/tmp/persist-test-small', { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); diff --git a/packages/codingcode/test/session/record-user-title.test.ts b/packages/codingcode/test/session/record-user-title.test.ts new file mode 100644 index 00000000..4ca8dcf8 --- /dev/null +++ b/packages/codingcode/test/session/record-user-title.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from 'vitest'; +import { mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/port.js'; +import { SessionLayer } from '../../src/session/session.js'; +import { encodeProjectPath } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; +import { readSessionMeta } from '../../src/session/file-ops.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); +} + +function cleanup(dir: string) { + rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); +} + +function newDir(): string { + const dir = join(base.dir, randomUUID()); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function metaOf(cwd: string, sessionId: string, parentSessionId?: string) { + return readSessionMeta(computePaths(cwd, sessionId, parentSessionId).transcriptPath); +} + +describe('title persistence — backfilled from the first user message', () => { + it('create without an explicit title persists an empty title (no id placeholder)', async () => { + const dir = newDir(); + try { + const created = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, { + model: 'test-model', + activeProfile: 'build', + permissionMode: 'ask', + }); + }) + ); + + expect(created.title).toBe(''); + expect(metaOf(dir, created.sessionId)?.title).toBe(''); + } finally { + cleanup(dir); + } + }); + + it('first recordUser derives and persists a title from the user message', async () => { + const dir = newDir(); + try { + const created = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, { + model: 'test-model', + activeProfile: 'build', + permissionMode: 'ask', + }); + }) + ); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + const state = yield* svc.load(dir, created.sessionId); + yield* svc.recordUser(state, 'fix the login bug'); + }) + ); + + expect(metaOf(dir, created.sessionId)?.title).toBe('fix the login bug'); + } finally { + cleanup(dir); + } + }); + + it('collapses newlines and truncates long content', async () => { + const dir = newDir(); + try { + const created = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, { + model: 'test-model', + activeProfile: 'build', + permissionMode: 'ask', + }); + }) + ); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + const state = yield* svc.load(dir, created.sessionId); + yield* svc.recordUser(state, `${'a'.repeat(40)}\nsecond line`); + }) + ); + + expect(metaOf(dir, created.sessionId)?.title).toBe(`${'a'.repeat(30)}...`); + } finally { + cleanup(dir); + } + }); + + it('later user messages do not overwrite the already-set title', async () => { + const dir = newDir(); + try { + const created = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, { + model: 'test-model', + activeProfile: 'build', + permissionMode: 'ask', + }); + }) + ); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + const state = yield* svc.load(dir, created.sessionId); + yield* svc.recordUser(state, 'first message'); + }) + ); + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + const state = yield* svc.load(dir, created.sessionId); + yield* svc.recordUser(state, 'second message should not win'); + }) + ); + + expect(metaOf(dir, created.sessionId)?.title).toBe('first message'); + } finally { + cleanup(dir); + } + }); + + it('an explicit title passed to create is never overwritten by the first message', async () => { + const dir = newDir(); + try { + const created = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, { + model: 'test-model', + title: 'explicit title', + activeProfile: 'build', + permissionMode: 'ask', + }); + }) + ); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + const state = yield* svc.load(dir, created.sessionId); + yield* svc.recordUser(state, 'a totally different message'); + }) + ); + + expect(metaOf(dir, created.sessionId)?.title).toBe('explicit title'); + } finally { + cleanup(dir); + } + }); + + it('a user rename is preserved against auto-backfill', async () => { + const dir = newDir(); + try { + const created = await run( + Effect.gen(function* () { + const svc = yield* SessionService; + return yield* svc.create(dir, { + model: 'test-model', + activeProfile: 'build', + permissionMode: 'ask', + }); + }) + ); + + await run( + Effect.gen(function* () { + const svc = yield* SessionService; + const state = yield* svc.load(dir, created.sessionId); + yield* svc.renameSession(state, 'my custom name'); + yield* svc.recordUser(state, 'first message'); + }) + ); + + expect(metaOf(dir, created.sessionId)?.title).toBe('my custom name'); + } finally { + cleanup(dir); + } + }); +}); diff --git a/packages/codingcode/test/session/rollback.test.ts b/packages/codingcode/test/session/rollback.test.ts index b8651691..23ab3944 100644 --- a/packages/codingcode/test/session/rollback.test.ts +++ b/packages/codingcode/test/session/rollback.test.ts @@ -4,7 +4,6 @@ import { join } from 'path'; import { randomUUID } from 'crypto'; import { filterForContext, buildContextMessages } from '../../src/context/context.js'; import { readHistory } from '../../src/session/file-ops.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -13,7 +12,6 @@ function makeFixture(sessionId: string, slug: string) { const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); - const indexPath = join(dir, `${sessionId}.index.json`); const lines: any[] = [ { @@ -21,6 +19,10 @@ function makeFixture(sessionId: string, slug: string) { sessionId, cwd: '/tmp/test', createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, @@ -44,22 +46,7 @@ function makeFixture(sessionId: string, slug: string) { writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd: '/tmp/test', - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 7, - title: 'fixture', - currentTurnId: 3, - usage: undefined, - activeProfile: 'build', - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { dir, transcriptPath, indexPath }; + return { dir, transcriptPath }; } function appendEvent(jsonlPath: string, event: object): void { diff --git a/packages/codingcode/test/session/session-jsonl-path.test.ts b/packages/codingcode/test/session/session-jsonl-path.test.ts index fae54972..c883dae7 100644 --- a/packages/codingcode/test/session/session-jsonl-path.test.ts +++ b/packages/codingcode/test/session/session-jsonl-path.test.ts @@ -6,7 +6,7 @@ import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { deleteSession, sessionJsonlPathFromCwd } from '../../src/session/file-ops.js'; -import { computePaths } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -24,7 +24,7 @@ describe('sessionJsonlPathFromCwd', () => { return yield* svc.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -51,7 +51,7 @@ describe('sessionJsonlPathFromCwd', () => { return yield* svc.create(cwd, { model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); }) ); @@ -60,18 +60,12 @@ describe('sessionJsonlPathFromCwd', () => { expect( existsSync(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath) ).toBe(true); - expect( - existsSync(computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath) - ).toBe(true); deleteSession(state.sessionId, cwd); expect( existsSync(computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath) ).toBe(false); - expect( - existsSync(computePaths(state.cwd, state.sessionId, state.parentSessionId).indexPath) - ).toBe(false); } finally { rmSync( join(base.dir, computePaths(state.cwd, state.sessionId, state.parentSessionId).projectPath), diff --git a/packages/codingcode/test/session/store-compact-usage.test.ts b/packages/codingcode/test/session/store-compact-usage.test.ts index 77737b65..e0c0b2bb 100644 --- a/packages/codingcode/test/session/store-compact-usage.test.ts +++ b/packages/codingcode/test/session/store-compact-usage.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; +import type { SessionStoreState } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -29,7 +29,6 @@ function makeFixture( const paths = computePaths(cwd, sessionId); mkdirSync(join(base.dir, slug, 'sessions'), { recursive: true }); const transcriptPath = paths.transcriptPath; - const indexPath = paths.indexPath; const lines: any[] = [ { @@ -37,6 +36,10 @@ function makeFixture( sessionId, cwd, createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, ]; turns.forEach((t, i) => { @@ -53,22 +56,7 @@ function makeFixture( writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd, - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: lines.length, - title: 'fixture', - currentTurnId: turns.length, - usage: turns[turns.length - 1]?.usage, - activeProfile: 'build', - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { cwd, transcriptPath, indexPath }; + return { cwd, transcriptPath }; } function buildState( @@ -76,31 +64,24 @@ function buildState( cwd: string, initialUsage: { prompt: number; completion: number; total: number } | undefined, currentTurnId: number -) { +): SessionStoreState { return { + type: 'session_meta', sessionId, cwd, - messageCount: 0, - currentTurnId, - sessionMeta: { - type: 'session_meta' as const, - sessionId, - cwd, - createdAt: new Date().toISOString(), - activeProfile: 'build' as const, - permissionMode: 'default' as const, - }, + createdAt: new Date().toISOString(), model: 'test-model', - activeProfile: 'build' as const, - permissionMode: 'default' as const, title: 'fixture', - usage: initialUsage, + activeProfile: 'build', + permissionMode: 'ask', + currentTurnId, memorySnapshot: '', + usage: initialUsage, }; } describe('SessionService.appendSummary - state.usage reset (used by tryCompaction)', () => { - it('clears state.usage and persists the cleared value to the session index', async () => { + it('clears state.usage', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const usage1 = { prompt: 100, completion: 50, total: 150 }; @@ -114,14 +95,12 @@ describe('SessionService.appendSummary - state.usage reset (used by tryCompactio }) ); expect(state.usage).toBeUndefined(); - const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; - expect(idx.usage).toBeUndefined(); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); - it('preserves state.usage when called with state that has no prior usage', async () => { + it('keeps state.usage undefined when called with state that has no prior usage', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug, [{ user: 'q1', assistant: 'a1', usage: undefined }]); @@ -134,8 +113,6 @@ describe('SessionService.appendSummary - state.usage reset (used by tryCompactio }) ); expect(state.usage).toBeUndefined(); - const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; - expect(idx.usage).toBeUndefined(); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } diff --git a/packages/codingcode/test/session/store-diff-rebuild.test.ts b/packages/codingcode/test/session/store-diff-rebuild.test.ts index 04a608e2..3b7ced75 100644 --- a/packages/codingcode/test/session/store-diff-rebuild.test.ts +++ b/packages/codingcode/test/session/store-diff-rebuild.test.ts @@ -11,7 +11,7 @@ describe('sessionEventsToTurns', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -61,7 +61,7 @@ describe('sessionEventsToTurns', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -109,7 +109,7 @@ describe('sessionEventsToTurns', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', diff --git a/packages/codingcode/test/session/store-rollback-usage.test.ts b/packages/codingcode/test/session/store-rollback-usage.test.ts index fa195c02..95bf5462 100644 --- a/packages/codingcode/test/session/store-rollback-usage.test.ts +++ b/packages/codingcode/test/session/store-rollback-usage.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; -import { computePaths } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; -import type { SessionIndex } from '../../src/contracts/session.js'; +import type { SessionStoreState } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -29,7 +29,6 @@ function makeFixture( const paths = computePaths(cwd, sessionId); mkdirSync(join(base.dir, slug, 'sessions'), { recursive: true }); const transcriptPath = paths.transcriptPath; - const indexPath = paths.indexPath; const lines: any[] = [ { @@ -37,6 +36,10 @@ function makeFixture( sessionId, cwd, createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, ]; turns.forEach((t, i) => { @@ -53,22 +56,7 @@ function makeFixture( writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd, - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: lines.length, - title: 'fixture', - currentTurnId: turns.length, - usage: turns[turns.length - 1]?.usage, - activeProfile: 'build', - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { cwd, transcriptPath, indexPath }; + return { cwd, transcriptPath }; } function buildState( @@ -76,26 +64,19 @@ function buildState( cwd: string, initialUsage: { prompt: number; completion: number; total: number } | undefined, currentTurnId: number -) { +): SessionStoreState { return { + type: 'session_meta', sessionId, cwd, - messageCount: 0, - currentTurnId, - sessionMeta: { - type: 'session_meta' as const, - sessionId, - cwd, - createdAt: new Date().toISOString(), - activeProfile: 'build' as const, - permissionMode: 'default' as const, - }, + createdAt: new Date().toISOString(), model: 'test-model', - activeProfile: 'build' as const, - permissionMode: 'default' as const, title: 'fixture', - usage: initialUsage, + activeProfile: 'build', + permissionMode: 'ask', + currentTurnId, memorySnapshot: '', + usage: initialUsage, }; } @@ -107,15 +88,15 @@ describe('SessionService.rollbackToTurn - state.usage reset', () => { const fx = makeFixture(sessionId, slug, [{ user: 'q1', assistant: 'a1', usage: usage1 }]); try { const state = buildState(sessionId, fx.cwd, usage1, 1); - await run( + const reloaded = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.rollbackToTurn(state, 1, 'user rollback'); + yield* svc.rollbackToTurn(state, 1, 'user rollback'); + return yield* svc.load(fx.cwd, sessionId); }) ); expect(state.usage).toBeUndefined(); - const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; - expect(idx.usage).toBeUndefined(); + expect(reloaded.usage).toBeUndefined(); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } @@ -134,15 +115,15 @@ describe('SessionService.rollbackToTurn - state.usage reset', () => { ]); try { const state = buildState(sessionId, fx.cwd, usage3, 3); - await run( + const reloaded = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.rollbackToTurn(state, 2, 'user rollback'); + yield* svc.rollbackToTurn(state, 2, 'user rollback'); + return yield* svc.load(fx.cwd, sessionId); }) ); expect(state.usage).toEqual(usage1); - const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; - expect(idx.usage).toEqual(usage1); + expect(reloaded.usage).toEqual(usage1); } finally { rmSync(join(base.dir, slug), { recursive: true, force: true }); } diff --git a/packages/codingcode/test/session/types-export.test.ts b/packages/codingcode/test/session/types-export.test.ts index 5068818e..cf9b248e 100644 --- a/packages/codingcode/test/session/types-export.test.ts +++ b/packages/codingcode/test/session/types-export.test.ts @@ -4,16 +4,16 @@ import type { SessionStoreState } from '../../src/contracts/session.js'; describe('SessionStoreState export', () => { it('contains only cwd as its path source', () => { const state: SessionStoreState = { + type: 'session_meta', sessionId: 'test-sid', cwd: '/tmp', - messageCount: 0, - sessionMeta: null, + createdAt: '2026-01-01T00:00:00.000Z', model: 'gpt-4', title: '', + activeProfile: 'build', + permissionMode: 'ask', currentTurnId: 0, usage: undefined, - activeProfile: 'build', - permissionMode: 'default', memorySnapshot: '', }; diff --git a/packages/codingcode/test/session/ui-history-rollback.test.ts b/packages/codingcode/test/session/ui-history-rollback.test.ts index db73b85f..4f23b24b 100644 --- a/packages/codingcode/test/session/ui-history-rollback.test.ts +++ b/packages/codingcode/test/session/ui-history-rollback.test.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'crypto'; import { filterForContext, buildContextMessages } from '../../src/context/context.js'; import { readHistory } from '../../src/session/file-ops.js'; import { filterForUI } from '../../src/session/session.js'; -import type { SessionEvent, SessionIndex } from '../../src/contracts/session.js'; +import type { SessionEvent } from '../../src/contracts/session.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -14,7 +14,6 @@ function makeFixture(sessionId: string, slug: string, extraEvents?: object[]) { const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); - const indexPath = join(dir, `${sessionId}.index.json`); const lines: any[] = [ { @@ -22,6 +21,10 @@ function makeFixture(sessionId: string, slug: string, extraEvents?: object[]) { sessionId, cwd: '/tmp/test', createdAt: new Date().toISOString(), + model: 'test-model', + title: 'fixture', + activeProfile: 'build', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, @@ -46,22 +49,7 @@ function makeFixture(sessionId: string, slug: string, extraEvents?: object[]) { writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - const idx: SessionIndex = { - sessionId, - cwd: '/tmp/test', - model: 'test-model', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: lines.length, - title: 'fixture', - currentTurnId: 3, - usage: undefined, - activeProfile: 'build', - permissionMode: 'default', - }; - writeFileSync(indexPath, JSON.stringify(idx, null, 2), 'utf8'); - - return { dir, transcriptPath, indexPath }; + return { dir, transcriptPath }; } describe('filterForContext', () => { @@ -75,8 +63,10 @@ describe('filterForContext', () => { sessionId, cwd: '/tmp', createdAt: new Date().toISOString(), + model: 'test-model', + title: 'test', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, @@ -122,8 +112,10 @@ describe('readUIHistory with visibility filtering', () => { sessionId, cwd: '/tmp', createdAt: new Date().toISOString(), + model: 'test-model', + title: 'test', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { type: 'assistant', turnId: 1, content: 'hi', toolCalls: [] }, @@ -135,22 +127,6 @@ describe('readUIHistory with visibility filtering', () => { mkdirSync(dir, { recursive: true }); const tp = join(dir, `${sessionId}.jsonl`); writeFileSync(tp, events.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); - writeFileSync( - join(dir, `${sessionId}.index.json`), - JSON.stringify({ - sessionId, - cwd: '/tmp', - model: 't', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 7, - title: 'test', - currentTurnId: 2, - usage: undefined, - activeProfile: 'build', - permissionMode: 'default', - }) - ); const visible = filterForUI(readHistory(tp)); const turnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); diff --git a/packages/codingcode/test/session/update-index-dedup.test.ts b/packages/codingcode/test/session/update-index-dedup.test.ts deleted file mode 100644 index b44310d4..00000000 --- a/packages/codingcode/test/session/update-index-dedup.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { mkdirSync, rmSync } from 'fs'; -import { join } from 'path'; -import { randomUUID } from 'crypto'; -import { Effect } from 'effect'; -import { SessionService } from '../../src/session/port.js'; -import { SessionLayer } from '../../src/session/session.js'; - -import { encodeProjectPath } from '../../src/core/path.js'; -import * as fileOps from '../../src/session/file-ops.js'; -import { useTempProjectBase } from '../helpers/project-base.js'; - -const base = useTempProjectBase(); - -function run(eff: Effect.Effect): Promise { - return Effect.runPromise(eff.pipe(Effect.provide(SessionLayer) as any)); -} - -describe('updateIndex writes from state without rereading the index', () => { - it('recordUser does not reread the index', async () => { - const slug = randomUUID(); - const dir = join(base.dir, slug); - mkdirSync(dir, { recursive: true }); - - const spy = vi.spyOn(fileOps, 'readCurrentIndex'); - - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, { - model: 'test-model', - activeProfile: 'build', - permissionMode: 'default', - }); - }) - ); - spy.mockClear(); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordUser(state, 'hello world'); - }) - ); - - expect(spy).not.toHaveBeenCalled(); - } finally { - spy.mockRestore(); - rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('recordAssistant does not reread the index', async () => { - const slug = randomUUID(); - const dir = join(base.dir, slug); - mkdirSync(dir, { recursive: true }); - - const spy = vi.spyOn(fileOps, 'readCurrentIndex'); - - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, { - model: 'test-model', - activeProfile: 'build', - permissionMode: 'default', - }); - }) - ); - spy.mockClear(); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.recordAssistant(state, 'reply', []); - }) - ); - - expect(spy).not.toHaveBeenCalled(); - } finally { - spy.mockRestore(); - rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('rollbackToTurn does not reread the index', async () => { - const slug = randomUUID(); - const dir = join(base.dir, slug); - mkdirSync(dir, { recursive: true }); - - const spy = vi.spyOn(fileOps, 'readCurrentIndex'); - - try { - const state = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - return yield* svc.create(dir, { - model: 'test-model', - activeProfile: 'build', - permissionMode: 'default', - }); - }) - ); - spy.mockClear(); - - await run( - Effect.gen(function* () { - const svc = yield* SessionService; - yield* svc.rollbackToTurn(state, 1, 'test'); - }) - ); - - expect(spy).not.toHaveBeenCalled(); - } finally { - spy.mockRestore(); - rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); - rmSync(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/codingcode/test/session/view-assembly.test.ts b/packages/codingcode/test/session/view-assembly.test.ts index 252dbce2..d970b854 100644 --- a/packages/codingcode/test/session/view-assembly.test.ts +++ b/packages/codingcode/test/session/view-assembly.test.ts @@ -15,7 +15,7 @@ function makeEvents(extra: SessionEvent[] = []): SessionEvent[] { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'hello' }, { @@ -109,7 +109,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -136,7 +136,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -185,7 +185,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -239,7 +239,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -283,7 +283,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', @@ -326,7 +326,7 @@ describe('buildContextMessages', () => { cwd: '/tmp', createdAt: new Date().toISOString(), activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }, { type: 'user', turnId: 1, content: 'q1' }, { diff --git a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts index 2183a952..3a2cdd75 100644 --- a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts +++ b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts @@ -10,7 +10,7 @@ import { AgentService } from '../../src/agent/port.js'; import { ApprovalService } from '../../src/approval/port.js'; import { CheckpointService } from '../../src/checkpoint/port.js'; import { ContextService } from '../../src/context/port.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; +import { LLMService } from '../../src/llm/port.js'; import { MemoryService } from '../../src/memory/port.js'; import { RulesService } from '../../src/rules/port.js'; import { SkillService } from '../../src/skills/port.js'; @@ -22,33 +22,24 @@ 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, - getProjectBaseDir, - computePaths, -} from '../../src/core/path.js'; +import { encodeProjectPath, normalizePath } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; +import { transcriptPathFor } from '../../src/context/context.js'; +import { projectBaseDir } from '../helpers/project-base.js'; +import type { SessionRef } from '../../src/contracts/session.js'; import type { Message } from '../../src/contracts/types.js'; -import type { LLMClient } from '../../src/contracts/provider.js'; import type { FrameBody } from '../../src/contracts/frame.js'; -function makeMockLLM(content: string): LLMClient { - return { - complete: () => Effect.succeed({ content }), - completeStream: () => - (async function* () { - yield { type: 'text' as const, text: content }; - yield { type: 'end' as const }; - })(), - modelInfo: { - provider: 'mock', - model: 'mock', - maxTokens: 128000, - supportsToolCalling: false, - supportsStreaming: true, - }, - }; -} +const ANSWER = 'subagent final answer'; + +const LLMMock = Layer.succeed(LLMService, { + complete: () => Effect.succeed({ content: ANSWER }), + completeStream: () => + (async function* () { + yield { type: 'text' as const, text: ANSWER }; + yield { type: 'end' as const }; + })(), +} as any); /** Read events back into the message list an LLM would see (like context.assemblePayload). */ function readMessages(transcriptPath: string): Message[] { @@ -107,19 +98,17 @@ const AgentDeps = Layer.mergeAll( extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), } as any), Layer.succeed(ContextService, { - willCompact: async () => false, - assemblePayload: async (transcriptPath: string) => readMessages(transcriptPath), + willCompact: () => Effect.succeed(false), + assemblePayload: (ref: SessionRef) => Effect.sync(() => readMessages(transcriptPathFor(ref))), } as any), Layer.succeed(MemoryService, { - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), - } as any), - Layer.succeed(LLMFactoryService, { - getLLMClient: () => Effect.succeed(makeMockLLM('subagent final answer') as LLMClient), + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), } as any), + LLMMock, Layer.succeed(RulesService, { - getAllRules: () => '', - evictProjectRules: () => {}, + getAllRules: () => Effect.succeed(''), + evictProjectRules: () => Effect.void, } as any), HookMock, McpMock, @@ -180,7 +169,7 @@ describe('subagent run end-to-end (session transcript is read by the agent loop) let cwd: string; beforeEach(() => { - projectBase = getProjectBaseDir(); + projectBase = projectBaseDir(); mkdirSync(projectBase, { recursive: true }); cwd = mkdtempSync(join(tmpdir(), 'codingcode-test-cwd-')); }); @@ -196,8 +185,9 @@ describe('subagent run end-to-end (session transcript is read by the agent loop) const session = yield* SessionService; const { stream, sessionId } = yield* agent.runTurn('analyze this code', { cwd, + model: 'test-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); const content = yield* drainStream(stream); const state = yield* session.load(normalizePath(cwd), sessionId); @@ -235,11 +225,11 @@ describe('subagent run end-to-end (session transcript is read by the agent loop) const parent = yield* session.create(cwd, { model: 'parent-model', activeProfile: 'build', - permissionMode: 'default', + permissionMode: 'ask', }); const child = yield* session.create( cwd, - { model: 'child-model', activeProfile: 'build', permissionMode: 'default' }, + { model: 'child-model', activeProfile: 'build', permissionMode: 'ask' }, { parentSessionId: parent.sessionId, agentName: 'build' } ); return { parentId: parent.sessionId, childId: child.sessionId }; diff --git a/packages/codingcode/test/subagent/dispatch-production-path.test.ts b/packages/codingcode/test/subagent/dispatch-production-path.test.ts new file mode 100644 index 00000000..2f911a9d --- /dev/null +++ b/packages/codingcode/test/subagent/dispatch-production-path.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer } from 'effect'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { useTempHome } from '../helpers/temp-home.js'; +import { AgentLayer } from '../../src/agent/agent.js'; +import { ToolEnvLayer } from '../../src/agent/tool-env.js'; +import { AgentService } from '../../src/agent/port.js'; +import { SubagentRunnerLayer } from '../../src/subagent/subagent.js'; +import { ApprovalService } from '../../src/approval/port.js'; +import { CheckpointService } from '../../src/checkpoint/port.js'; +import { ContextService } from '../../src/context/port.js'; +import { LLMService } from '../../src/llm/port.js'; +import { MemoryService } from '../../src/memory/port.js'; +import { RulesService } from '../../src/rules/port.js'; +import { SkillService } from '../../src/skills/port.js'; +import { ToolExecutorLayer } from '../../src/tools/tools.js'; +import { SessionLayer } from '../../src/session/session.js'; +import { HookService } from '../../src/hooks/port.js'; +import { McpService } from '../../src/mcp/port.js'; +import { TodoService } from '../../src/todo/port.js'; +import { readHistory } from '../../src/session/file-ops.js'; +import { encodeProjectPath, normalizePath } from '../../src/core/path.js'; +import { projectBaseDir } from '../helpers/project-base.js'; +import { transcriptPathFor } from '../../src/context/context.js'; +import type { SessionMetaEvent, SessionRef, ToolResultEvent } from '../../src/contracts/session.js'; +import type { Message } from '../../src/contracts/types.js'; +import type { LLMStreamPart } from '../../src/contracts/provider.js'; +import type { FrameBody, ToolOutcome } from '../../src/contracts/frame.js'; + +/** + * Scripted LLM: the parent turn asks for dispatch_agent, the child turn answers, + * then the parent turn wraps up. Ordering is fixed because the delegated turn + * runs synchronously inside the tool execution. + */ +const script: LLMStreamPart[][] = []; +let call = 0; + +const LLMMock = Layer.succeed(LLMService, { + complete: () => Effect.succeed({ content: '' }), + completeStream: () => + (async function* () { + const parts = script[call++] ?? [{ type: 'end' as const }]; + for (const p of parts) yield p; + })(), +} as any); + +function readMessages(transcriptPath: string): Message[] { + return readHistory(transcriptPath).flatMap((e) => { + if (e.type === 'user') return [{ role: 'user', content: e.content }] as Message[]; + if (e.type === 'assistant') + return [{ role: 'assistant', content: e.content, tool_calls: e.toolCalls }] as Message[]; + if (e.type === 'tool_result') + return [ + { + role: 'tool', + content: e.output ?? '', + tool_call_id: e.toolCallId, + tool_name: e.toolName, + } as Message, + ]; + return []; + }); +} + +interface Draining { + text: string; + toolResults: Array<{ id: string; name: string; outcome: ToolOutcome }>; + endReason: string; +} + +function drain(stream: AsyncGenerator): Effect.Effect { + return Effect.async((resume) => { + (async () => { + const out: Draining = { text: '', toolResults: [], endReason: 'unknown' }; + try { + for await (const body of stream) { + if (body.family === 'event') { + if (body.event.type === 'text_delta') out.text += body.event.text; + if (body.event.type === 'tool_result') { + out.toolResults.push({ + id: body.event.id, + name: body.event.name, + outcome: body.event.outcome, + }); + } + } else if (body.family === 'transition' && body.transition.to === 'end') { + out.endReason = body.transition.reason; + } + } + resume(Effect.succeed(out)); + } catch (e) { + resume(Effect.fail(e instanceof Error ? e : new Error(String(e)))); + } + })(); + }); +} + +const McpMock = Layer.succeed(McpService, { + syncConnections: () => Effect.void, + listProjectMcpTools: () => Effect.succeed([]), +} as any); + +const HookMock = Layer.succeed(HookService, { + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), +} as any); + +const TodoMock = Layer.succeed(TodoService, { read: () => [], write: () => {}, reset: () => {} } as any); + +// 真实的工具执行层:dispatch_agent 的参数解析与 ctx 装配都走生产代码 +const ToolExecutorWithDeps = ToolExecutorLayer.pipe(Layer.provide(HookMock)); + +const AgentDeps = Layer.mergeAll( + SessionLayer, + ToolExecutorWithDeps, + Layer.succeed(CheckpointService, { + snapshotBaseline: () => Effect.void, + snapshotFinal: () => Effect.void, + } as any), + Layer.succeed(ApprovalService, { + evaluate: () => Effect.succeed({ type: 'allow' }), + } as any), + Layer.succeed(SkillService, { + extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), + } as any), + Layer.succeed(ContextService, { + willCompact: () => Effect.succeed(false), + assemblePayload: (ref: SessionRef) => Effect.sync(() => readMessages(transcriptPathFor(ref))), + } as any), + Layer.succeed(MemoryService, { + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), + } as any), + LLMMock, + Layer.succeed(RulesService, { + getAllRules: () => Effect.succeed(''), + evictProjectRules: () => Effect.void, + } as any), + HookMock, + McpMock, + TodoMock, + ToolEnvLayer +); + +const AgentWired = AgentLayer.pipe(Layer.provide(AgentDeps as any)); +const SubagentWired = SubagentRunnerLayer.pipe(Layer.provide(AgentWired)); + +const Runtime = Layer.mergeAll( + AgentWired, + SubagentWired, + SessionLayer, + ToolExecutorWithDeps, + HookMock, + McpMock, + TodoMock +); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(Runtime as any)) as any); +} + +describe('dispatch_agent on the production path (parent turn -> tool -> subagent turn)', () => { + useTempHome('codingcode-test-prod-path-home-'); + + let projectBase: string; + let cwd: string; + + beforeEach(() => { + projectBase = projectBaseDir(); + mkdirSync(projectBase, { recursive: true }); + cwd = mkdtempSync(join(tmpdir(), 'codingcode-test-prod-path-cwd-')); + script.length = 0; + call = 0; + }); + + afterEach(() => { + if (existsSync(cwd)) rmSync(cwd, { recursive: true, force: true }); + }); + + it('delegates without CONFIG_MISSING and lands the child transcript under the parent', async () => { + script.push( + [ + { + type: 'tool_call', + id: 'call-1', + name: 'dispatch_agent', + arguments: { agentName: 'reviewer', prompt: 'inspect the module' }, + }, + { type: 'end' }, + ], + // 子代理的那一回合 + [{ type: 'text', text: 'child answer' }, { type: 'end' }], + // 父代理收尾 + [{ type: 'text', text: 'parent done' }, { type: 'end' }] + ); + + const { drained, parentId } = await run( + Effect.gen(function* () { + const agent = yield* AgentService; + const { stream, sessionId } = yield* agent.runTurn('please delegate', { + cwd, + model: 'test-model', + activeProfile: 'build', + permissionMode: 'ask', + }); + const drained = yield* drain(stream); + return { drained, parentId: sessionId }; + }) + ); + + expect(drained.endReason).toBe('done'); + + const delegated = drained.toolResults.find((r) => r.name === 'dispatch_agent'); + expect(delegated).toBeDefined(); + // 阶段一之前这里必然是 [Error: CONFIG_MISSING],父会话的 activeProfile 到不了 dispatch_agent + expect(delegated!.outcome.status).toBe('ok'); + if (delegated!.outcome.status === 'ok') { + expect(delegated!.outcome.output).toContain('child answer'); + } + + const sessionsRoot = join(projectBase, encodeProjectPath(normalizePath(cwd)), 'sessions'); + const subagentDir = join(sessionsRoot, parentId, 'subagents'); + expect(existsSync(subagentDir)).toBe(true); + const childFiles = readdirSync(subagentDir).filter((f) => f.endsWith('.jsonl')); + expect(childFiles).toHaveLength(1); + + const childEvents = readHistory(join(subagentDir, childFiles[0]!)); + const meta = childEvents[0] as SessionMetaEvent; + expect(meta.type).toBe('session_meta'); + expect(meta.agentName).toBe('reviewer'); + expect(meta.parentSessionId).toBe(parentId); + expect(childEvents.some((e) => e.type === 'assistant')).toBe(true); + + // 父转录里留下了这次委派的调用与结果 + const parentEvents = readHistory(join(sessionsRoot, `${parentId}.jsonl`)); + const toolResult = parentEvents.find( + (e) => e.type === 'tool_result' && e.toolName === 'dispatch_agent' + ) as ToolResultEvent | undefined; + expect(toolResult).toBeDefined(); + expect(toolResult!.output).toContain('child answer'); + }, 30_000); +}); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 5eaad5b9..bc8052d9 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -1,11 +1,19 @@ import { expect, it, describe, beforeEach, vi } from 'vitest'; import { Effect, Layer } from 'effect'; +import { z } from 'zod'; import { dispatchAgentTool } from '../../src/tools/domains/subagent/dispatch.js'; import { HookService } from '../../src/hooks/port.js'; import { SubagentRunnerService } from '../../src/subagent/port.js'; import type { ToolExecCtx } from '../../src/contracts/tool.js'; import type { FrameBody } from '../../src/contracts/frame.js'; +// 只有清单内的模型能被透传,测试内固定一份最小清单 +vi.mock('../../src/infra/models.js', async (importOriginal) => ({ + ...(await importOriginal()), + findModel: (id: string) => + id === 'child-model' ? ({ id: 'child-model@test', model: 'child-model' } as any) : null, +})); + const mockHooks = { emit: vi.fn(() => Effect.succeed(undefined)), emitDecision: vi.fn(() => Effect.succeed(null)), @@ -13,7 +21,7 @@ const mockHooks = { }; const mockRunner = { - runSubagent: vi.fn(() => + runSubagent: vi.fn((_input: string, _opts: Record) => Effect.succeed({ stream: makeRunStream(), sessionId: 'child-1' }) ), }; @@ -32,29 +40,58 @@ function makeLayers() { ); } +/** 工具执行上下文:activeProfile / model 由父会话透传,缺前者则派发不出来 */ +function parentCtx(overrides: Partial = {}): ToolExecCtx { + return { + projectPath: '/test', + sessionId: 'parent-1', + activeProfile: 'build', + model: 'parent-model', + ...overrides, + }; +} + function runTool(args: unknown, ctx: ToolExecCtx): Promise { return Effect.runPromise( dispatchAgentTool.execute(args, ctx).pipe(Effect.provide(makeLayers())) ); } +function runToolEither(args: unknown, ctx: ToolExecCtx) { + return Effect.runPromise( + Effect.either(dispatchAgentTool.execute(args, ctx).pipe(Effect.provide(makeLayers()))) + ); +} + describe('dispatch_agent (runner-based subagent spawn)', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('case 1: dispatches build subagent and returns the runner output', async () => { + it('exposes agentName/prompt/model/systemPrompt and no dangling catalog pointer', () => { + expect(dispatchAgentTool.description).not.toContain('Available Subagents'); + const schema = z.toJSONSchema(dispatchAgentTool.parameters) as any; + expect(Object.keys(schema.properties).sort()).toEqual([ + 'agentName', + 'model', + 'prompt', + 'systemPrompt', + ]); + expect(schema.required).toEqual(['agentName', 'prompt']); + }); + + it('case 1: dispatches a subagent and returns the runner output', async () => { const out = await runTool( - { agent: 'build', prompt: 'go' }, - { projectPath: '/test', sessionId: 'parent-1' } + { agentName: 'build', prompt: 'go' }, + parentCtx() ); expect(out).toBe('done'); }); - it('case 2: forwards prompt, cwd and parent session id to the runner', async () => { + it('case 2: forwards prompt, cwd, parent session id, profile and bypass to the runner', async () => { await runTool( - { agent: 'build', prompt: 'analyze this code' }, - { projectPath: '/test', sessionId: 'parent-1' } + { agentName: 'reviewer', prompt: 'analyze this code' }, + parentCtx() ); expect(mockRunner.runSubagent).toHaveBeenCalledTimes(1); @@ -64,43 +101,66 @@ describe('dispatch_agent (runner-based subagent spawn)', () => { cwd: '/test', parentSessionId: 'parent-1', activeProfile: 'build', - agentName: 'build', + agentName: 'reviewer', + permissionMode: 'bypass', }) ); }); - it('case 3: rejects unknown profile (custom subagents removed)', async () => { - const outcome = await Effect.runPromise( - Effect.either( - dispatchAgentTool - .execute( - { agent: 'custom', prompt: 'go' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - .pipe(Effect.provide(makeLayers())) - ) + it('case 3: passes model and systemPrompt through, inherits the turn model when model is omitted or not in the catalog', async () => { + await runTool( + { agentName: 'build', prompt: 'go', model: 'child-model', systemPrompt: 'CUSTOM PROMPT' }, + parentCtx() + ); + expect(mockRunner.runSubagent).toHaveBeenLastCalledWith( + 'go', + expect.objectContaining({ model: 'child-model', systemPrompt: 'CUSTOM PROMPT' }) + ); + + mockRunner.runSubagent.mockClear(); + await runTool({ agentName: 'build', prompt: 'go' }, parentCtx()); + const omitted = mockRunner.runSubagent.mock.calls.at(-1)![1]; + expect(omitted.model).toBe('parent-model'); + expect(omitted.systemPrompt).toBeUndefined(); + + mockRunner.runSubagent.mockClear(); + await runTool({ agentName: 'build', prompt: 'go', model: 'not-in-catalog' }, parentCtx()); + expect(mockRunner.runSubagent.mock.calls.at(-1)![1].model).toBe('parent-model'); + }); + + it('case 4: accepts any non-empty agentName (no profile lookup)', async () => { + const out = await runTool( + { agentName: 'custom-name', prompt: 'go' }, + parentCtx() + ); + expect(out).toBe('done'); + expect(mockRunner.runSubagent).toHaveBeenCalledWith( + 'go', + expect.objectContaining({ agentName: 'custom-name' }) + ); + }); + + it('case 5: fails with CONFIG_MISSING when the parent profile is missing', async () => { + const outcome = await runToolEither( + { agentName: 'build', prompt: 'go' }, + { projectPath: '/test', sessionId: 'parent-1', model: 'parent-model' } ); expect(outcome._tag).toBe('Left'); if (outcome._tag === 'Left') { const err: any = outcome.left; - expect(err.code).toBe('TOOL_EXECUTION_FAILED'); - expect(String(err.message)).toContain('Unknown subagent: custom'); + expect(err.code).toBe('CONFIG_MISSING'); + expect(String(err.message)).toContain('activeProfile'); } + expect(mockRunner.runSubagent).not.toHaveBeenCalled(); }); - it('case 4: spawn.before deny hook blocks the dispatch', async () => { + it('case 6: spawn.before deny hook blocks the dispatch', async () => { mockHooks.emitDecision.mockReturnValueOnce( Effect.succeed({ decision: 'deny' as const, reason: 'policy forbids it' }) as any ); - const outcome = await Effect.runPromise( - Effect.either( - dispatchAgentTool - .execute( - { agent: 'build', prompt: 'go' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - .pipe(Effect.provide(makeLayers())) - ) + const outcome = await runToolEither( + { agentName: 'build', prompt: 'go' }, + parentCtx() ); expect(outcome._tag).toBe('Left'); if (outcome._tag === 'Left') { @@ -109,15 +169,15 @@ describe('dispatch_agent (runner-based subagent spawn)', () => { } }); - it('case 5: emits spawn.after and complete carrying the child session id', async () => { + it('case 7: emits spawn.after and complete carrying the child session id and agentName', async () => { await runTool( - { agent: 'build', prompt: 'go' }, - { projectPath: '/test', sessionId: 'parent-1' } + { agentName: 'reviewer', prompt: 'go' }, + parentCtx() ); expect(mockHooks.emit).toHaveBeenCalledWith( 'agent.subagent.spawn.after', - expect.objectContaining({ childSessionId: 'child-1', profile: 'build' }) + expect.objectContaining({ childSessionId: 'child-1', agentName: 'reviewer' }) ); expect(mockHooks.emit).toHaveBeenCalledWith( 'agent.subagent.complete', @@ -125,7 +185,7 @@ describe('dispatch_agent (runner-based subagent spawn)', () => { ); }); - it('case 6: a stream that ends with error fails the tool and skips complete', async () => { + it('case 8: a stream that ends with error fails the tool and skips complete', async () => { mockRunner.runSubagent.mockReturnValueOnce( Effect.succeed({ stream: (async function* () { @@ -138,15 +198,9 @@ describe('dispatch_agent (runner-based subagent spawn)', () => { }) as any ); - const outcome = await Effect.runPromise( - Effect.either( - dispatchAgentTool - .execute( - { agent: 'build', prompt: 'go' }, - { projectPath: '/test', sessionId: 'parent-1' } - ) - .pipe(Effect.provide(makeLayers())) - ) + const outcome = await runToolEither( + { agentName: 'build', prompt: 'go' }, + parentCtx() ); expect(outcome._tag).toBe('Left'); diff --git a/packages/codingcode/test/subagent/runner-service.test.ts b/packages/codingcode/test/subagent/runner-service.test.ts index 75b597f0..c7d2b223 100644 --- a/packages/codingcode/test/subagent/runner-service.test.ts +++ b/packages/codingcode/test/subagent/runner-service.test.ts @@ -51,7 +51,7 @@ describe('SubagentRunnerService', () => { ( Effect.gen(function* () { const runner = yield* SubagentRunnerService; - const { stream, sessionId } = yield* runner.runSubagent('go', { cwd: '/test' }); + const { stream, sessionId } = yield* runner.runSubagent('go', { cwd: '/test', model: 'm' }); expect(sessionId).toBe('child-1'); // Consume the async generator outside the Effect generator return yield* Effect.async((resume) => { diff --git a/packages/codingcode/test/subagent/runner-wiring.test.ts b/packages/codingcode/test/subagent/runner-wiring.test.ts new file mode 100644 index 00000000..3f4aa5bc --- /dev/null +++ b/packages/codingcode/test/subagent/runner-wiring.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer } from 'effect'; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { useTempHome } from '../helpers/temp-home.js'; +import { AgentLayer } from '../../src/agent/agent.js'; +import { ToolEnvLayer } from '../../src/agent/tool-env.js'; +import { SubagentRunnerLayer } from '../../src/subagent/subagent.js'; +import { SubagentRunnerService } from '../../src/subagent/port.js'; +import { ApprovalService } from '../../src/approval/port.js'; +import { CheckpointService } from '../../src/checkpoint/port.js'; +import { ContextService } from '../../src/context/port.js'; +import { LLMService } from '../../src/llm/port.js'; +import { AgentError } from '../../src/core/error.js'; +import { MemoryService } from '../../src/memory/port.js'; +import { RulesService } from '../../src/rules/port.js'; +import { SkillService } from '../../src/skills/port.js'; +import { ToolExecutorService } from '../../src/tools/port.js'; +import { SessionLayer } from '../../src/session/session.js'; +import { SessionService } from '../../src/session/port.js'; +import { HookService } from '../../src/hooks/port.js'; +import { McpService } from '../../src/mcp/port.js'; +import { TodoService } from '../../src/todo/port.js'; +import { readHistory } from '../../src/session/file-ops.js'; +import { encodeProjectPath, normalizePath } from '../../src/core/path.js'; +import { computePaths } from '../../src/session/paths.js'; +import { transcriptPathFor } from '../../src/context/context.js'; +import { projectBaseDir } from '../helpers/project-base.js'; +import type { SessionMetaEvent, SessionRef } from '../../src/contracts/session.js'; +import type { Message } from '../../src/contracts/types.js'; +import type { LLMRequest } from '../../src/contracts/provider.js'; +import type { FrameBody } from '../../src/contracts/frame.js'; + +const CHILD_MODEL = 'child-model'; +const PARENT_MODEL = 'parent-model'; + +/** 记录每次推理请求:system 是那次回合真正用的系统提示词,model 是本次请求的模型值 */ +interface Recorder { + calls: Array<{ system: string; model: string }>; +} + +const rec: Recorder = { calls: [] }; +const CHILD_ANSWER = 'child answer'; + +/** 未命中即失败(与 LlmLayer 的 entryFor 同语义);空模型同样不是合法模型 */ +const LLMMock = Layer.succeed(LLMService, { + complete: () => Effect.succeed({ content: CHILD_ANSWER }), + completeStream: (req: LLMRequest, model: string) => + (async function* () { + const target = model?.trim() ?? ''; + if (target !== CHILD_MODEL) { + throw new AgentError('CONFIG_INVALID', `Model "${target}" not found in models.json`); + } + rec.calls.push({ system: req.system ?? '', model: target }); + yield { type: 'text' as const, text: CHILD_ANSWER }; + yield { type: 'end' as const }; + })(), +} as any); + +function readMessages(transcriptPath: string): Message[] { + return readHistory(transcriptPath).flatMap((e) => { + if (e.type === 'user') return [{ role: 'user', content: e.content }] as Message[]; + if (e.type === 'assistant') + return [{ role: 'assistant', content: e.content, tool_calls: e.toolCalls }] as Message[]; + if (e.type === 'tool_result') + return [ + { + role: 'tool', + content: e.output ?? '', + tool_call_id: e.toolCallId, + tool_name: e.toolName, + } as Message, + ]; + return []; + }); +} + +/** Drain a frame stream and return the accumulated assistant text. */ +function drainStream(stream: AsyncGenerator): Effect.Effect { + return Effect.async((resume) => { + (async () => { + let content = ''; + try { + for await (const body of stream) { + if (body.family === 'event' && body.event.type === 'text_delta') { + content += body.event.text; + } else if ( + body.family === 'transition' && + body.transition.to === 'end' && + body.transition.reason === 'error' + ) { + resume(Effect.fail(new Error(`subagent failed: ${body.transition.error.message}`))); + return; + } + } + resume(Effect.succeed(content)); + } catch (e) { + resume(Effect.fail(e instanceof Error ? e : new Error(String(e)))); + } + })(); + }); +} + +const McpMock = Layer.succeed(McpService, { + syncConnections: () => Effect.void, + listProjectMcpTools: () => Effect.succeed([]), +} as any); + +const HookMock = Layer.succeed(HookService, { + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), +} as any); + +const TodoMock = Layer.succeed(TodoService, { read: () => [], write: () => {}, reset: () => {} } as any); + +const AgentDeps = Layer.mergeAll( + SessionLayer, + Layer.succeed(ToolExecutorService, { + executeBatch: () => Effect.succeed([]), + prepare: () => Effect.succeed({ tools: [], lookup: () => undefined }), + } as any), + Layer.succeed(CheckpointService, { + snapshotBaseline: () => Effect.void, + snapshotFinal: () => Effect.void, + } as any), + Layer.succeed(ApprovalService, { + evaluate: () => Effect.succeed({ type: 'allow' }), + } as any), + Layer.succeed(SkillService, { + extractSkill: (_cwd: string, query: string) => Effect.succeed([undefined, query]), + } as any), + Layer.succeed(ContextService, { + willCompact: () => Effect.succeed(false), + assemblePayload: (ref: SessionRef) => Effect.sync(() => readMessages(transcriptPathFor(ref))), + } as any), + Layer.succeed(MemoryService, { + loadMemoryForPrompt: () => Effect.succeed(''), + flushSessionToMemory: () => Effect.succeed({ written: false, bytes: 0 }), + } as any), + LLMMock, + Layer.succeed(RulesService, { + getAllRules: () => Effect.succeed(''), + evictProjectRules: () => Effect.void, + } as any), + HookMock, + McpMock, + TodoMock, + ToolEnvLayer +); + +const AgentWired = AgentLayer.pipe(Layer.provide(AgentDeps as any)); +// 真实 SubagentRunnerLayer:这一层到 agent.runTurn 的参数折叠正是阶段一要修的地方 +const SubagentWired = SubagentRunnerLayer.pipe(Layer.provide(AgentWired)); + +const Runtime = Layer.mergeAll(AgentWired, SubagentWired, SessionLayer, HookMock, McpMock, TodoMock); + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(Runtime as any)) as any); +} + +describe('subagent runner wiring (child session mounts under the parent)', () => { + useTempHome('codingcode-test-runner-home-'); + + let projectBase: string; + let cwd: string; + + beforeEach(() => { + projectBase = projectBaseDir(); + mkdirSync(projectBase, { recursive: true }); + cwd = mkdtempSync(join(tmpdir(), 'codingcode-test-runner-cwd-')); + rec.calls.length = 0; + }); + + afterEach(() => { + if (existsSync(cwd)) rmSync(cwd, { recursive: true, force: true }); + }); + + /** Create a parent session, then run one subagent turn under it. */ + function spawnChild(opts: { model?: string; systemPrompt?: string; agentName?: string }) { + return run( + Effect.gen(function* () { + const session = yield* SessionService; + const runner = yield* SubagentRunnerService; + const parent = yield* session.create(cwd, { + model: PARENT_MODEL, + activeProfile: 'build', + permissionMode: 'ask', + }); + const { stream, sessionId } = yield* runner.runSubagent('do the thing', { + cwd, + activeProfile: 'build', + parentSessionId: parent.sessionId, + agentName: opts.agentName ?? 'reviewer', + model: opts.model ?? CHILD_MODEL, + ...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}), + }); + const content = yield* drainStream(stream); + return { parentId: parent.sessionId, childId: sessionId, content }; + }) + ); + } + + function pathsFor(childId: string, parentId: string) { + return computePaths(normalizePath(cwd), childId, parentId); + } + + it('writes the child transcript under sessions//subagents/ and reloads it with the parent id', async () => { + const { parentId, childId, content } = await spawnChild({}); + + const nested = pathsFor(childId, parentId).transcriptPath; + expect(existsSync(nested)).toBe(true); + expect(nested).toContain(join(parentId, 'subagents', `${childId}.jsonl`)); + + const sessionsRoot = join(projectBase, encodeProjectPath(normalizePath(cwd)), 'sessions'); + expect(existsSync(join(sessionsRoot, `${childId}.jsonl`))).toBe(false); + + expect(content.length).toBeGreaterThan(0); + const types = readHistory(nested).map((e) => e.type); + expect(types[0]).toBe('session_meta'); + expect(types).toContain('user'); + expect(types).toContain('assistant'); + }); + + it('records parentSessionId, agentName and the bypass permission mode in the child session', async () => { + const { parentId, childId } = await spawnChild({ agentName: 'reviewer' }); + + const meta = readHistory(pathsFor(childId, parentId).transcriptPath)[0] as SessionMetaEvent; + expect(meta.parentSessionId).toBe(parentId); + // permissionMode 默认 bypass:runSubagent 未传时由 runner 兜底 + expect(meta.permissionMode).toBe('bypass'); + expect(meta.agentName).toBe('reviewer'); + }); + + it('reloads the child session through session.load when the parent id is supplied', async () => { + const { parentId, childId } = await spawnChild({}); + + const reloaded = await run( + Effect.gen(function* () { + const session = yield* SessionService; + const ok = yield* Effect.either( + session.load(normalizePath(cwd), childId, parentId) + ); + const withoutParent = yield* Effect.either(session.load(normalizePath(cwd), childId)); + return { ok: ok._tag === 'Right' ? ok.right : null, withoutParent }; + }) + ); + + expect(reloaded.ok).not.toBeNull(); + expect(reloaded.ok!.parentSessionId).toBe(parentId); + expect(reloaded.ok!.cwd).toBe(normalizePath(cwd)); + + // 不带父参会算成顶层路径,子会话因此找不到 + expect(reloaded.withoutParent._tag).toBe('Left'); + if (reloaded.withoutParent._tag === 'Left') { + expect((reloaded.withoutParent.left as any).code).toBe('SESSION_NOT_FOUND'); + } + }); + + it('forwards the requested model through to the LLM layer', async () => { + await spawnChild({ model: CHILD_MODEL }); + expect(rec.calls.at(-1)!.model).toBe(CHILD_MODEL); + }); + + it('fails the child turn when the requested model is not in the catalog', async () => { + await expect(spawnChild({ model: 'not-registered' })).rejects.toThrow(/not-registered/); + }); + + it('replaces only the middle section of the child system prompt', async () => { + await spawnChild({ systemPrompt: 'CUSTOM-MIDDLE-MARKER' }); + const system = rec.calls.at(-1)!.system; + + expect(system).toContain('## Environment'); + expect(system).toContain('## System Notes'); + // profile 段被整段换掉:默认 build 提示词不再出现 + expect(system).not.toContain('How you work'); + + const envIdx = system.indexOf('## Environment'); + const notesIdx = system.indexOf('## System Notes'); + const customIdx = system.indexOf('CUSTOM-MIDDLE-MARKER'); + expect(customIdx).toBeGreaterThan(envIdx); + expect(customIdx).toBeLessThan(notesIdx); + }); +}); diff --git a/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts b/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts index 2908eaa1..5210cbae 100644 --- a/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts +++ b/packages/codingcode/test/tools/domains/bash/bash-project-path.test.ts @@ -36,7 +36,7 @@ describe('tools/domains/bash projectPath isolation', () => { } }); - const ctx = (cwd: string) => ({ projectPath: cwd }); + const ctx = (cwd: string) => ({ projectPath: cwd, model: 'test-model' }); it('executes command in ctx.projectPath when cwd arg is absent', async () => { // On Windows, use PowerShell to write a file in the current directory diff --git a/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts b/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts index 82b3ca5e..159e6981 100644 --- a/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts +++ b/packages/codingcode/test/tools/domains/fs/tool-project-path.test.ts @@ -40,7 +40,7 @@ describe('tools/domains/fs projectPath isolation', () => { } }); - const ctx = (cwd: string) => ({ projectPath: cwd }); + const ctx = (cwd: string) => ({ projectPath: cwd, model: 'test-model' }); it('read_file uses ctx.projectPath over workspaceCwd', async () => { writeFileSync(join(projectDir, 'a.txt'), 'hello', 'utf8'); diff --git a/packages/codingcode/test/tools/executor-concurrency.test.ts b/packages/codingcode/test/tools/executor-concurrency.test.ts index d02e6414..37dcf6e8 100644 --- a/packages/codingcode/test/tools/executor-concurrency.test.ts +++ b/packages/codingcode/test/tools/executor-concurrency.test.ts @@ -118,7 +118,13 @@ function tc(id: string, name: string, args: Record): ToolCall { function runBatch( calls: ToolCall[], - ctx: { projectPath: string; sessionId?: string; signal?: AbortSignal }, + ctx: { + projectPath: string; + sessionId?: string; + signal?: AbortSignal; + activeProfile?: 'plan' | 'build'; + model: string; + }, h: Harness, mcpSpecs: McpToolSpec[] = [] ): Promise { @@ -131,6 +137,8 @@ function runBatch( projectPath: ctx.projectPath, signal: ctx.signal, toolLookup: catalog.lookup, + activeProfile: ctx.activeProfile, + model: ctx.model, }); }); return Effect.runPromise( @@ -177,7 +185,7 @@ describe('executeBatch 保序波次调度', () => { tc('r2', 'read_file', { path: 'b.txt' }), tc('r3', 'read_file', { path: 'c.txt' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); @@ -199,7 +207,7 @@ describe('executeBatch 保序波次调度', () => { tc('e1', 'edit_file', { path: 'f.txt', old_string: 'AAA', new_string: 'XXX' }), tc('e2', 'edit_file', { path: 'f.txt', old_string: 'BBB', new_string: 'YYY' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); @@ -221,7 +229,7 @@ describe('executeBatch 保序波次调度', () => { tc('c1', 'execute_command', { command: 'echo hi' }), tc('r1', 'read_file', { path: 'a.txt' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); @@ -241,7 +249,7 @@ describe('executeBatch 保序波次调度', () => { tc('r', 'read_file', { path: 's.txt' }), tc('w', 'write_file', { path: 's.txt', content: 'AFTER' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); expect(okOutput(before[0])).toContain('BEFORE'); @@ -254,7 +262,7 @@ describe('executeBatch 保序波次调度', () => { tc('w', 'write_file', { path: 's.txt', content: 'AFTER' }), tc('r', 'read_file', { path: 's.txt' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); expect(okOutput(after[1])).toContain('AFTER'); @@ -272,7 +280,7 @@ describe('executeBatch 保序波次调度', () => { tc('e1', 'edit_file', { path: 't.txt', old_string: 'OLD', new_string: 'NEW' }), tc('r2', 'read_file', { path: 't.txt' }), ], - { projectPath: dir }, + { projectPath: dir, model: 'test-model' }, h ); @@ -299,13 +307,13 @@ describe('executeBatch 保序波次调度', () => { }); h.clear(); - const serial = await runBatch(calls, { projectPath: dir }, h, [makeSpec(false)]); + const serial = await runBatch(calls, { projectPath: dir, model: 'test-model' }, h, [makeSpec(false)]); expect(serial.map((r) => r.status)).toEqual(['ok', 'ok']); expect(okOutput(serial[1])).toBe('mcp-ok'); expect(overlaps(intervalFor(h, 'r1'), intervalFor(h, 'm1'))).toBe(false); h.clear(); - const parallel = await runBatch(calls, { projectPath: dir }, h, [makeSpec(true)]); + const parallel = await runBatch(calls, { projectPath: dir, model: 'test-model' }, h, [makeSpec(true)]); expect(parallel.map((r) => r.status)).toEqual(['ok', 'ok']); expect(overlaps(intervalFor(h, 'r1'), intervalFor(h, 'm1'))).toBe(true); }); @@ -320,7 +328,7 @@ describe('executeBatch 保序波次调度', () => { plan: [{ step: 'step one', status: 'pending' }], }), ], - { projectPath: dir, sessionId: 'sid-1' }, + { projectPath: dir, sessionId: 'sid-1', model: 'test-model' }, h ); @@ -334,10 +342,10 @@ describe('executeBatch 保序波次调度', () => { it('[dispatch_agent, write_file] 零重叠,且委派与写入的先后由声明顺序决定', async () => { const results = await runBatch( [ - tc('d1', 'dispatch_agent', { agent: 'build', prompt: 'go' }), + tc('d1', 'dispatch_agent', { agentName: 'build', prompt: 'go' }), tc('w1', 'write_file', { path: 'z.txt', content: 'z' }), ], - { projectPath: dir, sessionId: 'sid-1' }, + { projectPath: dir, sessionId: 'sid-1', activeProfile: 'build', model: 'm' }, h ); @@ -352,9 +360,9 @@ describe('executeBatch 保序波次调度', () => { const reversed = await runBatch( [ tc('w2', 'write_file', { path: 'z2.txt', content: 'z' }), - tc('d2', 'dispatch_agent', { agent: 'build', prompt: 'go' }), + tc('d2', 'dispatch_agent', { agentName: 'build', prompt: 'go' }), ], - { projectPath: dir, sessionId: 'sid-1' }, + { projectPath: dir, sessionId: 'sid-1', activeProfile: 'build', model: 'm' }, h ); expect(reversed.map((r) => r.id)).toEqual(['w2', 'd2']); @@ -372,7 +380,7 @@ describe('executeBatch 保序波次调度', () => { tc('w1', 'write_file', { path: 'w.txt', content: 'x' }), tc('r2', 'read_file', { path: 'a.txt' }), ], - { projectPath: dir, signal: controller.signal }, + { projectPath: dir, signal: controller.signal, model: 'test-model' }, h ); @@ -394,7 +402,7 @@ describe('executeBatch 保序波次调度', () => { tc('w1', 'write_file', { path: 'w.txt', content: 'x' }), tc('r2', 'read_file', { path: 'a.txt' }), ], - { projectPath: dir, signal: controller.signal }, + { projectPath: dir, signal: controller.signal, model: 'test-model' }, h ); diff --git a/packages/codingcode/test/tools/todo.test.ts b/packages/codingcode/test/tools/todo.test.ts index 3661dbb0..f5d2c843 100644 --- a/packages/codingcode/test/tools/todo.test.ts +++ b/packages/codingcode/test/tools/todo.test.ts @@ -21,7 +21,7 @@ describe('todo_write tool', () => { { step: 'third', status: 'completed' }, ], }, - { sessionId: 'test-agent' } + { sessionId: 'test-agent', model: 'test-model' } ) .pipe(Effect.provide(TodoLayer)) ); @@ -63,7 +63,7 @@ describe('todo_write tool', () => { it('fails with AgentError if sessionId is missing', async () => { const exit = await Effect.runPromiseExit( tool - .execute({ plan: [{ step: 'x', status: 'pending' }] }, {}) + .execute({ plan: [{ step: 'x', status: 'pending' }] }, { model: 'test-model' }) .pipe(Effect.provide(TodoLayer)) ); expect(exit._tag).toBe('Failure'); diff --git a/packages/codingcode/test/tsconfig.json b/packages/codingcode/test/tsconfig.json index 7e0a889a..8d182a63 100644 --- a/packages/codingcode/test/tsconfig.json +++ b/packages/codingcode/test/tsconfig.json @@ -8,5 +8,5 @@ "noEmit": true }, "include": ["**/*.ts"], - "references": [{ "path": "../tsconfig.json" }, { "path": "../../infra" }] + "references": [{ "path": "../tsconfig.json" }] } diff --git a/packages/codingcode/test/types/type-collapse.test.ts b/packages/codingcode/test/types/type-collapse.test.ts index 2c5145e1..bdf24e20 100644 --- a/packages/codingcode/test/types/type-collapse.test.ts +++ b/packages/codingcode/test/types/type-collapse.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import type { UITurn, UITurnItem } from '../../src/contracts/session.js'; -import type { ForkResult, RollbackContextResult } from '../../src/client/contracts.js'; import type { TodoItem, TokenUsage } from '../../src/contracts/types.js'; type AssertNotAny = 0 extends 1 & T ? never : T; @@ -9,10 +8,6 @@ type _UITurnNotAny = AssertNotAny; type _TodoNotAny = AssertNotAny; type _UsageNotAny = AssertNotAny; -// 同构断言:两侧互相可赋值才通过 -type _ForkTurnsIsUITurn = ForkResult['turns'] extends UITurn[] ? true : never; -type _ContextTurnsIsUITurn = RollbackContextResult['turns'] extends UITurn[] ? true : never; - describe('类型收口', () => { it('UITurn.status 不再退化为 string', () => { const turn: UITurn = { id: '1', items: [], status: 'completed' }; diff --git a/packages/codingcode/tsconfig.json b/packages/codingcode/tsconfig.json index fcd63a61..232d7618 100644 --- a/packages/codingcode/tsconfig.json +++ b/packages/codingcode/tsconfig.json @@ -7,6 +7,5 @@ "module": "NodeNext", "moduleResolution": "NodeNext" }, - "include": ["src/**/*.ts", "src/**/*.tsx"], - "references": [{ "path": "../infra" }] + "include": ["src/**/*.ts", "src/**/*.tsx"] } diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 401a14d2..3e87722a 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -4,9 +4,6 @@ import tailwindcss from '@tailwindcss/vite'; import { resolve } from 'path'; import { readFileSync } from 'fs'; -const codingcodeRoot = resolve(__dirname, '../codingcode/src'); -const infraRoot = resolve(__dirname, '../infra/src'); - // 读取 package.json 获取第三方依赖列表 const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')); const thirdPartyDeps = Object.keys(pkg.dependencies || {}).filter( @@ -26,14 +23,6 @@ export default defineConfig({ resolve: { alias: { '@shared': resolve('shared'), - '@codingcode/core/layer': resolve(codingcodeRoot, 'layer.ts'), - '@codingcode/core/server/create': resolve(codingcodeRoot, 'server/index.ts'), - '@codingcode/core/server/port-discovery': resolve( - codingcodeRoot, - 'server/port-discovery.ts' - ), - '@codingcode/infra/config': resolve(infraRoot, 'config.ts'), - '@codingcode/infra/logger': resolve(infraRoot, 'logger.ts'), }, }, }, diff --git a/packages/desktop/electron/core/child-process.ts b/packages/desktop/electron/core/child-process.ts index 22fe3717..6efdd42d 100644 --- a/packages/desktop/electron/core/child-process.ts +++ b/packages/desktop/electron/core/child-process.ts @@ -45,6 +45,7 @@ export async function startBackend(): Promise { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], shell: true, + env: { ...process.env, CODINGCODE_PROJECT_ROOT: root }, }); } else { // 生产模式:node 运行 esbuild 打包后的单文件 @@ -54,6 +55,7 @@ export async function startBackend(): Promise { env: { ...process.env, NODE_ENV: 'production', + CODINGCODE_PROJECT_ROOT: root, }, }); } diff --git a/packages/desktop/package.json b/packages/desktop/package.json index c41fb612..919def07 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -20,8 +20,7 @@ "release": "pnpm run bundle:backend && pnpm run build && electron-builder --publish always" }, "dependencies": { - "@codingcode/core": "workspace:*", - "@codingcode/infra": "workspace:*", + "@codingcode/sdk": "workspace:*", "@tanstack/react-virtual": "^3.12.6", "immer": "^10.1.1", "lucide-react": "^1.17.0", diff --git a/packages/desktop/shared/types.ts b/packages/desktop/shared/types.ts index 8525ce67..cb6f62e3 100644 --- a/packages/desktop/shared/types.ts +++ b/packages/desktop/shared/types.ts @@ -47,6 +47,7 @@ export interface Thread { projectId: string; title: string; cwd: string; + model?: string; turns: Turn[]; createdAt: number; updatedAt: number; diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index 3db821bb..ca4b0e32 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -1,16 +1,38 @@ import { useState, useRef, useCallback, useLayoutEffect, useEffect } from 'react'; import { createPortal } from 'react-dom'; -import { Send, Square, ShieldAlert, ShieldCheck, Shield, Eye, FileText } from 'lucide-react'; +import { Send, Square, ShieldAlert, ShieldCheck, Shield, FileText } from 'lucide-react'; import { useAgentStore } from '../stores/agent.store'; import { useWorkspaceStore } from '../stores/workspace.store'; -import { API_BASE, api } from '../lib/api'; -import { setSessionPermissionMode } from '../lib/core-api'; +import { + compactSession, + setSessionModel, + setSessionPermissionMode, + setAgentConfig, +} from '../lib/core-api'; import MessageStream from './MessageStream'; import TodoPanel from './TodoPanel'; import ApprovalPanel from './ApprovalPanel'; import ProfileIndicator from './ProfileIndicator'; -import { APPROVAL_POLICY_TO_PERMISSION_MODE } from '../hooks/useAgent'; import PlanPanel from '../shared/PlanPanel'; +import type { PermissionMode } from '@codingcode/sdk'; + +const MODE_LABELS: Record = { + ask: '全部询问', + acceptEdits: '半自动', + bypass: '完全放行', +}; + +const MODE_NEXT: Record = { + ask: 'acceptEdits', + acceptEdits: 'bypass', + bypass: 'ask', +}; + +const MODE_ICONS: Record = { + ask: , + acceptEdits: , + bypass: , +}; // ─── ContextIndicator ────────────────────────────────────────────────────── @@ -22,6 +44,7 @@ function ContextIndicator({ threadId }: { threadId: string }) { const isCompressing = useAgentStore((s) => s.isCompressing); const startCompressing = useAgentStore((s) => s.startCompressing); const stopCompressing = useAgentStore((s) => s.stopCompressing); + const model = useAgentStore((s) => s.model); const r = 7; const circ = 2 * Math.PI * r; @@ -70,14 +93,7 @@ function ContextIndicator({ threadId }: { threadId: string }) { onClick={async () => { startCompressing(); try { - const res = await api<{ promptEstimate: number; didCompress: boolean; released: number }>( - `/api/sessions/${threadId}/compact`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cwd: '' }), - } - ); + const res = await compactSession(threadId, '', model); if (res.didCompress && contextUsage) { setContextUsage({ used: res.promptEstimate, @@ -118,7 +134,9 @@ function ContextIndicator({ threadId }: { threadId: string }) { function ModelSelector() { const model = useAgentStore((s) => s.model); const models = useAgentStore((s) => s.models); - const setModel = useAgentStore((s) => s.setModel); + const selectModel = useAgentStore((s) => s.selectModel); + const currentThreadId = useAgentStore((s) => s.currentThreadId); + const rootPath = useWorkspaceStore((s) => s.rootPath); const [open, setOpen] = useState(false); const buttonRef = useRef(null); const dropdownRef = useRef(null); @@ -169,13 +187,9 @@ function ModelSelector() { type="button" key={m.id} onClick={async () => { - setModel(m.id); + selectModel(m.id); setOpen(false); - await api(`/api/models/switch`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ modelId: m.id }), - }).catch((e) => { + await setSessionModel(currentThreadId, rootPath ?? '', m.id).catch((e) => { console.error('Failed to switch model:', e); }); }} @@ -225,15 +239,21 @@ function InputBox({ const thread = s.threads[tid]; return thread?.turns.some((t) => t.status === 'running') ?? false; }); - const approvalPolicy = useAgentStore((s) => s.approvalPolicy); + const storePermissionMode = useAgentStore((s) => s.permissionMode); + const setPermissionMode = useAgentStore((s) => s.setPermissionMode); const workspace = useWorkspaceStore(); - const setApprovalPolicy = useAgentStore((s) => s.setApprovalPolicy); const pendingInput = useAgentStore((s) => s.pendingInput); const setPendingInput = useAgentStore((s) => s.setPendingInput); + // 有会话时显示该会话真实的权限模式;还没有会话时显示 config.yaml 里的值 + const sessionPermissionMode = useAgentStore((s) => + s.currentThreadId ? (s.profileByThreadId[s.currentThreadId]?.permissionMode ?? null) : null + ); + const permissionMode: PermissionMode = sessionPermissionMode ?? storePermissionMode; + const isPlanProfile = useAgentStore((s) => { if (!s.currentThreadId) { - return s.pendingProfile === 'plan'; + return s.profile === 'plan'; } return s.profileByThreadId[s.currentThreadId]?.activeProfile === 'plan'; }); @@ -259,24 +279,6 @@ function InputBox({ sendMessage(trimmed, workspace.rootPath || undefined); }, [text, isStreaming, sendMessage, workspace.rootPath]); - const POLICY_LABELS: Record = { - 'ask-all': '全部询问', - 'smart-allow': '半自动', - 'full-allow': '完全放行', - 'read-only': '只读模式', - }; - const POLICY_NEXT: Record = { - 'ask-all': 'smart-allow', - 'smart-allow': 'full-allow', - 'full-allow': 'read-only', - 'read-only': 'ask-all', - }; - const POLICY_ICONS: Record = { - 'ask-all': , - 'smart-allow': , - 'full-allow': , - 'read-only': , - }; return (
@@ -328,22 +330,30 @@ function InputBox({ )} diff --git a/packages/desktop/src/agent/AutomationForm.tsx b/packages/desktop/src/agent/AutomationForm.tsx index 6cefd4aa..924dbdb1 100644 --- a/packages/desktop/src/agent/AutomationForm.tsx +++ b/packages/desktop/src/agent/AutomationForm.tsx @@ -1,11 +1,12 @@ import { useState, useEffect } from 'react'; import { X } from 'lucide-react'; -import { useAgentStore, type Automation } from '../stores/agent.store'; +import { useAgentStore } from '../stores/agent.store'; +import type { AutomationSandbox } from '@codingcode/sdk'; import { createAutomation, updateAutomation } from '../lib/core-api'; interface AutomationFormProps { automationId: string | null; - defaultProjectCwd: string; + initialProjectCwd: string; onClose: () => void; onSaved: () => void; } @@ -34,12 +35,12 @@ const WEEKDAYS = [ export function AutomationForm({ automationId, - defaultProjectCwd, + initialProjectCwd, onClose, onSaved, }: AutomationFormProps) { const automations = useAgentStore((s) => s.automations); - const existing = automationId ? automations.find((a: Automation) => a.id === automationId) : null; + const existing = automationId ? automations.find((a) => a.id === automationId) : null; const [name, setName] = useState(existing?.name ?? ''); const [description, setDescription] = useState(existing?.description ?? ''); @@ -51,10 +52,10 @@ export function AutomationForm({ const [intervalUnit, setIntervalUnit] = useState<'hours' | 'minutes'>('hours'); const [customCron, setCustomCron] = useState(existing?.cron ?? ''); const [timezone, setTimezone] = useState(existing?.timezone ?? 'Asia/Shanghai'); - const [sandbox, setSandbox] = useState<'readonly' | 'workspace-write'>( + const [sandbox, setSandbox] = useState( existing?.sandbox ?? 'workspace-write' ); - const [projectCwd, setProjectCwd] = useState(existing?.projectCwd ?? defaultProjectCwd); + const [projectCwd, setProjectCwd] = useState(existing?.projectCwd ?? initialProjectCwd); const [runOnce, setRunOnce] = useState(existing?.runOnce ?? false); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); @@ -389,7 +390,7 @@ export function AutomationForm({