b7fea83ed6
Deliver the initial local bridge, Noctalia v4/v5 adapters, desktop client, service unit, tests, and implementation documentation for persistent Pi status and control.
241 lines
6.6 KiB
JavaScript
241 lines
6.6 KiB
JavaScript
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();
|
|
});
|