Skip to content

Commit b087473

Browse files
committed
fix(subagent): surface scrubbed xAI worker diagnostics
Worker mailbox errors used a summary that dropped the provider reason, and xAI fatal 400s never lifted nested error.message over HTTP statusText. Keep raw on the captured inference.error, lift nested 400 copy for xAI the same way Codex does, and put the scrubbed full line on wait_agents so the next Grok 400 names why.
1 parent c704dd7 commit b087473

6 files changed

Lines changed: 109 additions & 36 deletions

‎src/inference-error-message.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
33
import { normalizeInferenceErrorForTerminal } from "./inference-gateway-error.js";
44
import {
55
CREDENTIAL_FAILURE_USER_MESSAGE,
6+
createResolvedProviderFailureError,
67
inferenceErrorMessage,
78
terminalProviderFailureMessage,
89
} from "./inference-error-message.js";
@@ -229,6 +230,18 @@ describe("terminalProviderFailureMessage", () => {
229230
expect(message).toContain('reconnect profile "default-2"');
230231
});
231232

233+
test("xAI worker failure line lifts nested 400 diagnostic over Bad Request", () => {
234+
const error = createResolvedProviderFailureError("xai/default-2", {
235+
category: "fatal",
236+
message: "Bad Request",
237+
statusCode: 400,
238+
raw: { error: { message: "Invalid request: recursive JSON schema" } },
239+
});
240+
expect(error.message).toContain("Invalid request: recursive JSON schema");
241+
expect(error.message).toContain("xai/default-2");
242+
expect(error.message).not.toContain("Bad Request");
243+
});
244+
232245
test("terminal Codex credential failure keeps branded wording plus the explicit command", () => {
233246
const normalized = normalizeInferenceErrorForTerminal(
234247
{

‎src/inference-error-message.ts‎

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
isGatewayOverloadInferenceError,
2121
isKnownOAuthProviderId,
2222
isXaiShortRateLimitInferenceError,
23+
normalizeInferenceErrorForTerminal,
2324
parseCodexUsageLimitFromError,
2425
RATE_LIMIT_USER_MESSAGE,
2526
type InferenceErrorLike,
@@ -253,18 +254,6 @@ function terminalProviderFailureGuidance(
253254
: 'Try again or switch models with "/model".';
254255
}
255256

256-
function terminalProviderFailureSummary(
257-
providerId: string,
258-
error: InferenceErrorLike,
259-
displayLabel?: string,
260-
): string {
261-
const label = terminalProviderFailureLabel(providerId, displayLabel);
262-
const category = terminalProviderFailureCategory(error);
263-
const guidance = terminalProviderFailureGuidance(error, category, providerId);
264-
const tail = guidance.length > 0 ? ` ${guidance}` : "";
265-
return `${label} Provider failed (${category}).${tail}`;
266-
}
267-
268257
export type ResolvedProviderFailureError = Error & {
269258
readonly name: "ResolvedProviderFailureError";
270259
readonly providerId: string;
@@ -277,16 +266,20 @@ export function createResolvedProviderFailureError(
277266
providerError: InferenceErrorLike,
278267
displayLabel?: string,
279268
): ResolvedProviderFailureError {
269+
const normalized = normalizeInferenceErrorForTerminal(
270+
providerError,
271+
providerId,
272+
);
280273
return Object.assign(
281274
new Error(
282-
terminalProviderFailureSummary(providerId, providerError, displayLabel),
275+
terminalProviderFailureMessage(providerId, normalized, displayLabel),
283276
),
284277
{
285278
name: "ResolvedProviderFailureError" as const,
286279
providerId,
287-
category: terminalProviderFailureCategory(providerError),
288-
...(providerError.statusCode !== undefined
289-
? { statusCode: providerError.statusCode }
280+
category: terminalProviderFailureCategory(normalized),
281+
...(normalized.statusCode !== undefined
282+
? { statusCode: normalized.statusCode }
290283
: {}),
291284
},
292285
);

‎src/inference-gateway-error.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,34 @@ describe("normalizeInferenceErrorForRetry", () => {
627627
expect(normalizeInferenceErrorForRetry(error)).toBe(error);
628628
});
629629

630+
test("lifts an xAI fatal 400 nested diagnostic over Bad Request", () => {
631+
const raw = {
632+
error: { message: "Invalid request: recursive JSON schema" },
633+
};
634+
const normalized = normalizeInferenceErrorForRetry({
635+
category: "fatal",
636+
message: "Bad Request",
637+
statusCode: 400,
638+
providerId: "xai/default-2",
639+
raw,
640+
});
641+
expect(normalized.category).toBe("fatal");
642+
expect(normalized.message).toBe("Invalid request: recursive JSON schema");
643+
expect(normalized.statusCode).toBe(400);
644+
expect(normalized.raw).toBe(raw);
645+
});
646+
647+
test("lifts a grok-responses fatal 400 nested diagnostic", () => {
648+
const normalized = normalizeInferenceErrorForRetry({
649+
category: "fatal",
650+
message: "Bad Request",
651+
statusCode: 400,
652+
providerId: "grok-responses",
653+
raw: { error: { message: "tools.0.parameters is invalid" } },
654+
});
655+
expect(normalized.message).toBe("tools.0.parameters is invalid");
656+
});
657+
630658
test("does not lift a Codex-shaped fatal 400 without a providerId", () => {
631659
const error = {
632660
category: "fatal" as const,

‎src/inference-gateway-error.ts‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -638,17 +638,24 @@ function normalizeCodexCredential404Error(
638638
}
639639

640640
/**
641-
* Codex 400s arrive as fatal with HTTP statusText ("Bad Request") while the
642-
* real diagnostic sits on nested `detail.error`. Lift the nested copy so the
643-
* transcript is actionable; keep category fatal — this is not overflow,
644-
* rate-limit, or quota.
641+
* Fatal 400s arrive with HTTP statusText ("Bad Request") while the real
642+
* diagnostic sits on nested `detail.error` / `error.message`. Lift the nested
643+
* copy so the transcript is actionable; keep category fatal — this is not
644+
* overflow, rate-limit, or quota. Codex and xAI/Grok both do this; other
645+
* providers keep statusText until their body shape is characterized.
645646
*/
646-
function normalizeCodexFatal400Message(
647+
function normalizeFatal400NestedMessage(
647648
error: InferenceErrorWithGoContext,
648649
): InferenceError {
649-
if (!isKnownCodexProviderId(error.providerId)) return error;
650650
if (error.category !== "fatal") return error;
651651
if (error.statusCode !== 400) return error;
652+
const providerId = error.providerId;
653+
if (
654+
!isKnownCodexProviderId(providerId) &&
655+
!isKnownXaiProviderId(providerId)
656+
) {
657+
return error;
658+
}
652659

653660
const current = error.message ?? "";
654661
if (current.length > 0 && current.toLowerCase() !== "bad request") {
@@ -815,9 +822,9 @@ function normalizeOAuthUpgradeRequiredError(
815822
* 429s, attributable xAI capacity protocol_mismatch, Codex usage limits
816823
* (nested detail.error with resets_in_seconds), known-Codex short 429s that
817824
* are not usage_limit_reached, known-Codex 404s carrying an
818-
* auth-rejection signal (expired/revoked credential), known-Codex fatal
819-
* 400s whose message is empty or "Bad Request" (nested diagnostic on raw), and
820-
* known-OAuth fatal 426s (unauthenticated-provider upgrade rejection).
825+
* auth-rejection signal (expired/revoked credential), known-Codex and known-xAI
826+
* fatal 400s whose message is empty or "Bad Request" (nested diagnostic on raw),
827+
* and known-OAuth fatal 426s (unauthenticated-provider upgrade rejection).
821828
*/
822829
export function normalizeInferenceErrorForRetry(
823830
error: InferenceErrorWithGoContext,
@@ -840,8 +847,8 @@ export function normalizeInferenceErrorForRetry(
840847
const codexCredential = normalizeCodexCredential404Error(error);
841848
if (codexCredential !== error) return codexCredential;
842849

843-
const codexFatal400 = normalizeCodexFatal400Message(error);
844-
if (codexFatal400 !== error) return codexFatal400;
850+
const fatal400 = normalizeFatal400NestedMessage(error);
851+
if (fatal400 !== error) return fatal400;
845852

846853
const oauthUpgrade = normalizeOAuthUpgradeRequiredError(error);
847854
if (oauthUpgrade !== error) return oauthUpgrade;

‎src/subagent/run-resolved-provider-failure.test.ts‎

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ import {
3232
const OPAQUE_SECRET = "opaque credential with spaces?!";
3333
const RAW_DIAGNOSTIC = `\u001b[31mPOST https://provider.invalid returned\n credential ${OPAQUE_SECRET} in response body\u001b[0m`;
3434
const NORMALIZED_DIAGNOSTIC = `POST https://provider.invalid returned credential ${OPAQUE_SECRET} in response body`;
35-
const SAFE_MESSAGE =
36-
'test-provider Provider failed (fatal). Try again or switch models with "/model".';
35+
const SCRUBBED_DIAGNOSTIC =
36+
"POST https://provider.invalid returned credential [redacted: configured credential] in response body";
37+
const SAFE_MESSAGE = `test-provider Provider failed (fatal): ${SCRUBBED_DIAGNOSTIC}. Try again or switch models with "/model".`;
3738
const provider = {
3839
providerName: "test-provider",
3940
baseURL: "http://localhost",
@@ -282,6 +283,35 @@ describe("resolved sub-agent provider failures", () => {
282283
expect(JSON.stringify(observed)).not.toContain(RAW_DIAGNOSTIC);
283284
});
284285

286+
test("xAI 400 mailbox line lifts the nested diagnostic from raw", async () => {
287+
const caught = await withResolvedProviderRun(
288+
async (run, cwd) => {
289+
try {
290+
await run({
291+
...runParams(cwd),
292+
provider: { ...provider, providerName: "xai/default-2" },
293+
});
294+
} catch (error) {
295+
return error;
296+
}
297+
throw new Error("expected runSubAgent to reject");
298+
},
299+
{
300+
category: "fatal",
301+
message: "Bad Request",
302+
statusCode: 400,
303+
raw: { error: { message: "Invalid request: recursive JSON schema" } },
304+
},
305+
);
306+
expect(isResolvedProviderFailureError(caught)).toBe(true);
307+
expect((caught as ResolvedProviderFailureError).message).toContain(
308+
"Invalid request: recursive JSON schema",
309+
);
310+
expect((caught as ResolvedProviderFailureError).message).not.toContain(
311+
"Bad Request",
312+
);
313+
});
314+
285315
test("split spawn_agent and wait_agents return only the safe message", async () => {
286316
await withResolvedProviderRun(async (run, cwd) => {
287317
const { sessions, agentId, waitResult } = await spawnAndWait(
@@ -326,8 +356,7 @@ describe("resolved sub-agent provider failures", () => {
326356
cwd,
327357
"rejected provider failure",
328358
);
329-
const safeFailure =
330-
"test-provider Provider failed (retryable). Try again.";
359+
const safeFailure = `test-provider Provider failed (retryable): ${SCRUBBED_DIAGNOSTIC}. Try again.`;
331360

332361
const serialized = JSON.stringify(waitResult);
333362
expect(serialized).toContain(safeFailure);
@@ -351,15 +380,14 @@ describe("resolved sub-agent provider failures", () => {
351380
message: RAW_DIAGNOSTIC,
352381
statusCode: 500,
353382
},
354-
expected: "test-provider Provider failed (retryable). Try again.",
383+
expected: `test-provider Provider failed (retryable): ${SCRUBBED_DIAGNOSTIC}. Try again.`,
355384
},
356385
{
357386
error: { category: "protocol_mismatch", message: RAW_DIAGNOSTIC },
358-
expected:
359-
'test-provider Provider failed (protocol_mismatch). Switch models with "/model".',
387+
expected: `test-provider Provider failed (protocol_mismatch): ${SCRUBBED_DIAGNOSTIC}. Switch models with "/model".`,
360388
},
361389
] satisfies { error: InferenceErrorLike; expected: string }[])(
362-
"preserves $error.category guidance without exposing its diagnostic",
390+
"preserves $error.category guidance and the scrubbed diagnostic",
363391
async ({ error, expected }) => {
364392
const caught = await withResolvedProviderRun(async (run, cwd) => {
365393
try {
@@ -434,7 +462,7 @@ describe("resolved sub-agent provider failures", () => {
434462
expect(resolved.category).toBe("retryable");
435463
expect(resolved.statusCode).toBe(503);
436464
expect(resolved.message).toBe(
437-
"test-provider Provider failed (retryable). Try again.",
465+
`test-provider Provider failed (retryable): still failing. Try again.`,
438466
);
439467
});
440468

‎src/subagent/run.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,6 +1466,10 @@ async function runSubAgentInner(
14661466
...(error.retryAfterMs !== undefined
14671467
? { retryAfterMs: error.retryAfterMs }
14681468
: {}),
1469+
...("raw" in error && error.raw !== undefined
1470+
? { raw: error.raw }
1471+
: {}),
1472+
providerId: params.provider.providerName,
14691473
};
14701474
}
14711475
if (onTurnBoundary(event)) {

0 commit comments

Comments
 (0)