fix(core): keep plan mode reminders in sync with session agent (#42652)

This commit is contained in:
Aiden Cline
2026-08-16 16:01:02 -05:00
committed by GitHub
parent ca589273c7
commit ec10d71f22
2 changed files with 211 additions and 3 deletions
+31 -3
View File
@@ -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<Message>) {
return messages.reduce<string | undefined>((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)
}
+180
View File
@@ -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<SessionEvent.AgentSelected> = []) {
const persisted = new Array<string>()
let contextHook: ((input: SessionContext) => Effect.Effect<void>) | 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<void>
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<Message>): SessionContext => ({
sessionID,
agent,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test") },
system: [],
messages,
tools: {},
})
const settle = (persisted: ReadonlyArray<string>, expected: number, remaining = 1000): Effect.Effect<void, Error> =>
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 <recent-context>.
const messages = [Message.user(`<conversation-checkpoint>\n${enter}\n</conversation-checkpoint>`)]
yield* contextHook(request(plan, messages))
expect(messages).toHaveLength(2)
expect(persisted).toHaveLength(1)
}),
)
})