@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user