mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 15:13:31 +00:00
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
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<SessionEvent.AgentSelected> = []) {
|
|
const persisted = new Array<string>()
|
|
let contextHook: ((input: SessionContext) => Effect.Effect<void>) | undefined
|
|
let toolHook: ((input: ToolHooks["execute.after"]) => Effect.Effect<void>) | 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<Agent.Info>
|
|
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<void>
|
|
}
|
|
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<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",
|
|
}),
|
|
)
|
|
},
|
|
},
|
|
}),
|
|
).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<Message>): SessionContext => ({
|
|
sessionID,
|
|
agent,
|
|
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test") },
|
|
system: [],
|
|
messages,
|
|
tools: {},
|
|
})
|
|
|
|
type ToolErrorEvent = Extract<ToolHooks["execute.after"], { readonly status: "error" }>
|
|
|
|
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<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[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 <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)
|
|
}),
|
|
)
|
|
})
|
|
|
|
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)
|
|
}),
|
|
)
|
|
})
|