diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 197d412d93405..45780dfcf56a0 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -452,7 +452,33 @@ 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."; + const structuredOutputError = "Failed to provide valid structured output after 5 attempts"; + const structuredOutputFailure = { + type: "result", + subtype: "error_max_structured_output_retries", + is_error: true, + errors: [structuredOutputError], + }; it.effect.each([ + [ + "the errors list of a result with no result text", + JSON.stringify(structuredOutputFailure), + structuredOutputError, + ], + [ + "the errors list from verbose output", + JSON.stringify([{ type: "system", subtype: "init" }, structuredOutputFailure]), + structuredOutputError, + ], + [ + "the errors list without CLI telemetry", + JSON.stringify({ + ...structuredOutputFailure, + subtype: "error_during_execution", + errors: ["[ede_diagnostic] result_type=user last_content_type=n/a", apiError], + }), + apiError, + ], [ "the stdout error over a stderr notice", JSON.stringify({ is_error: true, api_error_status: 400, result: apiError }), @@ -494,4 +520,25 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { }), ), ); + + it.effect("does not report the raw result JSON when a failed run writes nothing to stderr", () => + withFakeClaudeEnv( + { output: JSON.stringify(structuredOutputFailure), 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: ${structuredOutputError}`); + }), + ), + ); }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 51acced2745e5..63d151455e212 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -67,7 +67,13 @@ const decodeClaudeOutput = Schema.decodeEffect( Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])), ); -const ClaudeErrorResult = Schema.Struct({ is_error: Schema.Literal(true), result: Schema.String }); +// Only `success` results carry `result`; error subtypes such as +// error_max_structured_output_retries report through `errors` instead. +const ClaudeErrorResult = Schema.Struct({ + is_error: Schema.Literal(true), + result: Schema.optionalKey(Schema.String), + errors: Schema.optionalKey(Schema.Array(Schema.String)), +}); const isClaudeErrorResult = Schema.is(ClaudeErrorResult); const decodeJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); @@ -77,7 +83,14 @@ function claudeErrorResult(stdout: string): string | undefined { const messages: ReadonlyArray = Array.isArray(output) ? output : [output]; return messages .filter(isClaudeErrorResult) - .map((message) => message.result.trim()) + .map((message) => + ( + message.result ?? + // error_during_execution mixes CLI telemetry into the list. + message.errors?.filter((error) => !error.startsWith("[ede_diagnostic]")).join("\n") ?? + "" + ).trim(), + ) .findLast((result) => result.length > 0); }