feat(rpc): add validated desktop Pi RPC command palette

This commit is contained in:
2026-08-24 20:16:51 +02:00
parent 475f95ba70
commit 99084cc5a5
25 changed files with 2170 additions and 344 deletions
+64 -4
View File
@@ -20,10 +20,12 @@ function createAdapterFactory() {
startAdapter: (options) => {
const adapter = {
sent: [],
sentWithOptions: [],
extensionResponses: [],
stopped: false,
send(command) {
send(command, options) {
this.sent.push(command);
this.sentWithOptions.push({ command, options });
return Promise.resolve({
type: "response",
command: command.type,
@@ -53,6 +55,10 @@ test("starts only the home agent and creates other worktree agents on explicit s
});
const home = await registry.start();
assert.deepEqual(fixture.calls[0].adapter.sentWithOptions[0], {
command: { type: "get_state" },
options: { timeoutMs: 90_000 },
});
assert.equal(registry.listAgents().length, 1);
assert.equal(home.worktreePath, worktrees.home);
@@ -72,6 +78,58 @@ test("starts only the home agent and creates other worktree agents on explicit s
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
});
test("sends abort without waiting behind a blocked prompt", 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 adapter = fixture.calls[0].adapter;
const send = adapter.send.bind(adapter);
let releasePrompt;
adapter.send = (command) => {
if (command.type !== "prompt") return send(command);
adapter.sent.push(command);
return new Promise((resolve) => {
releasePrompt = () =>
resolve({ type: "response", command: "prompt", success: true });
});
};
const prompt = registry.route(agent.id, "prompt", { message: "Work" });
while (!releasePrompt) await new Promise((resolve) => setImmediate(resolve));
const abort = registry.route(agent.id, "abort");
await new Promise((resolve) => setImmediate(resolve));
assert.equal(adapter.sent.at(-1).type, "abort");
releasePrompt();
await Promise.all([prompt, abort]);
await registry.stop();
});
test("keeps a runtime healthy after a non-terminal adapter diagnostic", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: fixture.startAdapter,
});
await registry.start();
fixture.calls[0].options.onError(
{ code: "frame_too_large", message: "Pi RPC frame exceeds 32 bytes" },
{ terminal: false },
);
const runtime = registry.getWorkspace().directories[0].runtimes[0];
assert.equal(runtime.state, "idle");
assert.equal(runtime.attention, false);
assert.equal(runtime.error, undefined);
await registry.stop();
});
test("stops agents created during shutdown and rejects new selections", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
@@ -270,8 +328,9 @@ test("coordinates forgetting with directory creation and active commands", async
/runtimes are open/,
);
const closing = registry.closeSessionRuntime(
registry.listAgents().find((agent) => agent.worktreePath === worktrees.feature)
.runtimeId,
registry
.listAgents()
.find((agent) => agent.worktreePath === worktrees.feature).runtimeId,
);
await new Promise((resolve) => setImmediate(resolve));
releaseStop();
@@ -483,9 +542,10 @@ test("routes commands by explicit agent ID and replays only events after the cur
type: "agent_state",
data: { state: "streaming" },
});
await registry.route(agent.id, "submit_prompt", {
const followUp = await registry.route(agent.id, "submit_prompt", {
message: "After this turn",
});
assert.equal(followUp.delivery, "follow_up");
assert.deepEqual(adapter.sent.at(-1), {
type: "follow_up",
message: "After this turn",
+39
View File
@@ -483,6 +483,45 @@ test("persists a first-prompt session when the runtime settles and awaits refres
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();
+110
View File
@@ -96,6 +96,32 @@ test("starts Pi in RPC mode and correlates a command response", async () => {
await adapter.stop();
});
test("allows a per-command timeout override without serializing it to Pi", async () => {
const fixture = createFixture();
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
commandTimeoutMs: 10,
});
const initial = adapter.send({ type: "get_state" }, { timeoutMs: 50 });
const [initialCommand] = fixture.sent();
await new Promise((resolve) => setTimeout(resolve, 20));
fixture.child.stdout.write(
`${JSON.stringify({ type: "response", id: initialCommand.id, command: "get_state", success: true })}\n`,
);
await initial;
assert.deepEqual(fixture.sent()[0], {
type: "get_state",
id: initialCommand.id,
});
await assert.rejects(
adapter.send({ type: "get_session_stats" }),
/timed out/,
);
await adapter.stop();
});
test("requests Pi session statistics for context and token status", async () => {
const fixture = createFixture();
const adapter = startPiRpcAdapter({
@@ -182,6 +208,90 @@ test("forwards extension responses without replacing Pi's request ID", async ()
await adapter.stop();
});
test("discards a fragmented oversized frame before processing the next frame", async () => {
const fixture = createFixture();
const errors = [];
const events = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
maxFrameBytes: 32,
onError: (error) => errors.push(error),
onEvent: (event) => events.push(event),
});
const oversized = JSON.stringify({
type: "message_update",
delta: "x".repeat(80),
});
fixture.child.stdout.write(oversized.slice(0, 40));
fixture.child.stdout.write(
`${oversized.slice(40)}\n${JSON.stringify({ type: "agent_settled" })}\n`,
);
assert.deepEqual(
errors.map((error) => error.code),
["frame_too_large"],
);
assert.deepEqual(events, [
{
seq: 1,
type: "agent_state",
data: { state: "idle", event: { type: "agent_settled" } },
},
]);
await adapter.stop();
});
test("discards an oversized complete frame before processing the next frame", async () => {
const fixture = createFixture();
const errors = [];
const events = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
maxFrameBytes: 32,
onError: (error) => errors.push(error),
onEvent: (event) => events.push(event),
});
const oversized = JSON.stringify({
type: "message_update",
delta: "x".repeat(80),
});
fixture.child.stdout.write(
`${oversized}\n${JSON.stringify({ type: "agent_settled" })}\n`,
);
assert.deepEqual(
errors.map((error) => error.code),
["frame_too_large"],
);
assert.equal(events.length, 1);
assert.equal(events[0].data.state, "idle");
await adapter.stop();
});
test("does not report an unterminated frame after discarding an oversized frame", async () => {
const fixture = createFixture();
const errors = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
maxFrameBytes: 32,
onError: (error) => errors.push(error),
});
fixture.child.stdout.write("x".repeat(40));
fixture.child.stdout.end();
assert.deepEqual(
errors.map((error) => error.code),
["frame_too_large"],
);
await adapter.stop();
});
test("reports invalid child output and rejects pending commands on child exit", async () => {
const fixture = createFixture();
const errors = [];