Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions src/browser/components/tools/ProposePlanToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,26 @@ import { usePopoverError } from "@/browser/hooks/usePopoverError";
import { PopoverError } from "../PopoverError";

/**
* Check if the result is a successful file-based propose_plan result
* Check if the result is a successful file-based propose_plan result.
* Note: planContent may be absent in newer results (context optimization).
*/
function isProposePlanResult(result: unknown): result is ProposePlanToolResult {
return (
result !== null &&
typeof result === "object" &&
"success" in result &&
result.success === true &&
"planContent" in result &&
"planPath" in result
);
}

/**
* Result type that may have planContent (for backwards compatibility with old chat history)
*/
interface ProposePlanResultWithContent extends ProposePlanToolResult {
planContent?: string;
}

/**
* Check if the result is an error from propose_plan tool
*/
Expand Down Expand Up @@ -173,11 +180,20 @@ export const ProposePlanToolCall: React.FC<ProposePlanToolCallProps> = (props) =
const titleMatch = /^#\s+(.+)$/m.exec(freshContent);
planTitle = titleMatch ? titleMatch[1] : (planPath?.split("/").pop() ?? "Plan");
} else if (isProposePlanResult(result)) {
planContent = result.planContent;
// New format: planContent may be absent (context optimization)
// For backwards compatibility, check if planContent exists in old chat history
const resultWithContent = result as ProposePlanResultWithContent;
planPath = result.planPath;
// Extract title from first markdown heading or use filename
const titleMatch = /^#\s+(.+)$/m.exec(result.planContent);
planTitle = titleMatch ? titleMatch[1] : (planPath.split("/").pop() ?? "Plan");
if (resultWithContent.planContent) {
// Old result with embedded content (backwards compatibility)
planContent = resultWithContent.planContent;
const titleMatch = /^#\s+(.+)$/m.exec(resultWithContent.planContent);
planTitle = titleMatch ? titleMatch[1] : (planPath.split("/").pop() ?? "Plan");
} else {
// New result without content - show path info, content is fetched for latest
planContent = `*Plan saved to ${planPath}*`;
planTitle = planPath.split("/").pop() ?? "Plan";
}
} else if (isLegacyProposePlanResult(result)) {
// Legacy format: title + plan passed directly (no file)
planContent = result.plan;
Expand Down
149 changes: 149 additions & 0 deletions src/browser/utils/messages/modelMessageTransform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,155 @@ describe("injectModeTransition", () => {
text: "[Mode switched from plan to exec. Follow exec mode instructions.]",
});
});

it("should include plan content when transitioning from plan to exec", () => {
const messages: MuxMessage[] = [
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Let's plan a feature" }],
metadata: { timestamp: 1000 },
},
{
id: "assistant-1",
role: "assistant",
parts: [{ type: "text", text: "Here's the plan..." }],
metadata: { timestamp: 2000, mode: "plan" },
},
{
id: "user-2",
role: "user",
parts: [{ type: "text", text: "Now execute it" }],
metadata: { timestamp: 3000 },
},
];

const planContent = "# My Plan\n\n## Step 1\nDo something\n\n## Step 2\nDo more";
const result = injectModeTransition(messages, "exec", undefined, planContent);

expect(result.length).toBe(4);
const transitionMessage = result[2];
expect(transitionMessage.role).toBe("user");
expect(transitionMessage.metadata?.synthetic).toBe(true);

const textPart = transitionMessage.parts[0];
expect(textPart.type).toBe("text");
if (textPart.type === "text") {
expect(textPart.text).toContain(
"[Mode switched from plan to exec. Follow exec mode instructions.]"
);
expect(textPart.text).toContain("The following plan was developed in plan mode");
expect(textPart.text).toContain("<plan>");
expect(textPart.text).toContain(planContent);
expect(textPart.text).toContain("</plan>");
}
});

it("should NOT include plan content when transitioning from exec to plan", () => {
const messages: MuxMessage[] = [
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Done with feature" }],
metadata: { timestamp: 1000 },
},
{
id: "assistant-1",
role: "assistant",
parts: [{ type: "text", text: "Feature complete" }],
metadata: { timestamp: 2000, mode: "exec" },
},
{
id: "user-2",
role: "user",
parts: [{ type: "text", text: "Let's plan the next one" }],
metadata: { timestamp: 3000 },
},
];

const planContent = "# Old Plan\n\nSome content";
const result = injectModeTransition(messages, "plan", undefined, planContent);

expect(result.length).toBe(4);
const transitionMessage = result[2];
const textPart = transitionMessage.parts[0];
if (textPart.type === "text") {
expect(textPart.text).toBe(
"[Mode switched from exec to plan. Follow plan mode instructions.]"
);
expect(textPart.text).not.toContain("<plan>");
}
});

