@koda-sl/baker-bridge 0.109.0-dev.fa751dbf1 → 0.109.0
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.
- package/README.md +19 -19
- package/dist/hono/agent-session.d.ts +1 -0
- package/dist/hono/agent-session.d.ts.map +1 -1
- package/dist/hono/agent-session.js +13 -2
- package/dist/hono/agent-session.js.map +1 -1
- package/dist/hono/agent-session.test.js +37 -0
- package/dist/hono/agent-session.test.js.map +1 -1
- package/dist/hono/agent-tasks.d.ts.map +1 -1
- package/dist/hono/agent-tasks.js +7 -3
- package/dist/hono/agent-tasks.js.map +1 -1
- package/dist/hono/agent-tasks.test.js +15 -0
- package/dist/hono/agent-tasks.test.js.map +1 -1
- package/dist/hono/agent.d.ts.map +1 -1
- package/dist/hono/agent.js +12 -9
- package/dist/hono/agent.js.map +1 -1
- package/dist/hono/attachments.d.ts +6 -1
- package/dist/hono/attachments.d.ts.map +1 -1
- package/dist/hono/attachments.js +9 -4
- package/dist/hono/attachments.js.map +1 -1
- package/dist/hono/attachments.test.js +5 -0
- package/dist/hono/attachments.test.js.map +1 -1
- package/dist/hono/bridge-job.d.ts +31 -12
- package/dist/hono/bridge-job.d.ts.map +1 -1
- package/dist/hono/bridge-job.js +130 -68
- package/dist/hono/bridge-job.js.map +1 -1
- package/dist/hono/bridge-job.test.js +227 -170
- package/dist/hono/bridge-job.test.js.map +1 -1
- package/dist/hono/session-continuity.d.ts +0 -1
- package/dist/hono/session-continuity.d.ts.map +1 -1
- package/dist/hono/session-continuity.js +2 -5
- package/dist/hono/session-continuity.js.map +1 -1
- package/dist/hono/types.d.ts +7 -6
- package/dist/hono/types.d.ts.map +1 -1
- package/dist/hono/workspace-settle.d.ts +20 -0
- package/dist/hono/workspace-settle.d.ts.map +1 -0
- package/dist/hono/workspace-settle.js +46 -0
- package/dist/hono/workspace-settle.js.map +1 -0
- package/dist/review/digest.d.ts +7 -1
- package/dist/review/digest.d.ts.map +1 -1
- package/dist/review/digest.js +80 -16
- package/dist/review/digest.js.map +1 -1
- package/dist/review/digest.test.js +83 -0
- package/dist/review/digest.test.js.map +1 -1
- package/package.json +2 -1
- package/dist/hono/main-sync.d.ts +0 -46
- package/dist/hono/main-sync.d.ts.map +0 -1
- package/dist/hono/main-sync.js +0 -409
- package/dist/hono/main-sync.js.map +0 -1
- package/dist/hono/main-sync.test.d.ts +0 -2
- package/dist/hono/main-sync.test.d.ts.map +0 -1
- package/dist/hono/main-sync.test.js +0 -276
- package/dist/hono/main-sync.test.js.map +0 -1
|
@@ -223,13 +223,14 @@ function createExecutor(options) {
|
|
|
223
223
|
stallCheckIntervalMs: options.stallCheckIntervalMs,
|
|
224
224
|
turnEndSettleMs: options.turnEndSettleMs,
|
|
225
225
|
turnEndExitGuardMs: options.turnEndExitGuardMs,
|
|
226
|
+
settleWorkspace: options.settleWorkspace ?? (() => Promise.resolve()),
|
|
226
227
|
...(options.turnEvents
|
|
227
228
|
? { recordTurnEvent: (event) => options.turnEvents?.push(event) }
|
|
228
229
|
: {}),
|
|
229
230
|
now: options.now,
|
|
230
231
|
readUsageBaseline: async (sessionId) => options.usageBaselines?.[sessionId],
|
|
231
232
|
stalledCliExitMs: 0,
|
|
232
|
-
|
|
233
|
+
prepareAttachments: options.prepareAttachments,
|
|
233
234
|
});
|
|
234
235
|
}
|
|
235
236
|
function resultMessage(costUsd, sessionId, modelUsage) {
|
|
@@ -315,8 +316,8 @@ describe("isControlChannelDeny", () => {
|
|
|
315
316
|
})).toBe(false);
|
|
316
317
|
});
|
|
317
318
|
it("does not match a PreToolUse hook block that quotes the envelope in its explanation", () => {
|
|
318
|
-
// Live-run finding: the scaffold's guard-background-commands hook
|
|
319
|
-
//
|
|
319
|
+
// Live-run finding: the scaffold's guard-background-commands hook, when it
|
|
320
|
+
// still blocked backgrounded subagents, explained why by quoting the failure.
|
|
320
321
|
// Its block arrives as an is_error tool_result, so an envelope-substring
|
|
321
322
|
// match counted it as a deny — three blocked spawns in one turn would have
|
|
322
323
|
// tripped the breaker and errored a perfectly healthy turn.
|
|
@@ -1170,8 +1171,7 @@ describe("BridgeJobExecutor", () => {
|
|
|
1170
1171
|
// Read off a session's own `init` tool list, the CLI offers these to every
|
|
1171
1172
|
// Session. Each one reaches past what a marketer's chat is for: the worktree
|
|
1172
1173
|
// pair rearranges the branches Publish owns, `Workflow` fans out a fleet on
|
|
1173
|
-
// the organization's OpenRouter key
|
|
1174
|
-
// single-session Runtime does not have.
|
|
1174
|
+
// the organization's OpenRouter key.
|
|
1175
1175
|
it("removes the tools that reach outside what a Session is", async () => {
|
|
1176
1176
|
const queryInputs = [];
|
|
1177
1177
|
const executor = createExecutor({
|
|
@@ -1180,10 +1180,12 @@ 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"]));
|
|
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.
|
|
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.
|
|
1186
1187
|
expect(disallowed).not.toContain("Task");
|
|
1188
|
+
expect(disallowed).not.toContain("SendMessage");
|
|
1187
1189
|
});
|
|
1188
1190
|
it("allowlists the workspace's own skills so the CLI's built-in developer skills stay out of the session", async () => {
|
|
1189
1191
|
const workspace = await mkdtemp(join(tmpdir(), "baker-job-skills-"));
|
|
@@ -1473,198 +1475,87 @@ describe("BridgeJobExecutor", () => {
|
|
|
1473
1475
|
expect(queryInputs[0]?.prompt).toBe("/publish");
|
|
1474
1476
|
expect(queryInputs[0]?.reminder).toBeUndefined();
|
|
1475
1477
|
});
|
|
1476
|
-
it("
|
|
1478
|
+
it("keeps the viewing-reference context beside the message when a stale session falls back to rehydration", async () => {
|
|
1477
1479
|
const queryInputs = [];
|
|
1478
|
-
const syncedThreads = [];
|
|
1479
|
-
const syncedPublishes = [];
|
|
1480
1480
|
const executor = createExecutor({
|
|
1481
|
+
loadedBoundary: cleanBoundary("session_stale_viewing"),
|
|
1481
1482
|
queryInputs,
|
|
1482
|
-
queryResults: [
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
-
},
|
|
1483
|
+
queryResults: [
|
|
1484
|
+
scriptedError(new Error("Cannot resume: session not found")),
|
|
1485
|
+
scriptedMessages(resultMessage(0, "session_next_viewing")),
|
|
1486
|
+
],
|
|
1494
1487
|
});
|
|
1495
1488
|
await executor.execute({
|
|
1496
|
-
threadId: "
|
|
1497
|
-
turnId: "
|
|
1498
|
-
prompt: "
|
|
1499
|
-
history: history([]),
|
|
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 }]),
|
|
1500
1493
|
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
|
-
],
|
|
1510
1494
|
}, activeControls());
|
|
1511
|
-
expect(
|
|
1512
|
-
expect(
|
|
1513
|
-
expect(queryInputs[
|
|
1514
|
-
expect(queryInputs[0]?.reminder).toContain("Baker: this workspace now includes work published");
|
|
1515
|
-
expect(queryInputs[0]?.reminder).toContain('landing "Homepage" (slug: homepage)');
|
|
1516
|
-
});
|
|
1517
|
-
it("realigns the workspace once the turn ends, after whatever the agent did to it", async () => {
|
|
1518
|
-
const queryInputs = [];
|
|
1519
|
-
const realignedAfter = [];
|
|
1520
|
-
const executor = createExecutor({
|
|
1521
|
-
queryInputs,
|
|
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
|
-
},
|
|
1531
|
-
});
|
|
1532
|
-
await executor.execute({ threadId: "thread_after_turn", turnId: "turn_after_turn", prompt: "edit the hero", history: history([]) }, activeControls());
|
|
1533
|
-
expect(realignedAfter).toEqual([1]);
|
|
1534
|
-
});
|
|
1535
|
-
it("does not move the workspace under a publish command", async () => {
|
|
1536
|
-
const queryInputs = [];
|
|
1537
|
-
const syncedThreads = [];
|
|
1538
|
-
const executor = createExecutor({
|
|
1539
|
-
queryInputs,
|
|
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
|
-
},
|
|
1549
|
-
});
|
|
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();
|
|
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)');
|
|
1568
1498
|
});
|
|
1569
|
-
|
|
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 () => {
|
|
1570
1503
|
const queryInputs = [];
|
|
1571
1504
|
const executor = createExecutor({
|
|
1572
|
-
loadedBoundary: cleanBoundary("session_stale_main"),
|
|
1573
1505
|
queryInputs,
|
|
1574
|
-
|
|
1575
|
-
scriptedError(new Error("Cannot resume: session not found")),
|
|
1576
|
-
scriptedMessages(resultMessage(0, "session_stale_main_next")),
|
|
1577
|
-
],
|
|
1578
|
-
mainSync: {
|
|
1579
|
-
beforeTurn: () => Promise.resolve({ recovered: "<system-reminder>\nBaker: standing\n</system-reminder>" }),
|
|
1580
|
-
refuseWrite: () => null,
|
|
1581
|
-
afterTurn: () => Promise.resolve(),
|
|
1582
|
-
},
|
|
1506
|
+
prepareAttachments: async (attachments, attachedTo) => `\n\nFILES for ${attachedTo}: ${attachments.map((attachment) => attachment.filename).join(", ")}`,
|
|
1583
1507
|
});
|
|
1584
1508
|
await executor.execute({
|
|
1585
|
-
threadId: "
|
|
1586
|
-
turnId: "
|
|
1587
|
-
prompt: "
|
|
1588
|
-
|
|
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 }]),
|
|
1589
1516
|
}, activeControls());
|
|
1590
|
-
expect(queryInputs[0]?.
|
|
1591
|
-
expect(queryInputs[1]?.reminder).toContain("Baker: standing");
|
|
1517
|
+
expect(queryInputs[0]?.prompt).toContain("FILES for continued_request: logo.png");
|
|
1592
1518
|
});
|
|
1593
|
-
it("
|
|
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;
|
|
1519
|
+
it("does not hand the continued request's files over again to a resumed Session", async () => {
|
|
1597
1520
|
const queryInputs = [];
|
|
1598
|
-
const syncs = [];
|
|
1599
1521
|
const executor = createExecutor({
|
|
1522
|
+
loadedBoundary: cleanBoundary("session_holds_files"),
|
|
1600
1523
|
queryInputs,
|
|
1601
|
-
|
|
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",
|
|
1524
|
+
prepareAttachments: async (attachments) => `\n\nFILES: ${attachments.map((attachment) => attachment.filename).join(", ")}`,
|
|
1646
1525
|
});
|
|
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" },
|
|
1532
|
+
],
|
|
1533
|
+
history: history([{ role: "user", text: "Build a page with my logo", createdAt: 1 }]),
|
|
1534
|
+
}, activeControls());
|
|
1535
|
+
expect(queryInputs[0]?.prompt).toBe("They have now replied: HubSpot");
|
|
1647
1536
|
});
|
|
1648
|
-
it("
|
|
1537
|
+
it("hands the continued request's files over when a stale Session falls back to history", async () => {
|
|
1649
1538
|
const queryInputs = [];
|
|
1650
1539
|
const executor = createExecutor({
|
|
1651
|
-
loadedBoundary: cleanBoundary("
|
|
1540
|
+
loadedBoundary: cleanBoundary("session_stale_files"),
|
|
1652
1541
|
queryInputs,
|
|
1653
1542
|
queryResults: [
|
|
1654
1543
|
scriptedError(new Error("Cannot resume: session not found")),
|
|
1655
|
-
scriptedMessages(resultMessage(0, "
|
|
1544
|
+
scriptedMessages(resultMessage(0, "session_rebuilt_files")),
|
|
1656
1545
|
],
|
|
1546
|
+
prepareAttachments: async (attachments) => `\n\nFILES: ${attachments.map((attachment) => attachment.filename).join(", ")}`,
|
|
1657
1547
|
});
|
|
1658
1548
|
await executor.execute({
|
|
1659
|
-
threadId: "
|
|
1660
|
-
turnId: "
|
|
1661
|
-
prompt: "
|
|
1662
|
-
|
|
1663
|
-
|
|
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 }]),
|
|
1664
1556
|
}, activeControls());
|
|
1665
|
-
expect(queryInputs[
|
|
1666
|
-
expect(queryInputs[1]?.prompt).
|
|
1667
|
-
expect(queryInputs[1]?.reminder).toContain('landing "Homepage" (slug: homepage)');
|
|
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");
|
|
1668
1559
|
});
|
|
1669
1560
|
it("clears an invalid clean boundary and retries once with rehydrated history", async () => {
|
|
1670
1561
|
const relay = recordingRelay();
|
|
@@ -3263,6 +3154,29 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
|
|
|
3263
3154
|
expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.2 });
|
|
3264
3155
|
expect(endInputAt.count).toBe(4);
|
|
3265
3156
|
});
|
|
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
|
+
});
|
|
3266
3180
|
it("ends the turn when the last task settles without waking the model into a second result", async () => {
|
|
3267
3181
|
// A settle is not guaranteed to produce another result. If only a result
|
|
3268
3182
|
// could arm the end, this shape would strand in `for await` until the
|
|
@@ -3278,6 +3192,120 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
|
|
|
3278
3192
|
expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.1 });
|
|
3279
3193
|
expect(endInputAt.count).toBe(3);
|
|
3280
3194
|
});
|
|
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
|
+
});
|
|
3281
3309
|
it("re-arms the settle window when a passive system event trails the result", async () => {
|
|
3282
3310
|
// Passive trailing events (files persisted, session state) must not cancel
|
|
3283
3311
|
// a scheduled end — only genuine model activity does. A cancel with no
|
|
@@ -3347,6 +3375,35 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
|
|
|
3347
3375
|
expect(outcome).toMatchObject({ kind: "completed", costUsd: 0.1 });
|
|
3348
3376
|
expect(endInputAt.count).toBe(3);
|
|
3349
3377
|
});
|
|
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
|
+
});
|
|
3350
3407
|
it("does not hold the turn open for ambient skip_transcript tasks", async () => {
|
|
3351
3408
|
const endInputAt = { count: -1 };
|
|
3352
3409
|
const executor = createExecutor({
|