Files
stack/packages/cli/tests/thread-launch.test.mjs
T
jason.woltje 39eadf8ba8 Row 332 R2 (#1830): hermetic pi launches in cli tests, pi bundle default, review F2 and F3
The cli tests that launch pi deleted only the session login names, so pi's
preflight passed or refused by whatever provider keys the host exported. A
shared helper now deletes every credential pi's preflight reads and gives pi
launches one made-up key. The refusal itself is unchanged.

pi bundle() takes a default argument again, so a call with none returns the
non-proxy bundle, as before P3. pi.bundle leaves out a launch.models entry
whose provider has no proxy route instead of failing every pi launch (F2);
malformed proxy entries still refuse. The invalid session proxy context
BundleError gets the code invalid-proxy-context. New tests cover F2, the code,
and the three proxy account branches in the launcher (F3).
2026-11-02 15:22:17 -06:00

563 lines
32 KiB
JavaScript

// Thread sessions (row 121, #1615): the launcher starts a thread instance's
// session when its thread has a pending message or was opened, stops it once
// the thread is idle, and gives every run of a thread the thread's own store.
// Real runners in the bwrap sandbox; a stub adapter keeps a history in the
// store and answers with what it found there.
//
// The host is in process: startBroker on bootConfig's config, with the ops
// the broker child forwards (packages/bus/src/process.mjs). The real host
// proves a human CLI process for every human verb, which a test run under an
// agent can't, so the test's human capability comes from bindHuman directly.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { Client } from "../../bus/src/client.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Store } from "../../bus/src/store.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, readSessions } from "../../seat/src/session.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
import { mappingEntry } from "../../library/tests/helpers.mjs";
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
"INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?",
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?",
];
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
async function until(fn, ms = 20000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
// The assistant is the thread instance; sonnet is its model's limit.
function prepare(t, { sonnet = 2, idle = 1, edit = (doc) => doc } = {}) {
const root = tmp(t, "mosaic-thread-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet });
doc.launch.threadIdleSeconds = idle;
return edit(doc);
});
withoutHostCredentials(f.env, { piKey: true });
const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main");
writeFileSync(join(project, "file.txt"), "one\n");
gitIn(project, "add", "file.txt");
gitIn(project, "commit", "-q", "-m", "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
// A host and its launcher on the prepared root. close() runs the launcher's
// close hook, then closes the broker, as the real host does. `ops` replaces
// launch ops, given the runtime.
async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
const ops = {
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
...replace?.(runtime),
};
const refusing = (what, fn) => async (m) => {
try {
return fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const logs = [];
const launcher = createLauncher({
host,
system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [ADAPTER],
threadTickMs: tickMs,
log: (l) => logs.push(l),
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
return { host, runtime, launcher, call, logs, close };
}
const launchLog = (f) =>
existsSync(launchLogFile(f.dataRoot, "acme"))
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
: [];
const threadRuns = (f, thread) => readSessions(f.dataRoot).filter((s) => s.thread === thread);
// Send to a thread and wait for the assistant's RESULT reply in that thread.
async function ask(call, thread, body) {
const sent = call("thread.send", { thread, body });
const reply = await until(() => call("thread.messages", { thread }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
assert.equal(reply.from_role, "assistant");
assert.equal(reply.to_role, "human");
return { sent, reply, saw: JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]) };
}
// The run stops on its own once the thread is idle: no session, no holder.
async function idleStop(f, host, thread, run) {
await until(() => launchLog(f).find((e) => e.event === "end" && e.run === run));
assert.ok(launchLog(f).find((e) => e.event === "thread-idle" && e.run === run && e.thread === thread));
assert.deepEqual(threadRuns(f, thread), []);
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === thread).holder, null);
}
test("REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; its history survives the stop and a restart and no other thread sees it", { skip, timeout: 90000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
let host = await start(t, prepared);
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const B = host.call("thread.create", { project, name: "beta" }).id;
// Nothing runs for a thread with nothing pending.
await host.launcher.tick();
assert.deepEqual(readSessions(f.dataRoot), []);
const first = await ask(host.call, A, "first for A");
assert.deepEqual(first.saw, { body: "first for A", before: [], transcripts: [] });
const [run1] = launchLog(f).filter((e) => e.event === "launch" && e.thread === A).map((e) => e.run);
assert.ok(run1);
const store = join(f.dataRoot, "threads", "acme", A, "sessions", host.runtime.threadsState("acme").find((x) => x.thread === A).session);
assert.equal(readFileSync(join(store, "session", "history"), "utf8"), "first for A\n");
assert.deepEqual(readdirSync(join(store, "claude-projects")), ["turn-1.jsonl"]);
assert.equal(existsSync(join(f.dataRoot, "launches", "acme", run1, "session")), false, "a thread run keeps no per-run session directory");
assert.equal(existsSync(join(f.dataRoot, "threads", "acme", A, "session")), false, "a thread.create session keeps no thread-level store");
await idleStop(f, host, A, run1);
// A second run of the same thread finds the first run's history.
const second = await ask(host.call, A, "second for A");
assert.deepEqual(second.saw, { body: "second for A", before: ["first for A"], transcripts: ["turn-1.jsonl"] });
const run2 = launchLog(f).filter((e) => e.event === "launch" && e.thread === A).at(-1).run;
assert.notEqual(run2, run1);
await idleStop(f, host, A, run2);
// A restart: a new host and launcher on the same data root.
await host.close();
host = await start(t, prepared);
const third = await ask(host.call, A, "third for A");
assert.deepEqual(third.saw, { body: "third for A", before: ["first for A", "second for A"], transcripts: ["turn-1.jsonl", "turn-2.jsonl"] });
// Thread B has its own store, empty, and A's messages aren't in it.
const other = await ask(host.call, B, "for B");
assert.deepEqual(other.saw, { body: "for B", before: [], transcripts: [] });
assert.ok(!host.call("thread.messages", { thread: B }).some((m) => /for A/.test(m.body)));
assert.ok(!host.call("thread.messages", { thread: A }).some((m) => /for B/.test(m.body)));
// Opening a thread starts its session with nothing pending; it idles out too.
for (const r of readSessions(f.dataRoot)) await idleStop(f, host, r.thread, r.run);
host.call("thread.open", { thread: B });
const opened = await until(() => threadRuns(f, B)[0]);
await idleStop(f, host, B, opened.run);
});
test("REQ-THREAD-2 REQ-LAUNCH-1 a running thread counts against its model's limit and an idle-stopped one doesn't", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t, { sonnet: 1 });
const { f } = prepared;
const host = await start(t, prepared);
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const B = host.call("thread.create", { project, name: "beta" }).id;
const toA = host.call("thread.send", { thread: A, body: "for A" });
const toB = host.call("thread.send", { thread: B, body: "for B" });
const refused = await until(() => launchLog(f).find((e) => e.event === "refused" && e.thread === B));
assert.equal(refused.code, "capacity-full");
assert.equal(refused.instance, "assistant");
const replyB = await until(() => host.call("thread.messages", { thread: B }).find((m) => m.in_reply_to === toB.id), 30000);
assert.ok(host.call("thread.messages", { thread: A }).find((m) => m.in_reply_to === toA.id));
// B started only after A's run ended, and its refusal was logged once.
const entries = launchLog(f);
const endA = entries.findIndex((e) => e.event === "end" && e.run === entries.find((x) => x.event === "launch" && x.thread === A).run);
const launchB = entries.findIndex((e) => e.event === "launch" && e.thread === B);
assert.ok(endA >= 0 && launchB > endA, JSON.stringify(entries.map((e) => [e.event, e.thread, e.code])));
assert.equal(entries.filter((e) => e.event === "refused" && e.thread === B).length, 1);
assert.match(replyB.body, /THREAD/);
});
test("REQ-THREAD-2 REQ-LAUNCH-1 a turn longer than the idle time isn't stopped, and the thread idles out after it", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
// The stub's "slow" turn takes 3 s and the idle time is 1 s. The message is
// unread for the whole turn.
const sent = host.call("thread.send", { thread: A, body: "slow" });
const reply = () => host.call("thread.messages", { thread: A }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT");
await until(() => reply() || launchLog(f).find((e) => e.event === "thread-idle"), 30000);
assert.ok(reply(), JSON.stringify(launchLog(f)));
await idleStop(f, host, A, launchLog(f).find((e) => e.event === "launch" && e.thread === A).run);
});
// Dewey's F3 (28591): the tick decides from a snapshot. A message sent after it
// keeps the session, which answers it, instead of going to a run being stopped.
test("REQ-THREAD-2 REQ-LAUNCH-1 a message sent after the idle snapshot keeps the session, which answers it", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
let race = null;
// Ticks only when the test calls tick(). The snapshot is taken before the race's send.
const host = await start(t, prepared, {
tickMs: 3600000,
ops: (runtime) => ({
threadsState: (m) => {
const snapshot = runtime.threadsState(m.business);
race?.();
race = null;
return snapshot;
},
}),
});
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const reply = (sent) => host.call("thread.messages", { thread: A }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT");
const first = host.call("thread.send", { thread: A, body: "first for A" });
await host.launcher.tick();
await until(() => reply(first));
const [launch] = launchLog(f).filter((e) => e.event === "launch" && e.thread === A);
// Past the idle time, so the snapshot says stop.
await new Promise((r) => setTimeout(r, 1200));
let late;
race = () => (late = host.call("thread.send", { thread: A, body: "late for A" }));
await host.launcher.tick();
assert.ok(late, "the race ran");
assert.equal(launchLog(f).find((e) => e.event === "thread-idle"), undefined, JSON.stringify(launchLog(f)));
await until(() => reply(late));
assert.deepEqual(launchLog(f).filter((e) => e.event === "launch").map((e) => e.run), [launch.run]);
// With nothing waiting, the next tick past the idle time stops it.
await new Promise((r) => setTimeout(r, 1200));
await host.launcher.tick();
await idleStop(f, host, A, launch.run);
});
// A thread held by a run the launcher has no session for, as after a restart
// before recovery. The hold is the bus's, so the launcher starts nothing.
test("REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session", { skip, timeout: 30000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const held = host.runtime.bindLaunch({ business: "acme", role: "assistant", run: "held-by-hand", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid), thread: A });
host.runtime.broker.request(held.cap, { verb: "role.claim" });
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, "held-by-hand");
host.call("thread.send", { thread: A, body: "for A" });
for (let i = 0; i < 3; i++) await host.launcher.tick();
assert.deepEqual(readSessions(f.dataRoot), []);
assert.deepEqual(launchLog(f), []);
});
// Row 156 round 2 (Filbert's F2 in 29033): a thread launch asks landHeld, never landHolders, so a
// landHolders entry naming the thread instance gives its session no landing. Without the
// `thread ? null :` guard in start, the entry would reach session.json.
test("REQ-LAUNCH-1 a thread run's session file has no landing, even when landHolders names its instance", { skip, timeout: 30000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const listed = [{ instance: "assistant", decision: "d9", task_ref: "vikunja:1/9", resolved_at: "2026-10-18T00:00:00.000Z" }];
const host = await start(t, prepared, { ops: () => ({ landHolders: () => listed }) });
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const sent = host.call("thread.send", { thread: A, body: "for A" });
await until(() => host.call("thread.messages", { thread: A }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
const [launch] = launchLog(f).filter((e) => e.event === "launch" && e.thread === A);
const session = JSON.parse(readFileSync(join(f.dataRoot, "launches", "acme", launch.run, "session.json"), "utf8"));
assert.equal(Object.hasOwn(session, "landing"), false, JSON.stringify(session));
});
// A run that ends before it takes the message it was started for. The next
// start waits for a new message or open, so a run that dies at once doesn't
// start again on every tick.
test("REQ-LAUNCH-1 a run that ends without taking its thread's message isn't started again for that message", { skip, timeout: 30000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
// Ticks only when the test calls tick().
const host = await start(t, prepared, { tickMs: 3600000 });
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const held = host.runtime.bindLaunch({ business: "acme", role: "assistant", run: "held-by-hand", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid), thread: A });
host.call("thread.send", { thread: A, body: "for A" });
await host.launcher.tick();
// tick() returns once the run is bound and its capability is queued on its
// stdin. This claim is synchronous, so it comes before the run's own.
host.runtime.broker.request(held.cap, { verb: "role.claim" });
const [launch] = launchLog(f).filter((e) => e.event === "launch" && e.thread === A);
const end = await until(() => launchLog(f).find((e) => e.event === "end" && e.run === launch.run));
assert.equal(end.exitCode, 21, "the run's claim was refused");
host.runtime.broker.request(held.cap, { verb: "role.release" });
const state = host.runtime.threadsState("acme").find((x) => x.thread === A);
assert.deepEqual([state.holder, state.pending], [null, 1]);
for (let i = 0; i < 3; i++) await host.launcher.tick();
assert.equal(launchLog(f).filter((e) => e.event === "launch").length, 1);
assert.deepEqual(readSessions(f.dataRoot), []);
});
// A thread id that can't be a path segment, made by hand in the store before
// the host starts, and a thread of a non-thread instance. Neither launches.
test("REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thread of a non-thread instance, launches nothing", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const store = new Store(f.dataRoot);
const at = () => new Date().toISOString();
store.run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)", "p-hand", at(), "acme", "hand made", "stack", "jason");
for (const [id, instance] of [["../evil", "assistant"], ["coder-thread", "coder"]]) {
store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", id, at(), "acme", "p-hand", instance, "jason", id);
for (const sql of DEFAULT_SESSION) store.run(sql, id);
store.run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)", at(), id, id, "jason");
}
store.close();
const host = await start(t, prepared);
const refusals = await until(() => {
const r = launchLog(f).filter((e) => e.event === "refused");
return r.length === 2 && r;
});
assert.deepEqual(refusals.map((e) => [e.thread, e.instance, e.code]).sort(), [["../evil", "assistant", "thread-store-refused"], ["coder-thread", "coder", "thread-not-allowed"]]);
assert.match(refusals.find((e) => e.thread === "../evil").why, /thread "..\/evil" isn't an id/);
// More ticks log nothing more and start nothing.
for (let i = 0; i < 3; i++) await host.launcher.tick();
assert.equal(launchLog(f).length, 2);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.equal(existsSync(join(f.dataRoot, "threads")), false);
assert.equal(existsSync(join(f.dataRoot, "evil")), false);
assert.equal(existsSync(join(f.dataRoot, "launches", "acme")), false, "no run directory");
assert.equal(existsSync(join(f.dataRoot, "workspaces")), false, "no workspace");
});
test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a thread", { skip, timeout: 30000 }, async (t) => {
const prepared = prepare(t);
const { f } = prepared;
const host = await start(t, prepared);
const chief = await host.host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: host.host.path, cap: chief.cap }).call("role.claim");
assert.deepEqual(await host.launcher.fromSession(chief.cap, "assistant"), { ok: false, error: "thread-required" });
assert.deepEqual(readSessions(f.dataRoot), []);
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
});
// Admission refuses before bundle construction or a sandbox is needed.
test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
const prepared = prepare(t, { edit: doc => {
doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
doc.roles.bean.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
doc.launch.instances.push('bean');
return doc;
} });
const { f } = prepared;
const host = await start(t, prepared);
const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
assert.deepEqual(readSessions(f.dataRoot), []);
assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
});
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
// bus's plug-in loader.
const SINCE = "2026-10-18T00:00:00.000Z";
const atLimit = (doc) => {
doc.plugins = { ...doc.plugins, "limit-check": ["token-limit"] };
doc.launch.limits.tokens = { instances: { assistant: { limit: 0, since: SINCE } } };
return doc;
};
const ASSISTANT_HIT = { path: ["tokens", "instances", "assistant"], limit: 0, count: 0, unknownTurns: 0 };
test("REQ-LIMIT-3 REQ-LIMIT-4 a thread start whose instance is at its token limit is refused, logged once with the limit and the count", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t, { edit: atLimit });
const { f } = prepared;
const host = await start(t, prepared);
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
host.call("thread.send", { thread: A, body: "for A" });
const refused = await until(() => launchLog(f).find((e) => e.event === "refused" && e.thread === A));
assert.equal(refused.code, "limit-reached");
assert.equal(refused.instance, "assistant");
assert.deepEqual(refused.limit, { check: "token-limit", ...ASSISTANT_HIT });
assert.deepEqual(JSON.parse(refused.why), ASSISTANT_HIT);
await host.launcher.tick();
assert.deepEqual(threadRuns(f, A), []);
assert.equal(launchLog(f).filter((e) => e.event === "launch").length, 0);
assert.equal(launchLog(f).filter((e) => e.event === "refused" && e.thread === A).length, 1);
});
test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn: the reply lands, and the end record names the limit", { skip, timeout: 60000 }, async (t) => {
const prepared = prepare(t, { edit: atLimit });
const { f } = prepared;
// The launch is allowed, as one started before the limit was lowered would have been; the
// stop after the turn comes from the real check through the bus.
const host = await start(t, prepared, { ops: () => ({ mayLaunch: () => ({ allow: true }) }) });
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
const A = host.call("thread.create", { project, name: "alpha" }).id;
const { reply } = await ask(host.call, A, "one turn");
assert.equal(reply.class, "RESULT");
const run = launchLog(f).find((e) => e.event === "launch" && e.thread === A).run;
const end = await until(() => launchLog(f).find((e) => e.event === "end" && e.run === run));
assert.equal(end.limit, "limit: token-limit tokens.instances.assistant limit 0 count 0");
assert.equal(end.exitCode, 22);
assert.ok(!launchLog(f).find((e) => e.event === "thread-idle" && e.run === run), "a limit stop is not an idle stop");
assert.deepEqual(threadRuns(f, A), []);
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
});
test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
const vars = { ...doc.roles.assistant.vars };
delete doc.roles.assistant;
doc.roles.bean = { definition: 'companion', vars: { ...vars } };
doc.roles.bean.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
doc.roles.chet.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
doc.launch.instances = ['coder', 'bean', 'chet'];
return doc;
} });
const host = await start(t, prepared), { f } = prepared;
const project = host.call('project.create', { name: 'Writing' }).id;
const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
const send = async (thread, instance, body) => {
const message = host.call('thread.send', { thread, body });
const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
const reply = outcome;
assert.equal(reply.from_role, instance);
assert.equal(reply.to_role, 'human');
return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
};
assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
const launches = launchLog(f).filter(row => row.event === 'launch');
assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
});
test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
doc.roles.bean.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
delete doc.roles.assistant;
doc.launch.instances = ['coder', 'bean'];
return doc;
} });
let requests = [];
const host = await start(t, prepared, { ops: () => ({
lifecycleRequests: () => requests,
lifecycleTake: () => ({ ...requests[0], go: true }),
lifecycleDone: () => { requests = []; return {}; },
}) });
const { f } = prepared;
const project = host.call('project.create', { name: 'Writing' }).id;
const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
for (const thread of threads) {
const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
}
const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
await host.launcher.tick();
await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
});
// Row224: the launcher must derive library presence from business projects, not
// from session checkouts (a library never has an agent clone).
test("C2-05b a real launched bundle gets lookups for a library project without a library checkout", { skip, timeout: 60000 }, async t => {
const prepared = prepare(t, { idle: 10, edit: doc => {
doc.projects.books = mappingEntry();
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
return doc;
} });
const { f } = prepared;
const host = await start(t, prepared);
const project = host.call("project.create", { name: "library work", repo: "stack" }).id;
const thread = host.call("thread.create", { project, name: "lookup" }).id;
const sent = host.call("thread.send", { thread, body: "inspect bundle" });
// broker.mjs:4854 uses async citation hydration for a business with a library.
const reply = await until(async () => (await host.call("thread.messages", { thread })).find(m => m.in_reply_to === sent.id && m.class === "RESULT"));
assert.equal(reply.from_role, "assistant");
assert.equal(reply.to_role, "human");
const run = launchLog(f).find(e => e.event === "launch" && e.thread === thread).run;
const tools = JSON.parse(readFileSync(join(f.dataRoot, "launches", "acme", run, "bundle", "tools.json"), "utf8"));
assert.deepEqual(tools.filter(tool => tool.name.startsWith("library_")).map(tool => tool.name),
["library_find", "library_get", "library_search", "library_manuscript", "library_universe"]);
const [session] = threadRuns(f, thread);
assert.ok(session);
assert.ok(!session.checkouts?.some(checkout => checkout.project === "books"));
});