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

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 (69) hide show
  1. package/README.md +42 -31
  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 +11 -10
  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 +25 -20
  20. package/dist/hono/bridge-job.d.ts.map +1 -1
  21. package/dist/hono/bridge-job.js +164 -111
  22. package/dist/hono/bridge-job.js.map +1 -1
  23. package/dist/hono/bridge-job.test.js +295 -76
  24. package/dist/hono/bridge-job.test.js.map +1 -1
  25. package/dist/hono/convex.d.ts +1 -0
  26. package/dist/hono/convex.d.ts.map +1 -1
  27. package/dist/hono/convex.js +4 -0
  28. package/dist/hono/convex.js.map +1 -1
  29. package/dist/hono/convex.test.js +3 -3
  30. package/dist/hono/main-sync.d.ts +41 -0
  31. package/dist/hono/main-sync.d.ts.map +1 -0
  32. package/dist/hono/main-sync.js +390 -0
  33. package/dist/hono/main-sync.js.map +1 -0
  34. package/dist/hono/main-sync.test.d.ts +2 -0
  35. package/dist/hono/main-sync.test.d.ts.map +1 -0
  36. package/dist/hono/main-sync.test.js +266 -0
  37. package/dist/hono/main-sync.test.js.map +1 -0
  38. package/dist/hono/mcp-servers.d.ts +0 -2
  39. package/dist/hono/mcp-servers.d.ts.map +1 -1
  40. package/dist/hono/mcp-servers.js +16 -17
  41. package/dist/hono/mcp-servers.js.map +1 -1
  42. package/dist/hono/mcp-servers.test.js +38 -10
  43. package/dist/hono/mcp-servers.test.js.map +1 -1
  44. package/dist/hono/model-capabilities.d.ts.map +1 -1
  45. package/dist/hono/model-capabilities.js +19 -28
  46. package/dist/hono/model-capabilities.js.map +1 -1
  47. package/dist/hono/model-capabilities.test.js +15 -37
  48. package/dist/hono/model-capabilities.test.js.map +1 -1
  49. package/dist/hono/relay.d.ts +2 -1
  50. package/dist/hono/relay.d.ts.map +1 -1
  51. package/dist/hono/relay.js +3 -0
  52. package/dist/hono/relay.js.map +1 -1
  53. package/dist/hono/relay.test.js +22 -6
  54. package/dist/hono/relay.test.js.map +1 -1
  55. package/dist/hono/session-continuity.d.ts +1 -0
  56. package/dist/hono/session-continuity.d.ts.map +1 -1
  57. package/dist/hono/session-continuity.integration.test.js +4 -4
  58. package/dist/hono/session-continuity.js +5 -2
  59. package/dist/hono/session-continuity.js.map +1 -1
  60. package/dist/hono/tag-fields.js +1 -1
  61. package/dist/hono/tag-fields.js.map +1 -1
  62. package/dist/hono/tag-input.js +1 -1
  63. package/dist/hono/tag-input.js.map +1 -1
  64. package/dist/hono/turn-events.d.ts +1 -1
  65. package/dist/hono/turn-events.d.ts.map +1 -1
  66. package/dist/hono/turn-events.js.map +1 -1
  67. package/dist/hono/types.d.ts +6 -0
  68. package/dist/hono/types.d.ts.map +1 -1
  69. package/package.json +1 -1
@@ -16,7 +16,7 @@ vi.mock("./runtime-files.ts", async (importOriginal) => {
16
16
  };
17
17
  });
18
18
  import { AVATAR_CASTING_TOOL_NAME } from "./avatar-casting.js";
19
- import { BridgeJobExecutor, createStallDetector, isContextOverflowRefusal, isControlChannelDeny, OUTPUT_STYLE, parsePlanDecision, remapAnswersToQuestionText, } from "./bridge-job.js";
19
+ import { BridgeJobExecutor, createStallDetector, isContextOverflowRefusal, isControlChannelDeny, parsePlanDecision, remapAnswersToQuestionText, } from "./bridge-job.js";
20
20
  import { CONNECTION_INPUT_TOOL_NAME } from "./connection-input.js";
