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/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/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index c42bdf4f..26248aa1 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -25,8 +25,10 @@ import { ProjectRuntimeService } 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'; const REACTIVE_COMPACT_MAX_RETRIES = 3; import { RulesService } from '../rules/index.js'; @@ -143,6 +145,9 @@ export const sendMessage = ( const state = sessionId ? yield* session.load(normalizedCwd, sessionId) : yield* session.create(normalizedCwd, llm.modelInfo.model); + if (state.activeProfile) { + yield* runtime.restoreSessionProfile(normalizedCwd, state.sessionId, state.activeProfile); + } state.model = llm.modelInfo.model; state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd); const sid = state.sessionId; @@ -160,9 +165,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 +190,7 @@ export const sendMessage = ( const stream = agent.runStream({ state, llm: activeLlm, + profile, toolPolicy: policy, maxStepsOverride: effectiveMaxSteps, approvalOverride: effectiveApproval, @@ -221,6 +225,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 +239,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 +253,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; @@ -281,6 +290,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; @@ -496,6 +506,31 @@ export function agentLoop( todoPrinted = true; } } + + const submittedPlan = allResults.some( + (r) => + r.type === 'ok' && + r.name === 'submit_plan' && + typeof r.output === 'string' && + r.output.startsWith('Plan written to ') + ); + if (submittedPlan) { + yield* q.offer({ + _tag: 'Done', + content: + 'Plan submitted and saved. Session switched to build mode — send a new message to execute.', + }); + yield* hooks.emit('agent.turn.end', { + sessionId, + turnId: state.currentTurnId, + status: 'done', + }); + yield* checkpoint.snapshotFinal(projectPath, state.sessionId, state.currentTurnId); + memory + .flushSessionToMemory(state.sessionId, llm, state.cwd) + .catch((e) => logger.error('memory flush failed:', e)); + return Result.ok('Plan submitted'); + } } if (overflow) continue; @@ -530,18 +565,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..77b0302e 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 { @@ -55,6 +56,8 @@ export type AgentEvent = readonly id: string; readonly tool: string; readonly args: Record; + /** Optional payload forwarded to the UI for richer approval UIs (e.g. plan content). */ + readonly payload?: Record; } | { readonly _tag: 'ToolResult'; @@ -90,6 +93,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/async-confirm.ts b/packages/codingcode/src/approval/async-confirm.ts index e0228691..0dfff6d5 100644 --- a/packages/codingcode/src/approval/async-confirm.ts +++ b/packages/codingcode/src/approval/async-confirm.ts @@ -11,7 +11,12 @@ export class ApprovalWaitService extends Effect.Service()(' const pendingConfirmations = new Map(); const approvalEmitters = new Map< string, - (id: string, tool: string, args: Record) => void + ( + id: string, + tool: string, + args: Record, + payload?: Record + ) => void >(); return { @@ -49,15 +54,21 @@ export class ApprovalWaitService extends Effect.Service()(' sessionId: string, id: string, tool: string, - args: Record + args: Record, + payload?: Record ): Effect.Effect => Effect.sync(() => { - approvalEmitters.get(sessionId)?.(id, tool, args); + approvalEmitters.get(sessionId)?.(id, tool, args, payload); }), registerEmitter: ( sessionId: string, - fn: (id: string, tool: string, args: Record) => void + fn: ( + id: string, + tool: string, + args: Record, + payload?: Record + ) => void ): Effect.Effect => Effect.sync(() => { approvalEmitters.set(sessionId, fn); diff --git a/packages/codingcode/src/approval/confirmation.ts b/packages/codingcode/src/approval/confirmation.ts index a4698c48..72f8c7b7 100644 --- a/packages/codingcode/src/approval/confirmation.ts +++ b/packages/codingcode/src/approval/confirmation.ts @@ -12,15 +12,15 @@ export function userConfirmAsync( tool: string, args: Record, sessionId: string, - callId: string + callId: string, + payload?: Record ): Effect.Effect { return Effect.gen(function* () { const waitSvc = yield* ApprovalWaitService; const id = callId; - yield* waitSvc.emitApprovalRequest(sessionId, id, tool, args); + yield* waitSvc.emitApprovalRequest(sessionId, id, tool, args, payload); - // 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/response.ts b/packages/codingcode/src/approval/response.ts index fc76704b..f8420db2 100644 --- a/packages/codingcode/src/approval/response.ts +++ b/packages/codingcode/src/approval/response.ts @@ -1,7 +1,52 @@ import type { ConfirmResult } from './confirmation.js'; -export function parseApprovalResponse(response: string): ConfirmResult { - switch (response) { +/** + * Parses a JSON or legacy string approval response from the desktop client. + * Tool approval only — the plan-approval modal uses a richer vocabulary + * (allow / modified / canceled) and is parsed by `parsePlanApprovalResponse` + * in `plan/`. The two wire protocols share the legacy `'allow' | 'deny'` + * codes but the plan path is the only one that accepts `modified`/`canceled`. + */ +export function parseApprovalResponse(raw: string): ConfirmResult { + if (raw && raw.startsWith('{')) { + try { + const obj = JSON.parse(raw) as { type?: string }; + switch (obj.type) { + case 'allow': + return { type: 'allow' }; + case 'deny': + return { type: 'deny' }; + case 'always': + return { + type: 'always', + rule: { + id: `user-allow-${Date.now()}`, + action: 'allow', + toolPattern: '*', + reason: 'User always allows', + source: 'user', + }, + }; + case 'never': + return { + type: 'never', + rule: { + id: `user-deny-${Date.now()}`, + action: 'deny', + toolPattern: '*', + reason: 'User never allows', + source: 'user', + }, + }; + default: + return { type: 'deny' }; + } + } catch { + return { type: 'deny' }; + } + } + + switch (raw) { case 'allow': return { type: 'allow' }; case 'deny': 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..08b48381 100644 --- a/packages/codingcode/src/approval/types.ts +++ b/packages/codingcode/src/approval/types.ts @@ -1,4 +1,4 @@ -export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypass'; +export type PermissionMode = 'default' | 'acceptEdits' | 'bypass'; export interface ToolCallRequest { tool: string; @@ -9,10 +9,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..7435ba4d 100644 --- a/packages/codingcode/src/client/direct.ts +++ b/packages/codingcode/src/client/direct.ts @@ -49,7 +49,13 @@ export async function* agentEventToStreamChunk( 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 }; + yield { + type: 'approval_request', + id: event.id, + tool: event.tool, + args: event.args, + payload: event.payload, + }; break; case 'Error': yield { @@ -119,13 +125,14 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis id: string; tool: string; args: Record; + payload?: Record; }) => void) | null = null; Effect.runSync( waitService.registerEmitter( sessionId, - (id: string, tool: string, args: Record) => { - notify?.({ type: 'approval_request', id, tool, args }); + (id: string, tool: string, args: Record, payload?: Record) => { + notify?.({ type: 'approval_request', id, tool, args, payload }); } ) ); @@ -140,6 +147,7 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis id: string; tool: string; args: Record; + payload?: Record; }>((resolve) => { notify = resolve; }); @@ -178,6 +186,15 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis }); }, + async sendPlanApprovalResponse(id: string, response: string) { + if (!currentSessionId) return; + await clients.agent.sendPlanApprovalResponse({ + sessionId: currentSessionId, + approvalId: id, + response, + }); + }, + async resumeSession(sid: string) { currentSessionId = sid; return clients.sessions.resumeSession({ sessionId: sid, cwd: cwd() }); @@ -379,8 +396,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 +408,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 +420,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 +447,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 +475,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..d1a641aa 100644 --- a/packages/codingcode/src/client/direct/agent-runtime.ts +++ b/packages/codingcode/src/client/direct/agent-runtime.ts @@ -2,6 +2,8 @@ import { Effect } from 'effect'; import { sendMessage } from '../../agent/agent.js'; import { ApprovalWaitService } from '../../approval/async-confirm.js'; import { parseApprovalResponse } from '../../approval/response.js'; +import { PlanApprovalService } from '../../plan/approval-service.js'; +import { parsePlanApprovalResponse } from '../../plan/parse-plan-approval.js'; import { ContextService } from '../../context/service.js'; import type { StreamChunk } from '../types.js'; import { agentEventToStreamChunk } from '../direct.js'; @@ -20,6 +22,12 @@ export interface AgentRuntimeClient { response: string; }): Promise; + sendPlanApprovalResponse(input: { + sessionId: string; + approvalId: string; + response: string; + }): Promise; + compact(input: { sessionId: string; cwd: string }): Promise; } @@ -39,6 +47,7 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu id: string; tool: string; args: Record; + payload?: Record; }) => void) | null = null; const waitService = await rt.runPromise( @@ -46,11 +55,24 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu return yield* ApprovalWaitService; }) ); + const planService = await rt.runPromise( + Effect.gen(function* () { + return yield* PlanApprovalService; + }) + ); Effect.runSync( waitService.registerEmitter( resolvedSessionId, - (id: string, tool: string, args: Record) => { - notify?.({ type: 'approval_request', id, tool, args }); + (id: string, tool: string, args: Record, payload?: Record) => { + notify?.({ type: 'approval_request', id, tool, args, payload }); + } + ) + ); + Effect.runSync( + planService.registerEmitter( + resolvedSessionId, + (id: string, tool: string, args: Record, payload?: Record) => { + notify?.({ type: 'approval_request', id, tool, args, payload }); } ) ); @@ -65,6 +87,7 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu id: string; tool: string; args: Record; + payload?: Record; }>((resolve) => { notify = resolve; }); @@ -91,6 +114,7 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu } } finally { Effect.runSync(waitService.unregisterEmitter(resolvedSessionId)); + Effect.runSync(planService.unregisterEmitter(resolvedSessionId)); } }, @@ -104,6 +128,16 @@ export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRu ); }, + async sendPlanApprovalResponse({ sessionId, approvalId, response }) { + const result = parsePlanApprovalResponse(response); + await rt.runPromise( + Effect.gen(function* () { + const svc = yield* PlanApprovalService; + return yield* svc.resolvePlanDecision(approvalId, sessionId, result); + }) + ); + }, + async compact({ sessionId, cwd }) { await rt.runPromise( Effect.gen(function* () { 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..7c6ac2b9 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -54,6 +54,7 @@ export async function createHttpClient(serverUrl: string): Promise id: data.id as string, tool: data.tool as string, args: data.args as Record, + payload: data.payload as Record | undefined, }; break; case 'tool_start': @@ -112,6 +113,15 @@ export async function createHttpClient(serverUrl: string): Promise }); }, + async sendPlanApprovalResponse(id: string, response: string) { + if (!currentSessionId) return; + await clients.agent.sendPlanApprovalResponse({ + sessionId: currentSessionId, + approvalId: id, + response, + }); + }, + async resumeSession(sid: string) { currentSessionId = sid; return clients.sessions.resumeSession({ sessionId: sid, cwd: '' }); @@ -248,8 +258,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 +270,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 +290,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 +326,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..e68a115a 100644 --- a/packages/codingcode/src/client/http/agent-runtime.ts +++ b/packages/codingcode/src/client/http/agent-runtime.ts @@ -14,6 +14,12 @@ export interface AgentRuntimeClient { response: string; }): Promise; + sendPlanApprovalResponse(input: { + sessionId: string; + approvalId: string; + response: string; + }): Promise; + compact(input: { sessionId: string; cwd: string }): Promise; } @@ -62,6 +68,7 @@ export function createHttpAgentClient( id: data.id as string, tool: data.tool as string, args: data.args as Record, + payload: data.payload as Record | undefined, }; break; case 'tool_start': @@ -122,6 +129,10 @@ export function createHttpAgentClient( await apiPost(`/api/sessions/${sessionId}/approval/${approvalId}`, { response }); }, + async sendPlanApprovalResponse({ sessionId, approvalId, response }) { + await apiPost(`/api/sessions/${sessionId}/plan-approval/${approvalId}`, { response }); + }, + async compact({ sessionId, cwd }) { await apiPost(`/api/sessions/${sessionId}/compact`, { 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..ded1fd4f 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -17,7 +17,7 @@ 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; payload?: Record } | { 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 } @@ -30,6 +30,7 @@ export type StreamChunk = export interface AgentClient { sendMessage(input: string, cwd?: string): AsyncGenerator; sendApprovalResponse(id: string, response: string): Promise; + sendPlanApprovalResponse(id: string, response: string): Promise; resumeSession(sid: string): Promise; listSessions(): Promise; listModels(): Promise<{ models: SelectableModel[]; activeId: string | null }>; @@ -65,15 +66,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 +86,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/core/path.ts b/packages/codingcode/src/core/path.ts index 3bed20c2..c84f4253 100644 --- a/packages/codingcode/src/core/path.ts +++ b/packages/codingcode/src/core/path.ts @@ -1,3 +1,6 @@ +import { homedir } from 'os'; +import { join } from 'path'; + /** 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 @@ -19,3 +22,33 @@ export function encodeProjectPath(p: string): string { .replace(/^-+|-+$/g, '') .toLowerCase(); } + +// ---- Project storage roots ---- +// +// `~/.codingcode/project//` holds per-project session state +// (jsonl + index + checkpoint git). +// `~/.codingcode/projects//` holds per-project plan files +// (one markdown per sessionId). +// +// Both roots can be overridden by tests via the set* functions below, so that +// the production code never has to know about a specific disk layout and tests +// can redirect everything to a per-test tmpdir. + +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..fc2339df 100644 --- a/packages/codingcode/src/hooks/registry.ts +++ b/packages/codingcode/src/hooks/registry.ts @@ -130,19 +130,41 @@ 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 () => { + // Internally `emit` may run Effect-returning observers that `yield*` + // services from the caller's fiber. The declared R is `never` so + // existing `Effect.runPromise(emit)` fire-and-forget call sites keep + // compiling; observers that need services should only be registered + // for hook points emitted from a fiber that provides them + // (e.g. `tool.execute.after` from `ToolExecutorService`). + 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') { + // Effect-returning observer: run in this fiber's context. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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); } } } - }); + // The gen's actual R is `any` (from inner Effects); expose `never` + // to keep callers that do `Effect.runPromise(emit)` happy. See the + // contract comment on `ObserverHandler` for the runtime guarantee. + }) as Effect.Effect; }, emitDecision: ( @@ -181,18 +203,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 +248,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..981d94c0 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' @@ -26,7 +28,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..1223bc4e 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 { afterPlanSubmittedObserver, planModeGateHook } from './plan/index.js'; export const WorkspaceLayer = WorkspaceService.Default; export const TodoLayer = TodoService.Default; @@ -38,12 +39,28 @@ 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', + }); + // plan mode: after submit_plan: switch to build profile + yield* hooks.register('tool.execute.after', afterPlanSubmittedObserver, { + source: 'system', + }); + }) + ) +); + /** ToolExecutor depends on HookLayer + ApprovalLayer. */ const ExecutorDeps = Layer.mergeAll(HookLayer, ApprovalLayer); const ExecutorLayer = ToolExecutorService.Default.pipe(Layer.provide(ExecutorDeps)); @@ -97,11 +114,15 @@ export const AppLayer = Layer.mergeAll( RulesLayer, MemoryLayer, ContextLayer, - SchedulerLayer + SchedulerLayer, + SystemHookLayer ); /** Create the application ManagedRuntime from AppLayer. */ -export const createAppRuntime = () => ManagedRuntime.make(AppLayer); +// Effect's ManagedRuntime.make typing is overly strict for our AppLayer union; +// runtime has access to all service tags so the cast is safe. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +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/approval-service.ts b/packages/codingcode/src/plan/approval-service.ts new file mode 100644 index 00000000..2904e661 --- /dev/null +++ b/packages/codingcode/src/plan/approval-service.ts @@ -0,0 +1,87 @@ +import { Effect, Deferred } from 'effect'; +import { randomUUID } from 'crypto'; +import type { PlanConfirmResult } from './plan-confirm.js'; + +interface PendingPlanEntry { + deferred: Deferred.Deferred; + sessionId: string; +} + +export type PlanApprovalEmitter = ( + id: string, + tool: string, + args: Record, + payload?: Record +) => void; + +export interface PlanDecisionRequest { + sessionId: string; + projectPath: string; + planContent: string; + planPath: string; +} + +export class PlanApprovalService extends Effect.Service()('PlanApproval', { + effect: Effect.gen(function* () { + const pendingPlanApprovals = new Map(); + const planEmitters = new Map(); + + return { + requestPlanDecision: ( + req: PlanDecisionRequest + ): Effect.Effect => + Effect.gen(function* () { + const id = `plan_${randomUUID()}`; + const args = { plan_content: req.planContent }; + const payload = { + kind: 'plan', + planPath: req.planPath, + projectPath: req.projectPath, + sessionId: req.sessionId, + }; + const emitter = planEmitters.get(req.sessionId); + emitter?.(id, 'submit_plan', args, payload); + + const d = yield* Deferred.make(); + pendingPlanApprovals.set(id, { deferred: d, sessionId: req.sessionId }); + return yield* Deferred.await(d); + }), + + resolvePlanDecision: ( + id: string, + _sessionId: string, + result: PlanConfirmResult + ): Effect.Effect => + Effect.sync(() => { + const entry = pendingPlanApprovals.get(id); + if (!entry) return false; + pendingPlanApprovals.delete(id); + Deferred.unsafeDone(entry.deferred, Effect.succeed(result)); + return true; + }), + + getPending: (sessionId?: string): Effect.Effect => + Effect.sync(() => { + if (sessionId) { + return Array.from(pendingPlanApprovals.entries()) + .filter(([_, e]) => e.sessionId === sessionId) + .map(([id]) => id); + } + return Array.from(pendingPlanApprovals.keys()); + }), + + registerEmitter: (sessionId: string, fn: PlanApprovalEmitter): Effect.Effect => + Effect.sync(() => { + planEmitters.set(sessionId, fn); + }), + + unregisterEmitter: (sessionId: string): Effect.Effect => + Effect.sync(() => { + planEmitters.delete(sessionId); + }), + + hasEmitter: (sessionId: string): Effect.Effect => + Effect.sync(() => planEmitters.has(sessionId)), + }; + }), +}) {} diff --git a/packages/codingcode/src/plan/index.ts b/packages/codingcode/src/plan/index.ts new file mode 100644 index 00000000..17382a57 --- /dev/null +++ b/packages/codingcode/src/plan/index.ts @@ -0,0 +1,112 @@ +import { Effect } from 'effect'; +import type { DecisionHandler, ObserverHandler } from '../hooks/types.js'; +import { ApprovalService } from '../approval/index.js'; +import { ProjectRuntimeService } from '../runtime/project-runtime.js'; +import { SessionService } from '../session/store.js'; +import { createLogger } from '@codingcode/infra/logger'; +import { BUILD_PROFILE } from '../subagent/registry.js'; + +const logger = createLogger(); + +// ---- 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.', + }; +}; + + +export const afterPlanSubmittedObserver: ObserverHandler = (payload) => + Effect.gen(function* () { + const toolName = payload.toolName as string | undefined; + if (toolName !== 'submit_plan') return; + const result = payload.result as { output?: string } | undefined; + if (!result?.output?.startsWith('Plan written to ')) return; + const sessionId = payload.sessionId as string | undefined; + const projectPath = payload.projectPath as string | undefined; + const args = (payload.args ?? {}) as { plan_content?: string }; + if (!sessionId || !projectPath) return; + if (!args.plan_content) return; + + yield* Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const session = yield* SessionService; + + // Switch to build profile (resolve build from runtime, fallback to built-in) + const buildProfile = + runtime.resolveSubagentProfile(projectPath, BUILD_PROFILE_NAME) ?? + runtime.resolveSubagentProfile(projectPath, BUILD_PROFILE.name) ?? + BUILD_PROFILE; + yield* runtime.setSessionProfile(projectPath, sessionId, buildProfile); + + // Persist activeProfile in the session index + try { + const state = yield* session.load(projectPath, sessionId); + session.updateActiveProfile(state, buildProfile.name); + } catch (err) { + logger.warn('afterPlanSubmitted: failed to persist activeProfile:', err); + } + + // Sync approval permission mode + const approval = yield* ApprovalService; + yield* approval.setPermissionMode(buildProfile.permissionMode ?? 'default'); + }).pipe( + Effect.catchAll((err) => Effect.sync(() => logger.error('afterPlanSubmitted error:', err))) + ); + }); diff --git a/packages/codingcode/src/plan/parse-plan-approval.ts b/packages/codingcode/src/plan/parse-plan-approval.ts new file mode 100644 index 00000000..ac25dab3 --- /dev/null +++ b/packages/codingcode/src/plan/parse-plan-approval.ts @@ -0,0 +1,35 @@ +import type { PlanConfirmResult } from './plan-confirm.js'; + +export function parsePlanApprovalResponse(raw: string): PlanConfirmResult { + if (raw && raw.startsWith('{')) { + try { + const obj = JSON.parse(raw) as { + type?: string; + input?: Record; + }; + switch (obj.type) { + case 'allow': + return { type: 'allow' }; + case 'modified': + if (obj.input && typeof obj.input === 'object') { + return { type: 'modified', input: obj.input }; + } + return { type: 'canceled' }; + case 'canceled': + return { type: 'canceled' }; + default: + return { type: 'canceled' }; + } + } catch { + return { type: 'canceled' }; + } + } + + // Legacy string protocol. + switch (raw) { + case 'allow': + return { type: 'allow' }; + default: + return { type: 'canceled' }; + } +} diff --git a/packages/codingcode/src/plan/plan-confirm.ts b/packages/codingcode/src/plan/plan-confirm.ts new file mode 100644 index 00000000..fbd56e45 --- /dev/null +++ b/packages/codingcode/src/plan/plan-confirm.ts @@ -0,0 +1,4 @@ +export type PlanConfirmResult = + | { type: 'allow' } + | { type: 'modified'; input: Record } + | { type: 'canceled' }; diff --git a/packages/codingcode/src/runtime/project-runtime.ts b/packages/codingcode/src/runtime/project-runtime.ts index e569544f..5ef19a2a 100644 --- a/packages/codingcode/src/runtime/project-runtime.ts +++ b/packages/codingcode/src/runtime/project-runtime.ts @@ -1,16 +1,29 @@ 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 { + 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); @@ -19,6 +32,10 @@ function buildGlobalProfiles(): AgentProfile[] { return profiles; } +function profileToPermissionMode(profile: AgentProfile | undefined): PermissionMode { + return profile?.permissionMode ?? 'default'; +} + /** 构建项目级 profile:/.codingcode/agents/ */ function buildProjectProfiles(projectPath: string): AgentProfile[] { return agentLoader.loadAgentProfiles(projectPath); @@ -32,7 +49,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 +103,55 @@ 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 + ): Effect.Effect => + Effect.gen(function* () { + sessionAgentProfiles.set(sessionId, profile); + const mode = profileToPermissionMode(profile); + sessionPermissionModes.set(sessionId, mode); + // Keep the plan-mode side channel in sync so synchronous decision + // hooks (planModeGateHook) can answer "is this session in plan mode?" + // without reaching back into the Effect runtime. + markSessionPlanMode(sessionId, isPlanProfile(profile)); + // 写盘:跨重启恢复时 messages.ts 从 idx 读 permissionMode + activeProfile + const state = yield* session.load(projectPath, sessionId); + yield* session.setPermissionMode(state, mode); + yield* session.updateActiveProfile(state, profile.name); + }), getSessionProfile: (sessionId: string): AgentProfile | undefined => sessionAgentProfiles.get(sessionId), + getSessionPermissionMode: (sessionId: string): PermissionMode => + sessionPermissionModes.get(sessionId) ?? 'default', + + restoreSessionProfile: ( + projectPath: string, + sessionId: string, + profileName: string | undefined + ): 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); + const mode = profileToPermissionMode(profile); + sessionPermissionModes.set(sessionId, mode); + markSessionPlanMode(sessionId, isPlanProfile(profile)); + // 写盘:保持 idx 状态与内存态一致 + const state = yield* session.load(projectPath, sessionId); + yield* session.setPermissionMode(state, mode); + }), + 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/server/adapter.ts b/packages/codingcode/src/server/adapter.ts index 33870962..4dcb6f0b 100644 --- a/packages/codingcode/src/server/adapter.ts +++ b/packages/codingcode/src/server/adapter.ts @@ -10,7 +10,13 @@ export function agentEventToSseEvent(event: AgentEvent): SseEvent | null { 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 }; + return { + type: 'approval_request', + id: event.id, + tool: event.tool, + args: event.args, + payload: event.payload, + }; case 'ToolResult': return { type: 'tool_result', diff --git a/packages/codingcode/src/server/handler.ts b/packages/codingcode/src/server/handler.ts index 3f503fa8..5c712720 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 { PlanApprovalService } from '../plan/approval-service.js'; import type { SseEvent } from './types.js'; import { AgentError } from '../core/error.js'; @@ -24,11 +25,24 @@ export function createSseHandler(rt: ManagedRt) { return yield* ApprovalWaitService; }) ); + const planService = await rt.runPromise( + Effect.gen(function* () { + return yield* PlanApprovalService; + }) + ); Effect.runSync( waitService.registerEmitter( sessionId, - (id: string, tool: string, args: Record) => { - enqueue({ type: 'approval_request', id, tool, args }); + (id: string, tool: string, args: Record, payload?: Record) => { + enqueue({ type: 'approval_request', id, tool, args, payload }); + } + ) + ); + Effect.runSync( + planService.registerEmitter( + sessionId, + (id: string, tool: string, args: Record, payload?: Record) => { + enqueue({ type: 'approval_request', id, tool, args, payload }); } ) ); @@ -53,6 +67,7 @@ export function createSseHandler(rt: ManagedRt) { }); } finally { Effect.runSync(waitService.unregisterEmitter(sessionId)); + Effect.runSync(planService.unregisterEmitter(sessionId)); opts?.onDone?.(); } controller.close(); 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..ec42e11a 100644 --- a/packages/codingcode/src/server/routes/approval.ts +++ b/packages/codingcode/src/server/routes/approval.ts @@ -2,6 +2,8 @@ import { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; import { ApprovalWaitService } from '../../approval/async-confirm.js'; import { parseApprovalResponse } from '../../approval/response.js'; +import { PlanApprovalService } from '../../plan/approval-service.js'; +import { parsePlanApprovalResponse } from '../../plan/parse-plan-approval.js'; import { errorResponse } from '../util.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -12,7 +14,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* () { @@ -36,5 +41,39 @@ export function createApprovalRouter(rt: ManagedRt): Hono { return c.json({ ok: result.value }); }); + router.post('/sessions/:sessionId/plan-approval/:id', async (c) => { + const id = c.req.param('id'); + const sessionId = c.req.param('sessionId'); + 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* () { + const svc = yield* PlanApprovalService; + return yield* svc.resolvePlanDecision( + id, + sessionId, + parsePlanApprovalResponse(response) + ); + }).pipe( + Effect.catchAllDefect((defect) => + Effect.fail(new Error(`Unexpected error: ${String(defect)}`)) + ), + Effect.match({ + onSuccess: (a) => ({ ok: true as const, value: a }), + onFailure: (e) => ({ ok: false as const, error: e }), + }) + ) + ); + if (!result.ok) { + const { status, body } = errorResponse(result.error); + return c.json(body, status as any); + } + + return c.json({ ok: result.value }); + }); + return router; } diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index 2da7902f..0ad25c0a 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -1,22 +1,26 @@ import { Hono } from 'hono'; import { Effect, ManagedRuntime } from 'effect'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; import type { SessionStoreState } 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, getProjectPlansBaseDir } from '../../core/path.js'; +import { ProjectRuntimeService } from '../../runtime/project-runtime.js'; +import { BUILD_PROFILE, PLAN_PROFILE } from '../../subagent/registry.js'; +import type { PermissionMode } from '../../approval/types.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -25,145 +29,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) => { @@ -220,7 +85,17 @@ export function createSessionsRouter(rt: ManagedRt): Hono { } const state = result.value as SessionStoreState; if (body.initialPermissionMode) { - setPermissionMode(state.sessionId, state.indexPath, body.initialPermissionMode); + const profile = body.initialPermissionMode === 'plan' ? PLAN_PROFILE : BUILD_PROFILE; + const setResult = await runWithLayer( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.setSessionProfile(normalizedCwd, state.sessionId, profile); + }) as any + ); + if (!setResult.ok) { + const { status, body: resp } = errorResponse(setResult.error); + return c.json(resp, status as any); + } } return c.json({ sessionId: state.sessionId }); }); @@ -301,6 +176,116 @@ export function createSessionsRouter(rt: ManagedRt): Hono { return c.json(turns); }); + // ---- Plan file: read the current plan document for a session ---- + router.get('/:id/plan', 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 planDir = join(getProjectPlansBaseDir(), encodeProjectPath(cwd)); + const planPath = join(planDir, `${sessionId}.md`); + if (!existsSync(planPath)) { + return c.json({ + content: '', + path: planPath, + directory: planDir, + exists: false, + }); + } + try { + const content = readFileSync(planPath, 'utf8'); + return c.json({ + content, + path: planPath, + 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 runtime = yield* ProjectRuntimeService; + const profile = runtime.getSessionProfile(sessionId); + return { + profileName: profile?.name ?? BUILD_PROFILE.name, + 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; profile?: string }; + const cwd = await rt.runPromise( + Effect.gen(function* () { + const ws = yield* WorkspaceService; + return ws.resolveWorkspaceCwd(body.cwd); + }) + ); + const profileName = body.profile ?? BUILD_PROFILE.name; + if (profileName !== PLAN_PROFILE.name && profileName !== BUILD_PROFILE.name) { + return c.json({ error: `Unsupported mode: ${profileName}` }, 400); + } + const result = await runWithLayer( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const session = yield* SessionService; + const target = runtime.resolveSubagentProfile(cwd, profileName) ?? + (profileName === PLAN_PROFILE.name ? PLAN_PROFILE : BUILD_PROFILE); + yield* runtime.setSessionProfile(cwd, sessionId, target); + try { + const state = yield* session.load(cwd, sessionId); + yield* session.updateActiveProfile(state, target.name); + } catch { + /* session not created yet — ignore */ + } + return { + profileName: target.name, + permissionMode: runtime.getSessionPermissionMode(sessionId), + }; + }) + ); + if (!result.ok) { + const { status, body: errBody } = errorResponse(result.error); + return c.json(errBody, status as any); + } + // Sync the live approval pipeline's permission mode (best-effort). + 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'); 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..b9c48ea3 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 }); } @@ -100,13 +102,14 @@ function buildIndexFromMeta(meta: SessionMetaEvent, history: SessionEvent[]): Se 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); diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 4043a7b6..1efdc132 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -54,6 +54,7 @@ export class SessionService extends Effect.Service()('Session', usage: state.usage, permissionMode: current?.permissionMode ?? 'default', memorySnapshot: state.memorySnapshot, + activeProfile: current?.activeProfile, }; writeFileSync(state.indexPath, JSON.stringify(index, null, 2), 'utf8'); } @@ -118,6 +119,7 @@ export class SessionService extends Effect.Service()('Session', currentTurnId: idx?.currentTurnId ?? 0, usage: idx?.usage ?? undefined, memorySnapshot: idx?.memorySnapshot ?? '', + activeProfile: idx?.activeProfile, }; if (existsSync(state.transcriptPath)) { @@ -321,6 +323,30 @@ export class SessionService extends Effect.Service()('Session', const getPermissionModeFromState = (state: SessionStoreState): Effect.Effect => Effect.sync(() => getPermissionMode(state.indexPath)); + 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, + permissionMode: current?.permissionMode ?? 'default', + memorySnapshot: state.memorySnapshot, + activeProfile: profileName, + }; + writeFileSync(state.indexPath, JSON.stringify(index, null, 2), 'utf8'); + }); + const incrementTurn = (state: SessionStoreState): number => { state.currentTurnId += 1; updateIndex(state); @@ -343,6 +369,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), diff --git a/packages/codingcode/src/session/types.ts b/packages/codingcode/src/session/types.ts index 21cd4046..00f70ade 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -79,6 +79,11 @@ export interface SessionIndex { usage: TokenUsage | undefined; permissionMode: string; memorySnapshot?: string; + /** + * Name of the main agent profile currently active for this session + * (e.g. 'plan' or 'build'). When undefined, the build profile is implicit. + */ + activeProfile?: string; } export interface SessionStoreState { @@ -94,4 +99,5 @@ export interface SessionStoreState { 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..ed731e63 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,19 @@ 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.', + // No `permissionMode` — plan mode is enforced structurally by the + // `plan/planModeGateHook` (registered on `tool.approval.pre`) and + // detected via `isPlanProfile(profile)`. The approval pipeline itself + // does not need to know about this profile. + 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 +190,43 @@ export const PLAN_PROFILE: AgentProfile = { 5. Check for existing implementations or similar patterns ## Output format -Structure your analysis as: +When ready, call \`submit_plan({ 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.`, +If the user provides modification feedback, revise the plan and call submit_plan again with the updated plan_content.`, 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..9b5ad3df 100644 --- a/packages/codingcode/src/subagent/types.ts +++ b/packages/codingcode/src/subagent/types.ts @@ -1,4 +1,12 @@ import type { UserHookConfig } from '../hooks/types.js'; +import type { PermissionMode } from '../approval/types.js'; + +/** + * Permission modes that may be declared on an `AgentProfile`. The `'plan'` + * mode lives in the `plan/` module and is detected structurally via + * `isPlanProfile(profile)` rather than via this field. + */ +export type ProfilePermissionMode = Exclude; export interface AgentProfile { name: string; @@ -7,6 +15,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..92cca79b 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -11,6 +11,7 @@ import { resolveSubagentEnabled, resolveAgentDisabled } from '../../../subagent/ 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'; export function createDispatchAgentTool(): Effect.Effect< ToolDefinition, @@ -89,10 +90,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( @@ -157,7 +172,12 @@ export function createDispatchAgentTool(): Effect.Effect< profile: agentName, }); - // Collect events and extract result — wrap AsyncGenerator in Effect + // Collect events and extract result — wrap AsyncGenerator in Effect. + // The emit is moved out of the Effect.async callback (which runs in a + // fresh fiber with no service context) into the surrounding Effect.gen + // (which has HookService, SessionService, etc. in scope) so any + // observer that yield*'s a service resolves correctly. + let didComplete = false; const finalContent = yield* Effect.async((resume) => { let content = ''; (async () => { @@ -178,19 +198,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 +218,19 @@ export function createDispatchAgentTool(): Effect.Effect< })(); }); + // Emit completion hook in the dispatch_agent Effect.gen fiber so + // observers can yield* services. Fire-and-forget — observer errors + // are swallowed by HookService.emit itself. + 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..52f540f4 --- /dev/null +++ b/packages/codingcode/src/tools/domains/subagent/submit-plan.ts @@ -0,0 +1,74 @@ +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, getProjectPlansBaseDir } from '../../../core/path.js'; +import { PlanApprovalService } from '../../../plan/approval-service.js'; +import type { PlanConfirmResult } from '../../../plan/plan-confirm.js'; + +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. Each call overwrites the plan file.', + shortDescription: 'Submit plan', + parameters: z.object({ + plan_content: z + .string() + .min(1) + .describe( + 'Full Markdown implementation plan, including Current state / Key files / Risks / Approach / Phases.' + ), + }), + execute: (args: unknown, ctx?: ToolExecCtx): Effect.Effect => + Effect.gen(function* () { + const { plan_content: initialContent } = args as { 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 and sessionId in tool context' + ) + ); + } + + const planDir = join(getProjectPlansBaseDir(), encodeProjectPath(projectPath)); + const planPath = join(planDir, `${sessionId}.md`); + + const planService = yield* PlanApprovalService; + const decision: PlanConfirmResult = yield* planService.requestPlanDecision({ + sessionId, + projectPath, + planContent: initialContent, + planPath, + }); + + if (decision.type === 'canceled') { + return yield* Effect.fail( + new AgentError('TOOL_NOT_ALLOWED', 'User canceled plan approval') + ); + } + + const finalContent = + decision.type === 'modified' && + typeof decision.input === 'object' && + decision.input !== null + ? String( + (decision.input as { plan_content?: string }).plan_content ?? initialContent + ) + : initialContent; + + try { + mkdirSync(planDir, { recursive: true }); + writeFileSync(planPath, finalContent, 'utf8'); + return `Plan written to ${planPath}`; + } catch (err) { + return yield* Effect.fail( + new AgentError('TOOL_EXECUTION_FAILED', `Failed to write plan: ${String(err)}`) + ); + } + }) 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-integration.test.ts b/packages/codingcode/test/agent/submit-plan-integration.test.ts new file mode 100644 index 00000000..a16ed841 --- /dev/null +++ b/packages/codingcode/test/agent/submit-plan-integration.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer } from 'effect'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { submitPlanTool } from '../../src/tools/domains/subagent/submit-plan.js'; +import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; +import { getBuiltinTools } from '../../src/tools/providers.js'; +import { TodoService } from '../../src/agent/todo.js'; +import { PlanApprovalService } from '../../src/plan/approval-service.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +useTempProjectBase(); + +const mockTodoService = { + read: () => [], + write: () => undefined, + clear: () => undefined, +} as any; + +const mockPlanApprovalService = { + requestPlanDecision: () => Effect.succeed({ type: 'allow' as const }) as any, + resolvePlanDecision: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + registerEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), +} as any; + +const TodoTestLayer = Layer.succeed(TodoService, mockTodoService); +const PlanTestLayer = Layer.succeed(PlanApprovalService, mockPlanApprovalService); +const TestLayer = Layer.mergeAll(TodoTestLayer, PlanTestLayer); + +function runWithLayer(eff: Effect.Effect): Promise { + return Effect.runPromise(eff.pipe(Effect.provide(TestLayer) as any)); +} + +describe('submit_plan tool integration', () => { + let cwd: string; + let sessionId: string; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'codingcode-tool-int-test-')); + sessionId = 'sess-tool-int'; + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('writes the plan file at the expected path under the project plan directory', async () => { + const result = await runWithLayer( + submitPlanTool.execute({ plan_content: '# Plan v1\n- step 1' }, { + projectPath: cwd, + sessionId, + } as any) + ); + expect(result).toMatch(/^Plan written to /); + + const planPath = result.replace(/^Plan written to /, ''); + expect(existsSync(planPath)).toBe(true); + expect(readFileSync(planPath, 'utf8')).toBe('# Plan v1\n- step 1'); + }); + + it('overwrites the plan file on each call (no history)', async () => { + const firstResult = await runWithLayer( + submitPlanTool.execute({ plan_content: 'v1' }, { projectPath: cwd, sessionId } as any) + ); + const secondResult = await runWithLayer( + submitPlanTool.execute({ plan_content: 'v2' }, { projectPath: cwd, sessionId } as any) + ); + // The path is determined by ~/.codingcode/projects//.md. + // We derive it from the first execute's return envelope so this test does + // not depend on the exact home-directory layout. + const planPath = firstResult.replace(/^Plan written to /, ''); + expect(secondResult).toMatch(new RegExp(`${planPath.replaceAll('\\', '\\\\')}$`)); + expect(readFileSync(planPath, 'utf8')).toBe('v2'); + }); + + it('returns output starting with "Plan written to" — afterPlanSubmitted observer matches on this', async () => { + const result = await runWithLayer( + submitPlanTool.execute({ plan_content: 'v1' }, { projectPath: cwd, sessionId } as any) + ); + expect(result.startsWith('Plan written to ')).toBe(true); + }); + + it('submit_plan is NOT in the default builtin tools (conditional injection only)', async () => { + const tools = await runWithLayer(getBuiltinTools()); + const names = tools.map((t) => t.name); + expect(names).not.toContain('submit_plan'); + }); + + it('PLAN_PROFILE declares submit_plan in its tools list (filter compatibility)', () => { + expect(PLAN_PROFILE.tools).toContain('submit_plan'); + }); + + it('BUILD_PROFILE does NOT declare submit_plan in its tools list (defense in depth)', () => { + expect(BUILD_PROFILE.tools).not.toContain('submit_plan'); + }); +}); 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..9f6005b8 100644 --- a/packages/codingcode/test/approval/response.test.ts +++ b/packages/codingcode/test/approval/response.test.ts @@ -35,4 +35,60 @@ describe('parseApprovalResponse', () => { vi.useRealTimers(); }); + + it('parses JSON envelope for { type: "allow" }', () => { + expect(parseApprovalResponse('{"type":"allow"}')).toEqual({ type: 'allow' }); + }); + + it('parses JSON envelope for { type: "deny" }', () => { + expect(parseApprovalResponse('{"type":"deny"}')).toEqual({ type: 'deny' }); + }); + + it('falls back to deny for plan-only { type: "canceled" } — tool approval does not handle that', () => { + expect(parseApprovalResponse('{"type":"canceled"}')).toEqual({ type: 'deny' }); + }); + + it('falls back to deny for plan-only { type: "modified" } — tool approval does not handle that', () => { + expect(parseApprovalResponse('{"type":"modified","input":{}}')).toEqual({ type: 'deny' }); + }); + + it('falls back to deny on malformed JSON', () => { + expect(parseApprovalResponse('{not-json')).toEqual({ type: 'deny' }); + }); + + it('falls back to deny on unknown JSON type', () => { + expect(parseApprovalResponse('{"type":"weird"}')).toEqual({ type: 'deny' }); + }); + + it('synthesizes persistent allow rule from JSON "always" envelope', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-20T12:00:00Z')); + expect(parseApprovalResponse('{"type":"always"}')).toEqual({ + type: 'always', + rule: { + id: 'user-allow-1779278400000', + action: 'allow', + toolPattern: '*', + reason: 'User always allows', + source: 'user', + }, + }); + vi.useRealTimers(); + }); + + it('synthesizes persistent deny rule from JSON "never" envelope', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-20T12:00:00Z')); + expect(parseApprovalResponse('{"type":"never"}')).toEqual({ + type: 'never', + rule: { + id: 'user-deny-1779278400000', + action: 'deny', + toolPattern: '*', + reason: 'User never allows', + source: 'user', + }, + }); + 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/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..5b647674 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,7 +98,7 @@ 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 }); }); diff --git a/packages/codingcode/test/context/compressor/behavior.test.ts b/packages/codingcode/test/context/compressor/behavior.test.ts index d4d504ca..929917b3 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 }); } 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..983b72c8 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: { 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..e088f406 --- /dev/null +++ b/packages/codingcode/test/layer/system-hook-layer.test.ts @@ -0,0 +1,78 @@ +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 (option E): + // - planModeGateHook stays — it's the right abstraction for tool-allow + // policy. Registered on tool.approval.pre with priority -1000. + // - afterPlanSubmittedObserver stays — handles plan → build transition. + // Registered 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'); + + // (3) afterPlanSubmittedObserver is registered; emit should not throw + // and an observer registered by us alongside should also fire. + let ourObserverRan = false; + yield* hooks.register('tool.execute.after', () => + Effect.sync(() => { + ourObserverRan = true; + }) + ); + yield* hooks.emit('tool.execute.after', { sessionId: 's', projectPath: '/p' }); + expect(ourObserverRan).toBe(true); + + 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/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/after-submit.test.ts b/packages/codingcode/test/plan/after-submit.test.ts new file mode 100644 index 00000000..b3f747c9 --- /dev/null +++ b/packages/codingcode/test/plan/after-submit.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { Effect } from 'effect'; +import { afterPlanSubmittedObserver } from '../../src/plan/index.js'; + +async function runObserver(payload: Record): Promise { + const result = afterPlanSubmittedObserver(payload as any); + return Effect.runPromise(result as Effect.Effect); +} + +describe('afterPlanSubmittedObserver', () => { + it('is an Effect-returning observer (not fire-and-forget Promise)', () => { + // The whole point of the fix: the observer must return an Effect so it + // can yield* services in the emit fiber's context. A Promise or + // void return would force us back to Effect.runFork / default runtime. + const result = afterPlanSubmittedObserver({} as any); + expect(result).toBeDefined(); + // Effect has a .pipe method; Promise and void do not. + expect(typeof (result as { pipe?: unknown }).pipe).toBe('function'); + }); + + it('no-ops on non-submit_plan tool', async () => { + await expect( + runObserver({ + toolName: 'write_file', + args: { path: '/x' }, + sessionId: 's', + projectPath: '/proj', + result: { output: 'Plan written to /x' }, + }) + ).resolves.toBeUndefined(); + }); + + it('no-ops when submit_plan result does not start with "Plan written to "', async () => { + await expect( + runObserver({ + toolName: 'submit_plan', + args: { plan_content: '# plan' }, + sessionId: 's', + projectPath: '/proj', + result: { output: 'some other output' }, + }) + ).resolves.toBeUndefined(); + }); + + it('no-ops when sessionId is missing', async () => { + await expect( + runObserver({ + toolName: 'submit_plan', + args: { plan_content: '# plan' }, + projectPath: '/proj', + result: { output: 'Plan written to /x' }, + }) + ).resolves.toBeUndefined(); + }); + + it('no-ops when projectPath is missing', async () => { + await expect( + runObserver({ + toolName: 'submit_plan', + args: { plan_content: '# plan' }, + sessionId: 's', + result: { output: 'Plan written to /x' }, + }) + ).resolves.toBeUndefined(); + }); + + it('no-ops when plan_content is missing', async () => { + await expect( + runObserver({ + toolName: 'submit_plan', + args: {}, + sessionId: 's', + projectPath: '/proj', + result: { output: 'Plan written to /x' }, + }) + ).resolves.toBeUndefined(); + }); +}); 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..9bf4dc8e --- /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, payload?: any) => + Effect.sync(() => { + capturedApproval = { sessionId, id, tool, args, payload }; + }), + 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..c509e602 --- /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', () => { + markSessionPlanMode('sess', true); + const result = 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', () => { + markSessionPlanMode('sess', true); + const result = 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/parse-plan-approval.test.ts b/packages/codingcode/test/plan/parse-plan-approval.test.ts new file mode 100644 index 00000000..48f9e994 --- /dev/null +++ b/packages/codingcode/test/plan/parse-plan-approval.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { parsePlanApprovalResponse } from '../../src/plan/parse-plan-approval.js'; + +describe('parsePlanApprovalResponse', () => { + describe('JSON envelope', () => { + it('parses {"type":"allow"}', () => { + expect(parsePlanApprovalResponse('{"type":"allow"}')).toEqual({ type: 'allow' }); + }); + + it('parses {"type":"modified","input":{...}}', () => { + const input = { plan_content: '# revised plan' }; + expect(parsePlanApprovalResponse(JSON.stringify({ type: 'modified', input }))).toEqual({ + type: 'modified', + input, + }); + }); + + it('parses {"type":"canceled"}', () => { + expect(parsePlanApprovalResponse('{"type":"canceled"}')).toEqual({ type: 'canceled' }); + }); + + it('falls back to canceled for missing type field', () => { + expect(parsePlanApprovalResponse('{}')).toEqual({ type: 'canceled' }); + }); + + it('falls back to canceled for unknown type', () => { + expect(parsePlanApprovalResponse('{"type":"weird"}')).toEqual({ type: 'canceled' }); + }); + + it('falls back to canceled for modified without object input', () => { + expect(parsePlanApprovalResponse('{"type":"modified"}')).toEqual({ type: 'canceled' }); + }); + + it('falls back to canceled for malformed JSON', () => { + expect(parsePlanApprovalResponse('{not-json')).toEqual({ type: 'canceled' }); + }); + }); + + describe('legacy string protocol', () => { + it('parses "allow" as allow', () => { + expect(parsePlanApprovalResponse('allow')).toEqual({ type: 'allow' }); + }); + + it('treats any other string as canceled (safe default)', () => { + expect(parsePlanApprovalResponse('y')).toEqual({ type: 'canceled' }); + expect(parsePlanApprovalResponse('n')).toEqual({ type: 'canceled' }); + expect(parsePlanApprovalResponse('yes')).toEqual({ type: 'canceled' }); + expect(parsePlanApprovalResponse('')).toEqual({ type: 'canceled' }); + }); + }); +}); 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..4bb73500 --- /dev/null +++ b/packages/codingcode/test/runtime/set-session-profile.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { mkdtempSync, rmSync, existsSync, 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 { BUILD_PROFILE, PLAN_PROFILE, EXPLORE_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; + +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 = mkdtempSync(join(tmpdir(), 'codingcode-runtime-test-')); + 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, 'test-model'); + return { sessionId: state.sessionId, indexPath: state.indexPath }; + }) + ); + sessionId = result.sessionId; + indexPath = result.indexPath; + }); + + afterEach(async () => { + await rt.dispose(); + rmSync(cwd, { recursive: true, force: true }); + }); + + it('writes idx.permissionMode AND idx.activeProfile when switching to plan', async () => { + // After the plan refactor, `permissionMode` is no longer a plan-specific + // value. The plan-mode signal lives in `activeProfile`; the approval + // pipeline itself only sees a generic permission mode. + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + }) + ); + + expect(existsSync(indexPath)).toBe(true); + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.permissionMode).toBe('default'); + expect(idx.activeProfile).toBe('plan'); + }); + + 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); + }) + ); + + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.permissionMode).toBe('default'); + 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'); + // The approval-side permission mode is now 'default' (the pipeline + // is plan-blind). The plan-mode signal is structural via the + // profile's name + the `plan/active-sessions` side channel. + 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/after-submit-plan-turn-end.test.ts b/packages/codingcode/test/security/after-submit-plan-turn-end.test.ts new file mode 100644 index 00000000..b8110fdf --- /dev/null +++ b/packages/codingcode/test/security/after-submit-plan-turn-end.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer } from 'effect'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { submitPlanTool } from '../../src/tools/domains/subagent/submit-plan.js'; +import { afterPlanSubmittedObserver } from '../../src/plan/index.js'; +import { PlanApprovalService } from '../../src/plan/approval-service.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +useTempProjectBase(); + +const defaultPlanLayer = Layer.succeed(PlanApprovalService, { + requestPlanDecision: () => Effect.succeed({ type: 'allow' as const }) as any, + resolvePlanDecision: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + registerEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), +} as any); + +function runWithDefaultPlan(eff: Effect.Effect) { + return Effect.runPromise(eff.pipe(Effect.provide(defaultPlanLayer))); +} + +describe('after submit_plan: observer + turn-end flow (v13 fix)', () => { + let cwd: string; + let sessionId: string; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'codingcode-submit-plan-flow-')); + sessionId = 'sess-flow-' + Math.random().toString(36).slice(2, 8); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('submit_plan writes the plan file with the "Plan written to " prefix', async () => { + const result = await runWithDefaultPlan( + submitPlanTool.execute({ plan_content: '# Plan\n- step 1' }, { + projectPath: cwd, + sessionId, + } as any) + ); + expect(result).toMatch(/^Plan written to /); + const planPath = result.replace(/^Plan written to /, ''); + expect(existsSync(planPath)).toBe(true); + expect(readFileSync(planPath, 'utf8')).toBe('# Plan\n- step 1'); + }); + + it('afterPlanSubmittedObserver returns Effect (yields services)', () => { + // The observer returns an Effect.gen that yields* ProjectRuntimeService, + // SessionService, ApprovalService. The Effect's return type must not be void + // or Promise — see the existing after-plan-submitted test. + const result = afterPlanSubmittedObserver({} as any); + expect(result).toBeDefined(); + expect(typeof (result as { pipe?: unknown }).pipe).toBe('function'); + }); + + it('submit_plan output starts with "Plan written to " — agent loop can match it to end the turn', () => { + // The agent loop's turn-end check looks for results with name='submit_plan' + // and output.startsWith('Plan written to '). Verify the contract. + const output = `Plan written to ${join(cwd, 'plan.md')}`; + expect(output.startsWith('Plan written to ')).toBe(true); + }); +}); + +describe('v13 turn-end contract: after submit_plan + observer', () => { + it('agent loop should call return(Result.ok) after submit_plan success (preventing plan-mode execution)', () => { + // The agent loop in agent.ts now checks for successful submit_plan and + // returns early. This test documents the contract by reading the source. + // The actual integration test is in the existing test/agent/agent.test.ts. + const agentSource = readFileSync( + join(__dirname, '..', '..', 'src', 'agent', 'agent.ts'), + 'utf-8' + ); + // The fix: a check for allResults containing a successful submit_plan, + // followed by a return statement. + expect(agentSource).toMatch( + /allResults\.some\([\s\S]*r\.name === 'submit_plan'[\s\S]*Plan written to/ + ); + // And a Result.ok return. + expect(agentSource).toMatch(/return\s+Result\.ok\('Plan submitted'\)/); + }); +}); 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..b47df3e3 --- /dev/null +++ b/packages/codingcode/test/security/plan-mode-restart.test.ts @@ -0,0 +1,270 @@ +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, 'test-model'); + 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 actual plan + // modal is driven by submit_plan.execute itself, 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 and write to disk + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + yield* runtime.prepareProject(cwd); + yield* runtime.setSessionProfile(cwd, sessionId, PLAN_PROFILE); + }) + ); + + // Verify disk state — in the new architecture, `permissionMode` is + // 'default' (the legacy default from profileToPermissionMode) and the + // plan-mode signal lives in `activeProfile`. + const idx = JSON.parse(readFileSync(indexPath, 'utf8')); + expect(idx.permissionMode).toBe('default'); + expect(idx.activeProfile).toBe('plan'); + + // Simulate restart: build a new runtime, load state, restore profile. + await rt.dispose(); + decisionHandlers.length = 0; + decisionHandlers.push(planModeGateHook); + rt = ManagedRuntime.make(makeLayer() as any); + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const session = yield* SessionService; + yield* runtime.prepareProject(cwd); + const state = yield* session.load(cwd, sessionId); + expect(state.activeProfile).toBe('plan'); + yield* runtime.restoreSessionProfile(cwd, sessionId, state.activeProfile); + // 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/create-session-active-profile.test.ts b/packages/codingcode/test/server/create-session-active-profile.test.ts new file mode 100644 index 00000000..dda7fd5c --- /dev/null +++ b/packages/codingcode/test/server/create-session-active-profile.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, 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 { WorkspaceService } from '../../src/core/workspace.js'; +import { createSessionsRouter } from '../../src/server/routes/sessions.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; + +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 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 — activeProfile persistence (v13 改 1)', () => { + let cwd: string; + let rt: ManagedRuntime.ManagedRuntime; + let app: Hono; + + beforeEach(async () => { + cwd = mkdtempSync(join(tmpdir(), 'codingcode-create-session-test-')); + 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(); + rmSync(cwd, { recursive: true, force: true }); + }); + + it('writes idx.permissionMode AND idx.activeProfile when initialPermissionMode=plan', async () => { + // After the plan refactor, `permissionMode` is no longer a plan-specific + // value. The plan-mode signal lives in `activeProfile`; the approval + // pipeline itself only sees a generic permission mode. + const res = await app.request('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cwd, initialPermissionMode: 'plan' }), + }); + expect(res.status).toBe(200); + const { sessionId } = await res.json(); + + // Load state to get indexPath + 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.permissionMode).toBe('default'); + expect(idx.activeProfile).toBe('plan'); + }); + + it('writes idx.activeProfile=build when initialPermissionMode=default (build)', async () => { + const res = await app.request('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cwd, initialPermissionMode: 'default' }), + }); + 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.permissionMode).toBe('default'); + expect(idx.activeProfile).toBe('build'); + }); + + it('does not write activeProfile when no initialPermissionMode is provided', async () => { + const res = await app.request('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cwd }), + }); + 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.activeProfile).toBeUndefined(); + }); + + it('new session with plan: state.activeProfile is set, restoreSessionProfile succeeds', async () => { + const res = await app.request('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cwd, initialPermissionMode: 'plan' }), + }); + expect(res.status).toBe(200); + const { sessionId } = await res.json(); + + // Simulate the agent.sendMessage flow: load + restore + await rt.runPromise( + Effect.gen(function* () { + const runtime = yield* ProjectRuntimeService; + const session = yield* SessionService; + 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'); + // The approval-side permission mode is 'default' (pipeline is + // plan-blind); plan-mode is enforced structurally by the + // `plan/planModeGateHook`. + expect(runtime.getSessionPermissionMode(sessionId)).toBe('default'); + }) + ); + }); +}); diff --git a/packages/codingcode/test/server/handler.test.ts b/packages/codingcode/test/server/handler.test.ts index 3bf2d27e..ed3af25c 100644 --- a/packages/codingcode/test/server/handler.test.ts +++ b/packages/codingcode/test/server/handler.test.ts @@ -1,12 +1,15 @@ import { describe, it, expect } from 'vitest'; -import { ManagedRuntime } from 'effect'; +import { Layer, 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 { PlanApprovalService } from '../../src/plan/approval-service.js'; import { AgentError } from '../../src/core/error.js'; import type { AgentEvent } from '../../src/agent/types.js'; -const rt = ManagedRuntime.make(ApprovalWaitService.Default); +const rt = ManagedRuntime.make( + Layer.merge(ApprovalWaitService.Default, PlanApprovalService.Default as any) as any +); async function readSSEStream(response: Response): Promise<{ events: any[] }> { const reader = response.body!.getReader(); 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..dc57c308 --- /dev/null +++ b/packages/codingcode/test/server/plan-mode-reject-perm-mode.test.ts @@ -0,0 +1,180 @@ +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, 'test-model'); + 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..d8280181 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,9 @@ 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)); @@ -61,7 +61,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 }); } }); @@ -92,10 +92,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..8776bdee 100644 --- a/packages/codingcode/test/session/index-write-sync.test.ts +++ b/packages/codingcode/test/session/index-write-sync.test.ts @@ -1,14 +1,14 @@ 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,7 +17,7 @@ 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 { @@ -44,14 +44,14 @@ 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 { @@ -83,7 +83,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..5e3bdde6 100644 --- a/packages/codingcode/test/session/load-create.test.ts +++ b/packages/codingcode/test/session/load-create.test.ts @@ -1,29 +1,29 @@ 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 { @@ -52,7 +52,7 @@ 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 { @@ -92,7 +92,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,9 +115,9 @@ 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 { @@ -150,7 +150,7 @@ 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 { @@ -172,7 +172,7 @@ 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 { @@ -192,7 +192,7 @@ 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 { @@ -215,7 +215,7 @@ 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 { @@ -262,7 +262,7 @@ 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 { 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..b119218e --- /dev/null +++ b/packages/codingcode/test/session/load-restore-profile.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer, ManagedRuntime } from 'effect'; +import { mkdtempSync, rmSync, writeFileSync, 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 { 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; + +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 = mkdtempSync(join(tmpdir(), 'codingcode-session-load-test-')); + rt = ManagedRuntime.make(makeLayer() as any); + const result = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + const state = yield* session.create(cwd, 'test-model'); + return { sessionId: state.sessionId, indexPath: state.indexPath }; + }) + ); + sessionId = result.sessionId; + indexPath = result.indexPath; + }); + + afterEach(async () => { + await rt.dispose(); + rmSync(cwd, { recursive: true, force: true }); + }); + + it('state.activeProfile is undefined for new sessions', async () => { + const state = await rt.runPromise( + Effect.gen(function* () { + const session = yield* SessionService; + return yield* session.load(cwd, sessionId); + }) + ); + expect(state.activeProfile).toBeUndefined(); + }); + + 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..d08730c9 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -1,7 +1,6 @@ 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'; @@ -9,15 +8,16 @@ 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 +117,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 +151,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,7 +166,7 @@ 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( @@ -181,7 +181,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..8d83b2f7 100644 --- a/packages/codingcode/test/session/record-tool-result-persist.test.ts +++ b/packages/codingcode/test/session/record-tool-result-persist.test.ts @@ -1,6 +1,9 @@ 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)); 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..5f9ac398 100644 --- a/packages/codingcode/test/session/session-jsonl-path.test.ts +++ b/packages/codingcode/test/session/session-jsonl-path.test.ts @@ -1,12 +1,12 @@ 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)); @@ -27,7 +27,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 }); } }); @@ -49,7 +49,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-closure.test.ts b/packages/codingcode/test/session/store-closure.test.ts new file mode 100644 index 00000000..3da917e7 --- /dev/null +++ b/packages/codingcode/test/session/store-closure.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Effect } from 'effect'; +import { SessionService } from '../../src/session/store.js'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +describe('SessionService — readHistoryFile rename', () => { + it('exposes readHistoryFile method (not readHistory)', () => { + const svc = SessionService.makeSync(); + expect(typeof svc.readHistoryFile).toBe('function'); + expect((svc as any).readHistory).toBeUndefined(); + }); +}); + +describe('SessionService — closure-local io functions', () => { + const testDir = join(tmpdir(), 'session-service-closure-test'); + + beforeEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it('readHistoryFile reads a jsonl file and returns parsed events', () => { + const svc = SessionService.makeSync(); + const filePath = join(testDir, 'test.jsonl'); + const event = { type: 'user', content: 'hello', turnId: 1, uuid: 'u1', timestamp: new Date().toISOString() }; + writeFileSync(filePath, JSON.stringify(event) + '\n', 'utf8'); + + const events = svc.readHistoryFile(filePath); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe('user'); + expect((events[0] as any).content).toBe('hello'); + }); + + it('readHistoryFile returns empty array for non-existent file', () => { + const svc = SessionService.makeSync(); + const events = svc.readHistoryFile(join(testDir, 'nonexistent.jsonl')); + expect(events).toEqual([]); + }); + + it('appendLine appends a JSON line to a file', () => { + const svc = SessionService.makeSync(); + const filePath = join(testDir, 'append-test.jsonl'); + svc.appendLine(filePath, { type: 'user', content: 'hi' }); + svc.appendLine(filePath, { type: 'assistant', content: 'there' }); + + const events = svc.readHistoryFile(filePath); + expect(events).toHaveLength(2); + expect(events[0]!.type).toBe('user'); + expect(events[1]!.type).toBe('assistant'); + }); + + it('truncateTitle truncates long content', () => { + const svc = SessionService.makeSync(); + const longContent = 'a'.repeat(50); + const result = svc.truncateTitle(longContent); + expect(result.length).toBeLessThanOrEqual(33); // 30 chars + '...' + expect(result.endsWith('...')).toBe(true); + }); + + it('truncateTitle keeps short content as-is', () => { + const svc = SessionService.makeSync(); + const shortContent = 'hello'; + const result = svc.truncateTitle(shortContent); + expect(result).toBe('hello'); + }); + + it('countNonMetaEvents counts only non-meta events', () => { + const svc = SessionService.makeSync(); + const events = [ + { type: 'session_meta', sessionId: 's1' }, + { type: 'user', content: 'hi' }, + { type: 'assistant', content: 'there' }, + ]; + expect(svc.countNonMetaEvents(events as any)).toBe(2); + }); + + it('findFirstUserContent returns first user content', () => { + const svc = SessionService.makeSync(); + const events = [ + { type: 'session_meta', sessionId: 's1' }, + { type: 'user', content: 'first question' }, + { type: 'assistant', content: 'answer' }, + { type: 'user', content: 'second question' }, + ]; + expect(svc.findFirstUserContent(events as any)).toBe('first question'); + }); + + it('findFirstUserContent returns null when no user events', () => { + const svc = SessionService.makeSync(); + const events = [ + { type: 'session_meta', sessionId: 's1' }, + { type: 'assistant', content: 'answer' }, + ]; + expect(svc.findFirstUserContent(events as any)).toBeNull(); + }); + + it('projectSessionsDir returns correct path', () => { + const svc = SessionService.makeSync(); + const result = svc.projectSessionsDir('encoded-project'); + expect(result).toContain('project'); + expect(result).toContain('encoded-project'); + expect(result).toContain('sessions'); + }); + + it('readCurrentIndex returns null for non-existent file', () => { + const svc = SessionService.makeSync(); + const result = svc.readCurrentIndex(join(testDir, 'nonexistent.index.json')); + expect(result).toBeNull(); + }); + + it('readCurrentIndex reads an existing index file', () => { + const svc = SessionService.makeSync(); + const indexPath = join(testDir, 'test.index.json'); + const index = { sessionId: 's1', messageCount: 5 }; + writeFileSync(indexPath, JSON.stringify(index), 'utf8'); + + const result = svc.readCurrentIndex(indexPath); + expect(result).not.toBeNull(); + expect((result as any)!.sessionId).toBe('s1'); + expect((result as any)!.messageCount).toBe(5); + }); +}); + +describe('SessionService — writeQueues inside closure', () => { + it('enqueueWrite writes data asynchronously', async () => { + const svc = SessionService.makeSync(); + const testDir = join(tmpdir(), 'session-enqueue-test'); + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + mkdirSync(testDir, { recursive: true }); + + const indexPath = join(testDir, 'test.index.json'); + svc.enqueueWrite('test-session', indexPath, { sessionId: 'test-session', messageCount: 3 }); + + // Wait for async write + await new Promise((r) => setTimeout(r, 100)); + + const result = svc.readCurrentIndex(indexPath); + expect(result).not.toBeNull(); + expect((result as any)!.sessionId).toBe('test-session'); + + rmSync(testDir, { 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..412ec834 100644 --- a/packages/codingcode/test/session/store-compact-usage.test.ts +++ b/packages/codingcode/test/session/store-compact-usage.test.ts @@ -1,14 +1,14 @@ 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 +23,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 +116,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 +136,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..3e6098c2 100644 --- a/packages/codingcode/test/session/store-rollback-usage.test.ts +++ b/packages/codingcode/test/session/store-rollback-usage.test.ts @@ -1,14 +1,14 @@ 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 +23,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 +116,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 +143,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 +167,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..1426e3a8 100644 --- a/packages/codingcode/test/session/update-index-dedup.test.ts +++ b/packages/codingcode/test/session/update-index-dedup.test.ts @@ -1,14 +1,14 @@ 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 +17,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'); @@ -41,14 +41,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'); @@ -72,14 +72,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'); @@ -103,7 +103,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.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 20b87d90..7218bf39 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -323,6 +323,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) { 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..00baf5bb 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', () => { @@ -218,3 +233,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-flow.test.ts b/packages/codingcode/test/tools/submit-plan-flow.test.ts new file mode 100644 index 00000000..0085209b --- /dev/null +++ b/packages/codingcode/test/tools/submit-plan-flow.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Layer } from 'effect'; +import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { PlanApprovalService } from '../../src/plan/approval-service.js'; +import { submitPlanTool } from '../../src/tools/domains/subagent/submit-plan.js'; + +const TEST_DIR = join(tmpdir(), 'codingcode-test-submit-plan-flow'); + +describe('submitPlanTool — plan approval flow (3-option modal)', () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it('writes the plan file when the user implements (allow)', async () => { + const mockPlan = { + requestPlanDecision: () => Effect.succeed({ type: 'allow' as const }) as any, + resolvePlanDecision: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + registerEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), + }; + const PlanLayer = Layer.succeed(PlanApprovalService, mockPlan as any); + + const sessionId = 'flow-sess-1'; + const result = await Effect.runPromise( + submitPlanTool + .execute({ plan_content: '# flow plan' }, { projectPath: TEST_DIR, sessionId } as any) + .pipe(Effect.provide(PlanLayer)) + ); + + expect(result).toMatch(/^Plan written to /); + const writtenPath = result.replace(/^Plan written to /, ''); + expect(existsSync(writtenPath)).toBe(true); + expect(readFileSync(writtenPath, 'utf8')).toBe('# flow plan'); + }); + + it('writes the user-modified content when the user picks Modify', async () => { + const mockPlan = { + requestPlanDecision: () => + Effect.succeed({ + type: 'modified' as const, + input: { plan_content: '# revised plan' }, + }) as any, + resolvePlanDecision: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + registerEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), + }; + const PlanLayer = Layer.succeed(PlanApprovalService, mockPlan as any); + + const sessionId = 'flow-sess-2'; + const result = await Effect.runPromise( + submitPlanTool + .execute( + { plan_content: '# original plan' }, + { projectPath: TEST_DIR, sessionId } as any + ) + .pipe(Effect.provide(PlanLayer)) + ); + + expect(result).toMatch(/^Plan written to /); + const writtenPath = result.replace(/^Plan written to /, ''); + expect(readFileSync(writtenPath, 'utf8')).toBe('# revised plan'); + }); + + it('fails with TOOL_NOT_ALLOWED when the user cancels', async () => { + const mockPlan = { + requestPlanDecision: () => Effect.succeed({ type: 'canceled' as const }) as any, + resolvePlanDecision: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + registerEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), + }; + const PlanLayer = Layer.succeed(PlanApprovalService, mockPlan as any); + + const sessionId = 'flow-sess-3'; + const result = await Effect.runPromiseExit( + submitPlanTool + .execute({ plan_content: '# nope' }, { projectPath: TEST_DIR, sessionId } as any) + .pipe(Effect.provide(PlanLayer)) + ); + + expect(result._tag).toBe('Failure'); + }); + + it('emits the plan metadata via emitter so the UI can render the 3-option modal', async () => { + let captured: { args: any; payload: any; tool: any } | null = null; + const mockPlan = { + requestPlanDecision: ( + req: any, + _sessionId: any, + _id: any, + _tool: any, + args: any, + payload: any + ) => { + captured = { args, payload, tool: 'submit_plan' }; + return Effect.succeed({ type: 'allow' as const }) as any; + }, + resolvePlanDecision: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + registerEmitter: ( + _sessionId: string, + fn: (id: string, tool: string, args: any, payload: any) => void + ) => + Effect.sync(() => { + fn('plan_test-id', 'submit_plan', { plan_content: '# capture me' }, { + kind: 'plan', + planPath: 'C:/x/test.md', + projectPath: TEST_DIR, + sessionId: 'flow-sess-4', + }); + }), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), + }; + const PlanLayer = Layer.succeed(PlanApprovalService, mockPlan as any); + + const sessionId = 'flow-sess-4'; + const planContent = '# capture me'; + await Effect.runPromise( + submitPlanTool + .execute({ plan_content: planContent }, { projectPath: TEST_DIR, sessionId } as any) + .pipe(Effect.provide(PlanLayer)) + ); + + expect(captured).not.toBeNull(); + }); + + it('falls back to original content if Modified result is missing plan_content', async () => { + const mockPlan = { + requestPlanDecision: () => + Effect.succeed({ + type: 'modified' as const, + input: {}, + }) as any, + resolvePlanDecision: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + registerEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), + }; + const PlanLayer = Layer.succeed(PlanApprovalService, mockPlan as any); + + const sessionId = 'flow-sess-5'; + const original = '# original'; + const result = await Effect.runPromise( + submitPlanTool + .execute({ plan_content: original }, { projectPath: TEST_DIR, sessionId } as any) + .pipe(Effect.provide(PlanLayer)) + ); + + const writtenPath = result.replace(/^Plan written to /, ''); + expect(readFileSync(writtenPath, 'utf8')).toBe(original); + }); +}); diff --git a/packages/codingcode/test/tools/submit-plan.test.ts b/packages/codingcode/test/tools/submit-plan.test.ts new file mode 100644 index 00000000..a460a91f --- /dev/null +++ b/packages/codingcode/test/tools/submit-plan.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Effect, Cause, Layer } from 'effect'; +import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; +import { join, sep } from 'path'; +import { tmpdir } from 'os'; +import { submitPlanTool } from '../../src/tools/domains/subagent/submit-plan'; +import { AgentError } from '../../src/core/error'; +import { PlanApprovalService } from '../../src/plan/approval-service.js'; +import { useTempProjectBase } from '../helpers/project-base.js'; + +useTempProjectBase(); + +const TEST_DIR = join(tmpdir(), 'codingcode-test-submit-plan'); + +const defaultPlanLayer = Layer.succeed(PlanApprovalService, { + requestPlanDecision: () => Effect.succeed({ type: 'allow' as const }) as any, + resolvePlanDecision: () => Effect.succeed(false), + getPending: () => Effect.succeed([]), + registerEmitter: () => Effect.succeed(undefined), + unregisterEmitter: () => Effect.succeed(undefined), + hasEmitter: () => Effect.succeed(false), +} as any); + +function runWithDefaultPlan(eff: Effect.Effect) { + return Effect.runPromise(eff.pipe(Effect.provide(defaultPlanLayer))); +} + +describe('submitPlanTool', () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it('has the canonical name and shape expected by the plan-mode approval pipeline', () => { + expect(submitPlanTool.name).toBe('submit_plan'); + expect(submitPlanTool.parameters).toBeDefined(); + }); + + it('persists the plan content and reports the absolute path it wrote to', async () => { + const sessionId = 'sess-001'; + const planContent = '# My Plan\n\n- step 1\n- step 2'; + + const result = await runWithDefaultPlan( + submitPlanTool.execute({ plan_content: planContent }, { + projectPath: TEST_DIR, + sessionId, + } as any) + ); + + expect(result).toMatch(/^Plan written to /); + const writtenPath = result.replace(/^Plan written to /, ''); + expect(existsSync(writtenPath)).toBe(true); + expect(readFileSync(writtenPath, 'utf8')).toBe(planContent); + expect(writtenPath.endsWith(`${sep}${sessionId}.md`)).toBe(true); + }); + + it('overwrites the plan file on subsequent calls (no history retained)', async () => { + const sessionId = 'sess-002'; + const ctx = { projectPath: TEST_DIR, sessionId } as any; + + const firstResult = await runWithDefaultPlan( + submitPlanTool.execute({ plan_content: 'first version' }, ctx) + ); + const firstPath = firstResult.replace(/^Plan written to /, ''); + + const secondResult = await runWithDefaultPlan( + submitPlanTool.execute({ plan_content: 'second version' }, ctx) + ); + const secondPath = secondResult.replace(/^Plan written to /, ''); + + expect(secondPath).toBe(firstPath); + expect(readFileSync(secondPath, 'utf8')).toBe('second version'); + }); + + it('fails with TOOL_EXECUTION_FAILED when projectPath is missing from the tool context', async () => { + const result = await Effect.runPromiseExit( + submitPlanTool.execute({ plan_content: 'x' }, { sessionId: 's' } as any) + ); + expect(result._tag).toBe('Failure'); + if (result._tag === 'Failure') { + const failureOption = Cause.failureOption(result.cause); + expect(failureOption._tag).toBe('Some'); + if (failureOption._tag === 'Some') { + const failure = failureOption.value as AgentError; + expect(failure).toBeInstanceOf(AgentError); + expect(failure.code).toBe('TOOL_EXECUTION_FAILED'); + expect(failure.message).toMatch(/projectPath and sessionId/); + } + } + }); + + it('fails with TOOL_EXECUTION_FAILED when sessionId is missing from the tool context', async () => { + const result = await Effect.runPromiseExit( + submitPlanTool.execute({ plan_content: 'x' }, { projectPath: TEST_DIR } as any) + ); + expect(result._tag).toBe('Failure'); + if (result._tag === 'Failure') { + const failureOption = Cause.failureOption(result.cause); + expect(failureOption._tag).toBe('Some'); + if (failureOption._tag === 'Some') { + const failure = failureOption.value as AgentError; + expect(failure).toBeInstanceOf(AgentError); + expect(failure.code).toBe('TOOL_EXECUTION_FAILED'); + } + } + }); +}); diff --git a/packages/desktop/shared/types.ts b/packages/desktop/shared/types.ts index 8525ce67..8d58d5d7 100644 --- a/packages/desktop/shared/types.ts +++ b/packages/desktop/shared/types.ts @@ -9,6 +9,12 @@ export type Item = name: string; args: object; status: 'pending' | 'approved' | 'rejected' | 'running'; + /** + * Optional payload forwarded by the agent (e.g. plan content for submit_plan). + * The Plan Approval modal uses this to render the plan preview without + * needing a separate file fetch. + */ + payload?: Record; } | { id: string; diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index db4bfed2..57eb2b98 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 { useAgentMode } 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); @@ -225,6 +230,48 @@ function InputBox({ const setApprovalPolicy = useAgentStore((s) => s.setApprovalPolicy); const pendingInput = useAgentStore((s) => s.pendingInput); const setPendingInput = useAgentStore((s) => s.setPendingInput); + const { fetchMode: fetchSessionMode, fetchPlan } = useAgentMode(); + const [isPlanMode, setIsPlanMode] = useState(false); + const [planExists, setPlanExists] = useState(false); + useEffect(() => { + let cancelled = false; + if (!currentThreadId) { + setIsPlanMode(false); + return; + } + fetchSessionMode(currentThreadId, workspace.rootPath) + .then((m) => { + if (cancelled) return; + setIsPlanMode(m.profileName === 'plan'); + }) + .catch(() => { + if (!cancelled) return; + setIsPlanMode(false); + }); + return () => { + cancelled = true; + }; + }, [currentThreadId, workspace.rootPath, fetchSessionMode]); + + useEffect(() => { + let cancelled = false; + if (!currentThreadId || !workspace.rootPath) { + setPlanExists(false); + return; + } + fetchPlan(currentThreadId, workspace.rootPath) + .then((p) => { + if (cancelled) return; + setPlanExists(p.exists); + }) + .catch(() => { + if (!cancelled) return; + setPlanExists(false); + }); + return () => { + cancelled = true; + }; + }, [currentThreadId, workspace.rootPath, fetchPlan]); // Consume pendingInput when it's set useEffect(() => { @@ -308,6 +355,7 @@ function InputBox({ {/* Row 2: toolbar */}
+ {!isPlanMode && ( + )}
+ {planExists && onOpenPlanPanel && ( + + )} + {currentThreadId && }
@@ -356,6 +418,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 +436,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..bec2fb67 100644 --- a/packages/desktop/src/agent/ApprovalPanel.tsx +++ b/packages/desktop/src/agent/ApprovalPanel.tsx @@ -1,16 +1,33 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import type { Item } from '@shared/types'; import { useAgentStore } from '../stores/agent.store'; -import { useAgentApproval } from '../hooks/useAgent'; +import { useAgentApproval, type PlanChoice } from '../hooks/useAgent'; import ToolCallCard from '../shared/ToolCallCard'; +import PlanApprovalModal from '../shared/PlanApprovalModal'; interface ApprovalPanelProps { threadId: string; } +type SubmitPlanItem = Item & { + type: 'tool_call'; + name: 'submit_plan'; + status: 'pending'; + args: { plan_content?: string; [k: string]: unknown }; + payload?: Record; +}; + +function isSubmitPlanItem(item: Item): item is SubmitPlanItem { + return ( + item.type === 'tool_call' && + item.name === 'submit_plan' && + item.status === 'pending' + ); +} + export default function ApprovalPanel({ threadId }: ApprovalPanelProps) { const [collapsed, setCollapsed] = useState(false); - const { approveTool, rejectTool } = useAgentApproval(); + const { approveTool, rejectTool, submitPlanChoice } = useAgentApproval(); // Stable string key: only changes when pending item IDs change, not on every content update const pendingKey = useAgentStore((s) => { @@ -35,8 +52,52 @@ export default function ApprovalPanel({ threadId }: ApprovalPanelProps) { ); }, [pendingKey, threadId]); + // Track the first submit_plan that needs approval; the modal will be shown + // for this single item at a time (subsequent plans queue behind). + const planItem = useMemo(() => { + for (const item of pendingItems) { + if (isSubmitPlanItem(item as Item)) return item as SubmitPlanItem; + } + return null; + }, [pendingItems]); + + const planContent = planItem?.args?.plan_content ?? ''; + const planPath = + typeof planItem?.payload?.path === 'string' + ? (planItem!.payload!.path as string) + : typeof planItem?.payload?.plan_path === 'string' + ? (planItem!.payload!.plan_path as string) + : undefined; + + const handlePlanChoice = useCallback( + async (callId: string, choice: PlanChoice) => { + await submitPlanChoice(threadId, callId, choice); + }, + [submitPlanChoice, threadId] + ); + if (pendingItems.length === 0) return null; + // If a submit_plan is pending, the modal takes over the whole screen — do + // not render the small approval card list to avoid double interaction. + if (planItem) { + return ( + void handlePlanChoice(planItem.id, { type: 'allow' })} + onModify={(newContent) => + void handlePlanChoice(planItem.id, { + type: 'modified', + input: { plan_content: newContent }, + }) + } + onCancel={() => void handlePlanChoice(planItem.id, { type: 'canceled' })} + /> + ); + } + if (collapsed) { return (
diff --git a/packages/desktop/src/agent/ModeIndicator.tsx b/packages/desktop/src/agent/ModeIndicator.tsx new file mode 100644 index 00000000..88a6b797 --- /dev/null +++ b/packages/desktop/src/agent/ModeIndicator.tsx @@ -0,0 +1,130 @@ +import { useState, useEffect } from 'react'; +import { Eye, Hammer, Loader2 } from 'lucide-react'; +import { useAgentMode, type SessionModeSnapshot } from '../hooks/useAgent'; +import { useAgentStore } from '../stores/agent.store'; + +interface ModeIndicatorProps { + sessionId: string | null; + cwd: string; +} + +type ModeValue = 'plan' | 'build'; + +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` — fetches the live mode for that session 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. + * + * The popover variant was clipped by the chat input box, so the pill is + * a direct toggle (no popover). + */ +export default function ModeIndicator({ sessionId, cwd }: ModeIndicatorProps) { + const { fetchMode, switchMode } = useAgentMode(); + const [mode, setMode] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + + const pendingProfile = useAgentStore((s) => s.pendingProfile); + const setPendingProfile = useAgentStore((s) => s.setPendingProfile); + + // Fetch on sessionId / cwd change + useEffect(() => { + let cancelled = false; + if (!sessionId) { + setMode(null); + return; + } + setLoading(true); + fetchMode(sessionId, cwd) + .then((m) => { + if (cancelled) return; + setMode(m); + }) + .catch((e) => { + console.error('Failed to fetch session mode:', e); + if (cancelled) return; + setMode(null); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [sessionId, cwd, fetchMode]); + + // Welcome-screen mode: derive from the pending profile (no live session). + const current: ModeValue = + sessionId === null ? pendingProfile : mode?.profileName === 'plan' ? 'plan' : 'build'; + const target: ModeValue = current === 'plan' ? 'build' : 'plan'; + + const handleToggle = async () => { + if (busy) return; + + // Welcome screen: persist the pending profile locally. The next + // session created will pick it up as the initial mode. + if (sessionId === null) { + setPendingProfile(target); + return; + } + + setBusy(true); + try { + await switchMode(sessionId, target, cwd); + const m = await fetchMode(sessionId, cwd); + setMode(m); + } catch (e) { + console.error('Failed to switch mode:', e); + } finally { + setBusy(false); + } + }; + + const meta = MODE_META[current]; + const Icon = meta.Icon; + // On the welcome screen, the pill is purely UI — no async fetch, so + // `loading` is meaningless. `busy` is also only meaningful with a + // live session. + 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..a1c5424c 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -12,6 +12,7 @@ import { createSession as createServerSession, deleteSession, sendApprovalResponse, + sendPlanApproval, getCheckpointDiff, revertCheckpointFiles, previewRollbackDiff, @@ -21,6 +22,9 @@ import { undoLastCodeRollback, getRollbackState, forkSession, + getSessionMode, + setSessionMode, + getSessionPlan, } from '../lib/core-api'; import type { CheckpointDiff, @@ -62,6 +66,7 @@ export function useAgentCore() { const workspace = useWorkspaceStore(); const currentThreadId = useAgentStore((s) => s.currentThreadId); const approvalPolicy = useAgentStore((s) => s.approvalPolicy); + const pendingProfile = useAgentStore((s) => s.pendingProfile); // Load sessions, models, and projects on mount useEffect(() => { @@ -162,6 +167,10 @@ export function useAgentCore() { name: event.tool, args: event.args, status: 'pending', + // Forward the server-side payload (e.g. plan_content for submit_plan) + // so the UI can render a specialized approval modal without a second + // round-trip to fetch the plan file. + payload: event.payload, }; case 'tool_result': return { @@ -232,7 +241,10 @@ export function useAgentCore() { 'full-allow': 'bypass', 'read-only': 'plan', }; - const initialMode = POLICY_TO_MODE[approvalPolicy] ?? 'default'; + // pendingProfile ('plan' | 'build') set on the welcome screen + // overrides the permission-policy default when it asks for plan. + const initialMode = + pendingProfile === 'plan' ? 'plan' : (POLICY_TO_MODE[approvalPolicy] ?? 'default'); const data = await createServerSession(effectiveCwd, initialMode); threadId = data.sessionId; setCurrentThread(threadId); @@ -296,6 +308,7 @@ export function useAgentCore() { completeTurn, workspace.rootPath, approvalPolicy, + pendingProfile, currentThreadId, ] ); @@ -313,7 +326,12 @@ export function useAgentCore() { return { sendMessage, abort }; } -// ---- useAgentApproval: approveTool + rejectTool ---- +// ---- useAgentApproval: approveTool + rejectTool + submitPlanChoice ---- + +export type PlanChoice = + | { type: 'allow' } + | { type: 'modified'; input: Record } + | { type: 'canceled' }; export function useAgentApproval() { const updateToolCallStatus = useAgentStore((s) => s.updateToolCallStatus); @@ -342,7 +360,37 @@ export function useAgentApproval() { [updateToolCallStatus] ); - return { approveTool, rejectTool }; + /** + * Send a structured plan approval decision. The server interprets the JSON + * envelope via `parseApprovalResponse` and either: + * - 'allow' → writes the plan and switches to build mode + * - 'modified' → re-executes submit_plan with the supplied input + * - 'canceled' → denies the call + * + * The local UI status is updated so the pending card collapses immediately + * instead of waiting for the next stream event. + */ + const submitPlanChoice = useCallback( + async (threadId: string, callId: string, choice: PlanChoice) => { + const nextStatus = + choice.type === 'allow' ? 'approved' : choice.type === 'canceled' ? 'rejected' : 'running'; + updateToolCallStatus(threadId, callId, nextStatus as any); + try { + if (choice.type === 'allow') { + await sendPlanApproval(threadId, callId, { type: 'allow' }); + } else if (choice.type === 'canceled') { + await sendPlanApproval(threadId, callId, { type: 'canceled' }); + } else { + await sendPlanApproval(threadId, callId, { type: 'modified', input: choice.input }); + } + } catch (e) { + console.error('Failed to submit plan choice:', e); + } + }, + [updateToolCallStatus] + ); + + return { approveTool, rejectTool, submitPlanChoice }; } // ---- useAgentRollback: all rollback methods ---- @@ -591,3 +639,55 @@ export function useAgent() { const rollback = useAgentRollback(); return { ...core, ...approval, ...rollback }; } + +// ---- useAgentMode: plan/build mode switching + plan file access ---- + +export type SessionModeSnapshot = { + profileName: string; + permissionMode: 'default' | 'acceptEdits' | 'plan' | 'bypass'; + 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, + profile: 'plan' | 'build', + cwd?: string + ): Promise<{ profileName: string; permissionMode: string }> => { + return setSessionMode(sessionId, cwd ?? workspace.rootPath ?? '', profile); + }, + [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..9f3bbb52 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -64,6 +64,66 @@ export function sendApprovalResponse( return clients.agent.sendApprovalResponse({ sessionId, approvalId: callId, response }); } +/** + * Send a structured plan approval decision to the server. Routed to the + * `/api/sessions/:sessionId/plan-approval/:callId` endpoint which is parsed by + * `parsePlanApprovalResponse` and resolved by `PlanApprovalService`. The shape + * is `allow` | `modified` (with revised `plan_content`) | `canceled`. There is + * no `deny` for plan approval — cancel is the user-controlled exit. + */ +export function sendPlanApproval( + sessionId: string, + callId: string, + decision: { type: 'allow' } | { type: 'modified'; input: Record } | { type: 'canceled' } +): Promise { + return clients.agent.sendPlanApprovalResponse({ + sessionId, + approvalId: callId, + response: JSON.stringify(decision), + }); +} + +// ---- 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 = { + profileName: string; + permissionMode: 'default' | 'acceptEdits' | 'plan' | 'bypass'; + 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, + profile: 'plan' | 'build' +): Promise<{ profileName: string; permissionMode: string }> { + return api<{ profileName: string; permissionMode: string }>( + `/api/sessions/${sessionId}/mode`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cwd, profile }), + } + ); +} + // ---- Settings: Memory ---- export function getMemoryConfig(): Promise<{ @@ -139,8 +199,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 && ( <> +
+ + {/* Plan meta */} + {planPathLabel && ( +
+ 计划文件:{planPathLabel} +
+ )} + + {/* Tabs */} +
+ + + +
+ + {/* Body */} +
+ {view === 'preview' ? ( +
+ {planContent ? ( + + ) : ( +
(计划内容为空)
+ )} +
+ ) : ( +
+