Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/core/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ function normalizeFrameBody(obj: Record<string, unknown>): 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"]),
};
Expand Down Expand Up @@ -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, unknown>): 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));
Expand Down
16 changes: 16 additions & 0 deletions test/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions test/turn_lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading