From 7fd2ba7526b94080810de575f5ab15fe96d2bb5d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 04:33:33 -0700 Subject: [PATCH] fix(server): failed Claude text generation shows the CLI's own error (#19394) Co-authored-by: Claude Opus 5.5 --- .../ClaudeTextGeneration.test.ts | 37 +++++++++++++++++++ .../textGeneration/ClaudeTextGeneration.ts | 21 ++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 577c122f65e01f333444f41b01ac48366fea1825..e907580eeffceb6b52d133adf9bc7dc74ca8de1c 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -484,6 +484,43 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { ), ); + const apiError = "API Error: 400 Model blocked by guardrail: 4 endpoints excluded"; + const wrapperNotice = "Using the OpenRouter credential from the global credential file."; + it.effect.each([ + [ + "the stdout error over a stderr notice", + JSON.stringify({ is_error: true, api_error_status: 400, result: apiError }), + apiError, + ], + [ + "the verbose stdout error over a stderr notice", + JSON.stringify([ + { type: "system", subtype: "init" }, + { type: "result", is_error: true, result: apiError }, + { type: "result", is_error: true, result: " " }, + ]), + apiError, + ], + ["stderr when stdout has no error", "not json", wrapperNotice], + ] as const)("reports %s when the Claude CLI fails", ([, output, expected]) => + withFakeClaudeEnv({ output, stderr: wrapperNotice, exitCode: 1 }, (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }), + ); + + expect(error.detail).toBe(`Claude CLI command failed: ${expected}`); + }), + ), + ); + it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => withFakeClaudeEnv( { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 7c6eb7532490b6960d9624c62685104d231a71eb..d655203af46d907f638b028120a40b043fb17dc1 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -75,6 +75,23 @@ const decodeClaudeOutput = Schema.decodeEffect( Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])), ); +const ClaudeErrorResult = Schema.Struct({ is_error: Schema.Literal(true), result: Schema.String }); +const isClaudeErrorResult = Schema.is(ClaudeErrorResult); +const decodeJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +/** + * The error a failed `claude -p --output-format json` run reports on stdout. Stderr may only + * hold notices from a wrapper around the binary, so the caller prefers this when present. + */ +function claudeErrorResult(stdout: string): string | undefined { + const output = Option.getOrUndefined(decodeJsonOption(stdout)); + const messages: ReadonlyArray = Array.isArray(output) ? output : [output]; + return messages + .filter(isClaudeErrorResult) + .map((message) => message.result.trim()) + .findLast((result) => result.length > 0); +} + export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, @@ -258,7 +275,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu if (exitCode !== 0) { const stderrDetail = stderr.trim(); const stdoutDetail = stdout.trim(); - const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + const detail = + claudeErrorResult(stdoutDetail) ?? + (stderrDetail.length > 0 ? stderrDetail : stdoutDetail); return yield* new TextGenerationError({ operation, detail: -- 2.54.0