it("should NOT include plan content when no plan content provided", () => {
const messages: MuxMessage[] = [
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Let's plan" }],
metadata: { timestamp: 1000 },
},
{
id: "assistant-1",
role: "assistant",
parts: [{ type: "text", text: "Planning..." }],
metadata: { timestamp: 2000, mode: "plan" },
},
{
id: "user-2",
role: "user",
parts: [{ type: "text", text: "Execute" }],
metadata: { timestamp: 3000 },
},
];

const result = injectModeTransition(messages, "exec", undefined, undefined);

expect(result.length).toBe(4);
const transitionMessage = result[2];
const textPart = transitionMessage.parts[0];
if (textPart.type === "text") {
expect(textPart.text).toBe(
"[Mode switched from plan to exec. Follow exec mode instructions.]"
);
expect(textPart.text).not.toContain("<plan>");
}
});

it("should include both tools and plan content in transition message", () => {
const messages: MuxMessage[] = [
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Plan done" }],
metadata: { timestamp: 1000 },
},
{
id: "assistant-1",
role: "assistant",
parts: [{ type: "text", text: "Plan ready" }],
metadata: { timestamp: 2000, mode: "plan" },
},
{
id: "user-2",
role: "user",
parts: [{ type: "text", text: "Go" }],
metadata: { timestamp: 3000 },
},
];

const toolNames = ["file_read", "bash"];
const planContent = "# Plan\n\nDo stuff";
const result = injectModeTransition(messages, "exec", toolNames, planContent);

expect(result.length).toBe(4);
const textPart = result[2].parts[0];
if (textPart.type === "text") {
expect(textPart.text).toContain("Available tools: file_read, bash.]");
expect(textPart.text).toContain("<plan>");
expect(textPart.text).toContain(planContent);
}
});
});

describe("filterEmptyAssistantMessages", () => {
Expand Down
18 changes: 17 additions & 1 deletion src/browser/utils/messages/modelMessageTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,20 @@ export function addInterruptedSentinel(messages: MuxMessage[]): MuxMessage[] {
* Inserts a synthetic user message before the final user message to signal the mode switch.
* This provides temporal context that helps models understand they should follow new mode instructions.
*
* When transitioning from plan → exec mode with plan content, includes the plan so the model
* can evaluate its relevance to the current request.
*
* @param messages The conversation history
* @param currentMode The mode for the upcoming assistant response (e.g., "plan", "exec")
* @param toolNames Optional list of available tool names to include in transition message
* @param planContent Optional plan content to include when transitioning plan → exec
* @returns Messages with mode transition context injected if needed
*/
export function injectModeTransition(
messages: MuxMessage[],
currentMode?: string,
toolNames?: string[]
toolNames?: string[],
planContent?: string
): MuxMessage[] {
// No mode specified, nothing to do
if (!currentMode) {
Expand Down Expand Up @@ -175,6 +180,17 @@ export function injectModeTransition(
transitionText += "]";
}

// When transitioning plan → exec with plan content, include the plan for context
if (lastMode === "plan" && currentMode === "exec" && planContent) {
transitionText += `

The following plan was developed in plan mode. Based on the user's message, determine if they have accepted the plan. If accepted and relevant, use it to guide your implementation:

<plan>
${planContent}
</plan>`;
}

const transitionMessage: MuxMessage = {
id: `mode-transition-${Date.now()}`,
role: "user",
Expand Down
5 changes: 3 additions & 2 deletions src/common/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,12 @@ export type FileEditToolArgs =
// Args derived from schema
export type ProposePlanToolArgs = z.infer<typeof TOOL_DEFINITIONS.propose_plan.schema>;

// Result type for new file-based propose_plan tool
// Result type for file-based propose_plan tool
// Note: planContent is NOT included to save context - plan is visible via file_edit_* diffs
// and will be included in mode transition message when switching to exec mode
export interface ProposePlanToolResult {
success: true;
planPath: string;
planContent: string;
message: string;
}

Expand Down
Loading
Loading