authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 04:33:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 04:33:33-07:00
log7fd2ba7526b94080810de575f5ab15fe96d2bb5d
treecb360e47170d438a5e34dfd0a0e48409e3044765
parentf99b20275359ff7acecdb916a9fa25ac33102b12
signature Signed by PGP key B5690EEEBB952194

fix(server): failed Claude text generation shows the CLI's own error (#19394)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 57 insertions(+), 1 deletions(-)

apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+37
...@@ -484,6 +484,43 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => {...@@ -484,6 +484,43 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => {
484 ),484 ),
485 );485 );
486486
487 const apiError = "API Error: 400 Model blocked by guardrail: 4 endpoints excluded";
488 const wrapperNotice = "Using the OpenRouter credential from the global credential file.";
489 it.effect.each([
490 [
491 "the stdout error over a stderr notice",
492 JSON.stringify({ is_error: true, api_error_status: 400, result: apiError }),
493 apiError,
494 ],
495 [
496 "the verbose stdout error over a stderr notice",
497 JSON.stringify([
498 { type: "system", subtype: "init" },
499 { type: "result", is_error: true, result: apiError },
500 { type: "result", is_error: true, result: " " },
501 ]),
502 apiError,
503 ],
504 ["stderr when stdout has no error", "not json", wrapperNotice],
505 ] as const)("reports %s when the Claude CLI fails", ([, output, expected]) =>
506 withFakeClaudeEnv({ output, stderr: wrapperNotice, exitCode: 1 }, (textGeneration) =>
507 Effect.gen(function* () {
508 const error = yield* Effect.flip(
509 textGeneration.generateThreadTitle({
510 cwd: process.cwd(),
511 message: "Name this thread",
512 modelSelection: {
513 instanceId: ProviderInstanceId.make("claudeAgent"),
514 model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
515 },
516 }),
517 );
518
519 expect(error.detail).toBe(`Claude CLI command failed: ${expected}`);
520 }),
521 ),
522 );
523
487 it.effect("falls back when Claude thread title normalization becomes whitespace-only", () =>524 it.effect("falls back when Claude thread title normalization becomes whitespace-only", () =>
488 withFakeClaudeEnv(525 withFakeClaudeEnv(
489 {526 {
apps/server/src/textGeneration/ClaudeTextGeneration.ts+20-1
...@@ -75,6 +75,23 @@ const decodeClaudeOutput = Schema.decodeEffect(...@@ -75,6 +75,23 @@ const decodeClaudeOutput = Schema.decodeEffect(
75 Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])),75 Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])),
76);76);
7777
78const ClaudeErrorResult = Schema.Struct({ is_error: Schema.Literal(true), result: Schema.String });
79const isClaudeErrorResult = Schema.is(ClaudeErrorResult);
80const decodeJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
81
82/**
83 * The error a failed `claude -p --output-format json` run reports on stdout. Stderr may only
84 * hold notices from a wrapper around the binary, so the caller prefers this when present.
85 */
86function claudeErrorResult(stdout: string): string | undefined {
87 const output = Option.getOrUndefined(decodeJsonOption(stdout));
88 const messages: ReadonlyArray<unknown> = Array.isArray(output) ? output : [output];
89 return messages
90 .filter(isClaudeErrorResult)
91 .map((message) => message.result.trim())
92 .findLast((result) => result.length > 0);
93}
94
78export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (95export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (
79 claudeSettings: ClaudeSettings,96 claudeSettings: ClaudeSettings,
80 environment?: NodeJS.ProcessEnv,97 environment?: NodeJS.ProcessEnv,
...@@ -258,7 +275,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu...@@ -258,7 +275,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
258 if (exitCode !== 0) {275 if (exitCode !== 0) {
259 const stderrDetail = stderr.trim();276 const stderrDetail = stderr.trim();
260 const stdoutDetail = stdout.trim();277 const stdoutDetail = stdout.trim();
261 const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;278 const detail =
279 claudeErrorResult(stdoutDetail) ??
280 (stderrDetail.length > 0 ? stderrDetail : stdoutDetail);
262 return yield* new TextGenerationError({281 return yield* new TextGenerationError({
263 operation,282 operation,
264 detail:283 detail: