@koda-sl/baker-bridge 0.108.0-dev.0306d9ad9 → 0.108.0-dev.92a938f8d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +30 -32
  2. package/dist/hono/agent-session.d.ts +0 -1
  3. package/dist/hono/agent-session.d.ts.map +1 -1
  4. package/dist/hono/agent-session.js +2 -13
  5. package/dist/hono/agent-session.js.map +1 -1
  6. package/dist/hono/agent-session.test.js +6 -43
  7. package/dist/hono/agent-session.test.js.map +1 -1
  8. package/dist/hono/agent-tasks.d.ts.map +1 -1
  9. package/dist/hono/agent-tasks.js +3 -7
  10. package/dist/hono/agent-tasks.js.map +1 -1
  11. package/dist/hono/agent-tasks.test.js +0 -15
  12. package/dist/hono/agent-tasks.test.js.map +1 -1
  13. package/dist/hono/agent.d.ts.map +1 -1
  14. package/dist/hono/agent.js +4 -9
  15. package/dist/hono/agent.js.map +1 -1
  16. package/dist/hono/agent.test.js +9 -9
  17. package/dist/hono/avatar-casting.js +2 -2
  18. package/dist/hono/avatar-casting.js.map +1 -1
  19. package/dist/hono/bridge-job.d.ts +15 -20
  20. package/dist/hono/bridge-job.d.ts.map +1 -1
  21. package/dist/hono/bridge-job.js +138 -110
  22. package/dist/hono/bridge-job.js.map +1 -1
  23. package/dist/hono/bridge-job.test.js +175 -76
  24. package/dist/hono/bridge-job.test.js.map +1 -1
  25. package/dist/hono/connection-input.d.ts +8 -1
  26. package/dist/hono/connection-input.d.ts.map +1 -1
  27. package/dist/hono/connection-input.js +4 -2
  28. package/dist/hono/connection-input.js.map +1 -1
  29. package/dist/hono/convex.d.ts +1 -0
  30. package/dist/hono/convex.d.ts.map +1 -1
  31. package/dist/hono/convex.js +4 -0
  32. package/dist/hono/convex.js.map +1 -1
  33. package/dist/hono/convex.test.js +3 -3
  34. package/dist/hono/mcp-servers.d.ts +0 -2
  35. package/dist/hono/mcp-servers.d.ts.map +1 -1
  36. package/dist/hono/mcp-servers.js +16 -17
  37. package/dist/hono/mcp-servers.js.map +1 -1
  38. package/dist/hono/mcp-servers.test.js +38 -10
  39. package/dist/hono/mcp-servers.test.js.map +1 -1
  40. package/dist/hono/model-capabilities.d.ts.map +1 -1
  41. package/dist/hono/model-capabilities.js +19 -28
  42. package/dist/hono/model-capabilities.js.map +1 -1
  43. package/dist/hono/model-capabilities.test.js +15 -37
  44. package/dist/hono/model-capabilities.test.js.map +1 -1
  45. package/dist/hono/relay.d.ts +2 -1
  46. package/dist/hono/relay.d.ts.map +1 -1
  47. package/dist/hono/relay.js +3 -0
  48. package/dist/hono/relay.js.map +1 -1
  49. package/dist/hono/relay.test.js +22 -6
  50. package/dist/hono/relay.test.js.map +1 -1
  51. package/dist/hono/session-continuity.integration.test.js +4 -4
  52. package/dist/hono/tag-fields.js +1 -1
  53. package/dist/hono/tag-fields.js.map +1 -1
  54. package/dist/hono/tag-input.js +1 -1
  55. package/dist/hono/tag-input.js.map +1 -1
  56. package/dist/hono/turn-events.d.ts +1 -1
  57. package/dist/hono/turn-events.d.ts.map +1 -1
  58. package/dist/hono/turn-events.js.map +1 -1
  59. package/package.json +1 -1
@@ -71,13 +71,6 @@ export function remapAnswersToQuestionText(questions, answers) {
71
71
  // Nothing mapped (unexpected shape) → pass the originals through untouched.
72
72
  return Object.keys(byText).length > 0 ? byText : answers;
73
73
  }
