@koda-sl/baker-bridge 0.109.0 → 0.110.0-dev.6a86a7961

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 (52) hide show
  1. package/README.md +19 -19
  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 +0 -37
  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 +9 -12
  15. package/dist/hono/agent.js.map +1 -1
  16. package/dist/hono/attachments.d.ts +1 -6
  17. package/dist/hono/attachments.d.ts.map +1 -1
  18. package/dist/hono/attachments.js +4 -9
  19. package/dist/hono/attachments.js.map +1 -1
  20. package/dist/hono/attachments.test.js +0 -5
  21. package/dist/hono/attachments.test.js.map +1 -1
  22. package/dist/hono/bridge-job.d.ts +12 -31
  23. package/dist/hono/bridge-job.d.ts.map +1 -1
  24. package/dist/hono/bridge-job.js +68 -130
  25. package/dist/hono/bridge-job.js.map +1 -1
  26. package/dist/hono/bridge-job.test.js +170 -227
  27. package/dist/hono/bridge-job.test.js.map +1 -1
  28. package/dist/hono/main-sync.d.ts +46 -0
  29. package/dist/hono/main-sync.d.ts.map +1 -0
  30. package/dist/hono/main-sync.js +422 -0
  31. package/dist/hono/main-sync.js.map +1 -0
  32. package/dist/hono/main-sync.test.d.ts +2 -0
  33. package/dist/hono/main-sync.test.d.ts.map +1 -0
  34. package/dist/hono/main-sync.test.js +288 -0
  35. package/dist/hono/main-sync.test.js.map +1 -0
  36. package/dist/hono/session-continuity.d.ts +1 -0
  37. package/dist/hono/session-continuity.d.ts.map +1 -1
  38. package/dist/hono/session-continuity.js +5 -2
  39. package/dist/hono/session-continuity.js.map +1 -1
  40. package/dist/hono/types.d.ts +6 -7
  41. package/dist/hono/types.d.ts.map +1 -1
  42. package/dist/review/digest.d.ts +1 -7
  43. package/dist/review/digest.d.ts.map +1 -1
  44. package/dist/review/digest.js +16 -80
  45. package/dist/review/digest.js.map +1 -1
  46. package/dist/review/digest.test.js +0 -83
  47. package/dist/review/digest.test.js.map +1 -1
  48. package/package.json +1 -2
  49. package/dist/hono/workspace-settle.d.ts +0 -20
  50. package/dist/hono/workspace-settle.d.ts.map +0 -1
  51. package/dist/hono/workspace-settle.js +0 -46
  52. package/dist/hono/workspace-settle.js.map +0 -1
@@ -223,14 +223,13 @@ function createExecutor(options) {
223
223
  stallCheckIntervalMs: options.stallCheckIntervalMs,
224
224
  turnEndSettleMs: options.turnEndSettleMs,
225
225
  turnEndExitGuardMs: options.turnEndExitGuardMs,
226
- settleWorkspace: options.settleWorkspace ?? (() => Promise.resolve()),
227
226
  ...(options.turnEvents
228
227
  ? { recordTurnEvent: (event) => options.turnEvents?.push(event) }
229
228
  : {}),
230
229
  now: options.now,
231
230
  readUsageBaseline: async (sessionId) => options.usageBaselines?.[sessionId],
232
231
  stalledCliExitMs: 0,
233
- prepareAttachments: options.prepareAttachments,
232
+ ...(options.mainSync ? { mainSync: options.mainSync } : {}),
234
233
  });
235
234
  }
236
235
  function resultMessage(costUsd, sessionId, modelUsage) {
@@ -316,8 +315,8 @@ describe("isControlChannelDeny", () => {
316
315
  })).toBe(false);
317
316
  });
