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", () => {
34493449 const harness = makeHarness();
34503450 return Effect.gen(function* () {
34513451 const adapter = yield* ClaudeAdapter;
3452 const runtimeEvents: Array<ProviderRuntimeEvent> = [];
3453 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
3454 Effect.sync(() => runtimeEvents.push(event)),
3455 ).pipe(Effect.forkChild);
3452 const runtimeEventsFiber = yield* adapter.streamEvents.pipe(
3453 Stream.takeUntil(
3454 (event) =>
3455 event.type === "session.state.changed" && event.payload.reason === "api_retry:3/10",
3456 ),
3457 Stream.runCollect,
3458 Effect.forkChild,
3459 );
34563460
34573461 yield* adapter.startSession({
34583462 threadId: THREAD_ID,
......@@ -3498,7 +3502,6 @@ describe("ClaudeAdapterLive", () => {
34983502 uuid: "tu",
34993503 },
35003504 { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" },
3501 { type: "system", subtype: "model_refusal_fallback", session_id: "session", uuid: "mrf" },
35023505 { type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" },
35033506 { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" },
35043507 { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" },
......@@ -3545,6 +3548,21 @@ describe("ClaudeAdapterLive", () => {
35453548 ]) {
35463549 harness.query.emit(message as unknown as SDKMessage);
35473550 }
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);
35483566 // High-priority notifications DO surface as a warning row.
35493567 harness.query.emit({
35503568 type: "system",
......@@ -3602,15 +3620,15 @@ describe("ClaudeAdapterLive", () => {
36023620 session_id: "session",
36033621 uuid: "retry",
36043622 } as unknown as SDKMessage);
3605 yield* Effect.yieldNow;
3606 yield* Effect.yieldNow;
3623 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
36073624
36083625 const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning");
3609 // Exactly three warnings: the high-priority notification, the
3626 // Exactly four warnings: the fallback notice, high-priority notification,
36103627 // warning-level informational note, and the refusal. Nothing else.
36113628 assert.deepEqual(
36123629 warnings.map((event) => event.payload.message),
36133630 [
3631 "Safeguards flagged this message. Switched to Opus 4.8.",
36143632 "context window nearly full",
36153633 "Stop hook prevented continuation",
36163634 "The request was declined by the API.",
......@@ -3638,7 +3656,6 @@ describe("ClaudeAdapterLive", () => {
36383656 event.payload.reason.startsWith("api_retry:"),
36393657 );
36403658 assert.equal(heartbeat?.type, "session.state.changed");
3641 runtimeEventsFiber.interruptUnsafe();
36423659 }).pipe(
36433660 Effect.provideService(Random.Random, makeDeterministicRandomService()),
36443661 Effect.provide(harness.layer),
apps/server/src/provider/Layers/ClaudeAdapter.ts+8-1
......@@ -3655,6 +3655,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
36553655 yield* emitRuntimeWarning(context, message.text, message);
36563656 }
36573657 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;
36583666 // Inner protocol/UX details with no T3 surface today — consumed
36593667 // deliberately so they don't masquerade as unknown-subtype warnings.
36603668 // `background_tasks_changed` is a roster snapshot ({tasks: [...]}); the
......@@ -3663,7 +3671,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
36633671 // source. `control_request_progress` is a liveness heartbeat for an
36643672 // in-flight control request. `worker_shutting_down` is a Remote
36653673 // Control worker notice; the session close path reports the outcome.
3666 case "model_refusal_fallback":
36673674 case "local_command_output":
36683675 case "plugin_install":
36693676 case "commands_changed":