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 missing intent and starts an idle home runtime", 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 runtimes = registry.getWorkspace().directories[0].runtimes; const failed = runtimes.find((runtime) => runtime.state === "failed"); const live = runtimes.find((runtime) => runtime.state === "idle"); assert.equal(failed.sessionPath, missing); assert.equal(live.worktreePath, paths.home); assert.equal(adapters.calls.length, 1); const manifest = await readJson( join(paths.sessionRoot, "bridge-workspace-v2.json"), ); assert.ok( manifest.runtimes.some((runtime) => runtime.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("restoration with no live adapter starts one idle home runtime", 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, 1); assert.equal(restoredAdapters.calls.length, 1); assert.equal(restored.listAgents()[0].worktreePath, paths.home); assert.equal(restored.listAgents()[0].state, "idle"); 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(); });