diff --git a/src/bridge/pi-rpc-adapter.js b/src/bridge/pi-rpc-adapter.js index f04280c..beea59d 100644 --- a/src/bridge/pi-rpc-adapter.js +++ b/src/bridge/pi-rpc-adapter.js @@ -67,7 +67,8 @@ function normalizePiEvent(event) { case "extension_ui_request": return { type: "extension_ui_request", data: { event } }; case "extension_error": - // Extension errors are diagnostics; the Pi process can still be healthy. + case "thinking_level_changed": + // Informational Pi notifications do not change runtime health. return { type: "transcript", data: { event } }; default: return undefined; @@ -209,6 +210,7 @@ export function startPiRpcAdapter({ "unsupported_event", `Pi RPC emitted unsupported event: ${event.type}`, ), + { terminal: false }, ); return; } @@ -229,7 +231,6 @@ export function startPiRpcAdapter({ "frame_too_large", `Pi RPC frame exceeds ${maxFrameBytes} bytes`, ), - { terminal: false }, ); const consumeStdout = (text) => { let remaining = text; @@ -303,10 +304,7 @@ export function startPiRpcAdapter({ send(commandInput, { timeoutMs: requestedTimeoutMs } = {}) { if (!isRecord(commandInput) || typeof commandInput.type !== "string") { return Promise.reject( - new PiRpcError( - "invalid_command", - "Pi RPC command must include a type", - ), + new PiRpcError("invalid_command", "Pi RPC command must include a type"), ); } if (Object.hasOwn(commandInput, "id")) { @@ -382,10 +380,7 @@ export function startPiRpcAdapter({ if (closed) return exited; intentionalStop = true; rejectPending( - new PiRpcError( - "child_stopped", - "Pi RPC child was stopped by the bridge", - ), + new PiRpcError("child_stopped", "Pi RPC child was stopped by the bridge"), ); child.kill("SIGTERM"); return exited; diff --git a/test/agent-registry.test.js b/test/agent-registry.test.js index 9279598..f070467 100644 --- a/test/agent-registry.test.js +++ b/test/agent-registry.test.js @@ -162,7 +162,7 @@ test("sends abort without waiting behind a blocked prompt", async () => { await registry.stop(); }); -test("keeps a runtime healthy after a non-terminal adapter diagnostic", async () => { +test("keeps a runtime healthy after an unknown non-terminal Pi notification", async () => { const worktrees = await createWorktrees(); const fixture = createAdapterFactory(); const registry = createAgentRegistry({ @@ -173,7 +173,10 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async () await registry.start(); fixture.calls[0].options.onError( - { code: "frame_too_large", message: "Pi RPC frame exceeds 32 bytes" }, + { + code: "unsupported_event", + message: "Pi RPC emitted unsupported event: future_pi_notification", + }, { terminal: false }, ); @@ -184,6 +187,31 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async () await registry.stop(); }); +test("keeps explicit transport failures terminal", 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: "invalid_json", + message: "Pi RPC emitted invalid JSON", + }); + + const runtime = registry.getWorkspace().directories[0].runtimes[0]; + assert.equal(runtime.state, "error"); + assert.equal(runtime.attention, true); + assert.deepEqual(runtime.error, { + code: "invalid_json", + message: "Pi RPC emitted invalid JSON", + }); + await registry.stop(); +}); + test("stops agents created during shutdown and rejects new selections", async () => { const worktrees = await createWorktrees(); const fixture = createAdapterFactory(); diff --git a/test/pi-rpc-adapter.test.js b/test/pi-rpc-adapter.test.js index 7b0c73a..992f09c 100644 --- a/test/pi-rpc-adapter.test.js +++ b/test/pi-rpc-adapter.test.js @@ -115,10 +115,7 @@ test("allows a per-command timeout override without serializing it to Pi", async type: "get_state", id: initialCommand.id, }); - await assert.rejects( - adapter.send({ type: "get_session_stats" }), - /timed out/, - ); + await assert.rejects(adapter.send({ type: "get_session_stats" }), /timed out/); await adapter.stop(); }); @@ -208,6 +205,49 @@ test("forwards extension responses without replacing Pi's request ID", async () await adapter.stop(); }); +test("normalizes thinking changes and treats every unknown Pi notification as non-terminal", async () => { + const fixture = createFixture(); + const events = []; + const errors = []; + const adapter = startPiRpcAdapter({ + cwd: "/workspace/home", + sessionDir: "/workspace/sessions", + spawnProcess: fixture.spawn, + onEvent: (event) => events.push(event), + onError: (error, metadata) => errors.push({ error, metadata }), + }); + const thinkingChange = { + type: "thinking_level_changed", + level: "high", + }; + fixture.child.stdout.write(`${JSON.stringify(thinkingChange)}\n`); + fixture.child.stdout.write( + `${JSON.stringify({ type: "future_pi_notification", detail: "new" })}\n`, + ); + fixture.child.stdout.write( + `${JSON.stringify({ type: "fatal_pi_notification", terminal: true })}\n`, + ); + + assert.deepEqual(events, [ + { + seq: 1, + type: "transcript", + data: { event: thinkingChange }, + }, + ]); + assert.deepEqual( + errors.map(({ error, metadata }) => ({ + code: error.code, + terminal: metadata.terminal, + })), + [ + { code: "unsupported_event", terminal: false }, + { code: "unsupported_event", terminal: false }, + ], + ); + await adapter.stop(); +}); + test("discards a fragmented oversized frame before processing the next frame", async () => { const fixture = createFixture(); const errors = []; @@ -217,7 +257,7 @@ test("discards a fragmented oversized frame before processing the next frame", a sessionDir: "/workspace/sessions", spawnProcess: fixture.spawn, maxFrameBytes: 32, - onError: (error) => errors.push(error), + onError: (error, metadata) => errors.push({ error, metadata }), onEvent: (event) => events.push(event), }); const oversized = JSON.stringify({ @@ -230,8 +270,11 @@ test("discards a fragmented oversized frame before processing the next frame", a ); assert.deepEqual( - errors.map((error) => error.code), - ["frame_too_large"], + errors.map(({ error, metadata }) => ({ + code: error.code, + terminal: metadata?.terminal !== false, + })), + [{ code: "frame_too_large", terminal: true }], ); assert.deepEqual(events, [ { diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js index 8c50884..b40f9ad 100644 --- a/test/quick-pane-ui.test.js +++ b/test/quick-pane-ui.test.js @@ -9,7 +9,7 @@ test("Quick Pane polls snapshots, renders answers, and gates handoff", async () ); assert.match(source, /get_session_runtime_snapshot/); assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/); - assert.match(source, /\{answer &&
\{answer\}<\/pre>\}/);
+  assert.match(source, /\{displayedAnswer && 
\{displayedAnswer\}<\/pre>\}/);
   assert.match(source, /const canContinue = !!escalation \|\| \(!!runtime && !!error\)/);
   assert.match(source, /\{canContinue && \(/);
 });
