@koda-sl/baker-bridge 0.109.0-dev.f28c07bd2 → 0.109.0-dev.fa751dbf1

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 (44) hide show
  1. package/README.md +14 -1
  2. package/dist/hono/agent.d.ts.map +1 -1
  3. package/dist/hono/agent.js +9 -4
  4. package/dist/hono/agent.js.map +1 -1
  5. package/dist/hono/attachments.d.ts +1 -6
  6. package/dist/hono/attachments.d.ts.map +1 -1
  7. package/dist/hono/attachments.js +4 -9
  8. package/dist/hono/attachments.js.map +1 -1
  9. package/dist/hono/attachments.test.js +0 -5
  10. package/dist/hono/attachments.test.js.map +1 -1
  11. package/dist/hono/bridge-job.d.ts +11 -10
  12. package/dist/hono/bridge-job.d.ts.map +1 -1
  13. package/dist/hono/bridge-job.js +45 -38
  14. package/dist/hono/bridge-job.js.map +1 -1
  15. package/dist/hono/bridge-job.test.js +164 -94
  16. package/dist/hono/bridge-job.test.js.map +1 -1
  17. package/dist/hono/main-sync.d.ts +46 -0
  18. package/dist/hono/main-sync.d.ts.map +1 -0
  19. package/dist/hono/main-sync.js +409 -0
  20. package/dist/hono/main-sync.js.map +1 -0
  21. package/dist/hono/main-sync.test.d.ts +2 -0
  22. package/dist/hono/main-sync.test.d.ts.map +1 -0
  23. package/dist/hono/main-sync.test.js +276 -0
  24. package/dist/hono/main-sync.test.js.map +1 -0
  25. package/dist/hono/session-continuity.d.ts +1 -0
  26. package/dist/hono/session-continuity.d.ts.map +1 -1
  27. package/dist/hono/session-continuity.js +5 -2
  28. package/dist/hono/session-continuity.js.map +1 -1
  29. package/dist/hono/tag-input.d.ts.map +1 -1
  30. package/dist/hono/tag-input.js +0 -4
  31. package/dist/hono/tag-input.js.map +1 -1
  32. package/dist/hono/types.d.ts +6 -7
  33. package/dist/hono/types.d.ts.map +1 -1
  34. package/dist/review/digest.d.ts +1 -7
  35. package/dist/review/digest.d.ts.map +1 -1
  36. package/dist/review/digest.js +16 -80
  37. package/dist/review/digest.js.map +1 -1
  38. package/dist/review/digest.test.js +0 -83
  39. package/dist/review/digest.test.js.map +1 -1
  40. package/package.json +1 -2
  41. package/dist/hono/baker-ui-server.test.d.ts +0 -2
  42. package/dist/hono/baker-ui-server.test.d.ts.map +0 -1
  43. package/dist/hono/baker-ui-server.test.js +0 -46
  44. package/dist/hono/baker-ui-server.test.js.map +0 -1
@@ -229,7 +229,7 @@ function createExecutor(options) {
229
229
  now: options.now,
230
230
  readUsageBaseline: async (sessionId) => options.usageBaselines?.[sessionId],
231
231
  stalledCliExitMs: 0,
232
- prepareAttachments: options.prepareAttachments,
232
+ ...(options.mainSync ? { mainSync: options.mainSync } : {}),
233
233
  });
234
234
  }
