From ec10d71f22b34e842a486e4661d2cb2dea836f6f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:01:02 -0500 Subject: [PATCH] fix(core): keep plan mode reminders in sync with session agent (#42652) --- packages/core/src/plugin/plan.ts | 34 ++++- packages/core/test/plugin/plan.test.ts | 180 +++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 packages/core/test/plugin/plan.test.ts diff --git a/packages/core/src/plugin/plan.ts b/packages/core/src/plugin/plan.ts index 1c5efeaedf..4f9edb7c72 100644 --- a/packages/core/src/plugin/plan.ts +++ b/packages/core/src/plugin/plan.ts @@ -1,6 +1,6 @@ export * as PlanPlugin from "./plan.js" -import { ToolFailure } from "@opencode-ai/ai" +import { Message, ToolFailure } from "@opencode-ai/ai" import { define } from "@opencode-ai/plugin/effect/plugin" import { Effect, Stream } from "effect" import { Agent } from "../agent.js" @@ -38,13 +38,33 @@ export const Plugin = define({ }) }) + // Compaction and committed reverts can strip reminders while the session's agent stays + // put. Reconcile per request, appending near the tail so the cached prefix stays warm. + yield* ctx.session.hook("context", (event) => { + const reminder = lastReminder(event.messages) + const missing = event.agent === plan && reminder !== enter + const stale = event.agent !== plan && reminder === enter + const text = missing ? enter : stale ? leave : undefined + if (!text) return Effect.void + // Before the user's prompt, matching where agent-switch reminders land. + const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length + event.messages.splice(at, 0, Message.user(text)) + return ctx.session + .synthetic({ sessionID: event.sessionID, text, resume: false }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to persist Plan mode reminder", { sessionID: event.sessionID, cause }), + ), + ) + }) + yield* ctx.event.subscribe().pipe( Stream.filter( (event): event is SessionEvent.Created | SessionEvent.AgentSelected => event.type === "session.created" || event.type === "session.agent.selected", ), Stream.runForEach((event) => { - const text = reminder(event) + const text = switchReminder(event) if (!text) return Effect.void return ctx.session .synthetic({ @@ -63,7 +83,7 @@ export const Plugin = define({ }), }) -function reminder(event: SessionEvent.Created | SessionEvent.AgentSelected) { +function switchReminder(event: SessionEvent.Created | SessionEvent.AgentSelected) { if (event.type === "session.created") { if (event.data.agent !== plan) return return enter @@ -72,3 +92,11 @@ function reminder(event: SessionEvent.Created | SessionEvent.AgentSelected) { if (event.data.agent === plan) return enter if (event.data.previous === plan) return leave } + +function lastReminder(messages: ReadonlyArray) { + return messages.reduce((found, message) => { + const part = message.role === "user" && message.content.length === 1 ? message.content[0] : undefined + if (part?.type !== "text") return found + return part.text === enter || part.text === leave ? part.text : found + }, undefined) +} diff --git a/packages/core/test/plugin/plan.test.ts b/packages/core/test/plugin/plan.test.ts new file mode 100644 index 0000000000..96c734240d --- /dev/null +++ b/packages/core/test/plugin/plan.test.ts @@ -0,0 +1,180 @@ +import { describe, expect } from "bun:test" +import { Message } from "@opencode-ai/ai" +import { DateTime, Effect, Stream } from "effect" +import type { SessionContext } from "@opencode-ai/plugin/effect/session" +import { Agent } from "@opencode-ai/core/agent" +import { Event } from "@opencode-ai/schema/event" +import { Model } from "@opencode-ai/core/model" +import { PlanPlugin } from "@opencode-ai/core/plugin/plan" +import { Provider } from "@opencode-ai/core/provider" +import { Session } from "@opencode-ai/core/session" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionInbox } from "@opencode-ai/core/session/inbox" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { it } from "../lib/effect" +import { host } from "./host" + +const sessionID = Session.ID.make("ses_plan_test") +const plan = Agent.ID.make("plan") +const build = Agent.ID.make("build") + +const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentSelected => ({ + id: Event.ID.create(), + created: 0, + durable: { aggregateID: sessionID, seq: Event.Seq.make(0), version: Event.Version.make(1) }, + type: "session.agent.selected", + data: { sessionID, agent, previous }, +}) + +/** Runs the plan plugin against stubbed domains, capturing persisted reminders and the context hook. */ +const run = Effect.fnUntraced(function* (events: ReadonlyArray = []) { + const persisted = new Array() + let contextHook: ((input: SessionContext) => Effect.Effect) | undefined + yield* PlanPlugin.Plugin.effect( + host({ + agent: { + get: () => Effect.die("unused agent.get"), + list: () => Effect.die("unused agent.list"), + reload: () => Effect.die("unused agent.reload"), + transform: () => Effect.succeed({ dispose: Effect.void }), + }, + tool: { + transform: () => Effect.die("unused tool.transform"), + hook: () => Effect.succeed({ dispose: Effect.void }), + }, + event: { + subscribe: () => Stream.fromIterable(events), + }, + session: { + hook: (name, callback) => { + if (name === "context") contextHook = callback as (input: SessionContext) => Effect.Effect + return Effect.succeed({ dispose: Effect.void }) + }, + synthetic: (input) => { + persisted.push(input.text) + return Effect.succeed( + SessionInbox.Synthetic.make({ + id: SessionMessage.ID.make("msg_plan_test"), + sessionID, + timeCreated: DateTime.makeUnsafe(0), + type: "synthetic", + payload: { text: input.text }, + delivery: "steer", + }), + ) + }, + }, + }), + ) + if (!contextHook) return yield* Effect.die("plan plugin did not register a context hook") + return { persisted, contextHook } +}) + +const request = (agent: Agent.ID, messages: Array): SessionContext => ({ + sessionID, + agent, + model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test") }, + system: [], + messages, + tools: {}, +}) + +const settle = (persisted: ReadonlyArray, expected: number, remaining = 1000): Effect.Effect => + Effect.gen(function* () { + if (persisted.length >= expected) return + if (remaining === 0) { + return yield* Effect.fail(new Error(`Timed out waiting for ${expected} reminders, saw ${persisted.length}`)) + } + yield* Effect.promise(() => Bun.sleep(1)) + yield* settle(persisted, expected, remaining - 1) + }) + +/** The exact reminder texts, derived from plugin behavior rather than duplicated here. */ +const reminders = Effect.gen(function* () { + const planRun = yield* run() + yield* planRun.contextHook(request(plan, [])) + const buildRun = yield* run() + yield* buildRun.contextHook(request(build, [Message.user(planRun.persisted[0]!)])) + return { enter: planRun.persisted[0]!, leave: buildRun.persisted[0]! } +}) + +describe("plan plugin reminders", () => { + it.effect("injects enter and leave reminders on agent switches", () => + Effect.gen(function* () { + const { persisted } = yield* run([agentSelected(plan, build), agentSelected(build, plan)]) + yield* settle(persisted, 2) + expect(persisted[0]).toContain("You are in Plan mode") + expect(persisted[1]).toContain("NO LONGER in Plan mode") + }), + ) + + it.effect("reconciles a missing enter reminder into the request and persists it", () => + Effect.gen(function* () { + const { persisted, contextHook } = yield* run() + const messages = [Message.user("what agent are you?")] + yield* contextHook(request(plan, messages)) + expect(messages).toHaveLength(2) + // Inserted before the user's prompt, matching where agent-switch reminders land. + const first = messages[0]?.content[0] + expect(first?.type === "text" && first.text).toContain("You are in Plan mode") + expect(persisted).toHaveLength(1) + }), + ) + + it.effect("does nothing when the transcript already has a live enter reminder", () => + Effect.gen(function* () { + const { enter } = yield* reminders + const { persisted, contextHook } = yield* run() + const messages = [Message.user(enter), Message.user("hello")] + yield* contextHook(request(plan, messages)) + expect(messages).toHaveLength(2) + expect(persisted).toHaveLength(0) + }), + ) + + it.effect("reconciles a stale enter reminder with a leave reminder", () => + Effect.gen(function* () { + const { enter } = yield* reminders + const { persisted, contextHook } = yield* run() + const messages = [Message.user(enter), Message.user("ok implement it")] + yield* contextHook(request(build, messages)) + expect(messages).toHaveLength(3) + const middle = messages[1]?.content[0] + expect(middle?.type === "text" && middle.text).toContain("NO LONGER in Plan mode") + expect(persisted).toHaveLength(1) + }), + ) + + it.effect("does nothing for non-plan sessions without plan history", () => + Effect.gen(function* () { + const { persisted, contextHook } = yield* run() + const messages = [Message.user("hello")] + yield* contextHook(request(build, messages)) + expect(messages).toHaveLength(1) + expect(persisted).toHaveLength(0) + }), + ) + + it.effect("does nothing when a leave reminder already follows the enter reminder", () => + Effect.gen(function* () { + const { enter, leave } = yield* reminders + const { persisted, contextHook } = yield* run() + const messages = [Message.user(enter), Message.user(leave), Message.user("continue")] + yield* contextHook(request(build, messages)) + expect(messages).toHaveLength(3) + expect(persisted).toHaveLength(0) + }), + ) + + it.effect("treats reminder text quoted inside a larger message as not live", () => + Effect.gen(function* () { + const { enter } = yield* reminders + const { persisted, contextHook } = yield* run() + // Mirrors a compaction checkpoint quoting the reminder inside . + const messages = [Message.user(`\n${enter}\n`)] + yield* contextHook(request(plan, messages)) + expect(messages).toHaveLength(2) + expect(persisted).toHaveLength(1) + }), + ) +})