74
- // The CLI output style every turn runs under, for `settings.outputStyle`.
75
- //
76
- // Exported because the CLI accepts an unrecognized style name silently — a
77
- // misspelling here would leave every chat on the default voice with nothing to
78
- // notice — so a test pins the literal. Built into the CLI since 2.1.237, which
79
- // is why the SDK floor is 0.3.237.
80
- export const OUTPUT_STYLE = "Concise";
81
74
  const DEFAULT_ASK_USER_QUESTION_TIMEOUT_MS = 15 * 60 * 1000;
82
75
  // A model request that streams nothing for this long is a hung upstream
83
76
  // connection (observed in production: an OpenRouter request that never yields
@@ -158,11 +151,10 @@ const BASE_DISALLOWED_TOOLS = [
158
151
  // not asked to fund a research fleet. Ordinary fan-out is unaffected:
159
152
  // `Task` stays, so the scaffold's subagents still run.
160
153
  "Workflow",
161
- // NOT listed: `SendMessage`. It is how the model follows up with a
162
- // subagent it already ran, and the CLI tells it to on every Agent result.
163
- // The continued subagent runs in the background, but it announces itself
164
- // with `task_started` / `background_tasks_changed`, so the turn stays open
165
- // until it finishes (`createTurnEndController`).
154
+ // Cross-session messaging. A Runtime holds exactly one session, so
155
+ // there is never a peer to reach; every call is a dead end that costs a
156
+ // round trip to discover.
157
+ "SendMessage",
166
158
  // A Session review may not change anything. Removing the definitions is
167
159
  // the second of three layers — the key it carries is read-scoped and its
168
160
  // Bash calls go through `reviewBashPolicy` — because each fails
@@ -331,6 +323,11 @@ function isUserVisibleSdkMessage(msg) {
331
323
  // failed turn clears the session, so sending again genuinely does start from the
332
324
  // shorter rehydrated history — which is the action this asks for.
333
325
  const CONTEXT_OVERFLOW_MESSAGE = "This chat has grown too long to continue in one step. Send your message again — it will pick up from a shorter version of this conversation.";
326
+ // What the user reads when the model refused the request and no fallback model
327
+ // retried it. The CLI's own refusal text speaks to a terminal user ("press esc"),
328
+ // so the turn ends on this instead. The refused exchange is not saved to the
329
+ // session, so a rephrased message starts from the conversation before it.
330
+ const MODEL_REFUSAL_MESSAGE = "I couldn't help with that request. Try rephrasing what you need.";
334
331
  function userFacingTurnError(message) {
335
332
  if (isContextOverflowError(message)) {
336
333
  return CONTEXT_OVERFLOW_MESSAGE;
@@ -470,6 +467,10 @@ function sdkResultErrorMessage(msg) {
470
467
  const message = msg.subtype === "success" ? sdkSuccessResultErrorMessage(msg) : msg.errors.join("; ");
471
468
  return message.length > 0 ? message : "SDK result error";
472
469
  }
470
+ /** The result's error, unless a refusal already decided how the turn ends. */
471
+ function turnResultError(state, msg) {
472
+ return state.refusedWithoutFallback ? undefined : sdkResultError(msg);
473
+ }
473
474
  function sdkResultError(msg) {
474
475
  if (msg.type !== "result" || !msg.is_error) {
475
476
  return undefined;
@@ -497,9 +498,9 @@ function sdkResultError(msg) {
497
498
  // the whole turn; the deny THRESHOLD below still guards a one-off end-of-turn race.
498
499
  // The trailing colon matters: the CLI always emits the envelope followed by the
499
500
  // error that caused it ("…failed: Error: Stream closed", "…failed: AbortError: …").
500
- // Prose that merely NAMES the failure quotes it without one, which is how a
501
- // hook explaining a block quotes it (the scaffold's former background-subagent
502
- // block did) — and a hook block is an is_error tool_result, so a colon-less match counted a healthy
501
+ // Prose that merely NAMES the failure quotes it without one, which is how the
502
+ // scaffold's guard-background-commands hook describes what it prevents — and its
503
+ // block is an is_error tool_result, so a colon-less match counted a healthy
503
504
  // blocked spawn as a deny and could trip the breaker on a healthy turn.
504
505
  const CONTROL_CHANNEL_DENY_MARKER = "Tool permission request failed:";
505
506
  // Hook failures are reported through the same is_error tool_result channel but
@@ -662,27 +663,9 @@ export function createStallDetector(input) {
662
663
  awaitingModel = (msg.type === "user" || msg.type === "stream_event") && pendingToolUses.size === 0;
663
664
  },
664
665
  stalled: () => stalled,
665
- hasPendingToolUses: () => pendingToolUses.size > 0,
666
666
  dispose: () => clearInterval(timer),
667
667
  };
668
668
  }
669
- // A query's cost and `modelUsage` are cumulative — on every result message and
670
- // on the CLI's usage reply alike — so the latest reading replaces the last one
671
- // rather than adding to it. It is folded into the turn totals when the query
672
- // settles (see `runQueryOnce`). A spuriously empty later reading never wipes a
673
- // real earlier one.
674
- function recordCumulativeUsage(state, reading) {
675
- const own = ownQueryUsage(reading, state.queryUsageBaseline);
676
- state.observedCostUsd = own.costUsd;
677
- const snapshot = queryModelUsage(own.modelUsage);
678
- if (snapshot.size > 0) {
679
- state.queryModelUsage = snapshot;
680
- }
681
- return own.costUsd;
682
- }
683
- // How long ending a turn waits on the CLI's usage reply before going ahead
684
- // without it. Billing a turn late is recoverable; hanging one is not.
685
- const USAGE_READING_TIMEOUT_MS = 5_000;
686
669
  // Decides when the turn is really over and the CLI may be told to exit.
687
670
  //
688
671
  // The prompt rides a held-open input stream (see `createHeldOpenQuery` in
@@ -704,10 +687,7 @@ const USAGE_READING_TIMEOUT_MS = 5_000;
704
687
  // events must not, or the end would never re-arm. After input ends, an exit
705
688
  // guard closes the query if the CLI neither speaks nor exits, so a wedged
706
689
  // process cannot hang the turn until the session watchdog.
707
- function createTurnEndController(job, query, closeQuery, timings, onTurnEvent,
708
- // Runs once, before input ends on a turn that ran tasks: a task can settle
709
- // without waking the model, so the last result may not include what it spent.
710
- beforeEndInput) {
690
+ function createTurnEndController(job, query, closeQuery, timings, onTurnEvent) {
711
691
  const inFlightTasks = new Set();
712
692
  // The background_tasks_changed LEVEL signal, tracked separately from the
713
693
  // edge-paired set above: a foreground command the CLI auto-backgrounds emits
@@ -730,21 +710,6 @@ beforeEndInput) {
730
710
  return;
731
711
  }
732
712
  inputEnded = true;
733
- if (sawAnyTask && beforeEndInput) {
734
- let timeout;
735
- const timedOut = new Promise((resolveTimeout) => {
736
- timeout = setTimeout(resolveTimeout, USAGE_READING_TIMEOUT_MS);
737
- timeout.unref?.();
738
- });
739
- void Promise.race([beforeEndInput().catch(() => undefined), timedOut]).finally(() => {
740
- clearTimeout(timeout);
741
- finishEndInput();
742
- });
743
- return;
744
- }
745
- finishEndInput();
746
- };
747
- const finishEndInput = () => {
748
713
  query.endInput?.();
749
714
  onTurnEvent({
750
715
  threadId: job.threadId,
@@ -817,11 +782,14 @@ beforeEndInput) {
817
782
  exitGuard.refresh();
818
783
  }
819
784
  },
820
- maybeEndAfter: (msg) => {
785
+ maybeEndAfter: (msg, refused) => {
821
786
  if (inputEnded) {
822
787
  return;
823
788
  }
824
- if (msg.type === "result" && !msg.is_error) {
789
+ // A refusal's error result is not thrown (the refusal already decided how
790
+ // the turn ends), so it has to end input like a clean one or the CLI idles
791
+ // on stdin until the session watchdog.
792
+ if (msg.type === "result" && (!msg.is_error || refused)) {
825
793
  cleanResultSeen = true;
826
794
  if (hasInFlightWork()) {
827
795
  console.error(`[BridgeJob] result received with ${inFlightTasks.size} task(s) and ${backgroundTaskCount} background command(s) in flight — holding the turn open (thread ${job.threadId})`);
@@ -838,7 +806,6 @@ beforeEndInput) {
838
806
  scheduleEnd();
839
807
  }
840
808
  },
841
- hasInFlightWork,
842
809
  dispose: () => {
843
810
  if (settleTimer) {
844
811
  clearTimeout(settleTimer);
@@ -1142,6 +1109,11 @@ export class BridgeJobExecutor {
1142
1109
  await this.clearIfDirty(job, controls, state, "abort");
1143
1110
  return { kind: "cancelled", costUsd, model: state.model, modelUsage };
1144
1111
  }
1112
+ // No boundary is handed back, so the refused exchange never becomes part of
1113
+ // the conversation the next message resumes.
1114
+ if (state.refusedWithoutFallback) {
1115
+ return { kind: "errored", costUsd, model: state.model, modelUsage, errors: [MODEL_REFUSAL_MESSAGE] };
1116
+ }
1145
1117
  if (!state.postedAssistantEvent && state.turnText.length > 0) {
1146
1118
  await this.postSyntheticAssistantEvent(job, state);
1147
1119
  }
@@ -1303,7 +1275,8 @@ export class BridgeJobExecutor {
1303
1275
  // avatar's casting would never open.
1304
1276
  const command = typeof input?.command === "string" ? input.command : undefined;
1305
1277
  // The agent asking for the casting we already owe is not the NEXT piece of work — it
1306
- // is that same casting, and the tool's own description tells it to call this here.
1278
+ // is that same casting, so an explicit call is tolerated (e.g. a re-ask after the
1279
+ // user declined every portrait).
1307
1280
  // Handled under its own tool-use id so the user's pick reaches the call that asked
1308
1281
  // for it; run as an auto-cast instead, the explicit call fell through to `allow` and
1309
1282
  // the tool answered itself with `{ status: "declined" }` — the agent was told the
@@ -1370,21 +1343,11 @@ export class BridgeJobExecutor {
1370
1343
  const skills = await readSessionSkills(bridgeRuntimeFiles.projectRoot());
1371
1344
  const autoCompactWindow = autoCompactWindowForModel(model);
1372
1345
  const betas = betasForModel(model);
1346
+ // No `outputStyle`: the built-in `Concise` style was pinned here against Opus 5's
1347
+ // preamble, and on Opus 5.5 it suppressed the one-line progress notes a
1348
+ // marketer reads while a long turn works. When to speak is now the scaffold
1349
+ // CLAUDE.md's job ("Talking to the User").
1373
1350
  const settings = {
1374
- // Lead with the answer. The chat's readers are marketers, and the CLI's
1375
- // default voice narrates its way to a result — preamble, a recap of what
1376
- // it is about to do, then the thing itself. `Concise` cuts that without
1377
- // touching how thoroughly the work gets done, and it keeps error reports
1378
- // and destructive-action confirmations complete, which is the part a
1379
- // shorter voice must not eat.
1380
- //
1381
- // Two limits to know before relying on it. It reaches the **main
1382
- // conversation only** — a subagent runs its own system prompt, so the
1383
- // fan-out workers are unaffected. And the CLI accepts an unrecognized
1384
- // style name **silently**: `outputStyle: "NotARealStyle"` returns a clean
1385
- // 200 with the default voice, so a typo here is invisible at runtime.
1386
- // That is why the literal is pinned by a test rather than left to review.
1387
- outputStyle: OUTPUT_STYLE,
1388
1351
  // Publish opts out of the CLI's threshold-driven summarization so the turn
1389
1352
  // starts working immediately instead of collapsing the chat first; a real
1390
1353
  // overflow is caught in `runAttempt` and retried with compaction on. Only
@@ -1411,11 +1374,11 @@ export class BridgeJobExecutor {
1411
1374
  // capacity, which also clamps the `autoCompactWindow` pin below to 200k. Not
1412
1375
  // an API requirement — see `betasForModel`. Omitted for Haiku 4.5, genuinely 200k.
1413
1376
  ...(betas ? { betas } : {}),
1414
- // How hard the model works per turn — thinking depth, preamble, and how
1415
- // many tool calls it makes. The API default is `high`; `medium` trades a
1416
- // slice of that for fewer, more consolidated tool calls. That matters more
1417
- // than the thinking saving alone: every tool call is another request that
1418
- // re-reads the whole prompt, so cutting calls cuts cache-read volume too.
1377
+ // How hard the model works per turn — thinking depth, preamble, and how many
1378
+ // tool calls it makes. `medium` is Opus 5.5's API default; it is set explicitly
1379
+ // so an SDK or model default change cannot move it silently. Opus 5.5 thinks
1380
+ // more per level than Opus 5, so compare `low` on real Sessions before
1381
+ // treating this as tuned.
1419
1382
  //
1420
1383
  // MUST stay a single constant. The resolved effort is rendered into the
1421
1384
  // prompt, so changing it between requests invalidates every cache
@@ -1444,6 +1407,12 @@ export class BridgeJobExecutor {
1444
1407
  ...reviewQueryOptions(job),
1445
1408
  ...(job.planMode ? { permissionMode: "plan" } : {}),
1446
1409
  canUseTool: (toolName, input, opts) => this.routeToolUse(job, controls, toolName, input, opts),
1410
+ // No `fallbackModel`, and no server-side refusal `fallbacks` in the extra
1411
+ // body: the CLI's refusal fallback is gated on a first-party base URL (see
1412
+ // `betasForModel`), and nothing shows OpenRouter's passthrough honouring the
1413
+ // API's `fallbacks` field. A refusal therefore ends the turn on
1414
+ // `MODEL_REFUSAL_MESSAGE`; a `model_refusal_fallback`, if one ever arrives,
1415
+ // is still handled (its superseded messages are retracted).
1447
1416
  model: modelForJob(job, model),
1448
1417
  abortController: controls.abortController ?? { signal: controls.abortSignal },
1449
1418
  // The SDK treats `env` as a full replacement for the subprocess environment
@@ -1525,16 +1494,7 @@ export class BridgeJobExecutor {
1525
1494
  // in-progress session.
1526
1495
  async drainQuery(job, controls, state, query, closeQuery) {
1527
1496
  const stallDetector = this.createStallDetector(job, controls, closeQuery);
1528
- // What the query had spent when its turn ended, read from the CLI — set
1529
- // only when a task ran, and higher than the last result's figure when a
1530
- // task kept working after it.
1531
- let settledCostUsd = 0;
1532
- const turnEnd = createTurnEndController(job, query, closeQuery, { settleMs: this.turnEndSettleMs, exitGuardMs: this.turnEndExitGuardMs }, this.recordTurnEvent, async () => {
1533
- const reading = await query.usage?.();
1534
- if (reading) {
1535
- settledCostUsd = recordCumulativeUsage(state, reading);
1536
- }
1537
- });
1497
+ const turnEnd = createTurnEndController(job, query, closeQuery, { settleMs: this.turnEndSettleMs, exitGuardMs: this.turnEndExitGuardMs }, this.recordTurnEvent);
1538
1498
  let costUsd = 0;
1539
1499
  // Errors thrown while HANDLING a message (control-channel breaker, error
1540
1500
  // results) are real verdicts and must surface as-is; only errors from the
@@ -1544,7 +1504,6 @@ export class BridgeJobExecutor {
1544
1504
  for await (const msg of query) {
1545
1505
  stallDetector.observeMessage(msg);
1546
1506
  turnEnd.observeMessage(msg);
1547
- controls.setWorkInFlight?.(stallDetector.hasPendingToolUses() || turnEnd.hasInFlightWork());
1548
1507
  try {
1549
1508
  costUsd = await this.consumeSdkMessage(job, controls, state, msg, costUsd);
1550
1509
  }
@@ -1552,7 +1511,7 @@ export class BridgeJobExecutor {
1552
1511
  consumeError = true;
1553
1512
  throw error;
1554
1513
  }
1555
- turnEnd.maybeEndAfter(msg);
1514
+ turnEnd.maybeEndAfter(msg, state.refusedWithoutFallback === true);
1556
1515
  if (!controls.isActive()) {
1557
1516
  closeQuery();
1558
1517
  break;
@@ -1567,7 +1526,6 @@ export class BridgeJobExecutor {
1567
1526
  throw error;
1568
1527
  }
1569
1528
  finally {
1570
- controls.setWorkInFlight?.(false);
1571
1529
  stallDetector.dispose();
1572
1530
  turnEnd.dispose();
1573
1531
  await this.openPendingCastingAtTurnEnd(job, controls);
@@ -1575,7 +1533,7 @@ export class BridgeJobExecutor {
1575
1533
  if (stallDetector.stalled() && controls.isActive()) {
1576
1534
  throw new StreamStallError();
1577
1535
  }
1578
- return Math.max(costUsd, settledCostUsd);
1536
+ return costUsd;
1579
1537
  }
1580
1538
  /**
1581
1539
  * Open a casting the turn recorded but never reached.
@@ -1650,23 +1608,7 @@ export class BridgeJobExecutor {
1650
1608
  return costUsd;
1651
1609
  }
1652
1610
  controls.markEvent?.();
1653
- // Drop the size refusal outright. A recovered turn must not leave a raw
1654
- // provider error sitting above the work that then succeeded, and a turn that
1655
- // fails anyway says so once, in product language, through the error outcome —
1656
- // never twice, and never in the provider's words. Dropping it before
1657
- // `observeInProgressSession` also keeps it from setting `resumeSessionAt` —
1658
- // the refusal is not work the agent did, and counting it as such would send
1659
- // the retry a "continue where you left off" note instead of the `/publish`
1660
- // the turn still owes.
1661
- if (isContextOverflowRefusal(msg)) {
1662
- // Today the refusal is synthetic — the request is rejected pre-flight, so
1663
- // no deltas precede it and `turnText` is empty. Clearing it anyway is what
1664
- // makes the drop hold if that ever changes: streamed text survives the
1665
- // dropped message, and a retry that ends on tool work with no assistant
1666
- // text of its own would post the refusal as this turn's end-of-turn text
1667
- // (`postSyntheticAssistantEvent`). The live preview needs no such guard —
1668
- // the next `message_start` restarts it and the turn's end clears it.
1669
- state.turnText = "";
1611
+ if (this.droppedBeforeRelay(job, state, msg)) {
1670
1612
  return costUsd;
1671
1613
  }
1672
1614
  state.inProgressSession = observeInProgressSession(state.inProgressSession, msg);
@@ -1684,7 +1626,17 @@ export class BridgeJobExecutor {
1684
1626
  }
1685
1627
  trackMainRequestUsage(state, msg);
1686
1628
  if (msg.type === "result") {
1687
- const ownCostUsd = recordCumulativeUsage(state, { totalCostUsd: msg.total_cost_usd, modelUsage: msg.modelUsage });
1629
+ const own = ownQueryUsage({ totalCostUsd: msg.total_cost_usd, modelUsage: msg.modelUsage }, state.queryUsageBaseline);
1630
+ state.observedCostUsd = own.costUsd;
1631
+ // A query's result `modelUsage` is cumulative (like `total_cost_usd`, which
1632
+ // we overwrite above), so keep the latest snapshot rather than summing —
1633
+ // it is folded into the turn totals when the query settles (see
1634
+ // `runQueryOnce`). Guard against a spuriously empty later result wiping a
1635
+ // real earlier one.
1636
+ const snapshot = queryModelUsage(own.modelUsage);
1637
+ if (snapshot.size > 0) {
1638
+ state.queryModelUsage = snapshot;
1639
+ }
1688
1640
  await this.publishContextUsage(job, state, msg.modelUsage);
1689
1641
  // What a Session review returns. The SDK enforces the JSON schema, so this
1690
1642
  // either parses against the findings contract or the review failed to
@@ -1694,12 +1646,14 @@ export class BridgeJobExecutor {
1694
1646
  if (structured !== undefined) {
1695
1647
  state.structuredOutput = structured;
1696
1648
  }
1697
- const resultError = sdkResultError(msg);
1649
+ // A refused turn's result can be an error; the refusal already decided
1650
+ // how the turn ends, and in product words rather than the CLI's.
1651
+ const resultError = turnResultError(state, msg);
1698
1652
  if (resultError) {
1699
1653
  await this.clearIfDirty(job, controls, state, "result_error");
1700
1654
  throw resultError;
1701
1655
  }
1702
- return ownCostUsd;
1656
+ return own.costUsd;
1703
1657
  }
1704
1658
  if (isUserVisibleSdkMessage(msg)) {
1705
1659
  state.sawUserVisibleSdkMessage = true;
@@ -1733,7 +1687,7 @@ export class BridgeJobExecutor {
1733
1687
  * turn (one per tool call, plus any same-model subagent work), so a handful of
1734
1688
  * tool calls inflates them past the window and pins the meter at 100% on an
1735
1689
  * almost-empty chat. The window is the model's real OpenRouter context length
1736
- * (opus/sonnet 4.6 = 1M); we resolve it from the model id because the runtime
1690
+ * (Opus 5.5 = 1M); we resolve it from the model id because the runtime
1737
1691
  * under-reports it through the OpenRouter passthrough, falling back to the
1738
1692
  * reported window for unknown models. A relay failure must never fail the turn.
1739
1693
  */
@@ -1789,6 +1743,61 @@ export class BridgeJobExecutor {
1789
1743
  await this.relay.assistantEvent(target, msg, state.model);
1790
1744
  }
1791
1745
  }
1746
+ /**
1747
+ * A message consumed here and never relayed or counted as the agent's work:
1748
+ * a provider refusal for size, or part of a model refusal.
1749
+ */
1750
+ droppedBeforeRelay(job, state, msg) {
1751
+ // Drop the size refusal outright. A recovered turn must not leave a raw
1752
+ // provider error sitting above the work that then succeeded, and a turn that
1753
+ // fails anyway says so once, in product language, through the error outcome —
1754
+ // never twice, and never in the provider's words. Dropping it before
1755
+ // `observeInProgressSession` also keeps it from setting `resumeSessionAt` —
1756
+ // the refusal is not work the agent did, and counting it as such would send
1757
+ // the retry a "continue where you left off" note instead of the `/publish`
1758
+ // the turn still owes.
1759
+ if (isContextOverflowRefusal(msg)) {
1760
+ // Today the refusal is synthetic — the request is rejected pre-flight, so
1761
+ // no deltas precede it and `turnText` is empty. Clearing it anyway is what
1762
+ // makes the drop hold if that ever changes: streamed text survives the
1763
+ // dropped message, and a retry that ends on tool work with no assistant
1764
+ // text of its own would post the refusal as this turn's end-of-turn text
1765
+ // (`postSyntheticAssistantEvent`). The live preview needs no such guard —
1766
+ // the next `message_start` restarts it and the turn's end clears it.
1767
+ state.turnText = "";
1768
+ return true;
1769
+ }
1770
+ return this.observeModelRefusal(job, state, msg);
1771
+ }
1772
+ /**
1773
+ * A refusal with no fallback model to retry it. True when the message is part
1774
+ * of the refusal — the CLI's error frame (`stop_reason: "refusal"`, written for
1775
+ * a terminal user) or the refusal notice — and is consumed here, never relayed.
1776
+ */
1777
+ observeModelRefusal(job, state, msg) {
1778
+ if (msg.type === "assistant") {
1779
+ if (msg.message.stop_reason === "refusal") {
1780
+ return true;
1781
+ }
1782
+ if (!msg.parent_tool_use_id) {
1783
+ state.refusedWithoutFallback = false;
1784
+ }
1785
+ return false;
1786
+ }
1787
+ if (msg.type !== "system" || msg.subtype !== "model_refusal_no_fallback") {
1788
+ return false;
1789
+ }
1790
+ state.refusedWithoutFallback = true;
1791
+ const category = msg.api_refusal_category ?? null;
1792
+ console.error(`[BridgeJob] model refused with no fallback (category: ${category ?? "none"}, model: ${msg.original_model}) for thread ${job.threadId}`);
1793
+ this.recordTurnEvent({
1794
+ threadId: job.threadId,
1795
+ turnId: job.turnId,
1796
+ event: "model_refusal",
1797
+ detail: { category, model: msg.original_model, fallback: false },
1798
+ });
1799
+ return true;
1800
+ }
1792
1801
  async handleSystemMessage(state, msg, target) {
1793
1802
  // Relay the compaction lifecycle so the dashboard can show a spinner then a
1794
1803
  // summary notice. This fires for both a manual `/compact` (which otherwise
@@ -1812,6 +1821,25 @@ export class BridgeJobExecutor {
1812
1821
  await this.relay.compaction(target, "done");
1813
1822
  return;
1814
1823
  }
1824
+ // A fallback model retried a refused request. The refused leg's messages
1825
+ // were already relayed; the SDK names them so they can leave the chat
1826
+ // instead of sitting above the answer that replaced them.
1827
+ if (msg.type === "system" && msg.subtype === "model_refusal_fallback") {
1828
+ this.recordTurnEvent({
1829
+ ...target,
1830
+ event: "model_refusal",
1831
+ detail: {
1832
+ category: msg.api_refusal_category ?? null,
1833
+ model: msg.original_model,
1834
+ fallback: msg.fallback_model,
1835
+ },
1836
+ });
1837
+ const retracted = msg.retracted_message_uuids ?? [];
1838
+ if (retracted.length > 0) {
1839
+ await this.relay.retractEvents(target, retracted);
1840
+ }
1841
+ return;
1842
+ }
1815
1843
  const agentTasks = applyTaskLifecycleMessage(state.agentTasks, msg, this.now());
1816
1844
  if (agentTasks) {
1817
1845
  state.agentTasks = agentTasks;