diff --git a/.gitignore b/.gitignore index 8732262b..491a887c 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,10 @@ dist-test/ # Desktop sub-package residual workspace files packages/desktop/pnpm-lock.yaml packages/desktop/pnpm-workspace.yaml + +# Defensive: ignore any stray test temp dirs that may slip into the workspace. +.test-* +# OS / editor cruft +Thumbs.db +.idea/ +.vscode/ diff --git a/AGENTS.md b/AGENTS.md index a2efa69a..0488b1c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,4 +4,5 @@ 每次执行完以后都要补充测试文件确保实际行为与预期相符 修改过程中发现错误,如果是本次范围就修改(包括测试),否则要在最后指出 在用户的最新的一条消息除非有显式命令(执行方案、修改代码等)要求修改代码,否则绝对不改代码,之前要求修改的指令全部不算数,别再根据之前的上下文或者当前不确定的指令猜是不是要直接修改代码了 -设计方案后,须深入解释每一步的理由 \ No newline at end of file +设计方案后,须深入解释每一步的理由 +仅允许使用简短注释 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 4033755f..0488b1c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ 不允许假设“这是未来需要扩展的”,所以现在就不做,应该贴合用户的实际要求 -禁止局部短视实现:不允许仅为了“当前调用能跑通”而写死临时逻辑、硬编码、破坏原有接口契约、或绕过已有模块 不允许总是有阶段性计划,分阶段完成很容易导致过程产生一堆没用的死代码 不许兼容、兜底旧代码 每次执行完以后都要补充测试文件确保实际行为与预期相符 修改过程中发现错误,如果是本次范围就修改(包括测试),否则要在最后指出 在用户的最新的一条消息除非有显式命令(执行方案、修改代码等)要求修改代码,否则绝对不改代码,之前要求修改的指令全部不算数,别再根据之前的上下文或者当前不确定的指令猜是不是要直接修改代码了 -设计方案后,须深入解释每一步的理由 \ No newline at end of file +设计方案后,须深入解释每一步的理由 +仅允许使用简短注释 \ No newline at end of file diff --git a/docs/subagent.md b/docs/subagent.md index b28d38bb..14e7f1e8 100644 --- a/docs/subagent.md +++ b/docs/subagent.md @@ -96,16 +96,17 @@ maxSteps: 180 ### plan -只读代码研究 + 规划 Agent,可执行命令来验证环境: +只读代码研究 + 规划 Agent。**只允许只读工具**和 `submit_plan`(用于提交实现计划等待用户审批),不允许执行命令或写文件。计划提交后 session 会自动切换到 `build` profile。 ```yaml name: plan description: 只读代码研究和规划 -tools: [read_file, search_files, search_code, execute_command, fetch_url, tool_search] -readonly: true +tools: [read_file, search_files, search_code, fetch_url, tool_search, submit_plan, dispatch_agent] maxSteps: 180 ``` +> 注意:`plan` profile 自身不设置 `permissionMode`。在 plan 模式下,写工具会被 `plan/planModeGateHook`(注册在 `tool.approval.pre`,priority -1000)拒绝,仅 `submit_plan` 与 `dispatch_agent` 放行。`dispatch_agent` 由 `plan/planSubagentWhitelistHook` 进一步限制为只能派发 `explore` 子代理。 + --- ## 执行流程 diff --git a/docs/tools.md b/docs/tools.md index 7ddd82f5..c4dd601d 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -108,7 +108,7 @@ interface ToolVisibilityPolicy { |------|------|------| | 1 | **RuleEngine** | 规则引擎匹配,支持 glob 模式匹配工具名和参数,按优先级排序 | | 2 | **ReadonlyWhitelist** | 只读工具自动放行(read_file, search_code, search_files, fetch_url, web_search, dispatch_agent, todo_write) | -| 3 | **PermissionMode** | 权限模式判断:`plan`(只允许只读)、`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行)、`default`(继续下一层) | +| 3 | **PermissionMode** | 权限模式判断:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行)、`default`(继续下一层)。`plan` 模式由独立的 `plan/planModeGateHook` 在 Layer 4 强制,不在此层处理 | | 4 | **HookPreToolUse** | 钩子决策,可返回 allow/deny/ask/continue,支持 `modifiedInput` 修改参数 | | 5 | **UserConfirmation** | 异步用户确认,支持 allow/deny/always/never 四种响应,always/never 会持久化为规则 | | 6 | **AuditLog** | 每一层决策后记录审计日志,通过 `tool.approval.post` 钩子发出 | @@ -132,14 +132,15 @@ interface ToolVisibilityPolicy { ### 权限模式 ```typescript -type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypass'; +type PermissionMode = 'default' | 'acceptEdits' | 'bypass'; ``` - `default`:逐层审批,危险操作需用户确认 - `acceptEdits`:非破坏性工具自动放行,减少确认弹窗 -- `plan`:只允许只读工具,适合纯分析场景 - `bypass`:全部放行,跳过所有审批(慎用) +> `plan` 不再是 `PermissionMode` 的成员。plan 模式通过 `AgentProfile.name === 'plan'` 结构化识别,由 `plan/planModeGateHook` 在 `tool.approval.pre` 阶段(priority -1000)强制拒绝非白名单工具。白名单见 `plan/policy.ts` 的 `PLAN_MODE_ALLOWED_TOOLS`。 + ### OS 级沙箱(预留) `packages/codingcode/src/sandbox/` 目前是 stub 实现(`SandboxService` 为空类),尚未集成实际的沙箱运行时。审批流水线已提供基本安全保障,OS 级沙箱将在未来版本中实现。 diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index b1811d69..42e0a181 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -13,6 +13,7 @@ "./agent/prompt": "./src/agent/prompt.ts", "./session/store": "./src/session/store.ts", "./session/io": "./src/session/io.ts", + "./session/types": "./src/session/types.ts", "./session/messages": "./src/session/messages.ts", "./core/path": "./src/core/path.ts", "./core/workspace": "./src/core/workspace.ts", diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index c42bdf4f..643144c6 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -21,12 +21,16 @@ import { ContextService } from '../context/service.js'; import { MemoryService } from '../memory/index.js'; import { createLogger } from '@codingcode/infra/logger'; import { resolveSubagentEnabled, resolveAgentDisabled } from '../subagent/registry.js'; -import { ProjectRuntimeService } from '../runtime/project-runtime.js'; +import { ProjectRuntimeService, modeToProfile } from '../runtime/project-runtime.js'; import { createDispatchAgentTool } from '../tools/domains/subagent/dispatch.js'; import { LLMFactoryService } from '../llm/factory.js'; import { getBuiltinTools } from '../tools/providers.js'; +import { submitPlanTool } from '../tools/domains/subagent/submit-plan.js'; import { canonicalizeSchema } from '../tools/utils/canonicalize-schema.js'; import { normalizePath } from '../core/path.js'; +import { isPlanProfile } from '../plan/index.js'; +import type { SessionMode } from '../session/types.js'; +import type { PermissionMode } from '../approval/types.js'; const REACTIVE_COMPACT_MAX_RETRIES = 3; import { RulesService } from '../rules/index.js'; @@ -116,9 +120,12 @@ export const sendMessage = ( input: string, cwd: string, llm: LLMClient, - options?: { + options: { signal?: AbortSignal; approvalOverride?: any; + mode: SessionMode; + permissionMode: PermissionMode; + model: string; } ) => Effect.gen(function* () { @@ -140,10 +147,30 @@ export const sendMessage = ( yield* runtime.prepareProject(normalizedCwd); yield* skills.evictProject(normalizedCwd); - const state = sessionId - ? yield* session.load(normalizedCwd, sessionId) - : yield* session.create(normalizedCwd, llm.modelInfo.model); - state.model = llm.modelInfo.model; + if (!sessionId) { + const created = yield* session.create(normalizedCwd, { + model: options.model, + mode: options.mode, + permissionMode: options.permissionMode, + }); + const profile = modeToProfile(options.mode); + yield* runtime.setSessionProfile( + normalizedCwd, + created.sessionId, + profile, + options.permissionMode + ); + sessionId = created.sessionId; + } + const state = yield* session.load(normalizedCwd, sessionId); + if (state.activeProfile) { + yield* runtime.restoreSessionProfile( + normalizedCwd, + state.sessionId, + state.activeProfile, + state.permissionMode + ); + } state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd); const sid = state.sessionId; @@ -160,9 +187,7 @@ export const sendMessage = ( } } const effectiveMaxSteps = profile?.maxSteps; - const effectiveApproval: any = profile?.readonly - ? { permissionMode: 'bypass' } - : options?.approvalOverride; + const effectiveApproval: any = options?.approvalOverride; if (profile?.hooks?.length) { yield* hooks.attachSessionHooks(sid, profile.hooks); @@ -187,6 +212,7 @@ export const sendMessage = ( const stream = agent.runStream({ state, llm: activeLlm, + profile, toolPolicy: policy, maxStepsOverride: effectiveMaxSteps, approvalOverride: effectiveApproval, @@ -221,6 +247,7 @@ export function agentLoop( > { const state = opts.state; const llm = opts.llm; + const profile = opts.profile; const sessionId = state.sessionId; const projectPath = state.cwd; @@ -234,9 +261,12 @@ export function agentLoop( const { skillInstruction, systemPromptVariant, rulesText } = opts; const allAgentProfiles = runtime.listAgentProfiles(projectPath); - const agentProfiles = resolveSubagentEnabled(projectPath) + const enabledAgentProfiles = resolveSubagentEnabled(projectPath) ? allAgentProfiles.filter((p) => !resolveAgentDisabled(projectPath, p.name)) : []; + const visibleAgentProfiles = isPlanProfile(profile) + ? enabledAgentProfiles.filter((p) => p.name === 'explore') + : enabledAgentProfiles; const basePrompt = opts.systemOverride ?? buildSystemPrompt({ @@ -245,8 +275,9 @@ export function agentLoop( shell: process.env.SHELL || process.env.ComSpec || 'bash', variant: systemPromptVariant ?? 'default', skillInstruction, - agentProfiles, + agentProfiles: visibleAgentProfiles, rules: rulesText, + profileSystemPrompt: profile?.systemPrompt, }); const memoryBlock = state.memorySnapshot; @@ -260,10 +291,11 @@ export function agentLoop( const effectiveMaxStopContinuations = opts.maxStopContinuations ?? maxStopContinuations; let messages: Message[] = []; + let submittedPlanTitle: string | null = null; for (let attempt = 0; attempt <= maxOverflowRetries; attempt++) { const payload = yield* Effect.sync(() => - context.assemblePayload(state.sessionId, state.projectPath, llm.modelInfo.maxTokens) + context.assemblePayload(state.transcriptPath, llm.modelInfo.maxTokens) ); messages = payload.messages; @@ -281,6 +313,7 @@ export function agentLoop( let allToolDefs: ToolDefinition[] = [...builtinTools, ...(opts.mcpTools ?? [])]; if (opts.dispatchTool && resolveSubagentEnabled(projectPath)) allToolDefs = [...allToolDefs, opts.dispatchTool]; + if (isPlanProfile(profile)) allToolDefs = [...allToolDefs, submitPlanTool]; const allowedByPolicy = opts.toolPolicy?.allowedTools; let filteredDefs = allToolDefs; @@ -303,8 +336,7 @@ export function agentLoop( const compressResult = yield* Effect.tryPromise({ try: () => context.compactIfNeeded( - state.sessionId, - state.projectPath, + state.transcriptPath, messages, llm.modelInfo.maxTokens, llm @@ -354,8 +386,7 @@ export function agentLoop( const compressResult = yield* Effect.tryPromise({ try: () => context.compactWithLLM( - state.sessionId, - state.projectPath, + state.transcriptPath, llm.modelInfo.maxTokens, llm, undefined @@ -438,6 +469,15 @@ export function agentLoop( continue; } + if (submittedPlanTitle !== null) { + yield* hooks.emit('plan.ready', { + sessionId, + projectPath, + title: submittedPlanTitle, + }); + submittedPlanTitle = null; + } + yield* q.offer({ _tag: 'Done', content: resp.content }); lastResult = Result.ok(resp.content); yield* hooks.emit('agent.turn.end', { @@ -496,6 +536,14 @@ export function agentLoop( todoPrinted = true; } } + + const submitPlanCall = toolCalls?.find((tc) => tc.name === 'submit_plan'); + const submitPlanResult = allResults.find( + (r) => r.name === 'submit_plan' && r.type === 'ok' + ); + if (submitPlanCall && submitPlanResult && submittedPlanTitle === null) { + submittedPlanTitle = String(submitPlanCall.arguments?.title ?? ''); + } } if (overflow) continue; @@ -530,18 +578,19 @@ export function agentLoop( }).pipe( Effect.interruptible, Effect.onInterrupt(() => - Effect.sync(() => { - Effect.runSync( - q.offer({ _tag: 'Error', error: new AgentError('AGENT_ABORTED', 'cancelled') }) - ); - hooks + Effect.gen(function* () { + yield* Effect.sync(() => { + Effect.runSync( + q.offer({ _tag: 'Error', error: new AgentError('AGENT_ABORTED', 'cancelled') }) + ); + }); + yield* hooks .emit('agent.turn.end', { sessionId, turnId: state.currentTurnId, status: 'aborted', }) - .pipe(Effect.runPromise) - .catch(() => {}); + .pipe(Effect.ignore); }) ), Effect.ensuring( diff --git a/packages/codingcode/src/agent/prompt.ts b/packages/codingcode/src/agent/prompt.ts index e20d13f6..d7212d77 100644 --- a/packages/codingcode/src/agent/prompt.ts +++ b/packages/codingcode/src/agent/prompt.ts @@ -1,28 +1,28 @@ import type { SystemPromptOptions } from './types.js'; -const DEFAULT_SYSTEM_PROMPT = `You are a coding assistant — an AI agent that helps users with software engineering tasks. +const DEFAULT_BEHAVIOR_PROMPT = `You are a coding assistant —an AI agent that helps users with software engineering tasks. ## How you work -- Your text output is displayed to the user as formatted text. Tool calls and their results are shown separately — the user can see what tools you used and their outcomes. -- Tools run behind a permission system. If a tool call is denied, the user declined it — adjust your approach, do not retry the same call verbatim. -- Messages may contain tags injected by the system, not by the user. They contain useful operational information — always read and follow them. +- Your text output is displayed to the user as formatted text. Tool calls and their results are shown separately —the user can see what tools you used and their outcomes. +- Tools run behind a permission system. If a tool call is denied, the user declined it —adjust your approach, do not retry the same call verbatim. +- Messages may contain tags injected by the system, not by the user. They contain useful operational information —always read and follow them. ## Rules -1. Read files before modifying them — never guess file contents -2. Use search_code or search_files to locate code before reading — this is faster than reading entire files blindly +1. Read files before modifying them —never guess file contents +2. Use search_code or search_files to locate code before reading —this is faster than reading entire files blindly 3. Prefer editing existing files over creating new ones -4. Make small, focused changes — avoid large rewrites +4. Make small, focused changes —avoid large rewrites 5. Run tests or type-check after changes when applicable 6. If the user's request is ambiguous, ask for clarification 7. For complex or broad tasks (understanding a whole module, cross-file analysis, comprehensive search): - a. Briefly assess the task scope using your own reasoning — do not use tools for exploration at this stage, as that would consume your limited context window. + 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 to dispatch_agent with the original task and your assessment of what needs to be explored. The subagent handles discovery in its own separate context, keeping your main context clean for coordination. ## Using your tools - **Prefer dedicated tools over shell commands.** Use read_file instead of cat, edit_file instead of sed, search_code instead of grep. Dedicated tools give the user better visibility into your work. -- **Call multiple tools in parallel** when they are independent — for example, reading several files at once, or searching with different patterns. Do NOT make sequential calls when the calls don't depend on each other. -- After editing a file, do NOT re-read it to verify — the edit tool already confirms success or reports failure. Only re-read if you suspect the edit did not apply correctly. +- **Call multiple tools in parallel** when they are independent —for example, reading several files at once, or searching with different patterns. Do NOT make sequential calls when the calls don't depend on each other. +- After editing a file, do NOT re-read it to verify —the edit tool already confirms success or reports failure. Only re-read if you suspect the edit did not apply correctly. - Reserve execute_command for actual system commands and terminal operations (git, npm, build, test). Do not use it for file operations that dedicated tools can handle. ## Executing actions with care @@ -30,42 +30,42 @@ Consider the reversibility and blast radius of actions before taking them: - **Freely take** local, reversible actions: editing files, running tests, reading code. - **Confirm with the user before** hard-to-reverse or outward-facing actions: pushing code, deleting files/branches, force-pushing, modifying CI/CD pipelines, sending messages to external services. - **Never** use destructive commands (rm -rf /, sudo, git reset --hard, git push --force, git clean -f) unless explicitly requested and approved by the user. -- When you encounter unexpected state (unfamiliar files, branches, or configuration), investigate before deleting or overwriting — it may be the user's in-progress work. Never revert changes you did not make. +- When you encounter unexpected state (unfamiliar files, branches, or configuration), investigate before deleting or overwriting —it may be the user's in-progress work. Never revert changes you did not make. ## Git operations - Do NOT commit changes unless the user explicitly asks you to. - Do NOT push to remote unless the user explicitly asks you to. - Do NOT use destructive git commands (git reset --hard, git push --force, git clean -f, git checkout -- .) unless explicitly requested and approved. -- If you notice unexpected changes in the working tree that you did not make, investigate before acting — they may be the user's in-progress work. +- If you notice unexpected changes in the working tree that you did not make, investigate before acting —they may be the user's in-progress work. ## Professional objectivity -Prioritize technical accuracy over validating the user's beliefs. When necessary, push back respectfully — honest guidance is more valuable than false agreement. +Prioritize technical accuracy over validating the user's beliefs. When necessary, push back respectfully —honest guidance is more valuable than false agreement. - Do not begin responses with conversational interjections ("Got it", "Sure", "Great question") - Do not apologize unnecessarily when results are unexpected ## Follow existing conventions When modifying code, first look at the surrounding code's style (naming, frameworks, imports) and match it: -- **Never assume a library is available** — check imports in neighboring files, or check the dependency file (package.json, cargo.toml, requirements.txt, etc.) before using it. +- **Never assume a library is available** —check imports in neighboring files, or check the dependency file (package.json, cargo.toml, requirements.txt, etc.) before using it. - **When creating a new component**, first look at existing components to understand naming conventions, typing patterns, and framework choices. - **When editing code**, look at the surrounding context (especially imports) to understand the code's choice of frameworks and libraries, then make your change in the most idiomatic way. -- **Comments**: default to writing no comments. Only add one when the WHY is non-obvious — a hidden constraint, a subtle invariant, or a workaround for a specific bug. Do not explain WHAT the code does. +- **Comments**: default to writing no comments. Only add one when the WHY is non-obvious —a hidden constraint, a subtle invariant, or a workaround for a specific bug. Do not explain WHAT the code does. ## Code references When referencing code, use the format \`file_path:line_number\` for easy navigation. ## Output efficiency - Be concise. Lead with the answer or action, not with reasoning or preamble. -- Skip filler words and unnecessary transitions. Do not restate what the user said — just do it. +- Skip filler words and unnecessary transitions. Do not restate what the user said —just do it. - When working on a multi-step task, give brief updates at key moments (when you find something, change direction, or hit a blocker). One sentence per update is enough. - When the task is done, give a one-to-two sentence summary of what changed. Do not narrate your entire process. - Match the response to the question: a simple question gets a direct answer, not headers and sections. -## Environment -- Working directory: {{cwd}} -- Operating system: {{platform}} -- Shell: {{shell}} Respond in the user's language. Use code blocks for code.`; +const DEFAULT_ENV_PROMPT = `## Environment +- Working directory: {{cwd}} +- Operating system: {{platform}} +- Shell: {{shell}}`; export const SYSTEM_NOTES = `## System Notes @@ -74,13 +74,14 @@ export const SYSTEM_NOTES = `## System Notes - The todo_write tool lets you track multi-step plans. Use it for tasks that require more than one step.`; function renderBase(opts: SystemPromptOptions): string { - return DEFAULT_SYSTEM_PROMPT.replace('{{cwd}}', opts.cwd) + return DEFAULT_ENV_PROMPT.replace('{{cwd}}', opts.cwd) .replace('{{platform}}', opts.platform) .replace('{{shell}}', opts.shell); } export function buildSystemPrompt(opts: SystemPromptOptions): string { let prompt = renderBase(opts); + prompt += '\n\n' + (opts.profileSystemPrompt ?? DEFAULT_BEHAVIOR_PROMPT); prompt += `\n\n${SYSTEM_NOTES}`; const rules = opts.rules; @@ -104,19 +105,19 @@ export function buildSystemPrompt(opts: SystemPromptOptions): string { ### When to dispatch -Dispatch a subagent when the task involves extensively reading files, searching across the codebase, or analyzing a whole module. A subagent runs in an independent context window — all of its tool calls (read_file, search_code, etc.) consume only the subagent\'s own context. Only the final result comes back to you. +Dispatch a subagent when the task involves extensively reading files, searching across the codebase, or analyzing a whole module. A subagent runs in an independent context window —all of its tool calls (read_file, search_code, etc.) consume only the subagent\'s own context. Only the final result comes back to you. **Dispatch = protect your context window.** If you do the same work yourself, all the raw content goes directly into your context. ### When NOT to dispatch -- The task needs only a small amount of information — do it yourself. -- You already know the exact file path and what to look for — use read_file / search_code directly. +- The task needs only a small amount of information —do it yourself. +- You already know the exact file path and what to look for —use read_file / search_code directly. ### Rules 1. Once you dispatch a subagent, do **NOT** also perform the same searches yourself. -2. **Do NOT peek** — the subagent runs independently. Do not try to read its intermediate output, as that defeats the context protection. +2. **Do NOT peek** —the subagent runs independently. Do not try to read its intermediate output, as that defeats the context protection. 3. When the subagent returns, relay its conclusion to the user concisely. ### Example diff --git a/packages/codingcode/src/agent/types.ts b/packages/codingcode/src/agent/types.ts index bc36b1b9..2cd7f078 100644 --- a/packages/codingcode/src/agent/types.ts +++ b/packages/codingcode/src/agent/types.ts @@ -28,6 +28,7 @@ export interface SystemPromptOptions { skillInstruction?: string; agentProfiles?: AgentProfile[]; rules?: string; + profileSystemPrompt?: string; } export interface ResolvedConfig { @@ -50,12 +51,6 @@ export type AgentEvent = readonly name: string; readonly reason: string; } - | { - readonly _tag: 'ApprovalRequest'; - readonly id: string; - readonly tool: string; - readonly args: Record; - } | { readonly _tag: 'ToolResult'; readonly id: string; @@ -90,6 +85,7 @@ export type AgentEvent = export interface RunStreamOptions { state: SessionStoreState; llm: LLMClient; + profile?: AgentProfile; skillInstruction?: string; systemPromptVariant?: SystemPromptVariant; systemOverride?: string; diff --git a/packages/codingcode/src/approval/confirmation.ts b/packages/codingcode/src/approval/confirmation.ts index a4698c48..a41bbac1 100644 --- a/packages/codingcode/src/approval/confirmation.ts +++ b/packages/codingcode/src/approval/confirmation.ts @@ -20,7 +20,6 @@ export function userConfirmAsync( yield* waitSvc.emitApprovalRequest(sessionId, id, tool, args); - // Suspend until resolveConfirm is called return yield* waitSvc.waitForConfirm(id, sessionId); }); } diff --git a/packages/codingcode/src/approval/index.ts b/packages/codingcode/src/approval/index.ts index dbbd168e..61814980 100644 --- a/packages/codingcode/src/approval/index.ts +++ b/packages/codingcode/src/approval/index.ts @@ -29,6 +29,7 @@ export class ApprovalService extends Effect.Service()('Approval context?: Record; callId?: string; sessionId: string; + projectPath?: string; }): Effect.Effect => runPipeline( { @@ -45,6 +46,7 @@ export class ApprovalService extends Effect.Service()('Approval onAlways: (rule) => engine.addRule(rule), onNever: (rule) => engine.addRule(rule), sessionId: request.sessionId, + projectPath: request.projectPath, callId: request.callId, } ).pipe( @@ -97,6 +99,7 @@ export class ApprovalService extends Effect.Service()('Approval context?: Record; callId?: string; sessionId: string; + projectPath?: string; }): Effect.Effect => runPipeline( { @@ -113,6 +116,7 @@ export class ApprovalService extends Effect.Service()('Approval onAlways: (rule) => ruleEngine.addRule(rule), onNever: (rule) => ruleEngine.addRule(rule), sessionId: request.sessionId, + projectPath: request.projectPath, callId: request.callId, } ).pipe( diff --git a/packages/codingcode/src/approval/pipeline.ts b/packages/codingcode/src/approval/pipeline.ts index c1ba44a6..d15a0921 100644 --- a/packages/codingcode/src/approval/pipeline.ts +++ b/packages/codingcode/src/approval/pipeline.ts @@ -16,6 +16,9 @@ export interface PipelineOptions { onNever?: (rule: PermissionRule) => void; /** Session ID for session-scoped approval routing. */ sessionId: string; + /** Project path for session-scoped approval routing (used by decision hooks + * that need to inspect the session's runtime state). */ + projectPath?: string; /** Optional LLM ToolCall ID to use as approval request ID. */ callId?: string; } @@ -83,6 +86,8 @@ export function runPipeline( const result = yield* hooks.emitDecision('tool.approval.pre', { toolName: request.tool, args: request.input, + sessionId: opts.sessionId, + projectPath: opts.projectPath, }); if (result && result.decision === 'continue') { return null; @@ -106,16 +111,27 @@ export function runPipeline( return final; } // 'ask' or no decision → continue to user confirmation + const nextRequest: ToolCallRequest = { ...request }; if (hookResult.modifiedInput) { - // Use modified input for user confirmation - request = { ...request, input: hookResult.modifiedInput }; + nextRequest.input = hookResult.modifiedInput; } + request = nextRequest; } } // Layer 5: User Confirmation { layers.push(LAYER_NAMES[4]); + + if (request.tool === 'submit_plan') { + const result: ApprovalDecision = { + type: 'allow', + source: 'system-plan-self-handles', + }; + const final = yield* recordAuditAndReturn(hooks, request, result, layers); + return final; + } + if (!asyncConfirm) { const result: ApprovalDecision = { type: 'deny', @@ -164,17 +180,6 @@ function applyPermissionMode( destructiveTools: Set ): ApprovalDecision | null { switch (mode) { - case 'plan': - // Plan mode: only read-only tools allowed - if (!readonlyTools.has(tool)) { - return { - type: 'deny', - reason: 'Write operations denied in plan mode', - source: 'permission-mode', - }; - } - return { type: 'allow', source: 'permission-mode' }; - case 'bypass': // Bypass mode: everything allowed (sandbox still restricts at OS level) return { type: 'allow', source: 'permission-mode' }; diff --git a/packages/codingcode/src/approval/rule-engine.ts b/packages/codingcode/src/approval/rule-engine.ts index 75872f72..b131483c 100644 --- a/packages/codingcode/src/approval/rule-engine.ts +++ b/packages/codingcode/src/approval/rule-engine.ts @@ -62,7 +62,7 @@ export function createRuleEngine(initialRules: PermissionRule[] = []): RuleEngin case 'allow': return { type: 'allow', source: `rule:${rule.id}` }; case 'ask': - return { type: 'ask', source: `rule:${rule.id}` }; + continue; } } diff --git a/packages/codingcode/src/approval/types.ts b/packages/codingcode/src/approval/types.ts index af0a32cd..5e98a5da 100644 --- a/packages/codingcode/src/approval/types.ts +++ b/packages/codingcode/src/approval/types.ts @@ -1,4 +1,10 @@ -export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypass'; +export type PermissionMode = 'default' | 'acceptEdits' | 'bypass'; + +export const PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'bypass'] as const; + +export function isPermissionMode(value: unknown): value is PermissionMode { + return typeof value === 'string' && (PERMISSION_MODES as readonly string[]).includes(value); +} export interface ToolCallRequest { tool: string; @@ -9,10 +15,7 @@ export interface ToolCallRequest { export type ApprovalDecision = | { type: 'deny'; reason: string; source: string } - | { type: 'allow'; source: string } - | { type: 'ask'; source: string } - | { type: 'modified'; input: Record; source: string } - | { type: 'continue' }; + | { type: 'allow'; source: string }; export type RuleAction = 'deny' | 'allow' | 'ask'; diff --git a/packages/codingcode/src/checkpoint/project-lock.ts b/packages/codingcode/src/checkpoint/project-lock.ts index 2ded92f4..493e88ca 100644 --- a/packages/codingcode/src/checkpoint/project-lock.ts +++ b/packages/codingcode/src/checkpoint/project-lock.ts @@ -1,9 +1,6 @@ import { openSync, closeSync, unlinkSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; -import { homedir } from 'os'; -import { normalizePath, encodeProjectPath } from '../core/path.js'; - -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +import { normalizePath, encodeProjectPath, getProjectBaseDir } from '../core/path.js'; export class ProjectLock { private readonly lockPath: string; @@ -11,7 +8,7 @@ export class ProjectLock { constructor(projectPath: string) { const encoded = encodeProjectPath(normalizePath(projectPath)); - this.lockPath = join(PROJECT_BASE, encoded, 'checkpoint', 'repo.lock'); + this.lockPath = join(getProjectBaseDir(), 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 499d7766..c936310d 100644 --- a/packages/codingcode/src/checkpoint/shadow-git.ts +++ b/packages/codingcode/src/checkpoint/shadow-git.ts @@ -1,10 +1,8 @@ import { spawnSync } from 'child_process'; import { existsSync, mkdirSync, statSync, writeFileSync } from 'fs'; -import { homedir } from 'os'; import { join } from 'path'; -import { normalizePath, encodeProjectPath } from '../core/path.js'; +import { normalizePath, encodeProjectPath, getProjectBaseDir } from '../core/path.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null'; const IGNORE_RULES = [ @@ -32,7 +30,7 @@ export class ShadowGit { // 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(PROJECT_BASE, encoded, 'checkpoint', 'repo.git'); + this.gitDir = join(getProjectBaseDir(), encoded, 'checkpoint', 'repo.git'); } init(): void { diff --git a/packages/codingcode/src/client/direct.ts b/packages/codingcode/src/client/direct.ts index 26caa152..d54d4522 100644 --- a/packages/codingcode/src/client/direct.ts +++ b/packages/codingcode/src/client/direct.ts @@ -48,9 +48,6 @@ export async function* agentEventToStreamChunk( case 'ToolDenied': yield { type: 'tool_denied', id: event.id, name: event.name, reason: event.reason }; break; - case 'ApprovalRequest': - yield { type: 'approval_request', id: event.id, tool: event.tool, args: event.args }; - break; case 'Error': yield { type: 'error', @@ -109,7 +106,11 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis return yield* ApprovalWaitService; }) ); - const program = sendMessage(currentSessionId || undefined, input, cwd(), activeLlm); + const program = sendMessage(currentSessionId || undefined, input, cwd(), activeLlm, { + mode: 'build', + permissionMode: 'default', + model: activeLlm.modelInfo.model, + }); const { stream: agentGen, sessionId } = (await runWithLayer(program)) as any; currentSessionId = sessionId; @@ -379,8 +380,8 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis await clients.settings.deleteMemoryExtraType(name); }, - async getSubagentEnabled() { - return clients.settings.getSubagentEnabled({ cwd: cwd() }); + async getSubagentEnabled({ cwd: targetCwd }: { cwd: string }) { + return clients.settings.getSubagentEnabled({ cwd: targetCwd }); }, async setSubagentEnabled(body: { enabled: boolean; cwd: string }) { @@ -391,8 +392,8 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis await clients.settings.resetSubagentEnabled(body); }, - async getMcpStatus() { - return clients.settings.getMcpStatus(); + async getMcpStatus({ cwd: targetCwd }: { cwd: string }) { + return clients.settings.getMcpStatus({ cwd: targetCwd }); }, async setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }) { @@ -403,16 +404,23 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis await clients.settings.resetMcpDisabled(body); }, - async createMcpServer(server: McpServerConfig): Promise { - await clients.settings.createMcpServer({ cwd: cwd(), server }); + async createMcpServer( + server: McpServerConfig, + { cwd: targetCwd }: { cwd: string } + ): Promise { + await clients.settings.createMcpServer({ cwd: targetCwd, server }); }, - async updateMcpServer(name: string, server: McpServerConfig): Promise { - await clients.settings.updateMcpServer({ cwd: cwd(), name, server }); + async updateMcpServer( + name: string, + server: McpServerConfig, + { cwd: targetCwd }: { cwd: string } + ): Promise { + await clients.settings.updateMcpServer({ cwd: targetCwd, name, server }); }, - async deleteMcpServer(name: string): Promise { - await clients.settings.deleteMcpServer({ cwd: cwd(), name }); + async deleteMcpServer(name: string, { cwd: targetCwd }: { cwd: string }): Promise { + await clients.settings.deleteMcpServer({ cwd: targetCwd, name }); }, async listSkills() { @@ -423,20 +431,24 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis await clients.settings.toggleSkill(body); }, - async listAgents() { - return clients.settings.listAgents({ cwd: cwd() }); + async listAgents({ cwd: targetCwd }: { cwd: string }) { + return clients.settings.listAgents({ cwd: targetCwd }); }, - async createAgent(profile: AgentProfile): Promise { - await clients.settings.createAgent({ cwd: cwd(), profile }); + async createAgent(profile: AgentProfile, { cwd: targetCwd }: { cwd: string }): Promise { + await clients.settings.createAgent({ cwd: targetCwd, profile }); }, - async updateAgent(name: string, profile: AgentProfile): Promise { - await clients.settings.updateAgent({ cwd: cwd(), name, profile }); + async updateAgent( + name: string, + profile: AgentProfile, + { cwd: targetCwd }: { cwd: string } + ): Promise { + await clients.settings.updateAgent({ cwd: targetCwd, name, profile }); }, - async deleteAgent(name: string): Promise { - await clients.settings.deleteAgent({ cwd: cwd(), name }); + async deleteAgent(name: string, { cwd: targetCwd }: { cwd: string }): Promise { + await clients.settings.deleteAgent({ cwd: targetCwd, name }); }, async setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise { @@ -447,32 +459,32 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis await clients.settings.resetAgentDisabled(body); }, - async listHooks() { - return clients.settings.listHooks({ cwd: cwd() }); + async listHooks({ cwd: targetCwd }: { cwd: string }) { + return clients.settings.listHooks({ cwd: targetCwd }); }, async setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise { - await clients.settings.setHookDisabled({ - cwd: cwd(), - name: body.name, - disabled: body.disabled, - }); + await clients.settings.setHookDisabled(body); }, async resetHookDisabled(body: { name: string; cwd: string }): Promise { await clients.settings.resetHookDisabled(body); }, - async createHook(hook: UserHookConfig): Promise { - await clients.settings.createHook({ cwd: cwd(), hook }); + async createHook(hook: UserHookConfig, { cwd: targetCwd }: { cwd: string }): Promise { + await clients.settings.createHook({ cwd: targetCwd, hook }); }, - async updateHook(name: string, hook: UserHookConfig): Promise { - await clients.settings.updateHook({ cwd: cwd(), name, hook }); + async updateHook( + name: string, + hook: UserHookConfig, + { cwd: targetCwd }: { cwd: string } + ): Promise { + await clients.settings.updateHook({ cwd: targetCwd, name, hook }); }, - async deleteHook(name: string): Promise { - await clients.settings.deleteHook({ cwd: cwd(), name }); + async deleteHook(name: string, { cwd: targetCwd }: { cwd: string }): Promise { + await clients.settings.deleteHook({ cwd: targetCwd, name }); }, async getPermissionMode(): Promise { diff --git a/packages/codingcode/src/client/direct/agent-runtime.ts b/packages/codingcode/src/client/direct/agent-runtime.ts index fa31080f..16e92043 100644 --- a/packages/codingcode/src/client/direct/agent-runtime.ts +++ b/packages/codingcode/src/client/direct/agent-runtime.ts @@ -3,6 +3,8 @@ import { sendMessage } from '../../agent/agent.js'; import { ApprovalWaitService } from '../../approval/async-confirm.js'; import { parseApprovalResponse } from '../../approval/response.js'; import { ContextService } from '../../context/service.js'; +import { HookService } from '../../hooks/registry.js'; +import { SessionService } from '../../session/store.js'; import type { StreamChunk } from '../types.js'; import { agentEventToStreamChunk } from '../direct.js'; import type { AppRuntime } from '../../layer.js'; @@ -26,70 +28,104 @@ export interface AgentRuntimeClient { export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRuntimeClient { return { async *sendMessage(input, { sessionId, cwd }) { - const program = sendMessage(sessionId || undefined, input, cwd, llm); + const program = sendMessage(sessionId || undefined, input, cwd, llm, { + mode: 'build', + permissionMode: 'default', + model: llm.modelInfo.model, + }); const { stream: agentGen, sessionId: resolvedSessionId } = (await rt.runPromise( program )) as any; yield { type: 'session_id', sessionId: resolvedSessionId }; - let notify: - | ((req: { - type: 'approval_request'; - id: string; - tool: string; - args: Record; - }) => void) - | null = null; + let notifyApproval: ((req: StreamChunk) => void) | null = null; + let notifyPlan: ((req: StreamChunk) => void) | null = null; const waitService = await rt.runPromise( Effect.gen(function* () { return yield* ApprovalWaitService; }) ); + const hookService = await rt.runPromise( + Effect.gen(function* () { + return yield* HookService; + }) + ); Effect.runSync( waitService.registerEmitter( resolvedSessionId, (id: string, tool: string, args: Record) => { - notify?.({ type: 'approval_request', id, tool, args }); + notifyApproval?.({ type: 'approval_request', id, tool, args }); } ) ); + const unregisterPlanReady = Effect.runSync( + hookService.register('plan.ready', (payload) => { + const p = payload as { + sessionId?: string; + title?: string; + }; + if (p.sessionId !== resolvedSessionId) return; + notifyPlan?.({ + type: 'plan_ready', + sessionId: p.sessionId ?? '', + title: p.title ?? '', + }); + }) + ); try { const gen = agentEventToStreamChunk(agentGen); let pending = gen.next(); + let currentApprovalPromise = new Promise((resolve) => { + notifyApproval = resolve; + }); + let currentPlanPromise = new Promise((resolve) => { + notifyPlan = resolve; + }); while (true) { - const approvalPromise = new Promise<{ - type: 'approval_request'; - id: string; - tool: string; - args: Record; - }>((resolve) => { - notify = resolve; - }); - + const approvalPromise = currentApprovalPromise; + const planPromise = currentPlanPromise; const winner = await Promise.race([ pending.then((c): { tag: 'chunk'; value: IteratorResult } => ({ tag: 'chunk', value: c, })), - approvalPromise.then((req): { tag: 'approval'; value: typeof req } => ({ + approvalPromise.then((req): { tag: 'approval'; value: StreamChunk } => ({ tag: 'approval', value: req, })), + planPromise.then((req): { tag: 'plan'; value: StreamChunk } => ({ + tag: 'plan', + value: req, + })), ]); if (winner.tag === 'chunk') { - notify = null; if (winner.value.done) break; yield winner.value.value; + currentApprovalPromise = new Promise((resolve) => { + notifyApproval = resolve; + }); + currentPlanPromise = new Promise((resolve) => { + notifyPlan = resolve; + }); pending = gen.next(); + } else if (winner.tag === 'approval') { + yield winner.value; + currentApprovalPromise = new Promise((resolve) => { + notifyApproval = resolve; + }); } else { yield winner.value; + currentPlanPromise = new Promise((resolve) => { + notifyPlan = resolve; + }); } } } finally { + unregisterPlanReady(); Effect.runSync(waitService.unregisterEmitter(resolvedSessionId)); } }, @@ -107,9 +143,11 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu async compact({ sessionId, cwd }) { await rt.runPromise( Effect.gen(function* () { + const session = yield* SessionService; const context = yield* ContextService; + const state = yield* session.load(cwd, sessionId); return yield* Effect.promise(() => - context.compactWithLLM(sessionId, cwd, llm.modelInfo.maxTokens, null) + context.compactWithLLM(state.transcriptPath, llm.modelInfo.maxTokens, null) ); }) ); diff --git a/packages/codingcode/src/client/direct/sessions.ts b/packages/codingcode/src/client/direct/sessions.ts index e3f4532d..02b8aba2 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/client/direct/sessions.ts @@ -1,5 +1,6 @@ import { Effect } from 'effect'; import { SessionService } from '../../session/store.js'; +import { ProjectRuntimeService, modeToProfile } from '../../runtime/project-runtime.js'; import { deleteSession } from '../../session/file-ops.js'; import type { PermissionMode } from '../../approval/types.js'; import type { @@ -9,13 +10,15 @@ import type { RollbackPreviewDiff, RollbackState, } from '../../checkpoint/types.js'; -import type { SessionEvent, SessionIndex } from '../../session/types.js'; +import type { SessionEvent, SessionIndex, SessionMode } from '../../session/types.js'; import type { AppRuntime } from '../../layer.js'; export interface SessionClient { createSession(input: { cwd: string; - initialPermissionMode?: string; + mode: SessionMode; + permissionMode: PermissionMode; + model: string; }): Promise<{ sessionId: string }>; resumeSession(input: { sessionId: string; cwd: string }): Promise; listSessions(input: { cwd: string }): Promise; @@ -75,11 +78,14 @@ export interface SessionClient { export function createDirectSessionClient(rt: AppRuntime): SessionClient { return { - async createSession({ cwd }) { + async createSession({ cwd, mode, permissionMode, model }) { return rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - const state = yield* session.create(cwd, 'unknown'); + const runtime = yield* ProjectRuntimeService; + const state = yield* session.create(cwd, { model, mode, permissionMode }); + const profile = modeToProfile(mode); + yield* runtime.setSessionProfile(cwd, state.sessionId, profile, permissionMode); return { sessionId: state.sessionId }; }) ); diff --git a/packages/codingcode/src/client/direct/settings.ts b/packages/codingcode/src/client/direct/settings.ts index 2f67418f..38a7de9b 100644 --- a/packages/codingcode/src/client/direct/settings.ts +++ b/packages/codingcode/src/client/direct/settings.ts @@ -6,10 +6,14 @@ import { ApprovalService } from '../../approval/index.js'; import type { PermissionMode } from '../../approval/types.js'; import type { AgentProfile } from '../../subagent/types.js'; import type { UserHookConfig } from '../../hooks/types.js'; +import { isGlobalCwd } from '../../core/workspace.js'; import { loadMcpConfig, writeMcpConfig, + loadGlobalMcpConfig, + writeGlobalMcpConfig, resolveMcpDisabled, + getGlobalMcpDisabledState, setGlobalMcpDisabledState, setProjectMcpDisabledState, resetProjectMcpDisabledState, @@ -19,6 +23,10 @@ import { writeAgentProfile, updateAgentProfile, deleteAgentProfile, + loadGlobalAgentProfiles, + writeGlobalAgentProfile, + updateGlobalAgentProfile, + deleteGlobalAgentProfile, } from '../../subagent/loader.js'; import { EXPLORE_PROFILE, @@ -28,6 +36,7 @@ import { getProjectSubagentEnabledState, setProjectSubagentEnabledState, resetProjectSubagentEnabledState, + getGlobalAgentDisabledState, setGlobalAgentDisabledState, setProjectAgentDisabledState, resetProjectAgentDisabledState, @@ -37,6 +46,9 @@ import { import { loadHookConfigs, writeHookConfigs, + loadGlobalHookConfigs, + writeGlobalHookConfigs, + resolveHookConfigs, resolveHookDisabled, setGlobalHookDisabledState, setProjectHookDisabledState, @@ -69,7 +81,7 @@ export interface SettingsClient { getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; resetSubagentEnabled(body: { cwd: string }): Promise; - getMcpStatus(): Promise; + getMcpStatus(input: { cwd: string }): Promise; setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetMcpDisabled(body: { name: string; cwd: string }): Promise; createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; @@ -95,31 +107,6 @@ export interface SettingsClient { // ---- Helpers 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).filter((s) => s.name !== name); - writeMcpConfig(cwd, servers); -} - function agentsList(cwd: string): Array<{ name: string; description: string; @@ -133,10 +120,48 @@ function agentsList(cwd: string): Array<{ hasProjectOverride?: boolean; projectDisabled?: boolean; }> { - const custom = loadAgentProfiles(cwd); - return [EXPLORE_PROFILE, PLAN_PROFILE, ...custom].map((a) => { + if (isGlobalCwd(cwd)) { + const custom = loadGlobalAgentProfiles(); + return [EXPLORE_PROFILE, PLAN_PROFILE, ...custom].map((a) => { + const disabled = getGlobalAgentDisabledState(a.name); + return { + name: a.name, + description: a.description, + tools: a.tools, + mcpServers: a.mcpServers, + readonly: a.readonly, + maxSteps: a.maxSteps, + model: a.model, + disabled, + source: + a.name === EXPLORE_PROFILE.name || a.name === PLAN_PROFILE.name + ? ('builtin' as const) + : ('global' as const), + }; + }); + } + const globalCustom = loadGlobalAgentProfiles(); + const projectCustom = loadAgentProfiles(cwd); + const globalNames = new Set(globalCustom.map((a) => a.name)); + const projectNames = new Set(projectCustom.map((a) => a.name)); + + const result: Array<{ + name: string; + description: string; + tools?: string[]; + mcpServers?: string[]; + readonly?: boolean; + maxSteps?: number; + model?: string; + disabled: boolean; + source: 'builtin' | 'global' | 'project'; + hasProjectOverride?: boolean; + projectDisabled?: boolean; + }> = []; + + for (const a of [EXPLORE_PROFILE, PLAN_PROFILE]) { const projectVal = getProjectAgentDisabledState(cwd, a.name); - return { + result.push({ name: a.name, description: a.description, tools: a.tools, @@ -145,17 +170,59 @@ function agentsList(cwd: string): Array<{ maxSteps: a.maxSteps, model: a.model, disabled: resolveAgentDisabled(cwd, a.name), - source: - a.name === EXPLORE_PROFILE.name || a.name === PLAN_PROFILE.name - ? ('builtin' as const) - : ('project' as const), + source: 'builtin', hasProjectOverride: projectVal !== undefined, projectDisabled: projectVal, - }; - }); + }); + } + + for (const a of globalCustom) { + if (projectNames.has(a.name)) continue; + const projectVal = getProjectAgentDisabledState(cwd, a.name); + result.push({ + name: a.name, + description: a.description, + tools: a.tools, + mcpServers: a.mcpServers, + readonly: a.readonly, + maxSteps: a.maxSteps, + model: a.model, + disabled: resolveAgentDisabled(cwd, a.name), + source: 'global', + hasProjectOverride: projectVal !== undefined, + projectDisabled: projectVal, + }); + } + + for (const a of projectCustom) { + const projectVal = getProjectAgentDisabledState(cwd, a.name); + result.push({ + name: a.name, + description: a.description, + tools: a.tools, + mcpServers: a.mcpServers, + readonly: a.readonly, + maxSteps: a.maxSteps, + model: a.model, + disabled: resolveAgentDisabled(cwd, a.name), + source: 'project', + hasProjectOverride: globalNames.has(a.name), + projectDisabled: projectVal, + }); + } + + return result; } function agentsCreate(cwd: string, profile: AgentProfile): void { + if (isGlobalCwd(cwd)) { + const existing = loadGlobalAgentProfiles(); + if (existing.some((a) => a.name === profile.name)) { + throw new AlreadyExistsError(`Agent '${profile.name}' already exists`); + } + writeGlobalAgentProfile(profile); + return; + } const existing = loadAgentProfiles(cwd); if (existing.some((a) => a.name === profile.name)) { throw new AlreadyExistsError(`Agent '${profile.name}' already exists`); @@ -164,6 +231,17 @@ function agentsCreate(cwd: string, profile: AgentProfile): void { } function agentsUpdate(cwd: string, name: string, profile: AgentProfile): void { + if (isGlobalCwd(cwd)) { + const existing = loadGlobalAgentProfiles(); + if (!existing.some((a) => a.name === name)) { + throw new NotFoundError(`Agent '${name}' not found`); + } + if (profile.name !== name && existing.some((a) => a.name === profile.name)) { + throw new AlreadyExistsError(`Agent '${profile.name}' already exists`); + } + updateGlobalAgentProfile(name, profile); + return; + } const existing = loadAgentProfiles(cwd); if (!existing.some((a) => a.name === name)) throw new NotFoundError(`Agent '${name}' not found`); if (profile.name !== name && existing.some((a) => a.name === profile.name)) { @@ -172,7 +250,101 @@ function agentsUpdate(cwd: string, name: string, profile: AgentProfile): void { updateAgentProfile(cwd, name, profile); } +function agentsDelete(cwd: string, name: string): void { + if (isGlobalCwd(cwd)) { + deleteGlobalAgentProfile(name); + return; + } + deleteAgentProfile(cwd, name); +} + +function mcpCreateServer(cwd: string, server: McpServerConfig): void { + if (isGlobalCwd(cwd)) { + const servers = loadGlobalMcpConfig(); + if (servers.some((s) => s.name === server.name)) { + throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); + } + writeGlobalMcpConfig([...servers, server]); + return; + } + 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 { + if (isGlobalCwd(cwd)) { + const servers = loadGlobalMcpConfig(); + 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; + writeGlobalMcpConfig(servers); + return; + } + 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 { + if (isGlobalCwd(cwd)) { + const servers = loadGlobalMcpConfig().filter((s) => s.name !== name); + writeGlobalMcpConfig(servers); + return; + } + 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 hooksList( + cwd: string +): Array { + if (isGlobalCwd(cwd)) { + return loadGlobalHookConfigs().map((h) => ({ ...h, source: 'global' as const })); + } + 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 merged.map((h) => { + const isFromProject = projectNames.has(h.name); + const isFromGlobal = globalNames.has(h.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...h, + source: (isFromProject ? 'project' : 'global') as 'global' | 'project', + hasProjectOverride, + }; + }); +} + function hooksCreate(cwd: string, hook: UserHookConfig): void { + if (isGlobalCwd(cwd)) { + const hooks = loadGlobalHookConfigs(); + if (hooks.some((h) => h.name === hook.name)) { + throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); + } + writeGlobalHookConfigs([...hooks, hook]); + return; + } const hooks = loadHookConfigs(cwd); if (hooks.some((h) => h.name === hook.name)) { throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); @@ -182,6 +354,17 @@ function hooksCreate(cwd: string, hook: UserHookConfig): void { } function hooksUpdate(cwd: string, name: string, hook: UserHookConfig): void { + if (isGlobalCwd(cwd)) { + const hooks = loadGlobalHookConfigs(); + 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; + writeGlobalHookConfigs(hooks); + return; + } const hooks = loadHookConfigs(cwd); const idx = hooks.findIndex((h) => h.name === name); if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); @@ -193,8 +376,19 @@ function hooksUpdate(cwd: string, name: string, hook: UserHookConfig): void { } function hooksDelete(cwd: string, name: string): void { - const hooks = loadHookConfigs(cwd).filter((h) => h.name !== name); - writeHookConfigs(cwd, hooks); + if (isGlobalCwd(cwd)) { + const hooks = loadGlobalHookConfigs().filter((h) => h.name !== name); + writeGlobalHookConfigs(hooks); + return; + } + 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) + ); } function hooksSetDisabled(cwd: string, name: string, disabled: boolean): void { @@ -261,7 +455,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { }, async setSubagentEnabled({ enabled, cwd }) { - if (!cwd || cwd === '' || cwd === 'global') { + if (isGlobalCwd(cwd)) { setSubagentEnabledState(enabled); } else { setProjectSubagentEnabledState(cwd, enabled); @@ -272,17 +466,71 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { resetProjectSubagentEnabledState(cwd); }, - async getMcpStatus() { - return rt.runPromise( + async getMcpStatus({ cwd }) { + const projectCwd = isGlobalCwd(cwd) ? process.cwd() : cwd; + const runtime = await rt.runPromise( Effect.gen(function* () { const mcp = yield* McpService; - return yield* mcp.status(process.cwd()); + return yield* mcp.status(projectCwd); }) ); + const runtimeByName = new Map(runtime.map((r) => [r.name, r])); + if (isGlobalCwd(cwd)) { + return loadGlobalMcpConfig().map((s) => ({ + ...runtimeByName.get(s.name), + name: s.name, + disabled: getGlobalMcpDisabledState(s.name), + source: 'global' as const, + })) as McpStatus[]; + } + const globalServers = loadGlobalMcpConfig(); + const projectServers = loadMcpConfig(projectCwd); + const globalNames = new Set(globalServers.map((s) => s.name)); + const seen = new Set(); + const result: Array< + McpStatus & { source: 'global' | 'project'; hasProjectOverride?: boolean } + > = []; + for (const s of projectServers) { + seen.add(s.name); + const isFromGlobal = globalNames.has(s.name); + const r = runtimeByName.get(s.name); + result.push({ + ...(r ?? { + name: s.name, + connected: false, + transport: 'stdio' as const, + reconnectAttempts: 0, + leaseCount: 0, + toolCount: 0, + }), + name: s.name, + disabled: r?.disabled ?? false, + source: 'project', + hasProjectOverride: isFromGlobal, + }); + } + for (const s of globalServers) { + if (seen.has(s.name)) continue; + const r = runtimeByName.get(s.name); + result.push({ + ...(r ?? { + name: s.name, + connected: false, + transport: 'stdio' as const, + reconnectAttempts: 0, + leaseCount: 0, + toolCount: 0, + }), + name: s.name, + disabled: r?.disabled ?? false, + source: 'global', + }); + } + return result as McpStatus[]; }, async setMcpDisabled({ name, disabled, cwd }) { - if (!cwd || cwd === '' || cwd === 'global') { + if (isGlobalCwd(cwd)) { setGlobalMcpDisabledState(name, disabled); } else { setProjectMcpDisabledState(cwd, name, disabled); @@ -291,8 +539,8 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { Effect.gen(function* () { const mcp = yield* McpService; return yield* disabled - ? mcp.disable(cwd || process.cwd(), name) - : mcp.enable(cwd || process.cwd(), name); + ? mcp.disable(isGlobalCwd(cwd) ? process.cwd() : cwd, name) + : mcp.enable(isGlobalCwd(cwd) ? process.cwd() : cwd, name); }) ); }, @@ -349,11 +597,11 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { }, async deleteAgent({ cwd, name }) { - deleteAgentProfile(cwd, name); + agentsDelete(cwd, name); }, async setAgentDisabled({ name, disabled, cwd }) { - if (!cwd || cwd === '' || cwd === 'global') { + if (isGlobalCwd(cwd)) { setGlobalAgentDisabledState(name, disabled); } else { setProjectAgentDisabledState(cwd, name, disabled); @@ -365,7 +613,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { }, async listHooks({ cwd }) { - return loadHookConfigs(cwd); + return hooksList(cwd) as unknown as UserHookConfig[]; }, async createHook({ cwd, hook }) { @@ -381,7 +629,7 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { }, async setHookDisabled({ cwd, name, disabled }) { - if (!cwd || cwd === '' || cwd === 'global') { + if (isGlobalCwd(cwd)) { setGlobalHookDisabledState(name, disabled); } else { setProjectHookDisabledState(cwd, name, disabled); diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index 7579d609..ed05e0fc 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -248,8 +248,8 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.deleteMemoryExtraType(name); }, - async getSubagentEnabled() { - return clients.settings.getSubagentEnabled({ cwd: '' }); + async getSubagentEnabled({ cwd }: { cwd: string }) { + return clients.settings.getSubagentEnabled({ cwd }); }, async setSubagentEnabled(body: { enabled: boolean; cwd: string }) { @@ -260,8 +260,8 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.resetSubagentEnabled(body); }, - async getMcpStatus() { - return clients.settings.getMcpStatus(); + async getMcpStatus({ cwd }: { cwd: string }) { + return clients.settings.getMcpStatus({ cwd }); }, async setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }) { @@ -280,32 +280,32 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.toggleSkill(body); }, - async createMcpServer(server: McpServerConfig) { - await clients.settings.createMcpServer({ cwd: '', server }); + async createMcpServer(server: McpServerConfig, { cwd }: { cwd: string }) { + await clients.settings.createMcpServer({ cwd, server }); }, - async updateMcpServer(name: string, server: McpServerConfig) { - await clients.settings.updateMcpServer({ cwd: '', name, server }); + async updateMcpServer(name: string, server: McpServerConfig, { cwd }: { cwd: string }) { + await clients.settings.updateMcpServer({ cwd, name, server }); }, - async deleteMcpServer(name: string) { - await clients.settings.deleteMcpServer({ cwd: '', name }); + async deleteMcpServer(name: string, { cwd }: { cwd: string }) { + await clients.settings.deleteMcpServer({ cwd, name }); }, - async listAgents() { - return clients.settings.listAgents({ cwd: '' }); + async listAgents({ cwd }: { cwd: string }) { + return clients.settings.listAgents({ cwd }); }, - async createAgent(profile: AgentProfile) { - await clients.settings.createAgent({ cwd: '', profile }); + async createAgent(profile: AgentProfile, { cwd }: { cwd: string }) { + await clients.settings.createAgent({ cwd, profile }); }, - async updateAgent(name: string, profile: AgentProfile) { - await clients.settings.updateAgent({ cwd: '', name, profile }); + async updateAgent(name: string, profile: AgentProfile, { cwd }: { cwd: string }) { + await clients.settings.updateAgent({ cwd, name, profile }); }, - async deleteAgent(name: string) { - await clients.settings.deleteAgent({ cwd: '', name }); + async deleteAgent(name: string, { cwd }: { cwd: string }) { + await clients.settings.deleteAgent({ cwd, name }); }, async setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }) { @@ -316,28 +316,28 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.resetAgentDisabled(body); }, - async listHooks() { - return clients.settings.listHooks({ cwd: '' }); + async listHooks({ cwd }: { cwd: string }) { + return clients.settings.listHooks({ cwd }); }, async setHookDisabled(body: { name: string; disabled: boolean; cwd: string }) { - await clients.settings.setHookDisabled({ cwd: '', name: body.name, disabled: body.disabled }); + await clients.settings.setHookDisabled(body); }, async resetHookDisabled(body: { name: string; cwd: string }) { await clients.settings.resetHookDisabled(body); }, - async createHook(hook: UserHookConfig) { - await clients.settings.createHook({ cwd: '', hook }); + async createHook(hook: UserHookConfig, { cwd }: { cwd: string }) { + await clients.settings.createHook({ cwd, hook }); }, - async updateHook(name: string, hook: UserHookConfig) { - await clients.settings.updateHook({ cwd: '', name, hook }); + async updateHook(name: string, hook: UserHookConfig, { cwd }: { cwd: string }) { + await clients.settings.updateHook({ cwd, name, hook }); }, - async deleteHook(name: string) { - await clients.settings.deleteHook({ cwd: '', name }); + async deleteHook(name: string, { cwd }: { cwd: string }) { + await clients.settings.deleteHook({ cwd, name }); }, async getPermissionMode() { diff --git a/packages/codingcode/src/client/http/agent-runtime.ts b/packages/codingcode/src/client/http/agent-runtime.ts index 9cc5cb66..6353d939 100644 --- a/packages/codingcode/src/client/http/agent-runtime.ts +++ b/packages/codingcode/src/client/http/agent-runtime.ts @@ -13,7 +13,6 @@ export interface AgentRuntimeClient { approvalId: string; response: string; }): Promise; - compact(input: { sessionId: string; cwd: string }): Promise; } @@ -64,6 +63,13 @@ export function createHttpAgentClient( args: data.args as Record, }; break; + case 'plan_ready': + yield { + type: 'plan_ready', + sessionId: data.sessionId as string, + title: data.title as string, + }; + break; case 'tool_start': yield { type: 'tool_start', diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts index f1edd155..1f3518d7 100644 --- a/packages/codingcode/src/client/http/sessions.ts +++ b/packages/codingcode/src/client/http/sessions.ts @@ -6,13 +6,15 @@ import type { RollbackPreviewDiff, RollbackState, } from '../../checkpoint/types.js'; -import type { SessionEvent, SessionIndex } from '../../session/types.js'; +import type { SessionEvent, SessionIndex, SessionMode } from '../../session/types.js'; import type { createRequestHelpers } from './request.js'; export interface SessionClient { createSession(input: { cwd: string; - initialPermissionMode?: string; + mode: SessionMode; + permissionMode: PermissionMode; + model: string; }): Promise<{ sessionId: string }>; resumeSession(input: { sessionId: string; cwd: string }): Promise; listSessions(input: { cwd: string }): Promise; @@ -75,8 +77,8 @@ export function createHttpSessionClient( const { apiGet, apiPost, apiPut, apiDelete } = request; return { - async createSession({ cwd, initialPermissionMode }) { - return apiPost('/api/sessions', { cwd, initialPermissionMode }); + async createSession({ cwd, mode, permissionMode, model }) { + return apiPost('/api/sessions', { cwd, mode, permissionMode, model }); }, async resumeSession({ sessionId, cwd }) { diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index aac106b2..b0761a1e 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -18,7 +18,7 @@ export interface SettingsClient { getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; resetSubagentEnabled(body: { cwd: string }): Promise; - getMcpStatus(): Promise; + getMcpStatus(input: { cwd: string }): Promise; setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetMcpDisabled(body: { name: string; cwd: string }): Promise; createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; @@ -95,8 +95,8 @@ export function createHttpSettingsClient( await apiPost(`/api/settings/subagent/enabled/reset${qsCwd(cwd)}`, {}); }, - async getMcpStatus() { - return apiGet('/api/settings/mcp'); + async getMcpStatus({ cwd }) { + return apiGet(`/api/settings/mcp${qsCwd(cwd)}`); }, async setMcpDisabled({ name, disabled, cwd }) { diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index 6eda8e1b..12c0cf1a 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -17,7 +17,13 @@ export type StreamChunk = | { type: 'turn_id'; turnId: number } | { type: 'text'; text: string; messageId?: number } | { type: 'message'; id: number; content: string; partial: false } - | { type: 'approval_request'; id: string; tool: string; args: Record } + | { + type: 'approval_request'; + id: string; + tool: string; + args: Record; + } + | { type: 'plan_ready'; sessionId: string; title: string } | { type: 'tool_start'; id: string; name: string; args: Record } | { type: 'tool_result'; id: string; name: string; output: string; ok: boolean } | { type: 'tool_denied'; id: string; name: string; reason: string } @@ -65,15 +71,15 @@ export interface AgentClient { getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; resetSubagentEnabled(body: { cwd: string }): Promise; - getMcpStatus(): Promise; - createMcpServer(server: McpServerConfig): Promise; - updateMcpServer(name: string, server: McpServerConfig): Promise; - deleteMcpServer(name: string): Promise; + getMcpStatus(query: { cwd: string }): Promise; + createMcpServer(server: McpServerConfig, query: { cwd: string }): Promise; + updateMcpServer(name: string, server: McpServerConfig, query: { cwd: string }): Promise; + deleteMcpServer(name: string, query: { cwd: string }): Promise; setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetMcpDisabled(body: { name: string; cwd: string }): Promise; listSkills(): Promise>; toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; - listAgents(): Promise< + listAgents(query: { cwd: string }): Promise< Array<{ name: string; description: string; @@ -85,17 +91,17 @@ export interface AgentClient { disabled?: boolean; }> >; - createAgent(profile: AgentProfile): Promise; - updateAgent(name: string, profile: AgentProfile): Promise; - deleteAgent(name: string): Promise; + createAgent(profile: AgentProfile, query: { cwd: string }): Promise; + updateAgent(name: string, profile: AgentProfile, query: { cwd: string }): Promise; + deleteAgent(name: string, query: { cwd: string }): Promise; setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetAgentDisabled(body: { name: string; cwd: string }): Promise; - listHooks(): Promise; + listHooks(query: { cwd: string }): Promise; setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetHookDisabled(body: { name: string; cwd: string }): Promise; - createHook(hook: UserHookConfig): Promise; - updateHook(name: string, hook: UserHookConfig): Promise; - deleteHook(name: string): Promise; + createHook(hook: UserHookConfig, query: { cwd: string }): Promise; + updateHook(name: string, hook: UserHookConfig, query: { cwd: string }): Promise; + deleteHook(name: string, query: { cwd: string }): Promise; getPermissionMode(): Promise; setPermissionMode(mode: PermissionMode): Promise; } diff --git a/packages/codingcode/src/context/service.ts b/packages/codingcode/src/context/service.ts index c419ba04..da9e6191 100644 --- a/packages/codingcode/src/context/service.ts +++ b/packages/codingcode/src/context/service.ts @@ -1,12 +1,11 @@ import { Effect } from 'effect'; import { randomUUID } from 'crypto'; -import { join } from 'path'; import { readFileSync, existsSync } from 'fs'; import { loadConfig } from '@codingcode/infra/config'; import type { Message } from '../core/types.js'; import { SessionService } from '../session/store.js'; import { estimateTokens, estimateMessageTokens } from '../core/util.js'; -import { projectSessionsDir, appendLine, readHistory } from '../session/file-ops.js'; +import { appendLine, readHistory } from '../session/file-ops.js'; import { resolveLLM } from '../llm/llm-resolver.js'; import { LLMFactoryService } from '../llm/factory.js'; import { COMPACTION_SYSTEM_PROMPT } from './compaction-prompt.js'; @@ -191,16 +190,12 @@ export class ContextService extends Effect.Service()('Context', effect: Effect.gen(function* () { const session = yield* SessionService; const factory = yield* LLMFactoryService; - const assemblePayload = ( - sessionId: string, - encodedProjectPath: string, - contextWindow: number - ): BuildResult => { - const jsonlPath = join(projectSessionsDir(encodedProjectPath), `${sessionId}.jsonl`); + const assemblePayload = (transcriptPath: string, contextWindow: number): BuildResult => { + const jsonlPath = transcriptPath; let events = session.readHistoryFile(jsonlPath); let currentTurnId = 0; - const idxPath = join(projectSessionsDir(encodedProjectPath), `${sessionId}.index.json`); + const idxPath = transcriptPath.replace('.jsonl', '.index.json'); if (existsSync(idxPath)) { try { const idx = JSON.parse(readFileSync(idxPath, 'utf8')); @@ -280,8 +275,7 @@ export class ContextService extends Effect.Service()('Context', } const compactIfNeeded = async ( - sessionId: string, - encodedProjectPath: string, + transcriptPath: string, messages: Message[], modelMaxTokens: number, llm: LLMClient | null @@ -292,20 +286,13 @@ export class ContextService extends Effect.Service()('Context', return { didCompress: false, released: 0, promptEstimate }; } - const result = await compactWithLLM( - sessionId, - encodedProjectPath, - modelMaxTokens, - llm, - promptEstimate - ); + const result = await compactWithLLM(transcriptPath, modelMaxTokens, llm, promptEstimate); return result; }; const compactWithLLM = async ( - sessionId: string, - encodedProjectPath: string, + transcriptPath: string, modelMaxTokens: number, llm: LLMClient | null, usage?: number @@ -316,11 +303,10 @@ export class ContextService extends Effect.Service()('Context', const threshold = modelMaxTokens * COMPACTION_THRESHOLD; if (usage === undefined || usage - released > threshold) { const { compactedEvents, currentTurnId, compactedTurnIds, promptEstimate } = - assemblePayload(sessionId, encodedProjectPath, modelMaxTokens); + assemblePayload(transcriptPath, modelMaxTokens); preEstimate = promptEstimate; released += await tryCompaction( - sessionId, - encodedProjectPath, + transcriptPath, llm, compactedEvents, currentTurnId, @@ -336,7 +322,7 @@ export class ContextService extends Effect.Service()('Context', }; } - const postPayload = assemblePayload(sessionId, encodedProjectPath, modelMaxTokens); + const postPayload = assemblePayload(transcriptPath, modelMaxTokens); return { didCompress: true, released, @@ -346,8 +332,7 @@ export class ContextService extends Effect.Service()('Context', }; async function tryCompaction( - sessionId: string, - encodedProjectPath: string, + transcriptPath: string, llm: LLMClient | null, compactedEvents: SessionEvent[], currentTurnId: number, @@ -387,8 +372,14 @@ export class ContextService extends Effect.Service()('Context', const startTurnId = Math.min(...turnIds); const endTurnId = Math.max(...turnIds); - const state = await Effect.runPromise(session.load(encodedProjectPath, sessionId)); - await Effect.runPromise(session.appendSummary(state, summary, startTurnId, endTurnId)); + const summaryEvent: SummaryEvent = { + type: 'summary', + uuid: randomUUID(), + startTurnId, + endTurnId, + summaryText: summary, + }; + appendLine(transcriptPath, summaryEvent); const summaryMsg: Message = { role: 'system', name: 'compacted_history', content: summary }; return Math.max(0, totalTokens - estimateMessageTokens(summaryMsg)); diff --git a/packages/codingcode/src/core/path.ts b/packages/codingcode/src/core/path.ts index 3bed20c2..75b3db5b 100644 --- a/packages/codingcode/src/core/path.ts +++ b/packages/codingcode/src/core/path.ts @@ -1,8 +1,6 @@ -/** Normalize a path to always produce the same encoded form for the same directory: - * - Convert POSIX /c/... → c:/... (Git Bash paths on Windows) - * - Convert backslashes to forward slashes - * - Lowercase drive letter - * Does NOT call path.resolve() since it mishandles /c/... on Windows. */ +import { homedir } from 'os'; +import { join } from 'path'; + export function normalizePath(p: string): string { let s = p.replaceAll('\\', '/'); s = s.replace(/^\/([a-zA-Z])\//, (_, letter: string) => `${letter.toLowerCase()}:/`); @@ -10,8 +8,6 @@ export function normalizePath(p: string): string { return s; } -/** Encode a project path as a human-readable, filesystem-safe directory name. - * Colons, slashes, and spaces are collapsed into single dashes. */ export function encodeProjectPath(p: string): string { const normalized = normalizePath(p); return normalized @@ -19,3 +15,23 @@ export function encodeProjectPath(p: string): string { .replace(/^-+|-+$/g, '') .toLowerCase(); } + + +let _projectBaseOverride: string | undefined; +let _projectPlansBaseOverride: string | undefined; + +export function setProjectBaseDir(dir: string | undefined): void { + _projectBaseOverride = dir; +} + +export function setProjectPlansBaseDir(dir: string | undefined): void { + _projectPlansBaseOverride = dir; +} + +export function getProjectBaseDir(): string { + return _projectBaseOverride ?? join(homedir(), '.codingcode', 'project'); +} + +export function getProjectPlansBaseDir(): string { + return _projectPlansBaseOverride ?? join(homedir(), '.codingcode', 'projects'); +} diff --git a/packages/codingcode/src/core/workspace.ts b/packages/codingcode/src/core/workspace.ts index bbd27226..f66d3cd9 100644 --- a/packages/codingcode/src/core/workspace.ts +++ b/packages/codingcode/src/core/workspace.ts @@ -32,6 +32,11 @@ export function parseWorkspaceArgs(argv: string[]): { workspaceCwd?: string; arg return { workspaceCwd, args }; } +/** Returns true when the given cwd refers to the global (home) config rather than a project. */ +export function isGlobalCwd(cwd: string | undefined): boolean { + return !cwd || cwd === '' || cwd === 'global'; +} + export class WorkspaceService extends Effect.Service()('Workspace', { sync: () => { let processRoot = process.cwd(); diff --git a/packages/codingcode/src/hooks/registry.ts b/packages/codingcode/src/hooks/registry.ts index 534a2d7a..3e63e123 100644 --- a/packages/codingcode/src/hooks/registry.ts +++ b/packages/codingcode/src/hooks/registry.ts @@ -130,19 +130,30 @@ export class HookService extends Effect.Service()('HookService', { emit: (point: HookPoint, payload: Record): Effect.Effect => { const projectPath = payload.projectPath as string | undefined; const sessionId = payload.sessionId as string | undefined; - return Effect.promise(async () => { + return Effect.gen(function* () { for (const entry of allHandlers(point, projectPath, sessionId)) { if (entry.type === 'observer') { const name = entry.id; if (isHookDisabled(name, projectPath, sessionId)) continue; - try { - await (entry.handler as ObserverHandler)(payload); - } catch (e) { - logger.error(`hook emit error [${point}]:`, e); + const result = entry.handler(payload); + if (result == null) { + continue; + } + if (typeof (result as { pipe?: unknown }).pipe === 'function') { + yield* (result as Effect.Effect).pipe( + Effect.catchAll((e) => + Effect.sync(() => logger.error(`hook emit error [${point}]:`, e)) + ) + ) as Effect.Effect; + } else { + yield* Effect.tryPromise({ + try: () => result as Promise, + catch: (e) => logger.error(`hook emit error [${point}]:`, e), + }).pipe(Effect.ignore); } } } - }); + }) as Effect.Effect; }, emitDecision: ( @@ -181,18 +192,26 @@ export class HookService extends Effect.Service()('HookService', { for (const hc of resolveHookConfigs(projectPath)) { if (resolveHookDisabled(projectPath, hc.name)) continue; const hookName = hc.name; + const observerHandler: ObserverHandler = (payload) => { + if (!isHookRuntimeEnabled(hookName)) return; + return Effect.tryPromise({ + try: () => executeHookCommand(hc, payload), + catch: (e) => logger.error(`user hook ${hookName} error:`, e), + }).pipe(Effect.ignore); + }; + const decisionHandler: DecisionHandler = (payload) => { + if (!isHookRuntimeEnabled(hookName)) return null; + return Effect.tryPromise({ + try: () => executeDecisionHookCommand(hc, payload), + catch: (e) => { + logger.error(`user decision hook ${hookName} error:`, e); + return null; + }, + }) as unknown as Promise; + }; const entry: HandlerEntry = { id: `${hc.type === 'observer' ? 'obs' : 'dec'}-${++entryCounter}`, - handler: - hc.type === 'observer' - ? (payload: Record) => { - if (!isHookRuntimeEnabled(hookName)) return; - return executeHookCommand(hc, payload); - } - : (payload: Record) => { - if (!isHookRuntimeEnabled(hookName)) return null; - return executeDecisionHookCommand(hc, payload); - }, + handler: hc.type === 'observer' ? observerHandler : decisionHandler, priority: hc.priority ?? 0, source: 'user', type: hc.type, @@ -218,17 +237,27 @@ export class HookService extends Effect.Service()('HookService', { Effect.sync(() => { const sessionMap = new Map(); for (const hc of hooks) { + const observerHandler: ObserverHandler = (payload) => + Effect.tryPromise({ + try: () => + executeHookCommand({ command: hc.command, args: hc.args, env: {} }, payload), + catch: (e) => logger.error(`session hook ${hc.name} error:`, e), + }).pipe(Effect.ignore); + const decisionHandler: DecisionHandler = (payload) => + Effect.tryPromise({ + try: () => + executeDecisionHookCommand( + { command: hc.command, args: hc.args, env: {} }, + payload + ), + catch: (e) => { + logger.error(`session decision hook ${hc.name} error:`, e); + return null; + }, + }) as unknown as Promise; const entry: HandlerEntry = { id: `session-${hc.name}-${++entryCounter}`, - handler: - hc.type === 'observer' - ? (payload: Record) => - executeHookCommand({ command: hc.command, args: hc.args, env: {} }, payload) - : (payload: Record) => - executeDecisionHookCommand( - { command: hc.command, args: hc.args, env: {} }, - payload - ), + handler: hc.type === 'observer' ? observerHandler : decisionHandler, priority: hc.priority ?? 0, source: 'user', type: hc.type, diff --git a/packages/codingcode/src/hooks/types.ts b/packages/codingcode/src/hooks/types.ts index 1ca4aed4..34193d77 100644 --- a/packages/codingcode/src/hooks/types.ts +++ b/packages/codingcode/src/hooks/types.ts @@ -1,3 +1,5 @@ +import type { Effect } from 'effect'; + export type HookPoint = | 'tool.execute.before' | 'tool.execute.after' @@ -16,7 +18,8 @@ export type HookPoint = | 'agent.turn.end' | 'agent.subagent.spawn.before' | 'agent.subagent.spawn.after' - | 'agent.subagent.complete'; + | 'agent.subagent.complete' + | 'plan.ready'; export interface HookDecision { decision?: 'allow' | 'deny' | 'ask' | 'continue'; @@ -26,7 +29,9 @@ export interface HookDecision { modifiedOutput?: unknown; } -export type ObserverHandler = (payload: Record) => void | Promise; +export type ObserverHandler = ( + payload: Record +) => Effect.Effect | void | Promise; export type DecisionHandler = ( payload: Record diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts index 4bce3fce..0e389aa6 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -1,4 +1,4 @@ -import { Layer, Effect, ManagedRuntime } from 'effect'; +import { Context, Layer, Effect, ManagedRuntime } from 'effect'; import { AgentService } from './agent/agent.js'; import { SessionService } from './session/store.js'; import { HookService } from './hooks/registry.js'; @@ -19,6 +19,7 @@ import { RulesService } from './rules/index.js'; import { MemoryService } from './memory/index.js'; import { ContextService } from './context/service.js'; import { SchedulerService } from './scheduler/service.js'; +import { planModeGateHook } from './plan/index.js'; export const WorkspaceLayer = WorkspaceService.Default; export const TodoLayer = TodoService.Default; @@ -38,12 +39,24 @@ export const ApprovalWaitLayer = ApprovalWaitService.Default; export const McpLayer = McpService.Default; export const SchedulerLayer = SchedulerService.Default; export const ProjectRuntimeLayer = ProjectRuntimeService.Default.pipe( - Layer.provide(Layer.mergeAll(HookLayer, McpLayer, SubagentLayer, RulesLayer)) + Layer.provide(Layer.mergeAll(HookLayer, McpLayer, SubagentLayer, RulesLayer, SessionLayer)) ); export const ApprovalLayer = ApprovalService.Default.pipe( Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer)) ); +export const SystemHookLayer = HookLayer.pipe( + Layer.tap((context) => + Effect.gen(function* () { + const hooks = Context.get(context, HookService); + yield* hooks.registerDecision('tool.approval.pre', planModeGateHook, { + priority: -1000, + source: 'system', + }); + }) + ) +); + /** ToolExecutor depends on HookLayer + ApprovalLayer. */ const ExecutorDeps = Layer.mergeAll(HookLayer, ApprovalLayer); const ExecutorLayer = ToolExecutorService.Default.pipe(Layer.provide(ExecutorDeps)); @@ -97,11 +110,12 @@ export const AppLayer = Layer.mergeAll( RulesLayer, MemoryLayer, ContextLayer, - SchedulerLayer + SchedulerLayer, + SystemHookLayer ); /** Create the application ManagedRuntime from AppLayer. */ -export const createAppRuntime = () => ManagedRuntime.make(AppLayer); +export const createAppRuntime = () => ManagedRuntime.make(AppLayer as any); /** Concrete runtime type for the application. */ export type AppRuntime = ManagedRuntime.ManagedRuntime; diff --git a/packages/codingcode/src/plan/index.ts b/packages/codingcode/src/plan/index.ts new file mode 100644 index 00000000..e640190a --- /dev/null +++ b/packages/codingcode/src/plan/index.ts @@ -0,0 +1,65 @@ +import type { DecisionHandler } from '../hooks/types.js'; + +// ---- Profile name constants + structural helper ---- + +export const PLAN_PROFILE_NAME = 'plan' as const; +export const BUILD_PROFILE_NAME = 'build' as const; + +export function isPlanProfile(p: { name: string } | null | undefined): boolean { + return p?.name === PLAN_PROFILE_NAME; +} + +export const PLAN_MODE_ALLOWED_TOOLS: ReadonlySet = new Set([ + 'submit_plan', + 'dispatch_agent', +]); + +// ---- Plan-mode side channel ---- + +const planModeSessions = new Set(); + +export function markSessionPlanMode(sessionId: string, isPlanMode: boolean): void { + if (isPlanMode) planModeSessions.add(sessionId); + else planModeSessions.delete(sessionId); +} + +export function isSessionInPlanMode(sessionId: string): boolean { + return planModeSessions.has(sessionId); +} + +export function clearPlanModeSession(sessionId: string): void { + planModeSessions.delete(sessionId); +} + +// ---- Plan-mode subagent whitelist (called inline by dispatch_agent) ---- + +export function checkSubagentAllowedInPlanMode( + parentSessionId: string | undefined, + parentMainProfile: string | undefined, + profile: string | undefined +): { allowed: true } | { allowed: false; reason: string } { + if (!parentSessionId) return { allowed: true }; + if (parentMainProfile !== PLAN_PROFILE_NAME) return { allowed: true }; + if (!profile) return { allowed: true }; + if (profile === 'explore') return { allowed: true }; + return { + allowed: false, + reason: `Plan mode can only dispatch the 'explore' subagent. Got: '${profile}'`, + }; +} + +export const planModeGateHook: DecisionHandler = (payload) => { + const sessionId = payload.sessionId as string | undefined; + if (!sessionId) return null; + if (!isSessionInPlanMode(sessionId)) return null; + + const toolName = payload.toolName as string | undefined; + if (!toolName) return null; + if (PLAN_MODE_ALLOWED_TOOLS.has(toolName)) return null; + + return { + decision: 'deny', + reason: 'Write operations denied in plan mode. Use submit_plan to submit a plan.', + }; +}; + diff --git a/packages/codingcode/src/runtime/project-runtime.ts b/packages/codingcode/src/runtime/project-runtime.ts index e569544f..7b619ad9 100644 --- a/packages/codingcode/src/runtime/project-runtime.ts +++ b/packages/codingcode/src/runtime/project-runtime.ts @@ -1,16 +1,32 @@ import { Effect } from 'effect'; import type { AgentProfile } from '../subagent/types.js'; -import { EXPLORE_PROFILE, PLAN_PROFILE, SubagentService } from '../subagent/registry.js'; +import { + EXPLORE_PROFILE, + PLAN_PROFILE, + BUILD_PROFILE, + SubagentService, +} from '../subagent/registry.js'; import * as agentLoader from '../subagent/loader.js'; import type { ToolVisibilityPolicy } from '../tools/types.js'; import { HookService } from '../hooks/registry.js'; import { McpService } from '../mcp/index.js'; import { RulesService } from '../rules/index.js'; +import { SessionService } from '../session/store.js'; import { normalizePath } from '../core/path.js'; +import { ApprovalService } from '../approval/index.js'; +import type { PermissionMode } from '../approval/types.js'; +import type { SessionMode } from '../session/types.js'; +import { computePaths, readCurrentIndex, setPermissionMode } from '../session/file-ops.js'; +import { writeFileSync } from 'fs'; +import { + isPlanProfile, + markSessionPlanMode, + clearPlanModeSession, +} from '../plan/index.js'; /** 构建全局 profile:内置 + ~/.codingcode/agents/ */ function buildGlobalProfiles(): AgentProfile[] { - const profiles: AgentProfile[] = [EXPLORE_PROFILE, PLAN_PROFILE]; + const profiles: AgentProfile[] = [BUILD_PROFILE, EXPLORE_PROFILE, PLAN_PROFILE]; for (const p of agentLoader.loadGlobalAgentProfiles()) { if (!profiles.find((existing) => existing.name === p.name)) { profiles.push(p); @@ -24,6 +40,10 @@ function buildProjectProfiles(projectPath: string): AgentProfile[] { return agentLoader.loadAgentProfiles(projectPath); } +export function modeToProfile(mode: SessionMode): AgentProfile { + return mode === 'plan' ? PLAN_PROFILE : BUILD_PROFILE; +} + export class ProjectRuntimeService extends Effect.Service()( 'ProjectRuntime', { @@ -32,7 +52,9 @@ export class ProjectRuntimeService extends Effect.Service const mcp = yield* McpService; const subagent = yield* SubagentService; const rules = yield* RulesService; + const session = yield* SessionService; const sessionAgentProfiles = new Map(); + const sessionPermissionModes = new Map(); const prepared = new Set(); // 启动时注册全局 profile(内置 + ~/.codingcode/agents/),只做一次 @@ -84,16 +106,79 @@ export class ProjectRuntimeService extends Effect.Service allowDeferredTools: false, }), - setSessionProfile: (sessionId: string, profile: AgentProfile): void => { - sessionAgentProfiles.set(sessionId, profile); - }, + setSessionProfile: ( + projectPath: string, + sessionId: string, + profile: AgentProfile, + permissionModeOverride?: PermissionMode, + parentSessionId?: string + ): Effect.Effect => + Effect.gen(function* () { + sessionAgentProfiles.set(sessionId, profile); + markSessionPlanMode(sessionId, isPlanProfile(profile)); + + if (isPlanProfile(profile)) { + // Plan 模式:内存 map 强制 'default',SessionIndex.permissionMode 不写盘(保留 build 偏好) + sessionPermissionModes.set(sessionId, 'default'); + return; + } + + const effectivePermissionMode: PermissionMode = + permissionModeOverride ?? profile.permissionMode ?? 'default'; + sessionPermissionModes.set(sessionId, effectivePermissionMode); + const paths = computePaths(projectPath, sessionId, parentSessionId); + setPermissionMode(sessionId, paths.indexPath, effectivePermissionMode); + // Update activeProfile in the same index file. + const current = readCurrentIndex(paths.indexPath); + if (current) { + const index = { + ...current, + activeProfile: profile.name, + updatedAt: new Date().toISOString(), + }; + writeFileSync(paths.indexPath, JSON.stringify(index, null, 2), 'utf8'); + } + }), getSessionProfile: (sessionId: string): AgentProfile | undefined => sessionAgentProfiles.get(sessionId), + getSessionPermissionMode: (sessionId: string): PermissionMode => + sessionPermissionModes.get(sessionId) ?? 'default', + + restoreSessionProfile: ( + projectPath: string, + sessionId: string, + profileName: string | undefined, + permissionModeOverride?: PermissionMode, + parentSessionId?: string + ): Effect.Effect => + Effect.gen(function* () { + if (!profileName) return; + const norm = normalizePath(projectPath); + const profile = subagent.get(norm, profileName); + if (!profile) return; + sessionAgentProfiles.set(sessionId, profile); + markSessionPlanMode(sessionId, isPlanProfile(profile)); + + if (isPlanProfile(profile)) { + sessionPermissionModes.set(sessionId, 'default'); + return; + } + + const effectivePermissionMode: PermissionMode = + permissionModeOverride ?? profile.permissionMode ?? 'default'; + sessionPermissionModes.set(sessionId, effectivePermissionMode); + // Direct write — see setSessionProfile above. + const paths = computePaths(projectPath, sessionId, parentSessionId); + setPermissionMode(sessionId, paths.indexPath, effectivePermissionMode); + }), + disposeSession: (sessionId: string): Effect.Effect => Effect.sync(() => { sessionAgentProfiles.delete(sessionId); + sessionPermissionModes.delete(sessionId); + clearPlanModeSession(sessionId); }), disposeProject: (projectPath: string): Effect.Effect => diff --git a/packages/codingcode/src/scheduler/service.ts b/packages/codingcode/src/scheduler/service.ts index af40d739..8659ef13 100644 --- a/packages/codingcode/src/scheduler/service.ts +++ b/packages/codingcode/src/scheduler/service.ts @@ -53,6 +53,9 @@ export class SchedulerService extends Effect.Service()('Schedu sendMessage(undefined, auto.description, auto.projectCwd, llm, { signal: controller.signal, approvalOverride: { permissionMode: 'bypass' }, + mode: 'build', + permissionMode: 'bypass', + model: llm.modelInfo.model, }) ); @@ -182,6 +185,9 @@ export class SchedulerService extends Effect.Service()('Schedu sendMessage(undefined, auto.description, auto.projectCwd, llm, { signal: controller.signal, approvalOverride: { permissionMode: 'bypass' }, + mode: 'build', + permissionMode: 'bypass', + model: llm.modelInfo.model, }) ); diff --git a/packages/codingcode/src/server/adapter.ts b/packages/codingcode/src/server/adapter.ts index 33870962..92f0c1d9 100644 --- a/packages/codingcode/src/server/adapter.ts +++ b/packages/codingcode/src/server/adapter.ts @@ -9,8 +9,6 @@ export function agentEventToSseEvent(event: AgentEvent): SseEvent | null { return { type: 'turn_id', turnId: event.turnId }; case 'ToolStart': return { type: 'tool_start', id: event.id, name: event.name, args: event.args }; - case 'ApprovalRequest': - return { type: 'approval_request', id: event.id, tool: event.tool, args: event.args }; case 'ToolResult': return { type: 'tool_result', diff --git a/packages/codingcode/src/server/handler.ts b/packages/codingcode/src/server/handler.ts index 3f503fa8..4eb5054a 100644 --- a/packages/codingcode/src/server/handler.ts +++ b/packages/codingcode/src/server/handler.ts @@ -1,6 +1,7 @@ import type { Context } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { ApprovalWaitService } from '../approval/async-confirm.js'; +import { HookService } from '../hooks/registry.js'; import type { SseEvent } from './types.js'; import { AgentError } from '../core/error.js'; @@ -11,7 +12,7 @@ export function createSseHandler(rt: ManagedRt) { createGenerator: () => AsyncGenerator, opts?: { initialEvents?: SseEvent[]; sessionId?: string; onDone?: () => void } ): (c: Context) => Promise { - return async (c: Context) => { + return async (c) => { const sessionId = opts?.sessionId ?? c.req.param('id') ?? 'default'; const stream = new ReadableStream({ async start(controller) { @@ -24,6 +25,11 @@ export function createSseHandler(rt: ManagedRt) { return yield* ApprovalWaitService; }) ); + const hookService = await rt.runPromise( + Effect.gen(function* () { + return yield* HookService; + }) + ); Effect.runSync( waitService.registerEmitter( sessionId, @@ -33,6 +39,21 @@ export function createSseHandler(rt: ManagedRt) { ) ); + const unregisterPlanReady = Effect.runSync( + hookService.register('plan.ready', (payload) => { + const p = payload as { + sessionId?: string; + title?: string; + }; + if (p.sessionId !== sessionId) return; + enqueue({ + type: 'plan_ready', + sessionId: p.sessionId, + title: p.title ?? '', + }); + }) + ); + try { if (opts?.initialEvents) { for (const ev of opts.initialEvents) enqueue(ev); @@ -52,6 +73,7 @@ export function createSseHandler(rt: ManagedRt) { ...(e instanceof AgentError ? { code: e.code } : {}), }); } finally { + unregisterPlanReady(); Effect.runSync(waitService.unregisterEmitter(sessionId)); opts?.onDone?.(); } diff --git a/packages/codingcode/src/server/routes/agent.ts b/packages/codingcode/src/server/routes/agent.ts index b728e9a7..97f2e722 100644 --- a/packages/codingcode/src/server/routes/agent.ts +++ b/packages/codingcode/src/server/routes/agent.ts @@ -1,6 +1,8 @@ import { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { ApprovalService } from '../../approval/index.js'; +import { ProjectRuntimeService } from '../../runtime/project-runtime.js'; +import { isPlanProfile } from '../../plan/index.js'; import type { PermissionMode } from '../../approval/types.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -8,7 +10,6 @@ type ManagedRt = ManagedRuntime.ManagedRuntime; const VALID_PERMISSION_MODES = new Set([ 'default', 'acceptEdits', - 'plan', 'bypass', ]); @@ -25,10 +26,28 @@ export function createAgentRouter(rt: ManagedRt): Hono { }); router.post('/permission-mode', async (c) => { - const body = (await c.req.json()) as { mode: string }; + const body = (await c.req.json()) as { mode: string; cwd?: string; sessionId?: string }; if (!VALID_PERMISSION_MODES.has(body.mode as PermissionMode)) { return c.json({ error: `Invalid mode: ${body.mode}` }, 400); } + if (body.cwd && body.sessionId) { + const result = await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const profile = runtime.getSessionProfile(body.sessionId!); + return profile; + }) + ); + if (isPlanProfile(result)) { + return c.json( + { + error: + 'Permission mode is fixed in plan mode. Use /mode to switch to build mode first.', + }, + 409 + ); + } + } const approval: any = await rt.runPromise( Effect.gen(function* () { return yield* ApprovalService; diff --git a/packages/codingcode/src/server/routes/approval.ts b/packages/codingcode/src/server/routes/approval.ts index 8cff9256..324f1c38 100644 --- a/packages/codingcode/src/server/routes/approval.ts +++ b/packages/codingcode/src/server/routes/approval.ts @@ -12,7 +12,10 @@ export function createApprovalRouter(rt: ManagedRt): Hono { router.post('/sessions/:sessionId/approval/:id', async (c) => { const id = c.req.param('id'); const sessionId = c.req.param('sessionId'); - const { response } = await c.req.json<{ response: string }>(); + const body = (await c.req.json().catch(() => ({}))) as { + response?: string; + }; + const response = typeof body.response === 'string' ? body.response : ''; const result = await rt.runPromise( Effect.gen(function* () { diff --git a/packages/codingcode/src/server/routes/messages.ts b/packages/codingcode/src/server/routes/messages.ts index 9b4a7875..c649f313 100644 --- a/packages/codingcode/src/server/routes/messages.ts +++ b/packages/codingcode/src/server/routes/messages.ts @@ -68,7 +68,13 @@ export function createMessagesRouter(rt: ManagedRt): Hono { input, normalizedCwd, llm, - { signal: c.req.raw.signal, approvalOverride } + { + signal: c.req.raw.signal, + approvalOverride, + mode: 'build', + permissionMode: 'default', + model: llm.modelInfo.model, + } ); const result = await rt.runPromise( diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 2da7902f..1b42e5a6 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -1,22 +1,27 @@ import { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; -import { existsSync } from 'fs'; -import type { SessionStoreState } from '../../session/types.js'; +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; +import type { SessionStoreState, SessionMode } from '../../session/types.js'; import { SessionService } from '../../session/store.js'; import { sessionJsonlPathFromCwd, getPermissionMode, setPermissionMode, - readHistory, deleteSession, } from '../../session/file-ops.js'; -import type { SessionEvent, SummaryEvent, CompactEvent } from '../../session/types.js'; +import { readUIHistory, findUserMessageForTurn } from '../../session/ui-history.js'; import { ContextService, estimatePromptTokens } from '../../context/service.js'; import { CheckpointService } from '../../checkpoint/checkpoint-service.js'; import { WorkspaceService } from '../../core/workspace.js'; import { LLMFactoryService } from '../../llm/factory.js'; import type { LLMClient } from '../../llm/client.js'; import { errorResponse } from '../util.js'; +import { encodeProjectPath, getProjectBaseDir } from '../../core/path.js'; +import { ProjectRuntimeService, modeToProfile } from '../../runtime/project-runtime.js'; +import { BUILD_PROFILE, PLAN_PROFILE } from '../../subagent/registry.js'; +import { isPermissionMode, type PermissionMode } from '../../approval/types.js'; +import { isPlanProfile } from '../../plan/index.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -25,145 +30,6 @@ export const activeApprovalForks = new Map< { setPermissionMode: (mode: any) => Promise | void } >(); -// --- UI history functions (moved from messages.ts) --- - -function filterForUI(events: SessionEvent[]): SessionEvent[] { - const rollbackHiddenTurnIds = new Set(); - const rollbackHiddenOpUuids = new Set(); - - for (const ev of events) { - if (ev.type !== 'rollback') continue; - for (const prior of events) { - if (prior === ev) break; - if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { - rollbackHiddenTurnIds.add(prior.turnId); - } - if (prior.type === 'summary' || prior.type === 'compact') { - if ((prior as SummaryEvent | CompactEvent).endTurnId >= ev.throughTurnId) { - rollbackHiddenOpUuids.add((prior as SummaryEvent | CompactEvent).uuid); - } - } - } - } - - return events.filter((ev) => { - if (ev.type === 'rollback') return false; - if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; - if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as CompactEvent).uuid)) return false; - if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; - return true; - }) as SessionEvent[]; -} - -function createTurnScopedIdGenerator() { - const counters = new Map(); - return (prefix: string, turnId: number): string => { - const key = `${prefix}:${turnId}`; - const next = (counters.get(key) ?? 0) + 1; - counters.set(key, next); - return `${prefix}-${turnId}-${next}`; - }; -} - -function sessionEventsToTurns( - events: SessionEvent[] -): Array<{ id: string; items: object[]; status: string }> { - const turnsMap = new Map(); - const nextId = createTurnScopedIdGenerator(); - - for (const event of events) { - if (event.type === 'session_meta') continue; - if (event.type === 'compact' || event.type === 'rollback') continue; - - if (event.type === 'summary') { - let turn = turnsMap.get(event.endTurnId); - if (!turn) { - turn = { id: String(event.endTurnId), items: [], status: 'completed' }; - turnsMap.set(event.endTurnId, turn); - } - turn.items.push({ - id: `summary-${event.uuid}`, - type: 'summary', - content: event.summaryText, - startTurnId: event.startTurnId, - endTurnId: event.endTurnId, - }); - continue; - } - - let turn = turnsMap.get(event.turnId); - if (!turn) { - turn = { id: String(event.turnId), items: [], status: 'completed' }; - turnsMap.set(event.turnId, turn); - } - switch (event.type) { - case 'user': - turn.items.push({ - id: nextId('user', event.turnId), - type: 'message', - role: 'user', - content: event.content, - }); - break; - case 'assistant': - if (event.content) { - turn.items.push({ - id: nextId('assistant', event.turnId), - type: 'message', - role: 'assistant', - content: event.content, - }); - } - for (const tc of event.toolCalls ?? []) { - const args = tc.arguments ?? {}; - turn.items.push({ - id: tc.id, - type: 'tool_call', - name: tc.name, - args, - status: 'approved', - }); - } - break; - case 'tool_result': { - const item: Record = { - id: `result-${event.toolCallId}`, - type: 'tool_result', - callId: event.toolCallId, - name: event.toolName, - output: event.output, - }; - turn.items.push(item); - break; - } - } - } - return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); -} - -function readUIHistory( - sessionId: string, - cwd: string -): Array<{ id: string; items: object[]; status: string }> { - const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); - if (!existsSync(jsonlPath)) return []; - const events = readHistory(jsonlPath); - const visibleEvents = filterForUI(events); - return sessionEventsToTurns(visibleEvents); -} - -function findUserMessageForTurn(sessionId: string, turnId: number, cwd: string): string { - const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); - if (!existsSync(jsonlPath)) return ''; - const rawEvents = readHistory(jsonlPath); - for (const ev of rawEvents) { - if (ev.type === 'user' && (ev as any).turnId === turnId) { - return (ev as any).content ?? ''; - } - } - return ''; -} - export function createSessionsRouter(rt: ManagedRt): Hono { const router = new Hono(); const runWithLayer = async (eff: Effect.Effect) => { @@ -201,7 +67,24 @@ export function createSessionsRouter(rt: ManagedRt): Hono { }); router.post('/', async (c) => { - const body = (await c.req.json()) as { cwd: string; initialPermissionMode?: string }; + const body = (await c.req.json()) as { + cwd: string; + mode: SessionMode; + permissionMode: PermissionMode; + model: string; + }; + if (body.mode !== 'plan' && body.mode !== 'build') { + return c.json({ error: `Invalid mode: ${body.mode}` }, 400); + } + if (!isPermissionMode(body.permissionMode)) { + return c.json({ error: `Invalid permissionMode: ${body.permissionMode}` }, 400); + } + if (body.mode === 'plan' && body.permissionMode !== 'default') { + return c.json({ error: 'Plan mode requires permissionMode "default"' }, 400); + } + if (!body.model) { + return c.json({ error: 'model required' }, 400); + } const normalizedCwd = await rt.runPromise( Effect.gen(function* () { const ws = yield* WorkspaceService; @@ -211,18 +94,27 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; - return yield* session.create(normalizedCwd, 'unknown'); + const runtime = yield* ProjectRuntimeService; + const state = yield* session.create(normalizedCwd, { + model: body.model, + mode: body.mode, + permissionMode: body.permissionMode, + }); + const profile = modeToProfile(body.mode); + yield* runtime.setSessionProfile( + normalizedCwd, + state.sessionId, + profile, + body.permissionMode + ); + return state; }) as any ); if (!result.ok) { const { status, body: resp } = errorResponse(result.error); return c.json(resp, status as any); } - const state = result.value as SessionStoreState; - if (body.initialPermissionMode) { - setPermissionMode(state.sessionId, state.indexPath, body.initialPermissionMode); - } - return c.json({ sessionId: state.sessionId }); + return c.json({ sessionId: (result.value as SessionStoreState).sessionId }); }); router.post('/:id/resume', async (c) => { @@ -274,7 +166,7 @@ export function createSessionsRouter(rt: ManagedRt): Hono { const maxTokens = llm?.modelInfo.maxTokens ?? 128000; return yield* Effect.promise(() => - context.compactWithLLM(state.sessionId, state.projectPath, maxTokens, llm) + context.compactWithLLM(state.transcriptPath, maxTokens, llm) ); }) ); @@ -301,6 +193,135 @@ export function createSessionsRouter(rt: ManagedRt): Hono { return c.json(turns); }); + // ---- Plan file: read the current plan document for a session ---- + // submit_plan writes a .md file per submission, so the + // "current" plan is whichever .md has the most recent mtime in the + // project's plan directory. + router.get('/:id/plan', async (c) => { + const cwd = await rt.runPromise( + Effect.gen(function* () { + const ws = yield* WorkspaceService; + return ws.resolveWorkspaceCwd(c.req.query('cwd')); + }) + ); + const planDir = join(getProjectBaseDir(), encodeProjectPath(cwd)); + if (!existsSync(planDir)) { + return c.json({ + content: '', + path: '', + directory: planDir, + exists: false, + }); + } + let latest: { path: string; mtime: number } | null = null; + for (const name of readdirSync(planDir)) { + if (!name.endsWith('.md')) continue; + const full = join(planDir, name); + const mtime = statSync(full).mtimeMs; + if (latest === null || mtime > latest.mtime) { + latest = { path: full, mtime }; + } + } + if (latest === null) { + return c.json({ + content: '', + path: '', + directory: planDir, + exists: false, + }); + } + try { + const content = readFileSync(latest.path, 'utf8'); + return c.json({ + content, + path: latest.path, + directory: planDir, + exists: true, + }); + } catch (e) { + return c.json({ error: `Failed to read plan: ${String(e)}` }, 500); + } + }); + + // ---- Plan/Build mode switching ---- + router.get('/:id/mode', async (c) => { + const sessionId = c.req.param('id'); + const cwd = await rt.runPromise( + Effect.gen(function* () { + const ws = yield* WorkspaceService; + return ws.resolveWorkspaceCwd(c.req.query('cwd')); + }) + ); + const result = await runWithLayer( + Effect.gen(function* () { + const session = yield* SessionService; + const runtime = yield* ProjectRuntimeService; + const state = yield* session.load(cwd, sessionId); + return { + mode: state.mode, + permissionMode: runtime.getSessionPermissionMode(sessionId), + }; + }) + ); + if (!result.ok) { + const { status, body } = errorResponse(result.error); + return c.json(body, status as any); + } + return c.json({ + ...result.value, + cwd, + available: [ + { name: PLAN_PROFILE.name, description: PLAN_PROFILE.description }, + { name: BUILD_PROFILE.name, description: BUILD_PROFILE.description }, + ], + }); + }); + + router.post('/:id/mode', async (c) => { + const sessionId = c.req.param('id'); + const body = (await c.req.json()) as { cwd?: string; mode: SessionMode }; + const cwd = await rt.runPromise( + Effect.gen(function* () { + const ws = yield* WorkspaceService; + return ws.resolveWorkspaceCwd(body.cwd); + }) + ); + const mode = body.mode ?? 'build'; + if (mode !== 'plan' && mode !== 'build') { + return c.json({ error: `Invalid mode: ${mode}` }, 400); + } + const result = await runWithLayer( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const session = yield* SessionService; + const profile = + runtime.resolveSubagentProfile(cwd, mode) ?? modeToProfile(mode); + const state = yield* session.load(cwd, sessionId); + yield* runtime.setSessionProfile(cwd, sessionId, profile, state.permissionMode); + if (!isPlanProfile(profile)) { + yield* session.updateActiveProfile(state, profile.name); + state.mode = 'build'; + } else { + state.mode = 'plan'; + } + return { + mode: state.mode, + permissionMode: runtime.getSessionPermissionMode(sessionId), + }; + }) + ); + if (!result.ok) { + const { status, body: errBody } = errorResponse(result.error); + return c.json(errBody, status as any); + } + const handle = activeApprovalForks.get(sessionId); + if (handle) { + const permMode: PermissionMode = result.value.permissionMode; + Promise.resolve(handle.setPermissionMode(permMode)).catch(() => undefined); + } + return c.json(result.value); + }); + router.get('/:id/permission-mode', async (c) => { const sessionId = c.req.param('id'); const cwd = c.req.query('cwd'); @@ -313,10 +334,34 @@ export function createSessionsRouter(rt: ManagedRt): Hono { router.put('/:id/permission-mode', async (c) => { const sessionId = c.req.param('id'); - const { cwd, mode } = await c.req.json<{ cwd: string; mode: string }>(); + const { cwd, mode } = await c.req.json<{ cwd: string; mode: PermissionMode }>(); if (!cwd) return c.json({ error: 'cwd required' }, 400); - const idxPath = sessionJsonlPathFromCwd(cwd, sessionId).replace('.jsonl', '.index.json'); - setPermissionMode(sessionId, idxPath, mode); + if (!isPermissionMode(mode)) { + return c.json({ error: `Invalid permissionMode: ${mode}` }, 400); + } + const setResult = await runWithLayer( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + const profileName = runtime.getSessionProfile(sessionId)?.name; + if (profileName === PLAN_PROFILE.name && mode !== 'default') { + return yield* Effect.fail( + new Error('Plan mode requires permissionMode "default"') + ); + } + const profile = + profileName === PLAN_PROFILE.name + ? PLAN_PROFILE + : runtime.getSessionProfile(sessionId) ?? BUILD_PROFILE; + yield* runtime.setSessionProfile(cwd, sessionId, profile, mode); + return { ok: true }; + }) as any + ); + if (!setResult.ok) { + const { status, body: errBody } = errorResponse(setResult.error); + return c.json(errBody, status as any); + } const handle = activeApprovalForks.get(sessionId); if (handle) handle.setPermissionMode(mode); return c.json({ ok: true }); diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index 1812338b..36da8c98 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { SkillService } from '../../skills/service.js'; -import { WorkspaceService } from '../../core/workspace.js'; +import { WorkspaceService, isGlobalCwd } from '../../core/workspace.js'; import { AlreadyExistsError, NotFoundError } from '../../core/error.js'; import type { McpServerConfig } from '../../mcp/types.js'; import type { AgentProfile } from '../../subagent/types.js'; @@ -61,7 +61,7 @@ import { setProjectSkillDisabledState, discoverGlobalSkillDirs, discoverProjectSkillDirs, -} from '../../skills/config.js'; +} from '../../skills/source.js'; import { getMemoryConfig, getAllTypesWithStatus, @@ -92,12 +92,6 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { ); const resolveWorkspaceCwd = (override?: string) => ws.resolveWorkspaceCwd(override); - // ---- Helpers for global vs project ---- - - function isGlobalCwd(cwd: string | undefined): boolean { - return !cwd || cwd === '' || cwd === 'global'; - } - // ---- Helpers for CRUD with validation ---- function mcpCreateServer(cwd: string, server: McpServerConfig): void { @@ -121,8 +115,14 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { } function mcpDeleteServer(cwd: string, name: string): void { - const servers = loadMcpConfig(cwd).filter((s) => s.name !== name); - writeMcpConfig(cwd, servers); + 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 agentsList(cwd: string): Array<{ @@ -268,8 +268,14 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { } function hooksDelete(cwd: string, name: string): void { - const hooks = loadHookConfigs(cwd).filter((h) => h.name !== name); - writeHookConfigs(cwd, hooks); + 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 ---- @@ -488,7 +494,7 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { const hasProjectOverride = isFromProject && isFromGlobal; return { ...h, - source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + source: isFromProject ? 'project' : 'global', hasProjectOverride, disabled: resolveHookDisabled(cwd, h.name), }; @@ -614,7 +620,7 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { return { ...s, disabled: resolveMcpDisabled(cwd, s.name), - source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + source: isFromProject ? 'project' : 'global', hasProjectOverride, }; }) @@ -736,7 +742,7 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { const hasProjectOverride = isFromProject && isFromGlobal; return { ...s, - source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + source: isFromProject ? 'project' : 'global', hasProjectOverride, }; }) diff --git a/packages/codingcode/src/session/file-ops.ts b/packages/codingcode/src/session/file-ops.ts index 24811e9b..baf4db1a 100644 --- a/packages/codingcode/src/session/file-ops.ts +++ b/packages/codingcode/src/session/file-ops.ts @@ -13,14 +13,15 @@ import { } from 'fs'; import { homedir } from 'os'; import { join, dirname } from 'path'; -import { normalizePath, encodeProjectPath } from '../core/path.js'; +import { + normalizePath, + encodeProjectPath, + getProjectBaseDir, +} from '../core/path.js'; import type { SessionEvent, SessionMetaEvent, SessionIndex, SessionStoreState } from './types.js'; -const CODINGCODE_DIR = join(homedir(), '.codingcode'); -const PROJECT_BASE = join(CODINGCODE_DIR, 'project'); - export function projectSessionsDir(encodedProjectPath: string): string { - return join(PROJECT_BASE, encodedProjectPath, 'sessions'); + return join(getProjectBaseDir(), encodedProjectPath, 'sessions'); } export function sessionJsonlPathFromCwd(cwd: string, sessionId: string): string { @@ -45,7 +46,8 @@ export function computePaths( } export function ensureDirs(transcriptPath: string): void { - if (!existsSync(CODINGCODE_DIR)) mkdirSync(CODINGCODE_DIR, { recursive: true }); + const codingcodeDir = join(homedir(), '.codingcode'); + if (!existsSync(codingcodeDir)) mkdirSync(codingcodeDir, { recursive: true }); const dir = dirname(transcriptPath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } @@ -94,19 +96,21 @@ function buildIndexFromMeta(meta: SessionMetaEvent, history: SessionEvent[]): Se title: firstUser ? truncateTitle(firstUser) : meta.sessionId.slice(0, 8), currentTurnId: 0, usage: undefined, + mode: 'build', permissionMode: 'default', }; } export function listSessions(projectPath?: string): SessionIndex[] { const results: SessionIndex[] = []; + const projectBase = getProjectBaseDir(); const encodedDirs = projectPath ? [projectPath] - : existsSync(PROJECT_BASE) - ? readdirSync(PROJECT_BASE) + : existsSync(projectBase) + ? readdirSync(projectBase) : []; for (const encoded of encodedDirs) { - const sessionsDir = join(PROJECT_BASE, encoded, 'sessions'); + const sessionsDir = join(projectBase, encoded, 'sessions'); if (!existsSync(sessionsDir)) continue; for (const file of readdirSync(sessionsDir).filter((f) => f.endsWith('.jsonl'))) { const jsonlPath = join(sessionsDir, file); @@ -154,7 +158,7 @@ export function readCurrentIndex(indexPath: string): Partial | nul } } -export function setPermissionMode(sessionId: string, indexPath: string, mode: string): void { +export function setPermissionMode(sessionId: string, indexPath: string, mode: import('../approval/types.js').PermissionMode): void { let index: SessionIndex | null = null; if (existsSync(indexPath)) { try { diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 4043a7b6..0a288bf0 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -4,8 +4,10 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join, dirname } from 'path'; import { AgentError } from '../core/error.js'; import { encodeProjectPath } from '../core/path.js'; +import type { PermissionMode } from '../approval/types.js'; import type { SessionMetaEvent, + SessionMode, UserEvent, AssistantEvent, ToolResultEvent, @@ -52,15 +54,21 @@ export class SessionService extends Effect.Service()('Session', title: state.title, currentTurnId: state.currentTurnId, usage: state.usage, - permissionMode: current?.permissionMode ?? 'default', + mode: state.mode, + permissionMode: state.permissionMode, memorySnapshot: state.memorySnapshot, + activeProfile: current?.activeProfile, }; writeFileSync(state.indexPath, JSON.stringify(index, null, 2), 'utf8'); } const create = ( cwd: string, - model: string, + options: { + model: string; + mode: SessionMode; + permissionMode: PermissionMode; + }, opts?: { parentSessionId?: string; agentName?: string } ): Effect.Effect => Effect.try({ @@ -72,7 +80,9 @@ export class SessionService extends Effect.Service()('Session', ...paths, messageCount: 0, sessionMeta: null, - model, + model: options.model, + mode: options.mode, + permissionMode: options.permissionMode, title: paths.sessionId.slice(0, 8), currentTurnId: 0, usage: undefined, @@ -85,6 +95,8 @@ export class SessionService extends Effect.Service()('Session', projectPath: state.projectPath, cwd: state.cwd, createdAt: new Date().toISOString(), + mode: options.mode, + permissionMode: options.permissionMode, ...(opts?.parentSessionId && { parentSessionId: opts.parentSessionId }), ...(opts?.agentName && { agentName: opts.agentName }), }; @@ -114,10 +126,13 @@ export class SessionService extends Effect.Service()('Session', messageCount: 0, sessionMeta: null, model: idx?.model ?? '', + mode: idx?.mode ?? 'build', + permissionMode: idx?.permissionMode ?? 'default', title: paths.sessionId.slice(0, 8), currentTurnId: idx?.currentTurnId ?? 0, usage: idx?.usage ?? undefined, memorySnapshot: idx?.memorySnapshot ?? '', + activeProfile: idx?.activeProfile, }; if (existsSync(state.transcriptPath)) { @@ -128,6 +143,8 @@ export class SessionService extends Effect.Service()('Session', if (meta) { state.sessionMeta = meta; state.messageCount = history.filter((e) => e.type !== 'session_meta').length; + if (meta.mode) state.mode = meta.mode; + if (meta.permissionMode) state.permissionMode = meta.permissionMode; } const firstUser = findFirstUserContent(history); if (firstUser) state.title = truncateTitle(firstUser); @@ -312,14 +329,43 @@ export class SessionService extends Effect.Service()('Session', const setPermissionModeFromState = ( state: SessionStoreState, - mode: string + mode: PermissionMode ): Effect.Effect => Effect.sync(() => { setPermissionMode(state.sessionId, state.indexPath, mode); }); - const getPermissionModeFromState = (state: SessionStoreState): Effect.Effect => - Effect.sync(() => getPermissionMode(state.indexPath)); + const getPermissionModeFromState = (state: SessionStoreState): Effect.Effect => + Effect.sync(() => { + const raw = getPermissionMode(state.indexPath); + if (raw === 'default' || raw === 'acceptEdits' || raw === 'bypass') return raw; + return 'default'; + }); + + const updateActiveProfile = ( + state: SessionStoreState, + profileName: string + ): Effect.Effect => + Effect.sync(() => { + const current = readCurrentIndex(state.indexPath); + const index: SessionIndex = { + sessionId: state.sessionId, + projectPath: state.projectPath, + cwd: state.cwd, + model: state.model, + createdAt: state.sessionMeta?.createdAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: state.messageCount, + title: state.title, + currentTurnId: state.currentTurnId, + usage: state.usage, + mode: state.mode, + permissionMode: state.permissionMode, + memorySnapshot: state.memorySnapshot, + activeProfile: profileName, + }; + writeFileSync(state.indexPath, JSON.stringify(index, null, 2), 'utf8'); + }); const incrementTurn = (state: SessionStoreState): number => { state.currentTurnId += 1; @@ -343,6 +389,7 @@ export class SessionService extends Effect.Service()('Session', getMessageCount, setPermissionMode: setPermissionModeFromState, getPermissionMode: getPermissionModeFromState, + updateActiveProfile, incrementTurn, readHistoryFile: (path: string): SessionEvent[] => readHistory(path), appendLineProxy: (path: string, event: object): void => appendLine(path, event), @@ -392,13 +439,15 @@ function forkSessionImpl(sourceJsonlPath: string, atTurnId: number): string { const sourceIdxPath = sourceJsonlPath.replace('.jsonl', '.index.json'); let title = newSessionId.slice(0, 8); let usage: TokenUsage | undefined = undefined; - let permissionMode = 'default'; + let mode: SessionMode = 'build'; + 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; + mode = srcIdx.mode ?? 'build'; permissionMode = srcIdx.permissionMode ?? 'default'; } catch { /* corrupt */ @@ -417,6 +466,7 @@ function forkSessionImpl(sourceJsonlPath: string, atTurnId: number): string { title, currentTurnId: turnId, usage, + mode, permissionMode, }; writeFileSync(newIndexPath, JSON.stringify(newIdx, null, 2), 'utf8'); diff --git a/packages/codingcode/src/session/types.ts b/packages/codingcode/src/session/types.ts index 21cd4046..54725ea0 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -1,9 +1,13 @@ +export type SessionMode = 'plan' | 'build'; + export interface SessionMetaEvent { type: 'session_meta'; sessionId: string; projectPath: string; cwd: string; createdAt: string; + mode: SessionMode; + permissionMode: import('../approval/types.js').PermissionMode; parentSessionId?: string; agentName?: string; } @@ -77,8 +81,10 @@ export interface SessionIndex { title: string; currentTurnId: number; usage: TokenUsage | undefined; - permissionMode: string; + mode: SessionMode; + permissionMode: import('../approval/types.js').PermissionMode; memorySnapshot?: string; + activeProfile?: string; } export interface SessionStoreState { @@ -90,8 +96,11 @@ export interface SessionStoreState { messageCount: number; sessionMeta: SessionMetaEvent | null; model: string; + mode: SessionMode; + permissionMode: import('../approval/types.js').PermissionMode; title: string; currentTurnId: number; usage: TokenUsage | undefined; memorySnapshot: string; + activeProfile?: string; } diff --git a/packages/codingcode/src/session/ui-history.ts b/packages/codingcode/src/session/ui-history.ts new file mode 100644 index 00000000..d5825a86 --- /dev/null +++ b/packages/codingcode/src/session/ui-history.ts @@ -0,0 +1,140 @@ +import { existsSync } from 'fs'; +import { sessionJsonlPathFromCwd, readHistory } from './file-ops.js'; +import type { SessionEvent, SummaryEvent, CompactEvent } from './types.js'; + +export function filterForUI(events: SessionEvent[]): SessionEvent[] { + const rollbackHiddenTurnIds = new Set(); + const rollbackHiddenOpUuids = new Set(); + + for (const ev of events) { + if (ev.type !== 'rollback') continue; + for (const prior of events) { + if (prior === ev) break; + if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { + rollbackHiddenTurnIds.add(prior.turnId); + } + if (prior.type === 'summary' || prior.type === 'compact') { + if ((prior as SummaryEvent | CompactEvent).endTurnId >= ev.throughTurnId) { + rollbackHiddenOpUuids.add((prior as SummaryEvent | CompactEvent).uuid); + } + } + } + } + + return events.filter((ev) => { + if (ev.type === 'rollback') return false; + if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; + if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as CompactEvent).uuid)) return false; + if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; + return true; + }) as SessionEvent[]; +} + +function createTurnScopedIdGenerator() { + const counters = new Map(); + return (prefix: string, turnId: number): string => { + const key = `${prefix}:${turnId}`; + const next = (counters.get(key) ?? 0) + 1; + counters.set(key, next); + return `${prefix}-${turnId}-${next}`; + }; +} + +export function sessionEventsToTurns( + events: SessionEvent[] +): Array<{ id: string; items: object[]; status: string }> { + const turnsMap = new Map(); + const nextId = createTurnScopedIdGenerator(); + + for (const event of events) { + if (event.type === 'session_meta') continue; + if (event.type === 'compact' || event.type === 'rollback') continue; + + if (event.type === 'summary') { + let turn = turnsMap.get(event.endTurnId); + if (!turn) { + turn = { id: String(event.endTurnId), items: [], status: 'completed' }; + turnsMap.set(event.endTurnId, turn); + } + turn.items.push({ + id: `summary-${event.uuid}`, + type: 'summary', + content: event.summaryText, + startTurnId: event.startTurnId, + endTurnId: event.endTurnId, + }); + continue; + } + + let turn = turnsMap.get(event.turnId); + if (!turn) { + turn = { id: String(event.turnId), items: [], status: 'completed' }; + turnsMap.set(event.turnId, turn); + } + switch (event.type) { + case 'user': + turn.items.push({ + id: nextId('user', event.turnId), + type: 'message', + role: 'user', + content: event.content, + }); + break; + case 'assistant': + if (event.content) { + turn.items.push({ + id: nextId('assistant', event.turnId), + type: 'message', + role: 'assistant', + content: event.content, + }); + } + for (const tc of event.toolCalls ?? []) { + const args = tc.arguments ?? {}; + turn.items.push({ + id: tc.id, + type: 'tool_call', + name: tc.name, + args, + status: 'approved', + }); + } + break; + case 'tool_result': { + const item: Record = { + id: `result-${event.toolCallId}`, + type: 'tool_result', + callId: event.toolCallId, + name: event.toolName, + output: event.output, + }; + turn.items.push(item); + break; + } + } + } + return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); +} + +export function readUIHistory( + sessionId: string, + cwd: string +): Array<{ id: string; items: object[]; status: string }> { + const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); + if (!existsSync(jsonlPath)) return []; + const events = readHistory(jsonlPath); + const visibleEvents = filterForUI(events); + return sessionEventsToTurns(visibleEvents); +} + +export function findUserMessageForTurn(sessionId: string, turnId: number, cwd: string): string { + const jsonlPath = sessionJsonlPathFromCwd(cwd, sessionId); + if (!existsSync(jsonlPath)) return ''; + const rawEvents = readHistory(jsonlPath); + for (const ev of rawEvents) { + if (ev.type === 'user' && (ev as any).turnId === turnId) { + return (ev as any).content ?? ''; + } + } + return ''; +} diff --git a/packages/codingcode/src/skills/loader.ts b/packages/codingcode/src/skills/loader.ts index 66bbfd43..b38a9a76 100644 --- a/packages/codingcode/src/skills/loader.ts +++ b/packages/codingcode/src/skills/loader.ts @@ -1,7 +1,7 @@ import { statSync } from 'fs'; import { basename } from 'path'; import type { Skill } from './types.js'; -import { readSkillMd, readFileContent, getFilesInDir, getMimeType } from './config.js'; +import { readSkillMd, readFileContent, getFilesInDir, getMimeType } from './source.js'; export function loadSkill(dirPath: string): Skill | null { const parsed = readSkillMd(dirPath); diff --git a/packages/codingcode/src/skills/service.ts b/packages/codingcode/src/skills/service.ts index f1c2e975..14e9aace 100644 --- a/packages/codingcode/src/skills/service.ts +++ b/packages/codingcode/src/skills/service.ts @@ -1,5 +1,5 @@ import { Effect } from 'effect'; -import { discoverSkillDirs, resolveSkillDisabled, setProjectSkillDisabledState } from './config.js'; +import { discoverSkillDirs, resolveSkillDisabled, setProjectSkillDisabledState } from './source.js'; import { loadSkill } from './loader.js'; import type { Skill } from './types.js'; diff --git a/packages/codingcode/src/skills/config.ts b/packages/codingcode/src/skills/source.ts similarity index 100% rename from packages/codingcode/src/skills/config.ts rename to packages/codingcode/src/skills/source.ts diff --git a/packages/codingcode/src/subagent/loader.ts b/packages/codingcode/src/subagent/loader.ts index 3c2b8c49..90d9231d 100644 --- a/packages/codingcode/src/subagent/loader.ts +++ b/packages/codingcode/src/subagent/loader.ts @@ -4,6 +4,7 @@ import { homedir } from 'os'; import { parse as parseYaml } from 'yaml'; import type { AgentProfile } from './types.js'; import { createLogger } from '@codingcode/infra/logger'; +import { NotFoundError } from '../core/error.js'; const logger = createLogger(); @@ -185,7 +186,10 @@ export function updateAgentProfile( export function deleteAgentProfile(projectCwd: string, name: string): void { const filePath = findAgentFile(projectCwd, name); - if (filePath) unlinkSync(filePath); + if (!filePath) { + throw new NotFoundError(`Agent '${name}' not found in project config`); + } + unlinkSync(filePath); } function loadAgentProfilesFromDir(dirPath: string): AgentProfile[] { diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts index 73f30e7f..a48b0323 100644 --- a/packages/codingcode/src/subagent/registry.ts +++ b/packages/codingcode/src/subagent/registry.ts @@ -151,6 +151,7 @@ export const EXPLORE_PROFILE: AgentProfile = { name: 'explore', description: 'Read-only code exploration: searching files, reading symbols, understanding structure. No writes.', + permissionMode: 'bypass', systemPrompt: `You are a read-only code exploration agent. Your role is to help explore and understand codebases through reading files, searching for symbols, and analyzing code structure. You can only read; you cannot write or modify files. ## Guidelines @@ -167,13 +168,15 @@ export const EXPLORE_PROFILE: AgentProfile = { export const PLAN_PROFILE: AgentProfile = { name: 'plan', description: - 'Read-only codebase research for planning. Analyzes project structure, patterns, and dependencies to inform implementation plans. No writes.', - systemPrompt: `You are a read-only code research agent. Your role is to analyze codebases and produce implementation plans. You can read files, search code, and run commands to gather information, but you cannot write or modify files. + 'Planning agent: analyzes the codebase, produces an implementation plan, and submits it via submit_plan for user approval. No business code modifications.', + systemPrompt: `You are a planning agent. Your role is to analyze the codebase and produce an implementation plan that the user reviews and approves before any code is written. -## Guidelines -- Start broad, then narrow down. Use search_files and search_code to get an overview before reading specific files. -- Call multiple tools in parallel when they are independent. -- When referencing code, use the format \`file_path:line_number\`. +You can read files, search code, and dispatch the 'explore' subagent for context-heavy investigation. You can submit a plan via the \`submit_plan\` tool — each call overwrites the previous plan file; use it to revise your plan based on user feedback. + +In plan mode, write_file / edit_file / execute_command are denied. The only write operation allowed is \`submit_plan\`. + +## Subagent dispatch +Use \`dispatch_agent({ agent: 'explore', prompt: '...' })\` to investigate large code sections without polluting your main context. The system hook enforces this — only 'explore' is permitted in plan mode; any other agent name will be denied. ## Research process 1. Understand the project structure and conventions @@ -183,22 +186,52 @@ export const PLAN_PROFILE: AgentProfile = { 5. Check for existing implementations or similar patterns ## Output format -Structure your analysis as: +When ready, call \`submit_plan({ title, plan_content: "..." })\` with a Markdown plan: - **Current state**: What exists today - **Key files**: Files that need modification or creation, with line references - **Dependencies and risks**: Breaking changes, third-party concerns - **Recommended approach**: Step-by-step implementation strategy -- **Phases**: If the task is complex, break it into ordered phases +- **Phases**: If complex, break into ordered phases -If you cannot fully understand the codebase, say so and explain what information is missing.`, +## After submit_plan +submit_plan returns synchronously after writing the plan file. Once you have called it, stop and wait for the user's decision — do not call submit_plan again until the user responds, and do not attempt to use any other write tool. + +The user's decision arrives as the next user message. The system has already handled the agent-profile switch (plan → build on approval, plan → plan on revise, no change on cancel); the message body itself is your signal: + +- "Implement"/"proceed"/"go ahead" (or any explicit approval) — the plan is approved. Acknowledge briefly and stop. The build agent will pick up the plan from the persisted file. +- The body contains a revised plan (a Markdown document, often with explicit section headers, or with a "Revise the plan with these changes:" wrapper) — treat the body as the new plan_content, call \`submit_plan\` again with the same title and the revised content, then stop. +- "Cancel"/"do not implement" — the plan is rejected. Acknowledge briefly and stop. + +Never re-call submit_plan on your own initiative. Never treat an implement message as a request for further exploration.`, tools: [ 'read_file', 'search_files', 'search_code', + 'fetch_url', + 'tool_search', + 'submit_plan', + 'dispatch_agent', + ], + maxSteps: 180, +}; + +export const BUILD_PROFILE: AgentProfile = { + name: 'build', + description: + 'Default build agent: full read/write access. Implements changes the user has approved.', + permissionMode: 'default', + tools: [ + 'read_file', + 'write_file', + 'edit_file', 'execute_command', + 'search_files', + 'search_code', 'fetch_url', + 'web_search', + 'todo_write', 'tool_search', + 'dispatch_agent', ], - readonly: true, maxSteps: 180, }; diff --git a/packages/codingcode/src/subagent/types.ts b/packages/codingcode/src/subagent/types.ts index 9a32fc03..f21036ac 100644 --- a/packages/codingcode/src/subagent/types.ts +++ b/packages/codingcode/src/subagent/types.ts @@ -1,4 +1,7 @@ import type { UserHookConfig } from '../hooks/types.js'; +import type { PermissionMode } from '../approval/types.js'; + +export type ProfilePermissionMode = Exclude; export interface AgentProfile { name: string; @@ -7,6 +10,7 @@ export interface AgentProfile { tools?: string[]; mcpServers?: string[]; readonly?: boolean; + permissionMode?: ProfilePermissionMode; maxSteps?: number; model?: string; hooks?: UserHookConfig[]; diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index ca304e91..23528e98 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -7,10 +7,13 @@ import { ApprovalService } from '../../../approval/index.js'; import { HookService } from '../../../hooks/registry.js'; import { McpService } from '../../../mcp/index.js'; import { LLMFactoryService } from '../../../llm/factory.js'; -import { resolveSubagentEnabled, resolveAgentDisabled } from '../../../subagent/registry.js'; +import { resolveSubagentEnabled, resolveAgentDisabled, BUILD_PROFILE } from '../../../subagent/registry.js'; import { RulesService } from '../../../rules/index.js'; import { ProjectRuntimeService } from '../../../runtime/project-runtime.js'; import { SubagentRunnerService } from '../../../subagent/runner-service.js'; +import { checkSubagentAllowedInPlanMode } from '../../../plan/index.js'; +import type { SessionMode } from '../../../session/types.js'; +import type { PermissionMode } from '../../../approval/types.js'; export function createDispatchAgentTool(): Effect.Effect< ToolDefinition, @@ -89,10 +92,24 @@ export function createDispatchAgentTool(): Effect.Effect< } // Emit spawn.before hook (decision hook, can deny) + const parentSessionId = ctx?.sessionId; + const parentMainProfile = parentSessionId + ? runtime.getSessionProfile(parentSessionId)?.name + : undefined; + + const whitelist = checkSubagentAllowedInPlanMode( + parentSessionId, + parentMainProfile, + agentName + ); + if (!whitelist.allowed) { + return yield* Effect.fail(new AgentError('TOOL_NOT_ALLOWED', whitelist.reason)); + } + const spawnDecision = yield* hooks.emitDecision('agent.subagent.spawn.before', { profile: agentName, prompt, - parentSessionId: ctx?.sessionId, + parentSessionId, }); if (spawnDecision && spawnDecision.decision === 'deny') { return yield* Effect.fail( @@ -104,10 +121,31 @@ export function createDispatchAgentTool(): Effect.Effect< } // Create subagent transcript nested under parent session - const childState = yield* session.create(projectPath, (ctx as any)?.model ?? 'subagent', { - parentSessionId: ctx?.sessionId, - agentName: agentName, - }); + const subagentProfile = runtime.resolveSubagentProfile(projectPath, agentName); + const childMode: SessionMode = 'build'; + const childPermissionMode: PermissionMode = + (subagentProfile?.permissionMode as PermissionMode | undefined) ?? 'default'; + const childModel: string = subagentProfile?.model ?? llm.modelInfo.model; + + const childState = yield* session.create( + projectPath, + { + model: childModel, + mode: childMode, + permissionMode: childPermissionMode, + }, + { + parentSessionId: ctx?.sessionId, + agentName: agentName, + } + ); + yield* runtime.setSessionProfile( + projectPath, + childState.sessionId, + subagentProfile ?? BUILD_PROFILE, + childPermissionMode, + ctx?.sessionId + ); const childUuid = childState.sessionId; session.incrementTurn(childState); yield* session.recordUser(childState, prompt); @@ -157,7 +195,7 @@ export function createDispatchAgentTool(): Effect.Effect< profile: agentName, }); - // Collect events and extract result — wrap AsyncGenerator in Effect + let didComplete = false; const finalContent = yield* Effect.async((resume) => { let content = ''; (async () => { @@ -178,19 +216,11 @@ export function createDispatchAgentTool(): Effect.Effect< } } - // Cleanup + // Cleanup (pure sync Effects — no service context required) await Effect.runPromise(mcp.disposeSession(childUuid)); await Effect.runPromise(hooks.disposeSession(childUuid)); - // Emit completion hook - await Effect.runPromise( - hooks.emit('agent.subagent.complete', { - childSessionId: childUuid, - profile: agentName, - status: 'done', - }) - ); - + didComplete = true; resume(Effect.succeed(content || '(subagent completed without output)')); } catch (e) { // Cleanup on unexpected error @@ -206,6 +236,16 @@ export function createDispatchAgentTool(): Effect.Effect< })(); }); + if (didComplete) { + yield* hooks + .emit('agent.subagent.complete', { + childSessionId: childUuid, + profile: agentName, + status: 'done', + }) + .pipe(Effect.ignore); + } + return finalContent; }) as Effect.Effect, }; diff --git a/packages/codingcode/src/tools/domains/subagent/submit-plan.ts b/packages/codingcode/src/tools/domains/subagent/submit-plan.ts new file mode 100644 index 00000000..6193459a --- /dev/null +++ b/packages/codingcode/src/tools/domains/subagent/submit-plan.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; +import { Effect } from 'effect'; +import { join } from 'path'; +import { writeFileSync, mkdirSync } from 'fs'; +import { AgentError } from '../../../core/error.js'; +import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import { encodeProjectPath, getProjectBaseDir } from '../../../core/path.js'; +import { createLogger } from '@codingcode/infra/logger'; + +const logger = createLogger(); + +export function slug(input: string): string { + return ( + input + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^一-鿿぀-ヿa-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'plan' + ); +} + +function ensureH1(content: string, title: string): string { + const firstLine = content.split('\n', 1)[0]?.trim() ?? ''; + if (/^#\s+/.test(firstLine)) return content; + return `# ${title}\n\n${content}`; +} + +function warnMissingSections(content: string): void { + const has = (re: RegExp) => re.test(content); + const missing: string[] = []; + if (!has(/^#{1,3}\s*(Verification|验证)/im)) missing.push('Verification'); + if (!has(/^#{1,3}\s*(Out of scope|不在范围内|范围外)/im)) missing.push('Out of scope'); + if (missing.length > 0) { + logger.warn( + `submit_plan: plan is missing recommended section(s): ${missing.join(', ')}. ` + + `See PLAN_PROFILE.systemPrompt for the required format.` + ); + } +} + +export const submitPlanTool: ToolDefinition = { + name: 'submit_plan', + description: + 'Submit (or update) the implementation plan for the current session. The only write operation allowed in plan mode. The file is written immediately and the tool returns synchronously; the user is then shown a plan approval modal in the UI. The user’s next message will contain their decision (implement / revised content / cancel).', + shortDescription: 'Submit plan', + parameters: z.object({ + title: z + .string() + .min(1) + .max(80) + .describe( + 'Short, human-readable title (max 80 chars). Becomes the filename. Must be in English. State the outcome, e.g. "Add OAuth login flow".' + ), + plan_content: z + .string() + .min(1) + .describe( + 'Full Markdown implementation plan. Must contain the sections: Goal, Current state, Out of scope, Approach, Key files, Dependencies and risks, Verification. Phases is optional.' + ), + }), + execute: (args: unknown, ctx?: ToolExecCtx): Effect.Effect => + Effect.gen(function* () { + const { title, plan_content: rawContent } = args as { + title: string; + plan_content: string; + }; + const projectPath = ctx?.projectPath; + const sessionId = ctx?.sessionId; + if (!projectPath || !sessionId) { + return yield* Effect.fail( + new AgentError( + 'TOOL_EXECUTION_FAILED', + 'submit_plan requires projectPath, sessionId in tool context' + ) + ); + } + + warnMissingSections(rawContent); + const initialContent = ensureH1(rawContent, title); + const planDir = join(getProjectBaseDir(), encodeProjectPath(projectPath)); + const initialPath = join(planDir, `${slug(title)}.md`); + + mkdirSync(planDir, { recursive: true }); + writeFileSync(initialPath, initialContent, 'utf8'); + + return `Plan written to ${initialPath}`; + }) as Effect.Effect, +}; diff --git a/packages/codingcode/src/tools/executor.ts b/packages/codingcode/src/tools/executor.ts index 892d2fc0..c15674dd 100644 --- a/packages/codingcode/src/tools/executor.ts +++ b/packages/codingcode/src/tools/executor.ts @@ -45,6 +45,7 @@ export class ToolExecutorService extends Effect.Service()(' input: args as Record, callId: opts?.callId, sessionId: opts?.sessionId ?? 'default', + projectPath: opts?.projectPath, }); if (decision.type === 'deny') { @@ -57,9 +58,7 @@ export class ToolExecutorService extends Effect.Service()(' return yield* Effect.fail(new AgentError('TOOL_NOT_ALLOWED', decision.reason)); } - // Use modified input from pipeline if present - const finalArgs: Record = - decision.type === 'modified' ? decision.input : (args as Record); + const finalArgs = args as Record; // 2. Notification hook — use callId for consistent pairing const callId = opts?.callId; diff --git a/packages/codingcode/test/agent/agent-on-interrupt-emit.test.ts b/packages/codingcode/test/agent/agent-on-interrupt-emit.test.ts new file mode 100644 index 00000000..911cee2a --- /dev/null +++ b/packages/codingcode/test/agent/agent-on-interrupt-emit.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { Effect, Fiber } from 'effect'; +import { HookService } from '../../src/hooks/registry.js'; + +// This file pins the fix to `Effect.onInterrupt` callback in agent.ts +// (around the `agent.turn.end` emit on abort). The old code wrapped the +// emit in `Effect.sync(() => { ... Effect.runPromise(emit) ... })`, which +// runs the emit in a fresh fiber with no service context — so any +// observer that yield*'d a service (HookService, SessionService, …) would +// Die with "Service not found: …". The fix wraps the callback in +// `Effect.gen` and `yield*`s the emit so it runs in the agent's fiber +// (the onInterrupt callback's fiber inherits the agent's services via +// `Effect.provideService` in `AgentService.runStream`). +// +// This test exercises the same `Effect.onInterrupt` + `yield* emit` +// pattern with an observer that yield*'s HookService. Before the fix +// the observer would Die; after the fix it resolves HookService from +// the fiber's context. + +describe('Effect.onInterrupt callback can yield* emit (agent.ts abort hook fix)', () => { + it('observer services resolve from the interrupted fiber context', async () => { + let observerRan = false; + let serviceResolved = false; + + const AppLayer = HookService.Default; + + const program = Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.register( + 'agent.turn.end', + () => + Effect.gen(function* () { + // This yield* is the contract under test. With the old + // Effect.runPromise path it would Die because the emit ran + // on a default runtime. With the yield* path it resolves + // from the agent's fiber context. + const h = yield* HookService; + observerRan = true; + serviceResolved = typeof h.register === 'function'; + }), + { source: 'system' } + ); + // Suspend forever so the only way out is via Fiber.interrupt, + // which triggers Effect.onInterrupt's callback. + yield* Effect.never; + }).pipe( + Effect.onInterrupt(() => + Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.emit('agent.turn.end', { status: 'aborted' }).pipe(Effect.ignore); + }) + ) + ); + + const fiber = Effect.runFork(Effect.provide(program, AppLayer)); + // Yield to the event loop so the registration's Effect.sync + // completes before we interrupt. + await new Promise((resolve) => setTimeout(resolve, 10)); + await Effect.runPromise(Fiber.interrupt(fiber)); + // Yield again so the onInterrupt callback's emit (and its observer) + // get a chance to finish before we assert. + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(observerRan).toBe(true); + expect(serviceResolved).toBe(true); + }); +}); diff --git a/packages/codingcode/test/agent/agent-profile-filter.test.ts b/packages/codingcode/test/agent/agent-profile-filter.test.ts new file mode 100644 index 00000000..eaeda1a3 --- /dev/null +++ b/packages/codingcode/test/agent/agent-profile-filter.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { buildSystemPrompt } from '../../src/agent/prompt.js'; +import { PLAN_PROFILE, BUILD_PROFILE, EXPLORE_PROFILE } from '../../src/subagent/registry.js'; + +describe('agent profile catalog filter', () => { + it('plan mode shows only explore in the catalog', () => { + const allProfiles = [BUILD_PROFILE, PLAN_PROFILE, EXPLORE_PROFILE]; + const visible = allProfiles.filter((p) => p.name === 'explore'); + const prompt = buildSystemPrompt({ + cwd: '/x', + platform: 'linux', + shell: 'bash', + agentProfiles: visible, + }); + expect(prompt).toContain('### explore'); + expect(prompt).not.toContain('### build'); + expect(prompt).not.toContain('### plan'); + }); + + it('build mode shows all profiles in the catalog', () => { + const allProfiles = [BUILD_PROFILE, PLAN_PROFILE, EXPLORE_PROFILE]; + const prompt = buildSystemPrompt({ + cwd: '/x', + platform: 'linux', + shell: 'bash', + agentProfiles: allProfiles, + }); + expect(prompt).toContain('### build'); + expect(prompt).toContain('### plan'); + expect(prompt).toContain('### explore'); + }); + + it('empty catalog produces no ## Available Subagents section', () => { + const prompt = buildSystemPrompt({ + cwd: '/x', + platform: 'linux', + shell: 'bash', + agentProfiles: [], + }); + expect(prompt).not.toContain('## Available Subagents'); + }); +}); diff --git a/packages/codingcode/test/agent/build-system-prompt.test.ts b/packages/codingcode/test/agent/build-system-prompt.test.ts new file mode 100644 index 00000000..c3830603 --- /dev/null +++ b/packages/codingcode/test/agent/build-system-prompt.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { buildSystemPrompt } from '../../src/agent/prompt.js'; +import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; + +describe('buildSystemPrompt', () => { + it('uses DEFAULT_BEHAVIOR_PROMPT when profileSystemPrompt is not provided', () => { + const prompt = buildSystemPrompt({ + cwd: '/test', + platform: 'linux', + shell: 'bash', + }); + expect(prompt).toContain('You are a coding assistant'); + expect(prompt).toContain('## How you work'); + expect(prompt).toContain('## Environment'); + expect(prompt).toContain('Working directory: /test'); + }); + + it('overrides default behavior with profileSystemPrompt when provided (plan mode)', () => { + const prompt = buildSystemPrompt({ + cwd: '/test', + platform: 'linux', + shell: 'bash', + profileSystemPrompt: PLAN_PROFILE.systemPrompt, + }); + expect(prompt).toContain('You are a planning agent'); + expect(prompt).toContain('## Environment'); + expect(prompt).toContain('Working directory: /test'); + expect(prompt).not.toContain('You are a coding assistant'); + expect(prompt).not.toContain('## How you work'); + }); + + it('emits env segment with cwd/platform/shell replaced', () => { + const prompt = buildSystemPrompt({ + cwd: '/projects/foo', + platform: 'darwin', + shell: 'zsh', + }); + expect(prompt).toContain('Working directory: /projects/foo'); + expect(prompt).toContain('Operating system: darwin'); + expect(prompt).toContain('Shell: zsh'); + expect(prompt).not.toContain('{{cwd}}'); + expect(prompt).not.toContain('{{platform}}'); + expect(prompt).not.toContain('{{shell}}'); + }); + + it('appends agent catalog when agentProfiles is provided', () => { + const prompt = buildSystemPrompt({ + cwd: '/x', + platform: 'linux', + shell: 'bash', + agentProfiles: [BUILD_PROFILE, PLAN_PROFILE], + }); + expect(prompt).toContain('## Available Subagents'); + expect(prompt).toContain('### build'); + expect(prompt).toContain('### plan'); + }); + + it('appends user-defined rules when provided', () => { + const prompt = buildSystemPrompt({ + cwd: '/x', + platform: 'linux', + shell: 'bash', + rules: 'Always use TypeScript strict mode.', + }); + expect(prompt).toContain('## User-defined Rules'); + expect(prompt).toContain('Always use TypeScript strict mode.'); + }); + + it('appends skill instructions when provided', () => { + const prompt = buildSystemPrompt({ + cwd: '/x', + platform: 'linux', + shell: 'bash', + skillInstruction: 'When reviewing code, focus on security.', + }); + expect(prompt).toContain('## Skill Instructions'); + expect(prompt).toContain('When reviewing code, focus on security.'); + }); + + it('plan profile prompt mentions submit_plan and dispatch_agent for explore only', () => { + const prompt = buildSystemPrompt({ + cwd: '/x', + platform: 'linux', + shell: 'bash', + profileSystemPrompt: PLAN_PROFILE.systemPrompt, + }); + expect(prompt).toContain('submit_plan'); + expect(prompt).toContain("dispatch the 'explore' subagent"); + expect(prompt).toContain("write_file / edit_file / execute_command are denied"); + }); +}); diff --git a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts new file mode 100644 index 00000000..8047b824 --- /dev/null +++ b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Effect, Layer, Queue, Chunk } from 'effect'; +import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { TodoService } from '../../src/agent/todo.js'; +import { ContextService } from '../../src/context/service.js'; +import { MemoryService } from '../../src/memory/index.js'; + +vi.mock('@codingcode/infra/config', () => ({ + loadConfig: () => ({ + context: { compactionModel: '' }, + memory: { + enabled: false, + model: '', + maxBytes: 16384, + promptMaxBytes: 8192, + extraTypes: [], + disabledTypes: [], + }, + server: { port: 8080 }, + }), +})); + +import { agentLoop } from '../../src/agent/agent'; +import { Result } from '../../src/core/result'; +import type { RunStreamOptions } from '../../src/agent/types'; +import { SessionService } from '../../src/session/store.js'; + +const AllMockLayer = Layer.mergeAll( + Layer.succeed(CheckpointService, { + snapshotBaseline: () => Effect.void, + snapshotFinal: () => Effect.void, + } as any), + Layer.succeed(SessionService, { + recordAssistant: () => Effect.succeed({}), + recordUser: () => Effect.succeed({}), + recordToolResult: () => Effect.succeed({}), + } as any), + Layer.succeed(ProjectRuntimeService, { + prepareProject: () => Effect.void, + resolveMainAgentProfile: () => undefined, + resolveSubagentProfile: () => undefined, + listAgentProfiles: () => [], + getToolPolicy: () => ({ + allowedTools: undefined, + allowedMcpServers: undefined, + allowToolSearch: true, + allowDeferredTools: false, + }), + setSessionProfile: () => {}, + getSessionProfile: () => undefined, + disposeSession: () => Effect.void, + disposeProject: () => Effect.void, + } as any), + Layer.succeed(TodoService, { + read: () => [], + write: () => {}, + reset: () => {}, + } as any), + Layer.succeed(ContextService, { + assemblePayload: () => ({ + messages: [{ role: 'user' as const, content: 'hi' }], + compactedEvents: [], + promptEstimate: 10, + currentTurnId: 1, + compactedTurnIds: new Set(), + }), + compactIfNeeded: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), + compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 10 }), + } as any), + Layer.succeed(MemoryService, { + getMemoryEnabled: () => false, + setMemoryEnabled: () => {}, + loadMemoryForPrompt: () => '', + flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), + } as any) +); + +const mockState = { + sessionId: 'test-session', + cwd: process.cwd(), + currentTurnId: 1, + sessionMeta: { model: 'test-model', createdAt: new Date().toISOString() } as any, + model: 'test-model', + title: 'test', + usage: undefined, + projectPath: 'test-project', + transcriptPath: '/tmp/test.jsonl', + indexPath: '/tmp/test.index.json', + messageCount: 0, + memorySnapshot: '', +}; + +describe('agentLoop plan.ready emission on turn-end', () => { + it('emits plan.ready when turn ends naturally after submit_plan tool call', async () => { + let callCount = 0; + const mockLlm = { + completeStream: vi.fn(() => { + callCount++; + if (callCount === 1) { + // First call: LLM emits submit_plan tool call + return { + stream: (async function* () {})(), + response: Promise.resolve( + Result.ok({ + content: '', + toolCalls: [ + { + id: 'tc-1', + name: 'submit_plan', + arguments: { title: 'My Plan', plan_content: '## Goal\nfix bug' }, + }, + ], + }) + ), + }; + } + // Second call: LLM emits pure content, turn ends + return { + stream: (async function* () {})(), + response: Promise.resolve( + Result.ok({ content: 'Plan is ready for your review.', toolCalls: [] }) + ), + }; + }), + }; + + const planReadyEmits: any[] = []; + const mockHooks = { + emit: vi.fn((point: string, payload: any) => { + if (point === 'plan.ready') planReadyEmits.push(payload); + return Effect.succeed(undefined); + }), + emitDecision: vi.fn(() => Effect.succeed(null)), + } as any; + + const executor = { + execute: () => Effect.succeed({ output: '' }), + executeBatch: (tcs: any[]) => + Effect.succeed( + tcs.map((tc: any) => { + if (tc.name === 'submit_plan') { + return { + type: 'ok' as const, + id: tc.id, + name: tc.name, + output: 'Plan written to /tmp/plans/my-plan.md', + }; + } + return { type: 'ok' as const, id: tc.id, name: tc.name, output: '' }; + }) + ), + } as any; + + const opts: RunStreamOptions = { + state: mockState, + llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, + }; + + const q = Effect.runSync(Queue.unbounded()); + await Effect.runPromise( + agentLoop(executor, mockHooks, 5, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any + ); + + // Exactly one plan.ready emitted, at turn-end (after the second LLM call) + expect(planReadyEmits).toHaveLength(1); + expect(planReadyEmits[0]).toEqual({ + sessionId: mockState.sessionId, + projectPath: mockState.cwd, + title: 'My Plan', + }); + }); + + it('does NOT emit plan.ready when no submit_plan was called this turn', async () => { + let callCount = 0; + const mockLlm = { + completeStream: vi.fn(() => { + callCount++; + return { + stream: (async function* () {})(), + response: Promise.resolve(Result.ok({ content: 'Just a regular response', toolCalls: [] })), + }; + }), + }; + + const planReadyEmits: any[] = []; + const mockHooks = { + emit: vi.fn((point: string, payload: any) => { + if (point === 'plan.ready') planReadyEmits.push(payload); + return Effect.succeed(undefined); + }), + emitDecision: vi.fn(() => Effect.succeed(null)), + } as any; + + const opts: RunStreamOptions = { + state: mockState, + llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, + }; + + const q = Effect.runSync(Queue.unbounded()); + await Effect.runPromise( + agentLoop({} as any, mockHooks, 5, 2, opts, q).pipe(Effect.provide(AllMockLayer)) as any + ); + + expect(planReadyEmits).toHaveLength(0); + }); + + it('does NOT switch profile after plan.ready (profile change is UI responsibility)', async () => { + let callCount = 0; + const setProfileCalls: any[] = []; + const mockLlm = { + completeStream: vi.fn(() => { + callCount++; + if (callCount === 1) { + return { + stream: (async function* () {})(), + response: Promise.resolve( + Result.ok({ + content: '', + toolCalls: [ + { + id: 'tc-1', + name: 'submit_plan', + arguments: { title: 'My Plan', plan_content: 'x' }, + }, + ], + }) + ), + }; + } + return { + stream: (async function* () {})(), + response: Promise.resolve(Result.ok({ content: 'done', toolCalls: [] })), + }; + }), + }; + + const mockRuntime = { + prepareProject: () => Effect.void, + resolveMainAgentProfile: () => undefined, + resolveSubagentProfile: () => undefined, + listAgentProfiles: () => [], + getToolPolicy: () => ({ + allowedTools: undefined, + allowedMcpServers: undefined, + allowToolSearch: true, + allowDeferredTools: false, + }), + setSessionProfile: (...args: any[]) => { + setProfileCalls.push(args); + return {}; + }, + getSessionProfile: () => undefined, + disposeSession: () => Effect.void, + disposeProject: () => Effect.void, + }; + + const layer = AllMockLayer.pipe( + Layer.provide(Layer.succeed(ProjectRuntimeService, mockRuntime as any)) + ); + + const mockHooks = { + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + } as any; + + const executor = { + execute: () => Effect.succeed({ output: '' }), + executeBatch: (tcs: any[]) => + Effect.succeed( + tcs.map((tc: any) => + tc.name === 'submit_plan' + ? { type: 'ok' as const, id: tc.id, name: tc.name, output: 'Plan written to /x' } + : { type: 'ok' as const, id: tc.id, name: tc.name, output: '' } + ) + ), + } as any; + + const opts: RunStreamOptions = { + state: mockState, + llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, + }; + + const q = Effect.runSync(Queue.unbounded()); + await Effect.runPromise( + agentLoop(executor, mockHooks, 5, 2, opts, q).pipe(Effect.provide(layer)) as any + ); + + // Profile must NOT be switched as a side effect of plan submission + // (UI button drives the switch) + expect(setProfileCalls).toHaveLength(0); + }); +}); diff --git a/packages/codingcode/test/approval/permission-mode.test.ts b/packages/codingcode/test/approval/permission-mode.test.ts index 2a88a93b..63c539f8 100644 --- a/packages/codingcode/test/approval/permission-mode.test.ts +++ b/packages/codingcode/test/approval/permission-mode.test.ts @@ -63,7 +63,7 @@ describe('Global permission mode state', () => { }); it('can be set to all valid modes', async () => { - const modes = ['default', 'acceptEdits', 'plan', 'bypass'] as const; + const modes = ['default', 'acceptEdits', 'bypass'] as const; for (const mode of modes) { await run((svc) => svc.setPermissionMode(mode)); const current = await run((svc) => Effect.succeed(svc.getPermissionMode())); @@ -72,11 +72,11 @@ describe('Global permission mode state', () => { }); it('is shared across multiple reads (module-level singleton)', async () => { - await run((svc) => svc.setPermissionMode('plan')); + await run((svc) => svc.setPermissionMode('bypass')); const mode1 = await run((svc) => Effect.succeed(svc.getPermissionMode())); const mode2 = await run((svc) => Effect.succeed(svc.getPermissionMode())); // Both reads return the same value — no per-call isolation - expect(mode1).toBe('plan'); - expect(mode2).toBe('plan'); + expect(mode1).toBe('bypass'); + expect(mode2).toBe('bypass'); }); }); diff --git a/packages/codingcode/test/approval/pipeline.test.ts b/packages/codingcode/test/approval/pipeline.test.ts index 116ebf94..628fba27 100644 --- a/packages/codingcode/test/approval/pipeline.test.ts +++ b/packages/codingcode/test/approval/pipeline.test.ts @@ -79,39 +79,6 @@ describe('Approval Pipeline', () => { expect((decision as any).source).toBe('readonly-whitelist'); }); - it('Layer 3: Plan mode should deny write tools', async () => { - const decision = await runWithLayer( - runPipeline( - { tool: 'write_file', input: { path: '/test.txt', content: 'data' } }, - { - ruleEngine: createRuleEngine(), - readonlyTools: readonlyTools, - destructiveTools: new Set(['Bash']), - permissionMode: 'plan', - sessionId: 'test', - } - ) - ); - expect((decision as any).type).toBe('deny'); - expect((decision as any).reason).toContain('plan mode'); - }); - - it('Layer 3: Plan mode should allow read-only tools', async () => { - const decision = await runWithLayer( - runPipeline( - { tool: 'read_file', input: { path: '/test.txt' } }, - { - ruleEngine: createRuleEngine(), - readonlyTools: readonlyTools, - destructiveTools: new Set(), - permissionMode: 'plan', - sessionId: 'test', - } - ) - ); - expect((decision as any).type).toBe('allow'); - }); - it('Layer 3: Bypass mode should allow everything', async () => { const decision = await runWithLayer( runPipeline( diff --git a/packages/codingcode/test/approval/presets.test.ts b/packages/codingcode/test/approval/presets.test.ts index 438250cc..a214afb2 100644 --- a/packages/codingcode/test/approval/presets.test.ts +++ b/packages/codingcode/test/approval/presets.test.ts @@ -28,18 +28,18 @@ describe('Presets', () => { expect(result!.type).toBe('deny'); }); - it('should ask for SSH key reads', () => { + it('should fall through (null) for SSH key reads so Layer 5 prompts the user', () => { const engine = createRuleEngine(DEFAULT_DENY_RULES); const result = engine.evaluate('read_file', { path: '/home/user/.ssh/id_rsa' }); - expect(result).not.toBeNull(); - expect(result!.type).toBe('ask'); + // 'ask' is a pass-through — the rule matches, but the engine returns + // null so the pipeline reaches the user confirmation layer. + expect(result).toBeNull(); }); - it('should ask for .env file reads', () => { + it('should fall through (null) for .env file reads so Layer 5 prompts the user', () => { const engine = createRuleEngine(DEFAULT_DENY_RULES); const result = engine.evaluate('read_file', { path: '/project/.env.production' }); - expect(result).not.toBeNull(); - expect(result!.type).toBe('ask'); + expect(result).toBeNull(); }); it('should define read-only tools', () => { diff --git a/packages/codingcode/test/approval/response.test.ts b/packages/codingcode/test/approval/response.test.ts index 3b6dff39..ed86a233 100644 --- a/packages/codingcode/test/approval/response.test.ts +++ b/packages/codingcode/test/approval/response.test.ts @@ -35,4 +35,5 @@ describe('parseApprovalResponse', () => { vi.useRealTimers(); }); + }); diff --git a/packages/codingcode/test/approval/rule-engine.test.ts b/packages/codingcode/test/approval/rule-engine.test.ts index 2780b034..c30d9169 100644 --- a/packages/codingcode/test/approval/rule-engine.test.ts +++ b/packages/codingcode/test/approval/rule-engine.test.ts @@ -33,7 +33,7 @@ describe('RuleEngine', () => { expect(result).toEqual({ type: 'allow', source: 'rule:allow-read' }); }); - it('should ask for commands matching an ask rule', () => { + it('ask rules return null so the pipeline falls through to user confirmation (Layer 5)', () => { const rules: PermissionRule[] = [ { id: 'ask-env', @@ -44,8 +44,10 @@ describe('RuleEngine', () => { }, ]; const engine = createRuleEngine(rules); - const result = engine.evaluate('read_file', { path: '/project/.env.local' }); - expect(result).toEqual({ type: 'ask', source: 'rule:ask-env' }); + // 'ask' must NOT produce a terminal decision — the executor has no + // 'ask' branch, so returning one would silently auto-allow. Returning + // null makes the pipeline reach Layer 5 (user confirmation) instead. + expect(engine.evaluate('read_file', { path: '/project/.env.local' })).toBeNull(); }); it('should respect rule priority (higher priority wins)', () => { diff --git a/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts b/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts index 80b80422..01afe14c 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-diff.test.ts @@ -4,8 +4,9 @@ import { join } from 'path'; import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { spawnSync } from 'child_process'; +import { useTempProjectBase } from '../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +useTempProjectBase(); function setupTempRepo(): { projectPath: string; slug: string } { const slug = `test-${randomUUID()}`; diff --git a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts index 72f33720..1acdbbc1 100644 --- a/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts +++ b/packages/codingcode/test/checkpoint/checkpoint-undo.test.ts @@ -13,7 +13,9 @@ import { randomUUID } from 'crypto'; import { spawnSync } from 'child_process'; import { Effect } from 'effect'; import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +import { useTempProjectBase } from '../helpers/project-base.js'; + +useTempProjectBase(); function setupTempRepo(): { projectPath: string; slug: string } { const slug = `test-${randomUUID()}`; diff --git a/packages/codingcode/test/checkpoint/project-lock.test.ts b/packages/codingcode/test/checkpoint/project-lock.test.ts index 63f53d84..5aa606ae 100644 --- a/packages/codingcode/test/checkpoint/project-lock.test.ts +++ b/packages/codingcode/test/checkpoint/project-lock.test.ts @@ -3,6 +3,9 @@ import { existsSync, mkdirSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { ProjectLock } from '../../src/checkpoint/project-lock.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +useTempProjectBase(); describe('ProjectLock', () => { const dirs: string[] = []; diff --git a/packages/codingcode/test/client/agent-client-cwd.test.ts b/packages/codingcode/test/client/agent-client-cwd.test.ts new file mode 100644 index 00000000..a77d49d7 --- /dev/null +++ b/packages/codingcode/test/client/agent-client-cwd.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; + +import { WorkspaceService } from '../../src/core/workspace.js'; +import { LLMFactoryService } from '../../src/llm/factory.js'; +import { AgentError } from '../../src/core/error.js'; +import type { LLMClient } from '../../src/llm/client.js'; + +const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { + getWorkspaceCwd: () => '/workspace', +} as any); + +const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { + getLLMClient: () => Effect.succeed(null), + listModels: () => Effect.succeed([]), + switchModel: () => Effect.fail(new AgentError('CONFIG_INVALID', 'not found')), + findModel: () => Effect.succeed(null), + getActiveEntry: () => Effect.fail(new AgentError('CONFIG_INVALID', 'No active model')), + createClient: () => Effect.succeed(null), +} as any); + +const TestLayer = Layer.mergeAll(MockWorkspaceLayer, MockLLMFactoryLayer); + +const noopLlm: LLMClient = { + completeStream: () => ({ + stream: (async function* () {})(), + response: Promise.resolve({ ok: true, value: { content: '', finishReason: 'stop' as const } }), + }), + complete: () => Effect.succeed({ content: '' } as any), + modelInfo: { id: 'test', provider: 'test', name: 'Test', contextWindow: 128000 } as any, +}; + +const calls: Record = { + getSubagentEnabled: [], + getMcpStatus: [], + createMcpServer: [], + updateMcpServer: [], + deleteMcpServer: [], + listAgents: [], + createAgent: [], + updateAgent: [], + deleteAgent: [], + listHooks: [], + createHook: [], + updateHook: [], + deleteHook: [], + toggleSkill: [], + setAgentDisabled: [], + setHookDisabled: [], +}; + +function makeMockSettings() { + return { + getMemoryEnabled: vi.fn().mockResolvedValue(true), + setMemoryEnabled: vi.fn().mockResolvedValue(undefined), + getMemoryConfig: vi.fn().mockResolvedValue({ enabled: true, types: [] }), + setMemoryTypeDisabled: vi.fn().mockResolvedValue(undefined), + addMemoryExtraType: vi.fn().mockResolvedValue(undefined), + updateMemoryExtraType: vi.fn().mockResolvedValue(undefined), + deleteMemoryExtraType: vi.fn().mockResolvedValue(undefined), + getSubagentEnabled: vi.fn().mockImplementation((...args: unknown[]) => { + calls.getSubagentEnabled.push(args); + return Promise.resolve({ enabled: true, source: 'global' }); + }), + setSubagentEnabled: vi.fn().mockResolvedValue(undefined), + resetSubagentEnabled: vi.fn().mockResolvedValue(undefined), + getMcpStatus: vi.fn().mockImplementation((...args: unknown[]) => { + calls.getMcpStatus.push(args); + return Promise.resolve([]); + }), + setMcpDisabled: vi.fn().mockResolvedValue(undefined), + resetMcpDisabled: vi.fn().mockResolvedValue(undefined), + createMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { + calls.createMcpServer.push(args); + return Promise.resolve(undefined); + }), + updateMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { + calls.updateMcpServer.push(args); + return Promise.resolve(undefined); + }), + deleteMcpServer: vi.fn().mockImplementation((...args: unknown[]) => { + calls.deleteMcpServer.push(args); + return Promise.resolve(undefined); + }), + listSkills: vi.fn().mockResolvedValue([]), + toggleSkill: vi.fn().mockImplementation((...args: unknown[]) => { + calls.toggleSkill.push(args); + return Promise.resolve(undefined); + }), + listAgents: vi.fn().mockImplementation((...args: unknown[]) => { + calls.listAgents.push(args); + return Promise.resolve([]); + }), + createAgent: vi.fn().mockImplementation((...args: unknown[]) => { + calls.createAgent.push(args); + return Promise.resolve(undefined); + }), + updateAgent: vi.fn().mockImplementation((...args: unknown[]) => { + calls.updateAgent.push(args); + return Promise.resolve(undefined); + }), + deleteAgent: vi.fn().mockImplementation((...args: unknown[]) => { + calls.deleteAgent.push(args); + return Promise.resolve(undefined); + }), + setAgentDisabled: vi.fn().mockImplementation((...args: unknown[]) => { + calls.setAgentDisabled.push(args); + return Promise.resolve(undefined); + }), + resetAgentDisabled: vi.fn().mockResolvedValue(undefined), + listHooks: vi.fn().mockImplementation((...args: unknown[]) => { + calls.listHooks.push(args); + return Promise.resolve([]); + }), + setHookDisabled: vi.fn().mockImplementation((...args: unknown[]) => { + calls.setHookDisabled.push(args); + return Promise.resolve(undefined); + }), + resetHookDisabled: vi.fn().mockResolvedValue(undefined), + createHook: vi.fn().mockImplementation((...args: unknown[]) => { + calls.createHook.push(args); + return Promise.resolve(undefined); + }), + updateHook: vi.fn().mockImplementation((...args: unknown[]) => { + calls.updateHook.push(args); + return Promise.resolve(undefined); + }), + deleteHook: vi.fn().mockImplementation((...args: unknown[]) => { + calls.deleteHook.push(args); + return Promise.resolve(undefined); + }), + getGlobalPermissionMode: vi.fn().mockResolvedValue('default'), + setGlobalPermissionMode: vi.fn().mockResolvedValue(undefined), + }; +} + +vi.mock('../../src/client/direct/settings.js', () => ({ + createDirectSettingsClient: () => makeMockSettings(), +})); + +const { createDirectClient } = await import('../../src/client/direct.js'); + +describe('AgentClient SDK - unified cwd forwarding', () => { + let client: Awaited>; + + beforeEach(async () => { + for (const key of Object.keys(calls)) calls[key] = []; + const rt = ManagedRuntime.make(TestLayer); + client = await createDirectClient(noopLlm, rt); + }); + + describe('getSubagentEnabled - explicit cwd', () => { + it('forwards project cwd from query arg', async () => { + await client.getSubagentEnabled({ cwd: '/my-project' }); + expect(calls.getSubagentEnabled).toEqual([[{ cwd: '/my-project' }]]); + }); + + it('forwards empty cwd (= global) from query arg', async () => { + await client.getSubagentEnabled({ cwd: '' }); + expect(calls.getSubagentEnabled).toEqual([[{ cwd: '' }]]); + }); + }); + + describe('getMcpStatus - explicit cwd', () => { + it('forwards project cwd from query arg', async () => { + await client.getMcpStatus({ cwd: '/my-project' }); + expect(calls.getMcpStatus).toEqual([[{ cwd: '/my-project' }]]); + }); + + it('forwards empty cwd (= global) from query arg', async () => { + await client.getMcpStatus({ cwd: '' }); + expect(calls.getMcpStatus).toEqual([[{ cwd: '' }]]); + }); + }); + + describe('createMcpServer - explicit cwd', () => { + it('forwards cwd as second arg, not via closure', async () => { + await client.createMcpServer({ name: 'srv', command: 'npx' } as any, { + cwd: '/my-project', + }); + expect(calls.createMcpServer).toEqual([ + [{ cwd: '/my-project', server: { name: 'srv', command: 'npx' } }], + ]); + }); + }); + + describe('updateMcpServer - explicit cwd', () => { + it('forwards cwd as third arg', async () => { + await client.updateMcpServer('srv', { name: 'srv', command: 'npx' } as any, { + cwd: '/my-project', + }); + expect(calls.updateMcpServer).toEqual([ + [{ cwd: '/my-project', name: 'srv', server: { name: 'srv', command: 'npx' } }], + ]); + }); + }); + + describe('deleteMcpServer - explicit cwd', () => { + it('forwards cwd as second arg', async () => { + await client.deleteMcpServer('srv', { cwd: '/my-project' }); + expect(calls.deleteMcpServer).toEqual([[{ cwd: '/my-project', name: 'srv' }]]); + }); + }); + + describe('listAgents - explicit cwd', () => { + it('forwards cwd from query', async () => { + await client.listAgents({ cwd: '/my-project' }); + expect(calls.listAgents).toEqual([[{ cwd: '/my-project' }]]); + }); + }); + + describe('createAgent - explicit cwd', () => { + it('forwards cwd as second arg', async () => { + const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; + await client.createAgent(profile as any, { cwd: '/my-project' }); + expect(calls.createAgent).toEqual([[{ cwd: '/my-project', profile }]]); + }); + + it('different cwds for the same agent name go to different settings calls', async () => { + const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; + await client.createAgent(profile as any, { cwd: '/project-a' }); + await client.createAgent(profile as any, { cwd: '/project-b' }); + expect(calls.createAgent).toEqual([ + [{ cwd: '/project-a', profile }], + [{ cwd: '/project-b', profile }], + ]); + }); + }); + + describe('updateAgent - explicit cwd', () => { + it('forwards cwd as third arg', async () => { + const profile = { name: 'a1', description: 'd', systemPrompt: 'sp' }; + await client.updateAgent('a1', profile as any, { cwd: '/my-project' }); + expect(calls.updateAgent).toEqual([[{ cwd: '/my-project', name: 'a1', profile }]]); + }); + }); + + describe('deleteAgent - explicit cwd', () => { + it('forwards cwd as second arg', async () => { + await client.deleteAgent('a1', { cwd: '/my-project' }); + expect(calls.deleteAgent).toEqual([[{ cwd: '/my-project', name: 'a1' }]]); + }); + }); + + describe('listHooks - explicit cwd', () => { + it('forwards cwd from query', async () => { + await client.listHooks({ cwd: '/my-project' }); + expect(calls.listHooks).toEqual([[{ cwd: '/my-project' }]]); + }); + }); + + describe('createHook - explicit cwd', () => { + it('forwards cwd as second arg', async () => { + const hook = { + name: 'h1', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }; + await client.createHook(hook as any, { cwd: '/my-project' }); + expect(calls.createHook).toEqual([[{ cwd: '/my-project', hook }]]); + }); + }); + + describe('updateHook - explicit cwd', () => { + it('forwards cwd as third arg', async () => { + const hook = { + name: 'h1', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }; + await client.updateHook('h1', hook as any, { cwd: '/my-project' }); + expect(calls.updateHook).toEqual([[{ cwd: '/my-project', name: 'h1', hook }]]); + }); + }); + + describe('deleteHook - explicit cwd', () => { + it('forwards cwd as second arg', async () => { + await client.deleteHook('h1', { cwd: '/my-project' }); + expect(calls.deleteHook).toEqual([[{ cwd: '/my-project', name: 'h1' }]]); + }); + }); +}); + +describe('AgentClient SDK - body-based methods still pass through', () => { + let client: Awaited>; + + beforeEach(async () => { + for (const key of Object.keys(calls)) calls[key] = []; + const rt = ManagedRuntime.make(TestLayer); + client = await createDirectClient(noopLlm, rt); + }); + + it('toggleSkill passes body with cwd unchanged', async () => { + await client.toggleSkill({ name: 's1', enabled: true, cwd: '/my-project' }); + expect(calls.toggleSkill).toEqual([[{ name: 's1', enabled: true, cwd: '/my-project' }]]); + }); + + it('setAgentDisabled passes body with cwd unchanged', async () => { + await client.setAgentDisabled({ name: 'a1', disabled: true, cwd: '/my-project' }); + expect(calls.setAgentDisabled).toEqual([[{ name: 'a1', disabled: true, cwd: '/my-project' }]]); + }); + + it('setHookDisabled passes body with cwd unchanged', async () => { + await client.setHookDisabled({ name: 'h1', disabled: true, cwd: '/my-project' }); + expect(calls.setHookDisabled).toEqual([[{ name: 'h1', disabled: true, cwd: '/my-project' }]]); + }); +}); diff --git a/packages/codingcode/test/client/direct.test.ts b/packages/codingcode/test/client/direct.test.ts index a8fdad33..77667a74 100644 --- a/packages/codingcode/test/client/direct.test.ts +++ b/packages/codingcode/test/client/direct.test.ts @@ -88,53 +88,7 @@ describe('createDirectClient model operations', () => { }); }); -describe('agentEventToStreamChunk - approval interleaving', () => { - it('yields approval_request chunks without blocking on subsequent events', async () => { - async function* source() { - yield { _tag: 'LlmChunk' as const, text: 'before' }; - yield { - _tag: 'ApprovalRequest' as const, - id: 'apr-1', - tool: 'bash', - args: { command: 'ls' }, - }; - yield { _tag: 'LlmChunk' as const, text: 'after' }; - yield { _tag: 'Done' as const, content: 'done' }; - } - - const chunks: any[] = []; - for await (const chunk of agentEventToStreamChunk(source())) { - chunks.push(chunk); - } - - expect(chunks[0]).toEqual({ type: 'text', text: 'before', messageId: 0 }); - expect(chunks[1]).toEqual({ - type: 'approval_request', - id: 'apr-1', - tool: 'bash', - args: { command: 'ls' }, - }); - expect(chunks[2]).toEqual({ type: 'text', text: 'after', messageId: 0 }); - expect(chunks[3]).toEqual({ type: 'done' }); - }); - - it('yields multiple sequential approval_request chunks', async () => { - async function* source() { - yield { _tag: 'ApprovalRequest' as const, id: 'apr-1', tool: 'bash', args: {} }; - yield { _tag: 'ApprovalRequest' as const, id: 'apr-2', tool: 'write_file', args: {} }; - yield { _tag: 'Done' as const, content: '' }; - } - - const chunks: any[] = []; - for await (const chunk of agentEventToStreamChunk(source())) { - chunks.push(chunk); - } - - expect(chunks[0]).toMatchObject({ type: 'approval_request', id: 'apr-1' }); - expect(chunks[1]).toMatchObject({ type: 'approval_request', id: 'apr-2' }); - expect(chunks[2]).toEqual({ type: 'done' }); - }); - +describe('agentEventToStreamChunk', () => { it('yields usage chunks', async () => { async function* source() { yield { _tag: 'Step' as const, step: 1, max: 10 }; diff --git a/packages/codingcode/test/client/direct/settings.test.ts b/packages/codingcode/test/client/direct/settings.test.ts index 6322c74f..8d6c935c 100644 --- a/packages/codingcode/test/client/direct/settings.test.ts +++ b/packages/codingcode/test/client/direct/settings.test.ts @@ -64,18 +64,29 @@ const rt = ManagedRuntime.make(TestLayer); vi.mock('../../../src/mcp/config.js', () => ({ loadMcpConfig: vi.fn().mockReturnValue([]), writeMcpConfig: vi.fn(), + loadGlobalMcpConfig: vi.fn().mockReturnValue([]), + writeGlobalMcpConfig: vi.fn(), resolveMcpDisabled: vi.fn().mockReturnValue(false), + resolveMcpConfig: vi.fn().mockReturnValue([]), + getGlobalMcpDisabledState: vi.fn().mockReturnValue(false), setGlobalMcpDisabledState: vi.fn(), setProjectMcpDisabledState: vi.fn(), resetProjectMcpDisabledState: vi.fn(), })); -vi.mock('../../../src/subagent/loader.js', () => ({ - loadAgentProfiles: vi.fn().mockReturnValue([]), - writeAgentProfile: vi.fn(), - updateAgentProfile: vi.fn(), - deleteAgentProfile: vi.fn(), -})); +vi.mock('../../../src/subagent/loader.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + loadAgentProfiles: vi.fn().mockReturnValue([]), + writeAgentProfile: vi.fn(), + updateAgentProfile: vi.fn(), + deleteAgentProfile: vi.fn().mockImplementation(actual.deleteAgentProfile), + loadGlobalAgentProfiles: vi.fn().mockReturnValue([]), + writeGlobalAgentProfile: vi.fn(), + updateGlobalAgentProfile: vi.fn(), + deleteGlobalAgentProfile: vi.fn(), + }; +}); vi.mock('../../../src/subagent/registry.js', () => ({ EXPLORE_PROFILE: { @@ -85,11 +96,19 @@ vi.mock('../../../src/subagent/registry.js', () => ({ readonly: true, maxSteps: 30, }, + PLAN_PROFILE: { + name: 'plan', + description: 'Plan', + tools: ['read_file'], + readonly: true, + maxSteps: 30, + }, setSubagentEnabledState: vi.fn(), resolveSubagentEnabled: vi.fn().mockReturnValue(true), getProjectSubagentEnabledState: vi.fn().mockReturnValue(undefined), setProjectSubagentEnabledState: vi.fn(), resetProjectSubagentEnabledState: vi.fn(), + getGlobalAgentDisabledState: vi.fn().mockReturnValue(false), setGlobalAgentDisabledState: vi.fn(), setProjectAgentDisabledState: vi.fn(), resetProjectAgentDisabledState: vi.fn(), @@ -100,6 +119,9 @@ vi.mock('../../../src/subagent/registry.js', () => ({ vi.mock('../../../src/hooks/config.js', () => ({ loadHookConfigs: vi.fn().mockReturnValue([]), writeHookConfigs: vi.fn(), + loadGlobalHookConfigs: vi.fn().mockReturnValue([]), + writeGlobalHookConfigs: vi.fn(), + resolveHookConfigs: vi.fn().mockReturnValue([]), resolveHookDisabled: vi.fn().mockReturnValue(false), setGlobalHookDisabledState: vi.fn(), setProjectHookDisabledState: vi.fn(), @@ -275,3 +297,247 @@ describe('createDirectSettingsClient - updated signatures with cwd', () => { }); }); }); + +// ---- Merged view: agents / hooks / MCP source labeling ---- + +describe('createDirectSettingsClient - merged views with source labeling', () => { + let client: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + client = createDirectSettingsClient(rt); + }); + + describe('listAgents', () => { + it('global cwd returns builtin (explore+plan) + global custom, all source labeled', async () => { + const { loadGlobalAgentProfiles } = await import('../../../src/subagent/loader.js'); + vi.mocked(loadGlobalAgentProfiles).mockReturnValue([ + { name: 'g1', description: 'G1', tools: ['read_file'] }, + { name: 'g2', description: 'G2', tools: ['read_file'] }, + ] as any); + const result = await client.listAgents({ cwd: 'global' }); + expect(result).toHaveLength(4); + expect(result.find((a: any) => a.name === 'explore')?.source).toBe('builtin'); + expect(result.find((a: any) => a.name === 'plan')?.source).toBe('builtin'); + expect(result.find((a: any) => a.name === 'g1')?.source).toBe('global'); + expect(result.find((a: any) => a.name === 'g2')?.source).toBe('global'); + }); + + it('project cwd returns builtin + global (deduped) + project, project override labeled source=project', async () => { + const { loadGlobalAgentProfiles, loadAgentProfiles } = + await import('../../../src/subagent/loader.js'); + vi.mocked(loadGlobalAgentProfiles).mockReturnValue([ + { name: 'shared', description: 'shared', tools: ['read_file'] }, + { name: 'global-only', description: 'G only', tools: ['read_file'] }, + ] as any); + vi.mocked(loadAgentProfiles).mockReturnValue([ + { name: 'shared', description: 'shared override', tools: ['read_file'] }, + { name: 'project-only', description: 'P only', tools: ['read_file'] }, + ] as any); + const result = await client.listAgents({ cwd: '/my-project' }); + const byName = new Map(result.map((a: any) => [a.name, a])); + expect(byName.get('explore')?.source).toBe('builtin'); + expect(byName.get('plan')?.source).toBe('builtin'); + expect(byName.get('global-only')?.source).toBe('global'); + // Override case: project's copy wins, labeled source=project + expect(byName.get('shared')?.source).toBe('project'); + expect(byName.get('shared')?.hasProjectOverride).toBe(true); + expect(byName.get('project-only')?.source).toBe('project'); + }); + }); + + describe('listHooks', () => { + it('global cwd returns global hooks with source=global', async () => { + const { loadGlobalHookConfigs } = await import('../../../src/hooks/config.js'); + vi.mocked(loadGlobalHookConfigs).mockReturnValue([ + { + name: 'gh', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + ] as any); + const result = (await client.listHooks({ cwd: 'global' })) as any[]; + expect(result).toHaveLength(1); + expect(result[0].source).toBe('global'); + }); + + it('project cwd returns merged hooks; project override labeled source=project', async () => { + const { loadGlobalHookConfigs, loadHookConfigs, resolveHookConfigs } = + await import('../../../src/hooks/config.js'); + vi.mocked(loadGlobalHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + { + name: 'gh', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + ] as any); + vi.mocked(loadHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'sh', + enabled: true, + }, + { name: 'ph', point: 'tool.execute.after', type: 'decision', command: 'sh', enabled: true }, + ] as any); + vi.mocked(resolveHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'sh', + enabled: true, + }, + { + name: 'gh', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + { name: 'ph', point: 'tool.execute.after', type: 'decision', command: 'sh', enabled: true }, + ] as any); + const result = (await client.listHooks({ cwd: '/my-project' })) as any[]; + const byName = new Map(result.map((h) => [h.name, h])); + expect(byName.get('shared')?.source).toBe('project'); + expect(byName.get('shared')?.hasProjectOverride).toBe(true); + expect(byName.get('gh')?.source).toBe('global'); + expect(byName.get('ph')?.source).toBe('project'); + }); + }); + + describe('getMcpStatus', () => { + it('global cwd returns global servers with source=global', async () => { + const { loadGlobalMcpConfig, getGlobalMcpDisabledState } = + await import('../../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([{ name: 'gs', command: 'npx' }] as any); + vi.mocked(getGlobalMcpDisabledState).mockReturnValue(false); + const result = (await client.getMcpStatus({ cwd: 'global' })) as any[]; + expect(result).toHaveLength(1); + expect(result[0].source).toBe('global'); + expect(result[0].name).toBe('gs'); + }); + + it('project cwd returns merged servers; project override labeled source=project', async () => { + const { loadGlobalMcpConfig, loadMcpConfig } = await import('../../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([ + { name: 'shared', command: 'global-cmd' }, + { name: 'gs', command: 'npx' }, + ] as any); + vi.mocked(loadMcpConfig).mockReturnValue([ + { name: 'shared', command: 'project-cmd' }, + { name: 'ps', command: 'node' }, + ] as any); + const result = (await client.getMcpStatus({ cwd: '/my-project' })) as any[]; + const byName = new Map(result.map((s) => [s.name, s])); + expect(byName.get('shared')?.source).toBe('project'); + expect(byName.get('shared')?.hasProjectOverride).toBe(true); + expect(byName.get('gs')?.source).toBe('global'); + expect(byName.get('ps')?.source).toBe('project'); + }); + }); +}); + +// ---- CRUD: global vs project branching ---- + +describe('createDirectSettingsClient - CRUD branches on global cwd', () => { + let client: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + client = createDirectSettingsClient(rt); + }); + + it('createAgent on global cwd calls writeGlobalAgentProfile', async () => { + const { writeGlobalAgentProfile } = await import('../../../src/subagent/loader.js'); + await client.createAgent({ + cwd: 'global', + profile: { name: 'new', description: 'New', systemPrompt: 'sp' } as any, + }); + expect(writeGlobalAgentProfile).toHaveBeenCalledWith({ + name: 'new', + description: 'New', + systemPrompt: 'sp', + }); + }); + + it('updateAgent on global cwd calls updateGlobalAgentProfile', async () => { + const { loadGlobalAgentProfiles, updateGlobalAgentProfile } = + await import('../../../src/subagent/loader.js'); + vi.mocked(loadGlobalAgentProfiles).mockReturnValue([ + { name: 'old', description: 'Old' }, + ] as any); + await client.updateAgent({ + cwd: 'global', + name: 'old', + profile: { name: 'old', description: 'Updated' } as any, + }); + expect(updateGlobalAgentProfile).toHaveBeenCalledWith('old', { + name: 'old', + description: 'Updated', + }); + }); + + it('deleteAgent on global cwd calls deleteGlobalAgentProfile', async () => { + const { deleteGlobalAgentProfile } = await import('../../../src/subagent/loader.js'); + await client.deleteAgent({ cwd: 'global', name: 'g1' }); + expect(deleteGlobalAgentProfile).toHaveBeenCalledWith('g1'); + }); + + it('createMcpServer on global cwd calls writeGlobalMcpConfig', async () => { + const { loadGlobalMcpConfig, writeGlobalMcpConfig } = + await import('../../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([{ name: 'existing', command: 'npx' }]); + await client.createMcpServer({ + cwd: 'global', + server: { name: 'new', command: 'node' } as any, + }); + expect(writeGlobalMcpConfig).toHaveBeenCalledWith([ + { name: 'existing', command: 'npx' }, + { name: 'new', command: 'node' }, + ]); + }); + + it('deleteHook on global cwd calls writeGlobalHookConfigs', async () => { + const { loadGlobalHookConfigs, writeGlobalHookConfigs } = + await import('../../../src/hooks/config.js'); + vi.mocked(loadGlobalHookConfigs).mockReturnValue([ + { + name: 'g1', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + { + name: 'g2', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + ] as any); + await client.deleteHook({ cwd: 'global', name: 'g1' }); + expect(writeGlobalHookConfigs).toHaveBeenCalledWith([ + { + name: 'g2', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + ]); + }); +}); diff --git a/packages/codingcode/test/context/append-turn-end.test.ts b/packages/codingcode/test/context/append-turn-end.test.ts index f6eac1cf..a206702f 100644 --- a/packages/codingcode/test/context/append-turn-end.test.ts +++ b/packages/codingcode/test/context/append-turn-end.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { estimateTokensForContent } from '../../src/core/util.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; vi.mock('@codingcode/infra/config', () => ({ loadConfig: () => ({ @@ -22,7 +22,7 @@ vi.mock('@codingcode/infra/config', () => ({ }), })); -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); describe('appendTurnEnd', () => { const projectSlug = randomUUID(); @@ -30,13 +30,13 @@ describe('appendTurnEnd', () => { beforeEach(() => { sessionId = randomUUID(); - const sessionDir = join(PROJECT_BASE, projectSlug, 'sessions'); + const sessionDir = join(base.dir, projectSlug, 'sessions'); mkdirSync(sessionDir, { recursive: true }); writeFileSync(join(sessionDir, `${sessionId}.jsonl`), '', 'utf8'); }); afterEach(() => { - const dir = join(PROJECT_BASE, projectSlug); + const dir = join(base.dir, projectSlug); if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); }); diff --git a/packages/codingcode/test/context/budget-integration.test.ts b/packages/codingcode/test/context/budget-integration.test.ts index 21da6d0f..2401368e 100644 --- a/packages/codingcode/test/context/budget-integration.test.ts +++ b/packages/codingcode/test/context/budget-integration.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect, Layer } from 'effect'; import { ContextService } from '../../src/context/service.js'; import { SessionService } from '../../src/session/store.js'; import { LLMFactoryService } from '../../src/llm/factory.js'; import type { SessionEvent } from '../../src/session/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); const TestLayer = Layer.merge( SessionService.Default, @@ -40,7 +40,7 @@ describe('assemblePayload integration', () => { beforeEach(() => { sessionId = randomUUID(); - sessionDir = join(PROJECT_BASE, projectSlug, 'sessions'); + sessionDir = join(base.dir, projectSlug, 'sessions'); mkdirSync(sessionDir, { recursive: true }); jsonlPath = join(sessionDir, `${sessionId}.jsonl`); indexPath = join(sessionDir, `${sessionId}.index.json`); @@ -98,13 +98,13 @@ describe('assemblePayload integration', () => { }); afterEach(() => { - const dir = join(PROJECT_BASE, projectSlug); + const dir = join(base.dir, projectSlug); if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); }); it('returns messages and compactedEvents', async () => { const ctx = await getCtxService(); - const result = ctx.assemblePayload(sessionId, projectSlug, 128000); + const result = ctx.assemblePayload(jsonlPath, 128000); expect(result.messages.length).toBeGreaterThan(0); expect(Array.isArray(result.compactedEvents)).toBe(true); @@ -114,7 +114,7 @@ describe('assemblePayload integration', () => { it('returns currentTurnId from session index', async () => { const ctx = await getCtxService(); - const result = ctx.assemblePayload(sessionId, projectSlug, 128000); + const result = ctx.assemblePayload(jsonlPath, 128000); expect(result.currentTurnId).toBe(1); }); }); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index d4d504ca..d2acdff1 100644 --- a/packages/codingcode/test/context/compressor/behavior.test.ts +++ b/packages/codingcode/test/context/compressor/behavior.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect, Layer } from 'effect'; import { ContextService } from '../../../src/context/service.js'; @@ -13,8 +12,9 @@ import type { SessionIndex, SessionEvent, SummaryEvent } from '../../../src/sess import { filterForContext, buildContextMessages } from '../../../src/context/service.js'; import { readHistory } from '../../../src/session/file-ops.js'; import { estimateTokens } from '../../../src/core/util.js'; +import { useTempProjectBase } from '../../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); interface FixtureOptions { numTurns: number; @@ -26,7 +26,7 @@ interface FixtureOptions { function makeFixture(opts: FixtureOptions) { const sessionId = randomUUID(); const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug, 'sessions'); + const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); const indexPath = join(dir, `${sessionId}.index.json`); @@ -84,7 +84,7 @@ function makeFixture(opts: FixtureOptions) { } function cleanup(slug: string) { - const dir = join(PROJECT_BASE, slug); + const dir = join(base.dir, slug); if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); } @@ -145,7 +145,7 @@ describe('compressor behavior', () => { '## 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.sessionId, fx.slug, llm.modelInfo.maxTokens, llm); + await ctx.compactWithLLM(fx.transcriptPath, llm.modelInfo.maxTokens, llm); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries.length).toBe(1); expect(summaries[0]!.summaryText).toContain('### Goal'); @@ -160,7 +160,7 @@ describe('compressor behavior', () => { const fx = makeFixture({ numTurns: 5 }); try { const ctx = await getCtxService(); - const result = await ctx.compactWithLLM(fx.sessionId, fx.slug, 1000, null); + const result = await ctx.compactWithLLM(fx.transcriptPath, 1000, null); expect(result.didCompress).toBe(false); expect(result.messages).toBeUndefined(); const summaries = readSummaryEvents(fx.transcriptPath); @@ -179,7 +179,7 @@ describe('compressor behavior', () => { '## Compacted History\n\n### Goal\na\n\n### Instructions\nb\n\n### Discoveries\nc\n\n### Accomplished\nd\n\n### Relevant Files\ne' ); const ctx = await getCtxService(); - await ctx.compactWithLLM(fx.sessionId, fx.slug, llm.modelInfo.maxTokens, llm); + await ctx.compactWithLLM(fx.transcriptPath, llm.modelInfo.maxTokens, llm); const summaries = readSummaryEvents(fx.transcriptPath); expect(summaries).toHaveLength(1); @@ -204,8 +204,7 @@ describe('compressor behavior', () => { ); const ctx = await getCtxService(); const result = await ctx.compactWithLLM( - fx.sessionId, - fx.slug, + fx.transcriptPath, llm.modelInfo.maxTokens, llm ); diff --git a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts index 12ea8069..96bc3382 100644 --- a/packages/codingcode/test/context/compressor/compact-if-needed.test.ts +++ b/packages/codingcode/test/context/compressor/compact-if-needed.test.ts @@ -3,6 +3,9 @@ import { Effect, Layer } from 'effect'; import { ContextService } from '../../../src/context/service.js'; import { SessionService } from '../../../src/session/store.js'; import { LLMFactoryService } from '../../../src/llm/factory.js'; +import { useTempProjectBase } from '../../helpers/project-base.js'; + +useTempProjectBase(); const { mockLLM } = vi.hoisted(() => ({ mockLLM: { @@ -98,7 +101,7 @@ describe('compactIfNeeded', () => { it('returns didCompress=false when promptEstimate is below threshold', async () => { (estimateTokens as any).mockReturnValue(100); const ctx = await getCtxService(); - const result = await ctx.compactIfNeeded('s1', 'proj', [], 10000, null); + const result = await ctx.compactIfNeeded('/tmp/s1.jsonl', [], 10000, null); expect(result.didCompress).toBe(false); expect(result.released).toBe(0); expect(result.promptEstimate).toBe(100); @@ -107,7 +110,7 @@ describe('compactIfNeeded', () => { it('returns didCompress=false when promptEstimate equals threshold', async () => { (estimateTokens as any).mockReturnValue(5000); const ctx = await getCtxService(); - const result = await ctx.compactIfNeeded('s1', 'proj', [], 10000, null); + const result = await ctx.compactIfNeeded('/tmp/s1.jsonl', [], 10000, null); expect(result.didCompress).toBe(false); expect(result.released).toBe(0); }); @@ -117,8 +120,7 @@ describe('compactIfNeeded', () => { (estimateMessageTokens as any).mockReturnValue(50); const ctx = await getCtxService(); const result = await ctx.compactIfNeeded( - 's1', - 'proj', + '/tmp/s1.jsonl', [ { type: 'user', content: 'a'.repeat(200), turnId: 1 }, { type: 'assistant', content: 'b'.repeat(200), turnId: 1 }, @@ -141,7 +143,7 @@ describe('compactIfNeeded', () => { it('does not return restoredFiles field (removed)', async () => { (estimateTokens as any).mockReturnValue(10000); const ctx = await getCtxService(); - const result = await ctx.compactIfNeeded('s1', 'proj', [], 10000, null); + const result = await ctx.compactIfNeeded('/tmp/s1.jsonl', [], 10000, null); expect('restoredFiles' in result).toBe(false); }); }); diff --git a/packages/codingcode/test/helpers/cleanup-test-artifacts.ts b/packages/codingcode/test/helpers/cleanup-test-artifacts.ts new file mode 100644 index 00000000..3a103138 --- /dev/null +++ b/packages/codingcode/test/helpers/cleanup-test-artifacts.ts @@ -0,0 +1,30 @@ +import { afterAll } from 'vitest'; +import { existsSync, readdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const TEST_DIR_PATTERN = /^c-users-10116-appdata-local-temp-codingcode-test-/; + +/** + * Registers a process-wide afterAll that wipes any leftover test artifacts + * from the real `~/.codingcode/projects/` directory. Safety net for tests + * that don't use `useTempProjectBase`. + * + * Pattern matches encoded tmp cwd paths (e.g. on Windows: + * `c-users-10116-appdata-local-temp-codingcode-test-submit-plan-flow`). + */ +export function cleanupTestArtifacts(): void { + afterAll(() => { + const plansBase = join(homedir(), '.codingcode', 'projects'); + if (!existsSync(plansBase)) return; + try { + for (const entry of readdirSync(plansBase)) { + if (TEST_DIR_PATTERN.test(entry)) { + rmSync(join(plansBase, entry), { recursive: true, force: true }); + } + } + } catch { + // best-effort cleanup + } + }); +} diff --git a/packages/codingcode/test/helpers/project-base.ts b/packages/codingcode/test/helpers/project-base.ts new file mode 100644 index 00000000..0e116aac --- /dev/null +++ b/packages/codingcode/test/helpers/project-base.ts @@ -0,0 +1,40 @@ +import { mkdtempSync, rmSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach } from 'vitest'; +import { + setProjectBaseDir, + setProjectPlansBaseDir, + getProjectBaseDir, + getProjectPlansBaseDir, +} from '../../src/core/path.js'; + +export interface TempProjectBase { + readonly dir: string; + readonly plansDir: string; +} + +export function useTempProjectBase(prefix = 'codingcode-test-project-base-'): TempProjectBase { + let dir = ''; + let plansDir = ''; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), prefix)); + plansDir = join(dir, 'plans'); + mkdirSync(plansDir, { recursive: true }); + setProjectBaseDir(dir); + setProjectPlansBaseDir(plansDir); + }); + afterEach(() => { + setProjectBaseDir(undefined); + setProjectPlansBaseDir(undefined); + rmSync(dir, { recursive: true, force: true }); + }); + return { + get dir() { + return getProjectBaseDir(); + }, + get plansDir() { + return getProjectPlansBaseDir(); + }, + }; +} diff --git a/packages/codingcode/test/hooks/config-merge.test.ts b/packages/codingcode/test/hooks/config-merge.test.ts index 6298d490..cee1b2d1 100644 --- a/packages/codingcode/test/hooks/config-merge.test.ts +++ b/packages/codingcode/test/hooks/config-merge.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { mkdtempSync, mkdirSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { loadHookConfigs, writeHookConfigs, @@ -14,39 +14,25 @@ import { setProjectHookDisabledState, resetProjectHookDisabledState, resolveHookDisabled, + _setGlobalConfigDir, } from '../../src/hooks/config.js'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const TEST_PROJECT_DIR = join(__dirname, '..', '..', '..', 'test-fixture-hooks-merge'); -const TEST_PROJECT_CODINGCODE = join(TEST_PROJECT_DIR, '.codingcode'); -const TEST_GLOBAL_DIR = join( - __dirname, - '..', - '..', - '..', - 'test-fixture-global-hooks', - '.codingcode' -); +let projectDir: string; +let globalDir: string; describe('Hooks config merge', () => { beforeEach(() => { - if (existsSync(TEST_PROJECT_DIR)) rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); - mkdirSync(TEST_PROJECT_CODINGCODE, { recursive: true }); - if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'))) - rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'), { - recursive: true, - force: true, - }); - mkdirSync(TEST_GLOBAL_DIR, { recursive: true }); + projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-project-')); + globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-global-')); + mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); + mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); + _setGlobalConfigDir(globalDir); }); afterEach(() => { - if (existsSync(TEST_PROJECT_DIR)) rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); - if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'))) - rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'), { - recursive: true, - force: true, - }); + _setGlobalConfigDir(undefined); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); }); it('should merge global and project hooks, project overrides global', () => { @@ -86,9 +72,9 @@ describe('Hooks config merge', () => { enabled: true, }, ]; - writeHookConfigs(TEST_PROJECT_DIR, projectHooks); + writeHookConfigs(projectDir, projectHooks); - const merged = resolveHookConfigs(TEST_PROJECT_DIR); + const merged = resolveHookConfigs(projectDir); expect(merged).toHaveLength(3); @@ -110,12 +96,18 @@ describe('Hook disabled state', () => { const testHook = '__test_hook__'; beforeEach(() => { - mkdirSync(TEST_PROJECT_CODINGCODE, { recursive: true }); + projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-project-')); + globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-hooks-merge-global-')); + mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); + mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); + _setGlobalConfigDir(globalDir); setGlobalHookDisabledState(testHook, false); }); afterEach(() => { - rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + _setGlobalConfigDir(undefined); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); setGlobalHookDisabledState(testHook, false); }); @@ -129,34 +121,34 @@ describe('Hook disabled state', () => { }); it('should return undefined when project has no config', () => { - expect(getProjectHookDisabledState(TEST_PROJECT_DIR, testHook)).toBe(undefined); + expect(getProjectHookDisabledState(projectDir, testHook)).toBe(undefined); }); it('should persist project-level disabled state', () => { - setProjectHookDisabledState(TEST_PROJECT_DIR, testHook, true); - expect(getProjectHookDisabledState(TEST_PROJECT_DIR, testHook)).toBe(true); + setProjectHookDisabledState(projectDir, testHook, true); + expect(getProjectHookDisabledState(projectDir, testHook)).toBe(true); }); it('should reset project-level disabled state', () => { - setProjectHookDisabledState(TEST_PROJECT_DIR, testHook, true); - resetProjectHookDisabledState(TEST_PROJECT_DIR, testHook); - expect(getProjectHookDisabledState(TEST_PROJECT_DIR, testHook)).toBe(undefined); + setProjectHookDisabledState(projectDir, testHook, true); + resetProjectHookDisabledState(projectDir, testHook); + expect(getProjectHookDisabledState(projectDir, testHook)).toBe(undefined); }); it('resolveHookDisabled should use project-level when set', () => { setGlobalHookDisabledState(testHook, false); - setProjectHookDisabledState(TEST_PROJECT_DIR, testHook, true); - expect(resolveHookDisabled(TEST_PROJECT_DIR, testHook)).toBe(true); + setProjectHookDisabledState(projectDir, testHook, true); + expect(resolveHookDisabled(projectDir, testHook)).toBe(true); }); it('resolveHookDisabled should fall back to global when project not set', () => { setGlobalHookDisabledState(testHook, true); - expect(resolveHookDisabled(TEST_PROJECT_DIR, testHook)).toBe(true); + expect(resolveHookDisabled(projectDir, testHook)).toBe(true); }); it('resolveHookDisabled should use project-level enabled over global disabled', () => { setGlobalHookDisabledState(testHook, true); - setProjectHookDisabledState(TEST_PROJECT_DIR, testHook, false); - expect(resolveHookDisabled(TEST_PROJECT_DIR, testHook)).toBe(false); + setProjectHookDisabledState(projectDir, testHook, false); + expect(resolveHookDisabled(projectDir, testHook)).toBe(false); }); }); diff --git a/packages/codingcode/test/hooks/config.test.ts b/packages/codingcode/test/hooks/config.test.ts index 70114e41..a0143fa0 100644 --- a/packages/codingcode/test/hooks/config.test.ts +++ b/packages/codingcode/test/hooks/config.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; import { join, resolve } from 'path'; +import { tmpdir } from 'os'; import { parse as parseYaml } from 'yaml'; import { loadHookConfigs, writeHookConfigs } from '../../src/hooks/config.js'; -const testDir = resolve(process.cwd(), '.test-hooks-config'); +const testDir = resolve(tmpdir(), 'codingcode-test-hooks-config'); describe('loadHookConfigs', () => { beforeEach(() => { diff --git a/packages/codingcode/test/hooks/registry.test.ts b/packages/codingcode/test/hooks/registry.test.ts index b4a32fef..0cf3b98b 100644 --- a/packages/codingcode/test/hooks/registry.test.ts +++ b/packages/codingcode/test/hooks/registry.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Effect } from 'effect'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join, resolve } from 'path'; +import { tmpdir } from 'os'; import { HookService } from '../../src/hooks/registry.js'; const AppLayer = HookService.Default; @@ -123,10 +124,49 @@ describe('HookService', () => { const result = await runWithLayer(program); expect(result?.decision).toBe('continue'); }); + + it('runs Effect-returning observers in the emit fiber context (yield* services)', async () => { + // The whole reason ObserverHandler is allowed to return an Effect: the + // observer should be able to yield* services from the caller's fiber + // (e.g. HookService) without resorting to Effect.runFork / default + // runtime. This test pins that contract. + const sideEffect: { ran: boolean; usedService: boolean } = { + ran: false, + usedService: false, + }; + + const observer: import('../../src/hooks/types.js').ObserverHandler = (payload) => + Effect.gen(function* () { + // yield* in the observer body — this is the contract under test. + // If emit runs the observer on a default runtime (no services), + // this line throws "Service not found: HookService". + const hooks = yield* HookService; + sideEffect.ran = true; + sideEffect.usedService = typeof hooks.register === 'function'; + void payload; + }); + + const program = Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.register('tool.execute.after', observer, { source: 'system' }); + yield* hooks.emit('tool.execute.after', { + toolName: 'submit_plan', + sessionId: 'sess-1', + projectPath: '/proj', + args: { plan_content: 'x' }, + result: { output: 'Plan written to /x' }, + }); + return sideEffect; + }); + + const result = await runWithLayer(program); + expect(result.ran).toBe(true); + expect(result.usedService).toBe(true); + }); }); describe('HookService.reloadUserHooks', () => { - const testDir = resolve(process.cwd(), '.test-hooks-reload'); + const testDir = resolve(tmpdir(), 'codingcode-test-hooks-reload'); beforeEach(() => { if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); diff --git a/packages/codingcode/test/layer/system-hook-layer.test.ts b/packages/codingcode/test/layer/system-hook-layer.test.ts new file mode 100644 index 00000000..a88515b5 --- /dev/null +++ b/packages/codingcode/test/layer/system-hook-layer.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { Effect } from 'effect'; +import { HookService } from '../../src/hooks/registry.js'; +import { SystemHookLayer } from '../../src/layer.js'; +import { markSessionPlanMode, clearPlanModeSession } from '../../src/plan/index.js'; + +describe('SystemHookLayer', () => { + it('builds without "Service not found: HookService" (regression: was a self-referential Layer.effect)', async () => { + // The previous implementation used `Layer.effect(HookService, body-yielding-HookService)` + // which Effect-TS does NOT support as a self-referential layer: the runtime + // does not place a placeholder HookService in the environment while + // building the layer, so the body's first `yield* HookService` would Die + // with "Service not found: HookService". This test would fail to even + // build the layer before the fix. + const program = Effect.gen(function* () { + const hooks = yield* HookService; + // touch the service to ensure it's resolvable from the build's output + return typeof hooks.register; + }); + + const result = await Effect.runPromise(program.pipe(Effect.provide(SystemHookLayer) as any)); + expect(result).toBe('function'); + }); + + it('registers the remaining plan-mode system hooks', async () => { + // After the plan approval decoupling: + // - planModeGateHook stays — it's the right abstraction for tool-allow + // policy. Registered on tool.approval.pre with priority -1000. + // - afterPlanSubmittedObserver REMOVED — plan.ready is now emitted by + // agentLoop on turn-end, not by an observer on tool.execute.after. + // - planApprovalHook REMOVED — submit_plan tool handles its own 3-option + // approval via ApprovalWaitService directly. + // - planSubagentWhitelistHook REMOVED — now an inline function + // (checkSubagentAllowedInPlanMode) called by dispatch_agent. + const program = Effect.gen(function* () { + const hooks = yield* HookService; + + // (1) planModeGateHook denies write tools in plan mode + markSessionPlanMode('s', true); + const denied = yield* hooks.emitDecision('tool.approval.pre', { + toolName: 'write_file', + args: { path: '/x' }, + sessionId: 's', + projectPath: '/p', + }); + expect(denied).not.toBeNull(); + expect(denied?.decision).toBe('deny'); + expect(denied?.reason).toMatch(/plan mode/i); + clearPlanModeSession('s'); + + // (2) planModeGateHook lets submit_plan through + markSessionPlanMode('s', true); + const allowed = yield* hooks.emitDecision('tool.approval.pre', { + toolName: 'submit_plan', + args: { plan_content: '## plan' }, + sessionId: 's', + projectPath: '/p', + }); + expect(allowed).toBeNull(); + clearPlanModeSession('s'); + + return true; + }); + + await Effect.runPromise(program.pipe(Effect.provide(SystemHookLayer) as any)); + }); +}); diff --git a/packages/codingcode/test/mcp/config-merge.test.ts b/packages/codingcode/test/mcp/config-merge.test.ts index df408723..0a7d4942 100644 --- a/packages/codingcode/test/mcp/config-merge.test.ts +++ b/packages/codingcode/test/mcp/config-merge.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { loadMcpConfig, writeMcpConfig, @@ -17,35 +17,22 @@ import { _setGlobalConfigDir, } from '../../src/mcp/config.js'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const TEST_PROJECT_DIR = join(__dirname, '..', '..', '..', 'test-fixture-mcp-merge'); -const TEST_PROJECT_CODINGCODE = join(TEST_PROJECT_DIR, '.codingcode'); - -// 模拟全局目录 -const TEST_GLOBAL_DIR = join(__dirname, '..', '..', '..', 'test-fixture-global', '.codingcode'); -const TEST_GLOBAL_PARENT = join(__dirname, '..', '..', '..', 'test-fixture-global'); +let projectDir: string; +let globalDir: string; describe('MCP config merge', () => { beforeEach(() => { - _setGlobalConfigDir(TEST_GLOBAL_PARENT); - if (existsSync(TEST_PROJECT_DIR)) rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); - mkdirSync(TEST_PROJECT_CODINGCODE, { recursive: true }); - if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global'))) - rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global'), { - recursive: true, - force: true, - }); - mkdirSync(TEST_GLOBAL_DIR, { recursive: true }); + projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-project-')); + globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-global-')); + mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); + mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); + _setGlobalConfigDir(globalDir); }); afterEach(() => { _setGlobalConfigDir(undefined); - if (existsSync(TEST_PROJECT_DIR)) rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); - if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global'))) - rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global'), { - recursive: true, - force: true, - }); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); }); it('should merge global and project configs, project overrides global', () => { @@ -68,7 +55,7 @@ describe('MCP config merge', () => { ]); // Write project config - writeMcpConfig(TEST_PROJECT_DIR, [ + writeMcpConfig(projectDir, [ { name: 'shared-server', transport: 'stdio', @@ -85,7 +72,7 @@ describe('MCP config merge', () => { } as any, ]); - const merged = resolveMcpConfig(TEST_PROJECT_DIR); + const merged = resolveMcpConfig(projectDir); // Should have 3 servers: global-server, shared-server (project override), project-server expect(merged).toHaveLength(3); @@ -104,7 +91,7 @@ describe('MCP config merge', () => { }); it('should return only project config when no global config', () => { - writeMcpConfig(TEST_PROJECT_DIR, [ + writeMcpConfig(projectDir, [ { name: 'project-server', transport: 'stdio', @@ -114,7 +101,7 @@ describe('MCP config merge', () => { } as any, ]); - const merged = resolveMcpConfig(TEST_PROJECT_DIR); + const merged = resolveMcpConfig(projectDir); expect(merged).toHaveLength(1); expect(merged[0]!.name).toBe('project-server'); }); @@ -130,7 +117,7 @@ describe('MCP config merge', () => { } as any, ]); - const merged = resolveMcpConfig(TEST_PROJECT_DIR); + const merged = resolveMcpConfig(projectDir); expect(merged).toHaveLength(1); expect(merged[0]!.name).toBe('global-server'); }); @@ -140,16 +127,18 @@ describe('MCP disabled state', () => { const testServer = '__test_mcp_server__'; beforeEach(() => { - _setGlobalConfigDir(TEST_GLOBAL_PARENT); - mkdirSync(TEST_PROJECT_CODINGCODE, { recursive: true }); - if (existsSync(TEST_GLOBAL_DIR)) rmSync(TEST_GLOBAL_DIR, { recursive: true, force: true }); - mkdirSync(TEST_GLOBAL_DIR, { recursive: true }); + projectDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-project-')); + globalDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-merge-global-')); + mkdirSync(join(projectDir, '.codingcode'), { recursive: true }); + mkdirSync(join(globalDir, '.codingcode'), { recursive: true }); + _setGlobalConfigDir(globalDir); setGlobalMcpDisabledState(testServer, false); }); afterEach(() => { _setGlobalConfigDir(undefined); - rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); setGlobalMcpDisabledState(testServer, false); }); @@ -163,34 +152,34 @@ describe('MCP disabled state', () => { }); it('should return undefined when project has no config', () => { - expect(getProjectMcpDisabledState(TEST_PROJECT_DIR, testServer)).toBe(undefined); + expect(getProjectMcpDisabledState(projectDir, testServer)).toBe(undefined); }); it('should persist project-level disabled state', () => { - setProjectMcpDisabledState(TEST_PROJECT_DIR, testServer, true); - expect(getProjectMcpDisabledState(TEST_PROJECT_DIR, testServer)).toBe(true); + setProjectMcpDisabledState(projectDir, testServer, true); + expect(getProjectMcpDisabledState(projectDir, testServer)).toBe(true); }); it('should reset project-level disabled state', () => { - setProjectMcpDisabledState(TEST_PROJECT_DIR, testServer, true); - resetProjectMcpDisabledState(TEST_PROJECT_DIR, testServer); - expect(getProjectMcpDisabledState(TEST_PROJECT_DIR, testServer)).toBe(undefined); + setProjectMcpDisabledState(projectDir, testServer, true); + resetProjectMcpDisabledState(projectDir, testServer); + expect(getProjectMcpDisabledState(projectDir, testServer)).toBe(undefined); }); it('resolveMcpDisabled should use project-level when set', () => { setGlobalMcpDisabledState(testServer, false); - setProjectMcpDisabledState(TEST_PROJECT_DIR, testServer, true); - expect(resolveMcpDisabled(TEST_PROJECT_DIR, testServer)).toBe(true); + setProjectMcpDisabledState(projectDir, testServer, true); + expect(resolveMcpDisabled(projectDir, testServer)).toBe(true); }); it('resolveMcpDisabled should fall back to global when project not set', () => { setGlobalMcpDisabledState(testServer, true); - expect(resolveMcpDisabled(TEST_PROJECT_DIR, testServer)).toBe(true); + expect(resolveMcpDisabled(projectDir, testServer)).toBe(true); }); it('resolveMcpDisabled should use project-level enabled over global disabled', () => { setGlobalMcpDisabledState(testServer, true); - setProjectMcpDisabledState(TEST_PROJECT_DIR, testServer, false); - expect(resolveMcpDisabled(TEST_PROJECT_DIR, testServer)).toBe(false); + setProjectMcpDisabledState(projectDir, testServer, false); + expect(resolveMcpDisabled(projectDir, testServer)).toBe(false); }); }); diff --git a/packages/codingcode/test/mcp/config.test.ts b/packages/codingcode/test/mcp/config.test.ts index e54b41d1..9a38e7f0 100644 --- a/packages/codingcode/test/mcp/config.test.ts +++ b/packages/codingcode/test/mcp/config.test.ts @@ -1,33 +1,32 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; import { loadMcpConfig, writeMcpConfig } from '../../src/mcp/config.js'; import { parse as parseYaml } from 'yaml'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const TEST_CODINGCODE_DIR = join(__dirname, '..', '..', '..', '.codingcode'); +let projectRoot: string; +let testDir: string; describe('loadMcpConfig', () => { beforeEach(() => { - if (existsSync(TEST_CODINGCODE_DIR)) - rmSync(TEST_CODINGCODE_DIR, { recursive: true, force: true }); - mkdirSync(TEST_CODINGCODE_DIR, { recursive: true }); + testDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-config-')); + projectRoot = testDir; + mkdirSync(join(projectRoot, '.codingcode'), { recursive: true }); }); afterEach(() => { - if (existsSync(TEST_CODINGCODE_DIR)) - rmSync(TEST_CODINGCODE_DIR, { recursive: true, force: true }); + rmSync(testDir, { recursive: true, force: true }); }); it('should return empty array when no config exists', () => { - const result = loadMcpConfig(join(__dirname, '..', '..', '..')); + const result = loadMcpConfig(projectRoot); expect(result).toEqual([]); }); it('should load stdio server config from mcp.yaml', () => { writeFileSync( - join(TEST_CODINGCODE_DIR, 'mcp.yaml'), + join(projectRoot, '.codingcode', 'mcp.yaml'), `servers: - name: test-stdio command: npx @@ -35,7 +34,7 @@ describe('loadMcpConfig', () => { ` ); - const configs = loadMcpConfig(join(__dirname, '..', '..', '..')); + const configs = loadMcpConfig(projectRoot); expect(configs).toHaveLength(1); expect(configs[0]!.name).toBe('test-stdio'); expect(configs[0]!.command).toBe('npx'); @@ -44,7 +43,7 @@ describe('loadMcpConfig', () => { it('should load SSE server config from mcp.yaml', () => { writeFileSync( - join(TEST_CODINGCODE_DIR, 'mcp.yaml'), + join(projectRoot, '.codingcode', 'mcp.yaml'), `servers: - name: test-sse url: "https://mcp.example.com/sse" @@ -54,7 +53,7 @@ describe('loadMcpConfig', () => { ` ); - const configs = loadMcpConfig(join(__dirname, '..', '..', '..')); + const configs = loadMcpConfig(projectRoot); expect(configs).toHaveLength(1); expect(configs[0]!.name).toBe('test-sse'); expect(configs[0]!.url).toBe('https://mcp.example.com/sse'); @@ -64,7 +63,7 @@ describe('loadMcpConfig', () => { it('should resolve ${ENV_VAR} placeholders', () => { process.env.TEST_TOKEN = 'resolved-token'; writeFileSync( - join(TEST_CODINGCODE_DIR, 'mcp.yaml'), + join(projectRoot, '.codingcode', 'mcp.yaml'), `servers: - name: test-env url: "https://api.example.com" @@ -73,7 +72,7 @@ describe('loadMcpConfig', () => { ` ); - const configs = loadMcpConfig(join(__dirname, '..', '..', '..')); + const configs = loadMcpConfig(projectRoot); expect(configs[0]!.headers!.Authorization).toBe('Bearer resolved-token'); delete process.env.TEST_TOKEN; @@ -81,7 +80,7 @@ describe('loadMcpConfig', () => { it('should handle unresolved env var as empty string', () => { writeFileSync( - join(TEST_CODINGCODE_DIR, 'mcp.yaml'), + join(projectRoot, '.codingcode', 'mcp.yaml'), `servers: - name: test-missing url: "https://api.example.com" @@ -90,25 +89,23 @@ describe('loadMcpConfig', () => { ` ); - const configs = loadMcpConfig(join(__dirname, '..', '..', '..')); + const configs = loadMcpConfig(projectRoot); expect(configs[0]!.headers!.Authorization).toBeUndefined(); }); }); describe('writeMcpConfig', () => { beforeEach(() => { - if (existsSync(TEST_CODINGCODE_DIR)) - rmSync(TEST_CODINGCODE_DIR, { recursive: true, force: true }); - mkdirSync(TEST_CODINGCODE_DIR, { recursive: true }); + testDir = mkdtempSync(join(tmpdir(), 'codingcode-test-mcp-config-')); + projectRoot = testDir; + mkdirSync(join(projectRoot, '.codingcode'), { recursive: true }); }); afterEach(() => { - if (existsSync(TEST_CODINGCODE_DIR)) - rmSync(TEST_CODINGCODE_DIR, { recursive: true, force: true }); + rmSync(testDir, { recursive: true, force: true }); }); it('should write and read back servers correctly', () => { - const projectRoot = join(__dirname, '..', '..', '..'); const servers = [{ name: 'test-server', command: 'npx', args: ['-y', 'test'], concurrency: 5 }]; writeMcpConfig(projectRoot, servers); const result = loadMcpConfig(projectRoot); @@ -118,7 +115,6 @@ describe('writeMcpConfig', () => { }); it('should overwrite existing servers list', () => { - const projectRoot = join(__dirname, '..', '..', '..'); writeMcpConfig(projectRoot, [{ name: 'old', command: 'echo' }]); writeMcpConfig(projectRoot, [{ name: 'new', command: 'ls' }]); const result = loadMcpConfig(projectRoot); @@ -127,9 +123,9 @@ describe('writeMcpConfig', () => { }); it('should preserve other top-level keys in the yaml', () => { - const p = join(TEST_CODINGCODE_DIR, 'mcp.yaml'); + const p = join(projectRoot, '.codingcode', 'mcp.yaml'); writeFileSync(p, 'otherKey: value\nservers: []\n'); - writeMcpConfig(join(__dirname, '..', '..', '..'), [{ name: 'srv', command: 'echo' }]); + writeMcpConfig(projectRoot, [{ name: 'srv', command: 'echo' }]); const raw = JSON.parse(JSON.stringify(parseYaml(readFileSync(p, 'utf8')))); expect(raw.otherKey).toBe('value'); expect(raw.servers).toHaveLength(1); diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index a14301d8..ec3e15d2 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -198,14 +198,16 @@ vi.mock('../src/runtime/project-runtime.js', () => ({ allowToolSearch: true, allowDeferredTools: false, })), - setSessionProfile: vi.fn(), + setSessionProfile: vi.fn(() => Effect.void), + restoreSessionProfile: vi.fn(() => Effect.void), getSessionProfile: vi.fn(() => undefined), disposeSession: vi.fn(() => Effect.void), disposeProject: vi.fn(() => Effect.void), })); const MockSessionLayer = Layer.succeed(SessionService, { - create: (_cwd: string, _model: string) => Effect.succeed({ ...mockState }), + create: (_cwd: string, _options: any) => Effect.succeed({ ...mockState }), + load: (_cwd: string, _sid: string) => Effect.succeed({ ...mockState }), recordUser: () => Effect.succeed({ type: 'user' as const, @@ -321,8 +323,23 @@ const AllDeps = Layer.mergeAll( const TestLayer = Layer.mergeAll(AgentLayer, AllDeps); describe('sendMessage stream', () => { + async function setupSession(): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.create('/tmp/test', { + model: 'mock-model', + mode: 'build', + permissionMode: 'default', + }); + return state.sessionId; + }).pipe(Effect.provide(TestLayer) as any) + ); + } + it('should yield AgentEvent chunks from LLM', async () => { - const program = sendMessage(undefined, 'hi', '/tmp/test', mockLlm); + const sessionId = await setupSession(); + const program = sendMessage(sessionId, 'hi', '/tmp/test', mockLlm); const { stream } = (await Effect.runPromise( program.pipe(Effect.provide(TestLayer) as any) )) as any; @@ -337,7 +354,8 @@ describe('sendMessage stream', () => { }); it('should not return empty event stream for normal LLM response', async () => { - const program = sendMessage(undefined, 'hi', '/tmp/test', mockLlm); + const sessionId = await setupSession(); + const program = sendMessage(sessionId, 'hi', '/tmp/test', mockLlm); const { stream } = (await Effect.runPromise( program.pipe(Effect.provide(TestLayer) as any) )) as any; diff --git a/packages/codingcode/test/plan/active-sessions.test.ts b/packages/codingcode/test/plan/active-sessions.test.ts new file mode 100644 index 00000000..23154446 --- /dev/null +++ b/packages/codingcode/test/plan/active-sessions.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + markSessionPlanMode, + isSessionInPlanMode, + clearPlanModeSession, +} from '../../src/plan/index.js'; + +describe('plan/active-sessions side channel', () => { + beforeEach(() => { + // Clear any leftover state between tests + clearPlanModeSession('s1'); + clearPlanModeSession('s2'); + }); + + it('starts as false for an unmarked session', () => { + expect(isSessionInPlanMode('s1')).toBe(false); + }); + + it('markSessionPlanMode(id, true) marks the session as plan mode', () => { + markSessionPlanMode('s1', true); + expect(isSessionInPlanMode('s1')).toBe(true); + }); + + it('markSessionPlanMode(id, false) unmarks a previously plan-mode session', () => { + markSessionPlanMode('s1', true); + markSessionPlanMode('s1', false); + expect(isSessionInPlanMode('s1')).toBe(false); + }); + + it('clearPlanModeSession always removes the session', () => { + markSessionPlanMode('s1', true); + clearPlanModeSession('s1'); + expect(isSessionInPlanMode('s1')).toBe(false); + }); + + it('is per-session: marking s1 does not affect s2', () => { + markSessionPlanMode('s1', true); + expect(isSessionInPlanMode('s1')).toBe(true); + expect(isSessionInPlanMode('s2')).toBe(false); + }); +}); diff --git a/packages/codingcode/test/plan/gate-pipeline.test.ts b/packages/codingcode/test/plan/gate-pipeline.test.ts new file mode 100644 index 00000000..af14f461 --- /dev/null +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Effect, Layer } from 'effect'; +import { runPipeline } from '../../src/approval/pipeline.js'; +import { createRuleEngine } from '../../src/approval/rule-engine.js'; +import { READONLY_TOOL_NAMES } from '../../src/approval/presets.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; +import { + planModeGateHook, + markSessionPlanMode, + clearPlanModeSession, +} from '../../src/plan/index.js'; +import type { DecisionHandler } from '../../src/hooks/types.js'; + +const decisionHandlers: DecisionHandler[] = []; + +const mockHookService = { + register: () => Effect.succeed(() => {}), + registerDecision: (_point: string, handler: DecisionHandler, _opts?: any) => + Effect.sync(() => { + decisionHandlers.push(handler); + }), + emit: () => Effect.succeed(undefined), + emitDecision: (point: string, payload: any) => + Effect.sync(() => { + if (point === 'tool.approval.pre') { + for (const h of decisionHandlers) { + const result = h(payload); + if (result) return result; + } + } + return null; + }), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), +}; + +// Capture the payload of emitApprovalRequest so we can verify the Layer 4 → Layer 5 handoff +let capturedApproval: any = null; + +function makeMockApprovalWait() { + return { + waitForConfirm: () => Effect.succeed({ type: 'deny' }) as any, + resolveConfirm: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + emitApprovalRequest: (sessionId: string, id: string, tool: string, args: any) => + Effect.sync(() => { + capturedApproval = { sessionId, id, tool, args }; + }), + registerEmitter: () => Effect.succeed(undefined), + delegateEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(true), + }; +} + +function runPipelineWithMock(opts: { + tool: string; + input: any; + permissionMode: 'default' | 'acceptEdits' | 'bypass'; + sessionId: string; + planMode: boolean; +}) { + capturedApproval = null; + decisionHandlers.length = 0; + decisionHandlers.push(planModeGateHook); + + if (opts.planMode) markSessionPlanMode(opts.sessionId, true); + else markSessionPlanMode(opts.sessionId, false); + + const mockWait = makeMockApprovalWait(); + const HookTestLayer = Layer.succeed(HookService, mockHookService as any); + const WaitTestLayer = Layer.succeed(ApprovalWaitService, mockWait as any); + const TestLayer = Layer.mergeAll(HookTestLayer, WaitTestLayer); + return Effect.runPromise( + runPipeline( + { tool: opts.tool, input: opts.input }, + { + ruleEngine: createRuleEngine([]), + readonlyTools: new Set(READONLY_TOOL_NAMES), + destructiveTools: new Set(), + permissionMode: opts.permissionMode, + sessionId: opts.sessionId, + } + ).pipe(Effect.provide(TestLayer) as any) + ); +} + +describe('Plan mode gate hook integration (planApprovalHook removed — submit_plan self-handles)', () => { + beforeEach(() => { + capturedApproval = null; + decisionHandlers.length = 0; + }); + + it('plan mode + write_file: gate denies before reaching user confirmation', async () => { + const decision: any = await runPipelineWithMock({ + tool: 'write_file', + input: { path: '/tmp/x', content: 'foo' }, + permissionMode: 'default', + sessionId: 's2', + planMode: true, + }); + // Gate denied, so no user confirmation fired. + expect(decision.type).toBe('deny'); + expect(decision.reason).toMatch(/plan mode/i); + expect(capturedApproval).toBeNull(); + + clearPlanModeSession('s2'); + }); + + it('plan mode + execute_command: gate denies with plan-mode reason', async () => { + const decision: any = await runPipelineWithMock({ + tool: 'execute_command', + input: { command: 'rm -rf /' }, + permissionMode: 'default', + sessionId: 's3', + planMode: true, + }); + expect(decision.type).toBe('deny'); + expect(decision.reason).toMatch(/plan mode/i); + expect(capturedApproval).toBeNull(); + + clearPlanModeSession('s3'); + }); + + it('plan mode + dispatch_agent: gate lets it through (subagent-whitelist inline at dispatch time)', async () => { + const decision: any = await runPipelineWithMock({ + tool: 'dispatch_agent', + input: { agent: 'build', prompt: 'do something' }, + permissionMode: 'default', + sessionId: 's4', + planMode: true, + }); + // The gate does not deny dispatch_agent (it's in PLAN_MODE_ALLOWED_TOOLS). + // The pipeline may short-circuit at Layer 2 (readonly-whitelist) since + // dispatch_agent is in READONLY_TOOL_NAMES. The subagent-whitelist check + // is now inline in dispatch_agent (not a hook) and runs at dispatch time. + expect(decision.type).toBe('allow'); + expect(decision.type).not.toBe('deny'); + + clearPlanModeSession('s4'); + }); + + it('build mode + write_file: gate does not fire, pipeline falls through normally', async () => { + const decision: any = await runPipelineWithMock({ + tool: 'write_file', + input: { path: '/tmp/x', content: 'foo' }, + permissionMode: 'default', + sessionId: 's5', + planMode: false, + }); + // build mode: write_file is not in any allowlist, pipeline reaches user confirm + expect(capturedApproval).not.toBeNull(); + expect(decision.source).toBe('user-confirm'); + + clearPlanModeSession('s5'); + }); + + it('submit_plan: pipeline short-circuits at Layer 5 (no 2-option modal)', async () => { + // The plan approval is no longer triggered by a hook. The pipeline + // recognizes submit_plan by name at Layer 5 and short-circuits with + // 'allow' + source 'system-plan-self-handles'. The plan modal is + // driven by submit_plan.execute itself, not by the pipeline. + const decision: any = await runPipelineWithMock({ + tool: 'submit_plan', + input: { plan_content: '# plan' }, + permissionMode: 'default', + sessionId: 's6', + planMode: true, + }); + expect(decision.type).toBe('allow'); + expect(decision.source).toBe('system-plan-self-handles'); + expect(capturedApproval).toBeNull(); + + clearPlanModeSession('s6'); + }); +}); diff --git a/packages/codingcode/test/plan/gate.test.ts b/packages/codingcode/test/plan/gate.test.ts new file mode 100644 index 00000000..63ec5a74 --- /dev/null +++ b/packages/codingcode/test/plan/gate.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + planModeGateHook, + markSessionPlanMode, + clearPlanModeSession, +} from '../../src/plan/index.js'; + +describe('planModeGateHook', () => { + beforeEach(() => { + clearPlanModeSession('sess'); + }); + + afterEach(() => { + clearPlanModeSession('sess'); + }); + + it('returns null when no sessionId is present', () => { + expect(planModeGateHook({ toolName: 'write_file' } as any)).toBeNull(); + }); + + it('returns null when the session is not in plan mode', () => { + expect(planModeGateHook({ toolName: 'write_file', sessionId: 'sess' } as any)).toBeNull(); + }); + + it('returns null when the tool is not provided', () => { + markSessionPlanMode('sess', true); + expect(planModeGateHook({ sessionId: 'sess' } as any)).toBeNull(); + }); + + it('allows submit_plan in plan mode', () => { + markSessionPlanMode('sess', true); + expect(planModeGateHook({ toolName: 'submit_plan', sessionId: 'sess' } as any)).toBeNull(); + }); + + it('allows dispatch_agent in plan mode (subagent-whitelist hook further restricts)', () => { + markSessionPlanMode('sess', true); + expect(planModeGateHook({ toolName: 'dispatch_agent', sessionId: 'sess' } as any)).toBeNull(); + }); + + it('denies write_file in plan mode with the plan-mode reason', () => { + markSessionPlanMode('sess', true); + const result = planModeGateHook({ + toolName: 'write_file', + sessionId: 'sess', + } as any); + expect(result).toEqual({ + decision: 'deny', + reason: 'Write operations denied in plan mode. Use submit_plan to submit a plan.', + }); + }); + + it('denies execute_command in plan mode', async () => { + markSessionPlanMode('sess', true); + const result = await planModeGateHook({ + toolName: 'execute_command', + sessionId: 'sess', + } as any); + expect(result?.decision).toBe('deny'); + expect(result?.reason).toMatch(/plan mode/i); + }); + + it('denies edit_file in plan mode', async () => { + markSessionPlanMode('sess', true); + const result = await planModeGateHook({ + toolName: 'edit_file', + sessionId: 'sess', + } as any); + expect(result?.decision).toBe('deny'); + }); +}); diff --git a/packages/codingcode/test/plan/is-plan-profile.test.ts b/packages/codingcode/test/plan/is-plan-profile.test.ts new file mode 100644 index 00000000..fdde72dd --- /dev/null +++ b/packages/codingcode/test/plan/is-plan-profile.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { isPlanProfile, PLAN_PROFILE_NAME, BUILD_PROFILE_NAME } from '../../src/plan/index.js'; + +describe('isPlanProfile', () => { + it('returns true for a profile named "plan"', () => { + expect(isPlanProfile({ name: 'plan' })).toBe(true); + }); + + it('returns false for "build"', () => { + expect(isPlanProfile({ name: 'build' })).toBe(false); + }); + + it('returns false for an arbitrary subagent name (e.g. "explore")', () => { + expect(isPlanProfile({ name: 'explore' })).toBe(false); + }); + + it('returns false for null/undefined', () => { + expect(isPlanProfile(null)).toBe(false); + expect(isPlanProfile(undefined)).toBe(false); + }); + + it('exposes the canonical plan/build profile name constants', () => { + expect(PLAN_PROFILE_NAME).toBe('plan'); + expect(BUILD_PROFILE_NAME).toBe('build'); + }); +}); diff --git a/packages/codingcode/test/plan/policy.test.ts b/packages/codingcode/test/plan/policy.test.ts new file mode 100644 index 00000000..1f5a5202 --- /dev/null +++ b/packages/codingcode/test/plan/policy.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { PLAN_MODE_ALLOWED_TOOLS } from '../../src/plan/index.js'; + +describe('PLAN_MODE_ALLOWED_TOOLS', () => { + it('contains submit_plan', () => { + expect(PLAN_MODE_ALLOWED_TOOLS.has('submit_plan')).toBe(true); + }); + + it('contains dispatch_agent (further restricted by subagent-whitelist hook)', () => { + expect(PLAN_MODE_ALLOWED_TOOLS.has('dispatch_agent')).toBe(true); + }); + + it('does NOT contain write tools', () => { + expect(PLAN_MODE_ALLOWED_TOOLS.has('write_file')).toBe(false); + expect(PLAN_MODE_ALLOWED_TOOLS.has('edit_file')).toBe(false); + expect(PLAN_MODE_ALLOWED_TOOLS.has('execute_command')).toBe(false); + }); + + it('does NOT contain read tools (they reach the pipeline as readonly whitelist, not as plan-mode bypass)', () => { + // Read-only tools are handled by Layer 2 of the approval pipeline, not by + // the plan-mode gate. The gate is a deny-list for non-allowed writes; it + // only short-circuits tools that *would* fail the gate. + expect(PLAN_MODE_ALLOWED_TOOLS.has('read_file')).toBe(false); + expect(PLAN_MODE_ALLOWED_TOOLS.has('search_files')).toBe(false); + }); +}); diff --git a/packages/codingcode/test/plan/subagent-whitelist.test.ts b/packages/codingcode/test/plan/subagent-whitelist.test.ts new file mode 100644 index 00000000..5ecccace --- /dev/null +++ b/packages/codingcode/test/plan/subagent-whitelist.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { checkSubagentAllowedInPlanMode } from '../../src/plan/index.js'; + +describe('checkSubagentAllowedInPlanMode', () => { + it('returns allowed when no parentSessionId is present (top-level dispatch is not in scope)', () => { + const result = checkSubagentAllowedInPlanMode(undefined, 'plan', 'build'); + expect(result).toEqual({ allowed: true }); + }); + + it('returns allowed when the parent main profile is not "plan"', () => { + const result = checkSubagentAllowedInPlanMode('parent-sess', 'build', 'build'); + expect(result).toEqual({ allowed: true }); + }); + + it('returns allowed when the parent main profile is missing', () => { + const result = checkSubagentAllowedInPlanMode('parent-sess', undefined, 'build'); + expect(result).toEqual({ allowed: true }); + }); + + it('allows dispatching the explore subagent in plan mode', () => { + const result = checkSubagentAllowedInPlanMode('parent-sess', 'plan', 'explore'); + expect(result).toEqual({ allowed: true }); + }); + + it('denies dispatching any non-explore subagent in plan mode', () => { + const result = checkSubagentAllowedInPlanMode('parent-sess', 'plan', 'build'); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toMatch(/Plan mode can only dispatch the 'explore' subagent/); + expect(result.reason).toContain("'build'"); + } + }); + + it('denies a custom user-defined agent name in plan mode', () => { + const result = checkSubagentAllowedInPlanMode('parent-sess', 'plan', 'my-custom-agent'); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toContain("'my-custom-agent'"); + } + }); + + it('returns allowed when no profile is provided (defensive — let other layers handle)', () => { + const result = checkSubagentAllowedInPlanMode('parent-sess', 'plan', undefined); + expect(result).toEqual({ allowed: true }); + }); +}); diff --git a/packages/codingcode/test/runtime/set-session-profile.test.ts b/packages/codingcode/test/runtime/set-session-profile.test.ts new file mode 100644 index 00000000..b3c08dee --- /dev/null +++ b/packages/codingcode/test/runtime/set-session-profile.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { existsSync, readFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { SessionService } from '../../src/session/store.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { McpService } from '../../src/mcp/index.js'; +import { SubagentService } from '../../src/subagent/registry.js'; +import { RulesService } from '../../src/rules/index.js'; +import { BUILD_PROFILE, PLAN_PROFILE, EXPLORE_PROFILE } from '../../src/subagent/registry.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +const mockHookService = { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), +}; + +const mockMcpService = { + syncConnections: () => Effect.succeed(undefined), + connectServers: () => Effect.succeed(undefined), + listProjectMcpTools: () => [], + disposeSession: () => Effect.succeed(undefined), +} as any; + +const mockRulesService = { + getAllRules: () => '', + evictProjectRules: () => undefined, +} as any; + +function makeLayer() { + const HookTestLayer = Layer.succeed(HookService, mockHookService as any); + const McpTestLayer = Layer.succeed(McpService, mockMcpService); + const SubagentTestLayer = SubagentService.Default; + const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); + const SessionTestLayer = SessionService.Default; + const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( + Layer.provide(Layer.mergeAll(HookTestLayer, McpTestLayer, SubagentTestLayer, RulesTestLayer, SessionTestLayer)) + ); + return Layer.mergeAll(ProjectRuntimeTestLayer, SessionTestLayer); +} + +describe('ProjectRuntimeService.setSessionProfile', () => { + let cwd: string; + let sessionId: string; + let indexPath: string; + let rt: ManagedRuntime.ManagedRuntime; + + beforeEach(async () => { + cwd = join(base.dir, 'set-session-profile'); + mkdirSync(cwd, { recursive: true }); + rt = ManagedRuntime.make(makeLayer() as any); + const result = await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const session = yield* SessionService; + yield* runtime.prepareProject(cwd); + const state = yield* session.create(cwd, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); + return { sessionId: state.sessionId, indexPath: state.indexPath }; + }) + ); + sessionId = result.sessionId; + indexPath = result.indexPath; + }); + + afterEach(async () => { + await rt.dispose(); + }); + + it('does NOT write idx.permissionMode when switching to plan (preserves build preference)', async () => { + // Plan mode: in-memory map is forced to 'default', but the on-disk + // SessionIndex.permissionMode is left untouched so the build preference + // survives plan→build transitions. + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + }) + ); + + expect(existsSync(indexPath)).toBe(true); + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + // build preference (default from create) is preserved on disk + expect(idx.permissionMode).toBe('default'); + // runtime memory is forced to 'default' + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); + }) + ); + }); + + it('writes idx.permissionMode AND idx.activeProfile when switching to build', async () => { + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE, 'bypass'); + }) + ); + + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.permissionMode).toBe('bypass'); + expect(idx.activeProfile).toBe('build'); + }); + + it('records profile in runtime memory (getSessionProfile returns it)', async () => { + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + const profile = runtime.getSessionProfile(sessionId); + expect(profile?.name).toBe('plan'); + expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); + }) + ); + }); + + it('explore profile (with explicit permissionMode=bypass) writes correctly', async () => { + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.setSessionProfile(cwd, sessionId, EXPLORE_PROFILE); + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.permissionMode).toBe('bypass'); + expect(idx.activeProfile).toBe('explore'); + }) + ); + }); +}); diff --git a/packages/codingcode/test/scheduler/store.test.ts b/packages/codingcode/test/scheduler/store.test.ts index 108ce0c1..f25ec4ab 100644 --- a/packages/codingcode/test/scheduler/store.test.ts +++ b/packages/codingcode/test/scheduler/store.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 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'; -const testDir = resolve(process.cwd(), '.test-scheduler-store'); +const testDir = resolve(tmpdir(), 'codingcode-test-scheduler-store'); const testFile = join(testDir, 'automations.yaml'); describe('readAutomations', () => { diff --git a/packages/codingcode/test/security/plan-mode-restart.test.ts b/packages/codingcode/test/security/plan-mode-restart.test.ts new file mode 100644 index 00000000..632072b9 --- /dev/null +++ b/packages/codingcode/test/security/plan-mode-restart.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { mkdtempSync, rmSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { SessionService } from '../../src/session/store.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { McpService } from '../../src/mcp/index.js'; +import { SubagentService } from '../../src/subagent/registry.js'; +import { RulesService } from '../../src/rules/index.js'; +import { ApprovalService } from '../../src/approval/index.js'; +import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; +import { + planModeGateHook, + markSessionPlanMode, + clearPlanModeSession, + isSessionInPlanMode, +} from '../../src/plan/index.js'; +import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; +import type { DecisionHandler } from '../../src/hooks/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +useTempProjectBase(); + +const decisionHandlers: DecisionHandler[] = []; + +const mockHookService = { + register: () => Effect.succeed(() => {}), + registerDecision: (_point: string, handler: DecisionHandler, _opts?: any) => + Effect.sync(() => { + decisionHandlers.push(handler); + }), + emit: () => Effect.succeed(undefined), + emitDecision: (point: string, payload: any) => + Effect.sync(() => { + if (point === 'tool.approval.pre') { + for (const h of decisionHandlers) { + const result = h(payload); + if (result) return result; + } + } + return null; + }), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), +}; + +const mockMcpService = { + syncConnections: () => Effect.succeed(undefined), + connectServers: () => Effect.succeed(undefined), + listProjectMcpTools: () => [], + disposeSession: () => Effect.succeed(undefined), +} as any; + +const mockRulesService = { + getAllRules: () => '', + evictProjectRules: () => undefined, +} as any; + +const mockApprovalWaitService = { + waitForConfirm: () => Effect.dieMessage('not implemented'), + resolveConfirm: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + emitApprovalRequest: () => Effect.succeed(undefined), + registerEmitter: () => Effect.succeed(undefined), + delegateEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), +}; + +function makeLayer() { + const HookTestLayer = Layer.succeed(HookService, mockHookService as any); + const McpTestLayer = Layer.succeed(McpService, mockMcpService); + const SubagentTestLayer = SubagentService.Default; + const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); + const SessionTestLayer = SessionService.Default; + const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( + Layer.provide( + Layer.mergeAll( + HookTestLayer, + McpTestLayer, + SubagentTestLayer, + RulesTestLayer, + SessionTestLayer + ) + ) + ); + const ApprovalTestLayer = ApprovalService.Default.pipe( + Layer.provide( + Layer.mergeAll( + HookTestLayer, + Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) + ) + ) + ); + const TestLayer = Layer.mergeAll( + ProjectRuntimeTestLayer, + SessionTestLayer, + HookTestLayer, + ApprovalTestLayer, + Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) + ); + return TestLayer; +} + +describe('plan mode security boundary (cross-restart)', () => { + let cwd: string; + let sessionId: string; + let indexPath: string; + let rt: ManagedRuntime.ManagedRuntime; + + beforeEach(async () => { + cwd = mkdtempSync(join(tmpdir(), 'codingcode-security-test-')); + decisionHandlers.length = 0; + decisionHandlers.push(planModeGateHook); + rt = ManagedRuntime.make(makeLayer() as any); + const result = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.create(cwd, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); + return { sessionId: state.sessionId, indexPath: state.indexPath }; + }) + ); + sessionId = result.sessionId; + indexPath = result.indexPath; + }); + + afterEach(async () => { + await rt.dispose(); + rmSync(cwd, { recursive: true, force: true }); + clearPlanModeSession(sessionId); + }); + + // Helper: simulate the real sendMessage path — fork approval, set the + // session's permission mode (from the runtime's in-memory map), then evaluate. + // The plan-mode side channel is kept in sync by `setSessionProfile`, so the + // gate hook fires correctly even via the approval pipeline. + async function evaluateAsSession(tool: string, input: any): Promise { + return rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const approval = yield* ApprovalService; + const mode = runtime.getSessionPermissionMode(sessionId); + const forked = yield* approval.fork({}); + yield* forked.setPermissionMode(mode); + return yield* forked.evaluate({ + tool, + input, + sessionId, + projectPath: cwd, + }); + }) + ); + } + + it('scenario 1: switch to plan, write_file is denied by the plan-mode gate hook', async () => { + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.prepareProject(cwd); + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + }) + ); + // setSessionProfile also marks the plan-mode side channel + expect(isSessionInPlanMode(sessionId)).toBe(true); + + const decision = await evaluateAsSession('write_file', { path: '/tmp/x', content: 'foo' }); + expect(decision.type).toBe('deny'); + expect(decision.reason).toMatch(/plan mode/i); + expect(decision.source).toBe('hook'); + }); + + it('scenario 2: switch to plan, execute_command is denied by the plan-mode gate hook', async () => { + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.prepareProject(cwd); + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + }) + ); + + const decision = await evaluateAsSession('execute_command', { command: 'echo hello' }); + expect(decision.type).toBe('deny'); + expect(decision.reason).toMatch(/plan mode/i); + expect(decision.source).toBe('hook'); + }); + + it('scenario 3: switch to plan, submit_plan is short-circuited by the pipeline (self-handles plan approval)', async () => { + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.prepareProject(cwd); + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + }) + ); + + const decision: any = await evaluateAsSession('submit_plan', { plan_content: 'do things' }); + // submit_plan is in PLAN_MODE_ALLOWED_TOOLS, so the gate does not fire. + // The pipeline recognizes submit_plan by name at Layer 5 and short-circuits + // to 'allow' with source 'system-plan-self-handles'. The plan modal is + // driven by agentLoop emitting plan.ready on turn-end, not by the pipeline. + expect(decision.type).toBe('allow'); + expect(decision.source).toBe('system-plan-self-handles'); + }); + + it('scenario 4: after restart (state reloaded from disk), plan mode still enforced', async () => { + // First: switch to plan. In the new design, plan mode does NOT write + // idx.permissionMode (the build preference is preserved on disk), + // but the in-memory planModeSessions side channel is updated. + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.prepareProject(cwd); + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + }) + ); + + // After plan: permissionMode is preserved (build preference from create). + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.permissionMode).toBe('default'); + + // Simulate restart: build a new runtime, load state, restore profile. + await rt.dispose(); + decisionHandlers.length = 0; + decisionHandlers.push(planModeGateHook); + rt = ManagedRuntime.make(makeLayer() as any); + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const session = yield* SessionService; + yield* runtime.prepareProject(cwd); + const state = yield* session.load(cwd, sessionId); + // state.mode is 'build' (set by create; plan mode didn't write to disk) + expect(state.mode).toBe('build'); + // To re-enter plan mode after restart, the client calls setSessionMode. + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + // After restore, the plan-mode side channel is re-marked. + expect(isSessionInPlanMode(sessionId)).toBe(true); + }) + ); + + const decision = await evaluateAsSession('write_file', { path: '/tmp/x', content: 'foo' }); + expect(decision.type).toBe('deny'); + expect(decision.reason).toMatch(/plan mode/i); + }); + + it('scenario 5: plan mode → switch to build → write_file is no longer denied by plan mode', async () => { + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.prepareProject(cwd); + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE); + }) + ); + // After switching to build, the plan-mode side channel is cleared. + expect(isSessionInPlanMode(sessionId)).toBe(false); + + const decision: any = await evaluateAsSession('write_file', { path: '/tmp/x', content: 'foo' }); + // Gate no longer fires; pipeline falls through to user confirm (no emitter → system deny). + if (decision.type === 'deny') { + expect(decision.source).not.toBe('hook'); + expect(decision.reason).not.toMatch(/plan mode/i); + } + }); +}); diff --git a/packages/codingcode/test/server/adapter.test.ts b/packages/codingcode/test/server/adapter.test.ts index 41a185b5..1583ed5a 100644 --- a/packages/codingcode/test/server/adapter.test.ts +++ b/packages/codingcode/test/server/adapter.test.ts @@ -27,22 +27,6 @@ describe('agentEventToSseEvent', () => { ).toEqual({ type: 'tool_denied', id: 'tc-1', name: 'bash', reason: 'not allowed' }); }); - it('maps ApprovalRequest to approval_request event', () => { - expect( - agentEventToSseEvent({ - _tag: 'ApprovalRequest', - id: 'abc', - tool: 'write_file', - args: { path: '/tmp/x' }, - }) - ).toEqual({ - type: 'approval_request', - id: 'abc', - tool: 'write_file', - args: { path: '/tmp/x' }, - }); - }); - it('maps ToolResult to tool_result event', () => { expect( agentEventToSseEvent({ _tag: 'ToolResult', id: 'x', name: 't', output: 'ok', ok: true }) diff --git a/packages/codingcode/test/server/compact-route.test.ts b/packages/codingcode/test/server/compact-route.test.ts index 848dd96f..ed9f5a85 100644 --- a/packages/codingcode/test/server/compact-route.test.ts +++ b/packages/codingcode/test/server/compact-route.test.ts @@ -217,9 +217,9 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { expect(mockCompactWithLLM).toHaveBeenCalledTimes(1); const args = mockCompactWithLLM.mock.calls[0]; - // args[3] is the llm parameter — should not be null - expect(args?.[3]).not.toBeNull(); - expect(args?.[3].modelInfo.model).toBe('deepseek-chat'); + // args[2] is the llm parameter — should not be null + expect(args?.[2]).not.toBeNull(); + expect(args?.[2].modelInfo.model).toBe('deepseek-chat'); }); it('should return CompressResult from the API', async () => { @@ -279,6 +279,6 @@ describe('POST /api/sessions/:id/compact (manual compact)', () => { expect(mockCompactWithLLM).toHaveBeenCalledTimes(1); const args = mockCompactWithLLM.mock.calls[0]; - expect(args?.[3]).toBeNull(); + expect(args?.[2]).toBeNull(); }); }); diff --git a/packages/codingcode/test/server/create-session-active-profile.test.ts b/packages/codingcode/test/server/create-session-active-profile.test.ts new file mode 100644 index 00000000..9745c9c1 --- /dev/null +++ b/packages/codingcode/test/server/create-session-active-profile.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { Hono } from 'hono'; +import { mkdtempSync, rmSync, readFileSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { SessionService } from '../../src/session/store.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { McpService } from '../../src/mcp/index.js'; +import { SubagentService } from '../../src/subagent/registry.js'; +import { RulesService } from '../../src/rules/index.js'; +import { WorkspaceService } from '../../src/core/workspace.js'; +import { createSessionsRouter } from '../../src/server/routes/sessions.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +const mockHookService = { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), +} as any; + +const mockMcpService = { + syncConnections: () => Effect.succeed(undefined), + connectServers: () => Effect.succeed(undefined), + listProjectMcpTools: () => [], + disposeSession: () => Effect.succeed(undefined), +} as any; + +const mockRulesService = { + getAllRules: () => '', + evictProjectRules: () => undefined, +} as any; + +function makeLayer() { + const HookTestLayer = Layer.succeed(HookService, mockHookService); + const McpTestLayer = Layer.succeed(McpService, mockMcpService); + const SubagentTestLayer = SubagentService.Default; + const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); + const SessionTestLayer = SessionService.Default; + const WorkspaceTestLayer = WorkspaceService.Default; + const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( + Layer.provide( + Layer.mergeAll( + HookTestLayer, + McpTestLayer, + SubagentTestLayer, + RulesTestLayer, + SessionTestLayer, + WorkspaceTestLayer + ) + ) + ); + return Layer.mergeAll(ProjectRuntimeTestLayer, SessionTestLayer, WorkspaceTestLayer); +} + +describe('POST /api/sessions — atomic mode + permissionMode + model', () => { + let cwd: string; + let rt: ManagedRuntime.ManagedRuntime; + let app: Hono; + + beforeEach(async () => { + cwd = join(base.dir, 'create-session-active-profile'); + mkdirSync(cwd, { recursive: true }); + rt = ManagedRuntime.make(makeLayer() as any); + app = new Hono(); + app.route('/api/sessions', createSessionsRouter(rt)); + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.prepareProject(cwd); + }) + ); + }); + + afterEach(async () => { + await rt.dispose(); + }); + + it('writes idx.mode=plan and idx.permissionMode=default when mode=plan', async () => { + const res = await app.request('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + cwd, + mode: 'plan', + permissionMode: 'default', + model: 'gpt-4', + }), + }); + expect(res.status).toBe(200); + const { sessionId } = await res.json(); + + const indexPath = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + return state.indexPath; + }) + ); + + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.mode).toBe('plan'); + expect(idx.permissionMode).toBe('default'); + }); + + it('writes idx.mode=build and idx.permissionMode=bypass when build+bypass', async () => { + const res = await app.request('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + cwd, + mode: 'build', + permissionMode: 'bypass', + model: 'gpt-4', + }), + }); + expect(res.status).toBe(200); + const { sessionId } = await res.json(); + + const indexPath = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + return state.indexPath; + }) + ); + + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.mode).toBe('build'); + expect(idx.permissionMode).toBe('bypass'); + }); + + it('rejects plan mode with non-default permissionMode', async () => { + const res = await app.request('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + cwd, + mode: 'plan', + permissionMode: 'bypass', + model: 'gpt-4', + }), + }); + expect(res.status).toBe(400); + }); + + it('rejects missing model', async () => { + const res = await app.request('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cwd, mode: 'build', permissionMode: 'default' }), + }); + expect(res.status).toBe(400); + }); + + it('rejects missing mode', async () => { + const res = await app.request('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cwd, permissionMode: 'default', model: 'gpt-4' }), + }); + expect(res.status).toBe(400); + }); + + it('new session with plan: state.mode is set, getSessionPermissionMode returns default', async () => { + const res = await app.request('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + cwd, + mode: 'plan', + permissionMode: 'default', + model: 'gpt-4', + }), + }); + expect(res.status).toBe(200); + const { sessionId } = await res.json(); + + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const session = yield* SessionService; + const state = yield* session.load(cwd, sessionId); + expect(state.mode).toBe('plan'); + const profile = runtime.getSessionProfile(sessionId); + expect(profile?.name).toBe('plan'); + // plan-mode forces in-memory permissionMode to 'default' + expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); + }) + ); + }); +}); diff --git a/packages/codingcode/test/server/handler.test.ts b/packages/codingcode/test/server/handler.test.ts deleted file mode 100644 index 3bf2d27e..00000000 --- a/packages/codingcode/test/server/handler.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { ManagedRuntime } from 'effect'; -import { createSseHandler } from '../../src/server/handler.js'; -import { toSseEvents } from '../../src/server/adapter.js'; -import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; -import { AgentError } from '../../src/core/error.js'; -import type { AgentEvent } from '../../src/agent/types.js'; - -const rt = ManagedRuntime.make(ApprovalWaitService.Default); - -async function readSSEStream(response: Response): Promise<{ events: any[] }> { - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let raw = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - raw += decoder.decode(value, { stream: true }); - } - raw += decoder.decode(); - - const events: any[] = []; - for (const line of raw.split('\n')) { - if (line.startsWith('data: ')) { - events.push(JSON.parse(line.slice(6))); - } - } - return { events }; -} - -describe('sseHandler + toSseEvents', () => { - it('should stream text chunks and complete event', async () => { - const sseHandler = createSseHandler(rt); - const handler = sseHandler( - async function* () { - yield* toSseEvents( - (async function* (): AsyncGenerator { - yield { _tag: 'TurnId', turnId: 0 }; - yield { _tag: 'Step', step: 1, max: 50 }; - yield { _tag: 'LlmChunk', text: 'Hello' }; - yield { _tag: 'LlmChunk', text: ' ' }; - yield { _tag: 'LlmChunk', text: 'world' }; - yield { _tag: 'Assistant', content: 'Hello world' }; - yield { _tag: 'Done', content: 'Hello world' }; - })() - ); - }, - { sessionId: 'test' } - ); - const response = await handler({} as any); - const { events } = await readSSEStream(response); - - expect(events).toHaveLength(8); - expect(events[0]).toEqual({ type: 'turn_id', turnId: 0 }); - expect(events[1]).toEqual({ type: 'step', step: 1 }); - expect(events[2]).toEqual({ type: 'text', text: 'Hello', messageId: 1 }); - expect(events[3]).toEqual({ type: 'text', text: ' ', messageId: 1 }); - expect(events[4]).toEqual({ type: 'text', text: 'world', messageId: 1 }); - expect(events[5]).toEqual({ type: 'message', id: 1, content: 'Hello world', partial: false }); - expect(events[6]).toEqual({ type: 'done' }); - expect(events[7]).toEqual({ type: 'complete' }); - }); - - it('should send complete event even when LLM returns no text', async () => { - const sseHandler = createSseHandler(rt); - const handler = sseHandler( - async function* () { - yield* toSseEvents( - (async function* (): AsyncGenerator { - yield { _tag: 'TurnId', turnId: 0 }; - yield { _tag: 'Step', step: 1, max: 50 }; - yield { _tag: 'Assistant', content: '' }; - yield { _tag: 'Done', content: '' }; - })() - ); - }, - { sessionId: 'test' } - ); - const response = await handler({} as any); - const { events } = await readSSEStream(response); - - expect(events[events.length - 1]).toEqual({ type: 'complete' }); - }); - - it('should forward [Using: ...] markers when LLM calls tools', async () => { - const sseHandler = createSseHandler(rt); - const handler = sseHandler( - async function* () { - yield* toSseEvents( - (async function* (): AsyncGenerator { - yield { _tag: 'TurnId', turnId: 0 }; - yield { _tag: 'Step', step: 1, max: 50 }; - yield { _tag: 'LlmChunk', text: '\n[Using: readFile]\n' }; - yield { _tag: 'ToolStart', id: 'tc1', name: 'readFile', args: { path: 'test.txt' } }; - yield { - _tag: 'ToolResult', - id: 'tc1', - name: 'readFile', - output: 'file contents', - ok: true, - }; - yield { _tag: 'Done', content: '' }; - })() - ); - }, - { sessionId: 'test' } - ); - const response = await handler({} as any); - const { events } = await readSSEStream(response); - - const textEvent = events.find((e: any) => e.type === 'text'); - expect(textEvent).toBeDefined(); - expect(textEvent!.text).toContain('[Using:'); - }); - - it('should preserve AgentError code in catch', async () => { - const sseHandler = createSseHandler(rt); - const handler = sseHandler( - async function* () { - throw AgentError.toolNotFound('myTool'); - }, - { sessionId: 'test' } - ); - const response = await handler({} as any); - const { events } = await readSSEStream(response); - - const errorEvent = events.find((e: any) => e.type === 'error'); - expect(errorEvent).toBeDefined(); - expect(errorEvent.code).toBe('TOOL_NOT_FOUND'); - expect(errorEvent.message).toContain('myTool'); - }); - - it('should not include code for plain Error in catch', async () => { - const sseHandler = createSseHandler(rt); - const handler = sseHandler( - async function* () { - throw new Error('plain error'); - }, - { sessionId: 'test' } - ); - const response = await handler({} as any); - const { events } = await readSSEStream(response); - - const errorEvent = events.find((e: any) => e.type === 'error'); - expect(errorEvent).toBeDefined(); - expect(errorEvent.code).toBeUndefined(); - }); -}); diff --git a/packages/codingcode/test/server/plan-file-route.test.ts b/packages/codingcode/test/server/plan-file-route.test.ts new file mode 100644 index 00000000..b57d86c2 --- /dev/null +++ b/packages/codingcode/test/server/plan-file-route.test.ts @@ -0,0 +1,287 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { mkdirSync, writeFileSync, utimesSync } from 'fs'; +import { join } from 'path'; +import { Hono } from 'hono'; +import { createSessionsRouter } from '../../src/server/routes/sessions.js'; +import { WorkspaceService } from '../../src/core/workspace.js'; +import { SessionService } from '../../src/session/store.js'; +import { LLMFactoryService } from '../../src/llm/factory.js'; +import { ApprovalService } from '../../src/approval/index.js'; +import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { SkillService } from '../../src/skills/service.js'; +import { McpService } from '../../src/mcp/index.js'; +import { MemoryService } from '../../src/memory/index.js'; +import { SchedulerService } from '../../src/scheduler/service.js'; +import { ContextService } from '../../src/context/service.js'; +import { CheckpointService } from '../../src/checkpoint/checkpoint-service.js'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { setProjectBaseDir } from '../../src/core/path.js'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; + +const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { + getWorkspaceCwd: () => '/tmp/test', + resolveWorkspaceCwd: (override?: string) => override ?? '/tmp/test', +} as any); + +const MockSessionLayer = Layer.succeed(SessionService, { + create: () => + Effect.succeed({ + sessionId: 'test-sid', + cwd: '/tmp/test', + projectPath: 'test-path', + model: 'deepseek-chat', + }), + load: () => + Effect.succeed({ + sessionId: 'test-sid', + cwd: '/tmp/test', + projectPath: 'test-path', + transcriptPath: '/tmp/test.jsonl', + model: 'deepseek-chat', + }), + recordUser: () => Effect.succeed({ type: 'user', content: '', turnId: 0 }), + recordAssistant: () => + Effect.succeed({ type: 'assistant', content: '', toolCalls: [], turnId: 0 }), + recordToolResult: () => + Effect.succeed({ + type: 'tool_result', + toolName: 'test', + toolCallId: 'tc1', + output: '', + turnId: 0, + }), + incrementTurn: () => 0, +} as any); + +const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { + findModel: () => + Effect.succeed({ + id: 'deepseek-chat', + model: 'deepseek-chat', + 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', + maxTokens: 64000, + supportsToolCalling: true, + supportsStreaming: true, + }, + }), + getLLMClient: () => Effect.succeed(null), + listModels: () => Effect.succeed([]), + getActiveEntry: () => + Effect.succeed({ + id: 'deepseek-chat', + model: 'deepseek-chat', + 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 = ApprovalService.Default.pipe( + Layer.provide(Layer.mergeAll(HookService.Default, ApprovalWaitService.Default)) +); + +const MockSkillLayer = Layer.succeed(SkillService, { + _tag: 'Skill' as const, + getAll: () => Effect.succeed([]), + findByName: () => Effect.succeed(undefined), + select: () => Effect.succeed(undefined), + selectImplicit: () => Effect.succeed(undefined), + extractSkill: (_p: string, q: string) => Effect.sync(() => [undefined, q] as [undefined, string]), + enableSkill: () => Effect.void, + disableSkill: () => Effect.void, + listWithStatus: () => Effect.succeed([]), + evictProject: () => Effect.void, +} as any); + +const MockMcpLayer = Layer.succeed(McpService, { + syncConnections: () => Effect.void, + connectServers: () => Effect.void, + disconnectServers: () => Effect.void, + getServerToolNames: () => [], + disconnectAll: () => Effect.void, + status: () => Effect.succeed([]), + listProjectMcpTools: () => [], +} as any); + +const MockMemoryLayer = Layer.succeed(MemoryService, { + getMemoryEnabled: () => true, + setMemoryEnabled: () => {}, + loadMemoryForPrompt: () => '', + flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), +} as any); + +const MockSchedulerLayer = Layer.succeed(SchedulerService, { + list: () => [], + add: () => ({}), + update: () => null, + remove: () => false, + runOnce: () => Promise.resolve('session-id'), +} as any); + +const MockContextLayer = Layer.succeed(ContextService, { + assemblePayload: () => ({ + messages: [], + compactedEvents: [], + promptEstimate: 0, + currentTurnId: 0, + compactedTurnIds: new Set(), + }), + compactWithLLM: () => Promise.resolve({ didCompress: false, released: 0, promptEstimate: 0 }), +} as any); + +const MockCheckpointLayer = Layer.succeed(CheckpointService, { + _tag: 'Checkpoint' as const, + snapshotBaseline: () => Effect.void, + snapshotFinal: () => Effect.void, + getCompletedTurns: () => Effect.succeed([]), + getCheckpoints: () => Effect.succeed([]), + getCheckpointDiff: () => Effect.succeed({ turnId: 0, files: [] }), + revertCheckpointFiles: () => + Effect.succeed({ + reverted: false, + throughTurnId: 0, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, + }), + previewRollbackDiff: () => Effect.succeed({ throughTurnId: 0, affectedTurns: [], diff: '' }), + rollbackCodeToTurn: () => + Effect.succeed({ + reverted: false, + throughTurnId: 0, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, + }), + undoLastCodeRollback: () => + Effect.succeed({ + restored: false, + conflict: false, + conflictFiles: [], + restoredFiles: [], + remainingRolledBack: [], + }), + getLatestRestoreEntry: () => Effect.succeed(null), +} as any); + +const MockProjectRuntimeLayer = Layer.succeed(ProjectRuntimeService, { + getSessionProfile: () => 'plan', + setSessionProfile: () => Effect.void, + resolveSubagentProfile: () => undefined, + registerActiveSession: () => Effect.void, + unregisterActiveSession: () => Effect.void, + getActiveSessions: () => [], + clearActiveSessions: () => Effect.void, +} as any); + +const TestLayer = Layer.mergeAll( + MockWorkspaceLayer, + MockSessionLayer, + MockLLMFactoryLayer, + MockApprovalLayer, + HookService.Default, + ApprovalWaitService.Default, + MockSkillLayer, + MockMcpLayer, + MockMemoryLayer, + MockSchedulerLayer, + MockContextLayer, + MockCheckpointLayer, + MockProjectRuntimeLayer +); + +let tempBase = ''; +let plansDir = ''; + +beforeEach(() => { + tempBase = mkdtempSync(join(tmpdir(), 'codingcode-plan-route-')); + // The route reads getProjectBaseDir() + encodeProjectPath(cwd). + // encodeProjectPath('/tmp/test') -> 'tmp-test'. + plansDir = join(tempBase, 'tmp-test'); + mkdirSync(plansDir, { recursive: true }); + setProjectBaseDir(tempBase); +}); + +afterEach(() => { + setProjectBaseDir(undefined); + rmSync(tempBase, { recursive: true, force: true }); +}); + +describe('GET /api/sessions/:id/plan', () => { + it('returns exists:false with empty content when no .md file is present', async () => { + const rt = ManagedRuntime.make(TestLayer); + const router = createSessionsRouter(rt); + const app = new Hono(); + app.route('/api/sessions', router); + const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); + expect(res.status).toBe(200); + const body = (await res.json()) as { + content: string; + path: string; + directory: string; + exists: boolean; + }; + expect(body.exists).toBe(false); + expect(body.content).toBe(''); + expect(body.path).toBe(''); + }); + + it('returns the most-recently-modified .md file in the plan directory', async () => { + const oldPath = join(plansDir, 'old-plan.md'); + const newPath = join(plansDir, 'new-plan.md'); + writeFileSync(oldPath, '# OLD', 'utf8'); + writeFileSync(newPath, '# NEW', 'utf8'); + // Make `oldPlan` newer so that we can verify the route picks by mtime, not by name + const newerDate = new Date(); + const olderDate = new Date(newerDate.getTime() - 60_000); + utimesSync(oldPath, olderDate, olderDate); + utimesSync(newPath, newerDate, newerDate); + + const rt = ManagedRuntime.make(TestLayer); + const router = createSessionsRouter(rt); + const app = new Hono(); + app.route('/api/sessions', router); + const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); + expect(res.status).toBe(200); + const body = (await res.json()) as { + content: string; + path: string; + exists: boolean; + }; + expect(body.exists).toBe(true); + expect(body.path).toBe(newPath); + expect(body.content).toBe('# NEW'); + }); + + it('ignores non-md files in the plan directory', async () => { + const mdPath = join(plansDir, 'plan.md'); + writeFileSync(mdPath, '# ONLY-MD', 'utf8'); + writeFileSync(join(plansDir, 'notes.txt'), 'should be ignored', 'utf8'); + + const rt = ManagedRuntime.make(TestLayer); + const router = createSessionsRouter(rt); + const app = new Hono(); + app.route('/api/sessions', router); + const res = await app.request('/api/sessions/s-1/plan?cwd=/tmp/test'); + const body = (await res.json()) as { content: string; exists: boolean }; + expect(body.exists).toBe(true); + expect(body.content).toBe('# ONLY-MD'); + }); +}); diff --git a/packages/codingcode/test/server/plan-mode-reject-perm-mode.test.ts b/packages/codingcode/test/server/plan-mode-reject-perm-mode.test.ts new file mode 100644 index 00000000..4c912796 --- /dev/null +++ b/packages/codingcode/test/server/plan-mode-reject-perm-mode.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { Hono } from 'hono'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { SessionService } from '../../src/session/store.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { McpService } from '../../src/mcp/index.js'; +import { SubagentService } from '../../src/subagent/registry.js'; +import { RulesService } from '../../src/rules/index.js'; +import { ApprovalService } from '../../src/approval/index.js'; +import { ApprovalWaitService } from '../../src/approval/async-confirm.js'; +import { createAgentRouter } from '../../src/server/routes/agent.js'; +import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +useTempProjectBase(); + +const mockHookService = { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), +}; + +const mockMcpService = { + syncConnections: () => Effect.succeed(undefined), + connectServers: () => Effect.succeed(undefined), + listProjectMcpTools: () => [], + disposeSession: () => Effect.succeed(undefined), +} as any; + +const mockRulesService = { + getAllRules: () => '', + evictProjectRules: () => undefined, +} as any; + +const mockApprovalWaitService = { + waitForConfirm: () => Effect.dieMessage('not implemented'), + resolveConfirm: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + emitApprovalRequest: () => Effect.succeed(undefined), + registerEmitter: () => Effect.succeed(undefined), + delegateEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), +}; + +describe('POST /api/agent/permission-mode rejects when session is in plan mode', () => { + let cwd: string; + let sessionId: string; + let rt: ManagedRuntime.ManagedRuntime; + let app: Hono; + + beforeEach(async () => { + cwd = mkdtempSync(join(tmpdir(), 'codingcode-server-test-')); + const HookTestLayer = Layer.succeed(HookService, mockHookService as any); + const McpTestLayer = Layer.succeed(McpService, mockMcpService); + const SubagentTestLayer = SubagentService.Default; + const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); + const SessionTestLayer = SessionService.Default; + const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( + Layer.provide( + Layer.mergeAll( + HookTestLayer, + McpTestLayer, + SubagentTestLayer, + RulesTestLayer, + SessionTestLayer + ) + ) + ); + const ApprovalTestLayer = ApprovalService.Default.pipe( + Layer.provide( + Layer.mergeAll( + HookTestLayer, + Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) + ) + ) + ); + const TestLayer = Layer.mergeAll( + ProjectRuntimeTestLayer, + SessionTestLayer, + HookTestLayer, + ApprovalTestLayer, + Layer.succeed(ApprovalWaitService, mockApprovalWaitService as any) + ); + rt = ManagedRuntime.make(TestLayer as any); + app = new Hono(); + app.route('/api/agent', createAgentRouter(rt)); + + sessionId = await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const session = yield* SessionService; + yield* runtime.prepareProject(cwd); + const state = yield* session.create(cwd, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); + return state.sessionId; + }) + ); + }); + + afterEach(async () => { + await rt.dispose(); + rmSync(cwd, { recursive: true, force: true }); + }); + + it('returns 409 when session is in plan profile', async () => { + // Switch session to plan + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.prepareProject(cwd); + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + }) + ); + + const res = await app.request('/api/agent/permission-mode', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'bypass', cwd, sessionId }), + }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/plan mode/i); + }); + + it('allows the change when session is in build profile', async () => { + // Switch to build (default) + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.prepareProject(cwd); + yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE); + }) + ); + + const res = await app.request('/api/agent/permission-mode', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'bypass', cwd, sessionId }), + }); + expect(res.status).toBe(200); + }); + + it('falls back to global when cwd+sessionId not provided (legacy clients)', async () => { + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.prepareProject(cwd); + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + }) + ); + + // No cwd/sessionId — bypass check, change applies to global ApprovalService + const res = await app.request('/api/agent/permission-mode', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'bypass' }), + }); + expect(res.status).toBe(200); + }); + + it('rejects invalid mode value with 400', async () => { + const res = await app.request('/api/agent/permission-mode', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'invalid', cwd, sessionId }), + }); + expect(res.status).toBe(400); + }); +}); diff --git a/packages/codingcode/test/server/settings-routes.test.ts b/packages/codingcode/test/server/settings-routes.test.ts index b0272c9e..6ae286e1 100644 --- a/packages/codingcode/test/server/settings-routes.test.ts +++ b/packages/codingcode/test/server/settings-routes.test.ts @@ -140,16 +140,19 @@ vi.mock('../../src/mcp/config.js', () => ({ resetProjectMcpDisabledState: vi.fn(), })); -vi.mock('../../src/subagent/loader.js', () => ({ - loadAgentProfiles: vi.fn().mockReturnValue([]), - writeAgentProfile: vi.fn(), - updateAgentProfile: vi.fn(), - deleteAgentProfile: vi.fn(), - loadGlobalAgentProfiles: vi.fn().mockReturnValue([]), - writeGlobalAgentProfile: vi.fn(), - updateGlobalAgentProfile: vi.fn(), - deleteGlobalAgentProfile: vi.fn(), -})); +vi.mock('../../src/subagent/loader.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + loadAgentProfiles: vi.fn().mockReturnValue([]), + writeAgentProfile: vi.fn(), + updateAgentProfile: vi.fn(), + deleteAgentProfile: vi.fn().mockImplementation(actual.deleteAgentProfile), + loadGlobalAgentProfiles: vi.fn().mockReturnValue([]), + writeGlobalAgentProfile: vi.fn(), + updateGlobalAgentProfile: vi.fn(), + deleteGlobalAgentProfile: vi.fn(), + }; +}); vi.mock('../../src/hooks/config.js', () => ({ loadHookConfigs: vi.fn().mockReturnValue([]), @@ -167,20 +170,22 @@ vi.mock('../../src/hooks/executor.js', () => ({ setHookRuntimeEnabled: vi.fn(), })); -vi.mock('../../src/skills/config.js', () => ({ +vi.mock('../../src/skills/source.js', () => ({ setGlobalSkillDisabledState: vi.fn(), setProjectSkillDisabledState: vi.fn(), discoverGlobalSkillDirs: vi.fn().mockReturnValue([]), discoverProjectSkillDirs: vi.fn().mockReturnValue([]), })); -vi.mock('../../src/core/workspace.js', () => { +vi.mock('../../src/core/workspace.js', async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { Context } = require('effect'); const tag = Context.GenericTag('Workspace') as any; + const actual = await importOriginal(); return { WorkspaceService: tag, resolveWorkspaceCwd: vi.fn((cwd?: string) => cwd ?? '/default'), + isGlobalCwd: actual.isGlobalCwd, }; }); @@ -620,7 +625,7 @@ describe('POST /skills', () => { }); it('calls setGlobalSkillDisabledState for global cwd', async () => { - const { setGlobalSkillDisabledState } = await import('../../src/skills/config.js'); + const { setGlobalSkillDisabledState } = await import('../../src/skills/source.js'); const res = await settingsRouter.request('/skills?cwd=global', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -631,7 +636,7 @@ describe('POST /skills', () => { }); it('calls setProjectSkillDisabledState for project cwd', async () => { - const { setProjectSkillDisabledState } = await import('../../src/skills/config.js'); + const { setProjectSkillDisabledState } = await import('../../src/skills/source.js'); const res = await settingsRouter.request('/skills?cwd=/my-project', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -714,3 +719,157 @@ describe('POST /context/compaction-model', () => { expect(updateContextCompactionModel).toHaveBeenCalledWith('gpt-4o-mini'); }); }); + +// ---- Override source labeling (regression for L2) ---- + +describe('GET /mcp - override source labeling', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('project override of global is labeled source=project + hasProjectOverride=true', async () => { + const { loadGlobalMcpConfig, loadMcpConfig, resolveMcpConfig, resolveMcpDisabled } = + await import('../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([{ name: 'shared', command: 'global-cmd' }]); + vi.mocked(loadMcpConfig).mockReturnValue([{ name: 'shared', command: 'project-cmd' }]); + vi.mocked(resolveMcpConfig).mockReturnValue([{ name: 'shared', command: 'project-cmd' }]); + vi.mocked(resolveMcpDisabled).mockReturnValue(false); + const res = await settingsRouter.request('/mcp?cwd=/my-project'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body).toHaveLength(1); + expect(body[0].source).toBe('project'); + expect(body[0].hasProjectOverride).toBe(true); + }); +}); + +describe('GET /hooks - override source labeling', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('project override of global is labeled source=project + hasProjectOverride=true', async () => { + const { loadGlobalHookConfigs, loadHookConfigs, resolveHookConfigs, resolveHookDisabled } = + await import('../../src/hooks/config.js'); + vi.mocked(loadGlobalHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + ]); + vi.mocked(loadHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'sh', + enabled: true, + }, + ]); + vi.mocked(resolveHookConfigs).mockReturnValue([ + { + name: 'shared', + point: 'tool.execute.before', + type: 'observer', + command: 'sh', + enabled: true, + }, + ]); + vi.mocked(resolveHookDisabled).mockReturnValue(false); + const res = await settingsRouter.request('/hooks?cwd=/my-project'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body).toHaveLength(1); + expect(body[0].source).toBe('project'); + expect(body[0].hasProjectOverride).toBe(true); + }); +}); + +describe('GET /skills - override source labeling', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('empty skill list returns empty array (override logic mirrors mcp/hooks)', async () => { + // The MockSkillLayer returns [] for listWithStatus, so we can only verify + // the endpoint shape with empty input. The source labeling change + // (isFromProject ? 'project' : 'global') is identical to mcp/hooks and + // is verified by the corresponding mcp/hooks tests above. + const res = await settingsRouter.request('/skills?cwd=/my-project'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body).toEqual([]); + }); +}); + +// ---- Project-level delete rejects names not in project config (L5) ---- + +describe('DELETE /mcp/:name - project view rejects non-project items', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 500 (NotFoundError) when deleting a global-only MCP from project view', async () => { + const { loadMcpConfig } = await import('../../src/mcp/config.js'); + vi.mocked(loadMcpConfig).mockReturnValue([]); + const res = await settingsRouter.request('/mcp/global-only?cwd=/my-project', { + method: 'DELETE', + }); + expect(res.status).toBe(500); + }); + + it('succeeds when deleting an MCP that exists in project config', async () => { + const { loadMcpConfig, writeMcpConfig } = await import('../../src/mcp/config.js'); + vi.mocked(loadMcpConfig).mockReturnValue([{ name: 'local', command: 'npx' }]); + const res = await settingsRouter.request('/mcp/local?cwd=/my-project', { + method: 'DELETE', + }); + expect(res.status).toBe(200); + expect(writeMcpConfig).toHaveBeenCalled(); + }); +}); + +describe('DELETE /mcp/:name - global view remains idempotent', () => { + it('returns 200 even when name does not exist in global config', async () => { + const { loadGlobalMcpConfig, writeGlobalMcpConfig } = await import('../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([]); + const res = await settingsRouter.request('/mcp/anything?cwd=global', { + method: 'DELETE', + }); + expect(res.status).toBe(200); + expect(writeGlobalMcpConfig).toHaveBeenCalled(); + }); +}); + +describe('DELETE /hooks/:name - project view rejects non-project items', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 500 (NotFoundError) when deleting a global-only hook from project view', async () => { + const { loadHookConfigs } = await import('../../src/hooks/config.js'); + vi.mocked(loadHookConfigs).mockReturnValue([]); + const res = await settingsRouter.request('/hooks/global-only?cwd=/my-project', { + method: 'DELETE', + }); + expect(res.status).toBe(500); + }); +}); + +describe('DELETE /agents/:name - project view rejects non-project items', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 500 (NotFoundError) when deleting a global-only agent from project view', async () => { + const { loadAgentProfiles } = await import('../../src/subagent/loader.js'); + vi.mocked(loadAgentProfiles).mockReturnValue([]); + const res = await settingsRouter.request('/agents/global-only?cwd=/my-project', { + method: 'DELETE', + }); + expect(res.status).toBe(500); + }); +}); diff --git a/packages/codingcode/test/session/compute-paths.test.ts b/packages/codingcode/test/session/compute-paths.test.ts index 981ef59d..338ed372 100644 --- a/packages/codingcode/test/session/compute-paths.test.ts +++ b/packages/codingcode/test/session/compute-paths.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from 'vitest'; import { rmSync, existsSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; @@ -11,8 +10,10 @@ import { projectSessionsDir, } from '../../src/session/file-ops.js'; import { normalizePath, encodeProjectPath } from '../../src/core/path.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); + +const base = useTempProjectBase(); function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); @@ -49,7 +50,11 @@ describe('computePaths', () => { const state = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(cwd, 'test-model'); + return yield* svc.create(cwd, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); @@ -61,7 +66,7 @@ describe('computePaths', () => { expect(state.cwd).toBe(expected.cwd); expect(existsSync(state.transcriptPath)).toBe(true); } finally { - rmSync(join(PROJECT_BASE, state.projectPath), { recursive: true, force: true }); + rmSync(join(base.dir, state.projectPath), { recursive: true, force: true }); } }); @@ -70,7 +75,11 @@ describe('computePaths', () => { const state = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(cwd, 'test-model'); + return yield* svc.create(cwd, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); @@ -78,7 +87,11 @@ describe('computePaths', () => { const childState = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(cwd, 'subagent-model', { + return yield* svc.create(cwd, { + model: 'subagent-model', + mode: 'build', + permissionMode: 'default', + }, { parentSessionId: state.sessionId, }); }) @@ -92,10 +105,10 @@ describe('computePaths', () => { expect(existsSync(childState.transcriptPath)).toBe(true); expect(existsSync(childState.indexPath)).toBe(true); } finally { - rmSync(join(PROJECT_BASE, childState.projectPath), { recursive: true, force: true }); + rmSync(join(base.dir, childState.projectPath), { recursive: true, force: true }); } } finally { - rmSync(join(PROJECT_BASE, state.projectPath), { recursive: true, force: true }); + rmSync(join(base.dir, state.projectPath), { recursive: true, force: true }); } }); }); diff --git a/packages/codingcode/test/session/filter-ui.test.ts b/packages/codingcode/test/session/filter-ui.test.ts index 21c2883a..f58fc606 100644 --- a/packages/codingcode/test/session/filter-ui.test.ts +++ b/packages/codingcode/test/session/filter-ui.test.ts @@ -1,119 +1,6 @@ import { describe, it, expect } from 'vitest'; -import type { SessionEvent, SummaryEvent, CompactEvent } from '../../src/session/types.js'; - -function filterForUI(events: SessionEvent[]): SessionEvent[] { - const rollbackHiddenTurnIds = new Set(); - const rollbackHiddenOpUuids = new Set(); - - for (const ev of events) { - if (ev.type !== 'rollback') continue; - for (const prior of events) { - if (prior === ev) break; - if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { - rollbackHiddenTurnIds.add(prior.turnId); - } - if (prior.type === 'summary' || prior.type === 'compact') { - if ((prior as SummaryEvent | CompactEvent).endTurnId >= ev.throughTurnId) { - rollbackHiddenOpUuids.add((prior as SummaryEvent | CompactEvent).uuid); - } - } - } - } - - return events.filter((ev) => { - if (ev.type === 'rollback') return false; - if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as SummaryEvent).uuid)) return false; - if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as CompactEvent).uuid)) return false; - if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; - return true; - }) as SessionEvent[]; -} - -function createTurnScopedIdGenerator() { - const counters = new Map(); - return (prefix: string, turnId: number): string => { - const key = `${prefix}:${turnId}`; - const next = (counters.get(key) ?? 0) + 1; - counters.set(key, next); - return `${prefix}-${turnId}-${next}`; - }; -} - -function sessionEventsToTurns( - events: SessionEvent[] -): Array<{ id: string; items: object[]; status: string }> { - const turnsMap = new Map(); - const nextId = createTurnScopedIdGenerator(); - - for (const event of events) { - if (event.type === 'session_meta') continue; - if (event.type === 'compact' || event.type === 'rollback') continue; - - if (event.type === 'summary') { - let turn = turnsMap.get(event.endTurnId); - if (!turn) { - turn = { id: String(event.endTurnId), items: [], status: 'completed' }; - turnsMap.set(event.endTurnId, turn); - } - turn.items.push({ - id: `summary-${event.uuid}`, - type: 'summary', - content: event.summaryText, - startTurnId: event.startTurnId, - endTurnId: event.endTurnId, - }); - continue; - } - - let turn = turnsMap.get(event.turnId); - if (!turn) { - turn = { id: String(event.turnId), items: [], status: 'completed' }; - turnsMap.set(event.turnId, turn); - } - switch (event.type) { - case 'user': - turn.items.push({ - id: nextId('user', event.turnId), - type: 'message', - role: 'user', - content: event.content, - }); - break; - case 'assistant': - if (event.content) { - turn.items.push({ - id: nextId('assistant', event.turnId), - type: 'message', - role: 'assistant', - content: event.content, - }); - } - for (const tc of event.toolCalls ?? []) { - const args = tc.arguments ?? {}; - turn.items.push({ - id: tc.id, - type: 'tool_call', - name: tc.name, - args, - status: 'approved', - }); - } - break; - case 'tool_result': { - const item: Record = { - id: `result-${event.toolCallId}`, - type: 'tool_result', - callId: event.toolCallId, - name: event.toolName, - output: event.output, - }; - turn.items.push(item); - break; - } - } - } - return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); -} +import type { SessionEvent } from '../../src/session/types.js'; +import { filterForUI, sessionEventsToTurns } from '../../src/session/ui-history.js'; function makeBaseEvents(extra: SessionEvent[] = []): SessionEvent[] { const base: SessionEvent[] = [ diff --git a/packages/codingcode/test/session/fork.test.ts b/packages/codingcode/test/session/fork.test.ts index 3476fdd5..2227879e 100644 --- a/packages/codingcode/test/session/fork.test.ts +++ b/packages/codingcode/test/session/fork.test.ts @@ -1,18 +1,18 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; import { filterForContext, buildContextMessages } from '../../src/context/service.js'; import { readHistory } from '../../src/session/file-ops.js'; import type { SessionIndex, SessionEvent } from '../../src/session/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); function makeFixture(sessionId: string, slug: string) { - const dir = join(PROJECT_BASE, slug, 'sessions'); + const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); const indexPath = join(dir, `${sessionId}.index.json`); @@ -143,7 +143,7 @@ describe('forkSession', () => { expect((newEvents[2] as any).content).toBe('reply1'); expect((newEvents[3] as any).content).toBe('second'); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); @@ -197,7 +197,7 @@ describe('forkSession', () => { expect(forkedToolResult).toBeDefined(); expect(forkedToolResult!.toolCallId).toBe(forkedAssistant!.toolCalls[0]!.id); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); @@ -261,7 +261,7 @@ describe('forkSession', () => { const forkUserContents = forkMessages.filter((m) => m.role === 'user').map((m) => m.content); expect(forkUserContents).toEqual(['first']); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); @@ -301,14 +301,14 @@ describe('forkSession', () => { expect(idx.permissionMode).toBe('default'); expect(idx.model).toBe('test'); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); it('fork preserves summary/compact uuid (no regeneration)', async () => { const sessionId = randomUUID(); const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug, 'sessions'); + const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); const indexPath = join(dir, `${sessionId}.index.json`); @@ -379,7 +379,7 @@ describe('forkSession', () => { expect((forkedSummary! as any).uuid).toBe(fixedSummaryUuid); expect((forkedCompact! as any).uuid).toBe(fixedCompactUuid); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); }); diff --git a/packages/codingcode/test/session/index-write-sync.test.ts b/packages/codingcode/test/session/index-write-sync.test.ts index 21e8fd37..92c3e6c0 100644 --- a/packages/codingcode/test/session/index-write-sync.test.ts +++ b/packages/codingcode/test/session/index-write-sync.test.ts @@ -1,14 +1,15 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; + import { encodeProjectPath } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); @@ -17,14 +18,18 @@ function run(eff: Effect.Effect): Promise { describe('index write is synchronous', () => { it('recordUser immediately updates index file', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + 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, 'test-model'); + return yield* svc.create(dir, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); @@ -44,21 +49,25 @@ describe('index write is synchronous', () => { expect(after.messageCount).toBe(2); expect(after.title).toBe('hello'); } finally { - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); + 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(PROJECT_BASE, slug); + 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, 'test-model'); + return yield* svc.create(dir, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); @@ -83,7 +92,7 @@ describe('index write is synchronous', () => { const updated = JSON.parse(readFileSync(indexPath, 'utf8')) as SessionIndex; expect(updated.messageCount).toBe(3); } finally { - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); + rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true }); } }); diff --git a/packages/codingcode/test/session/load-create.test.ts b/packages/codingcode/test/session/load-create.test.ts index fbbf2977..9507bc4a 100644 --- a/packages/codingcode/test/session/load-create.test.ts +++ b/packages/codingcode/test/session/load-create.test.ts @@ -1,36 +1,41 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; import { AgentError } from '../../src/core/error.js'; import { encodeProjectPath } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); + +const base = useTempProjectBase(); function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); } function cleanup(dir: string) { - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); + rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); 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 () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); try { const created = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(dir, 'gpt-4o'); + return yield* svc.create(dir, { + model: 'gpt-4o', + mode: 'build', + permissionMode: 'default', + }); }) ); const sid = created.sessionId; @@ -52,14 +57,18 @@ describe('load — restores model from disk, not overwritten', () => { it('load then rollbackToTurn preserves real model in index.json', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); try { const created = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(dir, 'claude-3-5-sonnet'); + return yield* svc.create(dir, { + model: 'claude-3-5-sonnet', + mode: 'build', + permissionMode: 'default', + }); }) ); const sid = created.sessionId; @@ -92,7 +101,7 @@ describe('load — restores model from disk, not overwritten', () => { it('load nonexistent session fails with SESSION_NOT_FOUND', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); try { @@ -115,16 +124,20 @@ describe('load — restores model from disk, not overwritten', () => { it('load mismatched workspace fails with SESSION_WORKSPACE_MISMATCH', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); - const otherDir = join(PROJECT_BASE, randomUUID()); + const otherDir = join(base.dir, randomUUID()); mkdirSync(otherDir, { recursive: true }); try { const created = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(dir, 'gpt-4o'); + return yield* svc.create(dir, { + model: 'gpt-4o', + mode: 'build', + permissionMode: 'default', + }); }) ); @@ -150,14 +163,18 @@ describe('load — restores model from disk, not overwritten', () => { describe('create — generates sessionId internally', () => { it('create without sessionId generates a new UUID', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + 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, 'test-model'); + return yield* svc.create(dir, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); @@ -172,14 +189,18 @@ describe('create — generates sessionId internally', () => { it('create writes model to index.json immediately', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + 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, 'my-special-model'); + return yield* svc.create(dir, { + model: 'my-special-model', + mode: 'build', + permissionMode: 'default', + }); }) ); @@ -192,14 +213,18 @@ describe('create — generates sessionId internally', () => { it('create returns default values for persisted fields', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + 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, 'test-model'); + return yield* svc.create(dir, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); @@ -215,14 +240,18 @@ describe('create — generates sessionId internally', () => { describe('load restores persisted fields', () => { it('load restores currentTurnId from index.json', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); try { const created = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(dir, 'test-model'); + return yield* svc.create(dir, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); const sid = created.sessionId; @@ -262,14 +291,18 @@ describe('load restores persisted fields', () => { it('load restores usage from index.json', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); try { const created = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(dir, 'test-model'); + return yield* svc.create(dir, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); const sid = created.sessionId; diff --git a/packages/codingcode/test/session/load-restore-profile.test.ts b/packages/codingcode/test/session/load-restore-profile.test.ts new file mode 100644 index 00000000..5d5dccff --- /dev/null +++ b/packages/codingcode/test/session/load-restore-profile.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { mkdirSync, writeFileSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { SessionService } from '../../src/session/store.js'; +import { BUILD_PROFILE } from '../../src/subagent/registry.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { McpService } from '../../src/mcp/index.js'; +import { SubagentService } from '../../src/subagent/registry.js'; +import { RulesService } from '../../src/rules/index.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +const base = useTempProjectBase(); + +const mockHookService = { + register: () => Effect.succeed(() => {}), + registerDecision: () => Effect.succeed(() => {}), + emit: () => Effect.succeed(undefined), + emitDecision: () => Effect.succeed(null), + reloadUserHooks: () => Effect.succeed(undefined), + attachSessionHooks: () => Effect.succeed(undefined), + disableHook: () => Effect.succeed(undefined), + enableHook: () => Effect.succeed(undefined), + disposeSession: () => Effect.succeed(undefined), + disposeProject: () => Effect.succeed(undefined), +}; + +const mockMcpService = { + syncConnections: () => Effect.succeed(undefined), + connectServers: () => Effect.succeed(undefined), + listProjectMcpTools: () => [], + disposeSession: () => Effect.succeed(undefined), +} as any; + +const mockRulesService = { + getAllRules: () => '', + evictProjectRules: () => undefined, +} as any; + +function makeLayer() { + const HookTestLayer = Layer.succeed(HookService, mockHookService as any); + const McpTestLayer = Layer.succeed(McpService, mockMcpService); + const SubagentTestLayer = SubagentService.Default; + const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); + const SessionTestLayer = SessionService.Default; + const ProjectRuntimeTestLayer = ProjectRuntimeService.Default.pipe( + Layer.provide(Layer.mergeAll(HookTestLayer, McpTestLayer, SubagentTestLayer, RulesTestLayer, SessionTestLayer)) + ); + return Layer.mergeAll(ProjectRuntimeTestLayer, SessionTestLayer); +} + +describe('SessionStoreState.activeProfile persistence', () => { + let cwd: string; + let sessionId: string; + let indexPath: string; + let rt: ManagedRuntime.ManagedRuntime; + + beforeEach(async () => { + cwd = join(base.dir, 'load-restore-profile'); + mkdirSync(cwd, { recursive: true }); + rt = ManagedRuntime.make(makeLayer() as any); + const result = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.create(cwd, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); + return { sessionId: state.sessionId, indexPath: state.indexPath }; + }) + ); + sessionId = result.sessionId; + indexPath = result.indexPath; + }); + + afterEach(async () => { + await rt.dispose(); + }); + + it('state.activeProfile is undefined for new sessions (set by setSessionProfile)', async () => { + // session.create() no longer writes activeProfile. After explicitly + // calling setSessionProfile, activeProfile is written to disk. + const stateBefore = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.load(cwd, sessionId); + }) + ); + expect(stateBefore.activeProfile).toBeUndefined(); + + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.setSessionProfile(cwd, sessionId, BUILD_PROFILE); + }) + ); + + const stateAfter = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.load(cwd, sessionId); + }) + ); + expect(stateAfter.activeProfile).toBe('build'); + }); + + it('state.activeProfile is set when index has activeProfile field', async () => { + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + idx.activeProfile = 'plan'; + // After the plan refactor, `permissionMode` no longer encodes plan-mode. + // Set it to 'default' to match what the runtime now writes. + idx.permissionMode = 'default'; + writeFileSync(indexPath, JSON.stringify(idx, null, 2)); + + const state = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.load(cwd, sessionId); + }) + ); + expect(state.activeProfile).toBe('plan'); + }); + + it('runtime.getSessionProfile reflects restored profile after restoreSessionProfile', async () => { + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + idx.activeProfile = 'plan'; + idx.permissionMode = 'default'; + writeFileSync(indexPath, JSON.stringify(idx, null, 2)); + + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const session = yield* SessionService; + yield* runtime.prepareProject(cwd); + const state = yield* session.load(cwd, sessionId); + expect(state.activeProfile).toBe('plan'); + yield* runtime.restoreSessionProfile(cwd, sessionId, state.activeProfile); + const profile = runtime.getSessionProfile(sessionId); + expect(profile?.name).toBe('plan'); + // Approval-side permission mode is 'default' (pipeline is plan-blind). + expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); + }) + ); + }); +}); diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index 49a73536..a8b208cc 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -1,23 +1,24 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; + import { estimatePromptTokens } from '../../src/context/service.js'; import { estimateTokensForContent } from '../../src/core/util.js'; import { encodeProjectPath } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); function makeFixture( sessionId: string, slug: string, usage?: { prompt: number; completion: number; total: number } ) { - const dir = join(PROJECT_BASE, slug, 'sessions'); + const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); const indexPath = join(dir, `${sessionId}.index.json`); @@ -117,7 +118,7 @@ describe('promptEstimate', () => { const idx = JSON.parse(readFileSync(newIndexPath, 'utf8')) as SessionIndex; expect(idx.usage).toEqual(usage); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); @@ -151,7 +152,7 @@ describe('promptEstimate', () => { expect(idx.sessionId).toBe(newSessionId); expect(estimatePromptTokens(join(fx.dir, `${newSessionId}.jsonl`))).toBeGreaterThan(0); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); }); @@ -166,13 +167,17 @@ describe('token estimation', () => { describe('SessionService create sets model', () => { it('create sets state.model and persists it to index', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + 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, 'my-test-model'); + return yield* svc.create(dir, { + model: 'my-test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); expect(state.model).toBe('my-test-model'); @@ -181,7 +186,7 @@ describe('SessionService create sets model', () => { expect(idx.model).toBe('my-test-model'); } finally { await new Promise((r) => setTimeout(r, 50)); - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); + rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); rmSync(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 47c12da0..f1fa1c4c 100644 --- a/packages/codingcode/test/session/record-tool-result-persist.test.ts +++ b/packages/codingcode/test/session/record-tool-result-persist.test.ts @@ -2,8 +2,12 @@ import { describe, it, expect, vi } from 'vitest'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +useTempProjectBase(); + function run(eff: Effect.Effect): Promise { - return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); + return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); } describe('recordToolResult', () => { @@ -11,7 +15,11 @@ describe('recordToolResult', () => { const state = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create('/tmp/persist-test', 'test-model'); + return yield* svc.create('/tmp/persist-test', { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); @@ -40,7 +48,11 @@ describe('recordToolResult', () => { const state = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create('/tmp/persist-test-small', 'test-model'); + return yield* svc.create('/tmp/persist-test-small', { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); diff --git a/packages/codingcode/test/session/rollback.test.ts b/packages/codingcode/test/session/rollback.test.ts index d251d9c7..fb24f5a5 100644 --- a/packages/codingcode/test/session/rollback.test.ts +++ b/packages/codingcode/test/session/rollback.test.ts @@ -1,16 +1,16 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, appendFileSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { filterForContext, buildContextMessages } from '../../src/context/service.js'; import { readHistory } from '../../src/session/file-ops.js'; import type { SessionIndex } from '../../src/session/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); function makeFixture(sessionId: string, slug: string) { - const dir = join(PROJECT_BASE, slug, 'sessions'); + const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); const indexPath = join(dir, `${sessionId}.index.json`); @@ -84,7 +84,7 @@ describe('rollback', () => { const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); expect(userContents).toEqual([]); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); @@ -104,7 +104,7 @@ describe('rollback', () => { const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); expect(userContents).toEqual(['hello']); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); }); diff --git a/packages/codingcode/test/session/session-jsonl-path.test.ts b/packages/codingcode/test/session/session-jsonl-path.test.ts index d8585e70..eca8144f 100644 --- a/packages/codingcode/test/session/session-jsonl-path.test.ts +++ b/packages/codingcode/test/session/session-jsonl-path.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect } from 'vitest'; import { rmSync, existsSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; + import { sessionJsonlPathFromCwd, deleteSession } from '../../src/session/file-ops.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); @@ -18,7 +19,11 @@ describe('sessionJsonlPathFromCwd', () => { const state = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(cwd, 'test-model'); + return yield* svc.create(cwd, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); @@ -27,7 +32,7 @@ describe('sessionJsonlPathFromCwd', () => { expect(result).toBe(state.transcriptPath); expect(existsSync(result)).toBe(true); } finally { - rmSync(join(PROJECT_BASE, state.projectPath), { recursive: true, force: true }); + rmSync(join(base.dir, state.projectPath), { recursive: true, force: true }); } }); @@ -36,7 +41,11 @@ describe('sessionJsonlPathFromCwd', () => { const state = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(cwd, 'test-model'); + return yield* svc.create(cwd, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); @@ -49,7 +58,7 @@ describe('sessionJsonlPathFromCwd', () => { expect(existsSync(state.transcriptPath)).toBe(false); expect(existsSync(state.indexPath)).toBe(false); } finally { - rmSync(join(PROJECT_BASE, state.projectPath), { recursive: true, force: true }); + rmSync(join(base.dir, state.projectPath), { recursive: true, force: true }); } }); }); diff --git a/packages/codingcode/test/session/store-compact-usage.test.ts b/packages/codingcode/test/session/store-compact-usage.test.ts index 6ce55efa..89d4459f 100644 --- a/packages/codingcode/test/session/store-compact-usage.test.ts +++ b/packages/codingcode/test/session/store-compact-usage.test.ts @@ -1,14 +1,15 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; + import { encodeProjectPath } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); @@ -23,7 +24,7 @@ function makeFixture( usage: { prompt: number; completion: number; total: number } | undefined; }> ) { - const dir = join(PROJECT_BASE, slug, 'sessions'); + const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); const indexPath = join(dir, `${sessionId}.index.json`); @@ -116,7 +117,7 @@ describe('SessionService.appendSummary - state.usage reset (used by tryCompactio const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; expect(idx.usage).toBeUndefined(); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); @@ -136,7 +137,7 @@ describe('SessionService.appendSummary - state.usage reset (used by tryCompactio const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; expect(idx.usage).toBeUndefined(); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + 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 2a6d1922..115b7358 100644 --- a/packages/codingcode/test/session/store-diff-rebuild.test.ts +++ b/packages/codingcode/test/session/store-diff-rebuild.test.ts @@ -1,91 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { SessionEvent } from '../../src/session/types.js'; - -function createTurnScopedIdGenerator() { - const counters = new Map(); - return (prefix: string, turnId: number): string => { - const key = `${prefix}:${turnId}`; - const next = (counters.get(key) ?? 0) + 1; - counters.set(key, next); - return `${prefix}-${turnId}-${next}`; - }; -} - -function sessionEventsToTurns( - events: SessionEvent[] -): Array<{ id: string; items: object[]; status: string }> { - const turnsMap = new Map(); - const nextId = createTurnScopedIdGenerator(); - - for (const event of events) { - if (event.type === 'session_meta') continue; - if (event.type === 'compact' || event.type === 'rollback') continue; - - if (event.type === 'summary') { - let turn = turnsMap.get(event.endTurnId); - if (!turn) { - turn = { id: String(event.endTurnId), items: [], status: 'completed' }; - turnsMap.set(event.endTurnId, turn); - } - turn.items.push({ - id: `summary-${event.uuid}`, - type: 'summary', - content: event.summaryText, - startTurnId: event.startTurnId, - endTurnId: event.endTurnId, - }); - continue; - } - - let turn = turnsMap.get(event.turnId); - if (!turn) { - turn = { id: String(event.turnId), items: [], status: 'completed' }; - turnsMap.set(event.turnId, turn); - } - switch (event.type) { - case 'user': - turn.items.push({ - id: nextId('user', event.turnId), - type: 'message', - role: 'user', - content: event.content, - }); - break; - case 'assistant': - if (event.content) { - turn.items.push({ - id: nextId('assistant', event.turnId), - type: 'message', - role: 'assistant', - content: event.content, - }); - } - for (const tc of event.toolCalls ?? []) { - const args = tc.arguments ?? {}; - turn.items.push({ - id: tc.id, - type: 'tool_call', - name: tc.name, - args, - status: 'approved', - }); - } - break; - case 'tool_result': { - const item: Record = { - id: `result-${event.toolCallId}`, - type: 'tool_result', - callId: event.toolCallId, - name: event.toolName, - output: event.output, - }; - turn.items.push(item); - break; - } - } - } - return [...turnsMap.values()].sort((a, b) => Number(a.id) - Number(b.id)); -} +import { sessionEventsToTurns } from '../../src/session/ui-history.js'; describe('sessionEventsToTurns', () => { it('parses edit_file tool_result without diff (diff is computed on frontend)', () => { diff --git a/packages/codingcode/test/session/store-rollback-usage.test.ts b/packages/codingcode/test/session/store-rollback-usage.test.ts index 6e8ab913..4bbf86ce 100644 --- a/packages/codingcode/test/session/store-rollback-usage.test.ts +++ b/packages/codingcode/test/session/store-rollback-usage.test.ts @@ -1,14 +1,15 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.js'; + import { encodeProjectPath } from '../../src/core/path.js'; import type { SessionIndex } from '../../src/session/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); @@ -23,7 +24,7 @@ function makeFixture( usage: { prompt: number; completion: number; total: number } | undefined; }> ) { - const dir = join(PROJECT_BASE, slug, 'sessions'); + const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); const indexPath = join(dir, `${sessionId}.index.json`); @@ -116,7 +117,7 @@ describe('SessionService.rollbackToTurn - state.usage reset', () => { const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; expect(idx.usage).toBeUndefined(); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); @@ -143,7 +144,7 @@ describe('SessionService.rollbackToTurn - state.usage reset', () => { const idx = JSON.parse(readFileSync(fx.indexPath, 'utf8')) as SessionIndex; expect(idx.usage).toEqual(usage1); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); @@ -167,7 +168,7 @@ describe('SessionService.rollbackToTurn - state.usage reset', () => { ); expect(state.usage).toEqual(usage1); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); }); diff --git a/packages/codingcode/test/session/ui-history-rollback.test.ts b/packages/codingcode/test/session/ui-history-rollback.test.ts index 79b0da82..07ace019 100644 --- a/packages/codingcode/test/session/ui-history-rollback.test.ts +++ b/packages/codingcode/test/session/ui-history-rollback.test.ts @@ -1,44 +1,17 @@ import { describe, it, expect } from 'vitest'; import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { filterForContext, buildContextMessages } from '../../src/context/service.js'; import { readHistory } from '../../src/session/file-ops.js'; -import type { SessionIndex, SessionEvent } from '../../src/session/types.js'; +import { filterForUI } from '../../src/session/ui-history.js'; +import type { SessionIndex } from '../../src/session/types.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; -function filterForUI(events: SessionEvent[]): SessionEvent[] { - const rollbackHiddenTurnIds = new Set(); - const rollbackHiddenOpUuids = new Set(); - - for (const ev of events) { - if (ev.type !== 'rollback') continue; - for (const prior of events) { - if (prior === ev) break; - if ('turnId' in prior && prior.turnId >= ev.throughTurnId) { - rollbackHiddenTurnIds.add(prior.turnId); - } - if (prior.type === 'summary' || prior.type === 'compact') { - if ((prior as any).endTurnId >= ev.throughTurnId) { - rollbackHiddenOpUuids.add((prior as any).uuid); - } - } - } - } - - return events.filter((ev) => { - if (ev.type === 'rollback') return false; - if (ev.type === 'summary' && rollbackHiddenOpUuids.has((ev as any).uuid)) return false; - if (ev.type === 'compact' && rollbackHiddenOpUuids.has((ev as any).uuid)) return false; - if ('turnId' in ev && rollbackHiddenTurnIds.has(ev.turnId)) return false; - return true; - }) as SessionEvent[]; -} - -const PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); function makeFixture(sessionId: string, slug: string, extraEvents?: object[]) { - const dir = join(PROJECT_BASE, slug, 'sessions'); + const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const transcriptPath = join(dir, `${sessionId}.jsonl`); const indexPath = join(dir, `${sessionId}.index.json`); @@ -115,7 +88,7 @@ describe('filterForContext', () => { const visibleTurnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); expect(visibleTurnIds).toEqual([]); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); }); @@ -133,7 +106,7 @@ describe('buildContextMessages with visibility filtering', () => { const userContents = messages.filter((m) => m.role === 'user').map((m) => m.content); expect(userContents).toEqual([]); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); }); @@ -157,7 +130,7 @@ describe('readUIHistory with visibility filtering', () => { { type: 'assistant', turnId: 2, content: 'bye', toolCalls: [] }, { type: 'rollback', throughTurnId: 1, reason: 'test' }, ]; - const dir = join(PROJECT_BASE, slug, 'sessions'); + const dir = join(base.dir, slug, 'sessions'); mkdirSync(dir, { recursive: true }); const tp = join(dir, `${sessionId}.jsonl`); writeFileSync(tp, events.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8'); @@ -182,7 +155,7 @@ describe('readUIHistory with visibility filtering', () => { const turnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); expect(turnIds).toEqual([]); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); @@ -195,7 +168,7 @@ describe('readUIHistory with visibility filtering', () => { const turnIds = visible.filter((e) => 'turnId' in e).map((e) => (e as any).turnId); expect(new Set(turnIds)).toEqual(new Set([1, 2, 3])); } finally { - rmSync(join(PROJECT_BASE, slug), { recursive: true, force: true }); + rmSync(join(base.dir, slug), { recursive: true, force: true }); } }); }); diff --git a/packages/codingcode/test/session/update-index-dedup.test.ts b/packages/codingcode/test/session/update-index-dedup.test.ts index eee8de76..09da81bf 100644 --- a/packages/codingcode/test/session/update-index-dedup.test.ts +++ b/packages/codingcode/test/session/update-index-dedup.test.ts @@ -1,14 +1,15 @@ import { describe, it, expect, vi } from 'vitest'; import { mkdirSync, rmSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { SessionService } from '../../src/session/store.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 PROJECT_BASE = join(homedir(), '.codingcode', 'project'); +const base = useTempProjectBase(); function run(eff: Effect.Effect): Promise { return Effect.runPromise(eff.pipe(Effect.provide(SessionService.Default) as any)); @@ -17,7 +18,7 @@ function run(eff: Effect.Effect): Promise { describe('updateIndex deduplication after removing appendEvent', () => { it('recordUser calls readCurrentIndex exactly once', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); const spy = vi.spyOn(fileOps, 'readCurrentIndex'); @@ -26,7 +27,11 @@ describe('updateIndex deduplication after removing appendEvent', () => { const state = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(dir, 'test-model'); + return yield* svc.create(dir, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); spy.mockClear(); @@ -41,14 +46,14 @@ describe('updateIndex deduplication after removing appendEvent', () => { expect(spy).toHaveBeenCalledTimes(1); } finally { spy.mockRestore(); - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); + rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true }); } }); it('recordAssistant calls readCurrentIndex exactly once', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); const spy = vi.spyOn(fileOps, 'readCurrentIndex'); @@ -57,7 +62,11 @@ describe('updateIndex deduplication after removing appendEvent', () => { const state = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(dir, 'test-model'); + return yield* svc.create(dir, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); spy.mockClear(); @@ -72,14 +81,14 @@ describe('updateIndex deduplication after removing appendEvent', () => { expect(spy).toHaveBeenCalledTimes(1); } finally { spy.mockRestore(); - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); + rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true }); } }); it('rollbackToTurn calls readCurrentIndex exactly once', async () => { const slug = randomUUID(); - const dir = join(PROJECT_BASE, slug); + const dir = join(base.dir, slug); mkdirSync(dir, { recursive: true }); const spy = vi.spyOn(fileOps, 'readCurrentIndex'); @@ -88,7 +97,11 @@ describe('updateIndex deduplication after removing appendEvent', () => { const state = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.create(dir, 'test-model'); + return yield* svc.create(dir, { + model: 'test-model', + mode: 'build', + permissionMode: 'default', + }); }) ); spy.mockClear(); @@ -103,7 +116,7 @@ describe('updateIndex deduplication after removing appendEvent', () => { expect(spy).toHaveBeenCalledTimes(1); } finally { spy.mockRestore(); - rmSync(join(PROJECT_BASE, encodeProjectPath(dir)), { recursive: true, force: true }); + rmSync(join(base.dir, encodeProjectPath(dir)), { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true }); } }); diff --git a/packages/codingcode/test/skills/layout.test.ts b/packages/codingcode/test/skills/layout.test.ts new file mode 100644 index 00000000..5cd8ba1c --- /dev/null +++ b/packages/codingcode/test/skills/layout.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync, existsSync } from 'fs'; +import { join, relative } from 'path'; + +const REPO_ROOT = join(process.cwd(), 'packages', 'codingcode'); +const SKILLS_SRC_DIR = join(REPO_ROOT, 'src', 'skills'); +const SEARCH_ROOTS = [join(REPO_ROOT, 'src'), join(REPO_ROOT, 'test')]; + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) walk(full, out); + else if (/\.(ts|tsx)$/.test(entry)) out.push(full); + } + return out; +} + +function collectAllFiles(): string[] { + return SEARCH_ROOTS.flatMap((root) => walk(root)); +} + +describe('skills module file layout', () => { + it('exposes source.ts (not config.ts) as the on-disk layer', () => { + expect(existsSync(join(SKILLS_SRC_DIR, 'source.ts'))).toBe(true); + expect(existsSync(join(SKILLS_SRC_DIR, 'config.ts'))).toBe(false); + }); + + it('does not import the renamed-away "skills/config" path anywhere', () => { + const stale: Array<{ file: string; line: number; text: string }> = []; + for (const file of collectAllFiles()) { + if (file.endsWith('layout.test.ts')) continue; + const text = readFileSync(file, 'utf8'); + const lines = text.split(/\r?\n/); + lines.forEach((line, i) => { + if (/['"][^'"]*skills[\\/]+config(\.js)?['"]/.test(line)) { + stale.push({ file: relative(REPO_ROOT, file), line: i + 1, text: line.trim() }); + } + }); + } + expect( + stale, + `stale "skills/config" imports found:\n${JSON.stringify(stale, null, 2)}` + ).toEqual([]); + }); +}); diff --git a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts new file mode 100644 index 00000000..c058be12 --- /dev/null +++ b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer } from 'effect'; +import { existsSync, readdirSync, mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { createDispatchAgentTool } from '../../src/tools/domains/subagent/dispatch.js'; +import { AppLayer } from '../../src/layer.js'; +import { SessionService } from '../../src/session/store.js'; +import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; +import { LLMFactoryService } from '../../src/llm/factory.js'; +import { readHistory } from '../../src/session/file-ops.js'; +import { encodeProjectPath, normalizePath, setProjectBaseDir } from '../../src/core/path.js'; +import type { LLMClient } from '../../src/llm/client.js'; +import { Result } from '../../src/core/result.js'; + +const TestLLMLayer = Layer.succeed( + LLMFactoryService, + ({ + listModels: () => Effect.succeed([]), + findModel: () => Effect.succeed(null), + getActiveEntry: () => Effect.fail(new Error('no active')), + switchModel: () => Effect.fail(new Error('no models')), + getLLMClient: () => Effect.succeed(makeMockLLM('subagent final answer')), + createClient: () => Effect.succeed(makeMockLLM('subagent final answer')), + } as any) +); + +function makeMockLLM(content: string): LLMClient { + return { + complete: () => Effect.succeed({ content, finishReason: 'stop' as const }), + completeStream: () => ({ + stream: (async function* () { + yield content; + })(), + response: Promise.resolve(Result.ok({ content, finishReason: 'stop' as const })), + }), + modelInfo: { + provider: 'mock', + model: 'mock', + maxTokens: 128000, + supportsToolCalling: false, + supportsStreaming: true, + }, + }; +} + +function run(eff: Effect.Effect): Promise { + return Effect.runPromise( + eff.pipe(Effect.provide(AppLayer as any), Effect.provide(TestLLMLayer)) as any + ); +} + +describe('dispatch_agent end-to-end (subagent reads its own jsonl)', () => { + let projectBase: string; + let cwd: string; + + beforeEach(() => { + projectBase = mkdtempSync(join(tmpdir(), 'codingcode-test-e2e-')); + setProjectBaseDir(projectBase); + cwd = mkdtempSync(join(tmpdir(), 'codingcode-test-cwd-')); + }); + + afterEach(() => { + if (existsSync(projectBase)) rmSync(projectBase, { recursive: true, force: true }); + if (existsSync(cwd)) rmSync(cwd, { recursive: true, force: true }); + }); + + it('subagent transcriptPath is /subagents/.jsonl and agentLoop reads it', async () => { + const result = await run( + Effect.gen(function* () { + const session = yield* SessionService; + const runtime = yield* ProjectRuntimeService; + + yield* runtime.prepareProject(cwd); + const parent = yield* session.create(cwd, { + model: 'parent-model', + mode: 'build', + permissionMode: 'default', + }); + + const dispatchTool = yield* createDispatchAgentTool(); + const output = yield* dispatchTool.execute( + { agent: 'explore', prompt: 'analyze this code' }, + { projectPath: cwd, sessionId: parent.sessionId } as any + ); + return { output, parentId: parent.sessionId }; + }) + ); + + expect(typeof result.output).toBe('string'); + expect(result.output.length).toBeGreaterThan(0); + + const sessionsRoot = join( + projectBase, + encodeProjectPath(normalizePath(cwd)), + 'sessions' + ); + const subagentDir = join(sessionsRoot, result.parentId, 'subagents'); + expect(existsSync(subagentDir)).toBe(true); + + const files = readdirSync(subagentDir).filter((f) => f.endsWith('.jsonl')); + expect(files.length).toBeGreaterThan(0); + + const childTranscriptPath = join(subagentDir, files[0]!); + const events = readHistory(childTranscriptPath); + + // First event: session_meta (written by session.create in dispatch.ts) + expect(events[0]!.type).toBe('session_meta'); + + // The user prompt recorded by dispatch.ts BEFORE invoking the runner. + // If agentLoop reads the wrong path, this event is invisible to the LLM, + // and the assistant response never lands. + const userEv = events.find((e) => e.type === 'user'); + expect(userEv).toBeDefined(); + if (userEv && userEv.type === 'user') { + expect(userEv.content).toBe('analyze this code'); + } + + // The LLM's reply lands on disk — proof that agentLoop read the jsonl, + // saw the user event, and emitted a real response. + const assistantEv = events.find((e) => e.type === 'assistant'); + expect(assistantEv).toBeDefined(); + }, 30_000); + + it('child session id does NOT produce a flat /.jsonl (old bug regression)', async () => { + const result = await run( + Effect.gen(function* () { + const session = yield* SessionService; + const runtime = yield* ProjectRuntimeService; + yield* runtime.prepareProject(cwd); + const parent = yield* session.create(cwd, { + model: 'parent-model', + mode: 'build', + permissionMode: 'default', + }); + const dispatchTool = yield* createDispatchAgentTool(); + yield* dispatchTool.execute( + { agent: 'explore', prompt: 'p' }, + { projectPath: cwd, sessionId: parent.sessionId } as any + ); + return { parentId: parent.sessionId }; + }) + ); + + const sessionsRoot = join( + projectBase, + encodeProjectPath(normalizePath(cwd)), + 'sessions' + ); + const subagentDir = join(sessionsRoot, result.parentId, 'subagents'); + const childFiles = readdirSync(subagentDir).filter((f) => f.endsWith('.jsonl')); + const childId = childFiles[0]!.replace('.jsonl', ''); + + // The wrong-path location (the bug from 3d493e4) MUST NOT contain the + // child's jsonl. If it did, some code constructed the path without + // parentSessionId. + const flatChildPath = join(sessionsRoot, `${childId}.jsonl`); + expect(existsSync(flatChildPath)).toBe(false); + }, 30_000); +}); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 20b87d90..575c330e 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -107,8 +107,8 @@ const mockModelEntry = { base_url: 'https://api.b.com', api_key_env: 'API_KEY_B', }; -const mockSubagentLlm = { _tag: 'subagent-llm' }; -const mockDefaultLlm = { _tag: 'default-llm' }; +const mockSubagentLlm = { _tag: 'subagent-llm', modelInfo: { model: 'subagent-model' } }; +const mockDefaultLlm = { _tag: 'default-llm', modelInfo: { model: 'default-model' } }; const mockLLMFactory = { listModels: vi.fn(() => Effect.succeed([])), @@ -157,7 +157,8 @@ const mockProjectRuntime = { allowToolSearch: true, allowDeferredTools: false, })), - setSessionProfile: vi.fn(), + setSessionProfile: vi.fn(() => Effect.void), + restoreSessionProfile: vi.fn(() => Effect.void), getSessionProfile: vi.fn(), disposeSession: vi.fn(() => Effect.void), disposeProject: vi.fn(() => Effect.void), @@ -323,6 +324,49 @@ describe('dispatch_agent tool', () => { ); }); + it('observer for agent.subagent.complete can yield* services from dispatch_agent fiber', async () => { + // Pin the dispatch.ts fix: `agent.subagent.complete` must be emitted in + // the dispatch_agent tool's Effect.gen fiber (not inside the + // Effect.async callback's async IIFE), so observers can yield* services + // like SessionService. Before the fix the emit was wrapped in + // `await Effect.runPromise(emit)`, which jumped to a fresh fiber with + // no services and would Die for any observer that yield*'d a service. + let observerRan = false; + let sessionResolved = false; + + const realHooksLayer = HookService.Default; + const customLayer = makeMockLayer({ hooks: realHooksLayer }); + + // Register observer, create the tool, and run the tool all in the same + // Effect.gen so they share the same HookService instance (a fresh + // HookService is built each time a layer is provided, so splitting this + // across multiple Effect.runPromise calls would register on one + // instance and emit on a different one). + const program = Effect.gen(function* () { + const hooks = yield* HookService; + yield* hooks.register( + 'agent.subagent.complete', + (_payload) => + Effect.gen(function* () { + const session = yield* SessionService; + observerRan = true; + sessionResolved = typeof session.create === 'function'; + }), + { source: 'system' } + ); + const tool = yield* createDispatchAgentTool(); + return yield* tool.execute( + { agent: 'explore', prompt: 'test' }, + { projectPath: '/test', sessionId: 'parent-1' } + ) as Effect.Effect; + }); + + await Effect.runPromise(Effect.provide(program, customLayer as any)); + + expect(observerRan).toBe(true); + expect(sessionResolved).toBe(true); + }); + it('should pass systemOverride with profile prompt, environment info, and user rules', async () => { let capturedSystemOverride: string | undefined; mockSubagentRunner.runStream.mockImplementation(async function* (opts: any) { @@ -447,7 +491,13 @@ describe('dispatch_agent tool', () => { ); expect(createFn).toHaveBeenCalledWith( '/test', - expect.any(String), + expect.objectContaining({ + model: expect.any(String), + mode: 'build', + // EXPLORE_PROFILE.permissionMode === 'bypass', which the dispatch + // tool now reads from the subagent's own profile. + permissionMode: 'bypass', + }), expect.objectContaining({ parentSessionId: 'parent-1', agentName: 'explore' }) ); }); diff --git a/packages/codingcode/test/subagent/loader.test.ts b/packages/codingcode/test/subagent/loader.test.ts index ee628042..4739c28f 100644 --- a/packages/codingcode/test/subagent/loader.test.ts +++ b/packages/codingcode/test/subagent/loader.test.ts @@ -1,6 +1,7 @@ -import { expect, it, describe, beforeEach, afterEach } from 'vitest'; +import { expect, it, describe, beforeEach, afterEach } from 'vitest'; import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; +import { tmpdir } from 'os'; import { loadAgentProfiles, writeAgentProfile, @@ -9,7 +10,7 @@ import { } from '../../src/subagent/loader'; describe('loadAgentProfiles', () => { - const testDir = join(process.cwd(), '.test-agents'); + const testDir = join(tmpdir(), 'codingcode-test-agents'); beforeEach(() => { mkdirSync(join(testDir, '.codingcode', 'agents'), { recursive: true }); @@ -224,7 +225,7 @@ System prompt.`; }); describe('writeAgentProfile', () => { - const testDir = join(process.cwd(), '.test-agents-write'); + const testDir = join(tmpdir(), 'codingcode-test-agents-write'); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); @@ -281,7 +282,7 @@ describe('writeAgentProfile', () => { }); describe('updateAgentProfile', () => { - const testDir = join(process.cwd(), '.test-agents-update'); + const testDir = join(tmpdir(), 'codingcode-test-agents-update'); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); @@ -305,7 +306,7 @@ describe('updateAgentProfile', () => { }); describe('deleteAgentProfile', () => { - const testDir = join(process.cwd(), '.test-agents-delete'); + const testDir = join(tmpdir(), 'codingcode-test-agents-delete'); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); diff --git a/packages/codingcode/test/subagent/plan-profile.test.ts b/packages/codingcode/test/subagent/plan-profile.test.ts index 79d91e85..86e429f1 100644 --- a/packages/codingcode/test/subagent/plan-profile.test.ts +++ b/packages/codingcode/test/subagent/plan-profile.test.ts @@ -1,13 +1,18 @@ import { describe, it, expect } from 'vitest'; -import { PLAN_PROFILE, EXPLORE_PROFILE } from '../../src/subagent/registry.js'; +import { PLAN_PROFILE, BUILD_PROFILE, EXPLORE_PROFILE } from '../../src/subagent/registry.js'; describe('PLAN_PROFILE', () => { it('has name "plan"', () => { expect(PLAN_PROFILE.name).toBe('plan'); }); - it('is readonly', () => { - expect(PLAN_PROFILE.readonly).toBe(true); + it('does NOT set a permissionMode (plan mode is enforced structurally by the plan-mode gate hook)', () => { + // After the plan refactor, the approval pipeline no longer special-cases + // a 'plan' PermissionMode. Plan mode is detected via `isPlanProfile(profile)` + // and enforced by the `plan/planModeGateHook` registered on + // `tool.approval.pre`. The profile intentionally has no `permissionMode` + // field so the approval pipeline treats it like any other profile. + expect(PLAN_PROFILE.permissionMode).toBeUndefined(); }); it('has maxSteps set to 180', () => { @@ -19,8 +24,8 @@ describe('PLAN_PROFILE', () => { expect(PLAN_PROFILE.systemPrompt!.length).toBeGreaterThan(50); }); - it('only includes read-only tools', () => { - const writeTools = ['write_file', 'edit_file']; + it('excludes write tools (the plan-mode gate hook enforces this at approval time)', () => { + const writeTools = ['write_file', 'edit_file', 'execute_command']; for (const wt of writeTools) { expect(PLAN_PROFILE.tools).not.toContain(wt); } @@ -31,8 +36,12 @@ describe('PLAN_PROFILE', () => { expect(PLAN_PROFILE.tools).toContain('search_code'); }); - it('includes execute_command for build checks', () => { - expect(PLAN_PROFILE.tools).toContain('execute_command'); + it('exposes submit_plan as the only allowed write in plan mode', () => { + expect(PLAN_PROFILE.tools).toContain('submit_plan'); + }); + + it('exposes dispatch_agent so the plan agent can delegate to explore', () => { + expect(PLAN_PROFILE.tools).toContain('dispatch_agent'); }); it('has a distinct name from explore', () => { @@ -43,3 +52,23 @@ describe('PLAN_PROFILE', () => { expect(PLAN_PROFILE.description.toLowerCase()).toContain('plan'); }); }); + +describe('BUILD_PROFILE', () => { + it('has name "build"', () => { + expect(BUILD_PROFILE.name).toBe('build'); + }); + + it('uses the default permission mode (full read/write)', () => { + expect(BUILD_PROFILE.permissionMode).toBe('default'); + }); + + it('exposes write tools (write_file, edit_file, execute_command)', () => { + expect(BUILD_PROFILE.tools).toContain('write_file'); + expect(BUILD_PROFILE.tools).toContain('edit_file'); + expect(BUILD_PROFILE.tools).toContain('execute_command'); + }); + + it('does not expose submit_plan (build mode does not need it)', () => { + expect(BUILD_PROFILE.tools).not.toContain('submit_plan'); + }); +}); diff --git a/packages/codingcode/test/subagent/registry.test.ts b/packages/codingcode/test/subagent/registry.test.ts index 2d4ed4fc..a4ed2759 100644 --- a/packages/codingcode/test/subagent/registry.test.ts +++ b/packages/codingcode/test/subagent/registry.test.ts @@ -1,6 +1,11 @@ import { expect, it, describe } from 'vitest'; import { Effect } from 'effect'; -import { SubagentService, EXPLORE_PROFILE, PLAN_PROFILE } from '../../src/subagent/registry'; +import { + SubagentService, + EXPLORE_PROFILE, + PLAN_PROFILE, + BUILD_PROFILE, +} from '../../src/subagent/registry'; import type { AgentProfile } from '../../src/subagent/types'; describe('SubagentService', () => { @@ -110,14 +115,24 @@ describe('SubagentService', () => { it('should support built-in plan profile', () => { expect(PLAN_PROFILE.name).toBe('plan'); - expect(PLAN_PROFILE.readonly).toBe(true); + // After the plan refactor, PLAN_PROFILE does not set a `permissionMode`. + // Plan mode is detected structurally via `isPlanProfile(profile)` and + // enforced by the `plan/planModeGateHook` registered on `tool.approval.pre`. + // The approval pipeline itself only sees generic permission modes. + expect(PLAN_PROFILE.permissionMode).toBeUndefined(); expect(PLAN_PROFILE.maxSteps).toBe(180); expect(PLAN_PROFILE.tools).toContain('read_file'); expect(PLAN_PROFILE.tools).toContain('search_files'); expect(PLAN_PROFILE.tools).toContain('search_code'); - expect(PLAN_PROFILE.tools).toContain('execute_command'); expect(PLAN_PROFILE.tools).toContain('fetch_url'); expect(PLAN_PROFILE.tools).toContain('tool_search'); + expect(PLAN_PROFILE.tools).toContain('submit_plan'); + expect(PLAN_PROFILE.tools).toContain('dispatch_agent'); + // Write tools are intentionally absent — the plan-mode gate hook denies + // them at approval time, and the catalog must not advertise them. + expect(PLAN_PROFILE.tools).not.toContain('write_file'); + expect(PLAN_PROFILE.tools).not.toContain('edit_file'); + expect(PLAN_PROFILE.tools).not.toContain('execute_command'); }); it('plan profile systemPrompt includes research process and output format', () => { @@ -128,6 +143,19 @@ describe('SubagentService', () => { expect(PLAN_PROFILE.systemPrompt).toContain('Recommended approach'); }); + it('plan profile systemPrompt teaches the LLM how to interpret post-submit user messages', () => { + // The plan refactor moved user decisions onto plain user messages + // (no async JSON envelope). The LLM has to know how to read them so + // it does not re-call submit_plan on its own initiative and does not + // ignore a revised-plan body. + expect(PLAN_PROFILE.systemPrompt).toContain('After submit_plan'); + expect(PLAN_PROFILE.systemPrompt).toContain('Implement'); + expect(PLAN_PROFILE.systemPrompt).toContain('Cancel'); + expect(PLAN_PROFILE.systemPrompt).toContain('submit_plan'); + expect(PLAN_PROFILE.systemPrompt).toMatch(/proceed/i); + expect(PLAN_PROFILE.systemPrompt).toMatch(/revised plan/i); + }); + it('should list profiles with project override', async () => { const globalProfile: AgentProfile = { name: 'agent1', @@ -218,3 +246,23 @@ describe('SubagentService', () => { expect(all.projectList.some((p) => p.name === 'p1')).toBe(true); }); }); + +describe('built-in profile set', () => { + it('exposes exactly {plan, build, explore} as the built-in global profiles', () => { + // The current product surface is intentionally limited to two main + // entry profiles (plan, build) plus the read-only explore subagent + // used by plan via dispatch_agent. The set-equivalence assertion + // catches accidental additions or removals during refactors. + const builtinNames = [PLAN_PROFILE.name, BUILD_PROFILE.name, EXPLORE_PROFILE.name].sort(); + expect(builtinNames).toEqual(['build', 'explore', 'plan']); + }); + + it('does not declare the removed isPrimary field on any built-in profile', () => { + // isPrimary was a forward-looking marker that no runtime code read; + // the field has been deleted from AgentProfile. This test guards + // against a future re-introduction without an actual consumer. + expect('isPrimary' in PLAN_PROFILE).toBe(false); + expect('isPrimary' in BUILD_PROFILE).toBe(false); + expect('isPrimary' in EXPLORE_PROFILE).toBe(false); + }); +}); 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 2e3202a3..82b3ca5e 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 @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { Effect } from 'effect'; -import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync, readFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { randomUUID } from 'crypto'; @@ -93,11 +93,18 @@ describe('tools/domains/fs projectPath isolation', () => { }); it('falls back to process.cwd() when ctx.projectPath is absent', async () => { - const cwd = process.cwd(); + const cwd = mkdtempSync(join(tmpdir(), 'codingcode-test-fallback-cwd-')); + const originalCwd = process.cwd(); + process.chdir(cwd); writeFileSync(join(cwd, 'h-test.txt'), 'fallback', 'utf8'); - const result = await Effect.runPromise( - readFileTool.execute({ path: 'h-test.txt', offset: 1, limit: 200 }, undefined) - ); - expect(result).toContain('fallback'); + try { + const result = await Effect.runPromise( + readFileTool.execute({ path: 'h-test.txt', offset: 1, limit: 200 }, undefined) + ); + expect(result).toContain('fallback'); + } finally { + process.chdir(originalCwd); + rmSync(cwd, { recursive: true, force: true }); + } }); }); diff --git a/packages/codingcode/test/tools/submit-plan-slug.test.ts b/packages/codingcode/test/tools/submit-plan-slug.test.ts new file mode 100644 index 00000000..fdcd90c8 --- /dev/null +++ b/packages/codingcode/test/tools/submit-plan-slug.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { slug } from '../../src/tools/domains/subagent/submit-plan.js'; + +describe('slug()', () => { + it('lowercases and joins with dashes', () => { + expect(slug('Add telemetry')).toBe('add-telemetry'); + }); + + it('lowercases all-caps', () => { + expect(slug('FOO')).toBe('foo'); + }); + + it('collapses runs of non-alphanumeric characters into a single dash', () => { + expect(slug('Foo!!@#Bar')).toBe('foo-bar'); + }); + + it('trims leading and trailing whitespace and dashes', () => { + expect(slug(' Foo ')).toBe('foo'); + expect(slug('---foo---')).toBe('foo'); + }); + + it('handles multi-word titles with spaces as separators', () => { + expect(slug('Foo Bar Baz')).toBe('foo-bar-baz'); + }); + + it('strips diacritics', () => { + expect(slug('Café déjà vu')).toBe('cafe-deja-vu'); + }); + + it('preserves CJK characters in the slug', () => { + expect(slug('写一篇《如果AI有了工资》幽默短文')).toBe( + '写一篇-如果ai有了工资-幽默短文' + ); + }); + + it('preserves mixed CJK + ASCII', () => { + expect(slug('添加 OAuth support')).toBe('添加-oauth-support'); + }); + + it('preserves hiragana and katakana', () => { + expect(slug('こんにちは')).toBe('こんにちは'); + expect(slug('カタカナ')).toBe('カタカナ'); + }); + + it('preserves digits', () => { + expect(slug('OAuth 2.0 support')).toBe('oauth-2-0-support'); + }); + + it('is deterministic for the same input', () => { + expect(slug('Same Title')).toBe(slug('Same Title')); + }); + + it('truncates to 80 characters', () => { + const long = 'a'.repeat(200); + expect(slug(long).length).toBe(80); + }); + + it('falls back to "plan" only when input is purely whitespace/punctuation', () => { + expect(slug('!!!')).toBe('plan'); + expect(slug(' ')).toBe('plan'); + }); +}); diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index db4bfed2..5216c714 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useLayoutEffect, useEffect } from 'react'; import { createPortal } from 'react-dom'; -import { Send, Square, ShieldAlert, ShieldCheck, Shield, Eye } from 'lucide-react'; +import { Send, Square, ShieldAlert, ShieldCheck, Shield, Eye, FileText } from 'lucide-react'; import { useAgentStore } from '../stores/agent.store'; import { useWorkspaceStore } from '../stores/workspace.store'; import { API_BASE, api } from '../lib/api'; @@ -8,6 +8,9 @@ import { setSessionPermissionMode } from '../lib/core-api'; import MessageStream from './MessageStream'; import TodoPanel from './TodoPanel'; import ApprovalPanel from './ApprovalPanel'; +import ModeIndicator from './ModeIndicator'; +import { APPROVAL_POLICY_TO_PERMISSION_MODE } from '../hooks/useAgent'; +import PlanPanel from '../shared/PlanPanel'; // ─── ContextIndicator ────────────────────────────────────────────────────── @@ -206,10 +209,12 @@ function InputBox({ centered, sendMessage, abort, + onOpenPlanPanel, }: { centered?: boolean; sendMessage: (content: string, cwd?: string) => Promise; abort: () => void; + onOpenPlanPanel?: () => void; }) { const [text, setText] = useState(''); const textareaRef = useRef(null); @@ -226,6 +231,17 @@ function InputBox({ const pendingInput = useAgentStore((s) => s.pendingInput); const setPendingInput = useAgentStore((s) => s.setPendingInput); + const isPlanMode = useAgentStore((s) => { + if (!s.currentThreadId) { + return s.pendingProfile === 'plan'; + } + return s.modeByThreadId[s.currentThreadId]?.mode === 'plan'; + }); + const planExists = useAgentStore((s) => { + if (!s.currentThreadId) return false; + return s.pendingPlanByThreadId[s.currentThreadId] != null; + }); + // Consume pendingInput when it's set useEffect(() => { if (pendingInput !== null) { @@ -308,22 +324,17 @@ function InputBox({ {/* Row 2: toolbar */}
+ {!isPlanMode && ( + )}
+ {planExists && onOpenPlanPanel && ( + + )} + {currentThreadId && }
@@ -356,6 +381,7 @@ export default function AgentWorkspace({ sendMessage, abort }: AgentWorkspacePro const currentThreadId = useAgentStore((s) => s.currentThreadId); const isCompressing = useAgentStore((s) => s.isCompressing); const workspace = useWorkspaceStore(); + const [planPanelOpen, setPlanPanelOpen] = useState(false); if (!currentThreadId) { return ( @@ -373,19 +399,32 @@ export default function AgentWorkspace({ sendMessage, abort }: AgentWorkspacePro } return ( -
- - - - {isCompressing && ( -
- - 正在压缩上下文... +
+
+ + + + {isCompressing && ( +
+ + 正在压缩上下文... +
+ )} +
+ setPlanPanelOpen(true)} + />
- )} -
-
+ {planPanelOpen && ( + setPlanPanelOpen(false)} + /> + )}
); } diff --git a/packages/desktop/src/agent/ApprovalPanel.tsx b/packages/desktop/src/agent/ApprovalPanel.tsx index dbc0f592..71d01e9d 100644 --- a/packages/desktop/src/agent/ApprovalPanel.tsx +++ b/packages/desktop/src/agent/ApprovalPanel.tsx @@ -1,8 +1,10 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import type { Item } from '@shared/types'; import { useAgentStore } from '../stores/agent.store'; -import { useAgentApproval } from '../hooks/useAgent'; +import { useAgentApproval, useAgentCore, useAgentMode } from '../hooks/useAgent'; import ToolCallCard from '../shared/ToolCallCard'; +import PlanApprovalModal from '../shared/PlanApprovalModal'; +import { useWorkspaceStore } from '../stores/workspace.store'; interface ApprovalPanelProps { threadId: string; @@ -11,8 +13,40 @@ interface ApprovalPanelProps { export default function ApprovalPanel({ threadId }: ApprovalPanelProps) { const [collapsed, setCollapsed] = useState(false); const { approveTool, rejectTool } = useAgentApproval(); + const { sendMessage } = useAgentCore(); + const { fetchPlan, switchMode } = useAgentMode(); + const workspace = useWorkspaceStore(); + + const pendingPlan = useAgentStore((s) => s.pendingPlanByThreadId[threadId] ?? null); + const clearPendingPlan = useAgentStore((s) => s.clearPendingPlan); + + const [planContent, setPlanContent] = useState(''); + const [planPath, setPlanPath] = useState(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!pendingPlan) return; + let cancelled = false; + setLoading(true); + fetchPlan(pendingPlan.sessionId, workspace.rootPath ?? '') + .then((snap) => { + if (cancelled) return; + setPlanContent(snap.content); + setPlanPath(snap.path); + }) + .catch(() => { + if (cancelled) return; + setPlanContent(''); + setPlanPath(undefined); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [pendingPlan, fetchPlan, workspace.rootPath]); - // Stable string key: only changes when pending item IDs change, not on every content update const pendingKey = useAgentStore((s) => { const thread = s.threads[threadId]; if (!thread) return ''; @@ -23,7 +57,6 @@ export default function ApprovalPanel({ threadId }: ApprovalPanelProps) { .join(','); }); - // Only compute pending items when the key changes const pendingItems = useMemo(() => { if (!pendingKey) return []; const thread = useAgentStore.getState().threads[threadId]; @@ -35,6 +68,44 @@ export default function ApprovalPanel({ threadId }: ApprovalPanelProps) { ); }, [pendingKey, threadId]); + const handleImplement = useCallback(async () => { + if (!pendingPlan) return; + const sessionId = pendingPlan.sessionId; + clearPendingPlan(threadId); + await switchMode(sessionId, 'build', workspace.rootPath ?? ''); + await sendMessage('Plan approved. Please start implementing it.', workspace.rootPath ?? ''); + }, [pendingPlan, clearPendingPlan, threadId, switchMode, sendMessage, workspace.rootPath]); + + const handleSubmitOpinion = useCallback( + async (opinion: string) => { + if (!pendingPlan) return; + clearPendingPlan(threadId); + await sendMessage( + `Please revise the plan based on this feedback:\n\n${opinion}`, + workspace.rootPath ?? '' + ); + }, + [pendingPlan, clearPendingPlan, threadId, sendMessage, workspace.rootPath] + ); + + const handleCancel = useCallback(() => { + clearPendingPlan(threadId); + }, [clearPendingPlan, threadId]); + + if (pendingPlan) { + return ( + void handleImplement()} + onSubmitOpinion={(op) => void handleSubmitOpinion(op)} + onCancel={() => void handleCancel()} + /> + ); + } + if (pendingItems.length === 0) return null; if (collapsed) { diff --git a/packages/desktop/src/agent/ModeIndicator.tsx b/packages/desktop/src/agent/ModeIndicator.tsx new file mode 100644 index 00000000..a647d38f --- /dev/null +++ b/packages/desktop/src/agent/ModeIndicator.tsx @@ -0,0 +1,139 @@ +import { useState, useEffect } from 'react'; +import { Eye, Hammer, Loader2 } from 'lucide-react'; +import { useAgentMode } from '../hooks/useAgent'; +import { useAgentStore } from '../stores/agent.store'; +import type { SessionMode } from '@codingcode/core/session/types'; + +interface ModeIndicatorProps { + sessionId: string | null; + cwd: string; +} + +const MODE_META: Record = { + plan: { + label: '计划模式', + color: 'text-[var(--accent-warning)] bg-[var(--tag-info-bg)]', + Icon: Eye, + }, + build: { + label: '构建模式', + color: 'text-[var(--accent-success)] bg-[var(--tag-action-bg)]', + Icon: Hammer, + }, +}; + +/** + * Compact status-bar pill that shows the current plan/build mode. + * + * Two modes of operation: + * - `sessionId !== null` — reads the live mode from the agent store and + * toggles by calling `switchMode` (server round-trip). + * - `sessionId === null` — welcome screen / no session yet. Shows the + * `pendingProfile` from the agent store and toggles it locally; the + * value is used as the initial mode when the user starts a session. + */ +export default function ModeIndicator({ sessionId, cwd }: ModeIndicatorProps) { + const { fetchMode, switchMode } = useAgentMode(); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + + const mode = useAgentStore((s) => + sessionId ? (s.modeByThreadId[sessionId] ?? null) : null + ); + const pendingProfile = useAgentStore((s) => s.pendingProfile); + const setPendingProfile = useAgentStore((s) => s.setPendingProfile); + const setModeForThread = useAgentStore((s) => s.setModeForThread); + const setOptimisticModeForThread = useAgentStore((s) => s.setOptimisticModeForThread); + + useEffect(() => { + let cancelled = false; + if (!sessionId) return; + + const existing = useAgentStore.getState().modeByThreadId[sessionId]; + if (existing && !existing.optimistic) return; + + if (!existing) { + const permissionMode = 'default' as const; + setOptimisticModeForThread(sessionId, { + mode: pendingProfile, + permissionMode, + }); + } + + setLoading(true); + const requestedAt = Date.now(); + fetchMode(sessionId, cwd) + .then((info) => { + if (cancelled) return; + setModeForThread(sessionId, { + mode: info.mode, + permissionMode: info.permissionMode, + requestedAt, + }); + }) + .catch((e) => { + if (cancelled) return; + console.error('Failed to fetch session mode:', e); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [sessionId, cwd, fetchMode, pendingProfile, setModeForThread, setOptimisticModeForThread]); + + const current: SessionMode = + sessionId === null ? pendingProfile : (mode?.mode ?? 'build'); + const target: SessionMode = current === 'plan' ? 'build' : 'plan'; + + const handleToggle = async () => { + if (busy) return; + + if (sessionId === null) { + setPendingProfile(target); + return; + } + + setBusy(true); + try { + const result = await switchMode(sessionId, target, cwd); + setModeForThread(sessionId, { + mode: result.mode, + permissionMode: result.permissionMode, + }); + } catch (e) { + console.error('Failed to switch mode:', e); + } finally { + setBusy(false); + } + }; + + const meta = MODE_META[current]; + const Icon = meta.Icon; + const disabled = sessionId === null ? false : loading || busy; + + return ( + + ); +} diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index dae383e2..a41d174d 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -4,6 +4,8 @@ import { useWorkspaceStore } from '../stores/workspace.store'; import { useRollbackStore } from '../stores/rollback.store'; import { agentClient } from '../lib/core-api'; import type { StreamChunk } from '@codingcode/core/client/types'; +import type { SessionMode } from '@codingcode/core/session/types'; +import type { PermissionMode } from '@codingcode/core/approval/types'; import { ApiError } from '../lib/api'; import { listModels, @@ -21,6 +23,9 @@ import { undoLastCodeRollback, getRollbackState, forkSession, + getSessionMode, + setSessionMode, + getSessionPlan, } from '../lib/core-api'; import type { CheckpointDiff, @@ -38,20 +43,72 @@ function randomId(): string { return crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2, 11); } -// Module-level abort controllers — shared across hooks (singleton pattern) -const abortControllers = new Map(); +const MAX_INFLIGHT_CONTROLLERS = 100; +const inflightControllers = new Map(); + +function abortAndClear(threadId: string): void { + const c = inflightControllers.get(threadId); + if (c) { + try { + c.abort(); + } catch { + /* ignore */ + } + inflightControllers.delete(threadId); + } +} + +function abortAndClearAll(): void { + for (const c of inflightControllers.values()) { + try { + c.abort(); + } catch { + /* ignore */ + } + } + inflightControllers.clear(); +} + +function registerInflight(threadId: string, controller: AbortController): void { + abortAndClear(threadId); + inflightControllers.set(threadId, controller); + while (inflightControllers.size > MAX_INFLIGHT_CONTROLLERS) { + const oldestKey = inflightControllers.keys().next().value; + if (oldestKey === undefined) break; + const oldest = inflightControllers.get(oldestKey); + inflightControllers.delete(oldestKey); + try { + oldest?.abort(); + } catch { + /* ignore */ + } + } +} + +export const APPROVAL_POLICY_TO_PERMISSION_MODE: Record< + 'ask-all' | 'smart-allow' | 'full-allow' | 'read-only', + PermissionMode +> = { + 'ask-all': 'default', + 'smart-allow': 'acceptEdits', + 'full-allow': 'bypass', + 'read-only': 'default', +}; // ---- useAgentCore: sendMessage + abort + initialization ---- export function useAgentCore() { + const lastRootRef = useRef(null); const startTurn = useAgentStore((s) => s.startTurn); const applyChunk = useAgentStore((s) => s.applyChunk); const updateTurnId = useAgentStore((s) => s.updateTurnId); const completeTurn = useAgentStore((s) => s.completeTurn); const setPendingInput = useAgentStore((s) => s.setPendingInput); + const setPendingPlan = useAgentStore((s) => s.setPendingPlan); const clearRunningTurns = useAgentStore((s) => s.clearRunningTurns); const applyTodoUpdate = useAgentStore((s) => s.applyTodoUpdate); const setCurrentThread = useAgentStore((s) => s.setCurrentThread); + const setCurrentThreadWithMode = useAgentStore((s) => s.setCurrentThreadWithMode); const loadThreads = useAgentStore((s) => s.loadThreads); const setThreadTurns = useAgentStore((s) => s.setThreadTurns); const setModel = useAgentStore((s) => s.setModel); @@ -62,6 +119,17 @@ export function useAgentCore() { const workspace = useWorkspaceStore(); const currentThreadId = useAgentStore((s) => s.currentThreadId); const approvalPolicy = useAgentStore((s) => s.approvalPolicy); + const pendingProfile = useAgentStore((s) => s.pendingProfile); + const modelId = useAgentStore((s) => s.model); + + // Abort all in-flight streams when the workspace root changes (project switch). + useEffect(() => { + const lastRoot = lastRootRef.current; + if (lastRoot !== null && lastRoot !== workspace.rootPath) { + abortAndClearAll(); + } + lastRootRef.current = workspace.rootPath; + }, [workspace.rootPath]); // Load sessions, models, and projects on mount useEffect(() => { @@ -163,6 +231,11 @@ export function useAgentCore() { args: event.args, status: 'pending', }; + case 'plan_ready': + // The server's plan.ready SSE event drives the plan-approval + // modal directly. We don't write a tool_call item — the modal + // renders from this payload via useAgentStore's pendingPlan. + return null; case 'tool_result': return { id: randomId(), @@ -226,19 +299,25 @@ export function useAgentCore() { let threadId = currentThreadId; if (!threadId) { - const POLICY_TO_MODE: Record = { - 'ask-all': 'default', - 'smart-allow': 'acceptEdits', - 'full-allow': 'bypass', - 'read-only': 'plan', - }; - const initialMode = POLICY_TO_MODE[approvalPolicy] ?? 'default'; - const data = await createServerSession(effectiveCwd, initialMode); + const mode: SessionMode = pendingProfile; + const permissionMode: PermissionMode = + pendingProfile === 'plan' + ? 'default' + : APPROVAL_POLICY_TO_PERMISSION_MODE[approvalPolicy] ?? 'default'; + const model = modelId; + if (!model) { + throw new Error('No model selected. Please select a model first.'); + } + const data = await createServerSession(effectiveCwd, { + mode, + permissionMode, + model, + }); threadId = data.sessionId; - setCurrentThread(threadId); + setCurrentThreadWithMode(threadId, { mode, permissionMode, optimistic: true }); } - if (abortControllers.has(threadId)) return; + if (inflightControllers.has(threadId)) return; let turnId = randomId(); let assistantMessageId = randomId(); @@ -248,7 +327,7 @@ export function useAgentCore() { startTurn(threadId, turn, { cwd: effectiveCwd, title: content.slice(0, 60) }); const controller = new AbortController(); - abortControllers.set(threadId, controller); + registerInflight(threadId, controller); try { const stream = agentClient.sendMessage(content, { @@ -265,6 +344,13 @@ export function useAgentCore() { hasError = true; } + if (event.type === 'plan_ready') { + setPendingPlan(threadId, { + sessionId: event.sessionId, + title: event.title, + }); + } + const item = streamChunkToItem(event, threadId, assistantMessageId, turnId); if (item) { applyChunk(threadId, turnId, item); @@ -285,17 +371,20 @@ export function useAgentCore() { applyChunk(threadId, turnId, { id: randomId(), type: 'error', message: msg }); completeTurn(threadId, turnId, 'error'); } finally { - abortControllers.delete(threadId); + abortAndClear(threadId); } }, [ startTurn, - setCurrentThread, + setCurrentThreadWithMode, streamChunkToItem, applyChunk, completeTurn, + setPendingPlan, workspace.rootPath, approvalPolicy, + pendingProfile, + modelId, currentThreadId, ] ); @@ -303,11 +392,7 @@ export function useAgentCore() { const abort = useCallback(() => { const threadId = currentThreadId; if (!threadId) return; - const controller = abortControllers.get(threadId); - if (controller) { - controller.abort(); - abortControllers.delete(threadId); - } + abortAndClear(threadId); }, [currentThreadId]); return { sendMessage, abort }; @@ -529,6 +614,7 @@ export function useAgentRollback() { const deleteThread = useCallback( async (threadId: string) => { + abortAndClear(threadId); const currentCwd = useWorkspaceStore.getState().rootPath; const wasCurrent = useAgentStore.getState().currentThreadId === threadId; try { @@ -536,35 +622,12 @@ export function useAgentRollback() { } catch (e) { console.error('Failed to delete session:', e); } - if (currentCwd) { - const sessions = await listSessions(currentCwd).catch(() => []); - if (sessions) { - const threads = sessions.map((s: any) => ({ - id: s.sessionId, - projectId: '', - title: s.title ?? s.sessionId.slice(0, 8), - cwd: normalizeCwd(s.cwd ?? ''), - turns: [], - createdAt: new Date(s.createdAt).getTime(), - updatedAt: new Date(s.updatedAt).getTime(), - })); - loadThreads(threads); - for (const s of sessions) { - if (s.usage) { - setThreadUsage(s.sessionId, { - prompt: s.usage.prompt, - completion: s.usage.completion, - total: s.usage.total, - }); - } - } - } - } + useAgentStore.getState().removeThread(threadId); if (wasCurrent) { useAgentStore.getState().setCurrentThread(null); } }, - [loadThreads, setThreadUsage] + [] ); return { @@ -591,3 +654,55 @@ export function useAgent() { const rollback = useAgentRollback(); return { ...core, ...approval, ...rollback }; } + +// ---- useAgentMode: plan/build mode switching + plan file access ---- + +export type SessionModeSnapshot = { + mode: SessionMode; + permissionMode: PermissionMode; + cwd: string; + available: Array<{ name: string; description: string }>; +}; + +export type PlanFileSnapshot = { + content: string; + path: string; + directory: string; + exists: boolean; +}; + +/** + * Hook for interacting with the plan/build mode of a single session, plus + * reading the persisted plan file. Each call returns a fresh API to the + * server — caching is done in the caller via useEffect / useState. + */ +export function useAgentMode() { + const workspace = useWorkspaceStore(); + + const fetchMode = useCallback( + async (sessionId: string, cwd?: string): Promise => { + return getSessionMode(sessionId, cwd ?? workspace.rootPath ?? ''); + }, + [workspace.rootPath] + ); + + const switchMode = useCallback( + async ( + sessionId: string, + mode: SessionMode, + cwd?: string + ): Promise<{ mode: SessionMode; permissionMode: PermissionMode }> => { + return setSessionMode(sessionId, cwd ?? workspace.rootPath ?? '', mode); + }, + [workspace.rootPath] + ); + + const fetchPlan = useCallback( + async (sessionId: string, cwd?: string): Promise => { + return getSessionPlan(sessionId, cwd ?? workspace.rootPath ?? ''); + }, + [workspace.rootPath] + ); + + return { fetchMode, switchMode, fetchPlan }; +} diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index 97ce6cc1..a383de08 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -1,10 +1,14 @@ import { API_BASE, api } from './api'; import { createHttpClients, type AgentRuntimeClient } from '@codingcode/core/client/http-clients'; +import type { PermissionMode } from '@codingcode/core/approval/types'; +import type { SessionMode } from '@codingcode/core/session/types'; const clients = createHttpClients(API_BASE); export const agentClient: AgentRuntimeClient = clients.agent; +export type { AgentRuntimeClient }; + // ---- Models ---- export function listModels(): Promise<{ @@ -26,9 +30,9 @@ export function listSessions(cwd?: string): Promise { export function createSession( cwd: string, - initialPermissionMode?: string + params: { mode: SessionMode; permissionMode: PermissionMode; model: string } ): Promise<{ sessionId: string }> { - return clients.sessions.createSession({ cwd, initialPermissionMode }); + return clients.sessions.createSession({ cwd, ...params }); } export function deleteSession(sessionId: string, cwd: string): Promise { @@ -51,9 +55,9 @@ export function resumeSession(sessionId: string, cwd: string): Promise { export function setSessionPermissionMode( sessionId: string, cwd: string, - mode: string + mode: PermissionMode ): Promise { - return clients.sessions.setSessionPermissionMode({ sessionId, cwd, mode: mode as any }); + return clients.sessions.setSessionPermissionMode({ sessionId, cwd, mode }); } export function sendApprovalResponse( @@ -64,6 +68,45 @@ export function sendApprovalResponse( return clients.agent.sendApprovalResponse({ sessionId, approvalId: callId, response }); } +// ---- Plan file ---- + +export function getSessionPlan( + sessionId: string, + cwd: string +): Promise<{ content: string; path: string; directory: string; exists: boolean }> { + return api<{ content: string; path: string; directory: string; exists: boolean }>( + `/api/sessions/${sessionId}/plan?cwd=${encodeURIComponent(cwd)}` + ); +} + +// ---- Plan/Build mode switching ---- + +export type SessionModeInfo = { + mode: SessionMode; + permissionMode: PermissionMode; + cwd: string; + available: Array<{ name: string; description: string }>; +}; + +export function getSessionMode(sessionId: string, cwd: string): Promise { + return api(`/api/sessions/${sessionId}/mode?cwd=${encodeURIComponent(cwd)}`); +} + +export function setSessionMode( + sessionId: string, + cwd: string, + mode: SessionMode +): Promise<{ mode: SessionMode; permissionMode: PermissionMode }> { + return api<{ mode: SessionMode; permissionMode: PermissionMode }>( + `/api/sessions/${sessionId}/mode`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cwd, mode }), + } + ); +} + // ---- Settings: Memory ---- export function getMemoryConfig(): Promise<{ @@ -139,8 +182,8 @@ export async function setCompactionModel( // ---- Settings: MCP ---- -export function listMcpServers(_cwd?: string): Promise { - return clients.settings.getMcpStatus(); +export function listMcpServers(cwd?: string): Promise { + return clients.settings.getMcpStatus({ cwd: cwd ?? '' }); } export function setMcpDisabled(name: string, disabled: boolean, cwd?: string): Promise { diff --git a/packages/desktop/src/settings/HooksPanel.tsx b/packages/desktop/src/settings/HooksPanel.tsx index e3cc4dce..0dfb7ef4 100644 --- a/packages/desktop/src/settings/HooksPanel.tsx +++ b/packages/desktop/src/settings/HooksPanel.tsx @@ -293,6 +293,7 @@ export default function HooksPanel({ global: isGlobal }: { global?: boolean }) {
); } + const canMutate = h.source === (isGlobal ? 'global' : 'project'); return (
- - + {canMutate && ( + <> + + + + )} { diff --git a/packages/desktop/src/settings/McpPanel.tsx b/packages/desktop/src/settings/McpPanel.tsx index ef32f931..55a4884e 100644 --- a/packages/desktop/src/settings/McpPanel.tsx +++ b/packages/desktop/src/settings/McpPanel.tsx @@ -268,6 +268,7 @@ export default function McpPanel({ global: isGlobal }: { global?: boolean }) {
); } + const canMutate = s.source === (isGlobal ? 'global' : 'project'); return (
- - + {canMutate && ( + <> + + + + )} toggle(s.name, !v)} />
); diff --git a/packages/desktop/src/settings/SubagentsPanel.tsx b/packages/desktop/src/settings/SubagentsPanel.tsx index f0a0e6c7..4cf704df 100644 --- a/packages/desktop/src/settings/SubagentsPanel.tsx +++ b/packages/desktop/src/settings/SubagentsPanel.tsx @@ -65,8 +65,6 @@ const EMPTY_FORM: AgentForm = { model: '', }; -const BUILT_IN = new Set(['explore', 'general']); - export default function SubagentsPanel({ global: isGlobal }: { global?: boolean }) { const [agents, setAgents] = useState([]); const [enabled, setEnabled] = useState(true); @@ -294,7 +292,7 @@ export default function SubagentsPanel({ global: isGlobal }: { global?: boolean
); } - const isBuiltIn = BUILT_IN.has(a.name); + const canMutate = a.source === (isGlobal ? 'global' : 'project'); return (
- {!isBuiltIn && ( + {canMutate && ( <> +
+ + {planPathLabel && ( +
+ 计划文件:{planPathLabel} +
+ )} + +
+ {loading ? ( +
加载中…
+ ) : planContent ? ( + + ) : ( +
(计划内容为空)
+ )} +
+ +
+