From c467b9b372b6e5c6ae48b236c5b4eeed995d3382 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 13:32:09 -0700 Subject: [PATCH 1/2] test(agent-chat): cover a rejected send on the transcript branch sendPrompt dispatches transcript prompts with `void sess.adapter.send(...)`, so an adapter rejection becomes an unhandled rejection and the user sees nothing. The other two call sites already catch, log and emit an error event. This fails on main. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test/server-transcript-send.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 agent-chat/test/server-transcript-send.test.ts diff --git a/agent-chat/test/server-transcript-send.test.ts b/agent-chat/test/server-transcript-send.test.ts new file mode 100644 index 000000000000..397ab87d3beb --- /dev/null +++ b/agent-chat/test/server-transcript-send.test.ts @@ -0,0 +1,78 @@ +// A transcript chat view dispatches prompts through the same adapter as any +// other session, and that adapter can reject: the Pi adapter throws when its +// process is replaced during startup, and any adapter throws when a spawn +// fails. The transcript branch of sendPrompt has no turn generation to unwind, +// so this only checks that the failure reaches the user instead of surfacing +// as an unhandled rejection. +import { emitDoneAfterFilesForTest, emitSessionEventForTest, sendPromptForTest } from "../server"; +import type { Adapter, AgentEvent, SessionStatus } from "../types"; + +function assert(cond: unknown, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} + +const unhandled: unknown[] = []; +const onUnhandled = (reason: unknown) => { + unhandled.push(reason); +}; +process.on("unhandledRejection", onUnhandled); + +const adapter = { + capabilities: { options: [] }, + async send() { + throw new Error("pi process changed during startup"); + }, + stop() {}, + dispose() {}, + setOption: async () => {}, +} as unknown as Adapter; + +const events: AgentEvent[] = []; +const sess = { + id: "transcript-send-rejection", + provider: "pi", + cwd: "/tmp", + title: "transcript send rejection", + autoApprove: true, + startOptions: {}, + status: "idle" as SessionStatus, + events, + internal: {}, + adapter, + sockets: new Set(), + createdAt: Date.now(), + transcript: { agent: {} as never, path: "/tmp/transcript.jsonl" }, + emit(evt: AgentEvent) { + if (evt.kind === "done") emitDoneAfterFilesForTest(this as never, evt); + else emitSessionEventForTest(this as never, evt); + }, + setStatus(status: SessionStatus) { + this.status = status; + }, +}; + +sendPromptForTest(sess as never, "prompt that cannot be delivered"); +// Two turns of the microtask queue: one for the rejection, one for the handler. +await Promise.resolve(); +await Promise.resolve(); +// An unhandledRejection listener fires on a macrotask, not a microtask. +await new Promise((resolve) => setTimeout(resolve, 0)); + +const error = events.find((evt) => evt.kind === "error") as Extract | undefined; +assert(error, `a rejected transcript send must emit an error event: ${JSON.stringify(events)}`); +// safeReason maps anything outside its allow-list to "unexpected error", so +// the contract is the sanitized send message the other two call sites emit, +// not the adapter's own text. +assert( + error.message === "Failed to send message: unexpected error", + `the error event should carry the sanitized send message: ${JSON.stringify(error)}`, +); +assert( + unhandled.length === 0, + `a rejected transcript send must not leave an unhandled rejection: ${String(unhandled[0])}`, +); + +process.off("unhandledRejection", onUnhandled); +console.log("transcript send rejection assertions passed"); + +export {}; From 47cfced855d24711ae651ee02c20335cb781b5c3 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 13:33:05 -0700 Subject: [PATCH 2/2] fix(agent-chat): surface a rejected send on the transcript branch A transcript chat view dispatched prompts with a bare `void`, so an adapter rejection was an unhandled rejection and the user got no feedback. Catch it, log it and emit the same sanitized error event the other two call sites use. There is no turn generation on this path, so nothing to unwind and no "done" to emit. Co-Authored-By: Claude Opus 5.5 (1M context) --- agent-chat/server.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/agent-chat/server.ts b/agent-chat/server.ts index 82ba05992a31..28a55e7de8df 100644 --- a/agent-chat/server.ts +++ b/agent-chat/server.ts @@ -614,8 +614,14 @@ function emitDoneAfterFiles(sess: Session, evt: InternalDoneEvent) { function sendPrompt(sess: Session, prompt: string, requestId = crypto.randomUUID()) { if (sess.transcript) { - // Typed into the terminal's agent; the transcript records the prompt. - void sess.adapter.send(sess, prompt); + // Typed into the terminal's agent; the transcript records the prompt, so + // there is no turn generation to unwind and no "done" to emit. The send + // can still reject (a replaced agent process, a failed spawn), and that + // has to reach the user rather than becoming an unhandled rejection. + Promise.resolve(sess.adapter.send(sess, prompt)).catch((err) => { + console.error("[agent-chat] send failed", err); + sess.emit({ kind: "error", message: safeErrorMessage("send", err) }); + }); return; } emitRouting(sess, { phase: "started", requestId, attempt: 1, provider: sess.provider });