authorgravatar for 63366288+darahaas15@users.noreply.github.comDarahaas Yajamanyam <63366288+darahaas15@users.noreply.github.com> 2026-09-05 05:51:15+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 17:21:15-07:00
loga5bbad910f78cc14eef8baa94fe6f46676f78d5a
tree84c2bc630ce58cd7c6d7adbf6e29e2cb0aa6e297
parent1963ca0abe07f9b9aac67820bca45ea3b46ce023
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

2 files changed, 34 insertions(+), 10 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+26-9
...@@ -3449,10 +3449,14 @@ describe("ClaudeAdapterLive", () => {...@@ -3449,10 +3449,14 @@ describe("ClaudeAdapterLive", () => {
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 );
34563460
3457 yield* adapter.startSession({3461 yield* adapter.startSession({
3458 threadId: THREAD_ID,3462 threadId: THREAD_ID,
...@@ -3498,7 +3502,6 @@ describe("ClaudeAdapterLive", () => {...@@ -3498,7 +3502,6 @@ describe("ClaudeAdapterLive", () => {
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", () => {...@@ -3545,6 +3548,21 @@ describe("ClaudeAdapterLive", () => {
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", () => {...@@ -3602,15 +3620,15 @@ describe("ClaudeAdapterLive", () => {
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;
36073624
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", () => {...@@ -3638,7 +3656,6 @@ describe("ClaudeAdapterLive", () => {
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* (...@@ -3655,6 +3655,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
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* (...@@ -3663,7 +3671,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
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":