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, /