From 9beaff4d89be2dadbe251abd533889e4bf2f7229 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Sun, 27 Sep 2026 18:05:18 -0400 Subject: [PATCH] fix: surface alternate streamed error text safely --- src/core/stream.ts | 11 +++++++++- test/stream.test.ts | 16 +++++++++++++++ test/turn_lifecycle.test.ts | 41 +++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/core/stream.ts b/src/core/stream.ts index ade870f..0a84800 100644 --- a/src/core/stream.ts +++ b/src/core/stream.ts @@ -145,7 +145,7 @@ function normalizeFrameBody(obj: Record): StreamFrameBody | nul case "error": return { type: "error", - msg: String(obj["msg"] ?? obj["message"] ?? ""), + msg: streamErrorMessage(obj), errorCode: strOrUndef(obj["error_code"] ?? obj["errorCode"] ?? obj["code"]), refId: strOrUndef(obj["ref_id"] ?? obj["refId"]), }; @@ -368,6 +368,15 @@ function numOrUndef(v: unknown): number | undefined { function strOrUndef(v: unknown): string | undefined { return v == null ? undefined : String(v); } +function streamErrorMessage(obj: Record): string { + // Some server paths send `error` instead of `msg`. Keep the internal `reason` + // field out of user-facing frames, even when all message fields are blank. + for (const key of ["msg", "message", "error"]) { + const value = obj[key]; + if (typeof value === "string" && value.trim()) return value; + } + return ""; +} function parseStrArray(v: unknown): string[] | undefined { if (!Array.isArray(v)) return undefined; return v.map((x) => String(x)); diff --git a/test/stream.test.ts b/test/stream.test.ts index 0d2708d..2ef85b1 100644 --- a/test/stream.test.ts +++ b/test/stream.test.ts @@ -31,6 +31,22 @@ test("normalizeFrame error uses contract keys msg/error_code/ref_id", () => { assert.deepEqual(f, { type: "error", msg: "boom", errorCode: "E42", refId: "r1" }); }); +test("normalizeFrame takes the first nonblank error text and never exposes reason", () => { + const base = { type: "error", reason: "internal detail" }; + assert.deepEqual(normalizeFrame({ ...base, msg: "primary", error: "fallback" }), { + type: "error", msg: "primary", errorCode: undefined, refId: undefined, + }); + assert.deepEqual(normalizeFrame({ ...base, msg: " ", message: "alternate", error: "fallback" }), { + type: "error", msg: "alternate", errorCode: undefined, refId: undefined, + }); + assert.deepEqual(normalizeFrame({ ...base, msg: "", error: "server failure" }), { + type: "error", msg: "server failure", errorCode: undefined, refId: undefined, + }); + assert.deepEqual(normalizeFrame({ ...base, error: { secret: true } }), { + type: "error", msg: "", errorCode: undefined, refId: undefined, + }); +}); + test("normalizeFrame surfaces the custody frame (client decides to save)", () => { const custody = { protocol: "custody-1", diff --git a/test/turn_lifecycle.test.ts b/test/turn_lifecycle.test.ts index 9b3849f..cc467f4 100644 --- a/test/turn_lifecycle.test.ts +++ b/test/turn_lifecycle.test.ts @@ -224,6 +224,47 @@ test("streamed 402 after a partial delta preserves text and adds actionable sani } }); +test("one-shot error-key frame is a visible failed turn without exposing reason or trailing done", async () => { + resetRegistry(); + const realFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return sseResponse([ + { type: "error", error: "The worker hit an error.\u001b]52;c;payload\u0007", reason: "internal detail" }, + { type: "done", uvt: 0, cents: 0 }, + ]); + }) as typeof globalThis.fetch; + try { + const result = await captureWrites(() => cmdChat(cloudContext(), "ship this")); + assert.equal(result.value, 1); + assert.equal(calls, 1, "a terminal error must not retry or switch transports"); + assert.match(result.stderr, /The worker hit an error\./); + assert.doesNotMatch(result.stderr, /internal detail|\u001b\]52|0 UVT/i); + assert.equal(result.stdout, "", "an error must not fabricate model output"); + } finally { + globalThis.fetch = realFetch; + } +}); + +test("one-shot blank error frame remains a failed turn with a fallback message", async () => { + resetRegistry(); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => sseResponse([ + { type: "error", msg: " ", reason: "internal detail" }, + { type: "done", uvt: 0, cents: 0 }, + ])) as typeof globalThis.fetch; + try { + const result = await captureWrites(() => cmdChat(cloudContext(), "ship this")); + assert.equal(result.value, 1); + assert.match(result.stderr, /turn failed/i); + assert.doesNotMatch(result.stderr, /internal detail|0 UVT/i); + assert.equal(result.stdout, ""); + } finally { + globalThis.fetch = realFetch; + } +}); + test("empty-body 402 still gives a visible balance action and a nonzero one-shot result", async () => { resetRegistry(); const realFetch = globalThis.fetch;