diff --git a/src/agents/dynamic-agent-core-sections.ts b/src/agents/dynamic-agent-core-sections.ts index 69742ff16..1f91c7a78 100644 --- a/src/agents/dynamic-agent-core-sections.ts +++ b/src/agents/dynamic-agent-core-sections.ts @@ -196,7 +196,7 @@ export function buildNonClaudePlannerSection(model: string): string { Multi-step task? **ALWAYS consult Plan Agent first.** Do NOT start implementation without a plan. - Single-file fix or trivial change → proceed directly -- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="prometheus", ...)\` FIRST +- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="plan", ...)\` FIRST - Use \`task_id\` to resume the same Plan Agent - ask follow-up questions aggressively - If ANY part of the task is ambiguous, ask Plan Agent before guessing diff --git a/src/agents/sisyphus/gpt-5-4.ts b/src/agents/sisyphus/gpt-5-4.ts index 5f5e5bc2c..964087f13 100644 --- a/src/agents/sisyphus/gpt-5-4.ts +++ b/src/agents/sisyphus/gpt-5-4.ts @@ -288,7 +288,7 @@ Every implementation task follows this cycle. No exceptions. Follow \`\` protocol for tool usage and agent prompts. 2. PLAN - List files to modify, specific changes, dependencies, complexity estimate. - Multi-step (2+) → consult Plan Agent via \`task(subagent_type="prometheus", ...)\`. + Multi-step (2+) → consult Plan Agent via \`task(subagent_type="plan", ...)\`. Single-step → mental plan is sufficient. diff --git a/src/features/team-mode/team-registry/validator.test.ts b/src/features/team-mode/team-registry/validator.test.ts index ffc9ad263..12d64015c 100644 --- a/src/features/team-mode/team-registry/validator.test.ts +++ b/src/features/team-mode/team-registry/validator.test.ts @@ -13,7 +13,7 @@ import { } from "./validator" const PROMETHEUS_REJECTION_MESSAGE = - "Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead." + "Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use delegate-task with subagent_type: 'plan' instead." function createCategoryMember(name: string): Member { return { diff --git a/src/features/team-mode/types.test.ts b/src/features/team-mode/types.test.ts index 1d944eec0..0a047a930 100644 --- a/src/features/team-mode/types.test.ts +++ b/src/features/team-mode/types.test.ts @@ -136,7 +136,7 @@ describe("team-mode types", () => { ], [ "prometheus", - "Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead.", + "Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use delegate-task with subagent_type: 'plan' instead.", ], ] as const @@ -286,7 +286,7 @@ describe("team-mode types", () => { "Agent 'momus' is read-only (plan reviewer). Cannot write to mailbox as team member. Use delegate-task for plan review instead.", ) expect(AGENT_ELIGIBILITY_REGISTRY.prometheus.rejectionMessage).toBe( - "Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead.", + "Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use delegate-task with subagent_type: 'plan' instead.", ) expect(CategoryMemberSchema).toBeDefined() expect(SubagentMemberSchema).toBeDefined() diff --git a/src/features/team-mode/types.ts b/src/features/team-mode/types.ts index 21f7a0d6a..a218cf3c0 100644 --- a/src/features/team-mode/types.ts +++ b/src/features/team-mode/types.ts @@ -229,7 +229,7 @@ export const AGENT_ELIGIBILITY_REGISTRY: Readonly { expect(result.categoryModel).toBeUndefined() }) + test("preserves hidden sort-prefixed plan agent model instead of using fallback", async () => { + //#given + readProviderModelsCacheMock.mockReturnValue({ + models: { anthropic: ["claude-opus-4-7"] }, + connected: ["anthropic"], + updatedAt: "2026-03-03T00:00:00.000Z", + }) + const args = createBaseArgs({ subagent_type: "plan" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "1|plan", mode: "subagent", hidden: true, model: "anthropic/claude-opus-4-7" }, + { name: "oracle", mode: "subagent" }, + ])) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.error).toBeUndefined() + expect(result.agentToUse).toBe("plan") + expect(result.categoryModel).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" }) + }) + + test("allows OpenCode-hidden-list plan fallback when planner_enabled and replace_plan are true", async () => { + //#given + const args = createBaseArgs({ subagent_type: "plan" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "oracle", mode: "subagent" }, + ]), { + sisyphusAgentConfig: { + planner_enabled: true, + replace_plan: true, + }, + }) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.error).toBeUndefined() + expect(result.agentToUse).toBe("plan") + expect(result.categoryModel).toBeUndefined() + }) + + test.each([ + { loader: "user", aliasName: "plan" }, + { loader: "user", aliasName: '"plan"' }, + { loader: "project", aliasName: "plan" }, + { loader: "project", aliasName: '"plan"' }, + ])( + "uses built-in hidden plan fallback when a $loader $aliasName alias exists", + async ({ loader, aliasName }) => { + //#given + readProviderModelsCacheMock.mockReturnValue({ + models: { openai: ["gpt-5.3-codex"] }, + connected: ["openai"], + updatedAt: "2026-03-03T00:00:00.000Z", + }) + + loadUserAgentsMock.mockImplementation(() => { + if (loader === "user") { + return { + [aliasName]: { + description: "Colliding plan alias from user agents", + mode: "subagent", + model: "openai/gpt-5.3-codex", + }, + } satisfies ClaudeCodeAgentRecord + } + return {} + }) + + loadProjectAgentsMock.mockImplementation(() => { + if (loader === "project") { + return { + [aliasName]: { + description: "Colliding plan alias from project agents", + mode: "subagent", + model: "openai/gpt-5.3-codex", + }, + } satisfies ClaudeCodeAgentRecord + } + return {} + }) + + const args = createBaseArgs({ subagent_type: "plan" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "oracle", mode: "subagent" }, + ]), { + sisyphusAgentConfig: { + planner_enabled: true, + replace_plan: true, + }, + }) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.error).toBeUndefined() + expect(result.agentToUse).toBe("plan") + expect(result.categoryModel).toBeUndefined() + }, + ) + + test.each([ + { loader: "user", aliasName: "plan" }, + { loader: "user", aliasName: '"plan"' }, + { loader: "project", aliasName: "plan" }, + { loader: "project", aliasName: '"plan"' }, + ])( + "uses built-in hidden plan fallback when a $loader primary $aliasName alias exists", + async ({ loader, aliasName }) => { + //#given + loadUserAgentsMock.mockImplementation(() => { + if (loader === "user") { + return { + [aliasName]: { + description: "Colliding primary plan alias from user agents", + mode: "primary", + model: "openai/gpt-5.3-codex", + }, + } satisfies ClaudeCodeAgentRecord + } + return {} + }) + + loadProjectAgentsMock.mockImplementation(() => { + if (loader === "project") { + return { + [aliasName]: { + description: "Colliding primary plan alias from project agents", + mode: "primary", + model: "openai/gpt-5.3-codex", + }, + } satisfies ClaudeCodeAgentRecord + } + return {} + }) + + const args = createBaseArgs({ subagent_type: "plan" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "oracle", mode: "subagent" }, + ]), { + sisyphusAgentConfig: { + planner_enabled: true, + replace_plan: true, + }, + }) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.error).toBeUndefined() + expect(result.agentToUse).toBe("plan") + expect(result.categoryModel).toBeUndefined() + }, + ) + + test.each([ + { loader: "user", aliasName: "build" }, + { loader: "user", aliasName: '"build"' }, + { loader: "user", aliasName: "1|build" }, + { loader: "user", aliasName: "\u200Bbuild" }, + { loader: "project", aliasName: "build" }, + { loader: "project", aliasName: '"build"' }, + { loader: "project", aliasName: "1|build" }, + { loader: "project", aliasName: "\u200Bbuild" }, + ])( + "rejects omitted hidden build when a $loader $aliasName alias exists", + async ({ loader, aliasName }) => { + //#given + loadUserAgentsMock.mockImplementation(() => { + if (loader === "user") { + return { + [aliasName]: { + description: "Colliding hidden build alias from user agents", + mode: "subagent", + model: "openai/gpt-5.3-codex", + }, + } satisfies ClaudeCodeAgentRecord + } + return {} + }) + + loadProjectAgentsMock.mockImplementation(() => { + if (loader === "project") { + return { + [aliasName]: { + description: "Colliding hidden build alias from project agents", + mode: "subagent", + model: "openai/gpt-5.3-codex", + }, + } satisfies ClaudeCodeAgentRecord + } + return {} + }) + + const args = createBaseArgs({ subagent_type: "build" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "oracle", mode: "subagent" }, + ])) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.agentToUse).toBe("") + expect(result.categoryModel).toBeUndefined() + expect(result.error).toBe('Unknown agent: "build". Available agents: oracle') + }, + ) + + test("preserves a visible server plan agent instead of using fallback", async () => { + //#given + readProviderModelsCacheMock.mockReturnValue({ + models: { openai: ["gpt-5.3-codex"] }, + connected: ["openai"], + updatedAt: "2026-03-03T00:00:00.000Z", + }) + const args = createBaseArgs({ subagent_type: "plan" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "plan", mode: "subagent", model: "openai/gpt-5.3-codex" }, + { name: "oracle", mode: "subagent" }, + ]), { + sisyphusAgentConfig: { + planner_enabled: true, + replace_plan: true, + }, + }) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.error).toBeUndefined() + expect(result.agentToUse).toBe("plan") + expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" }) + }) + + test.each([ + [{ planner_enabled: false, replace_plan: true }], + [{ planner_enabled: true, replace_plan: false }], + ])( + "does not allow hidden plan fallback when planner config blocks replacement (%j)", + async (sisyphusAgentConfig) => { + //#given + const args = createBaseArgs({ subagent_type: "plan" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "oracle", mode: "subagent" }, + ]), { + sisyphusAgentConfig, + }) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.agentToUse).toBe("") + expect(result.categoryModel).toBeUndefined() + expect(result.error).toBe('Unknown agent: "plan". Available agents: oracle') + }, + ) + test("hidden agents are excluded from error hints except callable demoted plan", async () => { //#given const args = createBaseArgs({ subagent_type: "nonexistent" })