diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index ba9a4d6f1a0f..b63597ed5896 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -387,63 +387,100 @@ export const layer = Layer.effect(
agent: Agent.Info
session: Session.Info
}) {
- const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
- if (!userMessage) return input.messages
+ const cloneWithReminder = (
+ messages: MessageV2.WithParts[],
+ targetMessageID: string,
+ text: string,
+ ): MessageV2.WithParts[] => {
+ let updated = false
+ const next = messages.map((message) => {
+ if (message.info.id !== targetMessageID) return message
+ if (message.parts.some((part) => part.type === "text" && part.synthetic && part.text === text)) return message
+ updated = true
+ return {
+ ...message,
+ parts: [
+ ...message.parts,
+ {
+ id: PartID.ascending(),
+ messageID: message.info.id,
+ sessionID: message.info.sessionID,
+ type: "text",
+ text,
+ synthetic: true,
+ } satisfies MessageV2.Part,
+ ],
+ }
+ })
+ return updated ? next : messages
+ }
- if (!flags.experimentalPlanMode) {
- if (input.agent.name === "plan") {
- userMessage.parts.push({
- id: PartID.ascending(),
- messageID: userMessage.info.id,
- sessionID: userMessage.info.sessionID,
- type: "text",
- text: PROMPT_PLAN,
- synthetic: true,
- })
+ const latestAssistant = (predicate: (assistant: MessageV2.WithParts) => boolean) => {
+ let match: MessageV2.WithParts | undefined
+ for (const message of input.messages) {
+ if (message.info.role !== "assistant") continue
+ if (!predicate(message)) continue
+ if (!match || message.info.id > match.info.id) match = message
}
- const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
- if (wasPlan && input.agent.name === "build") {
- userMessage.parts.push({
- id: PartID.ascending(),
- messageID: userMessage.info.id,
- sessionID: userMessage.info.sessionID,
- type: "text",
- text: BUILD_SWITCH,
- synthetic: true,
- })
+ return match
+ }
+
+ const stableUserFor = (boundary: MessageV2.WithParts | undefined) => {
+ let target: MessageV2.WithParts | undefined
+ for (const message of input.messages) {
+ if (message.info.role !== "user") continue
+ if (boundary && message.info.id <= boundary.info.id) continue
+ if (!target || message.info.id < target.info.id) target = message
}
- return input.messages
+ return target
}
- const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
- if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
+ if (input.agent.name === "build") {
+ const boundary = latestAssistant((assistant) => assistant.info.agent === "plan")
+ const userMessage = stableUserFor(boundary)
+ if (!boundary || !userMessage) return input.messages
+ if (!flags.experimentalPlanMode) return cloneWithReminder(input.messages, userMessage.info.id, BUILD_SWITCH)
+
const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
if (!(yield* fsys.existsSafe(plan))) return input.messages
+ const text = `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`
+ if (userMessage.parts.some((part) => part.type === "text" && part.synthetic && part.text === text)) {
+ return input.messages
+ }
const part = yield* sessions.updatePart({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
- text: `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`,
+ text,
synthetic: true,
})
- userMessage.parts.push(part)
- return input.messages
+ return cloneWithReminder(input.messages, userMessage.info.id, text).map((message) =>
+ message.info.id === userMessage.info.id
+ ? {
+ ...message,
+ parts: [...message.parts.filter((part) => part.type !== "text" || part.text !== text), part],
+ }
+ : message,
+ )
}
- if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages
+ if (input.agent.name !== "plan") return input.messages
+
+ const boundary = latestAssistant((assistant) => assistant.info.agent !== "plan")
+ const userMessage = stableUserFor(boundary)
+ if (!userMessage) return input.messages
+
+ if (!flags.experimentalPlanMode) {
+ return cloneWithReminder(input.messages, userMessage.info.id, PROMPT_PLAN)
+ }
const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
const exists = yield* fsys.existsSafe(plan)
if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
- const part = yield* sessions.updatePart({
- id: PartID.ascending(),
- messageID: userMessage.info.id,
- sessionID: userMessage.info.sessionID,
- type: "text",
- text: `
+ const text = `
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 changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
## Plan File Info:
@@ -512,11 +549,26 @@ This is critical - your turn should only end with either asking the user a quest
**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does.
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.
-`,
+`
+ if (userMessage.parts.some((part) => part.type === "text" && part.synthetic && part.text === text)) {
+ return input.messages
+ }
+ const part = yield* sessions.updatePart({
+ id: PartID.ascending(),
+ messageID: userMessage.info.id,
+ sessionID: userMessage.info.sessionID,
+ type: "text",
+ text,
synthetic: true,
})
- userMessage.parts.push(part)
- return input.messages
+ return cloneWithReminder(input.messages, userMessage.info.id, text).map((message) =>
+ message.info.id === userMessage.info.id
+ ? {
+ ...message,
+ parts: [...message.parts.filter((part) => part.type !== "text" || part.text !== text), part],
+ }
+ : message,
+ )
})
const resolveTools = Effect.fn("SessionPrompt.resolveTools")(function* (input: {
@@ -1790,21 +1842,27 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))
if (step > 1 && lastFinished) {
- for (const m of msgs) {
- if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
- for (const p of m.parts) {
- if (p.type !== "text" || p.ignored || p.synthetic) continue
- if (!p.text.trim()) continue
- p.text = [
- "",
- "The user sent the following message:",
- p.text,
- "",
- "Please address this message and continue with your tasks.",
- "",
- ].join("\n")
- }
- }
+ msgs = msgs.map((message) => {
+ if (message.info.role !== "user" || message.info.id <= lastFinished.id) return message
+ let changed = false
+ const parts = message.parts.map((part) => {
+ if (part.type !== "text" || part.ignored || part.synthetic) return part
+ if (!part.text.trim()) return part
+ changed = true
+ return {
+ ...part,
+ text: [
+ "",
+ "The user sent the following message:",
+ part.text,
+ "",
+ "Please address this message and continue with your tasks.",
+ "",
+ ].join("\n"),
+ }
+ })
+ return changed ? { ...message, parts } : message
+ })
}
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 891efc18721d..53e56922186b 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -108,6 +108,18 @@ function errorTool(parts: MessageV2.Part[]) {
return part?.state.status === "error" ? (part as ErrorToolPart) : undefined
}
+function requestMessages(hit: { body: Record }) {
+ const messages = hit.body.messages
+ return Array.isArray(messages) ? (messages as Array>) : []
+}
+
+function findUserMessageIndex(messages: Array>, text: string) {
+ return messages.findIndex((message) => {
+ if (message.role !== "user") return false
+ return JSON.stringify(message.content).includes(text)
+ })
+}
+
const mcp = Layer.succeed(
MCP.Service,
MCP.Service.of({
@@ -593,6 +605,62 @@ it.instance(
{ git: true },
)
+it.instance(
+ "keeps plan/build system reminders anchored to the original user turn across later serializations",
+ () =>
+ Effect.gen(function* () {
+ const { llm } = yield* useServerConfig(providerCfg)
+ const prompt = yield* SessionPrompt.Service
+ const sessions = yield* Session.Service
+ const session = yield* sessions.create({
+ title: "Reminder stability",
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
+ })
+
+ yield* prompt.prompt({
+ sessionID: session.id,
+ agent: "plan",
+ noReply: true,
+ parts: [{ type: "text", text: "Draft the plan" }],
+ })
+ yield* llm.text("plan done")
+ yield* prompt.loop({ sessionID: session.id })
+
+ yield* prompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ noReply: true,
+ parts: [{ type: "text", text: "Continue" }],
+ })
+ yield* llm.text("build one")
+ yield* prompt.loop({ sessionID: session.id })
+
+ const firstBuild = yield* llm.hits
+ const firstBuildMessages = requestMessages(firstBuild.at(-1)!)
+ const continueIndex = findUserMessageIndex(firstBuildMessages, "Continue")
+ expect(continueIndex).toBeGreaterThanOrEqual(0)
+ expect(JSON.stringify(firstBuildMessages[continueIndex])).toContain("")
+
+ yield* prompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ noReply: true,
+ parts: [{ type: "text", text: "Next step" }],
+ })
+ yield* llm.text("build two")
+ yield* prompt.loop({ sessionID: session.id })
+
+ const secondBuild = yield* llm.hits
+ const secondBuildMessages = requestMessages(secondBuild.at(-1)!)
+ expect(continueIndex).toBeGreaterThanOrEqual(0)
+ expect(JSON.stringify(secondBuildMessages[continueIndex])).toBe(JSON.stringify(firstBuildMessages[continueIndex]))
+ expect(JSON.stringify(secondBuildMessages.slice(0, continueIndex + 1))).toBe(
+ JSON.stringify(firstBuildMessages.slice(0, continueIndex + 1)),
+ )
+ }),
+ { git: true },
+)
+
it.instance(
"loop continues when finish is tool-calls",
() =>