History

fix(server): surface Claude safety model fallback notices instead of dropping them (#8853)

Darahaas Yajamanyam committed 2 months ago

Commit a5bbad91 parent 1963ca02 files · +34 −10Browse files

Unified · Split

2 files
  1. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +26 −9
  2. apps/server/src/provider/Layers/ClaudeAdapter.ts +8 −1
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+26−9
@@ -3449,10 +3449,14 @@ describe("ClaudeAdapterLive", () => { · 3448 unmodified lines
3449 const harness = makeHarness();3449 const harness = makeHarness();
3450 return Effect.gen(function* () {3450 return Effect.gen(function* () {
3451 const adapter = yield* ClaudeAdapter;3451 const adapter = yield* ClaudeAdapter;
3452 const runtimeEvents: Array<ProviderRuntimeEvent> = [];3452 const runtimeEventsFiber = yield* adapter.streamEvents.pipe(
3453 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>3453 Stream.takeUntil(
3454 Effect.sync(() => runtimeEvents.push(event)),3454 (event) =>
3455 ).pipe(Effect.forkChild);3455 event.type === "session.state.changed" && event.payload.reason === "api_retry:3/10",
3456 ),
3457 Stream.runCollect,
3458 Effect.forkChild,
3459 );
3456 3460
3457 yield* adapter.startSession({3461 yield* adapter.startSession({
3458 threadId: THREAD_ID,3462 threadId: THREAD_ID,
@@ -3498,7 +3502,6 @@ describe("ClaudeAdapterLive", () => { · 39 unmodified lines
3498 uuid: "tu",3502 uuid: "tu",
3499 },3503 },
3500 { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" },3504 { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" },
3501 { type: "system", subtype: "model_refusal_fallback", session_id: "session", uuid: "mrf" },
3502 { type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" },3505 { type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" },
3503 { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" },3506 { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" },
3504 { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" },3507 { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" },
@@ -3545,6 +3548,21 @@ describe("ClaudeAdapterLive", () => { · 40 unmodified lines
3545 ]) {3548 ]) {
3546 harness.query.emit(message as unknown as SDKMessage);3549 harness.query.emit(message as unknown as SDKMessage);
3547 }3550 }
3551 // Safety model-fallback notices DO surface as a warning row.
3552 harness.query.emit({
3553 type: "system",
3554 subtype: "model_refusal_fallback",
3555 trigger: "refusal",
3556 direction: "retry",
3557 original_model: "claude-fable-5",
3558 fallback_model: "claude-opus-4-8",
3559 request_id: "req_test",
3560 api_refusal_category: "cyber",
3561 api_refusal_explanation: null,
3562 content: "Safeguards flagged this message. Switched to Opus 4.8.",
3563 session_id: "session",
3564 uuid: "mrf",
3565 } as unknown as SDKMessage);
3548 // High-priority notifications DO surface as a warning row.3566 // High-priority notifications DO surface as a warning row.
3549 harness.query.emit({3567 harness.query.emit({
3550 type: "system",3568 type: "system",
@@ -3602,15 +3620,15 @@ describe("ClaudeAdapterLive", () => { · 51 unmodified lines
3602 session_id: "session",3620 session_id: "session",
3603 uuid: "retry",3621 uuid: "retry",
3604 } as unknown as SDKMessage);3622 } as unknown as SDKMessage);
3605 yield* Effect.yieldNow;3623 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
3606 yield* Effect.yieldNow;
3607 3624
3608 const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning");3625 const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning");
3609 // Exactly three warnings: the high-priority notification, the3626 // Exactly four warnings: the fallback notice, high-priority notification,
3610 // warning-level informational note, and the refusal. Nothing else.3627 // warning-level informational note, and the refusal. Nothing else.
3611 assert.deepEqual(3628 assert.deepEqual(
3612 warnings.map((event) => event.payload.message),3629 warnings.map((event) => event.payload.message),
3613 [3630 [
3631 "Safeguards flagged this message. Switched to Opus 4.8.",
3614 "context window nearly full",3632 "context window nearly full",
3615 "Stop hook prevented continuation",3633 "Stop hook prevented continuation",
3616 "The request was declined by the API.",3634 "The request was declined by the API.",
@@ -3638,7 +3656,6 @@ describe("ClaudeAdapterLive", () => { · 21 unmodified lines
3638 event.payload.reason.startsWith("api_retry:"),3656 event.payload.reason.startsWith("api_retry:"),
3639 );3657 );
3640 assert.equal(heartbeat?.type, "session.state.changed");3658 assert.equal(heartbeat?.type, "session.state.changed");
3641 runtimeEventsFiber.interruptUnsafe();
3642 }).pipe(3659 }).pipe(
3643 Effect.provideService(Random.Random, makeDeterministicRandomService()),3660 Effect.provideService(Random.Random, makeDeterministicRandomService()),
3644 Effect.provide(harness.layer),3661 Effect.provide(harness.layer),
apps/server/src/provider/Layers/ClaudeAdapter.ts+8−1
@@ -3655,6 +3655,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 3654 unmodified lines
3655 yield* emitRuntimeWarning(context, message.text, message);3655 yield* emitRuntimeWarning(context, message.text, message);
3656 }3656 }
3657 return;3657 return;
3658 case "model_refusal_fallback":
3659 // A safety fallback switched the model mid-session (e.g. Fable 5
3660 // retried on Opus 4.8 after a flagged request). The CLI ships the
3661 // user-facing notice in `content`; surface it like high-priority
3662 // notifications so the rest of the session isn't silently served
3663 // by a different model.
3664 yield* emitRuntimeWarning(context, message.content, message);
3665 return;
3658 // Inner protocol/UX details with no T3 surface today — consumed3666 // Inner protocol/UX details with no T3 surface today — consumed
3659 // deliberately so they don't masquerade as unknown-subtype warnings.3667 // deliberately so they don't masquerade as unknown-subtype warnings.
3660 // `background_tasks_changed` is a roster snapshot ({tasks: [...]}); the3668 // `background_tasks_changed` is a roster snapshot ({tasks: [...]}); the
@@ -3663,7 +3671,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 2 unmodified lines
3663 // source. `control_request_progress` is a liveness heartbeat for an3671 // source. `control_request_progress` is a liveness heartbeat for an
3664 // in-flight control request. `worker_shutting_down` is a Remote3672 // in-flight control request. `worker_shutting_down` is a Remote
3665 // Control worker notice; the session close path reports the outcome.3673 // Control worker notice; the session close path reports the outcome.
3666 case "model_refusal_fallback":
3667 case "local_command_output":3674 case "local_command_output":
3668 case "plugin_install":3675 case "plugin_install":
3669 case "commands_changed":3676 case "commands_changed":

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t