Skip to content

Commit 867d9a8

Browse files
Merge pull request #1306 from corbitsdev/cl-9836-xai-worker-failures-hide-the-provider-diagnostic
fix(subagent): surface scrubbed xAI worker diagnostics
2 parents c704dd7 + b087473 commit 867d9a8

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)