diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 8a2d352a51e5..e509cb71c482 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -712,7 +712,7 @@ function providerMeta(metadata: Record | undefined) { export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, + options?: { stripMedia?: boolean; stripPlanModeReminders?: boolean; toolOutputMaxChars?: number }, ) { const result: UIMessage[] = [] const toolNames = new Set() @@ -771,6 +771,15 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( return { type: "json", value: output as never } } + const skipText = (part: TextPart) => { + if (part.ignored) return true + if (!options?.stripPlanModeReminders) return false + if (!part.synthetic) return false + if (part.metadata?.["kind"] === "plan_reminder") return true + // Fallback for sessions persisted before reminders were tagged with metadata. + return part.text.includes("Plan mode is active") || part.text.includes("Plan mode ACTIVE") + } + for (const msg of input) { if (msg.parts.length === 0) continue @@ -782,7 +791,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( } result.push(userMessage) for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) + if (part.type === "text" && !skipText(part)) userMessage.parts.push({ type: "text", text: part.text, @@ -969,7 +978,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, + options?: { stripMedia?: boolean; stripPlanModeReminders?: boolean; toolOutputMaxChars?: number }, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 600eb42f795e..48cb7ecaabae 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -236,6 +236,7 @@ export const layer = Layer.effect( type: "text", text: PROMPT_PLAN, synthetic: true, + metadata: { kind: "plan_reminder" }, }) } const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan") @@ -349,6 +350,7 @@ This is critical - your turn should only end with either asking the user a quest NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. 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. `, synthetic: true, + metadata: { kind: "plan_reminder" }, }) userMessage.parts.push(part) return input.messages @@ -1479,7 +1481,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the sys.skills(agent), Effect.sync(() => sys.environment(model)), instruction.system().pipe(Effect.orDie), - MessageV2.toModelMessagesEffect(msgs, model), + MessageV2.toModelMessagesEffect(msgs, model, { stripPlanModeReminders: agent.name !== "plan" }), ]) const system = [...env, ...(skills ? [skills] : []), ...instructions] const format = lastUser.format ?? { type: "text" as const } diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 9591a5d6230f..ef7612f9e452 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -195,6 +195,100 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("can strip stale synthetic plan mode reminders", async () => { + const messageID = "m-user" + const planTurn: MessageV2.WithParts = { + info: userInfo("m-plan"), + parts: [ + { + ...basePart("m-plan", "p0"), + type: "text", + text: "\nPlan mode ACTIVE - older session without metadata.\n", + synthetic: true, + }, + { + ...basePart("m-plan", "p0b"), + type: "text", + text: "draft a plan", + }, + ] as MessageV2.Part[], + } + const buildTurn: MessageV2.WithParts = { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "\nPlan mode is active. Do not edit files.\n", + synthetic: true, + metadata: { kind: "plan_reminder" }, + }, + { + ...basePart(messageID, "p2"), + type: "text", + text: "\nYour operational mode has changed from plan to build.\n", + synthetic: true, + }, + { + ...basePart(messageID, "p3"), + type: "text", + text: "Please implement the plan.", + }, + ] as MessageV2.Part[], + } + const input: MessageV2.WithParts[] = [planTurn, buildTurn] + + expect(await MessageV2.toModelMessages(input, model, { stripPlanModeReminders: true })).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "draft a plan" }], + }, + { + role: "user", + content: [ + { + type: "text", + text: "\nYour operational mode has changed from plan to build.\n", + }, + { type: "text", text: "Please implement the plan." }, + ], + }, + ]) + }) + + test("keeps plan reminders when not stripping", async () => { + const messageID = "m-user" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "\nPlan mode is active.\n", + synthetic: true, + metadata: { kind: "plan_reminder" }, + }, + { + ...basePart(messageID, "p2"), + type: "text", + text: "draft a plan", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [ + { type: "text", text: "\nPlan mode is active.\n" }, + { type: "text", text: "draft a plan" }, + ], + }, + ]) + }) + test("converts user text/file parts and injects compaction/subtask prompts", async () => { const messageID = "m-user"