feat(bridge): establish local pi status bridge
Deliver the initial local bridge, Noctalia v4/v5 adapters, desktop client, service unit, tests, and implementation documentation for persistent Pi status and control.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createAgentRegistry } from "../src/bridge/agent-registry.js";
|
||||
|
||||
async function createWorktrees() {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-worktrees-"));
|
||||
const home = join(root, "home");
|
||||
const feature = join(root, "feature");
|
||||
await Promise.all([mkdir(home), mkdir(feature)]);
|
||||
return { root, home, feature, sessionRoot: join(root, "sessions") };
|
||||
}
|
||||
|
||||
function createAdapterFactory() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
startAdapter: (options) => {
|
||||
const adapter = {
|
||||
sent: [],
|
||||
extensionResponses: [],
|
||||
stopped: false,
|
||||
send(command) {
|
||||
this.sent.push(command);
|
||||
return Promise.resolve({
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
respondToExtension(requestId, response) {
|
||||
this.extensionResponses.push({ requestId, response });
|
||||
},
|
||||
async stop() {
|
||||
this.stopped = true;
|
||||
},
|
||||
};
|
||||
calls.push({ options, adapter });
|
||||
return adapter;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("starts only the home agent and creates other worktree agents on explicit selection", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
|
||||
const home = await registry.start();
|
||||
assert.equal(registry.listAgents().length, 1);
|
||||
assert.equal(home.worktreePath, worktrees.home);
|
||||
|
||||
const feature = await registry.selectWorktree(worktrees.feature);
|
||||
const selectedAgain = await registry.selectWorktree(
|
||||
`${worktrees.feature}/../feature`,
|
||||
);
|
||||
assert.equal(feature.id, selectedAgain.id);
|
||||
assert.notEqual(home.id, feature.id);
|
||||
assert.deepEqual(
|
||||
fixture.calls.map(({ options }) => options.cwd),
|
||||
[worktrees.home, worktrees.feature],
|
||||
);
|
||||
assert.notEqual(home.sessionDir, feature.sessionDir);
|
||||
|
||||
await registry.stop();
|
||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||
});
|
||||
|
||||
test("catalogues managed directories and exposes only their sessions", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
await registry.start();
|
||||
const feature = await registry.selectWorktree(worktrees.feature);
|
||||
const olderSession = join(feature.sessionDir, "older.jsonl");
|
||||
const newestSession = join(feature.sessionDir, "newest.jsonl");
|
||||
await writeFile(
|
||||
olderSession,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
id: "older",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
cwd: worktrees.feature,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: { role: "user", content: "Older task" },
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
newestSession,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
id: "newest",
|
||||
timestamp: "2025-01-02T00:00:00.000Z",
|
||||
cwd: worktrees.feature,
|
||||
}),
|
||||
JSON.stringify({ type: "session_info", name: "Newest work" }),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: { role: "user", content: "Newest task" },
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
join(feature.sessionDir, "bridge-agent.json"),
|
||||
`${JSON.stringify({ sessionPath: newestSession, worktreePath: worktrees.feature })}\n`,
|
||||
);
|
||||
|
||||
const sessions = await registry.listSessions(feature.id);
|
||||
assert.deepEqual(
|
||||
sessions.map(({ id, name, isCurrent }) => ({ id, name, isCurrent })),
|
||||
[
|
||||
{ id: "newest", name: "Newest work", isCurrent: false },
|
||||
{ id: "older", name: undefined, isCurrent: false },
|
||||
],
|
||||
);
|
||||
await registry.route(feature.id, "switch_session", {
|
||||
sessionPath: newestSession,
|
||||
});
|
||||
await registry.route(feature.id, "new_session");
|
||||
assert.deepEqual(
|
||||
fixture.calls[1].adapter.sent.map((command) => command.type),
|
||||
["get_state", "switch_session", "get_state", "new_session", "get_state"],
|
||||
);
|
||||
await assert.rejects(
|
||||
registry.route(feature.id, "switch_session", {
|
||||
sessionPath: olderSession.replace("older", "../outside"),
|
||||
}),
|
||||
/ENOENT|belong/,
|
||||
);
|
||||
await registry.stop();
|
||||
|
||||
const restored = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: createAdapterFactory().startAdapter,
|
||||
});
|
||||
await restored.start();
|
||||
assert.deepEqual(await restored.listDirectories(), [
|
||||
{ worktreePath: worktrees.feature, state: "inactive" },
|
||||
{
|
||||
worktreePath: worktrees.home,
|
||||
state: "idle",
|
||||
agentId: restored.listAgents()[0].id,
|
||||
},
|
||||
]);
|
||||
await restored.stop();
|
||||
});
|
||||
|
||||
test("routes commands by explicit agent ID and replays only events after the cursor", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
const agent = await registry.start();
|
||||
const [{ options, adapter }] = fixture.calls;
|
||||
|
||||
const response = await registry.route(agent.id, "prompt", {
|
||||
message: "Stay in this worktree",
|
||||
});
|
||||
assert.deepEqual(response, {
|
||||
type: "response",
|
||||
command: "prompt",
|
||||
success: true,
|
||||
});
|
||||
const stats = await registry.route(agent.id, "get_session_stats");
|
||||
assert.deepEqual(stats, {
|
||||
type: "response",
|
||||
command: "get_session_stats",
|
||||
success: true,
|
||||
});
|
||||
const commands = await registry.route(agent.id, "get_commands");
|
||||
assert.deepEqual(commands, {
|
||||
type: "response",
|
||||
command: "get_commands",
|
||||
success: true,
|
||||
});
|
||||
assert.deepEqual(adapter.sent, [
|
||||
{ type: "get_state" },
|
||||
{ type: "prompt", message: "Stay in this worktree" },
|
||||
{ type: "get_session_stats" },
|
||||
{ type: "get_commands" },
|
||||
]);
|
||||
|
||||
options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "streaming" },
|
||||
});
|
||||
await registry.route(agent.id, "submit_prompt", {
|
||||
message: "After this turn",
|
||||
});
|
||||
assert.deepEqual(adapter.sent.at(-1), {
|
||||
type: "follow_up",
|
||||
message: "After this turn",
|
||||
});
|
||||
|
||||
options.onEvent({
|
||||
type: "stream",
|
||||
data: { event: { type: "message_update" } },
|
||||
});
|
||||
options.onEvent({ type: "queue", data: { event: { type: "queue_update" } } });
|
||||
assert.deepEqual(
|
||||
registry.eventsAfter(agent.id, 0).map(({ seq, type }) => ({ seq, type })),
|
||||
[
|
||||
{ seq: 1, type: "agent_state" },
|
||||
{ seq: 2, type: "stream" },
|
||||
{ seq: 3, type: "queue" },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
registry.eventsAfter(agent.id, 1).map(({ seq, type }) => ({ seq, type })),
|
||||
[
|
||||
{ seq: 2, type: "stream" },
|
||||
{ seq: 3, type: "queue" },
|
||||
],
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
registry.route("missing-agent", "abort"),
|
||||
/unknown agent/,
|
||||
);
|
||||
await registry.stop();
|
||||
});
|
||||
Reference in New Issue
Block a user