@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.
- package/README.md +42 -31
- package/dist/hono/agent-session.d.ts +0 -1
- package/dist/hono/agent-session.d.ts.map +1 -1
- package/dist/hono/agent-session.js +2 -13
- package/dist/hono/agent-session.js.map +1 -1
- package/dist/hono/agent-session.test.js +6 -43
- 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 +3 -7
- package/dist/hono/agent-tasks.js.map +1 -1
- package/dist/hono/agent-tasks.test.js +0 -15
- package/dist/hono/agent-tasks.test.js.map +1 -1
- package/dist/hono/agent.d.ts.map +1 -1
- package/dist/hono/agent.js +11 -10
- package/dist/hono/agent.js.map +1 -1
- package/dist/hono/agent.test.js +9 -9
- package/dist/hono/avatar-casting.js +2 -2
- package/dist/hono/avatar-casting.js.map +1 -1
- package/dist/hono/bridge-job.d.ts +25 -20
- package/dist/hono/bridge-job.d.ts.map +1 -1
- package/dist/hono/bridge-job.js +164 -111
- package/dist/hono/bridge-job.js.map +1 -1
- package/dist/hono/bridge-job.test.js +295 -76
- package/dist/hono/bridge-job.test.js.map +1 -1
- package/dist/hono/convex.d.ts +1 -0
- package/dist/hono/convex.d.ts.map +1 -1
- package/dist/hono/convex.js +4 -0
- package/dist/hono/convex.js.map +1 -1
- package/dist/hono/convex.test.js +3 -3
- package/dist/hono/main-sync.d.ts +41 -0
- package/dist/hono/main-sync.d.ts.map +1 -0
- package/dist/hono/main-sync.js +390 -0
- package/dist/hono/main-sync.js.map +1 -0
- package/dist/hono/main-sync.test.d.ts +2 -0
- package/dist/hono/main-sync.test.d.ts.map +1 -0
- package/dist/hono/main-sync.test.js +266 -0
- package/dist/hono/main-sync.test.js.map +1 -0
- package/dist/hono/mcp-servers.d.ts +0 -2
- package/dist/hono/mcp-servers.d.ts.map +1 -1
- package/dist/hono/mcp-servers.js +16 -17
- package/dist/hono/mcp-servers.js.map +1 -1
- package/dist/hono/mcp-servers.test.js +38 -10
- package/dist/hono/mcp-servers.test.js.map +1 -1
- package/dist/hono/model-capabilities.d.ts.map +1 -1
- package/dist/hono/model-capabilities.js +19 -28
- package/dist/hono/model-capabilities.js.map +1 -1
- package/dist/hono/model-capabilities.test.js +15 -37
- package/dist/hono/model-capabilities.test.js.map +1 -1
- package/dist/hono/relay.d.ts +2 -1
- package/dist/hono/relay.d.ts.map +1 -1
- package/dist/hono/relay.js +3 -0
- package/dist/hono/relay.js.map +1 -1
- package/dist/hono/relay.test.js +22 -6
- package/dist/hono/relay.test.js.map +1 -1
- package/dist/hono/session-continuity.d.ts +1 -0
- package/dist/hono/session-continuity.d.ts.map +1 -1
- package/dist/hono/session-continuity.integration.test.js +4 -4
- package/dist/hono/session-continuity.js +5 -2
- package/dist/hono/session-continuity.js.map +1 -1
- package/dist/hono/tag-fields.js +1 -1
- package/dist/hono/tag-fields.js.map +1 -1
- package/dist/hono/tag-input.js +1 -1
- package/dist/hono/tag-input.js.map +1 -1
- package/dist/hono/turn-events.d.ts +1 -1
- package/dist/hono/turn-events.d.ts.map +1 -1
- package/dist/hono/turn-events.js.map +1 -1
- package/dist/hono/types.d.ts +6 -0
- package/dist/hono/types.d.ts.map +1 -1
- 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,
|
|
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
|
|
312
|
-
//
|
|
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-
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
1369
|
-
//
|
|
1370
|
-
//
|
|
1371
|
-
it("
|
|
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
|
-
|
|
1377
|
-
queryResults: [scriptedMessages(resultMessage(0, "session_no_settings"))],
|
|
1381
|
+
queryResults: [scriptedMessages(resultMessage(0, "session_style"))],
|
|
1378
1382
|
});
|
|
1379
|
-
await executor.execute({ threadId: "
|
|
1380
|
-
expect(queryInputs[0]?.options.settings).
|
|
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
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
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: "
|
|
1399
|
-
|
|
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
|
-
//
|
|
1933
|
-
//
|
|
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
|