235
235
  function resultMessage(costUsd, sessionId, modelUsage) {
@@ -1112,18 +1112,6 @@ describe("BridgeJobExecutor", () => {
1112
1112
  await executor.execute({ threadId: "thread_bedrock", turnId: "turn_bedrock", prompt: "Build a landing", history: history([]) }, activeControls());
1113
1113
  expect(queryInputs[0]?.options.env).toMatchObject({ CLAUDE_CODE_MODEL_CAPABILITIES: "-per_turn_effort" });
1114
1114
  });
1115
- // Behind the OpenRouter base URL the CLI loads every MCP tool schema up front:
1116
- // a connected Make toolkit put ~180 schemas (~115k tokens) into every request of
1117
- // a Session that called two of them. Tool search defers them to their names.
1118
- it("defers MCP tool schemas behind tool search instead of loading them into every request", async () => {
1119
- const queryInputs = [];
1120
- const executor = createExecutor({
1121
- queryInputs,
1122
- queryResults: [scriptedMessages(resultMessage(0, "session_tool_search"))],
1123
- });
1124
- await executor.execute({ threadId: "thread_tool_search", turnId: "turn_tool_search", prompt: "Check Make", history: history([]) }, activeControls());
1125
- expect(queryInputs[0]?.options.env).toMatchObject({ ENABLE_TOOL_SEARCH: "true" });
1126
- });
1127
1115
  it("silences the CLI task reminders and ANSI colour in the agent subprocess", async () => {
1128
1116
  const queryInputs = [];
1129
1117
  const executor = createExecutor({
@@ -1485,87 +1473,198 @@ describe("BridgeJobExecutor", () => {
1485
1473
  expect(queryInputs[0]?.prompt).toBe("/publish");
1486
1474
  expect(queryInputs[0]?.reminder).toBeUndefined();
1487
1475
  });
1488
- 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 () => {
1489
1477
  const queryInputs = [];
1478
+ const syncedThreads = [];
1479
+ const syncedPublishes = [];
1490
1480
  const executor = createExecutor({
1491
- loadedBoundary: cleanBoundary("session_stale_viewing"),
1492
1481
  queryInputs,
1493
- queryResults: [
1494
- scriptedError(new Error("Cannot resume: session not found")),
1495
- scriptedMessages(resultMessage(0, "session_next_viewing")),
1496
- ],
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
+ },
1497
1494
  });
1498
1495
  await executor.execute({
1499
- threadId: "thread_stale_viewing",
1500
- turnId: "turn_stale_viewing",
1501
- prompt: "quita el pricing",
1502
- 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([]),
1503
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
+ ],
1504
1510
  }, activeControls());
1505
- expect(queryInputs[1]?.prompt).toContain("Current user request\nquita el pricing");
1506
- expect(queryInputs[1]?.prompt).not.toContain("preview pane");
1507
- 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)');
1508
1516
  });
1509
- // A late answer to a blocking prompt continues a request whose files the
1510
- // Session received with it. A rebuilt Session only has that request's history,
1511
- // which names the files but cannot open them, so they are handed over again.
1512
- 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 () => {
1513
1518
  const queryInputs = [];
1519
+ const realignedAfter = [];
1514
1520
  const executor = createExecutor({
1515
1521
  queryInputs,
1516
- 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
+ },
1517
1531
  });
1518
- await executor.execute({
1519
- threadId: "thread_continued_files",
1520
- turnId: "turn_continued_files",
1521
- prompt: "They have now replied: HubSpot",
1522
- continuedAttachments: [
1523
- { url: "https://files.example/logo.png", contentType: "image/png", filename: "logo.png" },
1524
- ],
1525
- history: history([{ role: "user", text: "Build a page with my logo", createdAt: 1 }]),
1526
- }, activeControls());
1527
- 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]);
1528
1534
  });
1529
- 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 () => {
1530
1536
  const queryInputs = [];
1537
+ const syncedThreads = [];
1531
1538
  const executor = createExecutor({
1532
- loadedBoundary: cleanBoundary("session_holds_files"),
1533
1539
  queryInputs,
1534
- 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
+ },
1535
1549
  });
1536
- await executor.execute({
1537
- threadId: "thread_resumed_files",
1538
- turnId: "turn_resumed_files",
1539
- prompt: "They have now replied: HubSpot",
1540
- continuedAttachments: [
1541
- { 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")),
1542
1577
  ],
1543
- 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 }]),
1544
1589
  }, activeControls());
