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
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", () => { · 39 unmodified lines
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", () => { · 40 unmodified lines
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", () => { · 51 unmodified lines
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", () => { · 21 unmodified lines
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* ( · 3654 unmodified lines
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* ( · 2 unmodified lines
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":

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