feat: multi-work agent
CI / verify (pull_request) Failing after 9s

This commit is contained in:
2026-09-17 21:09:19 +02:00
parent ba9fddad9d
commit 5cf0beb74b
20 changed files with 2907 additions and 299 deletions
+464
View File
@@ -0,0 +1,464 @@
import { afterEach, expect, test } from "bun:test";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
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 { 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("/tmp/opencode/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: path,
source_workspace_id: id,
},
worktrees: [
{
path,
is_linked_worktree: path !== root,
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-5.6-luna", "gpt-5.6-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.4-fixture" },
command: {},
experimental: { terminal: {} },
generate: {},
provider: {},
permission: {},
plugin: {},
reference: {},
skill: {},
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);
+102
View File
@@ -0,0 +1,102 @@
import { Plugin, Agent, Model, Provider } from "@opencode/plugin/effect";
import { Rpc } from "@opencode/plugin/rpc";
import { Session } from "@opencode/schema/session";
import { SessionMessage } from "@opencode/schema/session-message";
import { Tool } from "@opencode/schema/tool";
import { Cause, Effect } from "effect";
import { z } from "zod";
import { appendFile } from "node:fs/promises";
import plugin from "../../src/index.js";
const probe = Rpc.define({
id: "multi-work-test",
events: {},
methods: {
delegate: {
input: z.object({
parent: z.string(),
child: z.string().optional(),
background: z.boolean(),
}),
output: z.string(),
},
},
});
export default Plugin.define({
id: plugin.id,
effect: (context) =>
Effect.gen(function* () {
const providerID = Provider.ID.make("fixture");
yield* context.provider.transform((editor) =>
editor.add({
info: {
...Provider.Info.empty(providerID),
name: "Isolated fixture",
activation: "enabled",
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: process.env.FIXTURE_MODEL_URL! },
},
models: ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map((id) =>
Model.Info.default(providerID, Model.ID.make(id)),
),
}),
);
yield* plugin.effect({ ...context, options: { provider: "fixture" } });
let delegate: Tool.Info | undefined;
yield* context.tool.transform((editor) => {
delegate = editor.get("subagent");
});
yield* context.session.hook("prompt", (event) =>
Effect.gen(function* () {
const child = yield* context.session.get({ sessionID: event.sessionID });
yield* Effect.promise(() =>
appendFile(
process.env.FIXTURE_TRACE!,
JSON.stringify({
id: child.id,
parent: child.parentID,
agent: child.agent,
directory: child.location.directory,
}) + "\n",
),
);
}).pipe(Effect.orDie),
);
yield* context.rpc
.register(probe, {
delegate: (input) =>
Effect.gen(function* () {
if (!delegate) throw new Error("Native subagent missing");
const result = yield* delegate.execute(
{
agent: "coordinate",
description: "native host proof",
prompt: "Reply with FIXTURE_COMPLETE",
background: input.background,
...(input.child
? { sessionID: input.child }
: { worktree: { base: "main" } }),
},
{
sessionID: Session.ID.make(input.parent),
agent: Agent.ID.make("multi-work"),
messageID: SessionMessage.ID.create(),
id: Tool.CallID.make(`call_${crypto.randomUUID()}`),
progress: () => Effect.void,
},
);
return JSON.stringify(result);
}).pipe(
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.interrupt
: Effect.succeed(
JSON.stringify({ failure: Cause.pretty(cause) }),
),
),
),
})
.pipe(Effect.orDie);
}),
});
+153
View File
@@ -0,0 +1,153 @@
import { afterEach, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { command, Herdr } from "../src/herdr.js";
const directories: string[] = [];
afterEach(async () => {
for (const directory of directories.splice(0))
await rm(directory, { recursive: true, force: true });
});
async function fixture() {
const temporary = await mkdtemp("/tmp/opencode/multi-work-");
directories.push(temporary);
const root = join(temporary, "root");
const destination = join(temporary, "returned checkout");
await mkdir(root);
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",
"initial",
],
root,
signal,
);
await command(["git", "branch", "other"], root, signal);
const calls: string[][] = [];
let open = true;
let linked = false;
let duplicate = false;
let branch = "";
const run = async (args: string[], cwd: string, signal: AbortSignal) => {
calls.push(args);
if (args[0] !== "herdr") return command(args, cwd, signal);
if (args[1] === "workspace")
return JSON.stringify({
result: {
workspaces: open
? [{ workspace_id: "w1" }, ...(duplicate ? [{ workspace_id: "w3" }] : [])]
: [],
},
});
if (args[2] === "list") {
const id = args[4]!;
const path = id === "w2" ? destination : root;
return JSON.stringify({
result: {
source: {
repo_key: join(root, ".git"),
repo_root: root,
source_checkout_path: path,
source_workspace_id: id,
},
worktrees: [
{
path,
is_linked_worktree: id === "w2" || linked,
is_bare: false,
open_workspace_id: id,
},
],
},
});
}
expect(args[2]).toBe("create");
expect(args.slice(3, 5)).toEqual(["--cwd", root]);
expect(args.at(-1)).toBe("--no-focus");
branch = args[args.indexOf("--branch") + 1]!;
const base = args[args.indexOf("--base") + 1]!;
// Real Git worktree behavior, but only in an isolated temporary repository.
await command(["git", "worktree", "add", "-b", branch, destination, base], root, signal);
return JSON.stringify({
result: {
workspace: { workspace_id: "w2" },
worktree: { path: destination, branch, is_linked_worktree: true },
},
});
};
return {
root,
destination,
signal,
calls,
herdr: new Herdr(run),
close() {
open = false;
},
link() {
linked = true;
},
duplicate() {
duplicate = true;
},
};
}
test("live inventory recognizes an unannotated root and creates a unique branch from another local branch", async () => {
const f = await fixture();
const root = await f.herdr.root(f.root, f.signal);
expect(root?.workspaceID).toBe("w1");
const destination = await f.herdr.create(root!, "other", "multi-work/fixture", f.signal);
expect(destination.directory).toBe(f.destination);
expect(destination.branch).toStartWith("multi-work/");
expect(await command(["git", "branch", "--show-current"], f.root, f.signal)).toBe("main");
await f.herdr.verify(
destination.directory,
destination.workspaceID,
root!.repo,
destination.branch,
f.signal,
);
});
test("closed, linked and ambiguous workspaces fail root gating", async () => {
for (const mode of ["close", "link", "duplicate"] as const) {
const f = await fixture();
f[mode]();
expect(await f.herdr.root(f.root, f.signal)).toBeUndefined();
}
});
test("revalidates before mutation and rejects non-branch refs without creating a worktree", async () => {
const f = await fixture();
const root = (await f.herdr.root(f.root, f.signal))!;
const oid = await command(["git", "rev-parse", "HEAD"], f.root, f.signal);
for (const base of [oid, "--help", "main; touch injected"]) {
await expect(f.herdr.create(root, base, "multi-work/fixture", f.signal)).rejects.toThrow();
}
f.close();
await expect(f.herdr.create(root, "main", "multi-work/fixture", f.signal)).rejects.toThrow(
"no longer eligible",
);
expect(f.calls.filter((args) => args[0] === "herdr" && args[2] === "create")).toHaveLength(0);
});
test("a missing or malformed Herdr CLI never qualifies", async () => {
const f = await fixture();
await expect(new Herdr(async () => "not json").root(f.root, f.signal)).rejects.toThrow();
await expect(
new Herdr(async () => {
throw new Error("ENOENT");
}).root(f.root, f.signal),
).rejects.toThrow("ENOENT");
});
+2 -2
View File
@@ -113,7 +113,7 @@ async function install(
'await Bun.write(process.env.IMPORT_MARKER, "imported");\n' +
(mode === "legacy"
? 'export default { id: "stanp-opencode", async server() { return {}; } };\n'
: 'export default { id: "stanp-opencode", setup() { return "native-ready"; } };\n'),
: 'export default { id: "stanp-opencode", effect() { return "native-ready"; } };\n'),
),
writeFile(
join(fixture, "dist", "tui.js"),
@@ -207,7 +207,7 @@ test("installs an importable native plugin and default config under XDG without
[
process.execPath,
"-e",
"const { default: plugin } = await import(process.argv[1]); console.log(JSON.stringify([plugin.id, plugin.setup()]));",
"const { default: plugin } = await import(process.argv[1]); console.log(JSON.stringify([plugin.id, plugin.effect()]));",
join(config, "plugins", "stanp-opencode"),
],
directory,
+583
View File
@@ -0,0 +1,583 @@
import { expect, test } from "bun:test";
import { Effect } from "effect";
import { Agent } from "@opencode/schema/agent";
import { Session } from "@opencode/schema/session";
import { SessionMessage } from "@opencode/schema/session-message";
import { Tool } from "@opencode/schema/tool";
import type { Context } from "@opencode/plugin/effect/plugin";
import type { ToolEditor, ToolHooks } from "@opencode/plugin/effect/tool";
import type { AgentEditor } from "@opencode/plugin/effect/agent";
import type { SessionContext } from "@opencode/plugin/effect/session";
import { z } from "zod";
import { Herdr, type HerdrRoot } from "../src/herdr.js";
import { bindingKey, bindingSchema, installMultiWork } from "../src/multi-work.js";
class FixtureHerdr extends Herdr {
eligible = true;
creations = 0;
verified = 0;
delayCreation = false;
creationAborted = false;
creationStarted = Promise.withResolvers<void>();
override async root(directory: string) {
return this.eligible && directory === "/root"
? { directory, workspaceID: "w1", repo: "/root/.git" }
: undefined;
}
override async create(_root: HerdrRoot, _base: string, branch: string, signal: AbortSignal) {
this.creations++;
if (!this.eligible) throw new Error("root closed");
if (this.delayCreation) {
this.creationStarted.resolve();
await new Promise<void>((_resolve, reject) => {
signal.addEventListener(
"abort",
() => {
this.creationAborted = true;
reject(new Error("creation interrupted"));
},
{ once: true },
);
});
}
return { directory: "/worktree", workspaceID: "w2", branch };
}
override async verify() {
this.verified++;
}
}
interface FixtureSession {
id: Session.ID;
projectID: string;
agent: string;
parentID: string | undefined;
location: { directory: string };
permissions: Agent.Info["permissions"];
}
function fixture() {
const herdr = new FixtureHerdr();
const storage = new Map<string, Parameters<Context["storage"]["set"]>[1]>();
const agents = new Map<string, NonNullable<ReturnType<AgentEditor["get"]>>>();
for (const name of ["coordinate", "fast-lo", "fast-hi", "good-lo", "good-hi"])
agents.set(name, { ...Agent.Info.default(Agent.ID.make(name)), permissions: [] });
const parent: FixtureSession = {
id: Session.ID.make("ses_parent"),
projectID: "project-fixture",
agent: "multi-work",
parentID: undefined,
location: { directory: "/root" },
permissions: [],
};
const sessions = new Map([[String(parent.id), parent]]);
const order: string[] = [];
let cancelled = false;
let hold = false;
let delayMove = false;
let crossProject = false;
let destinationAvailable = true;
let warming = false;
let agentReads = 0;
let prompts = 0;
let serial = 0;
const entered = Promise.withResolvers<void>();
const progress: Tool.Metadata[] = [];
const native: Tool.Info = {
name: "subagent",
description: "native",
input: z.object({}),
options: { codemode: false },
execute: (raw, call) =>
Effect.gen(function* () {
const input = z
.object({
agent: z.string(),
sessionID: z.string().optional(),
background: z.boolean().optional(),
})
.parse(raw);
const id = Session.ID.make(input.sessionID ?? `ses_child${++serial}`);
if (!input.sessionID)
sessions.set(id, {
...parent,
id,
parentID: call.sessionID,
agent: input.agent,
location: { directory: "/root" },
});
order.push("create");
yield* call.progress({ sessionID: id, status: "running" });
order.push(`prompt:${sessions.get(id)!.location.directory}`);
prompts++;
entered.resolve();
if (hold)
yield* Effect.never.pipe(
Effect.onInterrupt(() =>
Effect.sync(() => {
cancelled = true;
}),
),
);
return {
content: "native result",
metadata: { sessionID: id, status: input.background ? "running" : "completed" },
};
}),
};
let tool = native;
const toolHooks: ((event: ToolHooks["execute.before"]) => Effect.Effect<void, Tool.Error>)[] =
[];
const hooks: ((event: SessionContext) => Effect.Effect<void>)[] = [];
let transform!: (editor: AgentEditor) => void;
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) {
// SAFETY: Agent.Info.default returns a fresh draft that the fixture exclusively owns.
const agent = (agents.get(id) ?? Agent.Info.default(Agent.ID.make(id))) as NonNullable<
ReturnType<AgentEditor["get"]>
>;
update(agent);
agents.set(id, agent);
},
});
const editTools: ToolEditor = {
list: () => [{ ...tool, id: tool.name }],
get: () => ({ ...tool, id: tool.name }),
add() {},
namespace() {},
remove() {},
update(_id, update) {
const draft = { ...tool };
update(draft);
tool = draft;
},
};
const host = {
options: {},
location: { directory: "/root" },
storage: {
get: (key: string) => Effect.sync(() => storage.get(key)),
set: (key: string, value: Parameters<Context["storage"]["set"]>[1]) =>
Effect.sync(() => {
storage.set(key, value);
}),
},
agent: {
transform: (callback: typeof transform) =>
Effect.sync(() => {
transform = callback;
callback(editAgents());
}).pipe(Effect.andThen(registration)),
reload: () =>
Effect.sync(() => {
if (transform) transform(editAgents());
}),
get: () => Effect.succeed({ data: agents.get("multi-work")! }),
list: () =>
Effect.sync(() => {
agentReads++;
if (!destinationAvailable) throw new Error("Destination location unavailable");
return { data: warming && agentReads === 1 ? [] : [...agents.values()] };
}),
},
session: {
get: ({ sessionID }: { sessionID: string }) =>
Effect.sync(() => sessions.get(sessionID)!),
move: ({ sessionID, directory }: { sessionID: string; directory: string }) =>
Effect.sync(() => {
order.push("move-request");
if (!delayMove) {
sessions.get(sessionID)!.location.directory = directory;
order.push("moved");
if (crossProject) sessions.get(sessionID)!.projectID = "another-project";
}
entered.resolve();
}),
hook: (_name: string, callback: (event: SessionContext) => Effect.Effect<void>) =>
Effect.sync(() => {
hooks.push(callback);
}).pipe(Effect.andThen(registration)),
},
tool: {
transform: (callback: (editor: ToolEditor) => void) =>
Effect.sync(() => callback(editTools)).pipe(Effect.andThen(registration)),
hook: (
_name: string,
callback: (event: ToolHooks["execute.before"]) => Effect.Effect<void, Tool.Error>,
) =>
Effect.sync(() => {
toolHooks.push(callback);
}).pipe(Effect.andThen(registration)),
},
};
// SAFETY: The test setup only receives its exercised services; unexpected host dependencies fail loudly.
const install = installMultiWork as (
context: Pick<Context, "options">,
buildPermissions: () => Agent.Info["permissions"],
herdr: Herdr,
) => ReturnType<typeof installMultiWork>;
const call: Tool.Context = {
sessionID: parent.id,
agent: Agent.ID.make("multi-work"),
messageID: SessionMessage.ID.make("msg_test"),
id: Tool.CallID.make("call_test"),
progress: (value) =>
Effect.sync(() => {
progress.push(value);
}),
};
return {
herdr,
agents,
sessions,
parent,
storage,
order,
entered,
hooks,
toolHooks,
call,
progress,
install: install(
host,
() =>
agents.get("build")?.permissions ??
Agent.Info.default(Agent.ID.make("build")).permissions,
herdr,
),
invoke: (
input = {
agent: "coordinate",
description: "test",
prompt: "work",
worktree: { base: "main" },
},
caller = call,
) => tool.execute(input, caller),
execute: (input: z.infer<typeof request>, caller = call) => tool.execute(input, caller),
hold() {
hold = true;
},
delayMove() {
delayMove = true;
},
missingDestination() {
destinationAvailable = false;
},
crossProject() {
crossProject = true;
},
warmDestination() {
warming = true;
},
get agentReads() {
return agentReads;
},
get cancelled() {
return cancelled;
},
get prompts() {
return prompts;
},
get tool() {
return tool;
},
};
}
const request = z.object({
agent: z.string(),
description: z.string(),
prompt: z.string(),
worktree: z.object({ base: z.string() }).optional(),
sessionID: z.string().optional(),
background: z.boolean().optional(),
});
test("Effect-native wrapper preserves name, metadata and result; moves before prompt and resumes its bound child", async () => {
const f = fixture();
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
expect(f.agents.get("multi-work")?.mode).toBe("primary");
expect(f.tool.name).toBe("subagent");
expect(f.tool.options?.codemode).toBe(false);
expect(f.agents.get("coordinate")?.mode).toBe("all");
const result = yield* f.invoke();
expect(result.content).toStartWith("native result");
expect(result.content).toContain("Checkout: /worktree");
expect(result.metadata?.sessionID).toBe("ses_child1");
expect(f.order).toEqual(["create", "move-request", "moved", "prompt:/worktree"]);
expect(f.parent.location.directory).toBe("/root");
expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1"))).ready).toBe(
true,
);
const resume = yield* f.execute({
agent: "coordinate",
description: "resume",
prompt: "continue",
sessionID: "ses_child1",
background: true,
});
expect(resume.metadata?.status).toBe("running");
expect(f.herdr.creations).toBe(1);
expect(f.herdr.verified).toBe(1);
}),
),
);
});
test("local fast-lo uses native delegation without a worktree", async () => {
const f = fixture();
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
yield* f.execute({ agent: "fast-lo", description: "lookup", prompt: "read" });
expect(f.herdr.creations).toBe(0);
expect(f.order).toEqual(["create", "prompt:/root"]);
}),
),
);
});
test("root loss, session permission denial and missing destination fail before prompting", async () => {
for (const failure of ["root", "permission", "ask", "destination", "project"] as const) {
const f = fixture();
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
if (failure === "root") f.herdr.eligible = false;
if (failure === "permission")
f.parent.permissions = [
{ action: "herdr_worktree", resource: "*", effect: "deny" },
];
if (failure === "destination") f.missingDestination();
if (failure === "project") f.crossProject();
if (failure === "ask")
f.parent.permissions = [
{ action: "herdr_worktree", resource: "*", effect: "ask" },
];
const result = yield* Effect.exit(f.invoke());
expect(result._tag).toBe("Failure");
expect(f.prompts).toBe(0);
expect(f.herdr.creations).toBe(
failure === "destination" || failure === "project" ? 1 : 0,
);
}),
),
);
}
});
test("interruption crosses the wrapper into the native executor finalizer", async () => {
const f = fixture();
f.hold();
const controller = new AbortController();
const running = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
return yield* f.invoke();
}),
),
{ signal: controller.signal },
);
await f.entered.promise;
// entered may resolve at move request; yield until the native foreground wait is reached.
while (f.prompts === 0) await Bun.sleep(1);
controller.abort();
await expect(running).rejects.toBeDefined();
expect(f.cancelled).toBe(true);
});
test("move admission alone never admits a task; cancellation retains an unready binding", async () => {
const f = fixture();
f.delayMove();
const controller = new AbortController();
const running = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
return yield* f.invoke();
}),
),
{ signal: controller.signal },
);
await f.entered.promise;
expect(f.prompts).toBe(0);
controller.abort();
await expect(running).rejects.toBeDefined();
expect(f.prompts).toBe(0);
expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1"))).ready).toBe(false);
});
test("rejects foreign and changed-agent resumes, non-primary multi-work and unbound coordinate calls", async () => {
const f = fixture();
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
yield* f.invoke();
f.sessions.get("ses_child1")!.agent = "fast-lo";
expect(
(yield* Effect.exit(
f.execute({
agent: "coordinate",
description: "resume",
prompt: "x",
sessionID: "ses_child1",
}),
))._tag,
).toBe("Failure");
f.parent.parentID = "ses_foreign";
expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure");
f.parent.parentID = undefined;
expect(
(yield* Effect.exit(
f.invoke(undefined, { ...f.call, agent: Agent.ID.make("build") }),
))._tag,
).toBe("Failure");
expect(
(yield* Effect.exit(
f.execute({ agent: "multi-work", description: "x", prompt: "x" }),
))._tag,
).toBe("Failure");
expect(f.herdr.creations).toBe(1);
}),
),
);
});
test("ineligible roots do not register multi-work; multi-work cannot execute shell even with session overrides", async () => {
const f = fixture();
f.herdr.eligible = false;
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
expect(f.agents.has("multi-work")).toBe(false);
const hook = f.toolHooks[0]!;
expect(
(yield* Effect.exit(hook({ ...f.call, tool: "shell", input: {} })))._tag,
).toBe("Failure");
}),
),
);
});
test("interruption during Herdr creation aborts the command and retains an inspectable attempt without prompting", async () => {
const f = fixture();
f.herdr.delayCreation = true;
const controller = new AbortController();
const running = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
return yield* f.invoke();
}),
),
{ signal: controller.signal },
);
await f.herdr.creationStarted.promise;
controller.abort();
await expect(running).rejects.toBeDefined();
expect(f.herdr.creationAborted).toBe(true);
expect(f.prompts).toBe(0);
expect(f.storage.has(`multi-work-call:${f.call.sessionID}:${f.call.id}`)).toBe(true);
});
test("waits for asynchronous destination plugin activation before moving or prompting", async () => {
const f = fixture();
f.warmDestination();
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
yield* f.invoke();
expect(f.agentReads).toBe(2);
expect(f.prompts).toBe(1);
expect(f.order).toEqual(["create", "move-request", "moved", "prompt:/worktree"]);
}),
),
);
});
test("configured build restrictions remain effective for worktree preparation", async () => {
const f = fixture();
f.agents.set("build", {
...Agent.Info.default(Agent.ID.make("build")),
permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }],
});
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure");
expect(f.herdr.creations).toBe(0);
}),
),
);
});
test("failed initialization records the attempt and never blindly recreates on replay", async () => {
const f = fixture();
f.missingDestination();
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure");
expect((yield* Effect.exit(f.invoke()))._tag).toBe("Failure");
expect(f.herdr.creations).toBe(1);
expect(f.prompts).toBe(0);
const record = z
.object({ child: z.string(), branch: z.string() })
.parse(f.storage.get(`multi-work-call:${f.call.sessionID}:${f.call.id}`));
expect(record.child).toBe("ses_child1");
expect(record.branch).toStartWith("multi-work/");
}),
),
);
});
test("same-child concurrent resumes are rejected while the native foreground executor is active", async () => {
const f = fixture();
f.hold();
const controller = new AbortController();
const checked = Promise.withResolvers<void>();
const running = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
yield* f.invoke().pipe(Effect.forkScoped);
yield* Effect.promise(() => f.entered.promise);
const second = yield* Effect.exit(
f.execute({
agent: "coordinate",
description: "resume",
prompt: "continue",
sessionID: "ses_child1",
}),
);
expect(second._tag).toBe("Failure");
expect(f.herdr.creations).toBe(1);
checked.resolve();
return yield* Effect.never;
}),
),
{ signal: controller.signal },
);
await Promise.race([checked.promise, running]);
controller.abort();
await expect(running).rejects.toBeDefined();
expect(f.cancelled).toBe(true);
});
+84 -16
View File
@@ -10,8 +10,9 @@ import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client";
import type { SessionContext } from "@opencode/plugin/promise/session";
import { z } from "zod";
import { gptAgents } from "../src/config.js";
import plugin from "../src/index.js";
import { basePlugin as plugin } from "../src/index.js";
import { playwrightMcpCommand } from "../src/playwright.js";
import { bindingKey } from "../src/multi-work.js";
const providerID = Provider.ID.make("test");
function model(id: string): ModelInfo {
@@ -63,6 +64,7 @@ async function host(
let before!: (event: { tool: string; agent: string; input: unknown }) => Promise<void> | void;
let parent = "build";
let child = "fast-lo";
let childDirectory = directory;
let defaultAgent = "build";
const registration = { async dispose() {} };
let transform!: (editor: AgentEditor) => void;
@@ -218,8 +220,13 @@ async function host(
},
async get({ sessionID }: { sessionID: string }) {
return sessionID === "child"
? { agent: child, parentID: "parent" }
: { agent: parent };
? {
agent: child,
projectID: "project-fixture",
parentID: "parent",
location: { directory: childDirectory },
}
: { agent: parent, projectID: "project-fixture", location: { directory } };
},
},
tool: {
@@ -299,6 +306,19 @@ async function host(
child = target;
for (const hook of sessionHooks.prompt ?? []) await hook({ sessionID: "child" });
},
bindCoordinate(ready: boolean, moved = true) {
childDirectory = moved ? `${directory}/worktree` : directory;
storage.set(bindingKey("child"), {
owner: "parent",
child: "child",
projectID: "project-fixture",
ready,
root: { directory, workspaceID: "w1", repo: `${directory}/.git` },
directory: `${directory}/worktree`,
workspaceID: "w2",
branch: "multi-work/test",
});
},
async tools(caller: string) {
const event: ModelContext = {
agent: Agent.ID.make(caller),
@@ -318,28 +338,52 @@ async function host(
};
}
test("missing required models disables registration; only GPT mode degrades without Astra", async () => {
test("orchestration inherits deep-lo and falls back to good-lo with available worker sets", async () => {
const full = await host();
expect(full.agents.has("deep-hi")).toBe(true);
expect(String(full.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra");
expect(String(full.agents.get("coordinate")?.model?.variant)).toBe("low");
const reduced = await host(allModels.slice(0, 2));
expect(reduced.agents.has("deep-hi")).toBe(false);
expect(String(reduced.agents.get("coordinate")?.model?.id)).toBe("gpt-5.6-sol");
expect(String(reduced.agents.get("coordinate")?.model?.variant)).toBe("low");
expect(reduced.agents.get("coordinate")?.system).toContain("Deep agents are unavailable");
expect((await host([])).agents.has("coordinate")).toBe(false);
expect((await host(allModels.slice(1))).agents.has("coordinate")).toBe(false);
const custom = await host([...allModels, model("local")], {
const customAgents = {
...gptAgents,
"good-lo": { model: "custom-good", effort: "high" as const },
"deep-lo": { model: "custom-deep", effort: "medium" as const },
};
const custom = await host(
[...allModels, reasoningModel("custom-good"), reasoningModel("custom-deep")],
{
mode: "custom",
provider: "test",
agents: customAgents,
},
);
expect(custom.agents.get("coordinate")?.model).toMatchObject({
id: "custom-deep",
variant: "medium",
});
const customFallback = await host([...allModels, reasoningModel("custom-good")], {
mode: "custom",
provider: "test",
agents: { ...gptAgents, coordinate: { model: "local", effort: "low" } },
agents: customAgents,
});
expect(String(custom.agents.get("coordinate")?.model?.id)).toBe("local");
expect(customFallback.agents.get("coordinate")?.model).toMatchObject({
id: "custom-good",
variant: "high",
});
expect(customFallback.agents.has("deep-lo")).toBe(false);
expect(
(
await host(allModels.slice(0, 2), {
await host(allModels, {
mode: "custom",
provider: "test",
agents: gptAgents,
agents: customAgents,
})
).agents.has("coordinate"),
).toBe(false);
@@ -619,6 +663,27 @@ test("manual calls and command-created child sessions cannot bypass delegation r
expect((await result.tools("build")).subagent?.description).toBe("Native subagents");
});
test("coordinate prompt admission requires both owned provenance and a completed move; multi-work cannot be a child", async () => {
const result = await host();
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
"ready worktree binding",
);
result.bindCoordinate(false);
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
"ready worktree binding",
);
result.bindCoordinate(true, false);
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
"ready worktree binding",
);
result.bindCoordinate(true);
await result.command("multi-work", "coordinate");
await expect(result.command("build", "coordinate")).rejects.toThrow("ready worktree binding");
await expect(result.command("multi-work", "fast-lo")).rejects.toThrow("local fast-lo");
for (const invoke of [result.delegate, result.command])
await expect(invoke("build", "multi-work")).rejects.toThrow("primary only");
});
test("reasoning settings and Luna priority use native advertised or official support", async () => {
const luna = {
...model("gpt-5.6-luna"),
@@ -650,12 +715,12 @@ test("reasoning settings and Luna priority use native advertised or official sup
expect(disabled.agents.get("fast-lo")?.request.settings).toEqual({ reasoningEffort: "low" });
});
test("coordinator defaults expose their effort variant without relying on it for requests", async () => {
test("coordinator uses inherited effort and ignores the legacy custom assignment", async () => {
for (const options of [{ provider: "test" }, { mode: "gpt", provider: "test" }] as const) {
const result = await host(allModels, options);
expect(result.agents.get("coordinate")?.model).toMatchObject({
id: "gpt-6-astra",
variant: "medium",
variant: "low",
});
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
}
@@ -663,7 +728,7 @@ test("coordinator defaults expose their effort variant without relying on it for
const fallback = await host(allModels.slice(0, 2));
expect(fallback.agents.get("coordinate")?.model).toMatchObject({
id: "gpt-5.6-sol",
variant: "medium",
variant: "low",
});
expect(fallback.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
@@ -672,8 +737,11 @@ test("coordinator defaults expose their effort variant without relying on it for
provider: "test",
agents: { ...gptAgents, coordinate: { model: "local", effort: "xhigh" } },
});
expect(custom.agents.get("coordinate")?.model?.variant).toBeUndefined();
expect(custom.agents.get("coordinate")?.request.settings.reasoningEffort).toBe("xhigh");
expect(custom.agents.get("coordinate")?.model).toMatchObject({
id: "gpt-6-astra",
variant: "low",
});
expect(custom.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
});
test("the current host model API filters discovery and reloads changed availability", async () => {
@@ -685,12 +753,12 @@ test("the current host model API filters discovery and reloads changed availabil
const result = await host([]);
await result.refresh(allModels);
expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra");
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("medium");
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("low");
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
await result.refresh(allModels.slice(0, 2));
expect(result.agents.has("deep-lo")).toBe(false);
expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-5.6-sol");
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("medium");
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("low");
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
await result.refresh([]);
expect(result.agents.has("coordinate")).toBe(false);