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) }), ) })