21
21
  import { FLOW_INPUT_TOOL_NAME } from "./flow-input.js";
22
22
  import { bridgeRuntimeFiles } from "./runtime-files.js";
@@ -63,6 +63,7 @@ function recordingRelay() {
63
63
  const agentActivityUpdates = [];
64
64
  const compactionUpdates = [];
65
65
  const reviewDigests = [];
66
+ const retractedEvents = [];
66
67
  const contextUsageUpdates = [];
67
68
  return {
68
69
  assistantEvents,
@@ -71,6 +72,7 @@ function recordingRelay() {
71
72
  compactionUpdates,
72
73
  contextUsageUpdates,
73
74
  reviewDigests,
75
+ retractedEvents,
74
76
  startPreview: () => undefined,
75
77
  previewText: () => undefined,
76
78
  previewTools: () => undefined,
@@ -86,6 +88,10 @@ function recordingRelay() {
86
88
  assistantEvents.push({ target, event, model });
87
89
  return Promise.resolve();
88
90
  },
91
+ retractEvents: (target, eventIds) => {
92
+ retractedEvents.push({ target, eventIds });
93
+ return Promise.resolve();
94
+ },
89
95
  complete: () => Promise.resolve(),
90
96
  inputRequest: () => Promise.resolve(true),
91
97
  inputResolved: () => Promise.resolve(),
@@ -223,6 +229,7 @@ function createExecutor(options) {
223
229
  now: options.now,
224
230
  readUsageBaseline: async (sessionId) => options.usageBaselines?.[sessionId],
225
231
  stalledCliExitMs: 0,
232
+ ...(options.mainSync ? { mainSync: options.mainSync } : {}),
226
233
  });
227
234
  }
228
235
  function resultMessage(costUsd, sessionId, modelUsage) {
@@ -308,8 +315,8 @@ describe("isControlChannelDeny", () => {
308
315
  })).toBe(false);
309
316
  });
