Files
stanp-opencode/tests/composite.test.ts
StanPonomarev 1fdc558ff7
CI / verify (pull_request) Successful in 9s
chore: bump gpt version
2026-09-30 13:55:21 +02:00

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);