@@ -40,7 +40,11 @@ test("main Settings uses shared catalog selectors with provider derived from mod
   assert.match(pane, /set_thinking_level/);
   assert.doesNotMatch(pane, /showSettings/);
   assert.doesNotMatch(pane, /Save Quick Pane settings/);
-  assert.doesNotMatch(pane, /setRequest\(""\)/);
+  assert.match(pane, /isStreaming === false/);
+  assert.match(pane, /current === submitted\.request \? "" : current/);
+  assert.match(pane, /\.slice\(0, 5\)/);
+  assert.match(pane, /aria-label="Answer history"/);
+  assert.doesNotMatch(pane, /localStorage|sessionStorage|save_ui_state/);
   assert.match(persistence, /quickPane: current\.quickPane/);
 });
 
@@ -75,7 +79,7 @@ test("Quick Pane routes extension responses through existing bridge command", as
 });
 
 test("Quick Pane uses native dimensions and request-first compact layout", async () => {
-  const [source, stylesheet, configSource] = await Promise.all([
+  const [source, stylesheet, configSource, nativeSource, capabilitySource] = await Promise.all([
     readFile(
       new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
       "utf8",
@@ -85,8 +89,11 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
       "utf8",
     ),
     readFile(new URL("../ui/src-tauri/tauri.conf.json", import.meta.url), "utf8"),
+    readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"),
+    readFile(new URL("../ui/src-tauri/capabilities/default.json", import.meta.url), "utf8"),
   ]);
   const config = JSON.parse(configSource);
+  const capability = JSON.parse(capabilitySource);
   const quickPane = config.app.windows.find((window) => window.label === "quick-pane");
 
   assert.deepEqual(
@@ -97,11 +104,18 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
     },
     { width: 640, height: 440, transparent: true },
   );
+  assert.ok(capability.windows.includes("quick-pane"));
+  assert.match(nativeSource, /WindowEvent::CloseRequested/);
+  assert.match(nativeSource, /api\.prevent_close\(\)/);
+  assert.match(nativeSource, /WebviewWindowBuilder::from_config\(app, config\)/);
+  assert.match(nativeSource, /get_or_create_quick_pane/);
+  assert.doesNotMatch(source, /onCloseRequested/);
   assert.match(source, /className="quick-pane-request"/);
   assert.match(source, /className="quick-pane-output"/);
   assert.match(source, /