Compare commits

...
3 changed files with 163 additions and 23 deletions
+1 -1
View File
@@ -236,7 +236,6 @@ const pre = [
MCPCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
...SystemPromptPlugin.Plugins,
@@ -275,6 +274,7 @@ const post = [
ConfigWebSearchPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
PlanPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
+38 -17
View File
@@ -2,14 +2,19 @@ export * as PlanPlugin from "./plan.js"
import { Message, ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Global } from "@opencode-ai/util/global"
import { Effect, Stream } from "effect"
import path from "path"
import { Agent } from "../agent.js"
import { Environment } from "../environment/index.js"
import { Permission } from "../permission.js"
import { SessionEvent } from "../session/event.js"
const plan = Agent.ID.make("plan")
const enter = `<system-reminder>
You are in Plan mode. You are not allowed to edit or create files, and you may not ask a subagent to do that either.
const enter = (directory: string) => `<system-reminder>
You are in Plan mode. You may only edit or create files in the Plan directory: ${directory}
You may not modify files outside that directory, and you may not ask a subagent to do that either.
You are in Plan mode until the user switches agents. Plan mode is not changed by user intent, tone, or imperative language. If the user asks you to change files, do not edit. Tell them they need to switch agents.
</system-reminder>`
@@ -21,30 +26,42 @@ You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply.
export const Plugin = define({
id: "opencode.plan",
effect: Effect.fn(function* (ctx) {
const environment = yield* Environment.Service
const global = yield* Global.Service
const directory = path.join(global.home, ".opencode", "plan")
const enterReminder = enter(directory)
yield* environment.files.mkdir(directory).pipe(Effect.orDie)
yield* ctx.agent.transform((draft) => {
draft.update(plan, (item) => {
item.name = Agent.Name.make("Plan")
item.description = "Read-only agent for exploring the codebase and planning work before implementation."
item.mode = "primary"
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
item.permissions.push({ action: "edit", resource: "*", effect: "deny" })
item.permissions.push({ action: "edit", resource: path.join(directory, "*"), effect: "allow" })
item.permissions.push({ action: "external_directory", resource: path.join(directory, "*"), effect: "allow" })
})
})
yield* ctx.tool.hook("execute.before", (event) => {
yield* ctx.tool.hook("execute.after", (event) => {
if (event.agent !== plan) return Effect.void
if (event.status !== "error") return Effect.void
if (event.tool !== "edit" && event.tool !== "write" && event.tool !== "patch") return Effect.void
return new ToolFailure({
message: `Cannot use ${event.tool} in Plan mode. You are in a read-only mode and must not modify files.`,
if (!(event.error.error instanceof Permission.BlockedError)) return Effect.void
event.error = new ToolFailure({
message: `Cannot use ${event.tool} to modify files outside the Plan directory: ${directory}`,
})
return Effect.void
})
// 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
const reminder = lastReminder(event.messages, enterReminder)
const missing = event.agent === plan && reminder !== enterReminder
const stale = event.agent !== plan && reminder === enterReminder
const text = missing ? enterReminder : 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
@@ -64,7 +81,7 @@ export const Plugin = define({
event.type === "session.created" || event.type === "session.agent.selected",
),
Stream.runForEach((event) => {
const text = switchReminder(event)
const text = switchReminder(event, enterReminder)
if (!text) return Effect.void
return ctx.session
.synthetic({
@@ -83,20 +100,24 @@ export const Plugin = define({
}),
})
function switchReminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
function switchReminder(
event: SessionEvent.Created | SessionEvent.AgentSelected,
enterReminder: string,
): string | undefined {
if (event.type === "session.created") {
if (event.data.agent !== plan) return
return enter
if (event.data.agent !== plan) return undefined
return enterReminder
}
if (event.data.agent === event.data.previous) return
if (event.data.agent === plan) return enter
if (event.data.agent === event.data.previous) return undefined
if (event.data.agent === plan) return enterReminder
if (event.data.previous === plan) return leave
return undefined
}
function lastReminder(messages: ReadonlyArray<Message>) {
function lastReminder(messages: ReadonlyArray<Message>, enterReminder: string) {
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
return part.text === enterReminder || part.text === leave ? part.text : found
}, undefined)
}
+124 -5
View File
@@ -1,22 +1,30 @@
import { describe, expect } from "bun:test"
import { Message } from "@opencode-ai/ai"
import { DateTime, Effect, Stream } from "effect"
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(),
@@ -30,17 +38,45 @@ const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentS
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" }],
} 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: () => Effect.succeed({ dispose: Effect.void }),
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: () => Effect.succeed({ dispose: Effect.void }),
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),
@@ -65,9 +101,16 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
},
},
}),
).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")
return { persisted, contextHook }
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 => ({
@@ -79,6 +122,19 @@ const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => (
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
@@ -104,6 +160,7 @@ describe("plan plugin reminders", () => {
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(planDirectory)
expect(persisted[1]).toContain("NO LONGER in Plan mode")
}),
)
@@ -178,3 +235,65 @@ describe("plan plugin reminders", () => {
}),
)
})
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, "nested", "*"), planAgent.permissions)
.effect,
).toBe("allow")
}),
)
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)
}),
)
})