diff --git a/src/core/stream.ts b/src/core/stream.ts index 0a84800..0195cb0 100644 --- a/src/core/stream.ts +++ b/src/core/stream.ts @@ -368,13 +368,24 @@ function numOrUndef(v: unknown): number | undefined { function strOrUndef(v: unknown): string | undefined { return v == null ? undefined : String(v); } + +const LEGACY_PUBLIC_STREAM_ERRORS = new Set([ + "The media studio agent hit an error.", + "generation registry did not reach a terminal state", +]); + 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"]) { + // Only msg/message are a public message contract. Legacy media frames use + // fixed public `error` strings, but other `error` values may be provider + // exception text. Never map the internal `reason` field into a frame. + for (const key of ["msg", "message"]) { const value = obj[key]; if (typeof value === "string" && value.trim()) return value; } + const legacyError = obj["error"]; + if (typeof legacyError === "string" && LEGACY_PUBLIC_STREAM_ERRORS.has(legacyError)) { + return legacyError; + } return ""; } function parseStrArray(v: unknown): string[] | undefined { diff --git a/test/stream.test.ts b/test/stream.test.ts index 2ef85b1..9cd6a96 100644 --- a/test/stream.test.ts +++ b/test/stream.test.ts @@ -31,16 +31,25 @@ 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", () => { +test("normalizeFrame accepts canonical and fixed legacy public error text only", () => { const base = { type: "error", reason: "internal detail" }; - assert.deepEqual(normalizeFrame({ ...base, msg: "primary", error: "fallback" }), { + assert.deepEqual(normalizeFrame({ ...base, msg: "primary", error: "private provider exception" }), { type: "error", msg: "primary", errorCode: undefined, refId: undefined, }); - assert.deepEqual(normalizeFrame({ ...base, msg: " ", message: "alternate", error: "fallback" }), { + assert.deepEqual(normalizeFrame({ ...base, msg: " ", message: "alternate", error: "private provider exception" }), { 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, msg: "", error: "The media studio agent hit an error." }), { + type: "error", msg: "The media studio agent hit an error.", errorCode: undefined, refId: undefined, + }); + assert.deepEqual(normalizeFrame({ ...base, error: "generation registry did not reach a terminal state" }), { + type: "error", msg: "generation registry did not reach a terminal state", errorCode: undefined, refId: undefined, + }); + assert.deepEqual(normalizeFrame({ ...base, error: "private provider exception: token=SECRET" }), { + type: "error", msg: "", errorCode: undefined, refId: undefined, + }); + assert.deepEqual(normalizeFrame({ ...base, error: "The media studio agent hit an error. " }), { + type: "error", msg: "", errorCode: undefined, refId: undefined, }); assert.deepEqual(normalizeFrame({ ...base, error: { secret: true } }), { type: "error", msg: "", errorCode: undefined, refId: undefined, diff --git a/test/turn_lifecycle.test.ts b/test/turn_lifecycle.test.ts index cc467f4..ac7aab5 100644 --- a/test/turn_lifecycle.test.ts +++ b/test/turn_lifecycle.test.ts @@ -224,14 +224,14 @@ 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 () => { +test("one-shot fixed legacy media error is visible 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: "error", error: "The media studio agent hit an error.", reason: "internal detail" }, { type: "done", uvt: 0, cents: 0 }, ]); }) as typeof globalThis.fetch; @@ -239,14 +239,32 @@ test("one-shot error-key frame is a visible failed turn without exposing reason 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.match(result.stderr, /The media studio agent hit an error\./); + assert.doesNotMatch(result.stderr, /internal detail|0 UVT/i); assert.equal(result.stdout, "", "an error must not fabricate model output"); } finally { globalThis.fetch = realFetch; } }); +test("one-shot arbitrary error-key text is hidden while the turn still fails", async () => { + resetRegistry(); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => sseResponse([ + { type: "error", error: "provider token=SECRET\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.match(result.stderr, /turn failed/i); + assert.doesNotMatch(result.stderr, /SECRET|internal detail|\u001b\]52|0 UVT/i); + assert.equal(result.stdout, ""); + } finally { + globalThis.fetch = realFetch; + } +}); + test("one-shot blank error frame remains a failed turn with a fallback message", async () => { resetRegistry(); const realFetch = globalThis.fetch;