| author | |
| committer | |
| log | c471145e96fc2399b4df57ce78c72aa7fb7ccabf |
| tree | 2e4f1f7b372a2180fffa527abfa0e32b014789c3 |
| parent | 64a991ad455234e6fdd808a5a3caadc51f18a152 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>2 files changed, 84 insertions(+), 1 deletions(-)
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+61| ... | @@ -1450,6 +1450,67 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1450,6 +1450,67 @@ describe("ClaudeAdapterLive", () => { |
| 1450 | ); | 1450 | ); |
| 1451 | }); | 1451 | }); |
| 1452 | 1452 | ||
| 1453 | it.effect("treats aborted_tools results as interrupted and hides ede_diagnostic errors", () => { | ||
| 1454 | const harness = makeHarness(); | ||
| 1455 | return Effect.gen(function* () { | ||
| 1456 | const adapter = yield* ClaudeAdapter; | ||
| 1457 | |||
| 1458 | const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( | ||
| 1459 | Stream.runCollect, | ||
| 1460 | Effect.forkChild, | ||
| 1461 | ); | ||
| 1462 | |||
| 1463 | const session = yield* adapter.startSession({ | ||
| 1464 | threadId: THREAD_ID, | ||
| 1465 | provider: ProviderDriverKind.make("claudeAgent"), | ||
| 1466 | runtimeMode: "full-access", | ||
| 1467 | }); | ||
| 1468 | |||
| 1469 | const turn = yield* adapter.sendTurn({ | ||
| 1470 | threadId: session.threadId, | ||
| 1471 | input: "hello", | ||
| 1472 | attachments: [], | ||
| 1473 | }); | ||
| 1474 | |||
| 1475 | // Exact shape the CLI emits when Stop lands mid-tool-call: is_error | ||
| 1476 | // is true and the only error is internal diagnostic telemetry. | ||
| 1477 | harness.query.emit({ | ||
| 1478 | type: "result", | ||
| 1479 | subtype: "error_during_execution", | ||
| 1480 | is_error: true, | ||
| 1481 | errors: ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"], | ||
| 1482 | stop_reason: "tool_use", | ||
| 1483 | terminal_reason: "aborted_tools", | ||
| 1484 | session_id: "sdk-session-abort-tools", | ||
| 1485 | uuid: "result-abort-tools", | ||
| 1486 | } as unknown as SDKMessage); | ||
| 1487 | |||
| 1488 | const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); | ||
| 1489 | assert.deepEqual( | ||
| 1490 | runtimeEvents.map((event) => event.type), | ||
| 1491 | [ | ||
| 1492 | "session.started", | ||
| 1493 | "session.configured", | ||
| 1494 | "session.state.changed", | ||
| 1495 | "turn.started", | ||
| 1496 | "thread.started", | ||
| 1497 | "turn.completed", | ||
| 1498 | ], | ||
| 1499 | ); | ||
| 1500 | |||
| 1501 | const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; | ||
| 1502 | assert.equal(turnCompleted?.type, "turn.completed"); | ||
| 1503 | if (turnCompleted?.type === "turn.completed") { | ||
| 1504 | assert.equal(String(turnCompleted.turnId), String(turn.turnId)); | ||
| 1505 | assert.equal(turnCompleted.payload.state, "interrupted"); | ||
| 1506 | assert.equal(turnCompleted.payload.errorMessage, undefined); | ||
| 1507 | } | ||
| 1508 | }).pipe( | ||
| 1509 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | ||
| 1510 | Effect.provide(harness.layer), | ||
| 1511 | ); | ||
| 1512 | }); | ||
| 1513 | |||
| 1453 | it.effect("interruptTurn stops every live task before interrupting the turn", () => { | 1514 | it.effect("interruptTurn stops every live task before interrupting the turn", () => { |
| 1454 | const harness = makeHarness(); | 1515 | const harness = makeHarness(); |
| 1455 | return Effect.gen(function* () { | 1516 | return Effect.gen(function* () { |
apps/server/src/provider/Layers/ClaudeAdapter.ts+23-1| ... | @@ -348,7 +348,29 @@ function resultErrorsText(result: SDKResultMessage): string { | ... | @@ -348,7 +348,29 @@ function resultErrorsText(result: SDKResultMessage): string { |
| 348 | : ""; | 348 | : ""; |
| 349 | } | 349 | } |
| 350 | 350 | ||
| 351 | /** | ||
| 352 | * First user-facing error from a non-success result. "[ede_diagnostic] ..." | ||
| 353 | * entries are CLI-internal telemetry (the CLI hides them from its own UI too), | ||
| 354 | * so they must never become the error banner. | ||
| 355 | */ | ||
| 356 | function resultUserFacingError(result: SDKResultMessage): string | undefined { | ||
| 357 | if (result.subtype === "success" || !Array.isArray(result.errors)) { | ||
| 358 | return undefined; | ||
| 359 | } | ||
| 360 | return result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); | ||
| 361 | } | ||
| 362 | |||
| 351 | function isInterruptedResult(result: SDKResultMessage): boolean { | 363 | function isInterruptedResult(result: SDKResultMessage): boolean { |
| 364 | // The CLI stamps user aborts explicitly: interrupting mid-tool-call yields | ||
| 365 | // "aborted_tools" (with an internal "[ede_diagnostic] ..." error and | ||
| 366 | // is_error: true), interrupting mid-stream yields "aborted_streaming". | ||
| 367 | if ( | ||
| 368 | result.terminal_reason === "aborted_tools" || | ||
| 369 | result.terminal_reason === "aborted_streaming" | ||
| 370 | ) { | ||
| 371 | return true; | ||
| 372 | } | ||
| 373 | |||
| 352 | const errors = resultErrorsText(result); | 374 | const errors = resultErrorsText(result); |
| 353 | if (errors.includes("interrupt")) { | 375 | if (errors.includes("interrupt")) { |
| 354 | return true; | 376 | return true; |
| ... | @@ -2919,7 +2941,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2919,7 +2941,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2919 | } | 2941 | } |
| 2920 | 2942 | ||
| 2921 | const status = turnStatusFromResult(message); | 2943 | const status = turnStatusFromResult(message); |
| 2922 | const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; | 2944 | const errorMessage = resultUserFacingError(message); |
| 2923 | 2945 | ||
| 2924 | if (status === "failed") { | 2946 | if (status === "failed") { |
| 2925 | yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); | 2947 | yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); |