496 lines
20 KiB
TypeScript
496 lines
20 KiB
TypeScript
import { afterEach, expect, test } from "bun:test";
|
|
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { Effect, PubSub, Schema, Stream } from "effect";
|
|
import { Agent, Model, Provider } from "@opencode/plugin";
|
|
import { Session } from "@opencode/schema/session";
|
|
import { SessionMessage } from "@opencode/schema/session-message";
|
|
import { Tool } from "@opencode/schema/tool";
|
|
import { AbsolutePath } from "@opencode/schema/schema";
|
|
import type { Context } from "@opencode/plugin/effect/plugin";
|
|
import type { AgentEditor } from "@opencode/plugin/effect/agent";
|
|
import type { ToolEditor } from "@opencode/plugin/effect/tool";
|
|
import type { SkillEditor } from "@opencode/plugin/effect/skill";
|
|
import type { SessionHooks } from "@opencode/plugin/effect/session";
|
|
import { z } from "zod";
|
|
import plugin from "../src/index.js";
|
|
import { command } from "../src/herdr.js";
|
|
|
|
// This is a composite plugin host fixture, not OpenCode's native executor/job runtime.
|
|
// The real Promise adapter validates API payloads; registries replay from fresh drafts.
|
|
const directories: string[] = [];
|
|
afterEach(async () => {
|
|
for (const directory of directories.splice(0))
|
|
await rm(directory, { recursive: true, force: true });
|
|
});
|
|
interface LocationInstance {
|
|
agents: Map<string, NonNullable<ReturnType<AgentEditor["get"]>>>;
|
|
admit: () => Effect.Effect<void[]>;
|
|
}
|
|
const scenario = async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), "composite-"));
|
|
directories.push(directory);
|
|
const root = join(directory, "root");
|
|
const destination = join(directory, "child");
|
|
const bin = join(directory, "bin");
|
|
await mkdir(root);
|
|
await mkdir(bin);
|
|
await mkdir(join(directory, "store"));
|
|
const signal = new AbortController().signal;
|
|
await command(["git", "init", "-b", "main"], root, signal);
|
|
await command(
|
|
[
|
|
"git",
|
|
"-c",
|
|
"user.name=Fixture",
|
|
"-c",
|
|
"user.email=fixture@example.test",
|
|
"commit",
|
|
"--allow-empty",
|
|
"-m",
|
|
"fixture",
|
|
],
|
|
root,
|
|
signal,
|
|
);
|
|
// Fake Herdr, real isolated Git operations. No real Herdr server is contacted.
|
|
const inventory = (path: string, id: string) =>
|
|
JSON.stringify({
|
|
result: {
|
|
source: {
|
|
repo_key: join(root, ".git"),
|
|
repo_root: root,
|
|
source_checkout_path: root,
|
|
source_workspace_id: "w1",
|
|
},
|
|
worktrees: [
|
|
{
|
|
path: root,
|
|
is_linked_worktree: false,
|
|
is_bare: false,
|
|
open_workspace_id: "w1",
|
|
},
|
|
...(path === root
|
|
? []
|
|
: [
|
|
{
|
|
path,
|
|
is_linked_worktree: true,
|
|
is_bare: false,
|
|
open_workspace_id: id,
|
|
},
|
|
]),
|
|
],
|
|
},
|
|
});
|
|
const cli = join(bin, "herdr");
|
|
await writeFile(
|
|
cli,
|
|
`#!/bin/sh\ncase "$1 $2" in\n"worktree create") git -C '${root}' worktree add -b "$6" '${destination}' "$8" >&2 || exit 1; printf '{"result":{"workspace":{"workspace_id":"w2"},"worktree":{"path":"${destination}","branch":"%s","is_linked_worktree":true}}}' "$6"; exit ;;\nesac\ncase "$1 $2 $4" in\n"workspace list ") echo '${JSON.stringify({ result: { workspaces: [{ workspace_id: "w1" }] } })}' ;;\n"worktree list w1") echo '${inventory(root, "w1")}' ;;\n"worktree list w2") echo '${inventory(destination, "w2")}' ;;\n*) exit 9 ;;\nesac\n`,
|
|
);
|
|
await chmod(cli, 0o755);
|
|
const sessions = new Map<string, Session.Info>();
|
|
const parent = Schema.decodeUnknownSync(Session.Info)({
|
|
id: "ses_parent",
|
|
projectID: "fixture",
|
|
agent: "multi-work",
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
location: { directory: root },
|
|
});
|
|
sessions.set(parent.id, parent);
|
|
const admissions: string[] = [];
|
|
return {
|
|
directory,
|
|
root,
|
|
destination,
|
|
bin,
|
|
sessions,
|
|
instances: new Map<string, LocationInstance>(),
|
|
admissions,
|
|
};
|
|
};
|
|
|
|
function host(fixture: Awaited<ReturnType<typeof scenario>>, directory: string) {
|
|
const location = { directory };
|
|
const agents = new Map<string, NonNullable<ReturnType<AgentEditor["get"]>>>();
|
|
const transforms: ((editor: AgentEditor) => void)[] = [];
|
|
const prompts: ((event: SessionHooks["prompt"]) => Effect.Effect<void>)[] = [];
|
|
const tools = new Map<string, Tool.Info>();
|
|
const toolTransforms: ((editor: ToolEditor) => void)[] = [];
|
|
let lateBuild: Agent.Info["permissions"] = [];
|
|
let lateMulti: Agent.Info["permissions"] = [];
|
|
let reloads = 0;
|
|
const updates = Effect.runSync(
|
|
PubSub.unbounded<Schema.Schema.Type<typeof Agent.Event.Updated>>(),
|
|
);
|
|
const registration = Effect.succeed({ dispose: Effect.void });
|
|
const editAgents: AgentEditor = {
|
|
get: (id) => agents.get(id),
|
|
list: () => [...agents.values()],
|
|
default() {},
|
|
remove: (id) => {
|
|
agents.delete(id);
|
|
},
|
|
update(id, update) {
|
|
const draft = agents.get(id) ?? {
|
|
...Agent.Info.default(Agent.ID.make(id)),
|
|
permissions: [],
|
|
};
|
|
update(draft);
|
|
agents.set(id, draft);
|
|
},
|
|
};
|
|
const replay = () => {
|
|
agents.clear();
|
|
agents.set("build", Agent.Info.default(Agent.ID.make("build")));
|
|
for (const transform of transforms) transform(editAgents);
|
|
// Match the ordering under review: configuration runs AFTER both plugin transforms.
|
|
editAgents.update("build", (agent) => {
|
|
agent.permissions.push(...lateBuild);
|
|
});
|
|
if (agents.has("multi-work"))
|
|
editAgents.update("multi-work", (agent) => {
|
|
agent.permissions.push(...lateMulti);
|
|
});
|
|
};
|
|
const reload = () =>
|
|
Effect.gen(function* () {
|
|
replay();
|
|
reloads++;
|
|
yield* PubSub.publish(
|
|
updates,
|
|
Schema.decodeUnknownSync(Agent.Event.Updated)({
|
|
id: `evt_${reloads}`,
|
|
created: Date.now(),
|
|
type: "agent.updated",
|
|
location,
|
|
data: {},
|
|
}),
|
|
);
|
|
});
|
|
const editTools: ToolEditor = {
|
|
get: (id) => {
|
|
const tool = tools.get(id);
|
|
return tool && { ...tool, id };
|
|
},
|
|
list: () => [...tools.values()].map((tool) => ({ ...tool, id: tool.name })),
|
|
add: (tool) => {
|
|
tools.set(tool.name, tool);
|
|
},
|
|
namespace() {},
|
|
remove: (id) => {
|
|
tools.delete(id);
|
|
},
|
|
update(id, update) {
|
|
const tool = tools.get(id);
|
|
if (tool) {
|
|
const draft = { ...tool };
|
|
update(draft);
|
|
tools.set(id, draft);
|
|
}
|
|
},
|
|
};
|
|
const native: Tool.Info = {
|
|
name: "subagent",
|
|
description: "fixture executor",
|
|
input: z.object({}),
|
|
execute: (raw, call) =>
|
|
Effect.gen(function* () {
|
|
const input = z.object({ sessionID: z.string().optional() }).parse(raw);
|
|
if (!input.sessionID) {
|
|
const parent = fixture.sessions.get(call.sessionID)!;
|
|
fixture.sessions.set("ses_child", {
|
|
...parent,
|
|
id: Session.ID.make("ses_child"),
|
|
parentID: parent.id,
|
|
agent: Agent.ID.make("coordinate"),
|
|
});
|
|
}
|
|
yield* call.progress({ sessionID: "ses_child", status: "running" });
|
|
const child = fixture.sessions.get("ses_child")!;
|
|
yield* fixture.instances.get(child.location.directory)!.admit();
|
|
fixture.admissions.push(child.location.directory);
|
|
return { content: "fixture result", metadata: { sessionID: "ses_child" } };
|
|
}),
|
|
};
|
|
const store = (key: string) =>
|
|
join(fixture.directory, "store", `${encodeURIComponent(key)}.json`);
|
|
const providerID = Provider.ID.make("fixture");
|
|
const models = ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map((id) => ({
|
|
...Model.Info.default(providerID, Model.ID.make(id)),
|
|
enabled: true,
|
|
}));
|
|
// The adapter reads these domain objects at construction; unimplemented operations
|
|
// stay undefined and fail loudly if the plugin starts using them.
|
|
const context = {
|
|
options: { provider: "fixture" },
|
|
location,
|
|
app: { version: "2.0.15-fixture" },
|
|
command: {},
|
|
experimental: { terminal: {} },
|
|
generate: {},
|
|
provider: {},
|
|
permission: {},
|
|
plugin: {},
|
|
reference: {},
|
|
skill: {
|
|
transform: (callback: (editor: SkillEditor) => void) =>
|
|
Effect.sync(() => {
|
|
const skills = new Map<string, NonNullable<ReturnType<SkillEditor["get"]>>>();
|
|
callback({
|
|
list: () => [...skills.values()],
|
|
get: (id) => skills.get(id),
|
|
add: (skill) => {
|
|
skills.set(skill.id, skill);
|
|
},
|
|
update(id, update) {
|
|
const skill = skills.get(id);
|
|
if (skill) update(skill);
|
|
},
|
|
remove: (id) => {
|
|
skills.delete(id);
|
|
},
|
|
});
|
|
}).pipe(Effect.andThen(registration)),
|
|
},
|
|
vcs: { branch: {} },
|
|
websearch: {},
|
|
worktree: {},
|
|
model: { list: () => Effect.succeed({ location, data: models }) },
|
|
event: { subscribe: () => Stream.fromPubSub(updates) },
|
|
integration: {
|
|
connect: {},
|
|
oauth: {},
|
|
command: {},
|
|
connection: {
|
|
active: () => Effect.succeed(undefined),
|
|
resolve: () => Effect.succeed(undefined),
|
|
},
|
|
},
|
|
rpc: {
|
|
register: () =>
|
|
Effect.succeed({ dispose: Effect.void, events: { emit: () => Effect.void } }),
|
|
},
|
|
mcp: {
|
|
transform: (callback: (editor: { get: () => undefined }) => void) =>
|
|
Effect.sync(() => callback({ get: () => undefined })).pipe(
|
|
Effect.andThen(registration),
|
|
),
|
|
},
|
|
storage: {
|
|
get: (key: string) =>
|
|
Effect.promise(async () => {
|
|
try {
|
|
return JSON.parse(await readFile(store(key), "utf8"));
|
|
} catch (error) {
|
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
return undefined;
|
|
throw error;
|
|
}
|
|
}),
|
|
set: (key: string, value: Schema.Json) =>
|
|
Effect.promise(() => writeFile(store(key), JSON.stringify(value))),
|
|
},
|
|
agent: {
|
|
list: (input?: { location?: { directory?: string } }) =>
|
|
Effect.sync(() => ({
|
|
location,
|
|
data: [
|
|
...(
|
|
fixture.instances.get(input?.location?.directory ?? directory)
|
|
?.agents ?? agents
|
|
).values(),
|
|
],
|
|
})),
|
|
get: ({ agentID }: { agentID: string }) =>
|
|
Effect.sync(() => ({ location, data: agents.get(agentID)! })),
|
|
transform: (callback: (editor: AgentEditor) => void) =>
|
|
Effect.sync(() => {
|
|
transforms.push(callback);
|
|
replay();
|
|
}).pipe(Effect.andThen(registration)),
|
|
reload,
|
|
},
|
|
tool: {
|
|
transform: (callback: (editor: ToolEditor) => void) =>
|
|
Effect.sync(() => {
|
|
toolTransforms.push(callback);
|
|
tools.clear();
|
|
tools.set(native.name, native);
|
|
for (const transform of toolTransforms) transform(editTools);
|
|
}).pipe(Effect.andThen(registration)),
|
|
hook: () => registration,
|
|
},
|
|
session: {
|
|
get: ({ sessionID }: { sessionID: string }) =>
|
|
Effect.sync(() => fixture.sessions.get(sessionID)!),
|
|
move: ({ sessionID, directory }: { sessionID: string; directory: string }) =>
|
|
Effect.sync(() => {
|
|
const child = fixture.sessions.get(sessionID)!;
|
|
fixture.sessions.set(sessionID, {
|
|
...child,
|
|
location: { directory: AbsolutePath.make(directory) },
|
|
});
|
|
}),
|
|
hook: (
|
|
name: string,
|
|
callback: (event: SessionHooks["prompt"]) => Effect.Effect<void>,
|
|
) =>
|
|
Effect.sync(() => {
|
|
if (name === "prompt") prompts.push(callback);
|
|
}).pipe(Effect.andThen(registration)),
|
|
},
|
|
};
|
|
// SAFETY: Exercise the exported composite through the real Promise adapter. The
|
|
// fixture implements precisely the accessed services; unused operations fail.
|
|
const install = plugin.effect as (
|
|
context: Pick<Context, "options">,
|
|
) => ReturnType<typeof plugin.effect>;
|
|
const call: Tool.Context = {
|
|
sessionID: Session.ID.make("ses_parent"),
|
|
agent: Agent.ID.make("multi-work"),
|
|
messageID: SessionMessage.ID.make("msg_fixture"),
|
|
id: Tool.CallID.make("call_fixture"),
|
|
progress: () => Effect.void,
|
|
};
|
|
const result = {
|
|
agents,
|
|
install: install(context),
|
|
replay: reload,
|
|
get reloads() {
|
|
return reloads;
|
|
},
|
|
configure(build: Agent.Info["permissions"], multi: Agent.Info["permissions"] = []) {
|
|
lateBuild = build;
|
|
lateMulti = multi;
|
|
return reload();
|
|
},
|
|
resume: () =>
|
|
tools.get("subagent")!.execute(
|
|
{
|
|
agent: "coordinate",
|
|
sessionID: "ses_child",
|
|
description: "resume",
|
|
prompt: "continue",
|
|
},
|
|
call,
|
|
),
|
|
create: () =>
|
|
tools.get("subagent")!.execute(
|
|
{
|
|
agent: "coordinate",
|
|
worktree: { base: "main" },
|
|
description: "new",
|
|
prompt: "work",
|
|
},
|
|
call,
|
|
),
|
|
admit: () =>
|
|
Effect.forEach(prompts, (hook) =>
|
|
hook({
|
|
sessionID: Session.ID.make("ses_child"),
|
|
messageID: SessionMessage.ID.make("msg_child"),
|
|
prompt: { text: "continue" },
|
|
delivery: "steer",
|
|
metadata: {},
|
|
}),
|
|
),
|
|
};
|
|
fixture.instances.set(directory, result);
|
|
return result;
|
|
}
|
|
|
|
test("exported composite inherits late build permissions, remains stable over two replays, and preserves later overrides", async () => {
|
|
const f = await scenario();
|
|
const oldPath = process.env.PATH;
|
|
process.env.PATH = `${f.bin}:${oldPath}`;
|
|
try {
|
|
const h = host(f, f.root);
|
|
const destination = host(f, f.destination);
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
yield* h.install;
|
|
yield* destination.install;
|
|
expect(h.agents.has("multi-work")).toBe(true);
|
|
expect(h.agents.get("multi-work")?.model).toEqual(
|
|
h.agents.get("coordinate")?.model,
|
|
);
|
|
expect(h.agents.get("multi-work")?.request).toEqual(
|
|
h.agents.get("coordinate")?.request,
|
|
);
|
|
const before = h.reloads;
|
|
yield* h.configure([
|
|
{ action: "herdr_worktree", resource: "*", effect: "deny" },
|
|
]);
|
|
yield* Effect.gen(function* () {
|
|
while (h.reloads < before + 2) yield* Effect.sleep("1 millis");
|
|
}).pipe(Effect.timeout("2 seconds"));
|
|
const inherited = () =>
|
|
h.agents
|
|
.get("multi-work")!
|
|
.permissions.filter((rule) => rule.action === "herdr_worktree");
|
|
const expected = [...inherited()];
|
|
expect(expected.at(-1)?.effect).toBe("deny");
|
|
for (let replay = 0; replay < 2; replay++) {
|
|
yield* h.replay();
|
|
expect(inherited()).toEqual(expected);
|
|
expect((yield* Effect.exit(h.create()))._tag).toBe("Failure");
|
|
}
|
|
yield* h.configure(
|
|
[{ action: "herdr_worktree", resource: "*", effect: "deny" }],
|
|
[{ action: "herdr_worktree", resource: "*", effect: "allow" }],
|
|
);
|
|
expect((yield* h.create()).content).toContain("fixture result");
|
|
const parent = f.sessions.get("ses_parent")!;
|
|
f.sessions.set(parent.id, {
|
|
...parent,
|
|
permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }],
|
|
});
|
|
expect((yield* Effect.exit(h.resume()))._tag).toBe("Failure");
|
|
}),
|
|
),
|
|
);
|
|
} finally {
|
|
process.env.PATH = oldPath;
|
|
}
|
|
}, 15_000);
|
|
|
|
test("separate exported composite instances admit a disk-bound child and resume after plugin scopes restart", async () => {
|
|
const f = await scenario();
|
|
const oldPath = process.env.PATH;
|
|
process.env.PATH = `${f.bin}:${oldPath}`;
|
|
try {
|
|
for (let restart = 0; restart < 2; restart++) {
|
|
const root = host(f, f.root);
|
|
const destination = host(f, f.destination);
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
yield* root.install;
|
|
yield* destination.install;
|
|
expect(root.agents.has("multi-work")).toBe(true);
|
|
expect(destination.agents.has("multi-work")).toBe(false);
|
|
if (restart === 0) yield* root.create();
|
|
yield* destination.admit();
|
|
expect((yield* root.resume()).content).toContain(f.destination);
|
|
const child = f.sessions.get("ses_child")!;
|
|
f.sessions.set(child.id, {
|
|
...child,
|
|
location: { directory: AbsolutePath.make(f.root) },
|
|
});
|
|
expect((yield* Effect.exit(destination.admit()))._tag).toBe("Failure");
|
|
f.sessions.set(child.id, child);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
expect(f.admissions).toEqual([f.destination, f.destination, f.destination]);
|
|
} finally {
|
|
process.env.PATH = oldPath;
|
|
}
|
|
}, 15_000);
|