318
317
  it("does not match a PreToolUse hook block that quotes the envelope in its explanation", () => {
319
- // Live-run finding: the scaffold's guard-background-commands hook, when it
320
- // 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.
321
320
  // Its block arrives as an is_error tool_result, so an envelope-substring
322
321
  // match counted it as a deny — three blocked spawns in one turn would have
323
322
  // tripped the breaker and errored a perfectly healthy turn.
@@ -1171,7 +1170,8 @@ describe("BridgeJobExecutor", () => {
1171
1170
  // Read off a session's own `init` tool list, the CLI offers these to every
1172
1171
  // Session. Each one reaches past what a marketer's chat is for: the worktree
1173
1172
  // pair rearranges the branches Publish owns, `Workflow` fans out a fleet on
1174
- // the organization's OpenRouter key.
1173
+ // the organization's OpenRouter key, and `SendMessage` addresses peers a
1174
+ // single-session Runtime does not have.
1175
1175
  it("removes the tools that reach outside what a Session is", async () => {
1176
1176
  const queryInputs = [];
1177
1177
  const executor = createExecutor({
@@ -1180,12 +1180,10 @@ describe("BridgeJobExecutor", () => {
1180
1180
  });
1181
1181
  await executor.execute({ threadId: "thread_reach", turnId: "turn_reach", prompt: "Build a landing", history: history([]) }, activeControls());
1182
1182
  const disallowed = queryInputs[0]?.options.disallowedTools ?? [];
1183
- expect(disallowed).toEqual(expect.arrayContaining(["EnterWorktree", "ExitWorktree", "Workflow"]));
1183
+ expect(disallowed).toEqual(expect.arrayContaining(["EnterWorktree", "ExitWorktree", "Workflow", "SendMessage"]));
1184
1184
  // `Task` is the subagent spawn itself — the scaffold's own fan-out workers
1185
- // ride it, so blocking multi-agent orchestration must not catch it. And
1186
- // `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.
1187
1186
  expect(disallowed).not.toContain("Task");
1188
- expect(disallowed).not.toContain("SendMessage");
1189
1187
  });
1190
1188
  it("allowlists the workspace's own skills so the CLI's built-in developer skills stay out of the session", async () => {
1191
1189
  const workspace = await mkdtemp(join(tmpdir(), "baker-job-skills-"));
@@ -1475,87 +1473,198 @@ describe("BridgeJobExecutor", () => {
1475
1473
  expect(queryInputs[0]?.prompt).toBe("/publish");
1476
1474
  expect(queryInputs[0]?.reminder).toBeUndefined();
1477
1475
  });
1478
- it("keeps the viewing-reference context beside the message when a stale session falls back to rehydration", async () => {
1476
+ it("brings the workspace level with main before the turn and tells the agent what changed, beside the viewing context", async () => {
1479
1477
  const queryInputs = [];
1478
+ const syncedThreads = [];
1479
+ const syncedPublishes = [];
1480
1480
  const executor = createExecutor({
1481
- loadedBoundary: cleanBoundary("session_stale_viewing"),
1482
1481
  queryInputs,
1483
- queryResults: [
1484
- scriptedError(new Error("Cannot resume: session not found")),
1485
- scriptedMessages(resultMessage(0, "session_next_viewing")),
1486
- ],
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
+ afterTurn: () => Promise.resolve(),
1493
+ },
1487
1494
  });
1488
1495
  await executor.execute({
1489
- threadId: "thread_stale_viewing",
1490
- turnId: "turn_stale_viewing",
1491
- prompt: "quita el pricing",
1492
- history: history([{ role: "user", text: "Persisted fact", createdAt: 1 }]),
1496
+ threadId: "thread_main_moved",
1497
+ turnId: "turn_main_moved",
1498
+ prompt: "change the hero title",
1499
+ history: history([]),
1493
1500
  currentReference: { type: "landing", slug: "homepage", title: "Homepage" },
1501
+ publishedElsewhere: [
1502
+ {
1503
+ sha: "abc123",
1504
+ chatId: "sibling",
1505
+ title: "New hero",
1506
+ publishedAt: 1,
1507
+ paths: ["src/pages/hero/index.astro"],
1508
+ },
1509
+ ],
1494
1510
  }, activeControls());
1495
- expect(queryInputs[1]?.prompt).toContain("Current user request\nquita el pricing");
1496
- expect(queryInputs[1]?.prompt).not.toContain("preview pane");
1497
- expect(queryInputs[1]?.reminder).toContain('landing "Homepage" (slug: homepage)');
1511
+ expect(syncedThreads).toEqual(["thread_main_moved"]);
1512
+ expect(syncedPublishes).toEqual(["abc123"]);
1513
+ expect(queryInputs[0]?.prompt).toBe("change the hero title");
1514
+ expect(queryInputs[0]?.reminder).toContain("Baker: this workspace now includes work published");
1515
+ expect(queryInputs[0]?.reminder).toContain('landing "Homepage" (slug: homepage)');
1498
1516
  });
1499
- // A late answer to a blocking prompt continues a request whose files the
1500
- // Session received with it. A rebuilt Session only has that request's history,
1501
- // which names the files but cannot open them, so they are handed over again.
1502
- it("hands over the continued request's files when the Session is rebuilt", async () => {
1517
+ it("realigns the workspace once the turn ends, after whatever the agent did to it", async () => {
1503
1518
  const queryInputs = [];
1519
+ const realignedAfter = [];
1504
1520
  const executor = createExecutor({
1505
1521
  queryInputs,
1506
- prepareAttachments: async (attachments, attachedTo) => `\n\nFILES for ${attachedTo}: ${attachments.map((attachment) => attachment.filename).join(", ")}`,
1522
+ queryResults: [scriptedMessages(resultMessage(0, "session_after_turn"))],
1523
+ mainSync: {
1524
+ beforeTurn: () => Promise.resolve({}),
1525
+ refuseWrite: () => null,
1526
+ afterTurn: () => {
1527
+ realignedAfter.push(queryInputs.length);
1528
+ return Promise.resolve();
1529
+ },
1530
+ },
1507
1531
  });
1508
- await executor.execute({
1509
- threadId: "thread_continued_files",
1510
- turnId: "turn_continued_files",
1511
- prompt: "They have now replied: HubSpot",
1512
- continuedAttachments: [
1513
- { url: "https://files.example/logo.png", contentType: "image/png", filename: "logo.png" },
1514
- ],
1515
- history: history([{ role: "user", text: "Build a page with my logo", createdAt: 1 }]),
1516
- }, activeControls());
1517
- expect(queryInputs[0]?.prompt).toContain("FILES for continued_request: logo.png");
1532
+ await executor.execute({ threadId: "thread_after_turn", turnId: "turn_after_turn", prompt: "edit the hero", history: history([]) }, activeControls());
1533
+ expect(realignedAfter).toEqual([1]);
1518
1534
  });
1519
- it("does not hand the continued request's files over again to a resumed Session", async () => {
1535
+ it("does not move the workspace under a publish command", async () => {
1520
1536
  const queryInputs = [];
1537
+ const syncedThreads = [];
1521
1538
  const executor = createExecutor({
1522
- loadedBoundary: cleanBoundary("session_holds_files"),
1523
1539
  queryInputs,
1524
- prepareAttachments: async (attachments) => `\n\nFILES: ${attachments.map((attachment) => attachment.filename).join(", ")}`,
1540
+ queryResults: [scriptedMessages(resultMessage(0, "session_publish_main"))],
1541
+ mainSync: {
1542
+ beforeTurn: (threadId) => {
1543
+ syncedThreads.push(threadId);
1544
+ return Promise.resolve({ resumed: "<system-reminder>\nBaker: moved\n</system-reminder>" });
1545
+ },
1546
+ refuseWrite: () => null,
1547
+ afterTurn: () => Promise.resolve(),
1548
+ },
1525
1549
  });
1526
- await executor.execute({
1527
- threadId: "thread_resumed_files",
1528
- turnId: "turn_resumed_files",
1529
- prompt: "They have now replied: HubSpot",
1530
- continuedAttachments: [
1531
- { url: "https://files.example/logo.png", contentType: "image/png", filename: "logo.png" },
1550
+ await executor.execute({ threadId: "thread_publish_main", turnId: "turn_publish_main", prompt: "/publish", history: history([]) }, activeControls());
1551
+ expect(syncedThreads).toEqual([]);
1552
+ expect(queryInputs[0]?.reminder).toBeUndefined();
1553
+ });
1554
+ it("tells a resumed session only what is new, since it remembers every earlier note", async () => {
1555
+ const queryInputs = [];
1556
+ const executor = createExecutor({
1557
+ loadedBoundary: cleanBoundary("session_resumed_main"),
1558
+ queryInputs,
1559
+ queryResults: [scriptedMessages(resultMessage(0, "session_resumed_main_next"))],
1560
+ mainSync: {
1561
+ beforeTurn: () => Promise.resolve({ recovered: "<system-reminder>\nBaker: standing\n</system-reminder>" }),
1562
+ refuseWrite: () => null,
1563
+ afterTurn: () => Promise.resolve(),
1564
+ },
1565
+ });
1566
+ await executor.execute({ threadId: "thread_resumed_main", turnId: "turn_resumed_main", prompt: "hola", history: history([]) }, activeControls());
1567
+ expect(queryInputs[0]?.reminder).toBeUndefined();
1568
+ });
1569
+ it("repeats the standing workspace notice when a stale session falls back to rehydration", async () => {
1570
+ const queryInputs = [];
1571
+ const executor = createExecutor({
1572
+ loadedBoundary: cleanBoundary("session_stale_main"),
1573
+ queryInputs,
1574
+ queryResults: [
1575
+ scriptedError(new Error("Cannot resume: session not found")),
1576
+ scriptedMessages(resultMessage(0, "session_stale_main_next")),
1532
1577
  ],
1533
- history: history([{ role: "user", text: "Build a page with my logo", createdAt: 1 }]),
1578
+ mainSync: {
1579
+ beforeTurn: () => Promise.resolve({ recovered: "<system-reminder>\nBaker: standing\n</system-reminder>" }),
1580
+ refuseWrite: () => null,
1581
+ afterTurn: () => Promise.resolve(),
1582
+ },
1583
+ });
1584
+ await executor.execute({
1585
+ threadId: "thread_stale_main",
1586
+ turnId: "turn_stale_main",
1587
+ prompt: "quita el pricing",
1588
+ history: history([{ role: "user", text: "Persisted fact", createdAt: 1 }]),
1534
1589
  }, activeControls());
1535
- expect(queryInputs[0]?.prompt).toBe("They have now replied: HubSpot");
1590
+ expect(queryInputs[0]?.reminder).toBeUndefined();
1591
+ expect(queryInputs[1]?.reminder).toContain("Baker: standing");
1536
1592
  });
1537
- it("hands the continued request's files over when a stale Session falls back to history", async () => {
1593
+ it("brings the workspace level with main before a skill command without putting a note in front of it", async () => {
1594
+ const workspace = await mkdtemp(join(tmpdir(), "baker-job-skill-main-"));
1595
+ await mkdir(join(workspace, ".claude", "skills", "landing"), { recursive: true });
1596
+ projectRootMock.value = workspace;
1538
1597
  const queryInputs = [];
1598
+ const syncs = [];
1539
1599
  const executor = createExecutor({
1540
- loadedBoundary: cleanBoundary("session_stale_files"),
1600
+ queryInputs,
1601
+ queryResults: [scriptedMessages(resultMessage(0, "session_skill_main"))],
1602
+ mainSync: {
1603
+ beforeTurn: (threadId, _published, turn) => {
1604
+ syncs.push({ threadId, ...turn });
1605
+ return Promise.resolve({});
1606
+ },
1607
+ refuseWrite: () => null,
1608
+ afterTurn: () => Promise.resolve(),
1609
+ },
1610
+ });
1611
+ await executor.execute({ threadId: "thread_skill_main", turnId: "turn_skill_main", prompt: "/landing pricing", history: history([]) }, activeControls());
1612
+ expect(syncs).toEqual([{ threadId: "thread_skill_main", withNote: false }]);
1613
+ expect(queryInputs[0]?.prompt).toBe("/landing pricing");
1614
+ expect(queryInputs[0]?.reminder).toBeUndefined();
1615
+ });
1616
+ it("refuses a write that would create a page another chat already published, with the reason for the agent", async () => {
1617
+ let guardResult;
1618
+ const executor = new BridgeJobExecutor({
1619
+ sdk: {
1620
+ query: (input) => ({
1621
+ async *[Symbol.asyncIterator]() {
1622
+ guardResult = await input.options.canUseTool?.("Write", { file_path: "/workspace/src/pages/sales-v2/index.astro", content: "v2" }, {
1623
+ toolUseID: "tool_write",
1624
+ requestId: "req_write",
1625
+ signal: input.options.abortController?.signal ?? new AbortController().signal,
1626
+ });
1627
+ yield resultMessage(0, "session_page_clash");
1628
+ },
1629
+ }),
1630
+ },
1631
+ model: "claude-test",
1632
+ sessionStore: { load: async () => undefined, save: async () => undefined, clear: async () => undefined },
1633
+ relay: recordingRelay(),
1634
+ mainSync: {
1635
+ beforeTurn: () => Promise.resolve({}),
1636
+ refuseWrite: (threadId, filePath) => threadId === "thread_page_clash" && filePath.endsWith("src/pages/sales-v2/index.astro")
1637
+ ? "`src/pages/sales-v2` was already published by another chat"
1638
+ : null,
1639
+ afterTurn: () => Promise.resolve(),
1640
+ },
1641
+ });
1642
+ await executor.execute({ threadId: "thread_page_clash", turnId: "turn_page_clash", prompt: "build sales v2" }, activeControls());
1643
+ expect(guardResult).toEqual({
1644
+ behavior: "deny",
1645
+ message: "`src/pages/sales-v2` was already published by another chat",
1646
+ });
1647
+ });
1648
+ it("keeps the viewing-reference context beside the message when a stale session falls back to rehydration", async () => {
1649
+ const queryInputs = [];
1650
+ const executor = createExecutor({
1651
+ loadedBoundary: cleanBoundary("session_stale_viewing"),
1541
1652
  queryInputs,
1542
1653
  queryResults: [
1543
1654
  scriptedError(new Error("Cannot resume: session not found")),
1544
- scriptedMessages(resultMessage(0, "session_rebuilt_files")),
1655
+ scriptedMessages(resultMessage(0, "session_next_viewing")),
1545
1656
  ],
1546
- prepareAttachments: async (attachments) => `\n\nFILES: ${attachments.map((attachment) => attachment.filename).join(", ")}`,
1547
1657
  });
1548
1658
  await executor.execute({
1549
- threadId: "thread_stale_files",
1550
- turnId: "turn_stale_files",
1551
- prompt: "They have now replied: HubSpot",
1552
- continuedAttachments: [
1553
- { url: "https://files.example/logo.png", contentType: "image/png", filename: "logo.png" },
1554
- ],
1555
- history: history([{ role: "user", text: "Build a page with my logo", createdAt: 1 }]),
1659
+ threadId: "thread_stale_viewing",
1660
+ turnId: "turn_stale_viewing",
1661
+ prompt: "quita el pricing",
1662
+ history: history([{ role: "user", text: "Persisted fact", createdAt: 1 }]),
1663
+ currentReference: { type: "landing", slug: "homepage", title: "Homepage" },
1556
1664
  }, activeControls());
1557
- expect(queryInputs[0]?.prompt).not.toContain("FILES");
1558
- expect(queryInputs[1]?.prompt).toContain("Current user request\nThey have now replied: HubSpot\n\nFILES: logo.png");
1665
+ expect(queryInputs[1]?.prompt).toContain("Current user request\nquita el pricing");
1666
+ expect(queryInputs[1]?.prompt).not.toContain("preview pane");
1667
+ expect(queryInputs[1]?.reminder).toContain('landing "Homepage" (slug: homepage)');
1559
1668
  });
1560
1669
  it("clears an invalid clean boundary and retries once with rehydrated history", async () => {
1561
1670
  const relay = recordingRelay();
@@ -3154,29 +3263,6 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
3154
3263
  expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.2 });
3155
3264
  expect(endInputAt.count).toBe(4);
3156
3265
  });
3157
- it("tells the session watchdog while a tool call or background task is running", async () => {
3158
- const endInputAt = { count: -1 };
3159
- const executor = createExecutor({
3160
- turnEndSettleMs: 20,
3161
- queryResults: [
3162
- cliLikeQuery(endInputAt, toolUseAssistantMessage("Bash", { command: "baker canvas run x.canvas.json" }), {
3163
- type: "user",
3164
- session_id: "session_bg",
3165
- message: { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_stream", content: "ok" }] },
3166
- }, taskStarted("task_render"), resultMessage(0.1, "session_bg"), taskSettled("task_render"), resultMessage(0.2, "session_bg")),
3167
- ],
3168
- });
3169
- const reported = [];
3170
- const controls = {
3171
- ...activeControls(),
3172
- setWorkInFlight: (inFlight) => {
3173
- if (reported.at(-1) !== inFlight)
3174
- reported.push(inFlight);
3175
- },
3176
- };
3177
- await executor.execute({ threadId: "thread_bg", turnId: "turn_bg", prompt: "Render", history: history([]) }, controls);
3178
- expect(reported).toEqual([true, false, true, false]);
3179
- });
3180
3266
  it("ends the turn when the last task settles without waking the model into a second result", async () => {
3181
3267
  // A settle is not guaranteed to produce another result. If only a result
3182
3268
  // could arm the end, this shape would strand in `for await` until the
@@ -3192,120 +3278,6 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
3192
3278
  expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.1 });
3193
3279
  expect(endInputAt.count).toBe(3);
3194
3280
  });
3195
- it("bills what a task spent after the last result when it settles without waking the model", async () => {
3196
- // The last result reported $0.10; the task kept working after it. With no
3197
- // second result, only the CLI's own running total can say what it cost.
3198
- const endInputAt = { count: -1 };
3199
- const query = cliLikeQuery(endInputAt, taskStarted("task_silent"), resultMessage(0.1, "session_bg"), taskSettled("task_silent"));
3200
- const executor = createExecutor({
3201
- turnEndSettleMs: 20,
3202
- queryResults: [
3203
- { ...query, usage: () => Promise.resolve({ totalCostUsd: 0.35, modelUsage: {} }) },
3204
- ],
3205
- });
3206
- const outcome = await executor.execute({ threadId: "thread_silent", turnId: "turn_silent", prompt: "Fan out one producer", history: history([]) }, activeControls());
3207
- expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.35 });
3208
- });
3209
- it("settles the workspace itself when a turn that ran tasks ends, since its last Stop may never come", async () => {
3210
- // A task settling without waking the model ends the turn with no further
3211
- // Stop, so the end-of-turn hooks that deferred to a later Stop never run.
3212
- let settled = 0;
3213
- const executor = createExecutor({
3214
- turnEndSettleMs: 5,
3215
- settleWorkspace: () => {
3216
- settled += 1;
3217
- return Promise.resolve();
3218
- },
3219
- queryResults: [
3220
- cliLikeQuery({ count: -1 }, taskStarted("task_silent"), resultMessage(0.1, "session_bg"), taskSettled("task_silent")),
3221
- cliLikeQuery({ count: -1 }, resultMessage(0.1, "session_plain")),
3222
- ],
3223
- });
3224
- await executor.execute({ threadId: "thread_silent", turnId: "turn_silent", prompt: "Fan out", history: history([]) }, activeControls());
3225
- expect(settled).toBe(1);
3226
- await executor.execute({ threadId: "thread_plain", turnId: "turn_plain", prompt: "Say hi", history: history([]) }, activeControls());
3227
- expect(settled).toBe(1);
3228
- });
3229
- it("leaves the workspace untouched after a Session review that ran tasks", async () => {
3230
- // A review runs with every hook off and may not change anything; the
3231
- // settle steps format files and reconcile a publish.
3232
- let settled = 0;
3233
- const executor = createExecutor({
3234
- turnEndSettleMs: 5,
3235
- settleWorkspace: () => {
3236
- settled += 1;
3237
- return Promise.resolve();
3238
- },
3239
- queryResults: [
3240
- cliLikeQuery({ count: -1 }, taskStarted("task_read"), taskSettled("task_read"), {
3241
- ...resultMessage(0.1, "session_review"),
3242
- structured_output: { summary: "fine", findings: [] },
3243
- }),
3244
- ],
3245
- });
3246
- const outcome = await executor.execute({
3247
- threadId: "thread_review",
3248
- turnId: "turn_review",
3249
- prompt: "You are reviewing…",
3250
- review: {
3251
- reviewId: "rev_1",
3252
- apiKey: "bk_review_key",
3253
- modelAuthToken: "sk-or-platform",
3254
- model: "claude-review-model",
3255
- outputSchema: { type: "object" },
3256
- threadIds: ["thread_visible"],
3257
- },
3258
- }, activeControls());
3259
- expect(outcome).toMatchObject({ kind: "completed" });
3260
- expect(settled).toBe(0);
3261
- });
3262
- it("keeps input open for a wake that lands while the usage reading is in flight", async () => {
3263
- // The usage reading sits between deciding to end and ending. A wake queued
3264
- // in the CLI can land inside it; closing stdin then would deny the woken
3265
- // turn's first permission-gated tool call.
3266
- const endInputAt = { count: -1 };
3267
- const query = cliLikeQuery(endInputAt, taskStarted("task_bg"), resultMessage(0.1, "session_bg"), taskSettled("task_bg"), { pauseMs: 40 }, wakeMessage(),
3268
- // The woken turn is still on time-to-first-token when the reading returns.
3269
- { pauseMs: 150 }, resultMessage(0.2, "session_bg"));
3270
- const executor = createExecutor({
3271
- turnEndSettleMs: 5,
3272
- queryResults: [
3273
- {
3274
- ...query,
3275
- usage: () => new Promise((resolveUsage) => setTimeout(() => resolveUsage({ totalCostUsd: 0.2, modelUsage: {} }), 100)),
3276
- },
3277
- ],
3278
- });
3279
- await executor.execute({ threadId: "thread_wake", turnId: "turn_wake", prompt: "Fan out", history: history([]) }, activeControls());
3280
- expect(endInputAt.count).toBe(5);
3281
- });
3282
- it("leaves a query that ended during the usage reading alone when the reading returns", async () => {
3283
- // A Stop can close the CLI while the reading is in flight. The run is over
3284
- // by the time it answers; ending the turn then would log a turn end that
3285
- // never happened and arm an exit guard against a query nobody is draining.
3286
- const query = cliLikeQuery({ count: -1 }, taskStarted("task_bg"), resultMessage(0.1, "session_bg"), taskSettled("task_bg"));
3287
- let answerUsage;
3288
- const turnEvents = [];
3289
- const executor = createExecutor({
3290
- turnEndSettleMs: 5,
3291
- turnEvents,
3292
- queryResults: [
3293
- {
3294
- ...query,
3295
- usage: () => {
3296
- query.close?.();
3297
- return new Promise((resolveUsage) => {
3298
- answerUsage = () => resolveUsage({ totalCostUsd: 0.2, modelUsage: {} });
3299
- });
3300
- },
3301
- },
3302
- ],
3303
- });
3304
- await executor.execute({ threadId: "thread_stop", turnId: "turn_stop", prompt: "Fan out", history: history([]) }, activeControls());
3305
- answerUsage();
3306
- await new Promise((resolveTick) => setImmediate(resolveTick));
3307
- expect(turnEvents.map((event) => event.event)).not.toContain("turn_end");
3308
- });
3309
3281
  it("re-arms the settle window when a passive system event trails the result", async () => {
3310
3282
  // Passive trailing events (files persisted, session state) must not cancel
3311
3283
  // a scheduled end — only genuine model activity does. A cancel with no
@@ -3375,35 +3347,6 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
3375
3347
  expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.1 });
3376
3348
  expect(endInputAt.count).toBe(3);
3377
3349
  });
3378
- // The production shape: the model refused, the CLI answered with an error
3379
- // result and then waited on stdin like after any other result. The error was
3380
- // no longer thrown, and only a clean result ended input, so the turn sat idle
3381
- // until the 30-minute session watchdog reported a lost connection.
3382
- it("ends input after the error result of a refused turn", async () => {
3383
- const endInputAt = { count: -1 };
3384
- const refusal = {
3385
- type: "system",
3386
- subtype: "model_refusal_no_fallback",
3387
- original_model: "claude-opus-5-5",
3388
- api_refusal_category: "reasoning_extraction",
3389
- session_id: "session_refused",
3390
- };
3391
- const erroredResult = {
3392
- type: "result",
3393
- subtype: "error_during_execution",
3394
- is_error: true,
3395
- errors: ["API Error: Claude Code is unable to respond to this request."],
3396
- total_cost_usd: 0.01,
3397
- session_id: "session_refused",
3398
- };
3399
- const executor = createExecutor({ queryResults: [cliLikeQuery(endInputAt, refusal, erroredResult)] });
3400
- const outcome = await executor.execute({ threadId: "thread_refused", turnId: "turn_refused", prompt: "Hi", history: history([]) }, activeControls());
3401
- expect(outcome).toMatchObject({
3402
- kind: "errored",
3403
- errors: ["I couldn't help with that request. Try rephrasing what you need."],
3404
- });
3405
- expect(endInputAt.count).toBe(2);
3406
- });
3407
3350
  it("does not hold the turn open for ambient skip_transcript tasks", async () => {
3408
3351
  const endInputAt = { count: -1 };
3409
3352
  const executor = createExecutor({