| author | |
| committer | |
| log | 7fd2ba7526b94080810de575f5ab15fe96d2bb5d |
| tree | cb360e47170d438a5e34dfd0a0e48409e3044765 |
| parent | f99b20275359ff7acecdb916a9fa25ac33102b12 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 486 | 486 | ||
| 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 | ); |
| 77 | 77 | ||
| 78 | const ClaudeErrorResult = Schema.Struct({ is_error: Schema.Literal(true), result: Schema.String }); | ||
| 79 | const isClaudeErrorResult = Schema.is(ClaudeErrorResult); | ||
| 80 | const 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 | */ | ||
| 86 | function 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 | |||
| 78 | export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( | 95 | export 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: |