310
317
  it("does not match a PreToolUse hook block that quotes the envelope in its explanation", () => {
311
- // Live-run finding: the scaffold's guard-background-commands hook, when it
312
- // still blocked backgrounded subagents, explained why by quoting the failure.
318
+ // Live-run finding: the scaffold's guard-background-commands hook explains
319
+ // WHY it blocks a backgrounded subagent by quoting the failure it prevents.
313
320
  // Its block arrives as an is_error tool_result, so an envelope-substring
314
321
  // match counted it as a deny — three blocked spawns in one turn would have
315
322
  // tripped the breaker and errored a perfectly healthy turn.
@@ -745,13 +752,13 @@ describe("BridgeJobExecutor", () => {
745
752
  const relay = recordingRelay();
746
753
  const executor = createExecutor({
747
754
  relay,
748
- model: "claude-opus-4-6",
755
+ model: "claude-opus-5-5",
749
756
  queryResults: [
750
757
  scriptedMessages(assistantMessage("assistant_1", "Done.", "session_next", {
751
758
  usage: { input_tokens: 4000, cache_read_input_tokens: 96000 },
752
759
  }),
753
760
  // The runtime reports Opus's catalog window as 200k through the
754
- // OpenRouter passthrough — wrong. Opus 4.6 is 1M on OpenRouter.
761
+ // OpenRouter passthrough — wrong. Opus 5.5 is 1M on OpenRouter.
755
762
  resultMessage(0.02, "session_next", {
756
763
  "claude-main": {
757
764
  inputTokens: 4000,
@@ -1163,7 +1170,8 @@ describe("BridgeJobExecutor", () => {
1163
1170
  // Read off a session's own `init` tool list, the CLI offers these to every
1164
1171
  // Session. Each one reaches past what a marketer's chat is for: the worktree
1165
1172
  // pair rearranges the branches Publish owns, `Workflow` fans out a fleet on
1166
- // the organization's OpenRouter key.
1173
+ // the organization's OpenRouter key, and `SendMessage` addresses peers a
1174
+ // single-session Runtime does not have.
1167
1175
  it("removes the tools that reach outside what a Session is", async () => {
1168
1176
  const queryInputs = [];
1169
1177
  const executor = createExecutor({
@@ -1172,12 +1180,10 @@ describe("BridgeJobExecutor", () => {
1172
1180
  });
1173
1181
  await executor.execute({ threadId: "thread_reach", turnId: "turn_reach", prompt: "Build a landing", history: history([]) }, activeControls());
1174
1182
  const disallowed = queryInputs[0]?.options.disallowedTools ?? [];
1175
- expect(disallowed).toEqual(expect.arrayContaining(["EnterWorktree", "ExitWorktree", "Workflow"]));
1183
+ expect(disallowed).toEqual(expect.arrayContaining(["EnterWorktree", "ExitWorktree", "Workflow", "SendMessage"]));
1176
1184
  // `Task` is the subagent spawn itself — the scaffold's own fan-out workers
1177
- // ride it, so blocking multi-agent orchestration must not catch it. And
1178
- // `SendMessage` is how the agent follows up with a subagent it already ran.
1185
+ // ride it, so blocking multi-agent orchestration must not catch it.
1179
1186
  expect(disallowed).not.toContain("Task");
1180
- expect(disallowed).not.toContain("SendMessage");
1181
1187
  });
1182
1188
  it("allowlists the workspace's own skills so the CLI's built-in developer skills stay out of the session", async () => {
1183
1189
  const workspace = await mkdtemp(join(tmpdir(), "baker-job-skills-"));
@@ -1365,42 +1371,31 @@ describe("BridgeJobExecutor", () => {
1365
1371
  await executor.execute({ threadId: "thread_uw", turnId: "turn_uw", prompt: "Hi", history: history([]) }, activeControls());
1366
1372
  expect((queryInputs[0]?.options.settings ?? {})).not.toHaveProperty("autoCompactWindow");
1367
1373
  });
1368
- // The output style is pinned on every turn, so the settings tier now always has
1369
- // something in it — even for a model whose window we cannot resolve and with no
1370
- // chat id to enable the memory directory, which used to leave it empty.
1371
- it("always sends the settings tier, because the output style is pinned every turn", async () => {
1372
- vi.stubEnv("BAKER_CHAT_ID", "");
1374
+ // No output style is sent. `Concise` was pinned against Opus 5's preamble; on
1375
+ // Opus 5.5 it suppressed the one-line progress notes a marketer reads while a
1376
+ // long turn works. The voice rules live in the scaffold CLAUDE.md instead.
1377
+ it("sends no output style, so the default voice and its progress notes survive", async () => {
1373
1378
  const queryInputs = [];
1374
1379
  const executor = createExecutor({
1375
1380
  queryInputs,
1376
- model: "claude-unknown",
1377
- queryResults: [scriptedMessages(resultMessage(0, "session_no_settings"))],
1381
+ queryResults: [scriptedMessages(resultMessage(0, "session_style"))],
1378
1382
  });
1379
- await executor.execute({ threadId: "thread_ns", turnId: "turn_ns", prompt: "Hi", history: history([]) }, activeControls());
1380
- expect(queryInputs[0]?.options.settings).toEqual({ outputStyle: OUTPUT_STYLE });
1381
- });
1382
- // The CLI accepts an unrecognized output-style name silently — a misspelling
1383
- // returns a clean 200 on the default voice, with nothing at runtime to notice
1384
- // it. Verified against the CLI: `{"outputStyle":"NotARealStyle"}` succeeded.
1385
- // So the literal is pinned here rather than left to review.
1386
- it("names the built-in Concise style exactly", () => {
1387
- expect(OUTPUT_STYLE).toBe("Concise");
1383
+ await executor.execute({ threadId: "thread_style", turnId: "turn_style_1", prompt: "Hi", history: history([]) }, activeControls());
1384
+ expect((queryInputs[0]?.options.settings ?? {})).not.toHaveProperty("outputStyle");
1388
1385
  });
1389
- it("runs every turn under the pinned output style", async () => {
1386
+ // Nothing to put in the settings tier (unknown model window, no chat id for the
1387
+ // memory directory) means no `settings` key at all: the SDK forwards even `{}`
1388
+ // to the CLI as a `--settings` flag tier.
1389
+ it("omits the settings tier when nothing in it applies", async () => {
1390
+ vi.stubEnv("BAKER_CHAT_ID", "");
1390
1391
  const queryInputs = [];
1391
1392
  const executor = createExecutor({
1392
1393
  queryInputs,
1393
- queryResults: [
1394
- scriptedMessages(resultMessage(0, "session_style")),
1395
- scriptedMessages(resultMessage(0, "session_style")),
1396
- ],
1394
+ model: "claude-unknown",
1395
+ queryResults: [scriptedMessages(resultMessage(0, "session_no_settings"))],
1397
1396
  });
1398
- await executor.execute({ threadId: "thread_style", turnId: "turn_style_1", prompt: "Hi", history: history([]) }, activeControls());
1399
- await executor.execute({ threadId: "thread_style", turnId: "turn_style_2", prompt: "Again", history: history([]) }, activeControls());
1400
- expect(queryInputs[0]?.options.settings).toMatchObject({ outputStyle: "Concise" });
1401
- // Constant across turns — the style is part of the system prompt, so a
1402
- // per-turn value would re-write the cache prefix on every request.
1403
- expect(queryInputs[1]?.options.settings).toMatchObject({ outputStyle: "Concise" });
1397
+ await executor.execute({ threadId: "thread_ns", turnId: "turn_ns", prompt: "Hi", history: history([]) }, activeControls());
1398
+ expect(queryInputs[0]?.options).not.toHaveProperty("settings");
1404
1399
  });
1405
1400
  it("runs exact publish commands with the prompt passed through verbatim", async () => {
1406
1401
  const queryInputs = [];
@@ -1478,6 +1473,154 @@ describe("BridgeJobExecutor", () => {
1478
1473
  expect(queryInputs[0]?.prompt).toBe("/publish");
1479
1474
  expect(queryInputs[0]?.reminder).toBeUndefined();
1480
1475
  });
1476
+ it("brings the workspace level with main before the turn and tells the agent what changed, beside the viewing context", async () => {
1477
+ const queryInputs = [];
1478
+ const syncedThreads = [];
1479
+ const syncedPublishes = [];
1480
+ const executor = createExecutor({
1481
+ queryInputs,
1482
+ queryResults: [scriptedMessages(resultMessage(0, "session_main_moved"))],
1483
+ mainSync: {
1484
+ beforeTurn: (threadId, published) => {
1485
+ syncedThreads.push(threadId);
1486
+ syncedPublishes.push(...published.map((entry) => entry.sha));
1487
+ return Promise.resolve({
1488
+ recovered: "<system-reminder>\nBaker: this workspace now includes work published since your last look.\n</system-reminder>",
1489
+ });
1490
+ },
1491
+ refuseWrite: () => null,
1492
+ },
1493
+ });
1494
+ await executor.execute({
1495
+ threadId: "thread_main_moved",
1496
+ turnId: "turn_main_moved",
1497
+ prompt: "change the hero title",
1498
+ history: history([]),
1499
+ currentReference: { type: "landing", slug: "homepage", title: "Homepage" },
1500
+ publishedElsewhere: [
1501
+ {
1502
+ sha: "abc123",
1503
+ chatId: "sibling",
1504
+ title: "New hero",
1505
+ publishedAt: 1,
1506
+ paths: ["src/pages/hero/index.astro"],
1507
+ },
1508
+ ],
1509
+ }, activeControls());
1510
+ expect(syncedThreads).toEqual(["thread_main_moved"]);
1511
+ expect(syncedPublishes).toEqual(["abc123"]);
1512
+ expect(queryInputs[0]?.prompt).toBe("change the hero title");
1513
+ expect(queryInputs[0]?.reminder).toContain("Baker: this workspace now includes work published");
1514
+ expect(queryInputs[0]?.reminder).toContain('landing "Homepage" (slug: homepage)');
1515
+ });
1516
+ it("does not move the workspace under a publish command", async () => {
1517
+ const queryInputs = [];
1518
+ const syncedThreads = [];
1519
+ const executor = createExecutor({
1520
+ queryInputs,
1521
+ queryResults: [scriptedMessages(resultMessage(0, "session_publish_main"))],
1522
+ mainSync: {
1523
+ beforeTurn: (threadId) => {
1524
+ syncedThreads.push(threadId);
1525
+ return Promise.resolve({ resumed: "<system-reminder>\nBaker: moved\n</system-reminder>" });
1526
+ },
1527
+ refuseWrite: () => null,
1528
+ },
1529
+ });
1530
+ await executor.execute({ threadId: "thread_publish_main", turnId: "turn_publish_main", prompt: "/publish", history: history([]) }, activeControls());
1531
+ expect(syncedThreads).toEqual([]);
1532
+ expect(queryInputs[0]?.reminder).toBeUndefined();
1533
+ });
1534
+ it("tells a resumed session only what is new, since it remembers every earlier note", async () => {
1535
+ const queryInputs = [];
1536
+ const executor = createExecutor({
1537
+ loadedBoundary: cleanBoundary("session_resumed_main"),
1538
+ queryInputs,
1539
+ queryResults: [scriptedMessages(resultMessage(0, "session_resumed_main_next"))],
1540
+ mainSync: {
1541
+ beforeTurn: () => Promise.resolve({ recovered: "<system-reminder>\nBaker: standing\n</system-reminder>" }),
1542
+ refuseWrite: () => null,
1543
+ },
1544
+ });
1545
+ await executor.execute({ threadId: "thread_resumed_main", turnId: "turn_resumed_main", prompt: "hola", history: history([]) }, activeControls());
1546
+ expect(queryInputs[0]?.reminder).toBeUndefined();
1547
+ });
1548
+ it("repeats the standing workspace notice when a stale session falls back to rehydration", async () => {
1549
+ const queryInputs = [];
1550
+ const executor = createExecutor({
1551
+ loadedBoundary: cleanBoundary("session_stale_main"),
1552
+ queryInputs,
1553
+ queryResults: [
1554
+ scriptedError(new Error("Cannot resume: session not found")),
1555
+ scriptedMessages(resultMessage(0, "session_stale_main_next")),
1556
+ ],
1557
+ mainSync: {
1558
+ beforeTurn: () => Promise.resolve({ recovered: "<system-reminder>\nBaker: standing\n</system-reminder>" }),
1559
+ refuseWrite: () => null,
1560
+ },
1561
+ });
1562
+ await executor.execute({
1563
+ threadId: "thread_stale_main",
1564
+ turnId: "turn_stale_main",
1565
+ prompt: "quita el pricing",
1566
+ history: history([{ role: "user", text: "Persisted fact", createdAt: 1 }]),
1567
+ }, activeControls());
1568
+ expect(queryInputs[0]?.reminder).toBeUndefined();
1569
+ expect(queryInputs[1]?.reminder).toContain("Baker: standing");
1570
+ });
1571
+ it("brings the workspace level with main before a skill command without putting a note in front of it", async () => {
1572
+ const workspace = await mkdtemp(join(tmpdir(), "baker-job-skill-main-"));
1573
+ await mkdir(join(workspace, ".claude", "skills", "landing"), { recursive: true });
1574
+ projectRootMock.value = workspace;
1575
+ const queryInputs = [];
1576
+ const syncs = [];
1577
+ const executor = createExecutor({
1578
+ queryInputs,
1579
+ queryResults: [scriptedMessages(resultMessage(0, "session_skill_main"))],
1580
+ mainSync: {
1581
+ beforeTurn: (threadId, _published, turn) => {
1582
+ syncs.push({ threadId, ...turn });
1583
+ return Promise.resolve({});
1584
+ },
1585
+ refuseWrite: () => null,
1586
+ },
1587
+ });
1588
+ await executor.execute({ threadId: "thread_skill_main", turnId: "turn_skill_main", prompt: "/landing pricing", history: history([]) }, activeControls());
1589
+ expect(syncs).toEqual([{ threadId: "thread_skill_main", withNote: false }]);
1590
+ expect(queryInputs[0]?.prompt).toBe("/landing pricing");
1591
+ expect(queryInputs[0]?.reminder).toBeUndefined();
1592
+ });
1593
+ it("refuses a write that would create a page another chat already published, with the reason for the agent", async () => {
1594
+ let guardResult;
1595
+ const executor = new BridgeJobExecutor({
1596
+ sdk: {
1597
+ query: (input) => ({
1598
+ async *[Symbol.asyncIterator]() {
1599
+ guardResult = await input.options.canUseTool?.("Write", { file_path: "/workspace/src/pages/sales-v2/index.astro", content: "v2" }, {
1600
+ toolUseID: "tool_write",
1601
+ requestId: "req_write",
1602
+ signal: input.options.abortController?.signal ?? new AbortController().signal,
1603
+ });
1604
+ yield resultMessage(0, "session_page_clash");
1605
+ },
1606
+ }),
1607
+ },
1608
+ model: "claude-test",
1609
+ sessionStore: { load: async () => undefined, save: async () => undefined, clear: async () => undefined },
1610
+ relay: recordingRelay(),
1611
+ mainSync: {
1612
+ beforeTurn: () => Promise.resolve({}),
1613
+ refuseWrite: (threadId, filePath) => threadId === "thread_page_clash" && filePath.endsWith("src/pages/sales-v2/index.astro")
1614
+ ? "`src/pages/sales-v2` was already published by another chat"
1615
+ : null,
1616
+ },
1617
+ });
1618
+ await executor.execute({ threadId: "thread_page_clash", turnId: "turn_page_clash", prompt: "build sales v2" }, activeControls());
1619
+ expect(guardResult).toEqual({
1620
+ behavior: "deny",
1621
+ message: "`src/pages/sales-v2` was already published by another chat",
1622
+ });
1623
+ });
1481
1624
  it("keeps the viewing-reference context beside the message when a stale session falls back to rehydration", async () => {
1482
1625
  const queryInputs = [];
1483
1626
  const executor = createExecutor({
@@ -1929,8 +2072,8 @@ describe("BridgeJobExecutor", () => {
1929
2072
  expect(castingRequests[0]).toMatchObject({ avatarHandle: "elena" });
1930
2073
  });
1931
2074
  it("hands the agent's own casting call the face the user picked, instead of a second picker", async () => {
1932
- // The tool tells the agent to call it IMMEDIATELY after `baker avatars create`, so
1933
- // this is the documented path, not an edge. The auto-cast owed for that create used
2075
+ // An agent may still call it right after `baker avatars create` (the tool now says the
2076
+ // picker opens by itself, but the call is tolerated). The auto-cast owed for that create used
1934
2077
  // to run under its own tool-use id and then let the explicit call through unhandled —
1935
2078
  // which returns the tool's `{ status: "declined" }` default. The agent was told the
1936
2079
  // user had rejected all four faces moments after they picked one, and the remedy in
@@ -3096,29 +3239,6 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
3096
3239
  expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.2 });
3097
3240
  expect(endInputAt.count).toBe(4);
3098
3241
  });
3099
- it("tells the session watchdog while a tool call or background task is running", async () => {
3100
- const endInputAt = { count: -1 };
3101
- const executor = createExecutor({
3102
- turnEndSettleMs: 20,
3103
- queryResults: [
3104
- cliLikeQuery(endInputAt, toolUseAssistantMessage("Bash", { command: "baker canvas run x.canvas.json" }), {
3105
- type: "user",
3106
- session_id: "session_bg",
3107
- message: { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_stream", content: "ok" }] },
3108
- }, taskStarted("task_render"), resultMessage(0.1, "session_bg"), taskSettled("task_render"), resultMessage(0.2, "session_bg")),
3109
- ],
3110
- });
3111
- const reported = [];
3112
- const controls = {
3113
- ...activeControls(),
3114
- setWorkInFlight: (inFlight) => {
3115
- if (reported.at(-1) !== inFlight)
3116
- reported.push(inFlight);
3117
- },
3118
- };
3119
- await executor.execute({ threadId: "thread_bg", turnId: "turn_bg", prompt: "Render", history: history([]) }, controls);
3120
- expect(reported).toEqual([true, false, true, false]);
3121
- });
3122
3242
  it("ends the turn when the last task settles without waking the model into a second result", async () => {
3123
3243
  // A settle is not guaranteed to produce another result. If only a result
3124
3244
  // could arm the end, this shape would strand in `for await` until the
@@ -3134,20 +3254,6 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
3134
3254
  expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.1 });
3135
3255
  expect(endInputAt.count).toBe(3);
3136
3256
  });
3137
- it("bills what a task spent after the last result when it settles without waking the model", async () => {
3138
- // The last result reported $0.10; the task kept working after it. With no
3139
- // second result, only the CLI's own running total can say what it cost.
3140
- const endInputAt = { count: -1 };
3141
- const query = cliLikeQuery(endInputAt, taskStarted("task_silent"), resultMessage(0.1, "session_bg"), taskSettled("task_silent"));
3142
- const executor = createExecutor({
3143
- turnEndSettleMs: 20,
3144
- queryResults: [
3145
- { ...query, usage: () => Promise.resolve({ totalCostUsd: 0.35, modelUsage: {} }) },
3146
- ],
3147
- });
3148
- const outcome = await executor.execute({ threadId: "thread_silent", turnId: "turn_silent", prompt: "Fan out one producer", history: history([]) }, activeControls());
3149
- expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.35 });
3150
- });
3151
3257
  it("re-arms the settle window when a passive system event trails the result", async () => {
3152
3258
  // Passive trailing events (files persisted, session state) must not cancel
3153
3259
  // a scheduled end — only genuine model activity does. A cancel with no
@@ -3415,4 +3521,117 @@ describe("BridgeJobExecutor review mode", () => {
3415
3521
  expect(outcome).toMatchObject({ kind: "completed", structuredOutput: findings });
3416
3522
  });
3417
3523
  });
3524
+ describe("BridgeJobExecutor model refusals", () => {
3525
+ function refusalNoFallback() {
3526
+ return {
3527
+ type: "system",
3528
+ subtype: "model_refusal_no_fallback",
3529
+ original_model: "claude-opus-5-5",
3530
+ request_id: "req_1",
3531
+ api_refusal_category: "cyber",
3532
+ content: "Claude Code is unable to respond to this request.",
3533
+ uuid: "refusal_notice",
3534
+ session_id: "session_refused",
3535
+ };
3536
+ }
3537
+ it("ends a refused turn with a product-language error instead of a success", async () => {
3538
+ const executor = createExecutor({
3539
+ queryResults: [scriptedMessages(refusalNoFallback(), resultMessage(0.01, "session_refused"))],
3540
+ });
3541
+ const outcome = await executor.execute({ threadId: "thread_refused", turnId: "turn_refused", prompt: "Hi", history: history([]) }, activeControls());
3542
+ expect(outcome).toMatchObject({
3543
+ kind: "errored",
3544
+ errors: ["I couldn't help with that request. Try rephrasing what you need."],
3545
+ });
3546
+ });
3547
+ it("records the refusal category for whoever reads the turn afterwards", async () => {
3548
+ const turnEvents = [];
3549
+ const executor = createExecutor({
3550
+ turnEvents,
3551
+ queryResults: [scriptedMessages(refusalNoFallback(), resultMessage(0.01, "session_refused"))],
3552
+ });
3553
+ await executor.execute({ threadId: "thread_refused", turnId: "turn_refused", prompt: "Hi", history: history([]) }, activeControls());
3554
+ expect(turnEvents).toContainEqual(expect.objectContaining({ event: "model_refusal", detail: expect.objectContaining({ category: "cyber" }) }));
3555
+ });
3556
+ // The CLI renders the refusal as an assistant error frame written for a
3557
+ // terminal user. The turn already ends on a product-language error, so that
3558
+ // frame never reaches the chat.
3559
+ it("does not relay the CLI's refusal frame to the chat", async () => {
3560
+ const relay = recordingRelay();
3561
+ const refusalFrame = {
3562
+ type: "assistant",
3563
+ uuid: "refusal_frame",
3564
+ message: {
3565
+ role: "assistant",
3566
+ content: [{ type: "text", text: "API Error: Claude Code is unable to respond to this request." }],
3567
+ stop_reason: "refusal",
3568
+ },
3569
+ };
3570
+ const executor = createExecutor({
3571
+ relay,
3572
+ queryResults: [scriptedMessages(refusalFrame, refusalNoFallback(), resultMessage(0.01, "session_refused"))],
3573
+ });
3574
+ await executor.execute({ threadId: "thread_refused", turnId: "turn_refused", prompt: "Hi", history: history([]) }, activeControls());
3575
+ expect(relay.assistantEvents).toEqual([]);
3576
+ });
3577
+ // A refused subagent is reported the same way, but the main agent gets the
3578
+ // failed task back and carries on — the turn is still its to finish.
3579
+ it("lets the turn complete when the main conversation carries on after a refusal", async () => {
3580
+ const executor = createExecutor({
3581
+ queryResults: [
3582
+ scriptedMessages(refusalNoFallback(), assistantMessage("assistant_after", "Done another way.", "session_refused"), resultMessage(0.01, "session_refused")),
3583
+ ],
3584
+ });
3585
+ const outcome = await executor.execute({ threadId: "thread_refused", turnId: "turn_refused", prompt: "Hi", history: history([]) }, activeControls());
3586
+ expect(outcome).toMatchObject({ kind: "completed" });
3587
+ });
3588
+ it("keeps the product-language error when the refused turn's result is an error", async () => {
3589
+ const erroredResult = {
3590
+ type: "result",
3591
+ subtype: "error_during_execution",
3592
+ is_error: true,
3593
+ errors: ["API Error: Claude Code is unable to respond to this request."],
3594
+ total_cost_usd: 0.01,
3595
+ session_id: "session_refused",
3596
+ };
3597
+ const executor = createExecutor({ queryResults: [scriptedMessages(refusalNoFallback(), erroredResult)] });
3598
+ const outcome = await executor.execute({ threadId: "thread_refused", turnId: "turn_refused", prompt: "Hi", history: history([]) }, activeControls());
3599
+ expect(outcome).toMatchObject({
3600
+ kind: "errored",
3601
+ errors: ["I couldn't help with that request. Try rephrasing what you need."],
3602
+ });
3603
+ });
3604
+ // A fallback model retried the refused request. What the refused leg already
3605
+ // posted is superseded, so it leaves the chat instead of sitting above the
3606
+ // answer that replaced it.
3607
+ it("retracts the messages a refusal fallback superseded", async () => {
3608
+ const relay = recordingRelay();
3609
+ const fallbackNotice = {
3610
+ type: "system",
3611
+ subtype: "model_refusal_fallback",
3612
+ trigger: "refusal",
3613
+ direction: "retry",
3614
+ scope: "session",
3615
+ original_model: "claude-opus-5-5",
3616
+ fallback_model: "claude-sonnet-5",
3617
+ request_id: "req_1",
3618
+ api_refusal_category: "cyber",
3619
+ retracted_message_uuids: ["refused_partial_1", "refused_partial_2"],
3620
+ content: "Retried on a different model.",
3621
+ uuid: "fallback_notice",
3622
+ session_id: "session_fallback",
3623
+ };
3624
+ const executor = createExecutor({
3625
+ relay,
3626
+ queryResults: [
3627
+ scriptedMessages(assistantMessage("refused_partial_1", "Sure, here is", "session_fallback"), fallbackNotice, assistantMessage("fallback_answer", "Here is the plan.", "session_fallback"), resultMessage(0.01, "session_fallback")),
3628
+ ],
3629
+ });
3630
+ const outcome = await executor.execute({ threadId: "thread_fb", turnId: "turn_fb", prompt: "Hi", history: history([]) }, activeControls());
3631
+ expect(relay.retractedEvents).toEqual([
3632
+ { target: { threadId: "thread_fb", turnId: "turn_fb" }, eventIds: ["refused_partial_1", "refused_partial_2"] },
3633
+ ]);
3634
+ expect(outcome).toMatchObject({ kind: "completed" });
3635
+ });
3636
+ });
3418
3637
  //# sourceMappingURL=bridge-job.test.js.map