diff --git a/src/browser/hooks/useResumeManager.ts b/src/browser/hooks/useResumeManager.ts index 5d14be7148..b0fa88e2ed 100644 --- a/src/browser/hooks/useResumeManager.ts +++ b/src/browser/hooks/useResumeManager.ts @@ -180,6 +180,7 @@ export function useResumeManager() { text: lastUserMsg.compactionRequest.parsed.continueMessage?.text ?? "", imageParts: lastUserMsg.compactionRequest.parsed.continueMessage?.imageParts, model: lastUserMsg.compactionRequest.parsed.continueMessage?.model ?? options.model, + mode: lastUserMsg.compactionRequest.parsed.continueMessage?.mode ?? "exec", }, }); } diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 24d13cd733..f7264c9528 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -655,6 +655,9 @@ export function prepareCompactionMessage(options: CompactionOptions): { // Create compaction metadata (will be stored in user message) // Only include continueMessage if there's text, images, or reviews to queue after compaction const hasText = continueText; + // Determine mode for continue message - use mode from sendMessageOptions if it's exec/plan, otherwise default to exec + const continueMode = options.sendMessageOptions.mode === "plan" ? "plan" : "exec"; + const compactData: CompactionRequestData = { model: effectiveModel, maxOutputTokens: options.maxOutputTokens, @@ -664,6 +667,7 @@ export function prepareCompactionMessage(options: CompactionOptions): { text: options.continueMessage?.text ?? "", imageParts: options.continueMessage?.imageParts, model: options.continueMessage?.model ?? options.sendMessageOptions.model, + mode: continueMode, reviews: options.continueMessage?.reviews, } : undefined, diff --git a/src/common/types/message.ts b/src/common/types/message.ts index b8b125d78c..996a24eb02 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -26,10 +26,12 @@ export interface UserMessageContent { /** * Message to continue with after compaction. - * Extends UserMessageContent with model preference. + * Extends UserMessageContent with model and mode preferences. */ export interface ContinueMessage extends UserMessageContent { model?: string; + /** Mode for the continue message (determines tool policy). Defaults to 'exec'. */ + mode?: "exec" | "plan"; } // Parsed compaction request data (shared type for consistency) diff --git a/src/node/services/agentSession.continueMessageToolPolicy.test.ts b/src/node/services/agentSession.continueMessageToolPolicy.test.ts new file mode 100644 index 0000000000..df75b4a95e --- /dev/null +++ b/src/node/services/agentSession.continueMessageToolPolicy.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { modeToToolPolicy } from "@/common/utils/ui/modeUtils"; + +/** + * Regression test for continue message tool policy bug. + * + * Bug: When a /compact command includes a continue message, the continue message + * was being queued with the compaction mode's tool policy (all tools disabled) + * instead of the intended execution mode's tool policy. + * + * Fix: Continue message now carries its own mode field, and the backend uses + * modeToToolPolicy(continueMessage.mode) instead of copying options.toolPolicy. + * + * This test verifies that the mode-to-policy transformation produces the expected + * tool policies for continue messages. The actual integration of this logic into + * agentSession.ts is verified by the type system and manual testing. + */ +describe("Continue message tool policy derivation", () => { + test("exec mode enables tools (not disabled-all like compaction)", () => { + const execPolicy = modeToToolPolicy("exec"); + const compactionPolicy = [{ regex_match: ".*", action: "disable" }]; + + // Exec mode should NOT disable all tools like compaction does + expect(execPolicy).not.toEqual(compactionPolicy); + + // Exec mode specifically disables propose_plan (the plan mode tool) + expect(execPolicy).toEqual([{ regex_match: "propose_plan", action: "disable" }]); + }); + + test("plan mode has different policy than compaction", () => { + const planPolicy = modeToToolPolicy("plan"); + const compactionPolicy = [{ regex_match: ".*", action: "disable" }]; + + // Plan mode should NOT disable all tools like compaction does + expect(planPolicy).not.toEqual(compactionPolicy); + + // Plan mode enables propose_plan + expect(planPolicy).toEqual([{ regex_match: "propose_plan", action: "enable" }]); + }); + + test("verifies fix: mode field determines policy, not inherited compaction policy", () => { + // This test documents the fix behavior: + // Before fix: continueMessage used options.toolPolicy (compaction's disabled-all) + // After fix: continueMessage.mode determines the policy via modeToToolPolicy() + + // Simulating the fixed logic from agentSession.ts: + // const continueMode = continueMessage.mode ?? "exec"; + // toolPolicy: modeToToolPolicy(continueMode) + + const simulateContinueMessagePolicy = (mode?: "exec" | "plan") => { + const continueMode = mode ?? "exec"; // Default to exec as in the fix + return modeToToolPolicy(continueMode); + }; + + // Explicit exec mode + expect(simulateContinueMessagePolicy("exec")).toEqual([ + { regex_match: "propose_plan", action: "disable" }, + ]); + + // Explicit plan mode + expect(simulateContinueMessagePolicy("plan")).toEqual([ + { regex_match: "propose_plan", action: "enable" }, + ]); + + // No mode specified (defaults to exec) + expect(simulateContinueMessagePolicy(undefined)).toEqual([ + { regex_match: "propose_plan", action: "disable" }, + ]); + + // None of these should be the compaction policy + const compactionPolicy = [{ regex_match: ".*", action: "disable" }]; + expect(simulateContinueMessagePolicy("exec")).not.toEqual(compactionPolicy); + expect(simulateContinueMessagePolicy("plan")).not.toEqual(compactionPolicy); + expect(simulateContinueMessagePolicy(undefined)).not.toEqual(compactionPolicy); + }); +}); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 0227ccd3e8..0d5afcddec 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -39,6 +39,7 @@ import type { PostCompactionAttachment, PostCompactionExclusions } from "@/commo import { TURNS_BETWEEN_ATTACHMENTS } from "@/common/constants/attachments"; import { extractEditedFileDiffs } from "@/common/utils/messages/extractEditedFiles"; import { isValidModelFormat } from "@/common/utils/ai/models"; +import { modeToToolPolicy } from "@/common/utils/ui/modeUtils"; /** * Tracked file state for detecting external edits. @@ -525,13 +526,15 @@ export class AgentSession { const { finalText, metadata } = prepareUserMessageForSend(continueMessage); // Build options for the queued message (strip compaction-specific fields) + // Use the mode from continueMessage to derive correct tool policy, not the compaction mode's disabled-all policy + const continueMode = continueMessage.mode ?? "exec"; const sanitizedOptions: Omit< SendMessageOptions, "muxMetadata" | "mode" | "editMessageId" | "imageParts" | "maxOutputTokens" > & { imageParts?: typeof continueMessage.imageParts; muxMetadata?: typeof metadata } = { model: continueMessage.model ?? options.model, thinkingLevel: options.thinkingLevel, - toolPolicy: options.toolPolicy, + toolPolicy: modeToToolPolicy(continueMode), additionalSystemInstructions: options.additionalSystemInstructions, providerOptions: options.providerOptions, experiments: options.experiments,