import { describe, expect } from "bun:test" import { Message, ToolFailure } from "@opencode-ai/ai" import { DateTime, Effect, Stream, Types } from "effect" import type { SessionContext } from "@opencode-ai/plugin/effect/session" import type { ToolHooks } from "@opencode-ai/plugin/effect/tool" import { Agent } from "@opencode-ai/core/agent" import { Environment } from "@opencode-ai/core/environment/index" import { Event } from "@opencode-ai/schema/event" import { Model } from "@opencode-ai/core/model" import { PlanPlugin } from "@opencode-ai/core/plugin/plan" import { Permission } from "@opencode-ai/core/permission" 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 { Tool } from "@opencode-ai/schema/tool" import { Global } from "@opencode-ai/util/global" import path from "path" 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 home = "/home/plan-test" const planDirectory = path.join(home, ".opencode", "plan") 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 let toolHook: ((input: ToolHooks["execute.after"]) => Effect.Effect) | undefined const planAgent = { id: plan, name: Agent.Name.make("Plan"), request: { settings: {}, headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [ { action: "*", resource: "*", effect: "allow" }, { action: "external_directory", resource: "*", effect: "ask" }, ], } satisfies Types.DeepMutable const driver = Environment.makeMemoryDriver() 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: (callback) => { callback({ list: () => [planAgent], get: (id) => (id === plan ? planAgent : undefined), default: () => {}, update: (id, update) => { if (id === plan) update(planAgent) }, remove: () => {}, }) return Effect.succeed({ dispose: Effect.void }) }, }, tool: { transform: () => Effect.die("unused tool.transform"), hook: (name, callback) => { if (name === "execute.after") { // Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion toolHook = callback as unknown as (input: ToolHooks["execute.after"]) => Effect.Effect } return 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", }), ) }, }, }), ).pipe( Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })), Effect.provideService( Environment.Service, Environment.Service.of({ files: Environment.makeFiles(driver), spawner: driver.spawner }), ), ) if (!contextHook) return yield* Effect.die("plan plugin did not register a context hook") if (!toolHook) return yield* Effect.die("plan plugin did not register a tool hook") return { persisted, contextHook, toolHook, files: Environment.makeFiles(driver), planAgent } }) 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: {}, }) type ToolErrorEvent = Extract const toolError = (tool: "edit" | "write" | "patch", error: Tool.Error): ToolErrorEvent => ({ tool, input: {}, sessionID, agent: plan, messageID: SessionMessage.ID.make("msg_plan_tool"), id: Tool.CallID.make("call_plan_tool"), status: "error", error, }) 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[0]).toContain("optionally create or update plan documents") expect(persisted[0]).toContain(planDirectory) expect(persisted[0]).toContain("Do not modify any other files") 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) }), ) }) describe("plan plugin mutations", () => { it.effect("creates the Plan directory", () => Effect.gen(function* () { const { files } = yield* run() expect((yield* files.stat(planDirectory)).type).toBe("directory") }), ) it.effect("allows edits only inside the Plan directory", () => Effect.gen(function* () { const { planAgent } = yield* run() expect(Permission.evaluate("edit", path.join(planDirectory, "work.md"), planAgent.permissions).effect).toBe( "allow", ) expect(Permission.evaluate("edit", "/workspace/source.ts", planAgent.permissions).effect).toBe("deny") expect(Permission.evaluate("edit", "source.ts", planAgent.permissions).effect).toBe("deny") }), ) it.effect("allows the Plan directory external boundary", () => Effect.gen(function* () { const { planAgent } = yield* run() expect( Permission.evaluate("external_directory", path.join(planDirectory, "*"), planAgent.permissions).effect, ).toBe("allow") expect( Permission.evaluate("external_directory", path.join(planDirectory, "nested", "*"), planAgent.permissions) .effect, ).toBe("allow") expect(Permission.evaluate("external_directory", "/outside/*", planAgent.permissions).effect).toBe("ask") }), ) it.effect("rewrites blocked mutation failures with the Plan directory", () => Effect.gen(function* () { const { toolHook } = yield* run() for (const tool of ["edit", "write", "patch"] as const) { const event = toolError( tool, new ToolFailure({ message: "Unable to modify file", error: new Permission.BlockedError({ rules: [], permission: "edit", resources: ["source.ts"], }), }), ) yield* toolHook(event) expect(event.error.message).toContain("outside the Plan directory") expect(event.error.message).toContain(planDirectory) } }), ) it.effect("preserves mutation failures unrelated to permissions", () => Effect.gen(function* () { const { toolHook } = yield* run() const error = new ToolFailure({ message: "oldString was not found" }) const event = toolError("edit", error) yield* toolHook(event) expect(event.error).toBe(error) }), ) })