From 7bb48c1503f5ae0861e614e46a3caeb6db223d14 Mon Sep 17 00:00:00 2001 From: lewis617 Date: Mon, 1 Jun 2026 13:23:55 +0800 Subject: [PATCH] feat: inject plan mode as system-reminder messages instead of system prompt Move plan mode instructions from buildSystemPrompt() into transient user messages injected via buildPlanModeMessages() in AIManager. This preserves the cached system prompt prefix across mode transitions, matching Claude Code's attachment pattern. Key changes: - Add planModeReminders.ts with full/sparse/re-entry/exit reminders - Add hasExitedPlanMode and needsPlanModeExitAttachment flags to PermissionManager - Inject plan mode reminders every 5 human turns (full every 5th, sparse otherwise) with override language - Inject re-entry guidance when re-entering plan mode with existing plan file (one-time) - Inject exit notification when leaving plan mode (one-time) - Set flags in exitPlanMode.ts and planManager.ts on mode transitions - Remove planMode option from buildSystemPrompt() - Re-inject full plan mode reminder after compaction - Update specs 050, 021, 014, 022 with new user stories and FRs --- packages/agent-sdk/src/managers/aiManager.ts | 146 +++++++++++++++--- .../src/managers/permissionManager.ts | 18 +++ .../agent-sdk/src/managers/planManager.ts | 11 ++ packages/agent-sdk/src/prompts/index.ts | 8 - .../src/prompts/planModeReminders.ts | 135 ++++++++++++++++ packages/agent-sdk/src/tools/exitPlanMode.ts | 3 + .../integration/planMode.integration.test.ts | 22 ++- .../subagentPlanMode.integration.test.ts | 60 ++++--- .../tests/managers/aiManager.coverage.test.ts | 4 + .../aiManager.latestTotalTokens.test.ts | 4 + .../tests/managers/aiManager.maxTurns.test.ts | 4 + .../tests/managers/aiManager.plan.test.ts | 40 ++++- .../tests/managers/aiManager.test.ts | 64 ++++++++ .../managers/aiManager_duplicateTool.test.ts | 4 + .../managers/aiManager_finishReason.test.ts | 4 + .../agent-sdk/tests/prompts/prompts.test.ts | 8 +- .../tests/tools/exitPlanMode.test.ts | 58 ++++--- specs/014-message-compression/spec.md | 20 ++- specs/021-prompt-cache-control/spec.md | 19 ++- specs/022-prompt-engineering/spec.md | 58 +++++++ specs/050-plan-mode/spec.md | 71 ++++++++- specs/README.md | 22 +-- 22 files changed, 675 insertions(+), 108 deletions(-) create mode 100644 packages/agent-sdk/src/prompts/planModeReminders.ts diff --git a/packages/agent-sdk/src/managers/aiManager.ts b/packages/agent-sdk/src/managers/aiManager.ts index 07a8aef68..48726879a 100644 --- a/packages/agent-sdk/src/managers/aiManager.ts +++ b/packages/agent-sdk/src/managers/aiManager.ts @@ -5,6 +5,7 @@ import { microcompactMessages } from "../utils/microcompact.js"; import { parseTaskNotificationXml } from "../utils/notificationXml.js"; import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js"; import * as fs from "node:fs/promises"; +import { existsSync } from "node:fs"; import type { GatewayConfig, ModelConfig, @@ -23,6 +24,12 @@ import type { PermissionManager } from "./permissionManager.js"; import type { SubagentManager } from "./subagentManager.js"; import type { SkillManager } from "./skillManager.js"; import { buildSystemPrompt } from "../prompts/index.js"; +import { + buildPlanModeReminder, + buildPlanModeSparseReminder, + buildPlanModeReEntryReminder, + buildExitedPlanModeReminder, +} from "../prompts/planModeReminders.js"; import { Container } from "../utils/container.js"; import { recoverTruncatedJson } from "../utils/stringUtils.js"; import { ConfigurationService } from "../services/configurationService.js"; @@ -217,6 +224,119 @@ export class AIManager { }); } + /** + * Build plan mode system-reminder messages to inject into the API message stream. + * These are transient messages not stored in the message history. + * This preserves prompt caching by keeping the system prompt constant. + */ + private buildPlanModeMessages( + currentMode: PermissionMode | undefined, + ): import("openai/resources.js").ChatCompletionMessageParam[] { + const messages: import("openai/resources.js").ChatCompletionMessageParam[] = + []; + if (!this.permissionManager) return messages; + + // Handle exit notification (one-time after leaving plan mode) + if (this.permissionManager.getNeedsPlanModeExitAttachment()) { + const planFilePath = this.permissionManager.getPlanFilePath(); + const planExists = planFilePath ? existsSync(planFilePath) : false; + messages.push({ + role: "user", + content: buildExitedPlanModeReminder(planFilePath, planExists), + }); + this.permissionManager.setNeedsPlanModeExitAttachment(false); + } + + // Handle plan mode reminders + if (currentMode !== "plan") return messages; + + const planFilePath = this.permissionManager.getPlanFilePath(); + if (!planFilePath) return messages; + + const planExists = existsSync(planFilePath); + + // Check for re-entry: flag is set AND plan file exists + if (this.permissionManager.hasExitedPlanModeInSession() && planExists) { + messages.push({ + role: "user", + content: buildPlanModeReEntryReminder(planFilePath), + }); + this.permissionManager.setHasExitedPlanMode(false); // One-time + } + + // Count plan_mode system-reminders in existing messages to determine full vs sparse + // and count human turns since last reminder for throttling + const recentApiMessages = this.messageManager.getMessages(); + let planModeReminderCount = 0; + let humanTurnsSinceLastReminder = 0; + let foundLastReminder = false; + + for (let i = recentApiMessages.length - 1; i >= 0; i--) { + const msg = recentApiMessages[i]; + if (msg.role === "user" && !msg.isMeta) { + // Count human turns (non-meta user messages without tool results) + const hasToolResult = msg.blocks?.some( + (b: { type: string }) => b.type === "tool", + ); + if (!hasToolResult) { + if (!foundLastReminder) { + humanTurnsSinceLastReminder++; + } + } + } + // Check for existing plan mode system-reminders + if (msg.role === "user" && msg.isMeta) { + const textContent = msg.blocks + ?.filter((b) => b.type === "text") + .map((b) => ("content" in b ? b.content : "")) + .join(""); + if ( + textContent?.includes("Plan mode is active") || + textContent?.includes("Plan mode still active") + ) { + planModeReminderCount++; + if (!foundLastReminder) { + foundLastReminder = true; + } + } + } + } + + // Throttle: only inject every 5 human turns (but always inject on first turn) + const TURNS_BETWEEN_REMINDERS = 5; + const FULL_REMINDER_EVERY_N = 5; + + if ( + foundLastReminder && + humanTurnsSinceLastReminder < TURNS_BETWEEN_REMINDERS + ) { + return messages; // Throttled — skip reminder + } + + // Determine full vs sparse + // Every 5th reminder is full; rest are sparse + const reminderNumber = planModeReminderCount + 1; + const isFull = reminderNumber % FULL_REMINDER_EVERY_N === 1; + + if (isFull) { + messages.push({ + role: "user", + content: buildPlanModeReminder( + planFilePath, + planExists, + !!this.subagentType, + ), + }); + } else { + messages.push({ + role: "user", + content: buildPlanModeSparseReminder(planFilePath), + }); + } + + return messages; + } + public setIsLoading(isLoading: boolean): void { this.isLoading = isLoading; this.onLoadingChange?.(isLoading); @@ -377,8 +497,10 @@ export class AIManager { } catch { // Plan file doesn't exist yet } + // Inject full plan mode system-reminder after compaction + // so the model retains plan mode constraints and instructions contextParts.push( - `\n\n[Plan Mode]\nYou are in plan mode. Plan file: ${planFilePath} (exists: ${planExists})`, + `\n\n${buildPlanModeReminder(planFilePath, planExists, !!this.subagentType)}`, ); } } @@ -685,22 +807,11 @@ export class AIManager { .getTools() .filter((t) => toolNames.has(t.name)); - let planModeOptions: - | { planFilePath: string; planExists: boolean } - | undefined; - - if (currentMode === "plan") { - const planFilePath = this.permissionManager?.getPlanFilePath(); - if (planFilePath) { - let planExists = false; - try { - await fs.access(planFilePath); - planExists = true; - } catch { - planExists = false; - } - planModeOptions = { planFilePath, planExists }; - } + // Inject plan mode system-reminder messages (not system prompt) + // This preserves prompt caching by keeping the system prompt constant + const planModeMessages = this.buildPlanModeMessages(currentMode); + if (planModeMessages.length > 0) { + recentMessages.push(...planModeMessages); } let autoMemoryOptions: { directory: string; content: string } | undefined; @@ -733,7 +844,6 @@ export class AIManager { memory: combinedMemory, language: this.getLanguage(), isSubagent: !!this.subagentType, - planMode: planModeOptions, autoMemory: autoMemoryOptions, permissionMode: currentMode, }, diff --git a/packages/agent-sdk/src/managers/permissionManager.ts b/packages/agent-sdk/src/managers/permissionManager.ts index c9e7b6485..39a3ff8ff 100644 --- a/packages/agent-sdk/src/managers/permissionManager.ts +++ b/packages/agent-sdk/src/managers/permissionManager.ts @@ -129,6 +129,8 @@ export class PermissionManager { private additionalDirectories: string[] = []; private systemAdditionalDirectories: string[] = []; private planFilePath?: string; + private hasExitedPlanMode: boolean = false; + private needsPlanModeExitAttachment: boolean = false; private workdir?: string; private worktreeName?: string; private mainRepoRoot?: string; @@ -315,6 +317,22 @@ export class PermissionManager { return this.planFilePath; } + public setHasExitedPlanMode(value: boolean): void { + this.hasExitedPlanMode = value; + } + + public hasExitedPlanModeInSession(): boolean { + return this.hasExitedPlanMode; + } + + public setNeedsPlanModeExitAttachment(value: boolean): void { + this.needsPlanModeExitAttachment = value; + } + + public getNeedsPlanModeExitAttachment(): boolean { + return this.needsPlanModeExitAttachment; + } + /** * Public wrapper for isInsideSafeZone to check if a path is in the safe zone */ diff --git a/packages/agent-sdk/src/managers/planManager.ts b/packages/agent-sdk/src/managers/planManager.ts index e71185d5d..91034af35 100644 --- a/packages/agent-sdk/src/managers/planManager.ts +++ b/packages/agent-sdk/src/managers/planManager.ts @@ -74,7 +74,13 @@ export class PlanManager { this.container.get("PermissionManager"); const messageManager = this.container.get("MessageManager"); + const previousMode = this.container.get("PermissionMode"); + if (mode === "plan") { + // Entering plan mode: clear any pending exit attachment + // (prevents sending both plan_mode and plan_mode_exit on rapid toggle) + permissionManager?.setNeedsPlanModeExitAttachment(false); + this.getOrGeneratePlanFilePath(messageManager?.getRootSessionId()) .then(({ path }) => { logger?.debug("Plan file path generated", { path }); @@ -83,6 +89,11 @@ export class PlanManager { .catch((error) => { logger?.error("Failed to generate plan file path", error); }); + } else if (previousMode === "plan") { + // Leaving plan mode: set flags for exit notification and re-entry detection + permissionManager?.setHasExitedPlanMode(true); + permissionManager?.setNeedsPlanModeExitAttachment(true); + permissionManager?.setPlanFilePath(undefined); } else { permissionManager?.setPlanFilePath(undefined); } diff --git a/packages/agent-sdk/src/prompts/index.ts b/packages/agent-sdk/src/prompts/index.ts index 473e076cd..cbe559c47 100644 --- a/packages/agent-sdk/src/prompts/index.ts +++ b/packages/agent-sdk/src/prompts/index.ts @@ -239,10 +239,6 @@ export function buildSystemPrompt( memory?: string; language?: string; isSubagent?: boolean; - planMode?: { - planFilePath: string; - planExists: boolean; - }; autoMemory?: { directory: string; content: string; @@ -269,10 +265,6 @@ export function buildSystemPrompt( prompt += `\n\n# Language\nAlways respond in ${options.language}. Use ${options.language} for all explanations, comments, and communications with the user. Technical terms and code identifiers should remain in their original form.`; } - if (options.planMode) { - prompt += `\n\n${buildPlanModePrompt(options.planMode.planFilePath, options.planMode.planExists, options.isSubagent)}`; - } - if (options.workdir) { const isGitRepo = isGitRepository(options.workdir); const platform = os.platform(); diff --git a/packages/agent-sdk/src/prompts/planModeReminders.ts b/packages/agent-sdk/src/prompts/planModeReminders.ts new file mode 100644 index 000000000..fe10bd481 --- /dev/null +++ b/packages/agent-sdk/src/prompts/planModeReminders.ts @@ -0,0 +1,135 @@ +import { + ASK_USER_QUESTION_TOOL_NAME, + EDIT_TOOL_NAME, + WRITE_TOOL_NAME, + EXIT_PLAN_MODE_TOOL_NAME, + AGENT_TOOL_NAME, +} from "../constants/tools.js"; +import { + EXPLORE_SUBAGENT_TYPE, + PLAN_SUBAGENT_TYPE, +} from "../constants/subagents.js"; + +export function wrapInSystemReminder(content: string): string { + return `\n${content}\n`; +} + +export function buildPlanModeReminder( + planFilePath: string, + planExists: boolean, + isSubagent: boolean = false, +): string { + const planFileInfo = planExists + ? `A plan file already exists at ${planFilePath}. You can read it and make incremental edits using the ${EDIT_TOOL_NAME} tool if you need to.` + : `No plan file exists yet. You should create your plan at ${planFilePath} using the ${WRITE_TOOL_NAME} tool if you need to.`; + + if (isSubagent) { + return wrapInSystemReminder(`Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). Instead, you should: + +## Plan File Info: +${planFileInfo} +You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions. +Answer the user's query comprehensively, using the ${ASK_USER_QUESTION_TOOL_NAME} tool if you need to ask the user clarifying questions. If you do use the ${ASK_USER_QUESTION_TOOL_NAME}, make sure to ask all clarifying questions you need to fully understand the user's intent before proceeding.`); + } + + return wrapInSystemReminder(`Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including making configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received. + +## Plan File Info: +${planFileInfo} +You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions. + +## Plan Workflow + +### Phase 1: Initial Understanding +Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the ${AGENT_TOOL_NAME} tool with subagent_type=${EXPLORE_SUBAGENT_TYPE}. + +1. Focus on understanding the user's request and the code associated with their request. Actively search for existing functions, utilities, and patterns that can be reused — avoid proposing new code when suitable implementations already exist. + +2. **Launch up to 3 ${EXPLORE_SUBAGENT_TYPE} agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase. + - Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change. + - Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning. + - Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1) + - If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigating testing patterns + +### Phase 2: Design +Goal: Design an implementation approach. + +Launch agent(s) with subagent_type=${PLAN_SUBAGENT_TYPE} to design the implementation based on the user's intent and your exploration results from Phase 1. + +You can launch up to 3 agent(s) in parallel. + +**Guidelines:** +- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives +- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames) +- **Multiple agents**: Use up to 3 agents for complex tasks that benefit from different perspectives + +Examples of when to use multiple agents: +- The task touches multiple parts of the codebase +- It's a large refactor or architectural change +- There are many edge cases to consider +- You'd benefit from exploring different approaches + +Example perspectives by task type: +- New feature: simplicity vs performance vs maintainability +- Bug fix: root cause vs workaround vs prevention +- Refactoring: minimal change vs clean architecture + +In the agent prompt: +- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces +- Describe requirements and constraints +- Request a detailed implementation plan + +### Phase 3: Review +Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions. +1. Read the critical files identified by agents to deepen your understanding +2. Ensure that the plans align with the user's original request +3. Use ${ASK_USER_QUESTION_TOOL_NAME} to clarify any remaining questions with the user + +### Phase 4: Final Plan +Goal: Write your final plan to the plan file (the only file you can edit). +- Begin with a **Context** section: explain why this change is being made — the problem or need it addresses, what prompted it, and the intended outcome +- Include only your recommended approach, not all alternatives +- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively +- Include the paths of critical files to be modified +- Reference existing functions and utilities you found that should be reused, with their file paths +- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests) + +### Phase 5: Call ${EXIT_PLAN_MODE_TOOL_NAME} +At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call ${EXIT_PLAN_MODE_TOOL_NAME} to indicate to the user that you are done planning. +This is critical - your turn should only end with either using the ${ASK_USER_QUESTION_TOOL_NAME} tool OR calling ${EXIT_PLAN_MODE_TOOL_NAME}. Do not stop unless it's for these 2 reasons + +**Important:** Use ${ASK_USER_QUESTION_TOOL_NAME} ONLY to clarify requirements or choose between approaches. Use ${EXIT_PLAN_MODE_TOOL_NAME} to request plan approval. Do NOT ask about plan approval in any other way - no text questions, no AskUserQuestion. Phrases like "Is this plan okay?", "Should I proceed?", "How does this plan look?", "Any changes before we start?", or similar MUST use ${EXIT_PLAN_MODE_TOOL_NAME}. + +NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications using the ${ASK_USER_QUESTION_TOOL_NAME} tool. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.`); +} + +export function buildPlanModeSparseReminder(planFilePath: string): string { + return wrapInSystemReminder( + `Plan mode still active (see full instructions earlier in conversation). Read-only except plan file at ${planFilePath}. End turns with ${ASK_USER_QUESTION_TOOL_NAME} or ${EXIT_PLAN_MODE_TOOL_NAME}.`, + ); +} + +export function buildPlanModeReEntryReminder(planFilePath: string): string { + return wrapInSystemReminder(`## Re-entering Plan Mode + +You are returning to plan mode after having previously exited it. A plan file exists at ${planFilePath} from your previous planning session. + +**Before proceeding with any new planning, you should:** +1. Read the existing plan file to understand what was previously planned +2. Evaluate the user's current request against that plan +3. Decide how to proceed: + - **Different task**: If the user's request is for a different task—even if it's similar or related—start fresh by overwriting the existing plan + - **Same task, continuing**: If this is explicitly a continuation or refinement of the exact same task, modify the existing plan while cleaning up outdated or irrelevant sections +4. Continue on with the plan process and most importantly you should always edit the plan file one way or the other before calling ${EXIT_PLAN_MODE_TOOL_NAME} + +Treat this as a fresh planning session. Do not assume the existing plan is relevant without evaluating it first.`); +} + +export function buildExitedPlanModeReminder( + planFilePath?: string, + planExists?: boolean, +): string { + return wrapInSystemReminder(`## Exited Plan Mode + +You have exited plan mode. You can now make edits, run tools, and take actions.${planExists ? ` The plan file is located at ${planFilePath} if you need to reference it.` : ""}`); +} diff --git a/packages/agent-sdk/src/tools/exitPlanMode.ts b/packages/agent-sdk/src/tools/exitPlanMode.ts index 7af4598c5..4c02009cd 100644 --- a/packages/agent-sdk/src/tools/exitPlanMode.ts +++ b/packages/agent-sdk/src/tools/exitPlanMode.ts @@ -107,6 +107,9 @@ Ensure your plan is complete and unambiguous: }; } + context.permissionManager.setHasExitedPlanMode(true); + context.permissionManager.setNeedsPlanModeExitAttachment(true); + return { success: true, content: "Plan approved. Exiting plan mode.", diff --git a/packages/agent-sdk/tests/integration/planMode.integration.test.ts b/packages/agent-sdk/tests/integration/planMode.integration.test.ts index d2fd3d8ba..59990b94f 100644 --- a/packages/agent-sdk/tests/integration/planMode.integration.test.ts +++ b/packages/agent-sdk/tests/integration/planMode.integration.test.ts @@ -52,7 +52,7 @@ describe("Plan Mode Integration", () => { expect(fs.mkdir).toHaveBeenCalledWith(planDir, { recursive: true }); }); - it("should include plan reminder in system prompt when in plan mode", async () => { + it("should include plan reminder in messages when in plan mode", async () => { const agent = await Agent.create({ workdir }); // Transition to plan mode and wait for path generation @@ -77,20 +77,26 @@ describe("Plan Mode Integration", () => { // Check all calls to callAgent const calls = vi.mocked(callAgent).mock.calls; - // In integration test, we want to see if the systemPrompt passed to callAgent contains the reminder + // Plan mode content is now injected as user messages, not in systemPrompt const callWithPlanInfo = calls.find((call) => { const options = call[0]; - return ( - options.systemPrompt && options.systemPrompt.includes("Plan File Info") - ); + const userMessages = + (options.messages as Array<{ role: string; content: string }>)?.filter( + (m) => m.role === "user", + ) ?? []; + return userMessages.some((m) => m.content?.includes("Plan File Info")); }); expect(callWithPlanInfo).toBeDefined(); if (callWithPlanInfo) { - expect(callWithPlanInfo[0].systemPrompt).toContain("Plan File Info"); - expect(callWithPlanInfo[0].systemPrompt).toContain( - "No plan file exists yet", + const userMessages = ( + callWithPlanInfo[0].messages as Array<{ role: string; content: string }> + ).filter((m) => m.role === "user"); + const planMessage = userMessages.find((m) => + m.content?.includes("Plan File Info"), ); + expect(planMessage).toBeDefined(); + expect(planMessage!.content).toContain("No plan file exists yet"); } }); diff --git a/packages/agent-sdk/tests/integration/subagentPlanMode.integration.test.ts b/packages/agent-sdk/tests/integration/subagentPlanMode.integration.test.ts index a9d4a2834..4acad9836 100644 --- a/packages/agent-sdk/tests/integration/subagentPlanMode.integration.test.ts +++ b/packages/agent-sdk/tests/integration/subagentPlanMode.integration.test.ts @@ -8,14 +8,14 @@ import { Message } from "../../src/types/index.js"; import type { SubagentConfiguration } from "../../src/utils/subagentParser.js"; import { Container } from "../../src/utils/container.js"; import * as aiService from "../../src/services/aiService.js"; - -import { buildSystemPrompt } from "../../src/prompts/index.js"; +import { buildPlanModeReminder } from "../../src/prompts/planModeReminders.js"; describe("Subagent Plan Mode Integration", () => { let subagentManager: SubagentManager; let mockToolManager: ToolManager; let mockPermissionManager: PermissionManager; - let lastSystemPrompt: string | undefined; + let lastMessages: import("openai/resources.js").ChatCompletionMessageParam[] = + []; const subagentConfig: SubagentConfiguration = { name: "TestSubagent", @@ -30,11 +30,12 @@ describe("Subagent Plan Mode Integration", () => { beforeEach(() => { vi.clearAllMocks(); - lastSystemPrompt = undefined; + lastMessages = []; - // Mock callAgent to capture the system prompt + // Mock callAgent to capture the messages vi.spyOn(aiService, "callAgent").mockImplementation(async (options) => { - lastSystemPrompt = options.systemPrompt; + lastMessages = + options.messages as import("openai/resources.js").ChatCompletionMessageParam[]; return { content: "Subagent response", tool_calls: [], @@ -54,6 +55,10 @@ describe("Subagent Plan Mode Integration", () => { addTemporaryRules: vi.fn(), removeTemporaryRules: vi.fn(), clearTemporaryRules: vi.fn(), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as PermissionManager; // Mock ToolManager @@ -116,6 +121,11 @@ describe("Subagent Plan Mode Integration", () => { }), getTranscriptPath: vi.fn().mockReturnValue("/test/transcript.json"), mergeAssistantAdditionalFields: vi.fn(), + touchFile: vi.fn(), + finalizeStreamingBlocks: vi.fn(), + getLatestTotalTokens: vi.fn().mockReturnValue(0), + compactMessagesAndUpdateSession: vi.fn(), + updateToolBlock: vi.fn(), } as unknown as MessageManager; // Ensure mockMessages is populated for the test @@ -162,30 +172,25 @@ describe("Subagent Plan Mode Integration", () => { } ).container.register("MessageManager", mockMessageManager); instance.messageManager = mockMessageManager; - // Ensure the subagent's AIManager uses the mock callAgent + // Override sendAIMessage to avoid MCP manager dependency + // Instead, call callAgent directly with plan mode messages injected const aiManager = instance.aiManager as unknown as { sendAIMessage: () => Promise; }; aiManager.sendAIMessage = async () => { - const systemPrompt = buildSystemPrompt(config.systemPrompt, [], { - workdir: "/test/project", - memory: "", - language: undefined, - isSubagent: true, - planMode: { - planFilePath: "/test/project/plan.md", - planExists: true, - }, - }); - lastSystemPrompt = systemPrompt; + const planFilePath = "/test/project/plan.md"; + const planReminder = buildPlanModeReminder(planFilePath, false, true); + await aiService.callAgent({ + messages: [{ role: "user", content: planReminder }], + systemPrompt: config.systemPrompt, + } as unknown as import("@/services/aiService.js").CallAgentOptions); mockMessageManager.addAssistantMessage(); - return Promise.resolve(); }; return instance; }); }); - it("should include plan mode reminder in subagent system prompt when plan mode is active", async () => { + it("should include plan mode reminder in subagent messages when plan mode is active", async () => { const instance = await subagentManager.createInstance(subagentConfig, { description: "Test task", prompt: "Test prompt", @@ -198,12 +203,17 @@ describe("Subagent Plan Mode Integration", () => { // Execute task await subagentManager.executeAgent(instance, "Test prompt"); - // Verify the system prompt sent to callAgent - expect(lastSystemPrompt).toBeDefined(); - expect(lastSystemPrompt).toContain("Plan mode is active."); - expect(lastSystemPrompt).toContain( + // Plan mode content is now injected as user messages, not in systemPrompt + const userMessages = lastMessages.filter( + (m) => m.role === "user", + ) as Array<{ role: string; content: string }>; + const planMessage = userMessages.find((m) => + m.content?.includes("Plan mode is active"), + ); + expect(planMessage).toBeDefined(); + expect(planMessage!.content).toContain( "The user indicated that they do not want you to execute yet", ); - expect(lastSystemPrompt).toContain("/test/project/plan.md"); + expect(planMessage!.content).toContain("/test/project/plan.md"); }); }); diff --git a/packages/agent-sdk/tests/managers/aiManager.coverage.test.ts b/packages/agent-sdk/tests/managers/aiManager.coverage.test.ts index d8cff613d..fb30b09e7 100644 --- a/packages/agent-sdk/tests/managers/aiManager.coverage.test.ts +++ b/packages/agent-sdk/tests/managers/aiManager.coverage.test.ts @@ -113,6 +113,10 @@ function makeContainer(overrides: Record = {}) { getDeniedRules: vi.fn().mockReturnValue([]), getAdditionalDirectories: vi.fn().mockReturnValue([]), getSystemAdditionalDirectories: vi.fn().mockReturnValue([]), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), }); c.register("SubagentManager", { getConfigurations: vi.fn().mockReturnValue([]), diff --git a/packages/agent-sdk/tests/managers/aiManager.latestTotalTokens.test.ts b/packages/agent-sdk/tests/managers/aiManager.latestTotalTokens.test.ts index 2f37891f8..559da9cf6 100644 --- a/packages/agent-sdk/tests/managers/aiManager.latestTotalTokens.test.ts +++ b/packages/agent-sdk/tests/managers/aiManager.latestTotalTokens.test.ts @@ -108,6 +108,10 @@ describe("AIManager - latestTotalTokens calculation", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); // Mock SubagentManager and register it diff --git a/packages/agent-sdk/tests/managers/aiManager.maxTurns.test.ts b/packages/agent-sdk/tests/managers/aiManager.maxTurns.test.ts index 930405ac6..74b374062 100644 --- a/packages/agent-sdk/tests/managers/aiManager.maxTurns.test.ts +++ b/packages/agent-sdk/tests/managers/aiManager.maxTurns.test.ts @@ -101,6 +101,10 @@ describe("AIManager maxTurns", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); container.register("SubagentManager", { getConfigurations: vi.fn().mockReturnValue([]), diff --git a/packages/agent-sdk/tests/managers/aiManager.plan.test.ts b/packages/agent-sdk/tests/managers/aiManager.plan.test.ts index 6c1bf4fda..93c8300c7 100644 --- a/packages/agent-sdk/tests/managers/aiManager.plan.test.ts +++ b/packages/agent-sdk/tests/managers/aiManager.plan.test.ts @@ -3,6 +3,7 @@ import { Container } from "../../src/utils/container.js"; import { TaskManager } from "../../src/services/taskManager.js"; import { AIManager } from "../../src/managers/aiManager.js"; import fs from "node:fs/promises"; +import { existsSync } from "node:fs"; import { callAgent } from "../../src/services/aiService.js"; import { DEFAULT_SYSTEM_PROMPT } from "../../src/prompts/index.js"; import type { MessageManager } from "../../src/managers/messageManager.js"; @@ -10,6 +11,9 @@ import type { ToolManager } from "../../src/managers/toolManager.js"; import type { PermissionManager } from "../../src/managers/permissionManager.js"; vi.mock("node:fs/promises"); +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), +})); vi.mock("../../src/services/aiService.js"); vi.mock("../../src/services/memory.js", () => ({ MemoryService: vi.fn().mockImplementation(() => ({ @@ -48,6 +52,10 @@ describe("AIManager Plan Mode Prompt", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("default"), getPlanFilePath: vi.fn().mockReturnValue("/path/to/plan.md"), clearTemporaryRules: vi.fn(), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Mocked; const container = new Container(); @@ -102,31 +110,47 @@ describe("AIManager Plan Mode Prompt", () => { it("should add plan reminder in plan mode when file does not exist", async () => { mockPermissionManager.getCurrentEffectiveMode.mockReturnValue("plan"); + vi.mocked(existsSync).mockReturnValue(false); vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")); await aiManager.sendAIMessage(); const callOptions = vi.mocked(callAgent).mock.calls[0][0]; - expect(callOptions.systemPrompt).toContain("Plan File Info"); - expect(callOptions.systemPrompt).toContain( + // Plan mode content is now injected as user messages, not in systemPrompt + const userMessages = ( + callOptions.messages as Array<{ role: string; content: string }> + ).filter((m) => m.role === "user"); + const planMessage = userMessages.find((m) => + m.content?.includes("Plan File Info"), + ); + expect(planMessage).toBeDefined(); + expect(planMessage!.content).toContain( "Plan mode is active. The user indicated that they do not want you to execute yet", ); - expect(callOptions.systemPrompt).toContain("No plan file exists yet"); - expect(callOptions.systemPrompt).toContain("using the Write tool"); + expect(planMessage!.content).toContain("No plan file exists yet"); + expect(planMessage!.content).toContain("using the Write tool"); }); it("should add plan reminder in plan mode when file exists", async () => { mockPermissionManager.getCurrentEffectiveMode.mockReturnValue("plan"); + vi.mocked(existsSync).mockReturnValue(true); vi.mocked(fs.access).mockResolvedValue(undefined); await aiManager.sendAIMessage(); const callOptions = vi.mocked(callAgent).mock.calls[0][0]; - expect(callOptions.systemPrompt).toContain("Plan File Info"); - expect(callOptions.systemPrompt).toContain( + // Plan mode content is now injected as user messages, not in systemPrompt + const userMessages = ( + callOptions.messages as Array<{ role: string; content: string }> + ).filter((m) => m.role === "user"); + const planMessage = userMessages.find((m) => + m.content?.includes("Plan File Info"), + ); + expect(planMessage).toBeDefined(); + expect(planMessage!.content).toContain( "Plan mode is active. The user indicated that they do not want you to execute yet", ); - expect(callOptions.systemPrompt).toContain("A plan file already exists"); - expect(callOptions.systemPrompt).toContain("using the Edit tool"); + expect(planMessage!.content).toContain("A plan file already exists"); + expect(planMessage!.content).toContain("using the Edit tool"); }); }); diff --git a/packages/agent-sdk/tests/managers/aiManager.test.ts b/packages/agent-sdk/tests/managers/aiManager.test.ts index e5faddd90..0cce1c228 100644 --- a/packages/agent-sdk/tests/managers/aiManager.test.ts +++ b/packages/agent-sdk/tests/managers/aiManager.test.ts @@ -145,6 +145,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); // Mock SubagentManager and register it @@ -212,6 +216,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); container.register("NotificationQueue", { hasPending: vi.fn().mockReturnValue(false), @@ -273,6 +281,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("dontAsk"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); const aiManagerWithDontAsk = new AIManager(container, { @@ -323,6 +335,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); container.register("NotificationQueue", { hasPending: vi.fn().mockReturnValue(false), @@ -513,6 +529,10 @@ describe("AIManager", () => { addTemporaryRules: vi.fn(), clearTemporaryRules: vi.fn(), getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), }; const taskManager = { @@ -541,6 +561,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); container.register( "PermissionManager", @@ -571,6 +595,10 @@ describe("AIManager", () => { addTemporaryRules: vi.fn(), clearTemporaryRules: vi.fn(), getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), }; const taskManager = { @@ -599,6 +627,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); container.register( "PermissionManager", @@ -627,6 +659,10 @@ describe("AIManager", () => { addTemporaryRules: vi.fn(), clearTemporaryRules: vi.fn(), getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), }; const taskManager = { @@ -655,6 +691,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); container.register( "PermissionManager", @@ -710,6 +750,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), }); container.register("NotificationQueue", { hasPending: vi.fn().mockReturnValue(false), @@ -756,6 +800,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), }); container.register("NotificationQueue", { hasPending: vi.fn().mockReturnValue(false), @@ -883,6 +931,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); container.register("SubagentManager", { getConfigurations: vi.fn().mockReturnValue([]), @@ -933,6 +985,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); container.register("SubagentManager", { getConfigurations: vi.fn().mockReturnValue([]), @@ -995,6 +1051,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); container.register("SubagentManager", { getConfigurations: vi.fn().mockReturnValue([]), @@ -1057,6 +1117,10 @@ describe("AIManager", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); container.register("SubagentManager", { getConfigurations: vi.fn().mockReturnValue([]), diff --git a/packages/agent-sdk/tests/managers/aiManager_duplicateTool.test.ts b/packages/agent-sdk/tests/managers/aiManager_duplicateTool.test.ts index 3727463e8..e02d870dc 100644 --- a/packages/agent-sdk/tests/managers/aiManager_duplicateTool.test.ts +++ b/packages/agent-sdk/tests/managers/aiManager_duplicateTool.test.ts @@ -80,6 +80,10 @@ describe("AIManager - Duplicate Tool Call Reminder", () => { container.register("PermissionManager", { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), }); aiManager = new AIManager(container, { diff --git a/packages/agent-sdk/tests/managers/aiManager_finishReason.test.ts b/packages/agent-sdk/tests/managers/aiManager_finishReason.test.ts index c1b94bfed..ba53ff238 100644 --- a/packages/agent-sdk/tests/managers/aiManager_finishReason.test.ts +++ b/packages/agent-sdk/tests/managers/aiManager_finishReason.test.ts @@ -99,6 +99,10 @@ describe("AIManager finish reason", () => { getCurrentEffectiveMode: vi.fn().mockReturnValue("normal"), clearTemporaryRules: vi.fn(), getPlanFilePath: vi.fn().mockReturnValue(undefined), + setHasExitedPlanMode: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + setNeedsPlanModeExitAttachment: vi.fn(), + getNeedsPlanModeExitAttachment: vi.fn(() => false), } as unknown as Record); // Mock SubagentManager and register it diff --git a/packages/agent-sdk/tests/prompts/prompts.test.ts b/packages/agent-sdk/tests/prompts/prompts.test.ts index 685a1561c..a2834db8f 100644 --- a/packages/agent-sdk/tests/prompts/prompts.test.ts +++ b/packages/agent-sdk/tests/prompts/prompts.test.ts @@ -158,11 +158,9 @@ describe("prompts", () => { expect(result).toContain("# Language\nAlways respond in Spanish."); }); - it("should include plan mode when provided", () => { - const result = buildSystemPrompt(DEFAULT_SYSTEM_PROMPT, [], { - planMode: { planFilePath: "/plan.md", planExists: true }, - }); - expect(result).toContain("Plan mode is active."); + it("should not include plan mode in system prompt (moved to system-reminder messages)", () => { + const result = buildSystemPrompt(DEFAULT_SYSTEM_PROMPT, [], {}); + expect(result).not.toContain("Plan mode is active."); }); it("should include memory context when provided", () => { diff --git a/packages/agent-sdk/tests/tools/exitPlanMode.test.ts b/packages/agent-sdk/tests/tools/exitPlanMode.test.ts index d008abad4..ff1b2d532 100644 --- a/packages/agent-sdk/tests/tools/exitPlanMode.test.ts +++ b/packages/agent-sdk/tests/tools/exitPlanMode.test.ts @@ -12,11 +12,7 @@ vi.mock("fs/promises"); describe("exitPlanModeTool", () => { let mockContext: ToolContext; - let mockPermissionManager: { - getPlanFilePath: ReturnType; - createContext: ReturnType; - checkPermission: ReturnType; - }; + let mockPermissionManager: PermissionManager; const container = new Container(); beforeEach(() => { @@ -24,12 +20,16 @@ describe("exitPlanModeTool", () => { getPlanFilePath: vi.fn(), createContext: vi.fn(), checkPermission: vi.fn(), - }; + setHasExitedPlanMode: vi.fn(), + setNeedsPlanModeExitAttachment: vi.fn(), + hasExitedPlanModeInSession: vi.fn(() => false), + getNeedsPlanModeExitAttachment: vi.fn(() => false), + } as unknown as PermissionManager; mockContext = { workdir: "/test/workdir", taskManager: new TaskManager(container, "test-session"), - permissionManager: mockPermissionManager as unknown as PermissionManager, + permissionManager: mockPermissionManager, permissionMode: "plan", canUseToolCallback: vi.fn(), }; @@ -68,14 +68,16 @@ describe("exitPlanModeTool", () => { }); it("should fail if plan file path is not set", async () => { - mockPermissionManager.getPlanFilePath.mockReturnValue(undefined); + vi.mocked(mockPermissionManager.getPlanFilePath).mockReturnValue(undefined); const result = await exitPlanModeTool.execute({}, mockContext); expect(result.success).toBe(false); expect(result.error).toBe("Plan file path is not set"); }); it("should fail if plan file cannot be read", async () => { - mockPermissionManager.getPlanFilePath.mockReturnValue("/test/plan.md"); + vi.mocked(mockPermissionManager.getPlanFilePath).mockReturnValue( + "/test/plan.md", + ); vi.mocked(readFile).mockRejectedValue(new Error("File not found")); const result = await exitPlanModeTool.execute({}, mockContext); @@ -85,12 +87,15 @@ describe("exitPlanModeTool", () => { it("should succeed if plan is approved", async () => { const planContent = "My awesome plan"; - mockPermissionManager.getPlanFilePath.mockReturnValue("/test/plan.md"); + vi.mocked(mockPermissionManager.getPlanFilePath).mockReturnValue( + "/test/plan.md", + ); vi.mocked(readFile).mockResolvedValue(planContent); - mockPermissionManager.createContext.mockReturnValue({ + vi.mocked(mockPermissionManager.createContext).mockReturnValue({ toolName: "ExitPlanMode", + permissionMode: "plan", }); - mockPermissionManager.checkPermission.mockResolvedValue({ + vi.mocked(mockPermissionManager.checkPermission).mockResolvedValue({ behavior: "allow", }); @@ -111,12 +116,15 @@ describe("exitPlanModeTool", () => { it("should return feedback if plan is rejected", async () => { const planContent = "My awesome plan"; const feedback = "Please add more details"; - mockPermissionManager.getPlanFilePath.mockReturnValue("/test/plan.md"); + vi.mocked(mockPermissionManager.getPlanFilePath).mockReturnValue( + "/test/plan.md", + ); vi.mocked(readFile).mockResolvedValue(planContent); - mockPermissionManager.createContext.mockReturnValue({ + vi.mocked(mockPermissionManager.createContext).mockReturnValue({ toolName: "ExitPlanMode", + permissionMode: "plan", }); - mockPermissionManager.checkPermission.mockResolvedValue({ + vi.mocked(mockPermissionManager.checkPermission).mockResolvedValue({ behavior: "deny", message: feedback, }); @@ -131,12 +139,15 @@ describe("exitPlanModeTool", () => { }); it("should return default error if plan is rejected without message", async () => { - mockPermissionManager.getPlanFilePath.mockReturnValue("/test/plan.md"); + vi.mocked(mockPermissionManager.getPlanFilePath).mockReturnValue( + "/test/plan.md", + ); vi.mocked(readFile).mockResolvedValue("plan"); - mockPermissionManager.createContext.mockReturnValue({ + vi.mocked(mockPermissionManager.createContext).mockReturnValue({ toolName: "ExitPlanMode", + permissionMode: "plan", }); - mockPermissionManager.checkPermission.mockResolvedValue({ + vi.mocked(mockPermissionManager.checkPermission).mockResolvedValue({ behavior: "deny", }); @@ -150,12 +161,15 @@ describe("exitPlanModeTool", () => { }); it("should return cancellation message without prefix if cancelled by user", async () => { - mockPermissionManager.getPlanFilePath.mockReturnValue("/test/plan.md"); + vi.mocked(mockPermissionManager.getPlanFilePath).mockReturnValue( + "/test/plan.md", + ); vi.mocked(readFile).mockResolvedValue("plan"); - mockPermissionManager.createContext.mockReturnValue({ + vi.mocked(mockPermissionManager.createContext).mockReturnValue({ toolName: "ExitPlanMode", + permissionMode: "plan", }); - mockPermissionManager.checkPermission.mockResolvedValue({ + vi.mocked(mockPermissionManager.checkPermission).mockResolvedValue({ behavior: "deny", message: OPERATION_CANCELLED_BY_USER, }); @@ -168,7 +182,7 @@ describe("exitPlanModeTool", () => { }); it("should handle unexpected errors in execute", async () => { - mockPermissionManager.getPlanFilePath.mockImplementation(() => { + vi.mocked(mockPermissionManager.getPlanFilePath).mockImplementation(() => { throw new Error("Unexpected error"); }); diff --git a/specs/014-message-compression/spec.md b/specs/014-message-compression/spec.md index a45b69929..95c6856b2 100644 --- a/specs/014-message-compression/spec.md +++ b/specs/014-message-compression/spec.md @@ -71,6 +71,23 @@ As an AI agent, after compression replaces conversation history, I want importan --- +### User Story 5 - Plan Mode Reminder Preservation After Compaction (Priority: P2) + +As a user working in plan mode during a long session, I want the plan mode instructions to be re-injected after conversation compaction so that the agent retains its read-only constraints and workflow guidance even after the history is replaced with a summary. + +**Why this priority**: Without re-injection, compaction removes all plan mode `` messages from the conversation history. The agent would then have no awareness of plan mode constraints and might attempt to edit files or take actions outside the plan file. + +**Independent Test**: Trigger compaction while in plan mode, then verify that the full plan mode `` reminder is injected as a user message after compaction, and the agent continues to respect read-only constraints. + +**Acceptance Scenarios**: + +1. **Given** the agent is in plan mode and compaction occurs, **When** the compaction summary replaces the conversation history, **Then** the full plan mode `` MUST be injected as a user message in the next API request. +2. **Given** the agent is in plan mode and compaction occurs, **When** the plan file exists, **Then** the re-injected plan mode reminder MUST include the plan file path and existence status. +3. **Given** the agent is NOT in plan mode, **When** compaction occurs, **Then** no plan mode reminder is injected. +4. **Given** the agent is in plan mode and compaction occurs, **When** the re-injected reminder is the first reminder after compaction, **Then** it MUST be the full instructions (not sparse), since all prior reminders were removed by compaction. + +--- + ### Edge Cases - **Recursive Compression**: When compressing history that already contains a summary, the entire history (including the old summary) is replaced by a new continuation summary. @@ -89,11 +106,12 @@ As an AI agent, after compression replaces conversation history, I want importan - **FR-005**: System MUST convert `compress` blocks to user-role messages for API calls. - **FR-006**: System MUST apply microcompact (clear old tool results) before each API call when the time threshold (>30 min) is exceeded. - **FR-007**: System MUST skip compression after 3 consecutive compression failures (circuit breaker). -- **FR-008**: System MUST re-inject post-compact context (recent file reads, working directory, plan mode, skills, background tasks) into the compression summary. +- **FR-008**: System MUST re-inject post-compact context (recent file reads, working directory, plan mode, skills, background tasks) into the compression summary. When plan mode is active, the system MUST also re-inject the full plan mode `` as a user message after compaction. - **FR-009**: System MUST strip images from messages before the compress API call. - **FR-010**: System MUST use the fast model for compression API calls. - **FR-011**: System MUST group messages by API round boundaries (not fixed count) when determining which messages to preserve after compression. - **FR-012**: System MUST track recent `read` tool results for post-compact context restoration. +- **FR-013**: System MUST re-inject plan mode `` instructions after compaction when plan mode is active. This ensures the model does not lose its read-only constraints and workflow guidance after conversation history is replaced with a summary. ### Key Entities *(include if feature involves data)* diff --git a/specs/021-prompt-cache-control/spec.md b/specs/021-prompt-cache-control/spec.md index 22329d305..8f5a0aa3a 100644 --- a/specs/021-prompt-cache-control/spec.md +++ b/specs/021-prompt-cache-control/spec.md @@ -72,6 +72,23 @@ When using Claude models with cache control, developers need accurate token trac --- +### User Story 5 - System Prompt Stability Across Mode Transitions (Priority: P1) + +As a user who switches between permission modes (e.g., default → plan → acceptEdits), I want the system prompt to remain constant so that the cached system prompt prefix is not invalidated on every mode change, reducing token costs and improving response latency. + +**Why this priority**: Plan mode previously appended instructions to the system prompt, invalidating the entire cache on every mode transition. For long sessions with frequent mode switches, this causes significant unnecessary token costs. Keeping the system prompt stable maximizes cache hit rates. + +**Independent Test**: Enter plan mode, verify the system prompt is identical to the default mode system prompt, and check that plan mode instructions appear as `` user messages instead. + +**Acceptance Scenarios**: + +1. **Given** a Claude model is configured and the system prompt has been cached, **When** the user enters plan mode, **Then** the system prompt MUST remain identical to the previous turn's system prompt (no plan mode text appended). +2. **Given** plan mode is active, **When** the system sends the next API request, **Then** plan mode instructions MUST appear as `` wrapped user messages in the messages array, not in the system prompt. +3. **Given** the user exits plan mode, **When** the next API request is made, **Then** the system prompt MUST remain unchanged and usage tracking SHOULD show cache_read_input_tokens indicating a cache hit on the system message. +4. **Given** a non-Claude model is configured, **When** the user enters plan mode, **Then** plan mode instructions still appear as `` user messages (the injection pattern is model-agnostic, but caching benefits only apply to Claude models). + +--- + ### Edge Cases - **Edge Case 1**: Model name detection MUST be case-insensitive ("Claude-3-Sonnet" and "claude-3-sonnet" both trigger caching) @@ -85,7 +102,7 @@ When using Claude models with cache control, developers need accurate token trac ### Functional Requirements - **FR-001**: System MUST detect cache-supporting models using the `WAVE_PROMPT_CACHE_REGEX` environment variable (default: "claude"), which allows configurable regex patterns for model matching -- **FR-002**: System MUST add cache_control markers with type "ephemeral" to the first system message when using Claude models. This ensures core instructions are always cached even if reminders are added later. +- **FR-002**: System MUST add cache_control markers with type "ephemeral" to the first system message when using Claude models. This ensures core instructions are always cached even if reminders are added later. The system prompt MUST remain constant across plan mode transitions — plan mode instructions are injected as `` user messages rather than system prompt changes to preserve the cached system prompt prefix. - **FR-003**: System MUST create a cache marker when total message count reaches multiples of 20 (20, 40, 60, etc.) - **FR-004**: System MUST NOT create cache markers when total message count is below 20 or not a multiple of 20 - **FR-005**: System MUST maintain cache markers at the most recent multiple-of-20 message position (sliding window) diff --git a/specs/022-prompt-engineering/spec.md b/specs/022-prompt-engineering/spec.md index 5c9babeaa..096a9d674 100644 --- a/specs/022-prompt-engineering/spec.md +++ b/specs/022-prompt-engineering/spec.md @@ -35,11 +35,63 @@ As a developer, I want to provide dynamic tool descriptions based on the current --- +### User Story 3 - System-Reminder Message Injection (Priority: P1) + +As a developer, I want to inject transient instructions into the conversation as `` wrapped user messages (not system prompt changes) so that the system prompt stays constant across mode transitions, preserving prompt caching and reducing token costs. + +**Why this priority**: Modifying the system prompt mid-session (e.g., appending plan mode instructions) invalidates the entire cached system prompt prefix on every mode change. Injecting instructions as user messages with `` tags preserves the cache while still delivering contextual instructions to the model. This pattern is used by Claude Code for all dynamic mode-specific guidance. + +**Independent Test**: Enter plan mode, verify the system prompt is unchanged from the previous turn and plan mode instructions appear as `` user messages in the API request. + +**Acceptance Scenarios**: + +1. **Given** the agent enters plan mode, **When** the next API request is assembled, **Then** plan mode instructions MUST be injected as a `` wrapped user message, NOT appended to the system prompt. +2. **Given** the agent exits plan mode, **When** the next API request is assembled, **Then** an "exited plan mode" `` user message MUST be injected (one-time only). +3. **Given** the agent re-enters plan mode after having exited, **When** a plan file already exists, **Then** a re-entry `` user message MUST be injected instructing the model to read the existing plan and evaluate whether to continue or start fresh. +4. **Given** the system prompt has been cached by a prior request, **When** mode transitions occur, **Then** the system prompt MUST remain byte-identical, enabling cache hit on the system message prefix. + +--- + +### User Story 4 - Throttled Reminder Injection (Priority: P2) + +As a developer, I want plan mode reminders to be throttled so they are only injected every N human turns (not on every tool round) to reduce token waste while maintaining constraint awareness. + +**Why this priority**: Without throttling, plan mode reminders are injected on every API call (including tool rounds within a single human turn), wasting tokens. Throttling to every 5 human turns reduces cost while periodic full reminders prevent the model from forgetting constraints. + +**Independent Test**: Work in plan mode for 10+ turns, verify reminders appear every 5 human turns, alternating between full and sparse versions. + +**Acceptance Scenarios**: + +1. **Given** plan mode is active and a reminder was just injected, **When** fewer than 5 human turns have passed, **Then** no plan mode reminder is injected. +2. **Given** 5 human turns have passed since the last reminder, **When** the next API request is made, **Then** a plan mode reminder is injected. +3. **Given** every 5th reminder injection, **When** the reminder is injected, **Then** it MUST be the full plan mode instructions (complete 5-phase workflow). +4. **Given** a non-5th reminder injection, **When** the reminder is injected, **Then** it MUST be a short sparse reminder referencing earlier full instructions. + +--- + +### User Story 5 - Override Language for Mode Transitions (Priority: P1) + +As a developer, I want `` instructions injected after mode transitions to include explicit override language (e.g., "This supercedes any other instructions you have received") so that the model understands that the new constraints take precedence over prior tool call history in the conversation. + +**Why this priority**: When switching from default/acceptEdits mode to plan mode mid-conversation, the message history contains recent Edit/Write tool calls that may mislead the model into continuing to edit. The override language, combined with the reminder being the most recent instruction the model sees, ensures the model respects the new mode constraints. + +**Independent Test**: Have a conversation with Edit/Write tool calls, then enter plan mode, and verify the plan mode reminder contains "supercedes" override language and appears after all prior tool calls in the message stream. + +**Acceptance Scenarios**: + +1. **Given** the conversation contains recent Edit/Write tool calls, **When** the user enters plan mode, **Then** the plan mode `` MUST be injected as the last user message in the API request (after all prior tool calls). +2. **Given** the plan mode reminder is injected, **When** the model reads it, **Then** the reminder MUST contain the phrase "This supercedes any other instructions you have received" or equivalent override language. +3. **Given** the plan mode reminder is injected after mode transition, **When** the model attempts to use Edit or Write on any file other than the plan file, **Then** the permission system blocks the action at runtime (defense-in-depth). + +--- + ### Edge Cases - **Missing Prompts**: What happens when a required prompt is missing from the framework? (System should fall back to a default prompt or show a clear error message). - **Large Prompts**: How does the system handle very large prompts that might exceed token limits? (Framework should provide tools for prompt compression or truncation). - **Conflicting Prompts**: How does the system handle conflicting instructions from different prompt sources? (Framework should define a clear precedence order). +- **Rapid Mode Toggling**: When the user rapidly toggles between plan and non-plan mode, `` messages for both entry and exit should not be injected simultaneously. The exit attachment flag should be cleared when re-entering plan mode. +- **Re-entry After Compaction**: When compaction removes all conversation history including prior `` messages, the system MUST re-inject the full plan mode reminder (not sparse) since no earlier full instructions exist. ## Requirements *(mandatory)* @@ -52,6 +104,12 @@ As a developer, I want to provide dynamic tool descriptions based on the current - **FR-005**: System MUST provide a way to validate prompts against token limits. - **FR-006**: System MUST support prompt templates with variable substitution. - **FR-007**: System MUST incorporate best practices from Claude Code (e.g., action safety, collaborator mindset, concise output). +- **FR-008**: System MUST inject mode-specific instructions (e.g., plan mode, plan mode exit, plan mode re-entry) as `` wrapped user messages rather than system prompt modifications, to preserve the cached system prompt prefix across mode transitions. +- **FR-009**: All `` injected messages MUST use `isMeta: true` and MUST NOT be rendered in the UI. +- **FR-010**: System MUST include override language ("This supercedes any other instructions you have received") in plan mode `` messages to ensure the model respects new mode constraints despite prior tool call history. +- **FR-011**: System MUST throttle plan mode `` injection to every 5 human turns (non-meta, non-tool-result user messages), not on every tool round. Every 5th injected reminder MUST be full instructions; intermediate reminders MUST be sparse. +- **FR-012**: System MUST re-inject the full (not sparse) plan mode `` after compaction when plan mode is active, since prior reminders are removed by compaction. +- **FR-013**: System MUST track `hasExitedPlanMode` and `needsPlanModeExitAttachment` state flags for one-time injection of re-entry and exit notification `` messages. ### Key Entities *(include if feature involves data)* diff --git a/specs/050-plan-mode/spec.md b/specs/050-plan-mode/spec.md index 01513d444..55cc3b462 100644 --- a/specs/050-plan-mode/spec.md +++ b/specs/050-plan-mode/spec.md @@ -87,6 +87,67 @@ As an agent in plan mode, I want to use the `ExitPlanMode` tool after I have fin - **What happens when `ExitPlanMode` is called outside of plan mode?** The tool MUST NOT be available in the toolset when the agent is not in plan mode. If somehow invoked, it should return an error. - **How does the system handle multiple calls to `ExitPlanMode`?** If already exiting or if the first call is pending, subsequent calls should be handled gracefully (e.g., ignored or returned as pending). +### User Story 5 - Plan Mode Re-entry Guidance (Priority: P1) + +As a user who has previously exited plan mode, I want the system to recognize when I re-enter plan mode so that the agent knows about the existing plan file and can decide whether to continue or start fresh. + +**Why this priority**: Without re-entry guidance, the agent may either ignore an existing plan file or assume it's still relevant, leading to wasted work or incorrect plans. + +**Independent Test**: Enter plan mode, write a plan, approve ExitPlanMode, re-enter plan mode, and verify the agent receives a re-entry reminder about the existing plan file. + +**Acceptance Scenarios**: + +1. **Given** the agent has exited plan mode and a plan file exists, **When** the user re-enters plan mode, **Then** a re-entry `` is injected instructing the model to read the existing plan, evaluate if the task is the same or different, and always edit the plan file before ExitPlanMode. +2. **Given** the agent has exited plan mode but no plan file exists, **When** re-entering plan mode, **Then** no re-entry reminder is injected (treat as first entry). +3. **Given** the re-entry reminder has been injected once, **When** subsequent turns occur in plan mode, **Then** the re-entry reminder is NOT re-injected (one-time only). + +--- + +### User Story 6 - Mode Transition Awareness (Priority: P1) + +As a user switching from default/acceptEdits mode to plan mode mid-conversation, I want the agent to immediately understand it must stop editing and switch to planning, even though the conversation history contains recent Edit/Write tool calls. + +**Why this priority**: Without mode boundary awareness, the model may continue editing based on recent tool call history, ignoring the plan mode constraint. + +**Independent Test**: Have a conversation with Edit/Write calls, then enter plan mode, and verify the plan mode reminder appears as the last instruction with explicit override language. + +**Acceptance Scenarios**: + +1. **Given** the conversation contains recent Edit/Write tool calls and the user enters plan mode, **When** the next API call is made, **Then** the plan mode `` is injected as the last instruction the model sees (after all prior tool calls), explicitly stating "This supercedes any other instructions you have received." +2. **Given** the agent is in plan mode, **When** the agent attempts to use Edit or Write on any file other than the plan file, **Then** the permission system blocks the action at runtime. + +--- + +### User Story 7 - Plan Mode Exit Notification (Priority: P2) + +As a user who has just approved a plan, I want the agent to be explicitly told it has exited plan mode and can now take actions, so there is no confusion about the mode transition. + +**Why this priority**: Prevents the agent from continuing to behave as if it's still in plan mode after approval. + +**Independent Test**: Approve ExitPlanMode and verify an "exited plan mode" system-reminder appears on the next turn. + +**Acceptance Scenarios**: + +1. **Given** ExitPlanMode is approved, **When** the next API turn begins, **Then** an "exited plan mode" `` is injected as a one-time message. +2. **Given** the exit notification was injected, **When** the following turn begins, **Then** the exit notification is NOT injected again (one-time only). + +--- + +### User Story 8 - Throttled Plan Mode Reminders (Priority: P2) + +As a user working in plan mode for an extended session, I want the plan mode reminders to be throttled so they don't waste tokens on every tool round, but I still want periodic full instructions to prevent the agent from forgetting constraints. + +**Why this priority**: Sending full plan mode instructions on every tool round wastes tokens; throttling reduces cost while maintaining constraint awareness. + +**Independent Test**: Work in plan mode for multiple turns and verify reminders appear every 5 human turns, alternating between full and sparse. + +**Acceptance Scenarios**: + +1. **Given** plan mode is active and a full reminder was just injected, **When** fewer than 5 human turns have passed, **Then** no plan mode reminder is injected. +2. **Given** plan mode is active and 5 human turns have passed since the last reminder, **When** the next API call is made, **Then** a plan mode reminder is injected. +3. **Given** every 5th plan mode reminder injection, **When** the reminder is injected, **Then** it is the full 5-phase workflow instructions. +4. **Given** a non-5th plan mode reminder injection, **When** the reminder is injected, **Then** it is a short sparse reminder referencing the earlier full instructions. + ## Requirements *(mandatory)* ### Functional Requirements @@ -97,7 +158,7 @@ As an agent in plan mode, I want to use the `ExitPlanMode` tool after I have fin - **FR-003**: When in plan mode, the system MUST restrict the LLM to read-only actions for all files except the designated plan file. - **FR-004**: When in plan mode, the system MUST allow the LLM to execute commands. - **FR-005**: When plan mode is activated, the system MUST determine a plan file path in `~/.wave/plans/` with a human-readable name (adjective-noun format). This name MUST be deterministic within a session chain by using the `rootSessionId` as a seed, ensuring the same plan file is reused even after message compression or session restoration. -- **FR-006**: When plan mode is active, the system MUST append a specific reminder to the LLM's system prompt: +- **FR-006**: When plan mode is active, the system MUST inject a `` wrapped user message (isMeta: true) into the conversation messages. This preserves prompt caching by keeping the system prompt constant across mode changes. The reminder MUST contain: ```text Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). Instead, you should: @@ -123,6 +184,14 @@ As an agent in plan mode, I want to use the `ExitPlanMode` tool after I have fin - **FR-015**: If the agent is NOT in "plan mode", the `ExitPlanMode` tool MUST NOT be exposed to the LLM. - **FR-016**: `ExitPlanMode` MUST NOT be available when `permissionMode` is set to `bypassPermissions`. - **FR-017**: When used via the ACP bridge, `ExitPlanMode` MAY provide a simplified approval process (e.g., "Approve Plan" and "Reject Plan") and automatically transition to `default` mode upon approval. +- **FR-018**: System MUST track `hasExitedPlanMode` state. When the agent exits plan mode (via ExitPlanMode or mode transition), this flag MUST be set to true. +- **FR-019**: When entering plan mode and `hasExitedPlanMode` is true and a plan file already exists, the system MUST inject a re-entry `` message instructing the model to: (a) read the existing plan file, (b) evaluate whether the user's request is a new task or continuation, (c) always edit the plan file before calling ExitPlanMode. The flag MUST be cleared after injection (one-time). +- **FR-020**: When plan mode is active, the system MUST inject plan mode reminders every 5 human turns (non-meta, non-tool-result user messages), not on every tool round. Every 5th reminder MUST be the full instructions; intermediate reminders MUST be sparse (short reminder referencing earlier full instructions). +- **FR-021**: When exiting plan mode, the system MUST inject a one-time "exited plan mode" `` message on the next turn, notifying the model it can now make edits and take actions. If the plan file exists, the message MUST include the plan file path for reference. +- **FR-022**: All plan mode `` messages MUST use `isMeta: true` and MUST NOT be rendered in the UI. +- **FR-023**: After compaction, if plan mode is active, the system MUST re-inject the full plan mode reminder so the model retains its instructions. +- **FR-024**: The `hasExitedPlanMode` flag MUST be tracked in `PermissionManager` and persist across mode transitions within the same session. +- **FR-025**: The "needs plan mode exit attachment" flag (`needsPlanModeExitAttachment`) MUST be set when transitioning away from plan mode and cleared after the exit `` is injected (one-time). ### Key Entities diff --git a/specs/README.md b/specs/README.md index 3940a4651..8def0aaa5 100644 --- a/specs/README.md +++ b/specs/README.md @@ -29,10 +29,10 @@ This directory contains feature specifications that serve as the source of truth | Metric | Count | |--------|-------| | Specs | 54 | -| User Stories | 221 | -| Functional Requirements | 832 | -| Test Files | 300 | -| Test Cases | 3,792 | +| User Stories | 232 | +| Functional Requirements | 862 | +| Test Files | 301 | +| Test Cases | 3,813 | ## Specs @@ -40,24 +40,24 @@ This directory contains feature specifications that serve as the source of truth |---------|-------------|----|----|-------| | File System Tools | Read, Write, Edit, Glob, Grep tools for file operations | 3 | 15 | [spec](001-fs-tools/spec.md) · [plan](001-fs-tools/plan.md) | | Bash Tools | Bash, BashOutput, KillBash tools for shell command execution | 3 | 17 | [spec](002-bash-tools/spec.md) · [plan](002-bash-tools/plan.md) | -| MCP | Model Context Protocol support for external tools and context sources | 4 | 20 | [spec](003-mcp/spec.md) · [plan](003-mcp/plan.md) | +| MCP | Model Context Protocol support for external tools and context sources | 4 | 23 | [spec](003-mcp/spec.md) · [plan](003-mcp/plan.md) | | Session Management | Performance-optimized, project-based session management system | 3 | 17 | [spec](004-session-management/spec.md) · [plan](004-session-management/plan.md) | | Hooks | Event hooks system for extending Wave behavior | 14 | 55 | [spec](005-hooks/spec.md) · [plan](005-hooks/plan.md) | | Agent Skills | Discoverable skill packages with SKILL.md files for model-invoked capabilities | 8 | 25 | [spec](006-agent-skills/spec.md) · [plan](006-agent-skills/plan.md) | -| Agent Config | Constructor-based config instead of env vars, with max output tokens and custom headers | 8 | 30 | [spec](007-agent-config/spec.md) · [plan](007-agent-config/plan.md) | +| Agent Config | Constructor-based config instead of env vars, with max output tokens and custom headers | 10 | 41 | [spec](007-agent-config/spec.md) · [plan](007-agent-config/plan.md) | | Slash Commands | Custom slash command system for user-invoked commands | 6 | 22 | [spec](008-slash-commands/spec.md) · [plan](008-slash-commands/plan.md) | -| Subagent | Subagent support for delegating tasks to pre-configured AI personalities | 5 | 23 | [spec](009-subagent/spec.md) · [plan](009-subagent/plan.md) | +| Subagent | Subagent support for delegating tasks to pre-configured AI personalities | 5 | 24 | [spec](009-subagent/spec.md) · [plan](009-subagent/plan.md) | | Usage Tracking | SDK usage tracking callbacks (`onUsagesChange`) for AI calls and compression | 4 | 15 | [spec](010-usage-tracking-callback/spec.md) · [plan](010-usage-tracking-callback/plan.md) | | Streaming | Real-time content streaming for assistant messages and tool parameters | 5 | 22 | [spec](012-stream-content-updates/spec.md) · [plan](012-stream-content-updates/plan.md) | | AI Error Handling | Handle output token limit exceeded by prompting agent to break work into smaller pieces | 6 | 10 | [spec](013-ai-error-handling/spec.md) · [plan](013-ai-error-handling/plan.md) | -| Message Compression | Conversation history and user input size management | 4 | 12 | [spec](014-message-compression/spec.md) · [plan](014-message-compression/plan.md) | +| Message Compression | Conversation history and user input size management | 5 | 13 | [spec](014-message-compression/spec.md) · [plan](014-message-compression/plan.md) | | Image Pasting | Paste images from clipboard into chat input with placeholder and attachment | 3 | 10 | [spec](015-image-pasting/spec.md) · [plan](015-image-pasting/plan.md) | | File Selector | Quick file/directory selector UI component | 3 | 8 | [spec](016-file-selector/spec.md) · [plan](016-file-selector/plan.md) | | WebFetch Tool | Fetch URL content, convert HTML to markdown, process with AI model, with caching | 5 | 14 | [spec](017-web-fetch-tool/spec.md) · [plan](017-web-fetch-tool/plan.md) | | Memory Management | Persist information across conversations via memory files | 8 | 26 | [spec](018-memory-management/spec.md) · [plan](018-memory-management/plan.md) | | Markdown Rendering | Terminal Markdown rendering with Ink components for headings, lists, code blocks, tables | 3 | 8 | [spec](020-markdown-rendering-system/spec.md) · [plan](020-markdown-rendering-system/plan.md) | -| Prompt Cache Control | `cache_control` markers for Claude models on system messages, user messages, and tools | 4 | 11 | [spec](021-prompt-cache-control/spec.md) · [plan](021-prompt-cache-control/plan.md) | -| Prompt Engineering | Framework for prompt construction and management | 2 | 7 | [spec](022-prompt-engineering/spec.md) · [plan](022-prompt-engineering/plan.md) | +| Prompt Cache Control | `cache_control` markers for Claude models on system messages, user messages, and tools | 5 | 11 | [spec](021-prompt-cache-control/spec.md) · [plan](021-prompt-cache-control/plan.md) | +| Prompt Engineering | Framework for prompt construction and management | 5 | 13 | [spec](022-prompt-engineering/spec.md) · [plan](022-prompt-engineering/plan.md) | | Long Text Placeholder | Replace long pasted text in input with `[LongText#ID]` placeholder, expand on submit | 1 | 5 | [spec](023-long-text-placeholder/spec.md) · [plan](023-long-text-placeholder/plan.md) | | Tool Permissions | Permission system with modes, wildcards, deny rules, trust, acceptEdits, dontAsk, Safe Zone | 18 | 55 | [spec](024-tool-permission-system/spec.md) · [plan](024-tool-permission-system/plan.md) | | Built-in Subagent | Built-in subagent support for Explore agent | 2 | 10 | [spec](025-builtin-subagent/spec.md) · [plan](025-builtin-subagent/plan.md) | @@ -72,7 +72,7 @@ This directory contains feature specifications that serve as the source of truth | Confirm UI | Confirmation dialog UI components for tool permission approvals | 5 | 13 | [spec](034-confirm-ui/spec.md) · [plan](034-confirm-ui/plan.md) | | LSP Integration | Language Server Protocol for code intelligence (definitions, references, hover) | 3 | 8 | [spec](039-lsp-integration/spec.md) · [plan](039-lsp-integration/plan.md) | | Plugin | Plugin system with marketplace, scopes, Skills, LSP, MCP, Hooks, Agents | 6 | 28 | [spec](042-plugin/spec.md) · [plan](042-plugin/plan.md) | -| Plan Mode | Shift+Tab plan mode for read-only analysis with incremental plan file editing | 4 | 17 | [spec](050-plan-mode/spec.md) · [plan](050-plan-mode/plan.md) | +| Plan Mode | Shift+Tab plan mode for read-only analysis with incremental plan file editing | 8 | 25 | [spec](050-plan-mode/spec.md) · [plan](050-plan-mode/plan.md) | | AskUserQuestion Tool | AskUserQuestion tool for structured user interaction with options | 3 | 11 | [spec](052-ask-user-tool/spec.md) · [plan](052-ask-user-tool/plan.md) | | Init Command | `/init` slash command using init-prompt.md for project initialization | 2 | 7 | [spec](054-init-slash-command/spec.md) · [plan](054-init-slash-command/plan.md) | | Rewind Command | `/rewind` to revert conversation to a previous user message, reverting file changes | 3 | 10 | [spec](056-rewind-command/spec.md) · [plan](056-rewind-command/plan.md) |