Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline a7b55e3714 fix(core): restore plan mode reminder after compaction 2026-08-14 15:14:39 -05:00
2 changed files with 156 additions and 16 deletions
+28 -16
View File
@@ -1,7 +1,7 @@
export * as PlanPlugin from "./plan.js" export * as PlanPlugin from "./plan.js"
import { ToolFailure } from "@opencode-ai/ai" import { ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin" import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect" import { Effect, Stream } from "effect"
import { Agent } from "../agent.js" import { Agent } from "../agent.js"
import { SessionEvent } from "../session/event.js" import { SessionEvent } from "../session/event.js"
@@ -40,35 +40,47 @@ export const Plugin = define({
yield* ctx.event.subscribe().pipe( yield* ctx.event.subscribe().pipe(
Stream.filter( Stream.filter(
(event): event is SessionEvent.Created | SessionEvent.AgentSelected => (event): event is SessionEvent.Created | SessionEvent.AgentSelected | SessionEvent.Compaction.Ended =>
event.type === "session.created" || event.type === "session.agent.selected", event.type === "session.created" ||
event.type === "session.agent.selected" ||
event.type === "session.compaction.ended",
), ),
Stream.runForEach((event) => { Stream.runForEach((event) =>
const text = reminder(event) Effect.gen(function* () {
if (!text) return Effect.void const text = yield* reminder(ctx.session, event)
return ctx.session if (!text) return
.synthetic({ yield* ctx.session.synthetic({
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
text, text,
resume: false, resume: false,
}) })
.pipe( }).pipe(
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.logWarning("failed to inject Plan mode reminder", { sessionID: event.data.sessionID, cause }), Effect.logWarning("failed to inject Plan mode reminder", { sessionID: event.data.sessionID, cause }),
), ),
) ),
}), ),
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )
}), }),
}) })
function reminder(event: SessionEvent.Created | SessionEvent.AgentSelected) { const reminder = Effect.fnUntraced(function* (
session: Context["session"],
event: SessionEvent.Created | SessionEvent.AgentSelected | SessionEvent.Compaction.Ended,
) {
if (event.type === "session.created") { if (event.type === "session.created") {
if (event.data.agent !== plan) return if (event.data.agent !== plan) return
return enter return enter
} }
if (event.type === "session.compaction.ended") {
// Compaction cuts the transcript at the checkpoint, dropping any earlier enter
// reminder, so re-assert Plan mode when the session is still on the plan agent.
const info = yield* session.get({ sessionID: event.data.sessionID })
if (info.agent !== plan) return
return enter
}
if (event.data.agent === event.data.previous) return if (event.data.agent === event.data.previous) return
if (event.data.agent === plan) return enter if (event.data.agent === plan) return enter
if (event.data.previous === plan) return leave if (event.data.previous === plan) return leave
} })
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Stream } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Event } from "@opencode-ai/schema/event"
import { Money } from "@opencode-ai/schema/money"
import { PlanPlugin } from "@opencode-ai/core/plugin/plan"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Project } from "@opencode-ai/core/project"
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 envelope = () => ({
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
durable: { aggregateID: sessionID, seq: Event.Seq.make(0), version: Event.Version.make(1) },
})
const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentSelected => ({
...envelope(),
type: "session.agent.selected",
data: { sessionID, agent, previous },
})
const compactionEnded = (): SessionEvent.Compaction.Ended => ({
...envelope(),
type: "session.compaction.ended",
data: { sessionID, reason: "manual", text: "summary", recent: "" },
})
const sessionInfo = (agent?: Agent.ID): Session.Info => ({
id: sessionID,
projectID: Project.ID.make("test"),
agent,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/") },
})
/** Runs the plan plugin against stubbed domains and captures injected reminders. */
const run = Effect.fnUntraced(function* (
events: ReadonlyArray<SessionEvent.AgentSelected | SessionEvent.Compaction.Ended>,
agent?: Agent.ID,
) {
const captured = new Array<string>()
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: {
get: () => Effect.succeed(sessionInfo(agent)),
synthetic: (input) => {
captured.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",
}),
)
},
},
}),
)
return captured
})
const settle = (captured: ReadonlyArray<string>, expected: number, remaining = 1000): Effect.Effect<void, Error> =>
Effect.gen(function* () {
if (captured.length >= expected) return
if (remaining === 0) {
return yield* Effect.fail(new Error(`Timed out waiting for ${expected} reminders, saw ${captured.length}`))
}
yield* Effect.promise(() => Bun.sleep(1))
yield* settle(captured, expected, remaining - 1)
})
describe("plan plugin reminders", () => {
it.effect("injects enter and leave reminders on agent switches", () =>
Effect.gen(function* () {
const captured = yield* run([agentSelected(plan, build), agentSelected(build, plan)])
yield* settle(captured, 2)
expect(captured[0]).toContain("You are in Plan mode")
expect(captured[1]).toContain("NO LONGER in Plan mode")
}),
)
it.effect("re-injects the enter reminder after compaction while on the plan agent", () =>
Effect.gen(function* () {
const captured = yield* run([compactionEnded()], plan)
yield* settle(captured, 1)
expect(captured).toHaveLength(1)
expect(captured[0]).toContain("You are in Plan mode")
}),
)
it.effect("ignores compaction when the session is not on the plan agent", () =>
Effect.gen(function* () {
// The trailing switch to plan proves the earlier compaction event was processed.
const captured = yield* run([compactionEnded(), agentSelected(plan, build)], build)
yield* settle(captured, 1)
expect(captured).toHaveLength(1)
expect(captured[0]).toContain("You are in Plan mode")
}),
)
})