166b065ad4
Add in-place session renewal, searchable model selection, clearer progress feedback, tool-result previews, and per-session presentation state. Keep commands, errors, loading, and lifecycle indicators attached to their owning runtime so parallel tabs cannot interfere.
1245 lines
40 KiB
JavaScript
1245 lines
40 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import {
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import {
|
|
createAgentRegistry,
|
|
sessionDirectoryPath,
|
|
} from "../src/bridge/agent-registry.js";
|
|
|
|
async function fixture() {
|
|
const root = await mkdtemp(join(tmpdir(), "pi-multi-runtime-"));
|
|
const home = join(root, "home");
|
|
await mkdir(home);
|
|
return { root, home, sessionRoot: join(root, "sessions") };
|
|
}
|
|
|
|
function adapterFactory({ stateFor } = {}) {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
startAdapter(options) {
|
|
const adapter = {
|
|
sent: [],
|
|
stopped: false,
|
|
async send(command) {
|
|
this.sent.push(command);
|
|
if (command.type === "get_state")
|
|
return {
|
|
type: "response",
|
|
success: true,
|
|
data: stateFor?.(options, calls.length) ?? {},
|
|
};
|
|
return { type: "response", command: command.type, success: true };
|
|
},
|
|
respondToExtension() {},
|
|
async stop() {
|
|
this.stopped = true;
|
|
},
|
|
};
|
|
calls.push({ options, adapter });
|
|
return adapter;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function sessionFile(sessionRoot, worktree, name, id = name) {
|
|
const sessionDir = sessionDirectoryPath(sessionRoot, worktree);
|
|
await mkdir(sessionDir, { recursive: true });
|
|
const sessionPath = join(sessionDir, `${name}.jsonl`);
|
|
await writeFile(
|
|
sessionPath,
|
|
`${JSON.stringify({ type: "session", version: 3, id, cwd: worktree, timestamp: new Date().toISOString() })}\n`,
|
|
);
|
|
return sessionPath;
|
|
}
|
|
|
|
async function readJson(filePath) {
|
|
try {
|
|
return JSON.parse(await readFile(filePath, "utf8"));
|
|
} catch (error) {
|
|
throw new Error(`Could not parse test JSON at ${filePath}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
|
|
test("runs independent same-worktree sessions and deduplicates a session lease", async () => {
|
|
const paths = await fixture();
|
|
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "one");
|
|
const secondPath = await sessionFile(paths.sessionRoot, paths.home, "two");
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({
|
|
sessionFile: options.sessionPath,
|
|
sessionId: options.sessionPath?.endsWith("one.jsonl") ? "one" : "two",
|
|
}),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const homeRuntime = registry.getWorkspace().directories[0].runtimes[0];
|
|
if (!homeRuntime.sessionPath)
|
|
await registry.closeSessionRuntime(homeRuntime.runtimeId);
|
|
const first = await registry.openSessionRuntime(paths.home, firstPath);
|
|
const second = await registry.openSessionRuntime(paths.home, secondPath);
|
|
const duplicate = await registry.openSessionRuntime(paths.home, firstPath);
|
|
|
|
assert.equal(first.runtimeId, duplicate.runtimeId);
|
|
assert.notEqual(first.runtimeId, second.runtimeId);
|
|
assert.equal(registry.getWorkspaceSummary().openCount, 2);
|
|
assert.equal(new Set(registry.listAgents().map((agent) => agent.id)).size, 2);
|
|
await Promise.all([
|
|
registry.route(first.agentId, "prompt", { message: "first" }),
|
|
registry.route(second.agentId, "prompt", { message: "second" }),
|
|
]);
|
|
assert.deepEqual(
|
|
adapters.calls.slice(-2).map(({ adapter }) => adapter.sent.at(-1).message),
|
|
["first", "second"],
|
|
);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("concurrent new runtime creation never deduplicates empty sessions", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const [first, second] = await Promise.all([
|
|
registry.createSessionRuntime(paths.home),
|
|
registry.createSessionRuntime(paths.home),
|
|
]);
|
|
assert.notEqual(first.runtimeId, second.runtimeId);
|
|
assert.equal(registry.getWorkspaceSummary().openCount, 3);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("closing removes desired-open intent, preserves history, and refuses directory forget while open", async () => {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(paths.sessionRoot, paths.home, "saved");
|
|
const feature = join(paths.root, "feature");
|
|
await mkdir(feature);
|
|
const featureSession = await sessionFile(
|
|
paths.sessionRoot,
|
|
feature,
|
|
"feature",
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtime = await registry.openSessionRuntime(feature, featureSession);
|
|
await assert.rejects(registry.forgetDirectory(feature), /runtimes are open/);
|
|
await registry.route(runtime.agentId, "prompt", {
|
|
message: "work before close",
|
|
});
|
|
await registry.closeSessionRuntime(runtime.runtimeId);
|
|
const retainedDirectory = registry
|
|
.getWorkspace()
|
|
.directories.find((directory) => directory.worktreePath === feature);
|
|
assert.equal(retainedDirectory?.openCount, 0);
|
|
assert.deepEqual(retainedDirectory?.runtimes, []);
|
|
const featureAdapter = adapters.calls.find(
|
|
(call) => call.options.cwd === feature,
|
|
).adapter;
|
|
assert.deepEqual(
|
|
featureAdapter.sent.slice(-2).map((command) => command.type),
|
|
["prompt", "abort"],
|
|
);
|
|
await registry.forgetDirectory(feature);
|
|
assert.match(await readFile(featureSession, "utf8"), /"id":"feature"/);
|
|
assert.equal(
|
|
registry
|
|
.getWorkspace()
|
|
.directories.some((directory) => directory.worktreePath === feature),
|
|
false,
|
|
);
|
|
assert.match(await readFile(saved, "utf8"), /"id":"saved"/);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("restores durable runtime IDs with two-at-a-time concurrency and preserves command isolation", async () => {
|
|
const paths = await fixture();
|
|
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "one");
|
|
const secondPath = await sessionFile(paths.sessionRoot, paths.home, "two");
|
|
const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "three");
|
|
const firstAdapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const firstRegistry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: firstAdapters.startAdapter,
|
|
});
|
|
await firstRegistry.start();
|
|
const initial = firstRegistry.getWorkspace().directories[0].runtimes[0];
|
|
if (!initial.sessionPath)
|
|
await firstRegistry.closeSessionRuntime(initial.runtimeId);
|
|
const one = await firstRegistry.openSessionRuntime(paths.home, firstPath);
|
|
const two = await firstRegistry.openSessionRuntime(paths.home, secondPath);
|
|
const three = await firstRegistry.openSessionRuntime(paths.home, thirdPath);
|
|
await firstRegistry.stop();
|
|
|
|
let activeStarts = 0;
|
|
let maxActiveStarts = 0;
|
|
const gates = [];
|
|
const restoredAdapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const startAdapter = (options) => {
|
|
const adapter = restoredAdapters.startAdapter(options);
|
|
const send = adapter.send.bind(adapter);
|
|
adapter.send = async (command) => {
|
|
if (command.type !== "get_state") return send(command);
|
|
activeStarts += 1;
|
|
maxActiveStarts = Math.max(maxActiveStarts, activeStarts);
|
|
await new Promise((resolve) => gates.push(resolve));
|
|
activeStarts -= 1;
|
|
return send(command);
|
|
};
|
|
return adapter;
|
|
};
|
|
const restored = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter,
|
|
});
|
|
const starting = restored.start();
|
|
while (gates.length < 2) await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(gates.length, 2);
|
|
gates.splice(0).forEach((resolve) => resolve());
|
|
while (gates.length < 1) await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(gates.length, 1);
|
|
gates.splice(0).forEach((resolve) => resolve());
|
|
await starting;
|
|
const ids = restored
|
|
.getWorkspace()
|
|
.directories.flatMap((directory) =>
|
|
directory.runtimes.map((runtime) => runtime.runtimeId),
|
|
);
|
|
assert.deepEqual(
|
|
new Set(ids),
|
|
new Set([one.runtimeId, two.runtimeId, three.runtimeId]),
|
|
);
|
|
assert.equal(maxActiveStarts, 2);
|
|
assert.ok(
|
|
restored
|
|
.listAgents()
|
|
.every((agent) => ![one.agentId, two.agentId].includes(agent.id)),
|
|
);
|
|
await restored.stop();
|
|
});
|
|
|
|
test("serializes close behind an in-flight command and aborts the working runtime", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const runtime = await registry.start();
|
|
const adapter = adapters.calls[0].adapter;
|
|
const originalSend = adapter.send.bind(adapter);
|
|
let releasePrompt;
|
|
const promptGate = new Promise((resolve) => {
|
|
releasePrompt = resolve;
|
|
});
|
|
adapter.send = async (command) => {
|
|
if (command.type === "prompt") {
|
|
adapter.sent.push(command);
|
|
await promptGate;
|
|
return { type: "response", command: "prompt", success: true };
|
|
}
|
|
return originalSend(command);
|
|
};
|
|
const prompt = registry.route(runtime.id, "prompt", { message: "long work" });
|
|
const closing = registry.closeSessionRuntime(runtime.runtimeId);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(adapter.stopped, false);
|
|
releasePrompt();
|
|
await Promise.all([prompt, closing]);
|
|
assert.equal(adapter.stopped, true);
|
|
assert.deepEqual(
|
|
adapter.sent.slice(-2).map((command) => command.type),
|
|
["prompt", "abort"],
|
|
);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("forces a child kill and retains its lease until exit is confirmed", async () => {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(paths.sessionRoot, paths.home, "forced-close");
|
|
let killed;
|
|
let releaseStop;
|
|
let blockedSavedAdapter = false;
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
closeTimeoutMs: 5,
|
|
startAdapter: (options) => {
|
|
const blockStop = options.sessionPath === saved && !blockedSavedAdapter;
|
|
if (blockStop) blockedSavedAdapter = true;
|
|
return {
|
|
child: { kill: (signal) => (killed = signal) },
|
|
send: async (command) => ({
|
|
type: "response",
|
|
command: command.type,
|
|
success: true,
|
|
data: { sessionFile: options.sessionPath },
|
|
}),
|
|
respondToExtension() {},
|
|
stop: blockStop
|
|
? () =>
|
|
new Promise((resolve) => {
|
|
releaseStop = resolve;
|
|
})
|
|
: async () => {},
|
|
};
|
|
},
|
|
});
|
|
await registry.start();
|
|
const runtime = await registry.openSessionRuntime(paths.home, saved);
|
|
await registry.closeSessionRuntime(runtime.runtimeId);
|
|
assert.equal(killed, "SIGKILL");
|
|
await assert.rejects(
|
|
registry.openSessionRuntime(paths.home, saved),
|
|
/session runtime is closing/,
|
|
);
|
|
releaseStop();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
const reopened = await registry.openSessionRuntime(paths.home, saved);
|
|
assert.notEqual(reopened.runtimeId, runtime.runtimeId);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("forgets a managed directory after its worktree is removed", async () => {
|
|
const paths = await fixture();
|
|
const feature = join(paths.root, "feature");
|
|
await mkdir(feature);
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtime = await registry.selectWorktree(feature);
|
|
await registry.closeSessionRuntime(runtime.runtimeId);
|
|
await rm(feature, { recursive: true });
|
|
await registry.forgetDirectory(feature);
|
|
assert.equal(
|
|
(await registry.listDirectories()).some(
|
|
(directory) => directory.worktreePath === feature,
|
|
),
|
|
false,
|
|
);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("reserves a legacy switch target before either child is instructed", async () => {
|
|
const paths = await fixture();
|
|
const target = await sessionFile(paths.sessionRoot, paths.home, "target");
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const first = await registry.start();
|
|
const second = await registry.createSessionRuntime(paths.home);
|
|
let releaseSwitch;
|
|
const switchGate = new Promise((resolve) => {
|
|
releaseSwitch = resolve;
|
|
});
|
|
let switched = false;
|
|
const firstAdapter = adapters.calls[0].adapter;
|
|
const firstSend = firstAdapter.send.bind(firstAdapter);
|
|
firstAdapter.send = async (command) => {
|
|
if (command.type === "switch_session") {
|
|
await switchGate;
|
|
switched = true;
|
|
}
|
|
if (command.type === "get_state" && switched)
|
|
return { type: "response", success: true, data: { sessionFile: target } };
|
|
return firstSend(command);
|
|
};
|
|
const switching = registry.route(first.id, "switch_session", {
|
|
sessionPath: target,
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await assert.rejects(
|
|
registry.route(second.agentId, "switch_session", { sessionPath: target }),
|
|
/session is already open/,
|
|
);
|
|
assert.equal(
|
|
adapters.calls[1].adapter.sent.some(
|
|
(command) => command.type === "switch_session",
|
|
),
|
|
false,
|
|
);
|
|
releaseSwitch();
|
|
await switching;
|
|
await registry.stop();
|
|
});
|
|
|
|
test("legacy new session releases old identity and makes its history reopenable", async () => {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"saved",
|
|
"saved-id",
|
|
);
|
|
let currentState = {
|
|
sessionFile: saved,
|
|
sessionId: "saved-id",
|
|
sessionName: "Saved",
|
|
};
|
|
const adapters = adapterFactory({ stateFor: () => currentState });
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const original = await registry.start();
|
|
const adapter = adapters.calls[0].adapter;
|
|
const originalSend = adapter.send.bind(adapter);
|
|
adapter.send = async (command) => {
|
|
if (command.type === "new_session") currentState = {};
|
|
return originalSend(command);
|
|
};
|
|
await registry.route(original.id, "new_session");
|
|
const cleared = registry.getWorkspace().directories[0].runtimes[0];
|
|
assert.equal(cleared.sessionPath, undefined);
|
|
assert.equal(cleared.sessionId, undefined);
|
|
assert.equal(cleared.label, "New session");
|
|
currentState = { sessionFile: saved, sessionId: "saved-id" };
|
|
const reopened = await registry.openSessionRuntime(paths.home, saved);
|
|
assert.notEqual(reopened.runtimeId, original.runtimeId);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("renews one runtime in place, aborts work, and clears its active identity", async () => {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"renew-me",
|
|
"renew-me-id",
|
|
);
|
|
let currentState = {
|
|
sessionFile: saved,
|
|
sessionId: "renew-me-id",
|
|
sessionName: "Renew me",
|
|
};
|
|
const adapters = adapterFactory({ stateFor: () => currentState });
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const original = await registry.start();
|
|
const adapter = adapters.calls[0].adapter;
|
|
const originalSend = adapter.send.bind(adapter);
|
|
adapter.send = async (command) => {
|
|
if (command.type === "new_session") currentState = {};
|
|
return originalSend(command);
|
|
};
|
|
adapters.calls[0].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "stale-renew-request", method: "confirm" } },
|
|
});
|
|
adapters.calls[0].options.onEvent({
|
|
type: "agent_state",
|
|
data: { state: "streaming" },
|
|
});
|
|
|
|
const renewed = await registry.renewSession(original.id);
|
|
const active = registry.getWorkspace().directories[0].runtimes[0];
|
|
assert.equal(renewed.id, original.id);
|
|
assert.equal(renewed.runtimeId, original.runtimeId);
|
|
assert.equal(active.worktreePath, paths.home);
|
|
assert.equal(active.sessionPath, undefined);
|
|
assert.equal(active.sessionId, undefined);
|
|
assert.equal(active.label, "New session");
|
|
assert.equal(active.state, "idle");
|
|
assert.equal(active.attention, false);
|
|
assert.deepEqual(
|
|
adapter.sent.slice(-3).map((command) => command.type),
|
|
["abort", "new_session", "get_state"],
|
|
);
|
|
assert.equal(
|
|
registry
|
|
.workspaceEventsAfter(0)
|
|
.events.some((event) => event.type === "runtime_renewed"),
|
|
true,
|
|
);
|
|
|
|
currentState = { sessionFile: saved, sessionId: "renew-me-id" };
|
|
const reopened = await registry.openSessionRuntime(paths.home, saved);
|
|
assert.notEqual(reopened.runtimeId, original.runtimeId);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("persists a first-prompt session when the runtime settles and awaits refresh on shutdown", async () => {
|
|
const paths = await fixture();
|
|
const createdPath = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"created",
|
|
"created-id",
|
|
);
|
|
let stateCalls = 0;
|
|
const adapters = adapterFactory({
|
|
stateFor: () => {
|
|
stateCalls += 1;
|
|
return stateCalls === 1
|
|
? {}
|
|
: { sessionFile: createdPath, sessionId: "created-id" };
|
|
},
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const agent = await registry.start();
|
|
await registry.route(agent.id, "prompt", { message: "create a session" });
|
|
adapters.calls[0].options.onEvent({
|
|
type: "agent_state",
|
|
data: { state: "idle", event: { type: "agent_settled" } },
|
|
});
|
|
while (!registry.getWorkspace().directories[0].runtimes[0].sessionPath)
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await registry.stop();
|
|
const manifestText = await readFile(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
"utf8",
|
|
);
|
|
assert.match(manifestText, new RegExp(createdPath.replaceAll("/", "\\/")));
|
|
});
|
|
|
|
test("does not persist an uncreated Pi session file across a restart", async () => {
|
|
const paths = await fixture();
|
|
const futurePath = join(
|
|
sessionDirectoryPath(paths.sessionRoot, paths.home),
|
|
"future.jsonl",
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: () => ({ sessionFile: futurePath, sessionId: "future-id" }),
|
|
});
|
|
const firstRegistry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await firstRegistry.start();
|
|
assert.equal(
|
|
firstRegistry.getWorkspace().directories[0].runtimes[0].sessionPath,
|
|
undefined,
|
|
);
|
|
await firstRegistry.stop();
|
|
|
|
const manifest = await readJson(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
);
|
|
assert.equal(manifest.runtimes[0].sessionPath, undefined);
|
|
|
|
const restoredRegistry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await restoredRegistry.start();
|
|
const restored = restoredRegistry.getWorkspace().directories[0].runtimes[0];
|
|
assert.equal(restored.state, "idle");
|
|
assert.equal(restored.sessionPath, undefined);
|
|
assert.equal(adapters.calls[1].options.sessionPath, undefined);
|
|
await restoredRegistry.stop();
|
|
});
|
|
|
|
test("extension responses do not clear unrelated error attention", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const runtime = await registry.start();
|
|
adapters.calls[0].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "request-1", method: "confirm" } },
|
|
});
|
|
adapters.calls[0].options.onError({
|
|
code: "diagnostic",
|
|
message: "needs attention",
|
|
});
|
|
await registry.route(runtime.id, "extension_response", {
|
|
requestId: "request-1",
|
|
response: { confirmed: true },
|
|
});
|
|
const directory = registry.getWorkspace().directories[0];
|
|
assert.equal(directory.attentionCount, 1);
|
|
assert.equal(directory.errorCount, 1);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("successful recovery clears obsolete child error attention", async () => {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(paths.sessionRoot, paths.home, "saved");
|
|
await writeFile(
|
|
join(
|
|
sessionDirectoryPath(paths.sessionRoot, paths.home),
|
|
"bridge-agent.json",
|
|
),
|
|
`${JSON.stringify({ sessionPath: saved, worktreePath: paths.home })}\n`,
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
let releaseRecovery;
|
|
const recoveryGate = new Promise((resolve) => {
|
|
releaseRecovery = resolve;
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
sleep: () => recoveryGate,
|
|
});
|
|
const runtime = await registry.start();
|
|
adapters.calls[0].options.onError({ code: "child_exited", message: "gone" });
|
|
assert.equal(registry.getWorkspace().directories[0].recoveringCount, 1);
|
|
releaseRecovery();
|
|
await registry.waitForRecovery(runtime.id);
|
|
const recovered = registry.getWorkspace().directories[0].runtimes[0];
|
|
assert.equal(recovered.state, "idle");
|
|
assert.equal(recovered.attention, false);
|
|
assert.equal(recovered.error, undefined);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("non-default runtime snapshots do not replace the legacy session pointer", async () => {
|
|
const paths = await fixture();
|
|
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "first");
|
|
const secondPath = await sessionFile(paths.sessionRoot, paths.home, "second");
|
|
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
|
|
const referencePath = join(sessionDir, "bridge-agent.json");
|
|
await writeFile(
|
|
referencePath,
|
|
`${JSON.stringify({ sessionPath: firstPath, worktreePath: paths.home })}\n`,
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const secondary = await registry.openSessionRuntime(paths.home, secondPath);
|
|
await registry.getSessionRuntimeSnapshot(secondary.runtimeId);
|
|
const reference = await readFile(referencePath, "utf8");
|
|
assert.match(reference, new RegExp(firstPath.replaceAll("/", "\\/")));
|
|
assert.doesNotMatch(reference, new RegExp(secondPath.replaceAll("/", "\\/")));
|
|
await registry.stop();
|
|
});
|
|
|
|
test("reports available-model RPC failures in runtime snapshots", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const runtime = await registry.start();
|
|
const originalSend = adapters.calls[0].adapter.send;
|
|
adapters.calls[0].adapter.send = async function (command) {
|
|
if (command.type === "get_available_models")
|
|
throw new Error("model registry unavailable");
|
|
return originalSend.call(this, command);
|
|
};
|
|
|
|
const snapshot = await registry.getSessionRuntimeSnapshot(runtime.runtimeId);
|
|
assert.deepEqual(snapshot.models.data.models, []);
|
|
assert.match(snapshot.models.error, /model registry unavailable/);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("retains a corrupt workspace manifest and reports its issue", async () => {
|
|
const paths = await fixture();
|
|
await mkdir(paths.sessionRoot, { recursive: true });
|
|
const manifestPath = join(paths.sessionRoot, "bridge-workspace-v2.json");
|
|
await writeFile(manifestPath, "{broken", { mode: 0o600 });
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapterFactory().startAdapter,
|
|
});
|
|
await registry.start();
|
|
assert.equal(registry.getWorkspace().issue.code, "invalid_workspace_manifest");
|
|
assert.equal(await readFile(manifestPath, "utf8"), "{broken");
|
|
await registry.stop();
|
|
});
|
|
|
|
test("retains a canonical duplicate restore record as failed dormant intent", async () => {
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(paths.sessionRoot, paths.home, "saved");
|
|
const alias = join(
|
|
sessionDirectoryPath(paths.sessionRoot, paths.home),
|
|
"alias.jsonl",
|
|
);
|
|
await symlink(saved, alias);
|
|
await writeFile(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
`${JSON.stringify({
|
|
version: 2,
|
|
migrated: true,
|
|
runtimes: [
|
|
{
|
|
runtimeId: "runtime-alias",
|
|
worktreePath: paths.home,
|
|
sessionPath: alias,
|
|
},
|
|
{
|
|
runtimeId: "runtime-one",
|
|
worktreePath: paths.home,
|
|
sessionPath: saved,
|
|
},
|
|
],
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtimes = registry.getWorkspace().directories[0].runtimes;
|
|
assert.deepEqual(
|
|
new Set(runtimes.map((runtime) => runtime.runtimeId)),
|
|
new Set(["runtime-one", "runtime-alias"]),
|
|
);
|
|
assert.equal(
|
|
runtimes.filter((runtime) => runtime.state === "failed").length,
|
|
1,
|
|
JSON.stringify(runtimes),
|
|
);
|
|
assert.equal(
|
|
runtimes.find((runtime) => runtime.runtimeId === "runtime-one").state,
|
|
"idle",
|
|
);
|
|
await registry.stop();
|
|
}
|
|
});
|
|
|
|
test("stops a restore candidate when Pi substitutes a different session", async () => {
|
|
const paths = await fixture();
|
|
const requested = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"requested",
|
|
);
|
|
const substituted = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"substituted",
|
|
);
|
|
await writeFile(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
`${JSON.stringify({
|
|
version: 2,
|
|
migrated: true,
|
|
runtimes: [
|
|
{
|
|
runtimeId: "runtime-requested",
|
|
worktreePath: paths.home,
|
|
sessionPath: requested,
|
|
},
|
|
],
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: () => ({ sessionFile: substituted }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtime = registry.getWorkspace().directories[0].runtimes[0];
|
|
assert.equal(runtime.state, "failed");
|
|
assert.equal(runtime.sessionPath, requested);
|
|
assert.equal(adapters.calls[0].adapter.stopped, true);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("shutdown drains a create racing before runtime indexing", async () => {
|
|
const paths = await fixture();
|
|
const feature = join(paths.root, "feature");
|
|
await mkdir(feature);
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const creating = registry.createSessionRuntime(feature);
|
|
const stopping = registry.stop();
|
|
await assert.rejects(creating, /agent registry is stopping/);
|
|
await stopping;
|
|
assert.equal(adapters.calls.length, 1);
|
|
assert.equal(adapters.calls[0].adapter.stopped, true);
|
|
assert.equal(registry.listAgents().length, 1);
|
|
});
|
|
|
|
test("legacy selection prefers the manifest default and promotes a fallback on close", async () => {
|
|
const paths = await fixture();
|
|
const firstPath = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"first",
|
|
"first-id",
|
|
);
|
|
const secondPath = await sessionFile(
|
|
paths.sessionRoot,
|
|
paths.home,
|
|
"second",
|
|
"second-id",
|
|
);
|
|
await mkdir(paths.sessionRoot, { recursive: true });
|
|
await writeFile(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
`${JSON.stringify({
|
|
version: 2,
|
|
migrated: true,
|
|
runtimes: [
|
|
{
|
|
runtimeId: "secondary",
|
|
worktreePath: paths.home,
|
|
sessionPath: secondPath,
|
|
},
|
|
{
|
|
runtimeId: "default",
|
|
worktreePath: paths.home,
|
|
sessionPath: firstPath,
|
|
legacyDefault: true,
|
|
},
|
|
],
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({
|
|
sessionFile: options.sessionPath,
|
|
sessionId: options.sessionPath === firstPath ? "first-id" : "second-id",
|
|
}),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const selectedDefault = await registry.selectWorktree(paths.home);
|
|
assert.equal(selectedDefault.runtimeId, "default");
|
|
await registry.closeSessionRuntime("default");
|
|
const selectedFallback = await registry.selectWorktree(paths.home);
|
|
assert.equal(selectedFallback.runtimeId, "secondary");
|
|
const reference = await readJson(
|
|
join(
|
|
sessionDirectoryPath(paths.sessionRoot, paths.home),
|
|
"bridge-agent.json",
|
|
),
|
|
);
|
|
assert.equal(reference.sessionPath, secondPath);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("concurrent closes never promote a closing runtime as legacy default", async () => {
|
|
const paths = await fixture();
|
|
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "first");
|
|
const secondPath = await sessionFile(paths.sessionRoot, paths.home, "second");
|
|
const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "third");
|
|
await mkdir(paths.sessionRoot, { recursive: true });
|
|
await writeFile(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
`${JSON.stringify({
|
|
version: 2,
|
|
migrated: true,
|
|
runtimes: [
|
|
{
|
|
runtimeId: "default",
|
|
worktreePath: paths.home,
|
|
sessionPath: firstPath,
|
|
legacyDefault: true,
|
|
},
|
|
{
|
|
runtimeId: "closing",
|
|
worktreePath: paths.home,
|
|
sessionPath: secondPath,
|
|
},
|
|
{
|
|
runtimeId: "survivor",
|
|
worktreePath: paths.home,
|
|
sessionPath: thirdPath,
|
|
},
|
|
],
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
await Promise.all([
|
|
registry.closeSessionRuntime("closing"),
|
|
registry.closeSessionRuntime("default"),
|
|
]);
|
|
const selected = await registry.selectWorktree(paths.home);
|
|
assert.equal(selected.runtimeId, "survivor");
|
|
const manifest = await readJson(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
);
|
|
assert.equal(manifest.runtimes.length, 1);
|
|
assert.equal(manifest.runtimes[0].runtimeId, "survivor");
|
|
assert.equal(manifest.runtimes[0].legacyDefault, true);
|
|
const reference = await readJson(
|
|
join(
|
|
sessionDirectoryPath(paths.sessionRoot, paths.home),
|
|
"bridge-agent.json",
|
|
),
|
|
);
|
|
assert.equal(reference.sessionPath, thirdPath);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("closing retains a saved-session lease until the child stops", async () => {
|
|
const paths = await fixture();
|
|
const saved = await sessionFile(paths.sessionRoot, paths.home, "saved");
|
|
const adapters = adapterFactory({
|
|
stateFor: (options) => ({ sessionFile: options.sessionPath }),
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtime = await registry.openSessionRuntime(paths.home, saved);
|
|
const adapter = adapters.calls.find(
|
|
(call) => call.options.sessionPath === saved,
|
|
).adapter;
|
|
let releaseStop;
|
|
adapter.stop = () =>
|
|
new Promise((resolve) => {
|
|
releaseStop = () => {
|
|
adapter.stopped = true;
|
|
resolve();
|
|
};
|
|
});
|
|
const closing = registry.closeSessionRuntime(runtime.runtimeId);
|
|
while (!releaseStop) await new Promise((resolve) => setImmediate(resolve));
|
|
await assert.rejects(
|
|
registry.openSessionRuntime(paths.home, saved),
|
|
/session runtime is closing/,
|
|
);
|
|
releaseStop();
|
|
await closing;
|
|
const reopened = await registry.openSessionRuntime(paths.home, saved);
|
|
assert.notEqual(reopened.runtimeId, runtime.runtimeId);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("first migration retains a missing legacy session as failed dormant intent", async () => {
|
|
const paths = await fixture();
|
|
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
|
|
const missing = join(sessionDir, "missing.jsonl");
|
|
await mkdir(sessionDir, { recursive: true });
|
|
await writeFile(
|
|
join(sessionDir, "bridge-agent.json"),
|
|
`${JSON.stringify({ sessionPath: missing, worktreePath: paths.home })}\n`,
|
|
);
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
await registry.start();
|
|
const runtime = registry.getWorkspace().directories[0].runtimes[0];
|
|
assert.equal(runtime.state, "failed");
|
|
assert.equal(runtime.sessionPath, missing);
|
|
assert.equal(adapters.calls.length, 0);
|
|
const manifest = await readJson(
|
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
|
);
|
|
assert.equal(manifest.runtimes[0].sessionPath, missing);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("extension responses are scoped to actionable requests on one runtime", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
});
|
|
const first = await registry.start();
|
|
const second = await registry.createSessionRuntime(paths.home);
|
|
const replies = [[], []];
|
|
adapters.calls[0].adapter.respondToExtension = (...args) =>
|
|
replies[0].push(args);
|
|
adapters.calls[1].adapter.respondToExtension = (...args) =>
|
|
replies[1].push(args);
|
|
adapters.calls[0].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "collision", method: "confirm" } },
|
|
});
|
|
await assert.rejects(
|
|
registry.route(second.agentId, "extension_response", {
|
|
requestId: "collision",
|
|
response: { confirmed: true },
|
|
}),
|
|
/not actionable for this runtime/,
|
|
);
|
|
adapters.calls[1].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "collision", method: "confirm" } },
|
|
});
|
|
await registry.route(second.agentId, "extension_response", {
|
|
requestId: "collision",
|
|
response: { confirmed: false },
|
|
});
|
|
await registry.route(first.id, "extension_response", {
|
|
requestId: "collision",
|
|
response: { confirmed: true },
|
|
});
|
|
assert.equal(replies[0].length, 1);
|
|
assert.equal(replies[1].length, 1);
|
|
await assert.rejects(
|
|
registry.route(first.id, "extension_response", {
|
|
requestId: "collision",
|
|
response: { confirmed: false },
|
|
}),
|
|
/not actionable for this runtime/,
|
|
);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("manual restart ignores late extension dialogs from the replaced child", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
let releaseStop;
|
|
const stopGate = new Promise((resolve) => {
|
|
releaseStop = resolve;
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter(options) {
|
|
const adapter = adapters.startAdapter(options);
|
|
if (adapters.calls.length === 1) adapter.stop = () => stopGate;
|
|
return adapter;
|
|
},
|
|
sleep: async () => {},
|
|
});
|
|
const runtime = await registry.start();
|
|
adapters.calls[0].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "restart-request", method: "confirm" } },
|
|
});
|
|
const restarting = registry.restart(runtime.id);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
adapters.calls[0].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "late-old-child", method: "confirm" } },
|
|
});
|
|
releaseStop();
|
|
await restarting;
|
|
const snapshot = await registry.getSessionRuntimeSnapshot(runtime.runtimeId);
|
|
assert.deepEqual(snapshot.extensions, []);
|
|
assert.equal(snapshot.runtime.attention, false);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("manual restart rejects exhausted recovery and marks the runtime failed", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
let starts = 0;
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter(options) {
|
|
starts += 1;
|
|
if (starts > 1) throw new Error("replacement failed to start");
|
|
return adapters.startAdapter(options);
|
|
},
|
|
maxRecoveryAttempts: 1,
|
|
recoveryDelayForAttempt: () => 0,
|
|
sleep: async () => {},
|
|
});
|
|
const runtime = await registry.start();
|
|
|
|
await assert.rejects(
|
|
registry.restart(runtime.id),
|
|
/Pi did not restart after the recovery attempts were exhausted/,
|
|
);
|
|
assert.equal(
|
|
registry.getWorkspace().directories[0].runtimes[0].state,
|
|
"failed",
|
|
);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("child exit clears extension dialogs that recovery cannot answer", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
let releaseRecovery;
|
|
const recoveryGate = new Promise((resolve) => {
|
|
releaseRecovery = resolve;
|
|
});
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
sleep: () => recoveryGate,
|
|
});
|
|
const runtime = await registry.start();
|
|
adapters.calls[0].options.onEvent({
|
|
type: "extension_ui_request",
|
|
data: { event: { id: "dead-request", method: "input" } },
|
|
});
|
|
adapters.calls[0].options.onError({ code: "child_exited", message: "gone" });
|
|
const duringRecovery = await registry.getSessionRuntimeSnapshot(
|
|
runtime.runtimeId,
|
|
);
|
|
assert.deepEqual(duringRecovery.extensions, []);
|
|
releaseRecovery();
|
|
await registry.waitForRecovery(runtime.id);
|
|
const recovered = await registry.getSessionRuntimeSnapshot(runtime.runtimeId);
|
|
assert.deepEqual(recovered.extensions, []);
|
|
assert.equal(recovered.runtime.attention, false);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("snapshot rejects results from an adapter replaced during recovery", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
sleep: async () => {},
|
|
});
|
|
const runtime = await registry.start();
|
|
const oldAdapter = adapters.calls[0].adapter;
|
|
const oldSend = oldAdapter.send.bind(oldAdapter);
|
|
let releaseSnapshot;
|
|
let snapshotStarted;
|
|
const snapshotGate = new Promise((resolve) => {
|
|
snapshotStarted = resolve;
|
|
});
|
|
let stateCalls = 0;
|
|
oldAdapter.send = async (command) => {
|
|
if (command.type === "get_state" && ++stateCalls === 1) {
|
|
snapshotStarted();
|
|
await new Promise((resolve) => {
|
|
releaseSnapshot = resolve;
|
|
});
|
|
}
|
|
return oldSend(command);
|
|
};
|
|
const snapshot = registry.getSessionRuntimeSnapshot(runtime.runtimeId);
|
|
await snapshotGate;
|
|
adapters.calls[0].options.onError({ code: "child_exited", message: "gone" });
|
|
while (adapters.calls.length < 2)
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
releaseSnapshot();
|
|
await assert.rejects(snapshot, /session runtime is closing/);
|
|
await registry.waitForRecovery(runtime.id);
|
|
await registry.stop();
|
|
});
|
|
|
|
test("closing the last runtime persists a zero-runtime restart", async () => {
|
|
const paths = await fixture();
|
|
const firstAdapters = adapterFactory();
|
|
const first = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: firstAdapters.startAdapter,
|
|
});
|
|
const runtime = await first.start();
|
|
await first.closeSessionRuntime(runtime.runtimeId);
|
|
await first.stop();
|
|
const restoredAdapters = adapterFactory();
|
|
const restored = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: restoredAdapters.startAdapter,
|
|
});
|
|
await restored.start();
|
|
assert.equal(restored.getWorkspaceSummary().openCount, 0);
|
|
assert.equal(restoredAdapters.calls.length, 0);
|
|
await restored.stop();
|
|
});
|
|
|
|
test("workspace replay is globally ordered and reports truncation boundaries", async () => {
|
|
const paths = await fixture();
|
|
const adapters = adapterFactory();
|
|
const registry = createAgentRegistry({
|
|
homeWorktree: paths.home,
|
|
sessionRoot: paths.sessionRoot,
|
|
startAdapter: adapters.startAdapter,
|
|
eventLimit: 2,
|
|
});
|
|
await registry.start();
|
|
adapters.calls[0].options.onEvent({ type: "stream", data: { event: {} } });
|
|
adapters.calls[0].options.onEvent({ type: "tool", data: { event: {} } });
|
|
adapters.calls[0].options.onEvent({ type: "queue", data: { event: {} } });
|
|
const replay = registry.workspaceEventsAfter(1);
|
|
assert.equal(replay.truncated, true);
|
|
assert.equal(registry.workspaceEventsAfter(0).truncated, true);
|
|
assert.equal(replay.events.length, 2);
|
|
assert.ok(replay.events[0].seq < replay.events[1].seq);
|
|
assert.equal(replay.bridgeInstanceId, registry.bridgeInstanceId);
|
|
await registry.stop();
|
|
});
|