1545
- expect(queryInputs[0]?.prompt).toBe("They have now replied: HubSpot");
1590
+ expect(queryInputs[0]?.reminder).toBeUndefined();
1591
+ expect(queryInputs[1]?.reminder).toContain("Baker: standing");
1592
+ });
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;
1597
+ const queryInputs = [];
1598
+ const syncs = [];
1599
+ const executor = createExecutor({
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
+ });
1546
1647
  });
1547
- it("hands the continued request's files over when a stale Session falls back to history", async () => {
1648
+ it("keeps the viewing-reference context beside the message when a stale session falls back to rehydration", async () => {
1548
1649
  const queryInputs = [];
1549
1650
  const executor = createExecutor({
1550
- loadedBoundary: cleanBoundary("session_stale_files"),
1651
+ loadedBoundary: cleanBoundary("session_stale_viewing"),
1551
1652
  queryInputs,
1552
1653
  queryResults: [
1553
1654
  scriptedError(new Error("Cannot resume: session not found")),
1554
- scriptedMessages(resultMessage(0, "session_rebuilt_files")),
1655
+ scriptedMessages(resultMessage(0, "session_next_viewing")),
1555
1656
  ],
1556
- prepareAttachments: async (attachments) => `\n\nFILES: ${attachments.map((attachment) => attachment.filename).join(", ")}`,
1557
1657
  });
1558
1658
  await executor.execute({
1559
- threadId: "thread_stale_files",
1560
- turnId: "turn_stale_files",
1561
- prompt: "They have now replied: HubSpot",
1562
- continuedAttachments: [
1563
- { url: "https://files.example/logo.png", contentType: "image/png", filename: "logo.png" },
1564
- ],
1565
- 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" },
1566
1664
  }, activeControls());
1567
- expect(queryInputs[0]?.prompt).not.toContain("FILES");
1568
- 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)');
1569
1668
  });
1570
1669
  it("clears an invalid clean boundary and retries once with rehydrated history", async () => {
1571
1670
  const relay = recordingRelay();
@@ -3248,35 +3347,6 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
3248
3347
  expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.1 });
3249
3348
  expect(endInputAt.count).toBe(3);
3250
3349
  });
3251
- // The production shape: the model refused, the CLI answered with an error
3252
- // result and then waited on stdin like after any other result. The error was
3253
- // no longer thrown, and only a clean result ended input, so the turn sat idle
3254
- // until the 30-minute session watchdog reported a lost connection.
3255
- it("ends input after the error result of a refused turn", async () => {
3256
- const endInputAt = { count: -1 };
3257
- const refusal = {
3258
- type: "system",
3259
- subtype: "model_refusal_no_fallback",
3260
- original_model: "claude-opus-5-5",
3261
- api_refusal_category: "reasoning_extraction",
3262
- session_id: "session_refused",
3263
- };
3264
- const erroredResult = {
3265
- type: "result",
3266
- subtype: "error_during_execution",
3267
- is_error: true,
3268
- errors: ["API Error: Claude Code is unable to respond to this request."],
3269
- total_cost_usd: 0.01,
3270
- session_id: "session_refused",
3271
- };
3272
- const executor = createExecutor({ queryResults: [cliLikeQuery(endInputAt, refusal, erroredResult)] });
3273
- const outcome = await executor.execute({ threadId: "thread_refused", turnId: "turn_refused", prompt: "Hi", history: history([]) }, activeControls());
3274
- expect(outcome).toMatchObject({
3275
- kind: "errored",
3276
- errors: ["I couldn't help with that request. Try rephrasing what you need."],
3277
- });
3278
- expect(endInputAt.count).toBe(2);
3279
- });
3280
3350
  it("does not hold the turn open for ambient skip_transcript tasks", async () => {
3281
3351
  const endInputAt = { count: -1 };
3282
3352
  const executor = createExecutor({