feat(rpc): add validated desktop Pi RPC command palette
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user