diff --git a/docs/configuration.md b/docs/configuration.md index 38d9fdd9..f81dc40a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,7 +13,6 @@ Coding Code 的核心哲学是所有行为都可配置。本文档详细介绍 | `rules.md` | `~/.codingcode/rules.md` + `./AGENTS.md` | 全局 + 项目级规则 | 本文档 | | `mcp.yaml` | `~/.codingcode/mcp.yaml` + `.codingcode/mcp.yaml` | MCP 服务配置 | [→ mcp.md](mcp.md) | | `hooks.yaml` | `~/.codingcode/hooks.yaml` + `.codingcode/hooks.yaml` | 钩子配置 | [→ hooks.md](hooks.md) | -| `agents/*.md` | `~/.codingcode/agents/` + `.codingcode/agents/` | 子智能体 profile | [→ subagent.md](subagent.md) | | `memory.md` | `./.codingcode/memory.md` | 长期记忆(项目级) | [→ memory.md](memory.md) | --- diff --git a/docs/mcp.md b/docs/mcp.md index 5ebd9e2b..54f57372 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -99,18 +99,6 @@ MCP 连接使用 lease 机制管理会话级生命周期: --- -## MCP 工具白名单 +## 子智能体 -在子智能体 profile 中通过 `mcpServers` 字段指定允许的 MCP 服务: - -```yaml -# .codingcode/agents/my-agent.md ---- -name: my-agent -description: 使用特定 MCP 服务的 Agent -tools: ["read_file", "search_code"] -mcpServers: ["filesystem"] # 只允许使用 filesystem 服务的工具 ---- -``` - -在 `ToolVisibilityPolicy` 中通过 `allowedMcpServers` 控制可见的 MCP 服务。 +子智能体不再通过 profile 文件配置 MCP 服务或工具白名单。MCP 工具由当前项目的 MCP 配置提供;`plan` 模式仅由其独立工具策略限制。 diff --git a/docs/subagent.md b/docs/subagent.md index c88e28a8..a6874f51 100644 --- a/docs/subagent.md +++ b/docs/subagent.md @@ -1,168 +1,5 @@ # 子智能体系统 -每个子 Agent 是独立的 ReAct 引擎实例,拥有受限的工具集和独立的上下文。本文档介绍子 Agent 的特性、配置格式、内置 profile 和执行流程。 +当前运行时只保留 `build` 和 `plan` 两个内置 profile。子智能体不再从项目或全局配置文件加载,也不再支持用户通过 profile 配置工具、模型、MCP、hooks、步数或禁用状态。 ---- - -## 特性 - -- **独立执行**:子 Agent 在独立的 Effect Context 中运行 -- **受限工具集**:每个子 Agent profile 定义自己的工具白名单 -- **独立上下文**:不共享主 Agent 的消息历史 -- **独立模型**:可指定与主 Agent 不同的模型 -- **独立 MCP**:可连接指定的 MCP 服务器 -- **独立钩子**:可附加专属的钩子配置 -- **自由定义**:用户可配置任意数量的子 Agent profile - ---- - -## AgentProfile 类型定义 - -```typescript -interface AgentProfile { - name: string; // profile 名称,用于 dispatch_agent 引用 - description: string; // 功能描述,LLM 据此决定是否委派 - systemPrompt?: string; // 自定义系统提示词 - tools?: string[]; // 允许使用的工具白名单 - mcpServers?: string[]; // 允许连接的 MCP 服务白名单 - readonly?: boolean; // 是否只读模式 - maxSteps?: number; // 最大执行步数 - model?: string; // 使用的模型 ID - hooks?: UserHookConfig[]; // 专属钩子配置 - disabled?: boolean; // 是否禁用 -} -``` - ---- - -## 配置格式 - -子 Agent 使用 Markdown + frontmatter 格式配置,存放在 `.codingcode/agents/` 目录下: - -| 级别 | 路径 | 说明 | -|------|------|------| -| 全局 | `~/.codingcode/agents/*.md` | 所有项目共享 | -| 项目 | `.codingcode/agents/*.md` | 仅当前项目生效 | - -项目级同名 profile 覆盖全局级。 - -### 示例 - -```markdown ---- -name: code-searcher -description: 专门搜索代码库的子 Agent,擅长定位函数定义和引用 -tools: ["read_file", "search_code", "search_files"] -readonly: true -maxSteps: 100 -model: deepseek-chat -disabled: false ---- -You are a code search specialist. Your job is to find specific code patterns, function definitions, and usages in the codebase. Always provide the file path and line numbers in your results. -``` - -### 字段说明 - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `name` | `string` | 必填 | profile 名称 | -| `description` | `string` | 必填 | 功能描述,LLM 据此决定是否委派任务 | -| `systemPrompt` | `string` | frontmatter 之后的正文 | 系统提示词 | -| `tools` | `string[]` | 所有内置工具 | 允许使用的工具白名单 | -| `mcpServers` | `string[]` | 无 | 允许连接的 MCP 服务名列表 | -| `readonly` | `boolean` | `false` | 只读模式下只允许只读工具 | -| `maxSteps` | `number` | 继承主 Agent | 最大执行步数 | -| `model` | `string` | 继承主 Agent | 使用的模型 ID | -| `hooks` | `UserHookConfig[]` | 无 | 专属钩子配置 | -| `disabled` | `boolean` | `false` | 禁用此 profile | - ---- - -## 内置 Profile - -系统内置两个子 Agent profile: - -### explore - -只读代码探索 Agent,用于快速浏览和理解代码库: - -```yaml -name: explore -description: 只读代码探索 -tools: [read_file, search_files, search_code, fetch_url] -readonly: true -maxSteps: 180 -``` - -### plan - -只读代码研究 + 规划 Agent。**只允许只读工具**和 `submit_plan`(用于提交实现计划等待用户审批),不允许执行命令或写文件。计划提交后 session 会自动切换到 `build` profile。 - -```yaml -name: plan -description: 只读代码研究和规划 -tools: [read_file, search_files, search_code, fetch_url, 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` 子代理。 - ---- - -## 执行流程 - -主 Agent 通过 `dispatch_agent` 工具委派任务,完整执行流程如下: - -1. **检查开关**:验证全局子智能体开关是否启用 (`resolveSubagentEnabled`) -2. **解析 profile**:查找对应的 AgentProfile (`runtime.resolveSubagentProfile`) -3. **检查禁用**:验证该 profile 是否被禁用 (`resolveAgentDisabled`) -4. **创建 LLM**:如果 profile 指定了 model,创建对应的 LLM 客户端 -5. **钩子决策**:触发 `agent.subagent.spawn.before` 决策钩子(可 deny 阻止) -6. **创建子会话**:嵌套在父会话下,设置 `parentSessionId` -7. **Fork 审批**:如果非 readonly,fork 审批服务 -8. **附加钩子**:附加 profile 中定义的 hooks -9. **连接 MCP**:连接 profile 中指定的 MCP 服务器(会话级 lease) -10. **构建工具策略**:根据 profile.tools 和 ToolVisibilityPolicy 过滤可用工具 -11. **执行**:调用 `runner.runStream()` 执行子智能体 -12. **钩子通知**:触发 `agent.subagent.spawn.after` 钩子 -13. **收集输出**:提取事件流中的最终输出 -14. **清理**:断开 MCP 连接,移除 hooks -15. **完成钩子**:触发 `agent.subagent.complete` 钩子 - ---- - -## 使用示例 - -### 通过 dispatch_agent 工具委派 - -```typescript -// Agent 自动调用 -await agent.executeTool('dispatch_agent', { - agent: 'explore', - prompt: 'Find all usages of getUserById function' -}); -``` - -### 自定义子 Agent - -创建 `.codingcode/agents/security-auditor.md`: - -```markdown ---- -name: security-auditor -description: 安全审计 Agent,检查代码中的安全漏洞 -tools: ["read_file", "search_code", "search_files"] -readonly: true -maxSteps: 50 -model: deepseek-chat ---- -You are a security audit specialist. Review code for common vulnerabilities: -- SQL injection -- XSS -- CSRF -- Path traversal -- Command injection -Report findings with severity level and remediation suggestions. -``` - -然后在对话中请求安全审计时,主 Agent 会自动委派给 `security-auditor`。 +`plan` 模式由独立的 plan 工具策略限制可见和可执行工具,并通过 `submit_plan` 提交计划。普通子智能体工具名单机制已移除;后续子智能体配置由运行时机制另行提供。 diff --git a/docs/tools.md b/docs/tools.md index 1c8a9d60..43354839 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -39,7 +39,7 @@ Coding Code 的工具系统是 Agent 与外部世界交互的核心机制。本 | 工具 | 功能 | 关键参数 | |---|---|---| -| `dispatch_agent` | 将任务委派给子智能体 | `agent: string`(子智能体名称),`prompt: string`(任务描述,至少 1 字符) | +| `dispatch_agent` | 将任务委派给运行时注册的子智能体 | `agent: string`, `prompt: string` | --- @@ -50,7 +50,7 @@ Coding Code 的工具系统是 Agent 与外部世界交互的核心机制。本 - **Core 工具**:始终可用,在启动时注册。包括上述所有内置工具。 - **MCP 工具**:从 MCP 服务自动导入和注册。名称空间化为 `serverName:toolName` 格式,避免不同服务间的工具名冲突。 -Agent 在一次运行开始时将内置工具、项目 MCP 工具和 `dispatch_agent` 注册到 `ToolRegistry`。每轮通过注册表按 `AgentProfile.tools` 和 `ToolVisibilityPolicy` 过滤,并生成 LLM 工具描述与执行查找结果。 +Agent 在一次运行开始时注册内置工具、项目 MCP 工具和 `dispatch_agent`。plan 模式通过独立的 `PLAN_MODE_ALLOWED_TOOLS` 策略过滤工具。 --- @@ -103,7 +103,7 @@ interface ToolVisibilityPolicy { |------|------|------| | 1 | **RuleEngine** | 规则引擎匹配,支持 glob 模式匹配工具名和参数,按优先级排序 | | 2 | **ReadonlyWhitelist** | 只读工具自动放行(read_file, search_code, search_files, fetch_url, web_search, dispatch_agent, todo_write) | -| 3 | **PermissionMode** | 权限模式判断:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行)、`default`(继续下一层)。`plan` 模式由独立的 `plan/planModeGateHook` 在 Layer 4 强制,不在此层处理 | +| 3 | **PermissionMode** | 权限模式判断:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行)、`default`(继续下一层)。`plan` 模式由独立的 `agent/mode.ts` 中的 `planModeGateHook` 在 Layer 4 强制,不在此层处理 | | 4 | **HookPreToolUse** | 钩子决策,可返回 allow/deny/ask/continue,支持 `modifiedInput` 修改参数 | | 5 | **UserConfirmation** | 异步用户确认,支持 allow/deny/always/never 四种响应,always/never 会持久化为规则 | | 6 | **AuditLog** | 每一层决策后记录审计日志,通过 `tool.approval.post` 钩子发出 | @@ -134,7 +134,7 @@ type PermissionMode = 'default' | 'acceptEdits' | 'bypass'; - `acceptEdits`:非破坏性工具自动放行,减少确认弹窗 - `bypass`:全部放行,跳过所有审批(慎用) -> `plan` 不再是 `PermissionMode` 的成员。plan 模式通过 `AgentProfile.name === 'plan'` 结构化识别,由 `plan/planModeGateHook` 在 `tool.approval.pre` 阶段(priority -1000)强制拒绝非白名单工具。白名单见 `plan/policy.ts` 的 `PLAN_MODE_ALLOWED_TOOLS`。 +> `plan` 不再是 `PermissionMode` 的成员。plan 模式通过 `AgentProfile.name === 'plan'` 结构化识别,由 `agent/mode.ts` 的 `planModeGateHook` 和 `PLAN_MODE_ALLOWED_TOOLS` 共同限制工具。 ### OS 级沙箱(预留) diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 5759ca12..6bd93401 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -42,8 +42,6 @@ "./checkpoint/checkpoint-service": "./src/checkpoint/checkpoint-service.ts", "./checkpoint/shadow-git": "./src/checkpoint/shadow-git.ts", "./checkpoint/bootstrap": "./src/checkpoint/bootstrap.ts", - "./subagent/registry": "./src/subagent/registry.ts", - "./subagent/loader": "./src/subagent/loader.ts", "./llm/factory": "./src/llm/factory.ts", "./llm/client": "./src/llm/client.ts", "./layer": "./src/layer.ts" diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 38813a80..d27e46c2 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -18,15 +18,13 @@ import { McpService } from '../mcp/index.js'; import { ContextService } from '../context/service.js'; import { MemoryService } from '../memory/index.js'; import { createLogger } from '@codingcode/infra/logger'; -import { resolveSubagentEnabled, resolveAgentDisabled } from '../subagent/registry.js'; -import { ProjectRuntimeService, modeToProfile } from '../runtime/project-runtime.js'; -import { createDispatchAgentTool } from '../tools/domains/subagent/dispatch.js'; -import { LLMFactoryService } from '../llm/factory.js'; +import { ProjectRuntimeService } from '../runtime/project-runtime.js'; import { registerBuiltinTools } from '../tools/builtin-tools.js'; import { ToolRegistry } from '../tools/registry.js'; import { submitPlanTool } from '../tools/domains/subagent/submit-plan.js'; +import { createDispatchAgentTool } from '../tools/domains/subagent/dispatch.js'; import { normalizePath } from '../core/path.js'; -import { isPlanProfile } from '../plan/index.js'; +import { isPlanProfile } from './mode.js'; import type { SessionMode } from '../session/types.js'; import type { PermissionMode } from '../approval/types.js'; @@ -139,7 +137,6 @@ export const sendMessage = ( const rules = yield* RulesService; const context = yield* ContextService; const memory = yield* MemoryService; - const factory = yield* LLMFactoryService; const normalizedCwd = normalizePath(cwd); yield* runtime.prepareProject(normalizedCwd); @@ -148,10 +145,7 @@ export const sendMessage = ( if (!sessionId) { if (!options.mode || !options.permissionMode || !options.model) { return yield* Effect.fail( - new AgentError( - 'CONFIG_MISSING', - 'new session requires mode, permissionMode, and model' - ) + new AgentError('CONFIG_MISSING', 'new session requires mode, permissionMode, and model') ); } const created = yield* session.createSessionWithProfile(normalizedCwd, { @@ -178,28 +172,14 @@ export const sendMessage = ( const dispatchTool = yield* createDispatchAgentTool(); - let activeLlm = llm; - if (profile?.model) { - const entry = yield* factory.findModel(profile.model); - if (entry) { - activeLlm = yield* factory.createClient(entry); - } - } + const activeLlm = llm; const effectiveMaxSteps = profile?.maxSteps; const effectiveApproval: any = options?.approvalOverride; - if (profile?.hooks?.length) { - yield* hooks.attachSessionHooks(sid, profile.hooks); - } - - if (profile?.mcpServers?.length) { - yield* mcp.connectServers(normalizedCwd, sid, profile.mcpServers); - } - const mcpTools = mcp.listProjectMcpTools(normalizedCwd); const turnId = session.incrementTurn(state); - const [matchedSkill, actualInput] = yield* skills.extractSkill(state.cwd, input); + const [, actualInput] = yield* skills.extractSkill(state.cwd, input); yield* session.recordUser(state, actualInput); @@ -215,11 +195,10 @@ export const sendMessage = ( toolPolicy: policy, maxStepsOverride: effectiveMaxSteps, approvalOverride: effectiveApproval, - dispatchTool, mcpTools, - skillInstruction: matchedSkill?.instruction, abortSignal: options?.signal, rulesText, + dispatchTool, }); return { stream, sessionId: sid }; @@ -257,24 +236,14 @@ export function agentLoop( const todo = yield* TodoService; const context = yield* ContextService; const memory = yield* MemoryService; - const { skillInstruction, systemPromptVariant, rulesText } = opts; - - const allAgentProfiles = runtime.listAgentProfiles(projectPath); - const enabledAgentProfiles = resolveSubagentEnabled(projectPath) - ? allAgentProfiles.filter((p) => !resolveAgentDisabled(projectPath, p.name)) - : []; - const visibleAgentProfiles = isPlanProfile(profile) - ? enabledAgentProfiles.filter((p) => p.name === 'explore') - : enabledAgentProfiles; + const { rulesText } = opts; + const basePrompt = opts.systemOverride ?? buildSystemPrompt({ cwd: projectPath, platform: process.platform, shell: process.env.SHELL || process.env.ComSpec || 'bash', - variant: systemPromptVariant ?? 'default', - skillInstruction, - agentProfiles: visibleAgentProfiles, rules: rulesText, profileSystemPrompt: profile?.systemPrompt, }); @@ -292,7 +261,7 @@ export function agentLoop( const registry = new ToolRegistry(); yield* registerBuiltinTools(registry); registry.register(...(opts.mcpTools ?? [])); - if (opts.dispatchTool && resolveSubagentEnabled(projectPath)) registry.register(opts.dispatchTool); + if (opts.dispatchTool) registry.register(opts.dispatchTool); if (isPlanProfile(profile)) registry.register(submitPlanTool); let messages: Message[] = []; diff --git a/packages/codingcode/src/plan/index.ts b/packages/codingcode/src/agent/mode.ts similarity index 62% rename from packages/codingcode/src/plan/index.ts rename to packages/codingcode/src/agent/mode.ts index 89819176..3c1847fd 100644 --- a/packages/codingcode/src/plan/index.ts +++ b/packages/codingcode/src/agent/mode.ts @@ -1,23 +1,35 @@ import { readFileSync } from 'fs'; import type { DecisionHandler } from '../hooks/types.js'; import { computePaths } from '../core/path.js'; - -// ---- Profile name constants + structural helper ---- +import type { AgentProfile } from '../subagent/types.js'; +import { BUILD_PROMPT, PLAN_PROMPT } from './prompt.js'; export const PLAN_PROFILE_NAME = 'plan' as const; export const BUILD_PROFILE_NAME = 'build' as const; +export const PLAN_PROFILE: AgentProfile = { + name: PLAN_PROFILE_NAME, + systemPrompt: PLAN_PROMPT, + maxSteps: 180, +}; + +export const BUILD_PROFILE: AgentProfile = { + name: BUILD_PROFILE_NAME, + systemPrompt: BUILD_PROMPT, +}; + export function isPlanProfile(p: { name: string } | null | undefined): boolean { return p?.name === PLAN_PROFILE_NAME; } export const PLAN_MODE_ALLOWED_TOOLS: ReadonlySet = new Set([ + 'read_file', + 'search_files', + 'search_code', + 'fetch_url', 'submit_plan', - 'dispatch_agent', ]); -// ---- Plan-mode state: read from .index.json (disk is single source of truth) ---- - export function isSessionInPlanMode(sessionId: string, cwd: string): boolean { try { const paths = computePaths(cwd, sessionId); @@ -30,23 +42,6 @@ export function isSessionInPlanMode(sessionId: string, cwd: string): boolean { } } -// ---- 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; const projectPath = payload.projectPath as string | undefined; diff --git a/packages/codingcode/src/agent/prompt.ts b/packages/codingcode/src/agent/prompt.ts index d7212d77..cb5ebb24 100644 --- a/packages/codingcode/src/agent/prompt.ts +++ b/packages/codingcode/src/agent/prompt.ts @@ -1,6 +1,6 @@ import type { SystemPromptOptions } from './types.js'; -const DEFAULT_BEHAVIOR_PROMPT = `You are a coding assistant —an AI agent that helps users with software engineering tasks. +export const BUILD_PROMPT = `You are a coding assistant —an AI agent that helps users with software engineering tasks. ## How you work - Your text output is displayed to the user as formatted text. Tool calls and their results are shown separately —the user can see what tools you used and their outcomes. @@ -17,7 +17,7 @@ const DEFAULT_BEHAVIOR_PROMPT = `You are a coding assistant —an AI agent that 7. For complex or broad tasks (understanding a whole module, cross-file analysis, comprehensive search): a. Briefly assess the task scope using your own reasoning —do not use tools for exploration at this stage, as that would consume your limited context window. b. If you can clearly handle it without extensive file reading or searching, proceed yourself. - c. Otherwise, delegate 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. + c. Otherwise, delegate the discovery task with dispatch_agent when a runtime-configured subagent is available. ## Using your tools - **Prefer dedicated tools over shell commands.** Use read_file instead of cat, edit_file instead of sed, search_code instead of grep. Dedicated tools give the user better visibility into your work. @@ -62,6 +62,38 @@ When referencing code, use the format \`file_path:line_number\` for easy navigat Respond in the user's language. Use code blocks for code.`; + +export const PLAN_PROMPT = `You are a planning agent. Your role is to analyze the codebase and produce an implementation plan that the user reviews and approves before any code is written. + +You can read files and search code. You can submit a plan via the \`submit_plan\` tool — each call overwrites the previous plan file; use it to revise your plan based on user feedback. + +In plan mode, write_file / edit_file / execute_command are denied. The only write operation allowed is \`submit_plan\`. + +## Research process +1. Understand the project structure and conventions +2. Identify relevant files and existing patterns +3. Analyze dependencies and potential impacts +4. Assess complexity and risks +5. Check for existing implementations or similar patterns + +## Output format +When ready, call \`submit_plan({ title, plan_content: "..." })\` with a Markdown plan: +- **Current state**: What exists today +- **Key files**: Files that need modification or creation, with line references +- **Dependencies and risks**: Breaking changes, third-party concerns +- **Recommended approach**: Step-by-step implementation strategy +- **Phases**: If complex, break into ordered phases + +## After submit_plan +submit_plan returns synchronously after writing the plan file. Once you have called it, stop and wait for the user's decision — do not call submit_plan again until the user responds, and do not attempt to use any other write tool. + +The user's decision arrives as the next user message. The system has already handled the agent-profile switch (plan → build on approval, plan → plan on revise, no change on cancel); the message body itself is your signal: + +- "Implement"/"proceed"/"go ahead" (or any explicit approval) — the plan is approved. Acknowledge briefly and stop. The build agent will pick up the plan from the persisted file. +- The body contains a revised plan (a Markdown document, often with explicit section headers, or with a "Revise the plan with these changes:" wrapper) — treat the body as the new plan_content, call \`submit_plan\` again with the same title and the revised content, then stop. +- "Cancel"/"do not implement" — the plan is rejected. Acknowledge briefly and stop. + +Never re-call submit_plan on your own initiative. Never treat an implement message as a request for further exploration.`; const DEFAULT_ENV_PROMPT = `## Environment - Working directory: {{cwd}} - Operating system: {{platform}} @@ -81,7 +113,7 @@ function renderBase(opts: SystemPromptOptions): string { export function buildSystemPrompt(opts: SystemPromptOptions): string { let prompt = renderBase(opts); - prompt += '\n\n' + (opts.profileSystemPrompt ?? DEFAULT_BEHAVIOR_PROMPT); + prompt += '\n\n' + (opts.profileSystemPrompt ?? BUILD_PROMPT); prompt += `\n\n${SYSTEM_NOTES}`; const rules = opts.rules; @@ -89,55 +121,5 @@ export function buildSystemPrompt(opts: SystemPromptOptions): string { prompt += `\n\n## User-defined Rules\n\nThe following rules MUST be followed at all times. They override any conflicting instructions above.\n\n${rules}`; } - if (opts.agentProfiles && opts.agentProfiles.length > 0) { - const enabledProfiles = opts.agentProfiles.filter((p) => !p.disabled); - if (enabledProfiles.length > 0) { - prompt += '\n\n## Available Subagents\n'; - prompt += 'You can dispatch subagents using the dispatch_agent tool. Available profiles:\n'; - for (const p of enabledProfiles) { - prompt += `\n### ${p.name}\n${p.description}`; - if (p.tools && p.tools.length > 0) { - prompt += `\nTools: ${p.tools.join(', ')}`; - } - } - - prompt += ` - -### 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 = 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. - -### 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. -3. When the subagent returns, relay its conclusion to the user concisely. - -### Example - -\`\`\` -User: "Find all API route definitions in this project." - -Thinking: This requires searching multiple directories broadly. If I grep and read files myself, all the raw output piles into my context. I should dispatch explore. - -dispatch_agent({ - agent: "explore", - prompt: "Search the entire project for API route definitions..." -}) -\`\`\``; - } - } - - if (opts.skillInstruction) { - prompt += `\n\n## Skill Instructions\n\n${opts.skillInstruction}`; - } - return prompt; } diff --git a/packages/codingcode/src/agent/types.ts b/packages/codingcode/src/agent/types.ts index d9d90730..4fb3cfce 100644 --- a/packages/codingcode/src/agent/types.ts +++ b/packages/codingcode/src/agent/types.ts @@ -18,15 +18,10 @@ export interface TodoCounts { completed: number; } -export type SystemPromptVariant = 'default'; - export interface SystemPromptOptions { cwd: string; platform: string; shell: string; - variant?: SystemPromptVariant; - skillInstruction?: string; - agentProfiles?: AgentProfile[]; rules?: string; profileSystemPrompt?: string; } @@ -86,8 +81,6 @@ export interface RunStreamOptions { state: SessionStoreState; llm: LLMClient; profile?: AgentProfile; - skillInstruction?: string; - systemPromptVariant?: SystemPromptVariant; systemOverride?: string; coreAllowlist?: ReadonlySet; toolPolicy?: ToolVisibilityPolicy; diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index c8ae1468..024b3229 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -1,10 +1,7 @@ import type { AgentClient, StreamChunk } from './types.js'; import type { McpServerConfig } from '../mcp/types.js'; -import type { AgentProfile } from '../subagent/types.js'; import type { UserHookConfig } from '../hooks/types.js'; import type { PermissionMode } from '../approval/types.js'; -import type { SessionEvent } from '../session/types.js'; -import type { RollbackState } from '../checkpoint/types.js'; import { parseSseStream } from './sse.js'; import { createHttpClients } from './http/index.js'; @@ -152,24 +149,21 @@ export async function createHttpClient(serverUrl: string): Promise const res = await clients.agent.rollbackContext(throughTurnId); return { turns: (res as any).turns ?? [], - rollbackState: - (res as any).rollbackState ?? { active: false, currentThroughTurnId: null }, + rollbackState: (res as any).rollbackState ?? { active: false, currentThroughTurnId: null }, }; }, async rollbackBothToTurn(throughTurnId: number) { const res = await clients.agent.rollbackBothToTurn(throughTurnId); return { turns: (res as any).turns ?? [], - codeResult: - (res as any).codeResult ?? { - reverted: false, - throughTurnId, - affectedTurns: [], - selectedFiles: [], - restoreEntry: null, - }, - rollbackState: - (res as any).rollbackState ?? { active: false, currentThroughTurnId: null }, + codeResult: (res as any).codeResult ?? { + reverted: false, + throughTurnId, + affectedTurns: [], + selectedFiles: [], + restoreEntry: null, + }, + rollbackState: (res as any).rollbackState ?? { active: false, currentThroughTurnId: null }, }; }, async undoLastCodeRollback(force?: boolean, files?: string[]) { @@ -216,18 +210,6 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.deleteMemoryExtraType(name); }, - async getSubagentEnabled({ cwd }: { cwd: string }) { - return clients.settings.getSubagentEnabled({ cwd }); - }, - - async setSubagentEnabled(body: { enabled: boolean; cwd: string }) { - await clients.settings.setSubagentEnabled(body); - }, - - async resetSubagentEnabled(body: { cwd: string }) { - await clients.settings.resetSubagentEnabled(body); - }, - async getMcpStatus({ cwd }: { cwd: string }) { return clients.settings.getMcpStatus({ cwd }); }, @@ -260,30 +242,6 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.deleteMcpServer({ cwd, name }); }, - async listAgents({ cwd }: { cwd: string }) { - return clients.settings.listAgents({ cwd }); - }, - - async createAgent(profile: AgentProfile, { cwd }: { cwd: string }) { - await clients.settings.createAgent({ cwd, profile }); - }, - - async updateAgent(name: string, profile: AgentProfile, { cwd }: { cwd: string }) { - await clients.settings.updateAgent({ cwd, name, profile }); - }, - - async deleteAgent(name: string, { cwd }: { cwd: string }) { - await clients.settings.deleteAgent({ cwd, name }); - }, - - async setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }) { - await clients.settings.setAgentDisabled(body); - }, - - async resetAgentDisabled(body: { name: string; cwd: string }) { - await clients.settings.resetAgentDisabled(body); - }, - async listHooks({ cwd }: { cwd: string }) { return clients.settings.listHooks({ cwd }); }, diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index 59cbad9c..2fa5e137 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -1,6 +1,5 @@ import type { PermissionMode } from '../../approval/types.js'; import type { McpServerConfig, McpStatus } from '../../mcp/types.js'; -import type { AgentProfile } from '../../subagent/types.js'; import type { UserHookConfig } from '../../hooks/types.js'; import type { createRequestHelpers } from './request.js'; @@ -19,9 +18,6 @@ export interface SettingsClient { setMemoryModel(model: string): Promise<{ model: string }>; getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number }>; setCompactionModel(compactionModel: string): Promise<{ compactionModel: string }>; - getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; - setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; - resetSubagentEnabled(body: { cwd: string }): Promise; getMcpStatus(input: { cwd: string }): Promise; setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetMcpDisabled(body: { name: string; cwd: string }): Promise; @@ -30,12 +26,6 @@ export interface SettingsClient { deleteMcpServer(input: { cwd: string; name: string }): Promise; listSkills(): Promise>; toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; - listAgents(input: { cwd: string }): Promise; - createAgent(input: { cwd: string; profile: AgentProfile }): Promise; - updateAgent(input: { cwd: string; name: string; profile: AgentProfile }): Promise; - deleteAgent(input: { cwd: string; name: string }): Promise; - setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; - resetAgentDisabled(body: { name: string; cwd: string }): Promise; listHooks(input: { cwd: string }): Promise; createHook(input: { cwd: string; hook: UserHookConfig }): Promise; updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise; @@ -101,20 +91,6 @@ export function createHttpSettingsClient( await apiDelete(`/api/settings/memory/extra-type/${encodeURIComponent(name)}`); }, - async getSubagentEnabled({ cwd }) { - return apiGet<{ enabled: boolean; source: string }>( - `/api/settings/subagent/enabled${qsCwd(cwd)}` - ); - }, - - async setSubagentEnabled({ enabled, cwd }) { - await apiPost(`/api/settings/subagent/enabled${qsCwd(cwd)}`, { enabled }); - }, - - async resetSubagentEnabled({ cwd }) { - await apiPost(`/api/settings/subagent/enabled/reset${qsCwd(cwd)}`, {}); - }, - async getMcpStatus({ cwd }) { return apiGet(`/api/settings/mcp${qsCwd(cwd)}`); }, @@ -152,35 +128,6 @@ export function createHttpSettingsClient( await apiPost(`/api/settings/skills${qsCwd(cwd)}`, { name, enabled }); }, - async listAgents({ cwd }) { - return apiGet(`/api/settings/agents${qsCwd(cwd)}`); - }, - - async createAgent({ cwd, profile }) { - await apiPost(`/api/settings/agents${qsCwd(cwd)}`, profile); - }, - - async updateAgent({ cwd, name, profile }) { - await apiPut(`/api/settings/agents/${encodeURIComponent(name)}${qsCwd(cwd)}`, profile); - }, - - async deleteAgent({ cwd, name }) { - await apiDelete(`/api/settings/agents/${encodeURIComponent(name)}${qsCwd(cwd)}`); - }, - - async setAgentDisabled({ name, disabled, cwd }) { - await apiPost(`/api/settings/agents/${encodeURIComponent(name)}/disabled${qsCwd(cwd)}`, { - disabled, - }); - }, - - async resetAgentDisabled({ name, cwd }) { - await apiPost( - `/api/settings/agents/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, - {} - ); - }, - async listHooks({ cwd }) { return apiGet(`/api/settings/hooks${qsCwd(cwd)}`); }, diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index ba0773a7..131333f0 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -1,6 +1,5 @@ import type { PermissionMode } from '../approval/types.js'; import type { McpServerConfig, McpStatus } from '../mcp/types.js'; -import type { AgentProfile } from '../subagent/types.js'; import type { UserHookConfig } from '../hooks/types.js'; import type { SessionEvent, SessionIndex } from '../session/types.js'; import type { SelectableModel } from '../llm/factory.js'; @@ -56,9 +55,7 @@ export interface AgentClient { }>; undoLastCodeRollback(force?: boolean, files?: string[]): Promise; getRollbackState(): Promise; - forkSession( - atTurnId?: number - ): Promise<{ + forkSession(atTurnId?: number): Promise<{ sessionId: string; turns: Array<{ id: string; items: object[]; status: string }>; }>; @@ -74,9 +71,6 @@ export interface AgentClient { addExtraType(type: { name: string; description: string }): Promise; updateExtraType(name: string, type: { name: string; description: string }): Promise; deleteExtraType(name: string): Promise; - getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; - setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; - resetSubagentEnabled(body: { cwd: string }): Promise; getMcpStatus(query: { cwd: string }): Promise; createMcpServer(server: McpServerConfig, query: { cwd: string }): Promise; updateMcpServer(name: string, server: McpServerConfig, query: { cwd: string }): Promise; @@ -85,23 +79,6 @@ export interface AgentClient { resetMcpDisabled(body: { name: string; cwd: string }): Promise; listSkills(): Promise>; toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; - listAgents(query: { cwd: string }): Promise< - Array<{ - name: string; - description: string; - tools?: string[]; - mcpServers?: string[]; - readonly?: boolean; - maxSteps?: number; - model?: string; - disabled?: boolean; - }> - >; - 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(query: { cwd: string }): Promise; setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetHookDisabled(body: { name: string; cwd: string }): Promise; diff --git a/packages/codingcode/src/direct/settings.ts b/packages/codingcode/src/direct/settings.ts index c256cb8c..50361fbb 100644 --- a/packages/codingcode/src/direct/settings.ts +++ b/packages/codingcode/src/direct/settings.ts @@ -3,7 +3,6 @@ import { McpService } from '../mcp/index.js'; import type { McpServerConfig, McpStatus } from '../mcp/types.js'; import { SkillService } from '../skills/service.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 { @@ -11,44 +10,17 @@ import { writeMcpConfig, loadGlobalMcpConfig, writeGlobalMcpConfig, - resolveMcpDisabled, getGlobalMcpDisabledState, setGlobalMcpDisabledState, setProjectMcpDisabledState, resetProjectMcpDisabledState, } from '../mcp/config.js'; -import { - loadAgentProfiles, - writeAgentProfile, - updateAgentProfile, - deleteAgentProfile, - loadGlobalAgentProfiles, - writeGlobalAgentProfile, - updateGlobalAgentProfile, - deleteGlobalAgentProfile, -} from '../subagent/loader.js'; -import { - EXPLORE_PROFILE, - PLAN_PROFILE, - setSubagentEnabledState, - resolveSubagentEnabled, - getProjectSubagentEnabledState, - setProjectSubagentEnabledState, - resetProjectSubagentEnabledState, - getGlobalAgentDisabledState, - setGlobalAgentDisabledState, - setProjectAgentDisabledState, - resetProjectAgentDisabledState, - resolveAgentDisabled, - getProjectAgentDisabledState, -} from '../subagent/registry.js'; import { loadHookConfigs, writeHookConfigs, loadGlobalHookConfigs, writeGlobalHookConfigs, resolveHookConfigs, - resolveHookDisabled, setGlobalHookDisabledState, setProjectHookDisabledState, resetProjectHookDisabledState, @@ -87,9 +59,6 @@ export interface SettingsClient { setMemoryModel(model: string): Promise<{ model: string }>; getAgentConfig(): Promise<{ maxSteps: number; maxStopContinuations: number }>; setCompactionModel(compactionModel: string): Promise<{ compactionModel: string }>; - getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; - setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; - resetSubagentEnabled(body: { cwd: string }): Promise; getMcpStatus(input: { cwd: string }): Promise; setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; resetMcpDisabled(body: { name: string; cwd: string }): Promise; @@ -98,12 +67,6 @@ export interface SettingsClient { deleteMcpServer(input: { cwd: string; name: string }): Promise; listSkills(): Promise>; toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; - listAgents(input: { cwd: string }): Promise; - createAgent(input: { cwd: string; profile: AgentProfile }): Promise; - updateAgent(input: { cwd: string; name: string; profile: AgentProfile }): Promise; - deleteAgent(input: { cwd: string; name: string }): Promise; - setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; - resetAgentDisabled(body: { name: string; cwd: string }): Promise; listHooks(input: { cwd: string }): Promise; createHook(input: { cwd: string; hook: UserHookConfig }): Promise; updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise; @@ -120,157 +83,6 @@ export interface SettingsClient { // ---- Helpers with validation ---- -function agentsList(cwd: string): 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; -}> { - 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); - 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: '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`); - } - writeAgentProfile(cwd, profile); -} - -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)) { - throw new AlreadyExistsError(`Agent '${profile.name}' already exists`); - } - 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(); @@ -474,26 +286,6 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { _deleteMemoryExtraType(name); }, - async getSubagentEnabled({ cwd }) { - const projectVal = getProjectSubagentEnabledState(cwd); - return { - enabled: resolveSubagentEnabled(cwd), - source: projectVal !== undefined ? 'project' : 'global', - }; - }, - - async setSubagentEnabled({ enabled, cwd }) { - if (isGlobalCwd(cwd)) { - setSubagentEnabledState(enabled); - } else { - setProjectSubagentEnabledState(cwd, enabled); - } - }, - - async resetSubagentEnabled({ cwd }) { - resetProjectSubagentEnabledState(cwd); - }, - async getMcpStatus({ cwd }) { const projectCwd = isGlobalCwd(cwd) ? process.cwd() : cwd; const runtime = await rt.runPromise( @@ -612,34 +404,6 @@ export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { ); }, - async listAgents({ cwd }) { - return agentsList(cwd); - }, - - async createAgent({ cwd, profile }) { - agentsCreate(cwd, profile); - }, - - async updateAgent({ cwd, name, profile }) { - agentsUpdate(cwd, name, profile); - }, - - async deleteAgent({ cwd, name }) { - agentsDelete(cwd, name); - }, - - async setAgentDisabled({ name, disabled, cwd }) { - if (isGlobalCwd(cwd)) { - setGlobalAgentDisabledState(name, disabled); - } else { - setProjectAgentDisabledState(cwd, name, disabled); - } - }, - - async resetAgentDisabled({ name, cwd }) { - resetProjectAgentDisabledState(cwd, name); - }, - async listHooks({ cwd }) { return hooksList(cwd) as unknown as UserHookConfig[]; }, diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts index ecf012be..c60905ed 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -12,17 +12,15 @@ import { ProjectRuntimeService } from './runtime/project-runtime.js'; import { LLMFactoryService } from './llm/factory.js'; import { WorkspaceService } from './core/workspace.js'; import { TodoService } from './agent/todo.js'; -import { SubagentService } from './subagent/registry.js'; import { SubagentRunnerService } from './subagent/runner-service.js'; import { RulesService } from './rules/index.js'; import { MemoryService } from './memory/index.js'; import { ContextService } from './context/service.js'; import { SchedulerService } from './scheduler/service.js'; -import { planModeGateHook } from './plan/index.js'; +import { planModeGateHook } from './agent/mode.js'; export const WorkspaceLayer = WorkspaceService.Default; export const TodoLayer = TodoService.Default; -export const SubagentLayer = SubagentService.Default; export const RulesLayer = RulesService.Default; export const SessionLayer = SessionService.Default; export const LLMFactoryLayer = LLMFactoryService.Default.pipe(Layer.provide(WorkspaceLayer)); @@ -37,7 +35,7 @@ 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, SessionLayer)) + Layer.provide(Layer.mergeAll(HookLayer, McpLayer, RulesLayer, SessionLayer)) ); export const ApprovalLayer = ApprovalService.Default.pipe( Layer.provide(Layer.mergeAll(HookLayer, ApprovalWaitLayer)) @@ -103,7 +101,6 @@ export const AppLayer = Layer.mergeAll( LLMFactoryLayer, WorkspaceLayer, TodoLayer, - SubagentLayer, RulesLayer, MemoryLayer, ContextLayer, diff --git a/packages/codingcode/src/runtime/project-runtime.ts b/packages/codingcode/src/runtime/project-runtime.ts index de674f2c..01c56dab 100644 --- a/packages/codingcode/src/runtime/project-runtime.ts +++ b/packages/codingcode/src/runtime/project-runtime.ts @@ -1,12 +1,5 @@ import { Effect } from 'effect'; import type { AgentProfile } from '../subagent/types.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'; @@ -17,38 +10,33 @@ import type { PermissionMode } from '../approval/types.js'; import type { SessionMode } from '../session/types.js'; import { readCurrentIndex } from '../session/file-ops.js'; import { computePaths } from '../core/path.js'; - -function buildGlobalProfiles(): AgentProfile[] { - 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); - } - } - return profiles; -} - -function buildProjectProfiles(projectPath: string): AgentProfile[] { - return agentLoader.loadAgentProfiles(projectPath); -} +import { + BUILD_PROFILE, + PLAN_PROFILE, + isPlanProfile, + PLAN_MODE_ALLOWED_TOOLS, +} from '../agent/mode.js'; export function modeToProfile(mode: SessionMode): AgentProfile { return mode === 'plan' ? PLAN_PROFILE : BUILD_PROFILE; } +function profileByName(name: string | undefined): AgentProfile | undefined { + if (name === PLAN_PROFILE.name) return PLAN_PROFILE; + if (name === BUILD_PROFILE.name) return BUILD_PROFILE; + return undefined; +} + export class ProjectRuntimeService extends Effect.Service()( 'ProjectRuntime', { effect: Effect.gen(function* () { const hooks = yield* HookService; const mcp = yield* McpService; - const subagent = yield* SubagentService; const rules = yield* RulesService; const session = yield* SessionService; const prepared = new Set(); - subagent.registerGlobal(buildGlobalProfiles()); - return { prepareProject: (projectPath: string): Effect.Effect => Effect.gen(function* () { @@ -58,7 +46,6 @@ export class ProjectRuntimeService extends Effect.Service rules.evictProjectRules(norm); yield* hooks.reloadUserHooks(norm).pipe(Effect.catchAll(() => Effect.void)); yield* mcp.syncConnections(norm).pipe(Effect.catchAll(() => Effect.void)); - subagent.registerProject(norm, buildProjectProfiles(norm)); }), resolveMainAgentProfile: ( @@ -67,31 +54,15 @@ export class ProjectRuntimeService extends Effect.Service ): AgentProfile | undefined => { const idx = readCurrentIndex(computePaths(projectPath, sessionId).indexPath); const name = idx?.activeProfile; - if (!name) return agentLoader.loadMainAgentProfile(projectPath); - return subagent.get(projectPath, name) ?? agentLoader.loadMainAgentProfile(projectPath); + return profileByName(name); }, - resolveSubagentProfile: (projectPath: string, name: string): AgentProfile | undefined => { - const norm = normalizePath(projectPath); - if (!prepared.has(norm)) { - subagent.registerProject(norm, buildProjectProfiles(norm)); - prepared.add(norm); - } - return subagent.get(norm, name); - }, - - listAgentProfiles: (projectPath: string): AgentProfile[] => { - const normalized = normalizePath(projectPath); - if (!prepared.has(normalized)) { - subagent.registerProject(normalized, buildProjectProfiles(normalized)); - prepared.add(normalized); - } - return subagent.list(normalized); - }, + resolveSubagentProfile: (_projectPath: string, name: string): AgentProfile | undefined => + profileByName(name), getToolPolicy: (profile: AgentProfile | undefined): ToolVisibilityPolicy => ({ - allowedTools: profile?.tools ? new Set(profile.tools) : undefined, - allowedMcpServers: profile?.mcpServers ? new Set(profile.mcpServers) : undefined, + allowedTools: isPlanProfile(profile) ? new Set(PLAN_MODE_ALLOWED_TOOLS) : undefined, + allowedMcpServers: undefined, }), setSessionProfile: ( @@ -102,8 +73,7 @@ export class ProjectRuntimeService extends Effect.Service ): Effect.Effect => Effect.gen(function* () { const mode: SessionMode = profile.name === 'plan' ? 'plan' : 'build'; - const effectivePerm: PermissionMode = - permissionModeOverride ?? profile.permissionMode ?? 'default'; + const effectivePerm: PermissionMode = permissionModeOverride ?? 'default'; yield* session.setModeOnDisk(projectPath, sessionId, mode); yield* session.setPermissionModeOnDisk(projectPath, sessionId, effectivePerm); yield* session.setActiveProfile(projectPath, sessionId, profile.name); @@ -115,8 +85,7 @@ export class ProjectRuntimeService extends Effect.Service ): Effect.Effect => Effect.gen(function* () { const name = yield* session.getActiveProfile(projectPath, sessionId); - if (!name) return undefined; - return subagent.get(projectPath, name); + return profileByName(name); }), getSessionPermissionMode: ( @@ -133,11 +102,10 @@ export class ProjectRuntimeService extends Effect.Service ): Effect.Effect => Effect.gen(function* () { if (!profileName) return; - const profile = subagent.get(projectPath, profileName); + const profile = profileByName(profileName); if (!profile) return; const mode: SessionMode = profile.name === 'plan' ? 'plan' : 'build'; - const effectivePerm: PermissionMode = - permissionModeOverride ?? profile.permissionMode ?? 'default'; + const effectivePerm: PermissionMode = permissionModeOverride ?? 'default'; yield* session.setModeOnDisk(projectPath, sessionId, mode); yield* session.setPermissionModeOnDisk(projectPath, sessionId, effectivePerm); yield* session.setActiveProfile(projectPath, sessionId, profile.name); @@ -149,7 +117,6 @@ export class ProjectRuntimeService extends Effect.Service Effect.sync(() => { const norm = normalizePath(projectPath); prepared.delete(norm); - subagent.resetProject(norm); rules.evictProjectRules(norm); }), }; diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index a81ffc38..67d14910 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -4,10 +4,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; import type { SessionStoreState, SessionMode } from '../../session/types.js'; import { SessionService } from '../../session/store.js'; -import { - getPermissionMode, - deleteSession, -} from '../../session/file-ops.js'; +import { getPermissionMode, deleteSession } from '../../session/file-ops.js'; import { computePaths } from '../../core/path.js'; import { readUIHistory, findUserMessageForTurn } from '../../session/ui-history.js'; import { ContextService, estimatePromptTokens } from '../../context/service.js'; @@ -18,7 +15,7 @@ import type { LLMClient } from '../../llm/client.js'; import { errorResponse } from '../util.js'; import { encodeProjectPath, getProjectBaseDir } from '../../core/path.js'; import { modeToProfile } from '../../runtime/project-runtime.js'; -import { BUILD_PROFILE, PLAN_PROFILE } from '../../subagent/registry.js'; +import { BUILD_PROFILE, PLAN_PROFILE } from '../../agent/mode.js'; import { isPermissionMode, type PermissionMode } from '../../approval/types.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -251,10 +248,7 @@ export function createSessionsRouter(rt: ManagedRt): Hono { return c.json({ ...result.value, cwd, - available: [ - { name: PLAN_PROFILE.name, description: PLAN_PROFILE.description }, - { name: BUILD_PROFILE.name, description: BUILD_PROFILE.description }, - ], + available: [{ name: PLAN_PROFILE.name }, { name: BUILD_PROFILE.name }], }); }); diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index 36da8c98..4f5125ab 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -4,7 +4,6 @@ import { SkillService } from '../../skills/service.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'; import type { UserHookConfig } from '../../hooks/types.js'; import { loadMcpConfig, @@ -18,32 +17,6 @@ import { setProjectMcpDisabledState, resetProjectMcpDisabledState, } from '../../mcp/config.js'; -import { - loadAgentProfiles, - writeAgentProfile, - updateAgentProfile, - deleteAgentProfile, - loadGlobalAgentProfiles, - writeGlobalAgentProfile, - updateGlobalAgentProfile, - deleteGlobalAgentProfile, -} from '../../subagent/loader.js'; -import { - EXPLORE_PROFILE, - PLAN_PROFILE, - resolveSubagentEnabled, - getProjectSubagentEnabledState, - setProjectSubagentEnabledState, - resetProjectSubagentEnabledState, - getGlobalAgentDisabledState, - setGlobalAgentDisabledState, - getProjectAgentDisabledState, - setProjectAgentDisabledState, - resetProjectAgentDisabledState, - resolveAgentDisabled, - getSubagentEnabledState, - setSubagentEnabledState, -} from '../../subagent/registry.js'; import { loadHookConfigs, writeHookConfigs, @@ -78,7 +51,7 @@ import { updateMemoryModel, } from '@codingcode/infra/config'; import { MemoryService } from '../../memory/index.js'; -import { createRunWithLayer, errorResponse } from '../util.js'; +import { createRunWithLayer } from '../util.js'; type ManagedRt = ManagedRuntime.ManagedRuntime; @@ -125,128 +98,6 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { ); } - function agentsList(cwd: string): 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; - }> { - 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; - }> = []; - - // builtin: EXPLORE_PROFILE - const exploreProjectVal = getProjectAgentDisabledState(cwd, EXPLORE_PROFILE.name); - result.push({ - name: EXPLORE_PROFILE.name, - description: EXPLORE_PROFILE.description, - tools: EXPLORE_PROFILE.tools, - mcpServers: EXPLORE_PROFILE.mcpServers, - readonly: EXPLORE_PROFILE.readonly, - maxSteps: EXPLORE_PROFILE.maxSteps, - model: EXPLORE_PROFILE.model, - disabled: resolveAgentDisabled(cwd, EXPLORE_PROFILE.name), - source: 'builtin', - hasProjectOverride: exploreProjectVal !== undefined, - projectDisabled: exploreProjectVal, - }); - - // builtin: PLAN_PROFILE - const planProjectVal = getProjectAgentDisabledState(cwd, PLAN_PROFILE.name); - result.push({ - name: PLAN_PROFILE.name, - description: PLAN_PROFILE.description, - tools: PLAN_PROFILE.tools, - mcpServers: PLAN_PROFILE.mcpServers, - readonly: PLAN_PROFILE.readonly, - maxSteps: PLAN_PROFILE.maxSteps, - model: PLAN_PROFILE.model, - disabled: resolveAgentDisabled(cwd, PLAN_PROFILE.name), - source: 'builtin', - hasProjectOverride: planProjectVal !== undefined, - projectDisabled: planProjectVal, - }); - - // global agents (not overridden by project) - 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, - }); - } - - // project agents - 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: globalNames.has(a.name) ? 'global' : 'project', - hasProjectOverride: projectVal !== undefined, - projectDisabled: projectVal, - }); - } - - return result; - } - - function agentsCreate(cwd: string, profile: AgentProfile): void { - const existing = loadAgentProfiles(cwd); - if (existing.some((a) => a.name === profile.name)) { - throw new AlreadyExistsError(`Agent '${profile.name}' already exists`); - } - writeAgentProfile(cwd, profile); - } - - function agentsUpdate(cwd: string, name: string, profile: AgentProfile): void { - 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)) { - throw new AlreadyExistsError(`Agent '${profile.name}' already exists`); - } - updateAgentProfile(cwd, name, profile); - } - function hooksCreate(cwd: string, hook: UserHookConfig): void { const hooks = loadHookConfigs(cwd); if (hooks.some((h) => h.name === hook.name)) { @@ -378,98 +229,6 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { return c.json({ compactionModel: body.compactionModel }); }); - // ---- Agents ---- - settingsRouter.get('/agents', (c) => { - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - const custom = loadGlobalAgentProfiles(); - return c.json( - [EXPLORE_PROFILE, PLAN_PROFILE, ...custom].map((a) => ({ - name: a.name, - description: a.description, - tools: a.tools, - mcpServers: a.mcpServers, - readonly: a.readonly, - maxSteps: a.maxSteps, - model: a.model, - disabled: getGlobalAgentDisabledState(a.name), - source: - a.name === EXPLORE_PROFILE.name || a.name === PLAN_PROFILE.name ? 'builtin' : 'global', - })) - ); - } - const cwd = resolveWorkspaceCwd(rawCwd); - return c.json(agentsList(cwd)); - }); - - settingsRouter.post('/agents', async (c) => { - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as AgentProfile; - try { - if (isGlobalCwd(rawCwd)) { - const existing = loadGlobalAgentProfiles(); - if (existing.some((a) => a.name === body.name)) { - throw new AlreadyExistsError(`Agent '${body.name}' already exists`); - } - writeGlobalAgentProfile(body); - } else { - agentsCreate(resolveWorkspaceCwd(rawCwd), body); - } - return c.json({ ok: true }); - } catch (e) { - if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); - throw e; - } - }); - - settingsRouter.put('/agents/:name', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as AgentProfile; - try { - if (isGlobalCwd(rawCwd)) { - updateGlobalAgentProfile(name, body); - } else { - agentsUpdate(resolveWorkspaceCwd(rawCwd), name, body); - } - return c.json({ ok: true }); - } catch (e) { - if (e instanceof NotFoundError) return c.json({ error: e.message }, 404); - if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); - throw e; - } - }); - - settingsRouter.delete('/agents/:name', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - deleteGlobalAgentProfile(name); - } else { - deleteAgentProfile(resolveWorkspaceCwd(rawCwd), name); - } - return c.json({ ok: true }); - }); - - settingsRouter.post('/agents/:name/disabled', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - const body = (await c.req.json()) as { disabled: boolean }; - if (isGlobalCwd(rawCwd)) { - setGlobalAgentDisabledState(name, body.disabled); - } else { - setProjectAgentDisabledState(resolveWorkspaceCwd(rawCwd), name, body.disabled); - } - return c.json({ ok: true }); - }); - - settingsRouter.post('/agents/:name/disabled/reset', async (c) => { - const name = c.req.param('name'); - const rawCwd = c.req.query('cwd'); - resetProjectAgentDisabledState(resolveWorkspaceCwd(rawCwd), name); - return c.json({ ok: true }); - }); - // ---- Hooks ---- settingsRouter.get('/hooks', (c) => { const rawCwd = c.req.query('cwd'); @@ -761,36 +520,5 @@ export async function createSettingsRouter(rt: ManagedRt): Promise { return c.json({ ok: true }); }); - // ---- Subagent enabled ---- - settingsRouter.get('/subagent/enabled', (c) => { - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - return c.json({ enabled: getSubagentEnabledState(), source: 'global' }); - } - const cwd = resolveWorkspaceCwd(rawCwd); - const projectVal = getProjectSubagentEnabledState(cwd); - return c.json({ - enabled: resolveSubagentEnabled(cwd), - source: projectVal !== undefined ? 'project' : 'global', - }); - }); - - settingsRouter.post('/subagent/enabled', async (c) => { - const body = (await c.req.json()) as { enabled: boolean }; - const rawCwd = c.req.query('cwd'); - if (isGlobalCwd(rawCwd)) { - setSubagentEnabledState(body.enabled); - } else { - setProjectSubagentEnabledState(resolveWorkspaceCwd(rawCwd), body.enabled); - } - return c.json({ ok: true }); - }); - - settingsRouter.post('/subagent/enabled/reset', async (c) => { - const rawCwd = c.req.query('cwd'); - resetProjectSubagentEnabledState(resolveWorkspaceCwd(rawCwd)); - return c.json({ ok: true }); - }); - return settingsRouter; } diff --git a/packages/codingcode/src/subagent/loader.ts b/packages/codingcode/src/subagent/loader.ts deleted file mode 100644 index 90d9231d..00000000 --- a/packages/codingcode/src/subagent/loader.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs'; -import { join, basename } from 'path'; -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(); - -function parseFrontmatter(content: string): { frontmatter: Record; body: string } { - const lines = content.split('\n'); - if (lines[0] !== '---') { - return { frontmatter: {}, body: content }; - } - let endIdx = -1; - for (let i = 1; i < lines.length; i++) { - if (lines[i] === '---') { - endIdx = i; - break; - } - } - if (endIdx === -1) { - return { frontmatter: {}, body: content }; - } - - const fmLines = lines.slice(1, endIdx); - const frontmatter: Record = {}; - for (const line of fmLines) { - const colonIdx = line.indexOf(':'); - if (colonIdx > 0) { - const key = line.slice(0, colonIdx).trim(); - let value: unknown = line.slice(colonIdx + 1).trim(); - // Parse YAML-like values - if (value === 'true') value = true; - else if (value === 'false') value = false; - else if (value === 'null' || value === '') value = null; - else if (typeof value === 'string' && !isNaN(Number(value))) value = Number(value); - // Handle arrays: JSON ["a","b"] or YAML-style [a, b, c] - else if (typeof value === 'string' && value.startsWith('[') && value.endsWith(']')) { - const strValue = value; - try { - value = JSON.parse(strValue); - } catch { - const inner = strValue.slice(1, -1).trim(); - if (inner) { - value = inner - .split(',') - .map((s: string) => s.trim()) - .filter((s: string) => s.length > 0); - } else { - value = []; - } - } - } - frontmatter[key] = value; - } - } - - const body = lines - .slice(endIdx + 1) - .join('\n') - .trim(); - return { frontmatter, body }; -} - -function buildProfileFromFrontmatter( - frontmatter: Record, - body: string, - file: string -): AgentProfile | null { - const name = frontmatter.name as string | undefined; - const description = frontmatter.description as string | undefined; - - if (!name || !description) { - const logger = createLogger(); - logger.warn(`Skipping agent file ${file}: missing required name or description`); - return null; - } - - return { - name, - description, - systemPrompt: body || 'You are a specialized agent.', - tools: Array.isArray(frontmatter.tools) ? frontmatter.tools.map(String) : undefined, - mcpServers: Array.isArray(frontmatter.mcpServers) - ? frontmatter.mcpServers.map(String) - : undefined, - readonly: Boolean(frontmatter.readonly), - maxSteps: typeof frontmatter.maxSteps === 'number' ? frontmatter.maxSteps : undefined, - model: typeof frontmatter.model === 'string' ? frontmatter.model : undefined, - hooks: Array.isArray(frontmatter.hooks) ? (frontmatter.hooks as any[]) : undefined, - disabled: Boolean(frontmatter.disabled) || false, - }; -} - -export function loadAgentProfiles(projectCwd: string): AgentProfile[] { - const agentsDir = join(projectCwd, '.codingcode', 'agents'); - if (!existsSync(agentsDir)) { - return []; - } - - const profiles: AgentProfile[] = []; - try { - const files = readdirSync(agentsDir).filter((f) => f.endsWith('.md')); - for (const file of files) { - try { - const filePath = join(agentsDir, file); - const content = readFileSync(filePath, 'utf-8'); - const { frontmatter, body } = parseFrontmatter(content); - - const profile = buildProfileFromFrontmatter(frontmatter, body, file); - if (profile) profiles.push(profile); - } catch (err) { - logger.warn(`Failed to parse agent profile ${file}:`, err); - } - } - } catch (err) { - logger.warn(`Failed to read agents directory:`, err); - } - - return profiles; -} - -function serializeAgentProfile(profile: AgentProfile): string { - const fm: string[] = ['---']; - fm.push(`name: ${profile.name}`); - fm.push(`description: ${profile.description}`); - if (profile.tools && profile.tools.length > 0) { - fm.push(`tools: ${JSON.stringify(profile.tools)}`); - } - if (profile.mcpServers && profile.mcpServers.length > 0) { - fm.push(`mcpServers: ${JSON.stringify(profile.mcpServers)}`); - } - if (profile.readonly) fm.push(`readonly: true`); - if (profile.maxSteps !== undefined) fm.push(`maxSteps: ${profile.maxSteps}`); - if (profile.model) fm.push(`model: ${profile.model}`); - if (profile.disabled) fm.push(`disabled: true`); - fm.push('---'); - fm.push(''); - fm.push(profile.systemPrompt || 'You are a specialized agent.'); - return fm.join('\n'); -} - -function agentNameToFilename(name: string): string { - return ( - name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') + '.md' - ); -} - -function findAgentFile(projectCwd: string, name: string): string | null { - const agentsDir = join(projectCwd, '.codingcode', 'agents'); - if (!existsSync(agentsDir)) return null; - const files = readdirSync(agentsDir).filter((f) => f.endsWith('.md')); - for (const file of files) { - const filePath = join(agentsDir, file); - const content = readFileSync(filePath, 'utf-8'); - const { frontmatter } = parseFrontmatter(content); - if (frontmatter.name === name) return filePath; - } - return null; -} - -export function writeAgentProfile(projectCwd: string, profile: AgentProfile): void { - const agentsDir = join(projectCwd, '.codingcode', 'agents'); - if (!existsSync(agentsDir)) mkdirSync(agentsDir, { recursive: true }); - const existing = findAgentFile(projectCwd, profile.name); - const filePath = existing ?? join(agentsDir, agentNameToFilename(profile.name)); - writeFileSync(filePath, serializeAgentProfile(profile), 'utf-8'); -} - -export function updateAgentProfile( - projectCwd: string, - oldName: string, - profile: AgentProfile -): void { - if (oldName !== profile.name) { - const oldFile = findAgentFile(projectCwd, oldName); - if (oldFile) unlinkSync(oldFile); - } - writeAgentProfile(projectCwd, profile); -} - -export function deleteAgentProfile(projectCwd: string, name: string): void { - const filePath = findAgentFile(projectCwd, name); - if (!filePath) { - throw new NotFoundError(`Agent '${name}' not found in project config`); - } - unlinkSync(filePath); -} - -function loadAgentProfilesFromDir(dirPath: string): AgentProfile[] { - if (!existsSync(dirPath)) return []; - const profiles: AgentProfile[] = []; - try { - const files = readdirSync(dirPath).filter((f) => f.endsWith('.md')); - for (const file of files) { - try { - const filePath = join(dirPath, file); - const content = readFileSync(filePath, 'utf-8'); - const { frontmatter, body } = parseFrontmatter(content); - const profile = buildProfileFromFrontmatter(frontmatter, body, file); - if (profile) profiles.push(profile); - } catch (err) { - const logger = createLogger(); - logger.warn(`Failed to parse agent profile ${file}:`, err); - } - } - } catch (err) { - const logger = createLogger(); - logger.warn(`Failed to read agents directory ${dirPath}:`, err); - } - return profiles; -} - -/** Load subagent profiles from the global agents directory (~/.codingcode/agents/). */ -export function loadGlobalAgentProfiles(): AgentProfile[] { - return loadAgentProfilesFromDir(join(homedir(), '.codingcode', 'agents')); -} - -export function loadMainAgentProfile(projectCwd: string): AgentProfile | undefined { - const paths = [ - { dir: projectCwd, scope: 'project' as const }, - { dir: join(homedir(), '.codingcode', 'agents'), scope: 'global' as const }, - ]; - for (const { dir } of paths) { - const mainYaml = join(dir, '.codingcode', 'agents', 'main.yaml'); - if (existsSync(mainYaml)) { - try { - const raw = readFileSync(mainYaml, 'utf-8'); - const data = parseYaml(raw) as Record; - return { - name: (data.name as string) ?? 'default-main', - description: (data.description as string) ?? 'Default project assistant', - tools: Array.isArray(data.tools) ? data.tools.map(String) : undefined, - mcpServers: Array.isArray(data.mcpServers) ? data.mcpServers.map(String) : undefined, - readonly: Boolean(data.readonly), - maxSteps: typeof data.maxSteps === 'number' ? data.maxSteps : undefined, - model: typeof data.model === 'string' ? data.model : undefined, - }; - } catch (err) { - const logger = createLogger(); - logger.warn(`Failed to parse main.yaml at ${mainYaml}:`, err); - } - } - } - return undefined; -} - -export function resolveAgentProfile( - projectCwd: string, - name: string, - sessionOverride?: AgentProfile -): AgentProfile | undefined { - if (sessionOverride && sessionOverride.name === name) return sessionOverride; - // Check project profiles - const projectProfiles = loadAgentProfiles(projectCwd); - const fromProject = projectProfiles.find((p) => p.name === name); - if (fromProject) return fromProject; - // Check global profiles - const globalProfiles = loadAgentProfilesFromDir(join(homedir(), '.codingcode', 'agents')); - const fromGlobal = globalProfiles.find((p) => p.name === name); - if (fromGlobal) return fromGlobal; - // Check built-in - return undefined; -} - -function getGlobalAgentsDir(): string { - return join(homedir(), '.codingcode', 'agents'); -} - -function findAgentFileInDir(dirPath: string, name: string): string | null { - if (!existsSync(dirPath)) return null; - const files = readdirSync(dirPath).filter((f) => f.endsWith('.md')); - for (const file of files) { - const filePath = join(dirPath, file); - const content = readFileSync(filePath, 'utf-8'); - const { frontmatter } = parseFrontmatter(content); - if (frontmatter.name === name) return filePath; - } - return null; -} - -export function writeGlobalAgentProfile(profile: AgentProfile): void { - const agentsDir = getGlobalAgentsDir(); - if (!existsSync(agentsDir)) mkdirSync(agentsDir, { recursive: true }); - const existing = findAgentFileInDir(agentsDir, profile.name); - const filePath = existing ?? join(agentsDir, agentNameToFilename(profile.name)); - writeFileSync(filePath, serializeAgentProfile(profile), 'utf-8'); -} - -export function updateGlobalAgentProfile(oldName: string, profile: AgentProfile): void { - if (oldName !== profile.name) { - const oldFile = findAgentFileInDir(getGlobalAgentsDir(), oldName); - if (oldFile) unlinkSync(oldFile); - } - writeGlobalAgentProfile(profile); -} - -export function deleteGlobalAgentProfile(name: string): void { - const filePath = findAgentFileInDir(getGlobalAgentsDir(), name); - if (filePath) unlinkSync(filePath); -} diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts deleted file mode 100644 index ef83253b..00000000 --- a/packages/codingcode/src/subagent/registry.ts +++ /dev/null @@ -1,235 +0,0 @@ -import type { AgentProfile } from './types.js'; -import { loadConfig, getUserConfigPath } from '@codingcode/infra/config'; -import { createDisabledStore } from '@codingcode/infra/disabled-store'; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; -import { dirname, join } from 'path'; -import { Effect } from 'effect'; - -// ---- 全局级子智能体开关 ---- - -export function getSubagentEnabledState(): boolean { - try { - const config = loadConfig() as any; - return config.subagent?.enabled ?? true; - } catch { - return true; - } -} - -export function setSubagentEnabledState(v: boolean): void { - const p = getUserConfigPath(); - const dir = dirname(p); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; - const subagent = (existing.subagent as Record) ?? {}; - existing.subagent = { ...subagent, enabled: v }; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -// ---- 项目级子智能体开关:持久化到 .codingcode/config.yaml ---- - -export function getProjectSubagentEnabledState(projectCwd: string): boolean | undefined { - const p = join(projectCwd, '.codingcode', 'config.yaml'); - if (!existsSync(p)) return undefined; - try { - const raw = readFileSync(p, 'utf8'); - const config = parseYaml(raw) as any; - return config.subagent?.enabled; - } catch { - return undefined; - } -} - -export function setProjectSubagentEnabledState(projectCwd: string, v: boolean): void { - const dir = join(projectCwd, '.codingcode'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const p = join(dir, 'config.yaml'); - const existing: Record = existsSync(p) - ? (parseYaml(readFileSync(p, 'utf8')) as Record) - : {}; - const subagent = (existing.subagent as Record) ?? {}; - existing.subagent = { ...subagent, enabled: v }; - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -export function resetProjectSubagentEnabledState(projectCwd: string): void { - const p = join(projectCwd, '.codingcode', 'config.yaml'); - if (!existsSync(p)) return; - const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record< - string, - unknown - >; - const subagent = (existing.subagent as Record) ?? {}; - delete subagent.enabled; - if (Object.keys(subagent).length === 0) { - delete existing.subagent; - } else { - existing.subagent = subagent; - } - writeFileSync(p, stringifyYaml(existing), 'utf8'); -} - -// 解析最终生效的子智能体开关:项目级 > 全局级 -export function resolveSubagentEnabled(projectCwd: string): boolean { - const projectVal = getProjectSubagentEnabledState(projectCwd); - if (projectVal !== undefined) return projectVal; - return getSubagentEnabledState(); -} - -// ---- Agent disabled 状态:复用 createDisabledStore ---- - -const agentDisabledStore = createDisabledStore({ - globalKeyPath: ['subagent', 'disabledAgents'], -}); - -export const getGlobalAgentDisabledState = agentDisabledStore.getGlobal; -export const setGlobalAgentDisabledState = agentDisabledStore.setGlobal; -export const getProjectAgentDisabledState = agentDisabledStore.getProject; -export const setProjectAgentDisabledState = agentDisabledStore.setProject; -export const resetProjectAgentDisabledState = agentDisabledStore.resetProject; -export const resolveAgentDisabled = agentDisabledStore.resolve; - -// ---- SubagentService: Effect.Service with global + project-level registries ---- - -export class SubagentService extends Effect.Service()('Subagent', { - sync: () => { - // 全局层:内置 profile + 全局 ~/.codingcode/agents/ profile - const globalRegistry = new Map(); - // 项目层:按 projectPath 隔离,项目 profile 覆盖同名全局 profile - const projectRegistries = new Map>(); - - return { - /** 注册全局 profile(内置 + ~/.codingcode/agents/),只在启动时调用一次 */ - registerGlobal(profiles: AgentProfile[]): void { - for (const p of profiles) globalRegistry.set(p.name, p); - }, - - /** 注册项目级 profile,覆盖同名全局 profile */ - registerProject(projectPath: string, profiles: AgentProfile[]): void { - let projectMap = projectRegistries.get(projectPath); - if (!projectMap) { - projectMap = new Map(); - projectRegistries.set(projectPath, projectMap); - } - for (const p of profiles) projectMap.set(p.name, p); - }, - - /** 查找 profile:项目级优先,回退到全局级 */ - get(projectPath: string, name: string): AgentProfile | undefined { - const projectMap = projectRegistries.get(projectPath); - if (projectMap) { - const fromProject = projectMap.get(name); - if (fromProject) return fromProject; - } - return globalRegistry.get(name); - }, - - /** 列出某项目的全部 profile:项目级覆盖同名全局级 */ - list(projectPath: string): AgentProfile[] { - const result = new Map(globalRegistry); - const projectMap = projectRegistries.get(projectPath); - if (projectMap) { - for (const [name, profile] of projectMap) { - result.set(name, profile); - } - } - return Array.from(result.values()); - }, - - /** 清除某项目的注册,不影响其他项目 */ - resetProject(projectPath: string): void { - projectRegistries.delete(projectPath); - }, - }; - }, -}) {} - -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 -- 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 — for example, searching with different patterns at once, or reading several files simultaneously. -- When referencing code, use the format \`file_path:line_number\`. -- Be thorough but concise in your findings. Focus on what the user asked for — structure your answer around the question, not around the files you read. -- If you cannot find the answer, say so clearly rather than guessing.`, - tools: ['read_file', 'search_files', 'search_code', 'fetch_url'], - readonly: true, - maxSteps: 180, -}; - -export const PLAN_PROFILE: AgentProfile = { - name: 'plan', - description: - 'Planning agent: analyzes the codebase, produces an implementation plan, and submits it via submit_plan for user approval. No business code modifications.', - systemPrompt: `You are a planning agent. Your role is to analyze the codebase and produce an implementation plan that the user reviews and approves before any code is written. - -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 -2. Identify relevant files and existing patterns -3. Analyze dependencies and potential impacts -4. Assess complexity and risks -5. Check for existing implementations or similar patterns - -## Output format -When ready, call \`submit_plan({ title, plan_content: "..." })\` with a Markdown plan: -- **Current state**: What exists today -- **Key files**: Files that need modification or creation, with line references -- **Dependencies and risks**: Breaking changes, third-party concerns -- **Recommended approach**: Step-by-step implementation strategy -- **Phases**: If complex, break into ordered phases - -## After submit_plan -submit_plan returns synchronously after writing the plan file. Once you have called it, stop and wait for the user's decision — do not call submit_plan again until the user responds, and do not attempt to use any other write tool. - -The user's decision arrives as the next user message. The system has already handled the agent-profile switch (plan → build on approval, plan → plan on revise, no change on cancel); the message body itself is your signal: - -- "Implement"/"proceed"/"go ahead" (or any explicit approval) — the plan is approved. Acknowledge briefly and stop. The build agent will pick up the plan from the persisted file. -- The body contains a revised plan (a Markdown document, often with explicit section headers, or with a "Revise the plan with these changes:" wrapper) — treat the body as the new plan_content, call \`submit_plan\` again with the same title and the revised content, then stop. -- "Cancel"/"do not implement" — the plan is rejected. Acknowledge briefly and stop. - -Never re-call submit_plan on your own initiative. Never treat an implement message as a request for further exploration.`, - tools: [ - 'read_file', - 'search_files', - 'search_code', - 'fetch_url', - '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', - 'dispatch_agent', - ], - maxSteps: 180, -}; diff --git a/packages/codingcode/src/subagent/types.ts b/packages/codingcode/src/subagent/types.ts index f21036ac..87af141f 100644 --- a/packages/codingcode/src/subagent/types.ts +++ b/packages/codingcode/src/subagent/types.ts @@ -1,18 +1,5 @@ -import type { UserHookConfig } from '../hooks/types.js'; -import type { PermissionMode } from '../approval/types.js'; - -export type ProfilePermissionMode = Exclude; - export interface AgentProfile { name: string; - description: string; systemPrompt?: string; - tools?: string[]; - mcpServers?: string[]; - readonly?: boolean; - permissionMode?: ProfilePermissionMode; maxSteps?: number; - model?: string; - hooks?: UserHookConfig[]; - disabled?: boolean; } diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index de6d1e9b..c2961a05 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -7,17 +7,10 @@ import { ApprovalService } from '../../../approval/index.js'; import { HookService } from '../../../hooks/registry.js'; import { McpService } from '../../../mcp/index.js'; import { LLMFactoryService } from '../../../llm/factory.js'; -import { - resolveSubagentEnabled, - resolveAgentDisabled, - BUILD_PROFILE, -} from '../../../subagent/registry.js'; +import { BUILD_PROFILE } from '../../../agent/mode.js'; import { RulesService } from '../../../rules/index.js'; import { ProjectRuntimeService } from '../../../runtime/project-runtime.js'; import { SubagentRunnerService } from '../../../subagent/runner-service.js'; -import { checkSubagentAllowedInPlanMode } from '../../../plan/index.js'; -import { readCurrentIndex } from '../../../session/file-ops.js'; -import { computePaths } from '../../../core/path.js'; import type { SessionMode } from '../../../session/types.js'; import type { PermissionMode } from '../../../approval/types.js'; @@ -57,16 +50,6 @@ export function createDispatchAgentTool(): Effect.Effect< const projectPath = ctx?.projectPath || process.cwd(); - // Check global subagent switch - if (!resolveSubagentEnabled(projectPath)) { - return yield* Effect.fail( - new AgentError( - 'TOOL_EXECUTION_FAILED', - 'Subagent dispatch is disabled in global settings' - ) - ); - } - // Get profile const profile = runtime.resolveSubagentProfile(projectPath, agentName); if (!profile) { @@ -75,44 +58,10 @@ export function createDispatchAgentTool(): Effect.Effect< ); } - // Check individual agent disabled state - if (resolveAgentDisabled(projectPath, agentName)) { - return yield* Effect.fail( - new AgentError('TOOL_EXECUTION_FAILED', `Subagent '${agentName}' is disabled`) - ); - } - let llm = yield* factory.getLLMClient(); - if (profile.model) { - const entry = yield* factory.findModel(profile.model); - if (!entry) { - return yield* Effect.fail( - new AgentError( - 'TOOL_EXECUTION_FAILED', - `Subagent profile "${agentName}" specifies unknown model: ${profile.model}` - ) - ); - } - llm = yield* factory.createClient(entry); - } // Emit spawn.before hook (decision hook, can deny) const parentSessionId = ctx?.sessionId; - const parentMainProfile = - parentSessionId && projectPath - ? readCurrentIndex(computePaths(projectPath, parentSessionId).indexPath) - ?.activeProfile - : 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, @@ -138,11 +87,8 @@ export function createDispatchAgentTool(): Effect.Effect< const parentState = yield* loaded; parentPermissionMode = parentState.permissionMode; } - const childPermissionMode: PermissionMode = - (subagentProfile?.permissionMode as PermissionMode | undefined) ?? - parentPermissionMode ?? - 'default'; - const childModel: string = subagentProfile?.model ?? llm.modelInfo.model; + const childPermissionMode: PermissionMode = parentPermissionMode ?? 'default'; + const childModel: string = llm.modelInfo.model; const childState = yield* session.createSessionWithProfile( projectPath, @@ -163,22 +109,10 @@ export function createDispatchAgentTool(): Effect.Effect< // Approval: always fork with permissionMode closure (no longer omitted for readonly) const childApproval = yield* approval.fork({ - readonly: profile.readonly ?? false, permissionMode: childPermissionMode, }); - // Attach subagent hooks - if (profile.hooks && profile.hooks.length > 0) { - yield* hooks.attachSessionHooks(childUuid, profile.hooks); - } - - // Connect MCP servers (session lease) - const mcpServers = profile.mcpServers; - if (mcpServers?.length) { - yield* mcp.connectServers(projectPath, childUuid, mcpServers); - } - - // Build tool policy from profile + // Build the plan-only tool policy from the active profile. const childPolicy = runtime.getToolPolicy(profile); // Get MCP tools for subagent diff --git a/packages/codingcode/test/agent/agent-profile-filter.test.ts b/packages/codingcode/test/agent/agent-profile-filter.test.ts index eaeda1a3..3b162944 100644 --- a/packages/codingcode/test/agent/agent-profile-filter.test.ts +++ b/packages/codingcode/test/agent/agent-profile-filter.test.ts @@ -1,42 +1,13 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } 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'); +describe('system prompt', () => { + it('does not advertise a subagent catalog', () => { 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'); + expect(prompt).not.toContain('Available Subagents'); }); }); diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index f488bb24..68994c72 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -326,7 +326,7 @@ describe('agentLoop', () => { expect(textEvents.map((e: any) => e.text)).toEqual(['\n[Using: readFile]\n']); }); - it('should pass skillInstruction into the system prompt sent to LLM', async () => { + it('should not pass skill instructions into the system prompt sent to LLM', async () => { let capturedSystem: string | undefined; const mockLlm = { completeStream: (params: any) => { @@ -342,8 +342,8 @@ describe('agentLoop', () => { const opts = { state: mockState, llm: { ...mockLlm, modelInfo: { maxTokens: 1000 } } as any, - skillInstruction: 'Use strict TypeScript', - }; + } as any; + opts.skillInstruction = 'Use strict TypeScript'; const q = Effect.runSync(Queue.unbounded()); const effect = agentLoop( deps.executor, @@ -355,7 +355,8 @@ describe('agentLoop', () => { ); await Effect.runPromise(effect.pipe(Effect.provide(AllMockLayer))); - expect(capturedSystem).toContain('Use strict TypeScript'); + expect(capturedSystem).not.toContain('## Skill Instructions'); + expect(capturedSystem).not.toContain('Use strict TypeScript'); }); it('should yield a single maxSteps error and a single turn.end hook when maxSteps is exhausted', async () => { diff --git a/packages/codingcode/test/agent/build-system-prompt.test.ts b/packages/codingcode/test/agent/build-system-prompt.test.ts index fdd4cec0..82de2ec9 100644 --- a/packages/codingcode/test/agent/build-system-prompt.test.ts +++ b/packages/codingcode/test/agent/build-system-prompt.test.ts @@ -1,26 +1,26 @@ import { describe, it, expect } from 'vitest'; -import { buildSystemPrompt } from '../../src/agent/prompt.js'; -import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; +import { BUILD_PROMPT, PLAN_PROMPT, buildSystemPrompt } from '../../src/agent/prompt.js'; +import { PLAN_PROFILE } from '../../src/agent/mode.js'; describe('buildSystemPrompt', () => { - it('uses DEFAULT_BEHAVIOR_PROMPT when profileSystemPrompt is not provided', () => { + it('uses the build 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(BUILD_PROMPT); 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)', () => { + it('uses the plan prompt when profileSystemPrompt is provided', () => { const prompt = buildSystemPrompt({ cwd: '/test', platform: 'linux', shell: 'bash', - profileSystemPrompt: PLAN_PROFILE.systemPrompt, + profileSystemPrompt: PLAN_PROMPT, }); expect(prompt).toContain('You are a planning agent'); expect(prompt).toContain('## Environment'); @@ -43,18 +43,6 @@ describe('buildSystemPrompt', () => { 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', @@ -66,18 +54,16 @@ describe('buildSystemPrompt', () => { expect(prompt).toContain('Always use TypeScript strict mode.'); }); - it('appends skill instructions when provided', () => { + it('does not append skill instructions to the system prompt', () => { 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.'); + expect(prompt).not.toContain('## Skill Instructions'); }); - it('plan profile prompt mentions submit_plan and dispatch_agent for explore only', () => { + it('plan profile prompt limits implementation work to submit_plan', () => { const prompt = buildSystemPrompt({ cwd: '/x', platform: 'linux', @@ -85,7 +71,6 @@ describe('buildSystemPrompt', () => { 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/client/direct/settings.test.ts b/packages/codingcode/test/client/direct/settings.test.ts deleted file mode 100644 index 3b314451..00000000 --- a/packages/codingcode/test/client/direct/settings.test.ts +++ /dev/null @@ -1,543 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; -import { createDirectSettingsClient } from '../../../src/direct/settings.js'; -import { SkillService } from '../../../src/skills/service.js'; -import { MemoryService } from '../../../src/memory/index.js'; -import { McpService } from '../../../src/mcp/index.js'; -import { ApprovalService } from '../../../src/approval/index.js'; -import { ApprovalWaitService } from '../../../src/approval/async-confirm.js'; -import { HookService } from '../../../src/hooks/registry.js'; - -const mockEnableSkill = vi.fn(() => Effect.void); -const mockDisableSkill = vi.fn(() => Effect.void); -const mockListWithStatus = vi.fn(() => Effect.succeed([])); - -const MockSkillLayer = Layer.succeed( - SkillService, - SkillService.make({ - getAll: (_p: string) => Effect.succeed([]), - findByName: (_p: string, _n: string) => Effect.succeed(undefined), - select: (_p: string, _q: string) => Effect.succeed(undefined), - selectImplicit: (_p: string, _q: string, _m: any) => Effect.succeed(undefined), - extractSkill: (_p: string, _q: string) => Effect.succeed([undefined, '']), - enableSkill: mockEnableSkill, - disableSkill: mockDisableSkill, - listWithStatus: mockListWithStatus, - evictProject: (_p: string) => Effect.void, - }) -); - -const MockMemoryLayer = Layer.succeed(MemoryService, { - getMemoryEnabled: () => true, - setMemoryEnabled: () => {}, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), -} as any); - -const MockMcpLayer = Layer.succeed(McpService, { - syncConnections: () => Effect.void, - connectServers: () => Effect.void, - disconnectServers: () => Effect.void, - getServerToolNames: () => [], - disconnectAll: () => Effect.void, - status: () => Effect.succeed([]), - listProjectMcpTools: () => [], - disable: () => Effect.void, - enable: () => Effect.void, -} as any); - -const MockApprovalLayer = ApprovalService.Default.pipe( - Layer.provide(Layer.mergeAll(HookService.Default, ApprovalWaitService.Default)) -); - -const TestLayer = Layer.mergeAll( - MockSkillLayer, - MockMemoryLayer, - MockMcpLayer, - MockApprovalLayer, - HookService.Default, - ApprovalWaitService.Default -); - -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', 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: { - name: 'explore', - description: 'Explore', - tools: ['read_file'], - 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(), - resolveAgentDisabled: vi.fn().mockReturnValue(false), - getProjectAgentDisabledState: vi.fn().mockReturnValue(undefined), -})); - -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(), - resetProjectHookDisabledState: vi.fn(), -})); - -vi.mock('../../../src/hooks/executor.js', () => ({ - setHookRuntimeEnabled: vi.fn(), -})); - -vi.mock('../../../src/memory/config.js', () => ({ - getMemoryConfig: vi.fn().mockReturnValue({ enabled: true, disabledTypes: [], extraTypes: [] }), - getAllTypesWithStatus: vi.fn().mockReturnValue([]), - setMemoryTypeDisabled: vi.fn(), - addMemoryExtraType: vi.fn(), - updateMemoryExtraType: vi.fn(), - deleteMemoryExtraType: vi.fn(), -})); - -vi.mock('../../../src/core/error.js', () => ({ - AlreadyExistsError: class AlreadyExistsError extends Error { - constructor(msg: string) { - super(msg); - this.name = 'AlreadyExistsError'; - } - }, - NotFoundError: class NotFoundError extends Error { - constructor(msg: string) { - super(msg); - this.name = 'NotFoundError'; - } - }, -})); - -describe('createDirectSettingsClient - reset APIs', () => { - let client: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - client = createDirectSettingsClient(rt); - }); - - describe('resetSubagentEnabled', () => { - it('calls resetProjectSubagentEnabledState with cwd', async () => { - const { resetProjectSubagentEnabledState } = - await import('../../../src/subagent/registry.js'); - await client.resetSubagentEnabled({ cwd: '/my-project' }); - expect(resetProjectSubagentEnabledState).toHaveBeenCalledWith('/my-project'); - }); - }); - - describe('resetAgentDisabled', () => { - it('calls resetProjectAgentDisabledState with cwd and name', async () => { - const { resetProjectAgentDisabledState } = await import('../../../src/subagent/registry.js'); - await client.resetAgentDisabled({ name: 'my-agent', cwd: '/my-project' }); - expect(resetProjectAgentDisabledState).toHaveBeenCalledWith('/my-project', 'my-agent'); - }); - }); - - describe('resetMcpDisabled', () => { - it('calls resetProjectMcpDisabledState with cwd and name', async () => { - const { resetProjectMcpDisabledState } = await import('../../../src/mcp/config.js'); - await client.resetMcpDisabled({ name: 'my-server', cwd: '/my-project' }); - expect(resetProjectMcpDisabledState).toHaveBeenCalledWith('/my-project', 'my-server'); - }); - }); - - describe('resetHookDisabled', () => { - it('calls resetProjectHookDisabledState with cwd and name', async () => { - const { resetProjectHookDisabledState } = await import('../../../src/hooks/config.js'); - await client.resetHookDisabled({ name: 'my-hook', cwd: '/my-project' }); - expect(resetProjectHookDisabledState).toHaveBeenCalledWith('/my-project', 'my-hook'); - }); - }); -}); - -describe('createDirectSettingsClient - updated signatures with cwd', () => { - let client: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - client = createDirectSettingsClient(rt); - }); - - describe('getSubagentEnabled', () => { - it('returns enabled and source from resolveSubagentEnabled', async () => { - const { resolveSubagentEnabled, getProjectSubagentEnabledState } = - await import('../../../src/subagent/registry.js'); - vi.mocked(resolveSubagentEnabled).mockReturnValue(true); - vi.mocked(getProjectSubagentEnabledState).mockReturnValue(undefined); - const result = await client.getSubagentEnabled({ cwd: '/my-project' }); - expect(result).toEqual({ enabled: true, source: 'global' }); - expect(resolveSubagentEnabled).toHaveBeenCalledWith('/my-project'); - }); - - it('returns source=project when project override exists', async () => { - const { resolveSubagentEnabled, getProjectSubagentEnabledState } = - await import('../../../src/subagent/registry.js'); - vi.mocked(resolveSubagentEnabled).mockReturnValue(false); - vi.mocked(getProjectSubagentEnabledState).mockReturnValue(false); - const result = await client.getSubagentEnabled({ cwd: '/my-project' }); - expect(result).toEqual({ enabled: false, source: 'project' }); - }); - }); - - describe('setSubagentEnabled', () => { - it('calls setSubagentEnabledState for global cwd', async () => { - const { setSubagentEnabledState } = await import('../../../src/subagent/registry.js'); - await client.setSubagentEnabled({ enabled: false, cwd: 'global' }); - expect(setSubagentEnabledState).toHaveBeenCalledWith(false); - }); - - it('calls setProjectSubagentEnabledState for project cwd', async () => { - const { setProjectSubagentEnabledState } = await import('../../../src/subagent/registry.js'); - await client.setSubagentEnabled({ enabled: false, cwd: '/my-project' }); - expect(setProjectSubagentEnabledState).toHaveBeenCalledWith('/my-project', false); - }); - }); - - describe('setAgentDisabled', () => { - it('calls setGlobalAgentDisabledState for global cwd', async () => { - const { setGlobalAgentDisabledState } = await import('../../../src/subagent/registry.js'); - await client.setAgentDisabled({ name: 'my-agent', disabled: true, cwd: 'global' }); - expect(setGlobalAgentDisabledState).toHaveBeenCalledWith('my-agent', true); - }); - - it('calls setProjectAgentDisabledState for project cwd', async () => { - const { setProjectAgentDisabledState } = await import('../../../src/subagent/registry.js'); - await client.setAgentDisabled({ name: 'my-agent', disabled: true, cwd: '/my-project' }); - expect(setProjectAgentDisabledState).toHaveBeenCalledWith('/my-project', 'my-agent', true); - }); - }); - - describe('setMcpDisabled', () => { - it('calls setGlobalMcpDisabledState for global cwd', async () => { - const { setGlobalMcpDisabledState } = await import('../../../src/mcp/config.js'); - await client.setMcpDisabled({ name: 'my-server', disabled: true, cwd: 'global' }); - expect(setGlobalMcpDisabledState).toHaveBeenCalledWith('my-server', true); - }); - - it('calls setProjectMcpDisabledState for project cwd', async () => { - const { setProjectMcpDisabledState } = await import('../../../src/mcp/config.js'); - await client.setMcpDisabled({ name: 'my-server', disabled: true, cwd: '/my-project' }); - expect(setProjectMcpDisabledState).toHaveBeenCalledWith('/my-project', 'my-server', true); - }); - }); - - describe('setHookDisabled', () => { - it('calls setGlobalHookDisabledState for global cwd', async () => { - const { setGlobalHookDisabledState } = await import('../../../src/hooks/config.js'); - await client.setHookDisabled({ cwd: 'global', name: 'my-hook', disabled: true }); - expect(setGlobalHookDisabledState).toHaveBeenCalledWith('my-hook', true); - }); - - it('calls setProjectHookDisabledState for project cwd', async () => { - const { setProjectHookDisabledState } = await import('../../../src/hooks/config.js'); - await client.setHookDisabled({ cwd: '/my-project', name: 'my-hook', disabled: true }); - expect(setProjectHookDisabledState).toHaveBeenCalledWith('/my-project', 'my-hook', true); - }); - }); - - describe('toggleSkill', () => { - it('calls enableSkill with correct args', async () => { - mockEnableSkill.mockClear(); - await client.toggleSkill({ name: 'my-skill', enabled: true, cwd: '/my-project' }); - expect(mockEnableSkill).toHaveBeenCalledWith('/my-project', 'my-skill'); - }); - - it('calls disableSkill with correct args', async () => { - mockDisableSkill.mockClear(); - await client.toggleSkill({ name: 'my-skill', enabled: false, cwd: '/my-project' }); - expect(mockDisableSkill).toHaveBeenCalledWith('/my-project', 'my-skill'); - }); - }); -}); - -// ---- 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/client/http/settings.test.ts b/packages/codingcode/test/client/http/settings.test.ts deleted file mode 100644 index 154a36e9..00000000 --- a/packages/codingcode/test/client/http/settings.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { createHttpSettingsClient } from '../../../src/client/http/settings.js'; - -function createMockRequest() { - return { - apiGet: vi.fn().mockResolvedValue({ enabled: true }), - apiPost: vi.fn().mockResolvedValue(undefined), - apiPut: vi.fn().mockResolvedValue(undefined), - apiDelete: vi.fn().mockResolvedValue(undefined), - }; -} - -describe('createHttpSettingsClient - reset APIs', () => { - let request: ReturnType; - let client: ReturnType; - - beforeEach(() => { - request = createMockRequest(); - client = createHttpSettingsClient(request as any); - }); - - describe('resetSubagentEnabled', () => { - it('POSTs to /settings/subagent/enabled/reset with cwd query param', async () => { - await client.resetSubagentEnabled({ cwd: '/my-project' }); - expect(request.apiPost).toHaveBeenCalledWith( - '/api/settings/subagent/enabled/reset?cwd=%2Fmy-project', - {} - ); - }); - }); - - describe('resetAgentDisabled', () => { - it('POSTs to /settings/agents/:name/disabled/reset with cwd query param', async () => { - await client.resetAgentDisabled({ name: 'my-agent', cwd: '/my-project' }); - expect(request.apiPost).toHaveBeenCalledWith( - '/api/settings/agents/my-agent/disabled/reset?cwd=%2Fmy-project', - {} - ); - }); - - it('encodes agent name with special characters', async () => { - await client.resetAgentDisabled({ name: 'my agent', cwd: '/my-project' }); - expect(request.apiPost).toHaveBeenCalledWith( - '/api/settings/agents/my%20agent/disabled/reset?cwd=%2Fmy-project', - {} - ); - }); - }); - - describe('resetMcpDisabled', () => { - it('POSTs to /settings/mcp/:name/disabled/reset with cwd query param', async () => { - await client.resetMcpDisabled({ name: 'my-server', cwd: '/my-project' }); - expect(request.apiPost).toHaveBeenCalledWith( - '/api/settings/mcp/my-server/disabled/reset?cwd=%2Fmy-project', - {} - ); - }); - }); - - describe('resetHookDisabled', () => { - it('POSTs to /settings/hooks/:name/disabled/reset with cwd query param', async () => { - await client.resetHookDisabled({ name: 'my-hook', cwd: '/my-project' }); - expect(request.apiPost).toHaveBeenCalledWith( - '/api/settings/hooks/my-hook/disabled/reset?cwd=%2Fmy-project', - {} - ); - }); - }); -}); - -describe('createHttpSettingsClient - updated signatures with cwd', () => { - let request: ReturnType; - let client: ReturnType; - - beforeEach(() => { - request = createMockRequest(); - client = createHttpSettingsClient(request as any); - }); - - describe('getSubagentEnabled', () => { - it('GETs /settings/subagent/enabled with cwd query param', async () => { - request.apiGet.mockResolvedValue({ enabled: true, source: 'global' }); - const result = await client.getSubagentEnabled({ cwd: '/my-project' }); - expect(request.apiGet).toHaveBeenCalledWith( - '/api/settings/subagent/enabled?cwd=%2Fmy-project' - ); - expect(result).toEqual({ enabled: true, source: 'global' }); - }); - }); - - describe('setSubagentEnabled', () => { - it('POSTs to /settings/subagent/enabled with cwd query param and enabled in body', async () => { - await client.setSubagentEnabled({ enabled: false, cwd: '/my-project' }); - expect(request.apiPost).toHaveBeenCalledWith( - '/api/settings/subagent/enabled?cwd=%2Fmy-project', - { enabled: false } - ); - }); - }); - - describe('setMcpDisabled', () => { - it('POSTs to /settings/mcp/:name/disabled with cwd query param', async () => { - await client.setMcpDisabled({ name: 'my-server', disabled: true, cwd: '/my-project' }); - expect(request.apiPost).toHaveBeenCalledWith( - '/api/settings/mcp/my-server/disabled?cwd=%2Fmy-project', - { disabled: true } - ); - }); - }); - - describe('setAgentDisabled', () => { - it('POSTs to /settings/agents/:name/disabled with cwd query param', async () => { - await client.setAgentDisabled({ name: 'my-agent', disabled: true, cwd: '/my-project' }); - expect(request.apiPost).toHaveBeenCalledWith( - '/api/settings/agents/my-agent/disabled?cwd=%2Fmy-project', - { disabled: true } - ); - }); - }); - - describe('setHookDisabled', () => { - it('POSTs to /settings/hooks/:name/disabled with cwd query param', async () => { - await client.setHookDisabled({ cwd: '/my-project', name: 'my-hook', disabled: true }); - expect(request.apiPost).toHaveBeenCalledWith( - '/api/settings/hooks/my-hook/disabled?cwd=%2Fmy-project', - { disabled: true } - ); - }); - }); - - describe('toggleSkill', () => { - it('POSTs to /settings/skills with cwd query param', async () => { - await client.toggleSkill({ name: 'my-skill', enabled: true, cwd: '/my-project' }); - expect(request.apiPost).toHaveBeenCalledWith('/api/settings/skills?cwd=%2Fmy-project', { - name: 'my-skill', - enabled: true, - }); - }); - }); -}); diff --git a/packages/codingcode/test/plan/gate-pipeline.test.ts b/packages/codingcode/test/plan/gate-pipeline.test.ts index 168885b6..cbd69072 100644 --- a/packages/codingcode/test/plan/gate-pipeline.test.ts +++ b/packages/codingcode/test/plan/gate-pipeline.test.ts @@ -8,7 +8,7 @@ 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 } from '../../src/plan/index.js'; +import { planModeGateHook } from '../../src/agent/mode.js'; import { computePaths } from '../../src/core/path.js'; import type { DecisionHandler } from '../../src/hooks/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -153,7 +153,7 @@ describe('Plan mode gate hook integration', () => { expect(capturedApproval).toBeNull(); }); - it('plan mode + dispatch_agent: gate lets it through', async () => { + it('plan mode + dispatch_agent: readonly approval remains unchanged', async () => { const decision: any = await runPipelineWithMock({ tool: 'dispatch_agent', input: { agent: 'build', prompt: 'do something' }, @@ -163,7 +163,6 @@ describe('Plan mode gate hook integration', () => { cwd, }); expect(decision.type).toBe('allow'); - expect(decision.type).not.toBe('deny'); }); it('build mode + write_file: gate does not fire, pipeline falls through normally', async () => { diff --git a/packages/codingcode/test/plan/gate.test.ts b/packages/codingcode/test/plan/gate.test.ts index 6687cef3..5547d421 100644 --- a/packages/codingcode/test/plan/gate.test.ts +++ b/packages/codingcode/test/plan/gate.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { planModeGateHook, isSessionInPlanMode } from '../../src/plan/index.js'; +import { planModeGateHook, isSessionInPlanMode } from '../../src/agent/mode.js'; import { computePaths } from '../../src/core/path.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -66,11 +66,11 @@ describe('planModeGateHook', () => { ).toBeNull(); }); - it('allows dispatch_agent in plan mode', () => { + it('denies dispatch_agent in plan mode', () => { makeSessionIndex(cwd, sessionId, 'plan'); expect( planModeGateHook({ toolName: 'dispatch_agent', sessionId, projectPath: cwd } as any) - ).toBeNull(); + ).toMatchObject({ decision: 'deny' }); }); it('denies write_file in plan mode with the plan-mode reason', () => { diff --git a/packages/codingcode/test/plan/is-plan-profile.test.ts b/packages/codingcode/test/plan/is-plan-profile.test.ts index fdde72dd..00802f89 100644 --- a/packages/codingcode/test/plan/is-plan-profile.test.ts +++ b/packages/codingcode/test/plan/is-plan-profile.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isPlanProfile, PLAN_PROFILE_NAME, BUILD_PROFILE_NAME } from '../../src/plan/index.js'; +import { isPlanProfile, PLAN_PROFILE_NAME, BUILD_PROFILE_NAME } from '../../src/agent/mode.js'; describe('isPlanProfile', () => { it('returns true for a profile named "plan"', () => { @@ -10,8 +10,8 @@ describe('isPlanProfile', () => { 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 an arbitrary profile name', () => { + expect(isPlanProfile({ name: 'custom' })).toBe(false); }); it('returns false for null/undefined', () => { diff --git a/packages/codingcode/test/plan/policy.test.ts b/packages/codingcode/test/plan/policy.test.ts index 1f5a5202..2e22ae26 100644 --- a/packages/codingcode/test/plan/policy.test.ts +++ b/packages/codingcode/test/plan/policy.test.ts @@ -1,26 +1,16 @@ -import { describe, it, expect } from 'vitest'; -import { PLAN_MODE_ALLOWED_TOOLS } from '../../src/plan/index.js'; +import { describe, expect, it } from 'vitest'; +import { PLAN_MODE_ALLOWED_TOOLS } from '../../src/agent/mode.js'; describe('PLAN_MODE_ALLOWED_TOOLS', () => { - it('contains submit_plan', () => { - expect(PLAN_MODE_ALLOWED_TOOLS.has('submit_plan')).toBe(true); + it('contains only read tools and submit_plan', () => { + expect(PLAN_MODE_ALLOWED_TOOLS).toEqual( + new Set(['read_file', 'search_files', 'search_code', 'fetch_url', 'submit_plan']) + ); }); - 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', () => { + it('does not expose 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 deleted file mode 100644 index 5ecccace..00000000 --- a/packages/codingcode/test/plan/subagent-whitelist.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -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/prompts/system-prompt.test.ts b/packages/codingcode/test/prompts/system-prompt.test.ts index 0b1cf27d..458d10e8 100644 --- a/packages/codingcode/test/prompts/system-prompt.test.ts +++ b/packages/codingcode/test/prompts/system-prompt.test.ts @@ -107,38 +107,6 @@ describe('buildSystemPrompt', () => { expect(prompt).not.toContain('User-defined Rules'); }); - it('includes available subagents section when profiles are provided', () => { - const profiles = [ - { - name: 'explore', - description: 'Read-only code exploration.', - tools: ['read_file'], - disabled: false, - }, - ]; - const prompt = buildSystemPrompt({ ...baseOpts, agentProfiles: profiles }); - expect(prompt).toContain('Available Subagents'); - expect(prompt).toContain('dispatch_agent'); - expect(prompt).toContain('explore'); - expect(prompt).toContain('Read-only code exploration.'); - }); - - it('includes plan subagent in available subagents when provided', () => { - const profiles = [ - { name: 'explore', description: 'Explore.', tools: ['read_file'], disabled: false }, - { - name: 'plan', - description: 'Codebase research for planning.', - tools: ['read_file', 'search_code'], - disabled: false, - }, - ]; - const prompt = buildSystemPrompt({ ...baseOpts, agentProfiles: profiles }); - expect(prompt).toContain('plan'); - expect(prompt).toContain('Codebase research for planning'); - expect(prompt).toContain('dispatch_agent'); - }); - it('omits available subagents section when no profiles are provided', () => { const prompt = buildSystemPrompt(baseOpts); expect(prompt).not.toContain('Available Subagents'); diff --git a/packages/codingcode/test/runtime/set-session-profile.test.ts b/packages/codingcode/test/runtime/set-session-profile.test.ts index b4be41b8..b6c20f2c 100644 --- a/packages/codingcode/test/runtime/set-session-profile.test.ts +++ b/packages/codingcode/test/runtime/set-session-profile.test.ts @@ -6,9 +6,8 @@ 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 { BUILD_PROFILE, PLAN_PROFILE } from '../../src/agent/mode.js'; import { useTempProjectBase } from '../helpers/project-base.js'; const base = useTempProjectBase(); @@ -41,7 +40,6 @@ const mockRulesService = { 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( @@ -49,7 +47,6 @@ function makeLayer() { Layer.mergeAll( HookTestLayer, McpTestLayer, - SubagentTestLayer, RulesTestLayer, SessionTestLayer ) @@ -116,15 +113,4 @@ describe('ProjectRuntimeService.setSessionProfile (disk-only)', () => { expect(idx.activeProfile).toBe('build'); }); - it('writes activeProfile when switching to explore', 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/security/plan-mode-restart.test.ts b/packages/codingcode/test/security/plan-mode-restart.test.ts index 2a2d7a91..ec5c9980 100644 --- a/packages/codingcode/test/security/plan-mode-restart.test.ts +++ b/packages/codingcode/test/security/plan-mode-restart.test.ts @@ -7,12 +7,11 @@ 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, isSessionInPlanMode } from '../../src/plan/index.js'; -import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; +import { planModeGateHook, isSessionInPlanMode } from '../../src/agent/mode.js'; +import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/agent/mode.js'; import type { DecisionHandler } from '../../src/hooks/types.js'; import { useTempProjectBase } from '../helpers/project-base.js'; @@ -71,7 +70,6 @@ const mockApprovalWaitService = { 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( @@ -79,7 +77,6 @@ function makeLayer() { Layer.mergeAll( HookTestLayer, McpTestLayer, - SubagentTestLayer, RulesTestLayer, SessionTestLayer ) diff --git a/packages/codingcode/test/server/create-session-active-profile.test.ts b/packages/codingcode/test/server/create-session-active-profile.test.ts index c926bdf6..fd9a6def 100644 --- a/packages/codingcode/test/server/create-session-active-profile.test.ts +++ b/packages/codingcode/test/server/create-session-active-profile.test.ts @@ -8,7 +8,6 @@ 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'; @@ -44,7 +43,6 @@ const mockRulesService = { function makeLayer() { const HookTestLayer = Layer.succeed(HookService, mockHookService); const McpTestLayer = Layer.succeed(McpService, mockMcpService); - const SubagentTestLayer = SubagentService.Default; const RulesTestLayer = Layer.succeed(RulesService, mockRulesService); const SessionTestLayer = SessionService.Default; const WorkspaceTestLayer = WorkspaceService.Default; @@ -53,7 +51,6 @@ function makeLayer() { Layer.mergeAll( HookTestLayer, McpTestLayer, - SubagentTestLayer, RulesTestLayer, SessionTestLayer, WorkspaceTestLayer diff --git a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts index 2192a3d9..c0b1dd8a 100644 --- a/packages/codingcode/test/server/messages-fork-permission-mode.test.ts +++ b/packages/codingcode/test/server/messages-fork-permission-mode.test.ts @@ -9,7 +9,6 @@ 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'; @@ -74,7 +73,6 @@ function makeLayer() { Layer.mergeAll( Layer.succeed(HookService, mockHookService as any), Layer.succeed(McpService, mockMcpService), - SubagentService.Default, Layer.succeed(RulesService, mockRulesService), SessionService.Default ) diff --git a/packages/codingcode/test/server/settings-routes.test.ts b/packages/codingcode/test/server/settings-routes.test.ts deleted file mode 100644 index 6ae286e1..00000000 --- a/packages/codingcode/test/server/settings-routes.test.ts +++ /dev/null @@ -1,875 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; -import { createSettingsRouter } from '../../src/server/routes/settings.js'; -import { MemoryService } from '../../src/memory/index.js'; -import { SkillService } from '../../src/skills/service.js'; -import { McpService } from '../../src/mcp/index.js'; -import { WorkspaceService } from '../../src/core/workspace.js'; - -vi.mock('@codingcode/infra/config', () => ({ - loadConfig: vi.fn().mockReturnValue({ - maxSteps: 200, - maxStopContinuations: 2, - context: { compactionModel: '' }, - memory: { - enabled: true, - model: '', - disabledTypes: [], - extraTypes: [], - }, - activeModel: null, - server: { port: 8080 }, - }), - updateMaxSteps: vi.fn(), - updateMaxStopContinuations: vi.fn(), - updateContextCompactionModel: vi.fn(), - updateMemoryModel: vi.fn(), - updateMemoryEnabled: vi.fn(), - updateMemoryDisabledTypes: vi.fn(), - updateMemoryExtraTypes: vi.fn(), -})); - -vi.mock('../../src/memory/config.js', () => ({ - getMemoryConfig: vi.fn().mockReturnValue({ - enabled: true, - disabledTypes: [], - extraTypes: [], - model: '', - }), - getAllTypesWithStatus: vi - .fn() - .mockReturnValue([ - { name: 'builtin', description: 'Built-in', isBuiltIn: true, disabled: false }, - ]), - setMemoryTypeDisabled: vi.fn(), - addMemoryExtraType: vi.fn(), - updateMemoryExtraType: vi.fn(), - deleteMemoryExtraType: vi.fn(), -})); - -let memoryEnabled = true; - -const MockWorkspaceLayer = Layer.succeed(WorkspaceService, { - getWorkspaceCwd: () => '/tmp/test', - resolveWorkspaceCwd: (override?: string) => override ?? '/tmp/test', - getProcessRoot: () => '/tmp/test', - getWorkspacePath: () => 'test', - resolveInWorkspace: (path: string) => `/tmp/test/${path}`, - getConfig: () => ({ activeModel: null }), - init: () => {}, -} as any); - -const MockMemoryLayer = Layer.succeed(MemoryService, { - getMemoryEnabled: () => memoryEnabled, - setMemoryEnabled: (v: boolean) => { - memoryEnabled = v; - }, - loadMemoryForPrompt: () => '', - flushSessionToMemory: () => Promise.resolve({ written: false, bytes: 0 }), -} as any); - -const MockSkillLayer = Layer.succeed(SkillService, { - _tag: 'Skill' as const, - getAll: vi.fn(() => Effect.succeed([])), - findByName: vi.fn(() => Effect.succeed(undefined)), - select: vi.fn(() => Effect.succeed(undefined)), - selectImplicit: vi.fn(() => Effect.succeed(undefined)), - extractSkill: vi.fn((_p: string, q: string) => - Effect.sync(() => [undefined, q] as [undefined, string]) - ), - enableSkill: vi.fn(() => Effect.void), - disableSkill: vi.fn(() => Effect.void), - listWithStatus: vi.fn(() => Effect.succeed([])), - evictProject: vi.fn(() => Effect.void), -} as any); - -const MockMcpLayer = Layer.succeed(McpService, { - syncConnections: () => Effect.void, - connectServers: () => Effect.void, - disconnectServers: () => Effect.void, - getServerToolNames: () => [], - disconnectAll: () => Effect.void, - status: () => Effect.succeed([]), - listProjectMcpTools: () => [], -} as any); - -const TestLayer = Layer.mergeAll(MockWorkspaceLayer, MockMemoryLayer, MockSkillLayer, MockMcpLayer); - -const rt = ManagedRuntime.make(TestLayer); -const settingsRouter = await createSettingsRouter(rt); - -vi.mock('../../src/subagent/registry.js', () => ({ - EXPLORE_PROFILE: { - name: 'explore', - description: 'Explore', - tools: ['read_file'], - readonly: true, - maxSteps: 30, - }, - PLAN_PROFILE: { - name: 'plan', - description: 'Plan', - tools: ['read_file'], - readonly: true, - maxSteps: 30, - }, - resolveSubagentEnabled: vi.fn().mockReturnValue(true), - getProjectSubagentEnabledState: vi.fn().mockReturnValue(undefined), - setProjectSubagentEnabledState: vi.fn(), - resetProjectSubagentEnabledState: vi.fn(), - getGlobalAgentDisabledState: vi.fn().mockReturnValue(false), - setGlobalAgentDisabledState: vi.fn(), - getProjectAgentDisabledState: vi.fn().mockReturnValue(undefined), - setProjectAgentDisabledState: vi.fn(), - resetProjectAgentDisabledState: vi.fn(), - resolveAgentDisabled: vi.fn().mockReturnValue(false), - getSubagentEnabledState: vi.fn().mockReturnValue(true), - setSubagentEnabledState: vi.fn(), -})); - -vi.mock('../../src/mcp/config.js', () => ({ - loadMcpConfig: vi.fn().mockReturnValue([]), - writeMcpConfig: vi.fn(), - loadGlobalMcpConfig: vi.fn().mockReturnValue([]), - writeGlobalMcpConfig: vi.fn(), - resolveMcpConfig: vi.fn().mockReturnValue([]), - resolveMcpDisabled: vi.fn().mockReturnValue(false), - getGlobalMcpDisabledState: vi.fn().mockReturnValue(false), - setGlobalMcpDisabledState: vi.fn(), - setProjectMcpDisabledState: vi.fn(), - resetProjectMcpDisabledState: 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([]), - 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(), - resetProjectHookDisabledState: vi.fn(), -})); - -vi.mock('../../src/hooks/executor.js', () => ({ - setHookRuntimeEnabled: vi.fn(), -})); - -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', 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, - }; -}); - -vi.mock('../../src/core/error.js', () => ({ - AlreadyExistsError: class AlreadyExistsError extends Error { - constructor(msg: string) { - super(msg); - this.name = 'AlreadyExistsError'; - } - }, - NotFoundError: class NotFoundError extends Error { - constructor(msg: string) { - super(msg); - this.name = 'NotFoundError'; - } - }, -})); - -// ---- Memory ---- -describe('GET /memory/config', () => { - it('returns memory config with types', async () => { - const res = await settingsRouter.request('/memory/config'); - expect(res.status).toBe(200); - const body = (await res.json()) as { enabled: boolean; types: any[] }; - expect(body.enabled).toBe(true); - expect(body.types).toHaveLength(1); - expect(body.types[0]!.name).toBe('builtin'); - }); -}); - -describe('POST /memory/enabled', () => { - it('toggles memory enabled and returns state', async () => { - const res = await settingsRouter.request('/memory/enabled', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: false }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { enabled: boolean }; - expect(body.enabled).toBe(false); - }); -}); - -describe('POST /memory/extra-type', () => { - it('returns 409 when type already exists', async () => { - const { addMemoryExtraType } = await import('../../src/memory/config.js'); - vi.mocked(addMemoryExtraType).mockImplementation(() => { - throw new Error("Memory type 'dup' already exists"); - }); - const res = await settingsRouter.request('/memory/extra-type', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'dup', description: 'duplicate' }), - }); - expect(res.status).toBe(409); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain('already exists'); - }); -}); - -describe('PUT /memory/extra-type/:name', () => { - it('returns 404 when type not found', async () => { - const { updateMemoryExtraType } = await import('../../src/memory/config.js'); - vi.mocked(updateMemoryExtraType).mockImplementation(() => { - throw new Error("Memory type 'missing' not found"); - }); - const res = await settingsRouter.request('/memory/extra-type/missing', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'missing', description: 'x' }), - }); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain('not found'); - }); -}); - -// ---- Agents ---- -describe('GET /agents', () => { - it('returns global agents with source and disabled fields', async () => { - const { loadGlobalAgentProfiles } = await import('../../src/subagent/loader.js'); - vi.mocked(loadGlobalAgentProfiles).mockReturnValue([ - { name: 'my-agent', description: 'Test', tools: ['read_file'] }, - ]); - const res = await settingsRouter.request('/agents?cwd=global'); - expect(res.status).toBe(200); - const body = (await res.json()) as any[]; - expect(body).toHaveLength(3); // EXPLORE + PLAN + my-agent - // EXPLORE is builtin - expect(body[0].name).toBe('explore'); - expect(body[0].source).toBe('builtin'); - expect(body[0].disabled).toBe(false); - // PLAN is builtin - expect(body[1].name).toBe('plan'); - expect(body[1].source).toBe('builtin'); - expect(body[1].disabled).toBe(false); - // my-agent is global - expect(body[2].name).toBe('my-agent'); - expect(body[2].source).toBe('global'); - expect(body[2].disabled).toBe(false); - }); - - it('returns project agents with merged view', async () => { - const { loadGlobalAgentProfiles, loadAgentProfiles } = - await import('../../src/subagent/loader.js'); - const { getProjectAgentDisabledState, resolveAgentDisabled } = - await import('../../src/subagent/registry.js'); - vi.mocked(loadGlobalAgentProfiles).mockReturnValue([ - { name: 'global-agent', description: 'Global', tools: ['read_file'] }, - ]); - vi.mocked(loadAgentProfiles).mockReturnValue([ - { name: 'project-agent', description: 'Project', tools: ['write_file'] }, - ]); - vi.mocked(getProjectAgentDisabledState).mockReturnValue(undefined); - vi.mocked(resolveAgentDisabled).mockReturnValue(false); - const res = await settingsRouter.request('/agents?cwd=/my-project'); - expect(res.status).toBe(200); - const body = (await res.json()) as any[]; - // EXPLORE + PLAN + global-agent + project-agent - expect(body).toHaveLength(4); - expect(body[0].source).toBe('builtin'); - expect(body[1].source).toBe('builtin'); - expect(body[2].source).toBe('global'); - expect(body[3].source).toBe('project'); - }); -}); - -describe('POST /agents/:name/disabled', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('calls setGlobalAgentDisabledState for global cwd', async () => { - const { setGlobalAgentDisabledState } = await import('../../src/subagent/registry.js'); - const res = await settingsRouter.request('/agents/my-agent/disabled?cwd=global', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ disabled: true }), - }); - expect(res.status).toBe(200); - expect(setGlobalAgentDisabledState).toHaveBeenCalledWith('my-agent', true); - }); - - it('calls setProjectAgentDisabledState for project cwd', async () => { - const { setProjectAgentDisabledState } = await import('../../src/subagent/registry.js'); - const res = await settingsRouter.request('/agents/my-agent/disabled?cwd=/my-project', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ disabled: true }), - }); - expect(res.status).toBe(200); - expect(setProjectAgentDisabledState).toHaveBeenCalledWith('/my-project', 'my-agent', true); - }); -}); - -describe('POST /agents/:name/disabled/reset', () => { - it('calls resetProjectAgentDisabledState', async () => { - const { resetProjectAgentDisabledState } = await import('../../src/subagent/registry.js'); - const res = await settingsRouter.request('/agents/my-agent/disabled/reset?cwd=/my-project', { - method: 'POST', - }); - expect(res.status).toBe(200); - expect(resetProjectAgentDisabledState).toHaveBeenCalledWith('/my-project', 'my-agent'); - }); -}); - -// ---- Subagent enabled ---- -describe('GET /subagent/enabled', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns global enabled state with source=global when no cwd', async () => { - const { getSubagentEnabledState } = await import('../../src/subagent/registry.js'); - vi.mocked(getSubagentEnabledState).mockReturnValue(true); - const res = await settingsRouter.request('/subagent/enabled'); - expect(res.status).toBe(200); - const body = (await res.json()) as { enabled: boolean; source: string }; - expect(body.enabled).toBe(true); - expect(body.source).toBe('global'); - }); - - it('returns project enabled state with source=project when project override exists', async () => { - const { getProjectSubagentEnabledState, resolveSubagentEnabled } = - await import('../../src/subagent/registry.js'); - vi.mocked(getProjectSubagentEnabledState).mockReturnValue(false); - vi.mocked(resolveSubagentEnabled).mockReturnValue(false); - const res = await settingsRouter.request('/subagent/enabled?cwd=/my-project'); - expect(res.status).toBe(200); - const body = (await res.json()) as { enabled: boolean; source: string }; - expect(body.enabled).toBe(false); - expect(body.source).toBe('project'); - }); - - it('returns source=global when no project override', async () => { - const { getProjectSubagentEnabledState, resolveSubagentEnabled } = - await import('../../src/subagent/registry.js'); - vi.mocked(getProjectSubagentEnabledState).mockReturnValue(undefined); - vi.mocked(resolveSubagentEnabled).mockReturnValue(true); - const res = await settingsRouter.request('/subagent/enabled?cwd=/my-project'); - expect(res.status).toBe(200); - const body = (await res.json()) as { enabled: boolean; source: string }; - expect(body.source).toBe('global'); - }); -}); - -describe('POST /subagent/enabled', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('calls setSubagentEnabledState for global cwd', async () => { - const { setSubagentEnabledState } = await import('../../src/subagent/registry.js'); - const res = await settingsRouter.request('/subagent/enabled?cwd=global', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: false }), - }); - expect(res.status).toBe(200); - expect(setSubagentEnabledState).toHaveBeenCalledWith(false); - }); - - it('calls setProjectSubagentEnabledState for project cwd', async () => { - const { setProjectSubagentEnabledState } = await import('../../src/subagent/registry.js'); - const res = await settingsRouter.request('/subagent/enabled?cwd=/my-project', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: false }), - }); - expect(res.status).toBe(200); - expect(setProjectSubagentEnabledState).toHaveBeenCalledWith('/my-project', false); - }); -}); - -describe('POST /subagent/enabled/reset', () => { - it('calls resetProjectSubagentEnabledState', async () => { - const { resetProjectSubagentEnabledState } = await import('../../src/subagent/registry.js'); - const res = await settingsRouter.request('/subagent/enabled/reset?cwd=/my-project', { - method: 'POST', - }); - expect(res.status).toBe(200); - expect(resetProjectSubagentEnabledState).toHaveBeenCalledWith('/my-project'); - }); -}); - -// ---- MCP ---- -describe('GET /mcp', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns global MCP servers with source and disabled', async () => { - const { loadGlobalMcpConfig, getGlobalMcpDisabledState } = - await import('../../src/mcp/config.js'); - vi.mocked(loadGlobalMcpConfig).mockReturnValue([{ name: 'server1', command: 'npx' }]); - vi.mocked(getGlobalMcpDisabledState).mockReturnValue(false); - const res = await settingsRouter.request('/mcp?cwd=global'); - expect(res.status).toBe(200); - const body = (await res.json()) as any[]; - expect(body).toHaveLength(1); - expect(body[0].source).toBe('global'); - expect(body[0].disabled).toBe(false); - }); - - it('returns project MCP servers with merged view', async () => { - const { loadGlobalMcpConfig, loadMcpConfig, resolveMcpConfig, resolveMcpDisabled } = - await import('../../src/mcp/config.js'); - vi.mocked(loadGlobalMcpConfig).mockReturnValue([{ name: 'global-srv', command: 'npx' }]); - vi.mocked(loadMcpConfig).mockReturnValue([{ name: 'project-srv', command: 'node' }]); - vi.mocked(resolveMcpConfig).mockReturnValue([ - { name: 'global-srv', command: 'npx' }, - { name: 'project-srv', command: 'node' }, - ]); - 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(2); - expect(body[0].source).toBe('global'); - expect(body[1].source).toBe('project'); - }); -}); - -describe('POST /mcp/:name/disabled', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('calls setGlobalMcpDisabledState for global cwd', async () => { - const { setGlobalMcpDisabledState } = await import('../../src/mcp/config.js'); - const res = await settingsRouter.request('/mcp/srv1/disabled?cwd=global', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ disabled: true }), - }); - expect(res.status).toBe(200); - expect(setGlobalMcpDisabledState).toHaveBeenCalledWith('srv1', true); - }); - - it('calls setProjectMcpDisabledState for project cwd', async () => { - const { setProjectMcpDisabledState } = await import('../../src/mcp/config.js'); - const res = await settingsRouter.request('/mcp/srv1/disabled?cwd=/my-project', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ disabled: true }), - }); - expect(res.status).toBe(200); - expect(setProjectMcpDisabledState).toHaveBeenCalledWith('/my-project', 'srv1', true); - }); -}); - -describe('POST /mcp/:name/disabled/reset', () => { - it('calls resetProjectMcpDisabledState', async () => { - const { resetProjectMcpDisabledState } = await import('../../src/mcp/config.js'); - const res = await settingsRouter.request('/mcp/srv1/disabled/reset?cwd=/my-project', { - method: 'POST', - }); - expect(res.status).toBe(200); - expect(resetProjectMcpDisabledState).toHaveBeenCalledWith('/my-project', 'srv1'); - }); -}); - -// ---- Hooks ---- -describe('GET /hooks', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns global hooks with source field', async () => { - const { loadGlobalHookConfigs } = await import('../../src/hooks/config.js'); - vi.mocked(loadGlobalHookConfigs).mockReturnValue([ - { - name: 'hook1', - point: 'tool.execute.before', - type: 'observer', - command: 'echo', - enabled: true, - }, - ]); - const res = await settingsRouter.request('/hooks?cwd=global'); - expect(res.status).toBe(200); - const body = (await res.json()) as any[]; - expect(body).toHaveLength(1); - expect(body[0].source).toBe('global'); - }); - - it('returns project hooks with merged view', async () => { - const { loadGlobalHookConfigs, loadHookConfigs, resolveHookConfigs, resolveHookDisabled } = - await import('../../src/hooks/config.js'); - vi.mocked(loadGlobalHookConfigs).mockReturnValue([ - { - name: 'global-hook', - point: 'tool.execute.before', - type: 'observer', - command: 'echo', - enabled: true, - }, - ]); - vi.mocked(loadHookConfigs).mockReturnValue([ - { - name: 'project-hook', - point: 'tool.execute.after', - type: 'decision', - command: 'sh', - enabled: true, - }, - ]); - vi.mocked(resolveHookConfigs).mockReturnValue([ - { - name: 'global-hook', - point: 'tool.execute.before', - type: 'observer', - command: 'echo', - enabled: true, - }, - { - name: 'project-hook', - point: 'tool.execute.after', - type: 'decision', - 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(2); - expect(body[0].source).toBe('global'); - expect(body[1].source).toBe('project'); - }); -}); - -describe('POST /hooks/:name/disabled', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('calls setGlobalHookDisabledState for global cwd', async () => { - const { setGlobalHookDisabledState } = await import('../../src/hooks/config.js'); - const res = await settingsRouter.request('/hooks/hook1/disabled?cwd=global', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ disabled: true }), - }); - expect(res.status).toBe(200); - expect(setGlobalHookDisabledState).toHaveBeenCalledWith('hook1', true); - }); - - it('calls setProjectHookDisabledState for project cwd', async () => { - const { setProjectHookDisabledState } = await import('../../src/hooks/config.js'); - const res = await settingsRouter.request('/hooks/hook1/disabled?cwd=/my-project', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ disabled: true }), - }); - expect(res.status).toBe(200); - expect(setProjectHookDisabledState).toHaveBeenCalledWith('/my-project', 'hook1', true); - }); -}); - -describe('POST /hooks/:name/disabled/reset', () => { - it('calls resetProjectHookDisabledState', async () => { - const { resetProjectHookDisabledState } = await import('../../src/hooks/config.js'); - const res = await settingsRouter.request('/hooks/hook1/disabled/reset?cwd=/my-project', { - method: 'POST', - }); - expect(res.status).toBe(200); - expect(resetProjectHookDisabledState).toHaveBeenCalledWith('/my-project', 'hook1'); - }); -}); - -// ---- Skills ---- -describe('POST /skills', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('calls setGlobalSkillDisabledState for global cwd', async () => { - const { setGlobalSkillDisabledState } = await import('../../src/skills/source.js'); - const res = await settingsRouter.request('/skills?cwd=global', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'my-skill', enabled: false }), - }); - expect(res.status).toBe(200); - expect(setGlobalSkillDisabledState).toHaveBeenCalledWith('my-skill', true); - }); - - it('calls setProjectSkillDisabledState for project cwd', async () => { - const { setProjectSkillDisabledState } = await import('../../src/skills/source.js'); - const res = await settingsRouter.request('/skills?cwd=/my-project', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'my-skill', enabled: false }), - }); - expect(res.status).toBe(200); - expect(setProjectSkillDisabledState).toHaveBeenCalledWith('/my-project', 'my-skill', true); - }); -}); - -// ---- Memory config extended ---- -describe('GET /memory/config (extended)', () => { - it('returns model field', async () => { - const res = await settingsRouter.request('/memory/config'); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('model'); - }); -}); - -// ---- Memory model ---- -describe('POST /memory/model', () => { - it('updates memory model via updateMemoryModel', async () => { - const { updateMemoryModel } = await import('@codingcode/infra/config'); - const res = await settingsRouter.request('/memory/model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'deepseek-v4-flash' }), - }); - expect(res.status).toBe(200); - expect(updateMemoryModel).toHaveBeenCalledWith('deepseek-v4-flash'); - }); -}); - -// ---- Agent config ---- -describe('GET /agent/config', () => { - it('returns maxSteps and maxStopContinuations from loadConfig', async () => { - const res = await settingsRouter.request('/agent/config'); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('maxSteps'); - expect(body).toHaveProperty('maxStopContinuations'); - }); -}); - -describe('POST /agent/config', () => { - it('updates maxSteps via updateMaxSteps', async () => { - const { updateMaxSteps } = await import('@codingcode/infra/config'); - const res = await settingsRouter.request('/agent/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ maxSteps: 500 }), - }); - expect(res.status).toBe(200); - expect(updateMaxSteps).toHaveBeenCalledWith(500); - }); - - it('updates maxStopContinuations via updateMaxStopContinuations', async () => { - const { updateMaxStopContinuations } = await import('@codingcode/infra/config'); - const res = await settingsRouter.request('/agent/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ maxStopContinuations: 10 }), - }); - expect(res.status).toBe(200); - expect(updateMaxStopContinuations).toHaveBeenCalledWith(10); - }); -}); - -// ---- Context compaction model ---- -describe('POST /context/compaction-model', () => { - it('updates compaction model via updateContextCompactionModel', async () => { - const { updateContextCompactionModel } = await import('@codingcode/infra/config'); - const res = await settingsRouter.request('/context/compaction-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ compactionModel: 'gpt-4o-mini' }), - }); - expect(res.status).toBe(200); - 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/create-active-profile.test.ts b/packages/codingcode/test/session/create-active-profile.test.ts index 2daf2e34..37bb7de1 100644 --- a/packages/codingcode/test/session/create-active-profile.test.ts +++ b/packages/codingcode/test/session/create-active-profile.test.ts @@ -71,12 +71,12 @@ describe('create writes activeProfile in one updateIndex', () => { await run( Effect.gen(function* () { const svc = yield* SessionService; - yield* svc.setActiveProfile(cwd, state.sessionId, 'explore'); + yield* svc.setActiveProfile(cwd, state.sessionId, 'custom-profile'); }) ); const after = JSON.parse(readFileSync(state.indexPath, 'utf8')); - expect(after.activeProfile).toBe('explore'); + expect(after.activeProfile).toBe('custom-profile'); await run( Effect.gen(function* () { @@ -86,6 +86,6 @@ describe('create writes activeProfile in one updateIndex', () => { ); const afterRecord = JSON.parse(readFileSync(state.indexPath, 'utf8')); - expect(afterRecord.activeProfile).toBe('explore'); + expect(afterRecord.activeProfile).toBe('custom-profile'); }); }); diff --git a/packages/codingcode/test/session/create-session-with-profile.test.ts b/packages/codingcode/test/session/create-session-with-profile.test.ts index 5dbcab6d..70f656cd 100644 --- a/packages/codingcode/test/session/create-session-with-profile.test.ts +++ b/packages/codingcode/test/session/create-session-with-profile.test.ts @@ -34,11 +34,11 @@ describe('createSessionWithProfile helper', () => { return yield* svc.createSessionWithProfile( cwd, { model: 'gpt-4o', mode: 'build', permissionMode: 'default' }, - { activeProfile: 'explore' } + { activeProfile: 'custom-profile' } ); }) ); - expect(state.activeProfile).toBe('explore'); + expect(state.activeProfile).toBe('custom-profile'); expect(state.mode).toBe('build'); }); }); diff --git a/packages/codingcode/test/session/disk-setters.test.ts b/packages/codingcode/test/session/disk-setters.test.ts index ae43d4e1..64da131a 100644 --- a/packages/codingcode/test/session/disk-setters.test.ts +++ b/packages/codingcode/test/session/disk-setters.test.ts @@ -5,7 +5,6 @@ import { join } from 'path'; 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'; @@ -42,7 +41,6 @@ function makeLayer() { Layer.mergeAll( Layer.succeed(HookService, mockHookService as any), Layer.succeed(McpService, mockMcpService), - SubagentService.Default, Layer.succeed(RulesService, mockRulesService) ) ) @@ -127,7 +125,7 @@ describe('SessionService disk setter/getter consistency', () => { await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; - yield* session.setActiveProfile(cwd, sessionId, 'explore'); + yield* session.setActiveProfile(cwd, sessionId, 'custom-profile'); }) ); const state = await rt.runPromise( @@ -138,6 +136,6 @@ describe('SessionService disk setter/getter consistency', () => { ); expect(existsSync(state.indexPath)).toBe(true); const idx = JSON.parse(readFileSync(state.indexPath, 'utf8')); - expect(idx.activeProfile).toBe('explore'); + expect(idx.activeProfile).toBe('custom-profile'); }); }); diff --git a/packages/codingcode/test/session/load-restore-profile.test.ts b/packages/codingcode/test/session/load-restore-profile.test.ts index d699e3fd..02500e5c 100644 --- a/packages/codingcode/test/session/load-restore-profile.test.ts +++ b/packages/codingcode/test/session/load-restore-profile.test.ts @@ -4,10 +4,9 @@ import { mkdirSync, writeFileSync, readFileSync } from 'fs'; import { join } from 'path'; import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; import { SessionService } from '../../src/session/store.js'; -import { BUILD_PROFILE } from '../../src/subagent/registry.js'; +import { BUILD_PROFILE } from '../../src/agent/mode.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'; @@ -41,7 +40,6 @@ const mockRulesService = { 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( @@ -49,7 +47,6 @@ function makeLayer() { Layer.mergeAll( HookTestLayer, McpTestLayer, - SubagentTestLayer, RulesTestLayer, SessionTestLayer ) diff --git a/packages/codingcode/test/subagent/builtin-profiles.test.ts b/packages/codingcode/test/subagent/builtin-profiles.test.ts new file mode 100644 index 00000000..631c856c --- /dev/null +++ b/packages/codingcode/test/subagent/builtin-profiles.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { PLAN_PROFILE, BUILD_PROFILE } from '../../src/agent/mode.js'; +import { PLAN_MODE_ALLOWED_TOOLS } from '../../src/agent/mode.js'; + +describe('built-in subagent profiles', () => { + it('keeps only build and plan as built-in names', () => { + expect([BUILD_PROFILE.name, PLAN_PROFILE.name].sort()).toEqual(['build', 'plan']); + }); + + it('keeps plan tools independent from profile tool lists', () => { + expect('tools' in PLAN_PROFILE).toBe(false); + expect('tools' in BUILD_PROFILE).toBe(false); + expect(PLAN_MODE_ALLOWED_TOOLS).toEqual( + new Set(['read_file', 'search_files', 'search_code', 'fetch_url', 'submit_plan']) + ); + }); +}); diff --git a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts index b5097902..6e544f54 100644 --- a/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts +++ b/packages/codingcode/test/subagent/dispatch-end-to-end.test.ts @@ -77,7 +77,7 @@ describe('dispatch_agent end-to-end (subagent reads its own jsonl)', () => { const dispatchTool = yield* createDispatchAgentTool(); const output = yield* dispatchTool.execute( - { agent: 'explore', prompt: 'analyze this code' }, + { agent: 'build', prompt: 'analyze this code' }, { projectPath: cwd, sessionId: parent.sessionId } as any ); return { output, parentId: parent.sessionId }; @@ -127,7 +127,7 @@ describe('dispatch_agent end-to-end (subagent reads its own jsonl)', () => { permissionMode: 'default', }); const dispatchTool = yield* createDispatchAgentTool(); - yield* dispatchTool.execute({ agent: 'explore', prompt: 'p' }, { + yield* dispatchTool.execute({ agent: 'build', prompt: 'p' }, { projectPath: cwd, sessionId: parent.sessionId, } as any); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 6d14b002..48e9e79f 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -7,7 +7,7 @@ import { HookService } from '../../src/hooks/registry.js'; import { McpService } from '../../src/mcp/index.js'; import { LLMFactoryService } from '../../src/llm/factory.js'; import { RulesService } from '../../src/rules/index.js'; -import { SubagentService, EXPLORE_PROFILE, BUILD_PROFILE } from '../../src/subagent/registry.js'; +import { BUILD_PROFILE } from '../../src/agent/mode.js'; import { SubagentRunnerService } from '../../src/subagent/runner-service.js'; import { ProjectRuntimeService } from '../../src/runtime/project-runtime.js'; import type { ToolDefinition, ToolExecCtx } from '../../src/tools/types.js'; @@ -116,22 +116,21 @@ const mockRules = { const mockSubagent = { registerGlobal: () => undefined, - registerProject: () => undefined, get: (_p: string, name: string) => { - if (name === 'explore') return EXPLORE_PROFILE; if (name === 'build') return BUILD_PROFILE; - if (name === 'custom') return { name: 'custom', description: 'custom agent' } as any; + if (name === 'custom') { + return { name: 'custom' } as any; + } + if (name === 'custom-default') return { name } as any; return undefined; }, - list: () => [EXPLORE_PROFILE, BUILD_PROFILE], - resetProject: () => undefined, + list: () => [BUILD_PROFILE], }; const mockProjectRuntime = { prepareProject: () => Effect.void, resolveMainAgentProfile: () => undefined, resolveSubagentProfile: (_p: string, name: string) => mockSubagent.get(_p, name), - listAgentProfiles: () => [EXPLORE_PROFILE, BUILD_PROFILE], getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, @@ -162,7 +161,6 @@ function makeLayers(parentPermissionMode: 'default' | 'bypass' | 'acceptEdits' = Layer.succeed(McpService, McpService.make(mockMcp as any)), Layer.succeed(LLMFactoryService, mockLlmFactory as any), Layer.succeed(RulesService, mockRules as any), - Layer.succeed(SubagentService, mockSubagent as any), Layer.succeed(ProjectRuntimeService, ProjectRuntimeService.make(mockProjectRuntime as any)), Layer.succeed(SubagentRunnerService, subagentRunner as any) ); @@ -192,7 +190,6 @@ async function dispatchTool( Layer.succeed(McpService, McpService.make(mockMcp as any)), Layer.succeed(LLMFactoryService, mockLlmFactory as any), Layer.succeed(RulesService, mockRules as any), - Layer.succeed(SubagentService, mockSubagent as any), Layer.succeed(ProjectRuntimeService, ProjectRuntimeService.make(mockProjectRuntime as any)), Layer.succeed(SubagentRunnerService, { runStream: vi.fn().mockReturnValue(makeRunStream()), @@ -205,17 +202,17 @@ async function dispatchTool( return capturePerm.value; } -describe('dispatch_agent permission-mode priority (profile > parent > default)', () => { - it('case 1: profile has explicit permissionMode → child uses profile value', async () => { - const perm = await dispatchTool('default', 'explore', { +describe('dispatch_agent permission-mode priority (parent > default)', () => { + it('case 1: child uses default when profile has no permissionMode', async () => { + const perm = await dispatchTool('default', 'custom', { projectPath: '/test', sessionId: 'parent-1', } as ToolExecCtx); - expect(perm).toBe('bypass'); + expect(perm).toBe('default'); }); it('case 2: profile has no permissionMode + parent has bypass → child uses parent value', async () => { - const perm = await dispatchTool('bypass', 'custom', { + const perm = await dispatchTool('bypass', 'custom-default', { projectPath: '/test', sessionId: 'parent-1', } as ToolExecCtx); @@ -223,7 +220,7 @@ describe('dispatch_agent permission-mode priority (profile > parent > default)', }); it('case 3: profile has no permissionMode + no parent (top-level) → child uses default', async () => { - const perm = await dispatchTool('default', 'custom', { + const perm = await dispatchTool('default', 'custom-default', { projectPath: '/test', } as ToolExecCtx); expect(perm).toBe('default'); diff --git a/packages/codingcode/test/subagent/loader.test.ts b/packages/codingcode/test/subagent/loader.test.ts deleted file mode 100644 index 4739c28f..00000000 --- a/packages/codingcode/test/subagent/loader.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -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, - updateAgentProfile, - deleteAgentProfile, -} from '../../src/subagent/loader'; - -describe('loadAgentProfiles', () => { - const testDir = join(tmpdir(), 'codingcode-test-agents'); - - beforeEach(() => { - mkdirSync(join(testDir, '.codingcode', 'agents'), { recursive: true }); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - it('should return empty array when agents directory does not exist', () => { - const result = loadAgentProfiles(join(process.cwd(), 'nonexistent')); - expect(result).toEqual([]); - }); - - it('should load basic profile from markdown file', () => { - const profile = `--- -name: basic-agent -description: A basic agent for testing ---- -You are a basic test agent.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'basic.md'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.name).toBe('basic-agent'); - expect(results[0]!.description).toBe('A basic agent for testing'); - expect(results[0]!.systemPrompt).toBe('You are a basic test agent.'); - }); - - it('should parse readonly and maxSteps fields', () => { - const profile = `--- -name: advanced-agent -description: Advanced agent -readonly: true -maxSteps: 50 ---- -Advanced system prompt.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'advanced.md'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.readonly).toBe(true); - expect(results[0]!.maxSteps).toBe(50); - }); - - it('should parse tools array', () => { - const profile = `--- -name: tool-agent -description: Agent with tools -tools: [read_file, write_file, bash] ---- -System prompt.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'tools.md'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.tools).toEqual(['read_file', 'write_file', 'bash']); - }); - - it('should parse mcpServers array', () => { - const profile = `--- -name: mcp-agent -description: Agent with MCP servers -mcpServers: [postgres, redis] ---- -System prompt.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'mcp.md'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.mcpServers).toEqual(['postgres', 'redis']); - }); - - it('should leave mcpServers undefined when not specified', () => { - const profile = `--- -name: no-mcp-agent -description: Agent without MCP ---- -System prompt.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'no-mcp.md'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.mcpServers).toBeUndefined(); - }); - - it('should skip files without name or description', () => { - const profile = `--- -description: Missing name ---- -Incomplete profile.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'incomplete.md'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(0); - }); - - it('should load multiple profiles from different files', () => { - const profile1 = `--- -name: agent1 -description: First agent ---- -System 1`; - - const profile2 = `--- -name: agent2 -description: Second agent ---- -System 2`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'agent1.md'), profile1); - writeFileSync(join(testDir, '.codingcode', 'agents', 'agent2.md'), profile2); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(2); - expect(results.map((r) => r.name)).toEqual(expect.arrayContaining(['agent1', 'agent2'])); - }); - - it('should handle profiles without frontmatter', () => { - const profile = `Just a plain text file without frontmatter.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'plain.md'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(0); - }); - - it('should handle multiline system prompts', () => { - const profile = `--- -name: multiline-agent -description: Agent with multiline prompt ---- -You are a specialized agent. - -Your responsibilities: -- Task 1 -- Task 2 -- Task 3 - -Always follow these rules.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'multiline.md'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.systemPrompt).toContain('- Task 1'); - expect(results[0]!.systemPrompt).toContain('Always follow these rules.'); - }); - - it('should ignore non-.md files', () => { - const profile = `--- -name: should-ignore -description: This should be ignored ---- -Content.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'ignore.txt'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(0); - }); - - it('should parse false value for readonly', () => { - const profile = `--- -name: writable-agent -description: Agent that can write -readonly: false ---- -System prompt.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'writable.md'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.readonly).toBe(false); - }); - - it('should parse model field from frontmatter', () => { - const profile = `--- -name: model-agent -description: Agent with specific model -model: gpt-4o@openai ---- -System prompt.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'model.md'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.model).toBe('gpt-4o@openai'); - }); - - it('should leave model undefined when not specified in frontmatter', () => { - const profile = `--- -name: no-model-agent -description: Agent without model ---- -System prompt.`; - - writeFileSync(join(testDir, '.codingcode', 'agents', 'no-model.md'), profile); - - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.model).toBeUndefined(); - }); -}); - -describe('writeAgentProfile', () => { - const testDir = join(tmpdir(), 'codingcode-test-agents-write'); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - it('should write and read back a profile', () => { - writeAgentProfile(testDir, { - name: 'test-agent', - description: 'Agent for testing', - systemPrompt: 'You are a test agent.', - }); - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.name).toBe('test-agent'); - expect(results[0]!.description).toBe('Agent for testing'); - expect(results[0]!.systemPrompt).toBe('You are a test agent.'); - }); - - it('should write profile with all optional fields', () => { - writeAgentProfile(testDir, { - name: 'full-agent', - description: 'Full agent', - systemPrompt: 'You are full.', - tools: ['read_file', 'glob'], - mcpServers: ['postgres', 'redis'], - readonly: true, - maxSteps: 50, - model: 'sonnet', - }); - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.tools).toEqual(['read_file', 'glob']); - expect(results[0]!.mcpServers).toEqual(['postgres', 'redis']); - expect(results[0]!.readonly).toBe(true); - expect(results[0]!.maxSteps).toBe(50); - expect(results[0]!.model).toBe('sonnet'); - }); - - it('should overwrite existing profile with same name', () => { - writeAgentProfile(testDir, { - name: 'dup-agent', - description: 'Original', - systemPrompt: 'Original.', - }); - writeAgentProfile(testDir, { - name: 'dup-agent', - description: 'Updated', - systemPrompt: 'Updated.', - }); - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.description).toBe('Updated'); - }); -}); - -describe('updateAgentProfile', () => { - const testDir = join(tmpdir(), 'codingcode-test-agents-update'); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - it('should rename a profile', () => { - writeAgentProfile(testDir, { - name: 'old-name', - description: 'Test', - systemPrompt: 'Test.', - }); - updateAgentProfile(testDir, 'old-name', { - name: 'new-name', - description: 'Test', - systemPrompt: 'Test.', - }); - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.name).toBe('new-name'); - }); -}); - -describe('deleteAgentProfile', () => { - const testDir = join(tmpdir(), 'codingcode-test-agents-delete'); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - it('should delete a profile by name', () => { - writeAgentProfile(testDir, { - name: 'to-delete', - description: 'Will be deleted', - systemPrompt: 'Bye.', - }); - writeAgentProfile(testDir, { - name: 'keep', - description: 'Stays', - systemPrompt: 'Hi.', - }); - deleteAgentProfile(testDir, 'to-delete'); - const results = loadAgentProfiles(testDir); - expect(results).toHaveLength(1); - expect(results[0]!.name).toBe('keep'); - }); -}); diff --git a/packages/codingcode/test/subagent/plan-profile.test.ts b/packages/codingcode/test/subagent/plan-profile.test.ts deleted file mode 100644 index 86e429f1..00000000 --- a/packages/codingcode/test/subagent/plan-profile.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect } from 'vitest'; -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('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', () => { - expect(PLAN_PROFILE.maxSteps).toBe(180); - }); - - it('has a systemPrompt', () => { - expect(PLAN_PROFILE.systemPrompt).toBeTruthy(); - expect(PLAN_PROFILE.systemPrompt!.length).toBeGreaterThan(50); - }); - - 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); - } - }); - - it('includes read_file and search_code', () => { - expect(PLAN_PROFILE.tools).toContain('read_file'); - expect(PLAN_PROFILE.tools).toContain('search_code'); - }); - - 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', () => { - expect(PLAN_PROFILE.name).not.toBe(EXPLORE_PROFILE.name); - }); - - it('has description stating it is for planning', () => { - 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 deleted file mode 100644 index e422036b..00000000 --- a/packages/codingcode/test/subagent/registry.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { expect, it, describe } from 'vitest'; -import { Effect } from 'effect'; -import { - SubagentService, - EXPLORE_PROFILE, - PLAN_PROFILE, - BUILD_PROFILE, -} from '../../src/subagent/registry'; -import type { AgentProfile } from '../../src/subagent/types'; - -describe('SubagentService', () => { - it('should register global profiles and retrieve them', async () => { - const profile: AgentProfile = { - name: 'test-agent', - description: 'Test agent', - systemPrompt: 'You are a test agent', - }; - - const result = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* SubagentService; - svc.registerGlobal([profile]); - return svc.get('', 'test-agent'); - }).pipe(Effect.provide(SubagentService.Default)) - ); - - expect(result).toEqual(profile); - }); - - it('should register project profiles and retrieve with project path', async () => { - const globalProfile: AgentProfile = { - name: 'global-agent', - description: 'Global agent', - systemPrompt: 'Global system', - }; - const projectProfile: AgentProfile = { - name: 'project-agent', - description: 'Project agent', - systemPrompt: 'Project system', - }; - - const result = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* SubagentService; - svc.registerGlobal([globalProfile]); - svc.registerProject('/project/a', [projectProfile]); - return { - globalViaProject: svc.get('/project/a', 'global-agent'), - projectViaProject: svc.get('/project/a', 'project-agent'), - projectViaEmpty: svc.get('', 'project-agent'), - }; - }).pipe(Effect.provide(SubagentService.Default)) - ); - - expect(result.globalViaProject).toEqual(globalProfile); - expect(result.projectViaProject).toEqual(projectProfile); - expect(result.projectViaEmpty).toBeUndefined(); - }); - - it('should let project profile override global profile with same name', async () => { - const globalProfile: AgentProfile = { - name: 'shared', - description: 'Global version', - systemPrompt: 'Global system', - }; - const projectProfile: AgentProfile = { - name: 'shared', - description: 'Project version', - systemPrompt: 'Project system', - }; - - const result = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* SubagentService; - svc.registerGlobal([globalProfile]); - svc.registerProject('/project/a', [projectProfile]); - return { - fromProject: svc.get('/project/a', 'shared'), - fromGlobal: svc.get('', 'shared'), - }; - }).pipe(Effect.provide(SubagentService.Default)) - ); - - expect(result.fromProject?.description).toBe('Project version'); - expect(result.fromGlobal?.description).toBe('Global version'); - }); - - it('should return undefined for unknown profile', async () => { - const result = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* SubagentService; - return svc.get('', 'unknown-agent'); - }).pipe(Effect.provide(SubagentService.Default)) - ); - - expect(result).toBeUndefined(); - }); - - it('should support built-in explore profile', () => { - expect(EXPLORE_PROFILE.name).toBe('explore'); - expect(EXPLORE_PROFILE.readonly).toBe(true); - expect(EXPLORE_PROFILE.maxSteps).toBe(180); - expect(EXPLORE_PROFILE.tools).toContain('read_file'); - expect(EXPLORE_PROFILE.tools).toContain('search_files'); - expect(EXPLORE_PROFILE.tools).toContain('search_code'); - expect(EXPLORE_PROFILE.tools).toContain('fetch_url'); - expect(EXPLORE_PROFILE.tools).not.toContain('tool_search'); - }); - - it('explore profile systemPrompt includes guidelines', () => { - expect(EXPLORE_PROFILE.systemPrompt).toContain('Start broad, then narrow down'); - expect(EXPLORE_PROFILE.systemPrompt).toContain('Call multiple tools in parallel'); - expect(EXPLORE_PROFILE.systemPrompt).toContain('file_path:line_number'); - }); - - it('should support built-in plan profile', () => { - expect(PLAN_PROFILE.name).toBe('plan'); - // 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('fetch_url'); - expect(PLAN_PROFILE.tools).not.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', () => { - expect(PLAN_PROFILE.systemPrompt).toContain('Research process'); - expect(PLAN_PROFILE.systemPrompt).toContain('Output format'); - expect(PLAN_PROFILE.systemPrompt).toContain('Current state'); - expect(PLAN_PROFILE.systemPrompt).toContain('Key files'); - expect(PLAN_PROFILE.systemPrompt).toContain('Recommended approach'); - }); - - it('plan profile systemPrompt teaches the LLM how to interpret post-submit user messages', () => { - // The plan refactor moved user decisions onto plain user messages - // (no async JSON envelope). The LLM has to know how to read them so - // it does not re-call submit_plan on its own initiative and does not - // ignore a revised-plan body. - expect(PLAN_PROFILE.systemPrompt).toContain('After submit_plan'); - expect(PLAN_PROFILE.systemPrompt).toContain('Implement'); - expect(PLAN_PROFILE.systemPrompt).toContain('Cancel'); - expect(PLAN_PROFILE.systemPrompt).toContain('submit_plan'); - expect(PLAN_PROFILE.systemPrompt).toMatch(/proceed/i); - expect(PLAN_PROFILE.systemPrompt).toMatch(/revised plan/i); - }); - - it('should list profiles with project override', async () => { - const globalProfile: AgentProfile = { - name: 'agent1', - description: 'Global agent1', - systemPrompt: 'S1', - }; - const projectProfile: AgentProfile = { - name: 'agent1', - description: 'Project agent1', - systemPrompt: 'S1-project', - }; - const projectOnly: AgentProfile = { - name: 'agent2', - description: 'Project only', - systemPrompt: 'S2', - }; - - const all = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* SubagentService; - svc.registerGlobal([globalProfile]); - svc.registerProject('/project/a', [projectProfile, projectOnly]); - return svc.list('/project/a'); - }).pipe(Effect.provide(SubagentService.Default)) - ); - - expect(all.length).toBe(2); - expect(all.find((p) => p.name === 'agent1')?.description).toBe('Project agent1'); - expect(all.find((p) => p.name === 'agent2')?.description).toBe('Project only'); - }); - - it('should reset project registry without affecting global', async () => { - const result = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* SubagentService; - svc.registerGlobal([ - { - name: 'global-agent', - description: 'Global', - systemPrompt: 'G', - }, - ]); - svc.registerProject('/project/a', [ - { - name: 'project-agent', - description: 'Project', - systemPrompt: 'P', - }, - ]); - - expect(svc.get('/project/a', 'project-agent')).toBeDefined(); - expect(svc.get('/project/a', 'global-agent')).toBeDefined(); - - svc.resetProject('/project/a'); - - return { - projectAfterReset: svc.get('/project/a', 'project-agent'), - globalAfterReset: svc.get('/project/a', 'global-agent'), - }; - }).pipe(Effect.provide(SubagentService.Default)) - ); - - expect(result.projectAfterReset).toBeUndefined(); - expect(result.globalAfterReset).toBeDefined(); - }); - - it('list without project returns global profiles only', async () => { - const all = await Effect.runPromise( - Effect.gen(function* () { - const svc = yield* SubagentService; - svc.registerGlobal([ - { name: 'g1', description: 'Global 1', systemPrompt: 's1' }, - { name: 'g2', description: 'Global 2', systemPrompt: 's2' }, - ]); - svc.registerProject('/project/a', [ - { name: 'p1', description: 'Project 1', systemPrompt: 's3' }, - ]); - return { - globalList: svc.list(''), - projectList: svc.list('/project/a'), - }; - }).pipe(Effect.provide(SubagentService.Default)) - ); - - expect(all.globalList.length).toBe(2); - expect(all.projectList.length).toBe(3); - expect(all.globalList.some((p) => p.name === 'p1')).toBe(false); - 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/subagent/switch.test.ts b/packages/codingcode/test/subagent/switch.test.ts deleted file mode 100644 index ee9069a1..00000000 --- a/packages/codingcode/test/subagent/switch.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { - getSubagentEnabledState, - setSubagentEnabledState, - getProjectSubagentEnabledState, - setProjectSubagentEnabledState, - resetProjectSubagentEnabledState, - resolveSubagentEnabled, - getGlobalAgentDisabledState, - setGlobalAgentDisabledState, - getProjectAgentDisabledState, - setProjectAgentDisabledState, - resetProjectAgentDisabledState, - resolveAgentDisabled, -} from '../../src/subagent/registry.js'; -import { buildSystemPrompt } from '../../src/agent/prompt.js'; -import type { AgentProfile } from '../../src/subagent/types.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// 临时项目目录用于测试 -const TMP_PROJECT = join(__dirname, '__tmp_project_test__'); - -describe('Subagent switch', () => { - describe('Global enabled state', () => { - afterEach(() => { - setSubagentEnabledState(true); - }); - - it('should default to enabled', () => { - expect(getSubagentEnabledState()).toBe(true); - }); - - it('should persist disabled state', () => { - setSubagentEnabledState(false); - expect(getSubagentEnabledState()).toBe(false); - }); - - it('should persist enabled state', () => { - setSubagentEnabledState(false); - setSubagentEnabledState(true); - expect(getSubagentEnabledState()).toBe(true); - }); - }); - - describe('System prompt filtering', () => { - it('should filter out disabled agents from system prompt', () => { - const profiles: AgentProfile[] = [ - { name: 'enabled-agent', description: 'I am enabled', disabled: false }, - { name: 'disabled-agent', description: 'I am disabled', disabled: true }, - ]; - - const prompt = buildSystemPrompt({ - cwd: '/tmp', - platform: 'linux', - shell: 'bash', - agentProfiles: profiles, - }); - - expect(prompt).toContain('enabled-agent'); - expect(prompt).not.toContain('disabled-agent'); - }); - - it('should not inject Available Subagents when all agents are disabled', () => { - const profiles: AgentProfile[] = [ - { name: 'disabled-agent', description: 'I am disabled', disabled: true }, - ]; - - const prompt = buildSystemPrompt({ - cwd: '/tmp', - platform: 'linux', - shell: 'bash', - agentProfiles: profiles, - }); - - expect(prompt).not.toContain('Available Subagents'); - }); - - it('should inject Available Subagents when at least one agent is enabled', () => { - const profiles: AgentProfile[] = [ - { name: 'enabled-agent', description: 'I am enabled', disabled: false }, - { name: 'disabled-agent', description: 'I am disabled', disabled: true }, - ]; - - const prompt = buildSystemPrompt({ - cwd: '/tmp', - platform: 'linux', - shell: 'bash', - agentProfiles: profiles, - }); - - expect(prompt).toContain('Available Subagents'); - }); - - it('should not inject Available Subagents when no profiles provided', () => { - const prompt = buildSystemPrompt({ cwd: '/tmp', platform: 'linux', shell: 'bash' }); - - expect(prompt).not.toContain('Available Subagents'); - }); - - it('should not inject Available Subagents when subagent switch is off (empty profiles)', () => { - // Simulates agent.ts logic: when resolveSubagentEnabled is false, agentProfiles = [] - const prompt = buildSystemPrompt({ - cwd: '/tmp', - platform: 'linux', - shell: 'bash', - agentProfiles: [], - }); - - expect(prompt).not.toContain('Available Subagents'); - }); - - it('should filter out resolveAgentDisabled agents from system prompt', () => { - // Simulates agent.ts logic: allAgentProfiles.filter(p => !resolveAgentDisabled(projectPath, p.name)) - const allProfiles: AgentProfile[] = [ - { name: 'agent-a', description: 'Agent A' }, - { name: 'agent-b', description: 'Agent B' }, - ]; - // Simulate agent-b being disabled via resolveAgentDisabled - const filteredProfiles = allProfiles.filter((p) => p.name !== 'agent-b'); - - const prompt = buildSystemPrompt({ - cwd: '/tmp', - platform: 'linux', - shell: 'bash', - agentProfiles: filteredProfiles, - }); - - expect(prompt).toContain('agent-a'); - expect(prompt).not.toContain('agent-b'); - }); - }); -}); - -describe('Project-level subagent enabled state', () => { - beforeEach(() => { - // 创建临时项目目录 - mkdirSync(join(TMP_PROJECT, '.codingcode'), { recursive: true }); - // 确保全局开关为 true - setSubagentEnabledState(true); - }); - - afterEach(() => { - // 清理临时目录 - rmSync(TMP_PROJECT, { recursive: true, force: true }); - setSubagentEnabledState(true); - }); - - it('should return undefined when project has no config', () => { - expect(getProjectSubagentEnabledState(TMP_PROJECT)).toBe(undefined); - }); - - it('should persist project-level enabled state', () => { - setProjectSubagentEnabledState(TMP_PROJECT, false); - expect(getProjectSubagentEnabledState(TMP_PROJECT)).toBe(false); - }); - - it('should persist project-level enabled=true state', () => { - setProjectSubagentEnabledState(TMP_PROJECT, false); - setProjectSubagentEnabledState(TMP_PROJECT, true); - expect(getProjectSubagentEnabledState(TMP_PROJECT)).toBe(true); - }); - - it('should reset project-level state to undefined', () => { - setProjectSubagentEnabledState(TMP_PROJECT, false); - resetProjectSubagentEnabledState(TMP_PROJECT); - expect(getProjectSubagentEnabledState(TMP_PROJECT)).toBe(undefined); - }); - - it('resolveSubagentEnabled should use project-level when set', () => { - setSubagentEnabledState(true); // 全局开启 - setProjectSubagentEnabledState(TMP_PROJECT, false); // 项目级关闭 - expect(resolveSubagentEnabled(TMP_PROJECT)).toBe(false); - }); - - it('resolveSubagentEnabled should fall back to global when project not set', () => { - setSubagentEnabledState(false); // 全局关闭 - // 项目级未设置 - expect(resolveSubagentEnabled(TMP_PROJECT)).toBe(false); - }); - - it('resolveSubagentEnabled should use global when project config does not exist', () => { - setSubagentEnabledState(true); - const noConfigProject = join(__dirname, '__no_config__'); - try { - mkdirSync(noConfigProject, { recursive: true }); - expect(resolveSubagentEnabled(noConfigProject)).toBe(true); - } finally { - rmSync(noConfigProject, { recursive: true, force: true }); - } - }); -}); - -describe('Global agent disabled state', () => { - const testAgent = '__test_global_agent__'; - - afterEach(() => { - // 清理:重置全局 disabled 状态 - setGlobalAgentDisabledState(testAgent, false); - }); - - it('should default to not disabled', () => { - expect(getGlobalAgentDisabledState(testAgent)).toBe(false); - }); - - it('should persist disabled state', () => { - setGlobalAgentDisabledState(testAgent, true); - expect(getGlobalAgentDisabledState(testAgent)).toBe(true); - }); - - it('should persist re-enabled state', () => { - setGlobalAgentDisabledState(testAgent, true); - setGlobalAgentDisabledState(testAgent, false); - expect(getGlobalAgentDisabledState(testAgent)).toBe(false); - }); -}); - -describe('Project-level agent disabled state', () => { - const testAgent = '__test_project_agent__'; - - beforeEach(() => { - mkdirSync(join(TMP_PROJECT, '.codingcode'), { recursive: true }); - setGlobalAgentDisabledState(testAgent, false); - }); - - afterEach(() => { - rmSync(TMP_PROJECT, { recursive: true, force: true }); - setGlobalAgentDisabledState(testAgent, false); - }); - - it('should return undefined when project has no config', () => { - expect(getProjectAgentDisabledState(TMP_PROJECT, testAgent)).toBe(undefined); - }); - - it('should persist project-level disabled state', () => { - setProjectAgentDisabledState(TMP_PROJECT, testAgent, true); - expect(getProjectAgentDisabledState(TMP_PROJECT, testAgent)).toBe(true); - }); - - it('should reset project-level disabled state', () => { - setProjectAgentDisabledState(TMP_PROJECT, testAgent, true); - resetProjectAgentDisabledState(TMP_PROJECT, testAgent); - expect(getProjectAgentDisabledState(TMP_PROJECT, testAgent)).toBe(undefined); - }); - - it('resolveAgentDisabled should use project-level when set', () => { - setGlobalAgentDisabledState(testAgent, false); // 全局未禁用 - setProjectAgentDisabledState(TMP_PROJECT, testAgent, true); // 项目级禁用 - expect(resolveAgentDisabled(TMP_PROJECT, testAgent)).toBe(true); - }); - - it('resolveAgentDisabled should fall back to global when project not set', () => { - setGlobalAgentDisabledState(testAgent, true); // 全局禁用 - // 项目级未设置 - expect(resolveAgentDisabled(TMP_PROJECT, testAgent)).toBe(true); - }); - - it('resolveAgentDisabled should use project-level enabled over global disabled', () => { - setGlobalAgentDisabledState(testAgent, true); // 全局禁用 - setProjectAgentDisabledState(TMP_PROJECT, testAgent, false); // 项目级启用 - expect(resolveAgentDisabled(TMP_PROJECT, testAgent)).toBe(false); - }); - - it('resolveAgentDisabled should use global when project config does not exist', () => { - setGlobalAgentDisabledState(testAgent, false); - const noConfigProject = join(__dirname, '__no_config_agent__'); - try { - mkdirSync(noConfigProject, { recursive: true }); - expect(resolveAgentDisabled(noConfigProject, testAgent)).toBe(false); - } finally { - rmSync(noConfigProject, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index a93c8ee5..a67584ec 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -196,55 +196,6 @@ export function deleteMcpServer(cwd: string | undefined, name: string): Promise< return clients.settings.deleteMcpServer({ cwd: cwd ?? '', name }); } -// ---- Settings: Agents ---- - -export function listAgents(cwd?: string): Promise { - return clients.settings.listAgents({ cwd: cwd ?? '' }); -} - -export function setAgentDisabled(name: string, disabled: boolean, cwd?: string): Promise { - return clients.settings.setAgentDisabled({ name, disabled, cwd: cwd ?? '' }); -} - -export function resetAgentDisabled(name: string, cwd: string): Promise { - return clients.settings.resetAgentDisabled({ name, cwd }); -} - -export function createAgent( - cwd: string | undefined, - profile: Record -): Promise { - return clients.settings.createAgent({ cwd: cwd ?? '', profile: profile as any }); -} - -export function updateAgent( - cwd: string | undefined, - name: string, - profile: Record -): Promise { - return clients.settings.updateAgent({ cwd: cwd ?? '', name, profile: profile as any }); -} - -export function deleteAgent(cwd: string | undefined, name: string): Promise { - return clients.settings.deleteAgent({ cwd: cwd ?? '', name }); -} - -// ---- Settings: Subagent enabled ---- - -export async function getSubagentEnabled( - cwd?: string -): Promise<{ enabled: boolean; source: string }> { - return clients.settings.getSubagentEnabled({ cwd: cwd ?? '' }); -} - -export function setSubagentEnabled(enabled: boolean, cwd?: string): Promise { - return clients.settings.setSubagentEnabled({ enabled, cwd: cwd ?? '' }); -} - -export function resetSubagentEnabled(cwd: string): Promise { - return clients.settings.resetSubagentEnabled({ cwd }); -} - // ---- Settings: Skills ---- export function listSkills( diff --git a/packages/desktop/src/settings/GlobalSettingsPage.tsx b/packages/desktop/src/settings/GlobalSettingsPage.tsx index b145f38c..09d6739d 100644 --- a/packages/desktop/src/settings/GlobalSettingsPage.tsx +++ b/packages/desktop/src/settings/GlobalSettingsPage.tsx @@ -3,18 +3,16 @@ import { useUIStore } from '../stores/ui.store'; import { useState } from 'react'; import McpPanel from './McpPanel'; import HooksPanel from './HooksPanel'; -import SubagentsPanel from './SubagentsPanel'; import SkillPanel from './SkillPanel'; import AgentConfigPanel from './AgentConfigPanel'; -type Section = 'theme' | 'mcp' | 'hooks' | 'agents' | 'skills' | 'agent-config'; +type Section = 'theme' | 'mcp' | 'hooks' | 'skills' | 'agent-config'; const NAV_ITEMS: { id: Section; label: string }[] = [ { id: 'theme', label: '主题' }, { id: 'agent-config', label: '执行配置' }, { id: 'mcp', label: 'MCP 服务器' }, { id: 'hooks', label: '钩子' }, - { id: 'agents', label: '子智能体' }, { id: 'skills', label: 'Skills' }, ]; @@ -89,7 +87,6 @@ export default function GlobalSettingsPage() { {section === 'agent-config' && } {section === 'mcp' && } {section === 'hooks' && } - {section === 'agents' && } {section === 'skills' && } diff --git a/packages/desktop/src/settings/McpPanel.tsx b/packages/desktop/src/settings/McpPanel.tsx index 55a4884e..71d885f7 100644 --- a/packages/desktop/src/settings/McpPanel.tsx +++ b/packages/desktop/src/settings/McpPanel.tsx @@ -8,7 +8,6 @@ import { createMcpServer, updateMcpServer, deleteMcpServer, - listAgents, } from '../lib/core-api'; interface McpEntry { @@ -141,21 +140,6 @@ export default function McpPanel({ global: isGlobal }: { global?: boolean }) { if (isCreating) { await createMcpServer(cwd, server); } else if (editingName) { - if (editingName !== form.name) { - const agents = await listAgents(cwd); - const dependent = agents.filter((a: { mcpServers?: string[] }) => - a.mcpServers?.includes(editingName) - ); - if (dependent.length > 0) { - const names = dependent.map((a: { name: string }) => a.name).join(', '); - if ( - !confirm( - `以下智能体引用了此 MCP 服务器:${names}\n重命名后需要手动更新它们的配置。是否继续?` - ) - ) - return; - } - } await updateMcpServer(cwd, editingName, server); } cancelForm(); @@ -168,19 +152,6 @@ export default function McpPanel({ global: isGlobal }: { global?: boolean }) { const confirmDelete = async () => { if (!deletingName) return; try { - const agents = await listAgents(cwd); - const dependent = agents.filter((a: { mcpServers?: string[] }) => - a.mcpServers?.includes(deletingName) - ); - if (dependent.length > 0) { - const names = dependent.map((a: { name: string }) => a.name).join(', '); - if ( - !confirm( - `以下智能体引用了此 MCP 服务器:${names}\n删除后需要手动更新它们的配置。是否继续?` - ) - ) - return; - } await deleteMcpServer(cwd, deletingName); setDeletingName(null); await load(); diff --git a/packages/desktop/src/settings/ProjectSettingsPage.tsx b/packages/desktop/src/settings/ProjectSettingsPage.tsx index f5b69a7e..cf5fb4e9 100644 --- a/packages/desktop/src/settings/ProjectSettingsPage.tsx +++ b/packages/desktop/src/settings/ProjectSettingsPage.tsx @@ -3,16 +3,14 @@ import { useUIStore } from '../stores/ui.store'; import { useState } from 'react'; import McpPanel from './McpPanel'; import HooksPanel from './HooksPanel'; -import SubagentsPanel from './SubagentsPanel'; import SkillPanel from './SkillPanel'; import MemoryPanel from './MemoryPanel'; -type Section = 'mcp' | 'hooks' | 'agents' | 'skills' | 'memory'; +type Section = 'mcp' | 'hooks' | 'skills' | 'memory'; const NAV_ITEMS: { id: Section; label: string }[] = [ { id: 'mcp', label: 'MCP 服务器' }, { id: 'hooks', label: '钩子' }, - { id: 'agents', label: '子智能体' }, { id: 'skills', label: 'Skills' }, { id: 'memory', label: '记忆模式' }, ]; @@ -58,7 +56,6 @@ export default function ProjectSettingsPage() {
{section === 'mcp' && } {section === 'hooks' && } - {section === 'agents' && } {section === 'skills' && } {section === 'memory' && }
diff --git a/packages/desktop/src/settings/SubagentsPanel.tsx b/packages/desktop/src/settings/SubagentsPanel.tsx deleted file mode 100644 index 47a6c4bc..00000000 --- a/packages/desktop/src/settings/SubagentsPanel.tsx +++ /dev/null @@ -1,691 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; -import Toggle from './Toggle'; -import { useWorkspaceStore } from '../stores/workspace.store'; -import { - listAgents, - getSubagentEnabled, - setSubagentEnabled, - setAgentDisabled, - resetSubagentEnabled, - resetAgentDisabled, - createAgent, - updateAgent, - deleteAgent, - listMcpServers, -} from '../lib/core-api'; -import type { ModelEntry } from '../stores/agent.store'; - -const AVAILABLE_TOOLS = [ - 'read_file', - 'write_file', - 'edit_file', - 'execute_command', - 'search_code', - 'search_files', - 'fetch_url', - 'web_search', - 'todo_write', - 'dispatch_agent', -]; - -interface AgentEntry { - name: string; - description: string; - systemPrompt?: string; - tools?: string[]; - mcpServers?: string[]; - readonly?: boolean; - maxSteps?: number; - model?: string; - disabled?: boolean; - source?: 'builtin' | 'global' | 'project'; - hasProjectOverride?: boolean; -} - -interface AgentForm { - name: string; - description: string; - systemPrompt: string; - tools: string[]; - mcpServers: string[]; - readonly: boolean; - maxSteps: string; - model: string; -} - -const EMPTY_FORM: AgentForm = { - name: '', - description: '', - systemPrompt: '', - tools: [], - mcpServers: [], - readonly: false, - maxSteps: '', - model: '', -}; - -export default function SubagentsPanel({ global: isGlobal }: { global?: boolean }) { - const [agents, setAgents] = useState([]); - const [enabled, setEnabled] = useState(true); - const [enabledSource, setEnabledSource] = useState<'global' | 'project'>('global'); - const [loading, setLoading] = useState(true); - const [models, setModels] = useState([]); - const [mcpList, setMcpList] = useState([]); - const [isCreating, setIsCreating] = useState(false); - const [editingName, setEditingName] = useState(null); - const [deletingName, setDeletingName] = useState(null); - const [form, setForm] = useState(EMPTY_FORM); - const rootPath = useWorkspaceStore((s) => s.rootPath); - const cwd = isGlobal ? undefined : rootPath; - - const load = async () => { - setLoading(true); - try { - const [agentsData, enabledData, modelData, mcpData] = await Promise.all([ - listAgents(cwd), - getSubagentEnabled(cwd), - import('../lib/core-api').then((m) => m.listModels()), - listMcpServers(), - ]); - setAgents(agentsData ?? []); - setEnabled(enabledData.enabled ?? true); - setEnabledSource((enabledData.source as 'global' | 'project') ?? 'global'); - setModels((modelData.models ?? []) as ModelEntry[]); - setMcpList((mcpData ?? []).map((s: { name: string }) => s.name)); - } catch { - setAgents([]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - load(); - }, [rootPath]); - - const toggleEnabled = async (v: boolean) => { - await setSubagentEnabled(v, cwd); - setEnabled(v); - setEnabledSource(isGlobal ? 'global' : 'project'); - }; - - const resetEnabled = async () => { - if (!cwd) return; - await resetSubagentEnabled(cwd); - await load(); - }; - - const toggleAgent = async (name: string, disabled: boolean) => { - await setAgentDisabled(name, disabled, cwd); - setAgents((prev) => prev.map((a) => (a.name === name ? { ...a, disabled } : a))); - }; - - const startCreate = () => { - setForm(EMPTY_FORM); - setIsCreating(true); - setEditingName(null); - setDeletingName(null); - }; - - const startEdit = (a: AgentEntry) => { - setForm({ - name: a.name, - description: a.description, - systemPrompt: a.systemPrompt ?? '', - tools: a.tools ?? [], - mcpServers: a.mcpServers ?? [], - readonly: a.readonly ?? false, - maxSteps: a.maxSteps?.toString() ?? '', - model: a.model ?? '', - }); - setEditingName(a.name); - setIsCreating(false); - setDeletingName(null); - }; - - const cancelForm = () => { - setIsCreating(false); - setEditingName(null); - }; - - const saveForm = async () => { - const profile: Record = { - name: form.name, - description: form.description, - systemPrompt: form.systemPrompt, - }; - if (form.tools.length > 0) profile.tools = form.tools; - if (form.mcpServers.length > 0) profile.mcpServers = form.mcpServers; - if (form.readonly) profile.readonly = true; - if (form.maxSteps.trim()) profile.maxSteps = Number(form.maxSteps); - if (form.model.trim()) profile.model = form.model; - - try { - if (isCreating) { - await createAgent(cwd, profile); - } else if (editingName) { - await updateAgent(cwd, editingName, profile); - } - cancelForm(); - await load(); - } catch (e: any) { - alert(e.message ?? '操作失败'); - } - }; - - const confirmDelete = async () => { - if (!deletingName) return; - try { - await deleteAgent(cwd, deletingName); - setDeletingName(null); - await load(); - } catch (e: any) { - alert(e.message ?? '删除失败'); - } - }; - - const inputCls = - 'w-full bg-[var(--bg-hover)] border border-[var(--border-hover)] text-[var(--text-title)] px-3 py-2 rounded text-[13px] focus:outline-none focus:ring-1 focus:ring-[var(--accent-primary)]'; - const labelCls = 'text-[12px] text-[var(--text-placeholder)] mb-1'; - const btnPrimary = - 'px-4 py-2 rounded text-[13px] bg-[var(--btn-primary-bg)] text-[var(--accent-primary)] hover:bg-[var(--btn-primary-hover)]'; - const btnDanger = - 'px-4 py-2 rounded text-[13px] bg-[var(--btn-danger-bg)] text-[var(--accent-danger)] hover:bg-[var(--btn-danger-hover)]'; - const btnCancel = - 'px-4 py-2 rounded text-[13px] bg-[var(--border-card)] text-[var(--text-tertiary)] border border-[var(--border-hover)] hover:bg-[var(--border-hover)] hover:border-[var(--border-strong)]'; - - if (loading) { - return
加载中…
; - } - - return ( -
-
-
-
启用子智能体
-
- 允许 agent 派发子任务给子智能体 - {!isGlobal && enabledSource === 'project' ? '(项目级覆盖)' : ''} -
-
-
- {!isGlobal && enabledSource === 'project' && ( - - )} - -
-
- -
-
- 已注册的子智能体 -
- -
- - {isCreating && ( - - )} - - {agents.length === 0 && !isCreating ? ( -
- 未找到子智能体配置 -
- 点击上方按钮添加 -
- ) : ( -
- {agents.map((a) => { - if (editingName === a.name) { - return ( - - ); - } - if (deletingName === a.name) { - return ( -
- - 删除智能体 {a.name}? - -
- - -
-
- ); - } - const canMutate = a.source === (isGlobal ? 'global' : 'project'); - return ( -
-
-
-
- {a.name} - {a.readonly && ( - - 只读 - - )} - {a.model && ( - - {a.model} - - )} - {a.source === 'builtin' && ( - - 内置 - - )} - {a.source === 'global' && ( - - 全局 - - )} - {a.source === 'project' && ( - - 项目 - - )} - {a.hasProjectOverride && ( - - 覆盖全局 - - )} -
-
- {a.description} -
- {a.tools && a.tools.length > 0 && ( -
- {a.tools.map((t) => ( - - {t} - - ))} -
- )} - {a.mcpServers && a.mcpServers.length > 0 && ( -
- {a.mcpServers.map((s) => ( - - {s} - - ))} -
- )} -
-
- {canMutate && ( - <> - - - - )} - toggleAgent(a.name, !v)} /> - {a.maxSteps !== undefined && ( - - {a.maxSteps} 步 - - )} -
-
-
- ); - })} -
- )} -
- ); -} - -function ToolMultiSelect({ - selected, - onChange, - inputCls, -}: { - selected: string[]; - onChange: (tools: string[]) => void; - inputCls: string; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); - - const toggle = (tool: string) => { - if (selected.includes(tool)) onChange(selected.filter((t) => t !== tool)); - else onChange([...selected, tool]); - }; - - return ( -
- - {open && ( -
- {AVAILABLE_TOOLS.map((tool) => ( - - ))} -
- )} -
- ); -} - -function McpMultiSelect({ - selected, - onChange, - availableMcpServers, - inputCls, -}: { - selected: string[]; - onChange: (servers: string[]) => void; - availableMcpServers: string[]; - inputCls: string; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); - - const toggle = (server: string) => { - if (selected.includes(server)) onChange(selected.filter((s) => s !== server)); - else onChange([...selected, server]); - }; - - return ( -
- - {open && ( -
- {availableMcpServers.length === 0 ? ( -
- 无已配置的 MCP 服务器 -
- ) : ( - availableMcpServers.map((server) => ( - - )) - )} -
- )} -
- ); -} - -function FormCard({ - form, - setForm, - models, - mcpList, - onSave, - onCancel, - inputCls, - labelCls, - btnPrimary, - btnCancel, -}: { - form: AgentForm; - setForm: (f: AgentForm) => void; - models: ModelEntry[]; - mcpList: string[]; - onSave: () => void; - onCancel: () => void; - inputCls: string; - labelCls: string; - btnPrimary: string; - btnCancel: string; -}) { - const modelGroups = models.reduce>((acc, m) => { - if (!acc[m.provider]) acc[m.provider] = []; - acc[m.provider]!.push(m); - return acc; - }, {}); - return ( -
-
-
名称
- setForm({ ...form, name: e.target.value })} - /> -
-
-
描述
- setForm({ ...form, description: e.target.value })} - /> -
-
-
系统提示词 (systemPrompt)
-