claude-code-rust 0.12.0 → 0.12.2
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 +10 -6
- package/agent-sdk/README.md +1 -1
- package/agent-sdk/dist/bridge/account_metadata.js +44 -0
- package/agent-sdk/dist/bridge/available_commands.js +129 -0
- package/agent-sdk/dist/bridge/commands.js +58 -36
- package/agent-sdk/dist/bridge/error_classification.js +20 -7
- package/agent-sdk/dist/bridge/events.js +18 -0
- package/agent-sdk/dist/bridge/history.js +183 -11
- package/agent-sdk/dist/bridge/logger.js +3 -0
- package/agent-sdk/dist/bridge/mcp.js +49 -79
- package/agent-sdk/dist/bridge/mcp_metadata.js +369 -0
- package/agent-sdk/dist/bridge/message_handlers.js +401 -57
- package/agent-sdk/dist/bridge/model_metadata.js +228 -0
- package/agent-sdk/dist/bridge/session_lifecycle.js +197 -326
- package/agent-sdk/dist/bridge/state_parsing.js +7 -1
- package/agent-sdk/dist/bridge/task_links.js +34 -0
- package/agent-sdk/dist/bridge/tasks.js +862 -0
- package/agent-sdk/dist/bridge/tool_calls.js +88 -31
- package/agent-sdk/dist/bridge/tooling.js +1262 -32
- package/agent-sdk/dist/bridge.js +95 -43
- package/agent-sdk/dist/bridge.test.js +3680 -269
- package/package.json +2 -2
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { AsyncQueue, CACHE_SPLIT_POLICY, buildApiRetryUpdate, buildRateLimitUpdate, buildQueryOptions, canGenerateSessionTitle, generatePersistedSessionTitle, buildSessionMutationOptions, buildSessionListOptions, buildToolResultFields, createToolCall, handleTaskSystemMessage, handleSdkMessage, mapAvailableAgents, mapAvailableModels, mapSessionMessagesToUpdates, mapSdkSessions, agentSdkVersionCompatibilityError,
|
|
3
|
+
import { AsyncQueue, CACHE_SPLIT_POLICY, buildApiRetryUpdate, buildRateLimitUpdate, buildQueryOptions, canGenerateSessionTitle, generatePersistedSessionTitle, buildSessionMutationOptions, buildSessionListOptions, buildToolResultFields, createToolCall, applySessionAgent, applySessionEffort, emitAgentConfigOptionUpdate, emitEffortConfigOptionUpdate, handleTaskSystemMessage, handleSdkMessage, mapSdkAccountInfo, mapAvailableAgents, mapAvailableModels, mapSessionMessagesToUpdates, mapSdkSessions, agentSdkVersionCompatibilityError, looksLikeAuthRequired, normalizeToolResultText, parseFastModeState, parseRuntimeSessionState, parseRateLimitStatus, bridgeMcpConfigToSdk, mapMcpServerStatus, mapMcpServerStatusConfig, normalizeSettingsParseError, normalizeToolKind, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, staleMcpAuthCandidates, resolveInstalledAgentSdkVersion, unwrapToolUseResult, updateAvailableCommands, handleReloadPluginsCommand, } from "./bridge.js";
|
|
4
4
|
import { availableModesForSession, buildModeState, markModeUnavailableForSession, permissionModeFailureLooksUnsupported, refreshSupportedModesForSession, } from "./bridge/commands.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { handleMcpSetServersCommand } from "./bridge/mcp.js";
|
|
6
|
+
import { emitCurrentModelUpdate, handleUserDialogResponse, refreshCurrentModel, resolveCurrentModel, sessions, shouldInvalidateResolvedRuntimeModel, shouldEmitStartupAuthRequiredForAccount, } from "./bridge/session_lifecycle.js";
|
|
7
|
+
import { classifyTurnErrorKind } from "./bridge/error_classification.js";
|
|
8
|
+
import { emitToolCall, emitToolProgressUpdate, emitToolResultUpdate } from "./bridge/tool_calls.js";
|
|
9
|
+
import { linkTaskToolUse } from "./bridge/task_links.js";
|
|
7
10
|
import { requestAskUserQuestionAnswers } from "./bridge/user_interaction.js";
|
|
8
11
|
import { handleResultMessage } from "./bridge/message_handlers.js";
|
|
9
12
|
const BRIDGE_RUNTIME_PROCESS_NAME = process.platform === "win32" ? "claude-rs-bridge-node.exe" : "claude-rs-bridge-node";
|
|
@@ -28,9 +31,13 @@ function makeSessionState() {
|
|
|
28
31
|
connected: true,
|
|
29
32
|
connectEvent: "connected",
|
|
30
33
|
toolCalls: new Map(),
|
|
34
|
+
tasksById: new Map(),
|
|
35
|
+
taskOrder: [],
|
|
31
36
|
taskToolUseIds: new Map(),
|
|
37
|
+
taskIdsByToolUseId: new Map(),
|
|
32
38
|
pendingPermissions: new Map(),
|
|
33
39
|
pendingQuestions: new Map(),
|
|
40
|
+
pendingUserDialogs: new Map(),
|
|
34
41
|
pendingElicitations: new Map(),
|
|
35
42
|
mcpStatusRevalidatedAt: new Map(),
|
|
36
43
|
hiddenToolUseIds: new Set(),
|
|
@@ -115,16 +122,16 @@ function captureBridgeEvents(run) {
|
|
|
115
122
|
const writes = [];
|
|
116
123
|
const originalWrite = process.stdout.write;
|
|
117
124
|
process.stdout.write = (chunk) => {
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
const text = Buffer.isBuffer(chunk)
|
|
126
|
+
? chunk.toString("utf8")
|
|
127
|
+
: typeof chunk === "string"
|
|
128
|
+
? chunk
|
|
129
|
+
: String(chunk);
|
|
130
|
+
if (text.trimStart().startsWith("{")) {
|
|
131
|
+
writes.push(text);
|
|
132
|
+
return true;
|
|
120
133
|
}
|
|
121
|
-
|
|
122
|
-
writes.push(chunk.toString("utf8"));
|
|
123
|
-
}
|
|
124
|
-
else {
|
|
125
|
-
writes.push(String(chunk));
|
|
126
|
-
}
|
|
127
|
-
return true;
|
|
134
|
+
return originalWrite.call(process.stdout, chunk);
|
|
128
135
|
};
|
|
129
136
|
try {
|
|
130
137
|
run();
|
|
@@ -148,16 +155,16 @@ async function captureBridgeEventsAsync(run) {
|
|
|
148
155
|
const writes = [];
|
|
149
156
|
const originalWrite = process.stdout.write;
|
|
150
157
|
process.stdout.write = (chunk) => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
158
|
+
const text = Buffer.isBuffer(chunk)
|
|
159
|
+
? chunk.toString("utf8")
|
|
160
|
+
: typeof chunk === "string"
|
|
161
|
+
? chunk
|
|
162
|
+
: String(chunk);
|
|
163
|
+
if (text.trimStart().startsWith("{")) {
|
|
164
|
+
writes.push(text);
|
|
165
|
+
return true;
|
|
156
166
|
}
|
|
157
|
-
|
|
158
|
-
writes.push(String(chunk));
|
|
159
|
-
}
|
|
160
|
-
return true;
|
|
167
|
+
return originalWrite.call(process.stdout, chunk);
|
|
161
168
|
};
|
|
162
169
|
try {
|
|
163
170
|
await run();
|
|
@@ -288,6 +295,22 @@ test("parseCommandEnvelope validates mcp_set_servers command", () => {
|
|
|
288
295
|
headers: {
|
|
289
296
|
"X-Test": "1",
|
|
290
297
|
},
|
|
298
|
+
timeout: 5000,
|
|
299
|
+
always_load: true,
|
|
300
|
+
tools: [
|
|
301
|
+
{
|
|
302
|
+
name: "search",
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: "read",
|
|
306
|
+
permission_policy: "always_ask",
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: "write",
|
|
310
|
+
permission_policy: "always_deny",
|
|
311
|
+
org_max_permission: "ask",
|
|
312
|
+
},
|
|
313
|
+
],
|
|
291
314
|
},
|
|
292
315
|
},
|
|
293
316
|
}));
|
|
@@ -304,9 +327,245 @@ test("parseCommandEnvelope validates mcp_set_servers command", () => {
|
|
|
304
327
|
headers: {
|
|
305
328
|
"X-Test": "1",
|
|
306
329
|
},
|
|
330
|
+
timeout: 5000,
|
|
331
|
+
always_load: true,
|
|
332
|
+
tools: [
|
|
333
|
+
{
|
|
334
|
+
name: "search",
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: "read",
|
|
338
|
+
permission_policy: "always_ask",
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: "write",
|
|
342
|
+
permission_policy: "always_deny",
|
|
343
|
+
org_max_permission: "ask",
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
test("parseCommandEnvelope rejects invalid latest MCP config fields", () => {
|
|
350
|
+
assert.throws(() => parseCommandEnvelope(JSON.stringify({
|
|
351
|
+
command: "mcp_set_servers",
|
|
352
|
+
session_id: "session-123",
|
|
353
|
+
servers: {
|
|
354
|
+
bad: {
|
|
355
|
+
type: "http",
|
|
356
|
+
url: "https://mcp.example.com",
|
|
357
|
+
tools: [{ name: "read", org_max_permission: "deny" }],
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
})), /org_max_permission must be one of allow, ask, blocked/);
|
|
361
|
+
assert.throws(() => parseCommandEnvelope(JSON.stringify({
|
|
362
|
+
command: "mcp_set_servers",
|
|
363
|
+
session_id: "session-123",
|
|
364
|
+
servers: { bad: { type: "http", url: "https://mcp.example.com", timeout: 999 } },
|
|
365
|
+
})), /mcp_set_servers\.servers\.bad\.timeout must be an integer >= 1000/);
|
|
366
|
+
assert.throws(() => parseCommandEnvelope(JSON.stringify({
|
|
367
|
+
command: "mcp_set_servers",
|
|
368
|
+
session_id: "session-123",
|
|
369
|
+
servers: { bad: { type: "http", url: "https://mcp.example.com", always_load: "yes" } },
|
|
370
|
+
})), /mcp_set_servers\.servers\.bad\.always_load must be a boolean/);
|
|
371
|
+
assert.throws(() => parseCommandEnvelope(JSON.stringify({
|
|
372
|
+
command: "mcp_set_servers",
|
|
373
|
+
session_id: "session-123",
|
|
374
|
+
servers: {
|
|
375
|
+
bad: {
|
|
376
|
+
type: "http",
|
|
377
|
+
url: "https://mcp.example.com",
|
|
378
|
+
tools: [{ name: "read", permission_policy: "sometimes" }],
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
})), /permission_policy must be one of always_allow, always_ask, always_deny/);
|
|
382
|
+
assert.throws(() => parseCommandEnvelope(JSON.stringify({
|
|
383
|
+
command: "mcp_set_servers",
|
|
384
|
+
session_id: "session-123",
|
|
385
|
+
servers: {
|
|
386
|
+
bad: {
|
|
387
|
+
type: "stdio",
|
|
388
|
+
command: "npx",
|
|
389
|
+
tools: [{ name: "read", permission_policy: "always_allow" }],
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
})), /tools is only supported for http and sse MCP servers/);
|
|
393
|
+
});
|
|
394
|
+
test("handleMcpSetServersCommand emits SDK result", async () => {
|
|
395
|
+
const session = makeSessionState();
|
|
396
|
+
let receivedServers;
|
|
397
|
+
session.query = {
|
|
398
|
+
setMcpServers: async (servers) => {
|
|
399
|
+
receivedServers = servers;
|
|
400
|
+
return {
|
|
401
|
+
added: ["docs"],
|
|
402
|
+
removed: ["plugin:Notion:notion"],
|
|
403
|
+
errors: { docs: "connection failed" },
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
mcpServerStatus: async () => [
|
|
407
|
+
{
|
|
408
|
+
name: "docs",
|
|
409
|
+
status: "connected",
|
|
410
|
+
config: {
|
|
411
|
+
type: "http",
|
|
412
|
+
url: "https://example.test/mcp",
|
|
413
|
+
alwaysLoad: true,
|
|
414
|
+
},
|
|
415
|
+
tools: [
|
|
416
|
+
{
|
|
417
|
+
name: "read_resource",
|
|
418
|
+
description: "Read docs resources",
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
};
|
|
424
|
+
const events = await captureBridgeEventsAsync(async () => {
|
|
425
|
+
await handleMcpSetServersCommand(session, {
|
|
426
|
+
command: "mcp_set_servers",
|
|
427
|
+
session_id: "session-1",
|
|
428
|
+
servers: {
|
|
429
|
+
docs: {
|
|
430
|
+
type: "http",
|
|
431
|
+
url: "https://example.test/mcp",
|
|
432
|
+
always_load: true,
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
}, "req-mcp-set");
|
|
436
|
+
});
|
|
437
|
+
assert.deepEqual(receivedServers, {
|
|
438
|
+
docs: {
|
|
439
|
+
type: "http",
|
|
440
|
+
url: "https://example.test/mcp",
|
|
441
|
+
alwaysLoad: true,
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
assert.deepEqual(events, [
|
|
445
|
+
{
|
|
446
|
+
request_id: "req-mcp-set",
|
|
447
|
+
event: "mcp_set_servers_result",
|
|
448
|
+
session_id: "session-1",
|
|
449
|
+
result: {
|
|
450
|
+
added: ["docs"],
|
|
451
|
+
removed: ["plugin:Notion:notion"],
|
|
452
|
+
errors: { docs: "connection failed" },
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
request_id: "req-mcp-set",
|
|
457
|
+
event: "mcp_snapshot",
|
|
458
|
+
session_id: "session-1",
|
|
459
|
+
source: "mcp_set_servers",
|
|
460
|
+
servers: [
|
|
461
|
+
{
|
|
462
|
+
name: "docs",
|
|
463
|
+
status: "connected",
|
|
464
|
+
config: {
|
|
465
|
+
type: "http",
|
|
466
|
+
url: "https://example.test/mcp",
|
|
467
|
+
always_load: true,
|
|
468
|
+
},
|
|
469
|
+
tools: [
|
|
470
|
+
{
|
|
471
|
+
name: "read_resource",
|
|
472
|
+
description: "Read docs resources",
|
|
473
|
+
},
|
|
474
|
+
],
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
},
|
|
478
|
+
]);
|
|
479
|
+
});
|
|
480
|
+
test("handleMcpSetServersCommand emits MCP operation error on failure", async () => {
|
|
481
|
+
const session = makeSessionState();
|
|
482
|
+
session.query = {
|
|
483
|
+
setMcpServers: async () => {
|
|
484
|
+
throw new Error("dynamic update failed");
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
const events = await captureBridgeEventsAsync(async () => {
|
|
488
|
+
await handleMcpSetServersCommand(session, {
|
|
489
|
+
command: "mcp_set_servers",
|
|
490
|
+
session_id: "session-1",
|
|
491
|
+
servers: {},
|
|
492
|
+
}, "req-mcp-set");
|
|
493
|
+
});
|
|
494
|
+
assert.deepEqual(events, [
|
|
495
|
+
{
|
|
496
|
+
request_id: "req-mcp-set",
|
|
497
|
+
event: "mcp_operation_error",
|
|
498
|
+
session_id: "session-1",
|
|
499
|
+
error: {
|
|
500
|
+
operation: "set-servers",
|
|
501
|
+
message: "dynamic update failed",
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
request_id: "req-mcp-set",
|
|
506
|
+
event: "slash_error",
|
|
507
|
+
session_id: "session-1",
|
|
508
|
+
message: "failed to set MCP servers: dynamic update failed",
|
|
509
|
+
},
|
|
510
|
+
]);
|
|
511
|
+
});
|
|
512
|
+
test("bridgeMcpConfigToSdk maps latest MCP fields to SDK casing", () => {
|
|
513
|
+
assert.deepEqual(bridgeMcpConfigToSdk({
|
|
514
|
+
type: "sse",
|
|
515
|
+
url: "https://mcp.example.com/sse",
|
|
516
|
+
timeout: 2500,
|
|
517
|
+
always_load: true,
|
|
518
|
+
tools: [
|
|
519
|
+
{ name: "search" },
|
|
520
|
+
{ name: "write", permission_policy: "always_allow", org_max_permission: "blocked" },
|
|
521
|
+
],
|
|
522
|
+
}), {
|
|
523
|
+
type: "sse",
|
|
524
|
+
url: "https://mcp.example.com/sse",
|
|
525
|
+
timeout: 2500,
|
|
526
|
+
alwaysLoad: true,
|
|
527
|
+
tools: [
|
|
528
|
+
{ name: "search" },
|
|
529
|
+
{ name: "write", permission_policy: "always_allow", org_max_permission: "blocked" },
|
|
530
|
+
],
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
test("mapMcpServerStatus preserves latest MCP status config fields", () => {
|
|
534
|
+
const mapped = mapMcpServerStatus({
|
|
535
|
+
name: "notion",
|
|
536
|
+
status: "connected",
|
|
537
|
+
config: {
|
|
538
|
+
type: "http",
|
|
539
|
+
url: "https://mcp.notion.com/mcp",
|
|
540
|
+
headers: { Authorization: "Bearer token" },
|
|
541
|
+
timeout: 5000,
|
|
542
|
+
alwaysLoad: true,
|
|
543
|
+
tools: [
|
|
544
|
+
{ name: "search" },
|
|
545
|
+
{ name: "write", permission_policy: "always_deny", org_max_permission: "ask" },
|
|
546
|
+
],
|
|
307
547
|
},
|
|
548
|
+
tools: [],
|
|
549
|
+
});
|
|
550
|
+
assert.deepEqual(mapped.config, {
|
|
551
|
+
type: "http",
|
|
552
|
+
url: "https://mcp.notion.com/mcp",
|
|
553
|
+
headers: { Authorization: "Bearer token" },
|
|
554
|
+
timeout: 5000,
|
|
555
|
+
always_load: true,
|
|
556
|
+
tools: [
|
|
557
|
+
{ name: "search" },
|
|
558
|
+
{ name: "write", permission_policy: "always_deny", org_max_permission: "ask" },
|
|
559
|
+
],
|
|
308
560
|
});
|
|
309
561
|
});
|
|
562
|
+
test("mapMcpServerStatusConfig maps unknown config types without throwing", () => {
|
|
563
|
+
const mapped = mapMcpServerStatusConfig({
|
|
564
|
+
type: "future-transport",
|
|
565
|
+
url: "future://server",
|
|
566
|
+
});
|
|
567
|
+
assert.deepEqual(mapped, { type: "unknown", raw_type: "future-transport" });
|
|
568
|
+
});
|
|
310
569
|
test("parseCommandEnvelope validates reload_plugins command", () => {
|
|
311
570
|
const parsed = parseCommandEnvelope(JSON.stringify({
|
|
312
571
|
request_id: "req-reload",
|
|
@@ -319,6 +578,122 @@ test("parseCommandEnvelope validates reload_plugins command", () => {
|
|
|
319
578
|
session_id: "session-123",
|
|
320
579
|
});
|
|
321
580
|
});
|
|
581
|
+
test("handleReloadPluginsCommand emits MCP snapshot from reload result", async () => {
|
|
582
|
+
const session = makeSessionState();
|
|
583
|
+
let mcpServerStatusCalls = 0;
|
|
584
|
+
session.query = {
|
|
585
|
+
reloadPlugins: async () => ({
|
|
586
|
+
commands: [],
|
|
587
|
+
agents: [],
|
|
588
|
+
plugins: [],
|
|
589
|
+
mcpServers: [
|
|
590
|
+
{
|
|
591
|
+
name: "docs",
|
|
592
|
+
status: "connected",
|
|
593
|
+
config: {
|
|
594
|
+
type: "http",
|
|
595
|
+
url: "https://example.test/mcp",
|
|
596
|
+
},
|
|
597
|
+
tools: [],
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
error_count: 0,
|
|
601
|
+
}),
|
|
602
|
+
mcpServerStatus: async () => {
|
|
603
|
+
mcpServerStatusCalls += 1;
|
|
604
|
+
throw new Error("mcpServerStatus should not be called");
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
const events = await captureBridgeEventsAsync(async () => {
|
|
608
|
+
await handleReloadPluginsCommand(session, "req-reload");
|
|
609
|
+
});
|
|
610
|
+
assert.equal(mcpServerStatusCalls, 0);
|
|
611
|
+
assert.deepEqual(events.filter((event) => event.event === "mcp_snapshot"), [
|
|
612
|
+
{
|
|
613
|
+
request_id: "req-reload",
|
|
614
|
+
event: "mcp_snapshot",
|
|
615
|
+
session_id: "session-1",
|
|
616
|
+
source: "reload_plugins",
|
|
617
|
+
servers: [
|
|
618
|
+
{
|
|
619
|
+
name: "docs",
|
|
620
|
+
status: "connected",
|
|
621
|
+
config: {
|
|
622
|
+
type: "http",
|
|
623
|
+
url: "https://example.test/mcp",
|
|
624
|
+
},
|
|
625
|
+
tools: [],
|
|
626
|
+
},
|
|
627
|
+
],
|
|
628
|
+
},
|
|
629
|
+
]);
|
|
630
|
+
assert.deepEqual(events.filter((event) => event.event === "runtime_reload_completed"), [
|
|
631
|
+
{
|
|
632
|
+
request_id: "req-reload",
|
|
633
|
+
event: "runtime_reload_completed",
|
|
634
|
+
session_id: "session-1",
|
|
635
|
+
},
|
|
636
|
+
]);
|
|
637
|
+
});
|
|
638
|
+
test("handleReloadPluginsCommand revalidates stale MCP auth statuses", async () => {
|
|
639
|
+
const session = makeSessionState();
|
|
640
|
+
const serverName = "reload-auth-revalidation";
|
|
641
|
+
let reloadCalls = 0;
|
|
642
|
+
let mcpServerStatusCalls = 0;
|
|
643
|
+
const reconnectCalls = [];
|
|
644
|
+
const connectedServer = {
|
|
645
|
+
name: serverName,
|
|
646
|
+
status: "connected",
|
|
647
|
+
config: {
|
|
648
|
+
type: "http",
|
|
649
|
+
url: "https://example.test/mcp",
|
|
650
|
+
},
|
|
651
|
+
tools: [],
|
|
652
|
+
};
|
|
653
|
+
session.query = {
|
|
654
|
+
reloadPlugins: async () => {
|
|
655
|
+
reloadCalls += 1;
|
|
656
|
+
return {
|
|
657
|
+
commands: [],
|
|
658
|
+
agents: [],
|
|
659
|
+
plugins: [],
|
|
660
|
+
mcpServers: reloadCalls === 1
|
|
661
|
+
? [connectedServer]
|
|
662
|
+
: [
|
|
663
|
+
{
|
|
664
|
+
...connectedServer,
|
|
665
|
+
status: "needs-auth",
|
|
666
|
+
},
|
|
667
|
+
],
|
|
668
|
+
error_count: 0,
|
|
669
|
+
};
|
|
670
|
+
},
|
|
671
|
+
reconnectMcpServer: async (name) => {
|
|
672
|
+
reconnectCalls.push(name);
|
|
673
|
+
},
|
|
674
|
+
mcpServerStatus: async () => {
|
|
675
|
+
mcpServerStatusCalls += 1;
|
|
676
|
+
return [connectedServer];
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
await captureBridgeEventsAsync(async () => {
|
|
680
|
+
await handleReloadPluginsCommand(session, "req-reload-seed");
|
|
681
|
+
});
|
|
682
|
+
const events = await captureBridgeEventsAsync(async () => {
|
|
683
|
+
await handleReloadPluginsCommand(session, "req-reload");
|
|
684
|
+
});
|
|
685
|
+
assert.deepEqual(reconnectCalls, [serverName]);
|
|
686
|
+
assert.equal(mcpServerStatusCalls, 1);
|
|
687
|
+
assert.deepEqual(events.filter((event) => event.event === "mcp_snapshot"), [
|
|
688
|
+
{
|
|
689
|
+
request_id: "req-reload",
|
|
690
|
+
event: "mcp_snapshot",
|
|
691
|
+
session_id: "session-1",
|
|
692
|
+
source: "reload_plugins",
|
|
693
|
+
servers: [connectedServer],
|
|
694
|
+
},
|
|
695
|
+
]);
|
|
696
|
+
});
|
|
322
697
|
test("parseCommandEnvelope validates get_context_usage command", () => {
|
|
323
698
|
const parsed = parseCommandEnvelope(JSON.stringify({
|
|
324
699
|
request_id: "req-usage",
|
|
@@ -433,6 +808,8 @@ test("buildQueryOptions maps launch settings into sdk query options", () => {
|
|
|
433
808
|
preset: "claude_code",
|
|
434
809
|
append: `${BRIDGE_RUNTIME_GUARD_PROMPT} ${GERMAN_LANGUAGE_PROMPT}`,
|
|
435
810
|
});
|
|
811
|
+
const _systemPrompt = options.systemPrompt;
|
|
812
|
+
assert.ok(_systemPrompt);
|
|
436
813
|
assert.equal(options.model, "haiku");
|
|
437
814
|
assert.equal(options.permissionMode, "plan");
|
|
438
815
|
assert.equal("allowDangerouslySkipPermissions" in options, false);
|
|
@@ -563,6 +940,53 @@ test("buildQueryOptions omits optional startup overrides but keeps bridge guard
|
|
|
563
940
|
});
|
|
564
941
|
assert.equal("agentProgressSummaries" in options, false);
|
|
565
942
|
});
|
|
943
|
+
test("buildQueryOptions forwards SDK-provided spawn env without passing top-level env", async () => {
|
|
944
|
+
const input = new AsyncQueue();
|
|
945
|
+
const options = buildQueryOptions({
|
|
946
|
+
cwd: "C:/work",
|
|
947
|
+
launchSettings: {},
|
|
948
|
+
provisionalSessionId: "session-spawn-env",
|
|
949
|
+
input,
|
|
950
|
+
canUseTool: async () => ({ behavior: "deny", message: "not used" }),
|
|
951
|
+
enableSdkDebug: false,
|
|
952
|
+
enableSpawnDebug: false,
|
|
953
|
+
sessionIdForLogs: () => "session-spawn-env",
|
|
954
|
+
});
|
|
955
|
+
assert.equal("env" in options, false);
|
|
956
|
+
const previousParentOnly = process.env.PHASE10_PARENT_ONLY;
|
|
957
|
+
process.env.PHASE10_PARENT_ONLY = "must-not-leak";
|
|
958
|
+
try {
|
|
959
|
+
const child = options.spawnClaudeCodeProcess({
|
|
960
|
+
command: process.execPath,
|
|
961
|
+
args: [
|
|
962
|
+
"-e",
|
|
963
|
+
"process.stdout.write(JSON.stringify({check:process.env.PHASE10_ENV_CHECK??null,parent:process.env.PHASE10_PARENT_ONLY??null}))",
|
|
964
|
+
],
|
|
965
|
+
cwd: process.cwd(),
|
|
966
|
+
env: { PHASE10_ENV_CHECK: "forwarded" },
|
|
967
|
+
signal: new AbortController().signal,
|
|
968
|
+
});
|
|
969
|
+
let stdout = "";
|
|
970
|
+
child.stdout.setEncoding("utf8");
|
|
971
|
+
child.stdout.on("data", (chunk) => {
|
|
972
|
+
stdout += chunk;
|
|
973
|
+
});
|
|
974
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
975
|
+
child.on("error", reject);
|
|
976
|
+
child.on("exit", (code) => resolve(code));
|
|
977
|
+
});
|
|
978
|
+
assert.equal(exitCode, 0);
|
|
979
|
+
assert.deepEqual(JSON.parse(stdout), { check: "forwarded", parent: null });
|
|
980
|
+
}
|
|
981
|
+
finally {
|
|
982
|
+
if (previousParentOnly === undefined) {
|
|
983
|
+
delete process.env.PHASE10_PARENT_ONLY;
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
process.env.PHASE10_PARENT_ONLY = previousParentOnly;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
});
|
|
566
990
|
test("buildQueryOptions makes sandbox fallback explicit when enabled", () => {
|
|
567
991
|
const input = new AsyncQueue();
|
|
568
992
|
const options = buildQueryOptions({
|
|
@@ -683,7 +1107,7 @@ test("handleTaskSystemMessage falls back to description and last tool when progr
|
|
|
683
1107
|
},
|
|
684
1108
|
});
|
|
685
1109
|
});
|
|
686
|
-
test("handleTaskSystemMessage
|
|
1110
|
+
test("handleTaskSystemMessage keeps Agent completed notification provisional until tool result", () => {
|
|
687
1111
|
const session = makeSessionState();
|
|
688
1112
|
const events = captureBridgeEvents(() => {
|
|
689
1113
|
handleTaskSystemMessage(session, "task_started", {
|
|
@@ -710,7 +1134,6 @@ test("handleTaskSystemMessage final summary replaces prior task content and fina
|
|
|
710
1134
|
tool_call_update: {
|
|
711
1135
|
tool_call_id: "tool-1",
|
|
712
1136
|
fields: {
|
|
713
|
-
status: "completed",
|
|
714
1137
|
raw_output: "Found the auth bug and prepared the fix",
|
|
715
1138
|
content: [
|
|
716
1139
|
{
|
|
@@ -718,10 +1141,62 @@ test("handleTaskSystemMessage final summary replaces prior task content and fina
|
|
|
718
1141
|
content: { type: "text", text: "Found the auth bug and prepared the fix" },
|
|
719
1142
|
},
|
|
720
1143
|
],
|
|
1144
|
+
task_metadata: {
|
|
1145
|
+
summary: "Found the auth bug and prepared the fix",
|
|
1146
|
+
terminal_status: "completed",
|
|
1147
|
+
},
|
|
721
1148
|
},
|
|
722
1149
|
},
|
|
723
1150
|
});
|
|
1151
|
+
assert.equal(session.toolCalls.get("tool-1")?.status, "in_progress");
|
|
1152
|
+
assert.equal(session.taskToolUseIds.get("task-1"), "tool-1");
|
|
1153
|
+
assert.equal(session.taskIdsByToolUseId.get("tool-1"), "task-1");
|
|
1154
|
+
});
|
|
1155
|
+
test("emitToolResultUpdate finalizes deferred Agent completion and unlinks lifecycle task", () => {
|
|
1156
|
+
const session = makeSessionState();
|
|
1157
|
+
const events = captureBridgeEvents(() => {
|
|
1158
|
+
handleTaskSystemMessage(session, "task_started", {
|
|
1159
|
+
task_id: "task-1",
|
|
1160
|
+
tool_use_id: "tool-1",
|
|
1161
|
+
description: "Initial task description",
|
|
1162
|
+
});
|
|
1163
|
+
handleTaskSystemMessage(session, "task_notification", {
|
|
1164
|
+
task_id: "task-1",
|
|
1165
|
+
status: "completed",
|
|
1166
|
+
summary: "Found the auth bug and prepared the fix",
|
|
1167
|
+
});
|
|
1168
|
+
emitToolResultUpdate(session, "tool-1", false, {
|
|
1169
|
+
agentId: "agent-1",
|
|
1170
|
+
agentType: "general-purpose",
|
|
1171
|
+
resolvedModel: "claude-opus-4-8",
|
|
1172
|
+
content: [{ type: "text", text: "Done" }],
|
|
1173
|
+
status: "completed",
|
|
1174
|
+
prompt: "Review the branch",
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
1177
|
+
const lastEvent = events.at(-1);
|
|
1178
|
+
assert.ok(lastEvent);
|
|
1179
|
+
assert.equal(lastEvent.event, "session_update");
|
|
1180
|
+
const update = lastEvent.update;
|
|
1181
|
+
assert.equal(update.type, "tool_call_update");
|
|
1182
|
+
const toolCallUpdate = update.tool_call_update;
|
|
1183
|
+
const fields = toolCallUpdate.fields;
|
|
1184
|
+
assert.equal(toolCallUpdate.tool_call_id, "tool-1");
|
|
1185
|
+
assert.equal(fields.status, "completed");
|
|
1186
|
+
assert.deepEqual(fields.output_metadata, {
|
|
1187
|
+
agent: {
|
|
1188
|
+
resolved_model: "claude-opus-4-8",
|
|
1189
|
+
},
|
|
1190
|
+
});
|
|
1191
|
+
const toolCall = session.toolCalls.get("tool-1");
|
|
1192
|
+
assert.equal(toolCall?.status, "completed");
|
|
1193
|
+
assert.deepEqual(toolCall?.output_metadata, {
|
|
1194
|
+
agent: {
|
|
1195
|
+
resolved_model: "claude-opus-4-8",
|
|
1196
|
+
},
|
|
1197
|
+
});
|
|
724
1198
|
assert.equal(session.taskToolUseIds.has("task-1"), false);
|
|
1199
|
+
assert.equal(session.taskIdsByToolUseId.has("tool-1"), false);
|
|
725
1200
|
});
|
|
726
1201
|
test("handleTaskSystemMessage ignores lifecycle content for concrete output tools", () => {
|
|
727
1202
|
const session = makeSessionState();
|
|
@@ -878,66 +1353,387 @@ test("handleSdkMessage suppresses ToolSearch bridge events without denying SDK u
|
|
|
878
1353
|
assert.equal(session.hiddenToolUseIds.has("tool-search-1"), true);
|
|
879
1354
|
assert.equal(session.toolCalls.has("tool-search-1"), false);
|
|
880
1355
|
});
|
|
881
|
-
test("
|
|
1356
|
+
test("handleSdkMessage emits transcript retraction for model_refusal_fallback", () => {
|
|
882
1357
|
const session = makeSessionState();
|
|
883
1358
|
const events = captureBridgeEvents(() => {
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1359
|
+
handleSdkMessage(session, {
|
|
1360
|
+
type: "system",
|
|
1361
|
+
subtype: "model_refusal_fallback",
|
|
1362
|
+
trigger: "refusal",
|
|
1363
|
+
direction: "retry",
|
|
1364
|
+
original_model: "claude-opus-4-1",
|
|
1365
|
+
fallback_model: "claude-sonnet-4-5",
|
|
1366
|
+
request_id: "req-1",
|
|
1367
|
+
api_refusal_category: "cyber",
|
|
1368
|
+
api_refusal_explanation: "policy text",
|
|
1369
|
+
retracted_message_uuids: ["assistant-old", "assistant-old", "", 7],
|
|
1370
|
+
content: "Retried with fallback model",
|
|
1371
|
+
uuid: "fallback-notice",
|
|
1372
|
+
session_id: "session-1",
|
|
896
1373
|
});
|
|
897
1374
|
});
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
content: { type: "text", text: "Refining the migration plan" },
|
|
912
|
-
},
|
|
913
|
-
],
|
|
914
|
-
task_metadata: {
|
|
915
|
-
is_backgrounded: true,
|
|
916
|
-
},
|
|
917
|
-
},
|
|
1375
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
1376
|
+
{
|
|
1377
|
+
type: "transcript_retraction",
|
|
1378
|
+
message_uuids: ["assistant-old"],
|
|
1379
|
+
reason: "model_refusal_fallback",
|
|
1380
|
+
request_id: "req-1",
|
|
1381
|
+
trigger: "refusal",
|
|
1382
|
+
direction: "retry",
|
|
1383
|
+
original_model: "claude-opus-4-1",
|
|
1384
|
+
fallback_model: "claude-sonnet-4-5",
|
|
1385
|
+
api_refusal_category: "cyber",
|
|
1386
|
+
api_refusal_explanation: "policy text",
|
|
1387
|
+
content: "Retried with fallback model",
|
|
918
1388
|
},
|
|
919
|
-
|
|
1389
|
+
]);
|
|
1390
|
+
assert.equal(events.some((event) => event.update?.type === "system_notice_update"), false);
|
|
920
1391
|
});
|
|
921
|
-
test("
|
|
1392
|
+
test("handleSdkMessage emits tolerant transcript retraction for model_fallback", () => {
|
|
922
1393
|
const session = makeSessionState();
|
|
923
1394
|
const events = captureBridgeEvents(() => {
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
status: "killed",
|
|
933
|
-
error: "Task stopped by parent agent",
|
|
934
|
-
end_time: 1234,
|
|
935
|
-
total_paused_ms: 250,
|
|
936
|
-
},
|
|
1395
|
+
handleSdkMessage(session, {
|
|
1396
|
+
type: "system",
|
|
1397
|
+
subtype: "model_fallback",
|
|
1398
|
+
original_model: "claude-opus-4-1",
|
|
1399
|
+
fallback_model: "claude-sonnet-4-5",
|
|
1400
|
+
retracted_message_uuids: ["old-1", "old-2"],
|
|
1401
|
+
uuid: "fallback-notice",
|
|
1402
|
+
session_id: "session-1",
|
|
937
1403
|
});
|
|
938
1404
|
});
|
|
939
|
-
|
|
940
|
-
|
|
1405
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
1406
|
+
{
|
|
1407
|
+
type: "transcript_retraction",
|
|
1408
|
+
message_uuids: ["old-1", "old-2"],
|
|
1409
|
+
reason: "model_fallback",
|
|
1410
|
+
original_model: "claude-opus-4-1",
|
|
1411
|
+
fallback_model: "claude-sonnet-4-5",
|
|
1412
|
+
},
|
|
1413
|
+
]);
|
|
1414
|
+
});
|
|
1415
|
+
test("handleSdkMessage emits assistant supersedes before replacement content", () => {
|
|
1416
|
+
const session = makeSessionState();
|
|
1417
|
+
const events = captureBridgeEvents(() => {
|
|
1418
|
+
handleSdkMessage(session, {
|
|
1419
|
+
type: "assistant",
|
|
1420
|
+
uuid: "assistant-new",
|
|
1421
|
+
supersedes: ["assistant-old"],
|
|
1422
|
+
session_id: "session-1",
|
|
1423
|
+
message: {
|
|
1424
|
+
role: "assistant",
|
|
1425
|
+
content: [
|
|
1426
|
+
{ type: "tool_use", id: "tool-1", name: "Bash", input: { command: "echo ok" } },
|
|
1427
|
+
],
|
|
1428
|
+
},
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
1432
|
+
{
|
|
1433
|
+
type: "transcript_retraction",
|
|
1434
|
+
message_uuids: ["assistant-old"],
|
|
1435
|
+
reason: "assistant_supersedes",
|
|
1436
|
+
},
|
|
1437
|
+
{
|
|
1438
|
+
type: "tool_call",
|
|
1439
|
+
tool_call: {
|
|
1440
|
+
tool_call_id: "tool-1",
|
|
1441
|
+
title: "echo ok",
|
|
1442
|
+
kind: "execute",
|
|
1443
|
+
status: "in_progress",
|
|
1444
|
+
source_message_uuid: "assistant-new",
|
|
1445
|
+
content: [],
|
|
1446
|
+
raw_input: { command: "echo ok" },
|
|
1447
|
+
locations: [],
|
|
1448
|
+
meta: { claudeCode: { toolName: "Bash", parentToolUseId: null } },
|
|
1449
|
+
},
|
|
1450
|
+
},
|
|
1451
|
+
]);
|
|
1452
|
+
});
|
|
1453
|
+
test("handleSdkMessage propagates source UUIDs for stream text and tool results", () => {
|
|
1454
|
+
const session = makeSessionState();
|
|
1455
|
+
const events = captureBridgeEvents(() => {
|
|
1456
|
+
handleSdkMessage(session, {
|
|
1457
|
+
type: "stream_event",
|
|
1458
|
+
uuid: "assistant-stream",
|
|
1459
|
+
session_id: "session-1",
|
|
1460
|
+
event: {
|
|
1461
|
+
type: "content_block_delta",
|
|
1462
|
+
delta: { type: "text_delta", text: "partial" },
|
|
1463
|
+
},
|
|
1464
|
+
});
|
|
1465
|
+
handleSdkMessage(session, {
|
|
1466
|
+
type: "stream_event",
|
|
1467
|
+
uuid: "assistant-tool",
|
|
1468
|
+
session_id: "session-1",
|
|
1469
|
+
event: {
|
|
1470
|
+
type: "content_block_start",
|
|
1471
|
+
content_block: {
|
|
1472
|
+
type: "tool_use",
|
|
1473
|
+
id: "tool-1",
|
|
1474
|
+
name: "Bash",
|
|
1475
|
+
input: { command: "echo ok" },
|
|
1476
|
+
},
|
|
1477
|
+
},
|
|
1478
|
+
});
|
|
1479
|
+
handleSdkMessage(session, {
|
|
1480
|
+
type: "user",
|
|
1481
|
+
uuid: "user-result",
|
|
1482
|
+
session_id: "session-1",
|
|
1483
|
+
parent_tool_use_id: "tool-1",
|
|
1484
|
+
message: {
|
|
1485
|
+
role: "user",
|
|
1486
|
+
content: [
|
|
1487
|
+
{ type: "tool_result", tool_use_id: "tool-1", content: "ok", is_error: false },
|
|
1488
|
+
],
|
|
1489
|
+
},
|
|
1490
|
+
});
|
|
1491
|
+
});
|
|
1492
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
1493
|
+
{
|
|
1494
|
+
type: "agent_message_chunk",
|
|
1495
|
+
content: { type: "text", text: "partial" },
|
|
1496
|
+
source_message_uuid: "assistant-stream",
|
|
1497
|
+
},
|
|
1498
|
+
{
|
|
1499
|
+
type: "tool_call",
|
|
1500
|
+
tool_call: {
|
|
1501
|
+
tool_call_id: "tool-1",
|
|
1502
|
+
title: "echo ok",
|
|
1503
|
+
kind: "execute",
|
|
1504
|
+
status: "in_progress",
|
|
1505
|
+
source_message_uuid: "assistant-tool",
|
|
1506
|
+
content: [],
|
|
1507
|
+
raw_input: { command: "echo ok" },
|
|
1508
|
+
locations: [],
|
|
1509
|
+
meta: { claudeCode: { toolName: "Bash", parentToolUseId: null } },
|
|
1510
|
+
},
|
|
1511
|
+
},
|
|
1512
|
+
{
|
|
1513
|
+
type: "tool_call_update",
|
|
1514
|
+
tool_call_update: {
|
|
1515
|
+
tool_call_id: "tool-1",
|
|
1516
|
+
source_message_uuid: "user-result",
|
|
1517
|
+
fields: {
|
|
1518
|
+
status: "completed",
|
|
1519
|
+
raw_output: "ok",
|
|
1520
|
+
content: [{ type: "content", content: { type: "text", text: "ok" } }],
|
|
1521
|
+
},
|
|
1522
|
+
},
|
|
1523
|
+
},
|
|
1524
|
+
]);
|
|
1525
|
+
});
|
|
1526
|
+
test("handleSdkMessage refreshes Grep title when final assistant snapshot carries input", () => {
|
|
1527
|
+
const session = makeSessionState();
|
|
1528
|
+
const events = captureBridgeEvents(() => {
|
|
1529
|
+
handleSdkMessage(session, {
|
|
1530
|
+
type: "stream_event",
|
|
1531
|
+
uuid: "assistant-stream",
|
|
1532
|
+
session_id: "session-1",
|
|
1533
|
+
event: {
|
|
1534
|
+
type: "content_block_start",
|
|
1535
|
+
content_block: {
|
|
1536
|
+
type: "tool_use",
|
|
1537
|
+
id: "tool-grep",
|
|
1538
|
+
name: "Grep",
|
|
1539
|
+
input: {},
|
|
1540
|
+
},
|
|
1541
|
+
},
|
|
1542
|
+
});
|
|
1543
|
+
handleSdkMessage(session, {
|
|
1544
|
+
type: "assistant",
|
|
1545
|
+
uuid: "assistant-final",
|
|
1546
|
+
session_id: "session-1",
|
|
1547
|
+
message: {
|
|
1548
|
+
role: "assistant",
|
|
1549
|
+
content: [
|
|
1550
|
+
{
|
|
1551
|
+
type: "tool_use",
|
|
1552
|
+
id: "tool-grep",
|
|
1553
|
+
name: "Grep",
|
|
1554
|
+
input: { pattern: "<rare string>", output_mode: "content", "-n": true },
|
|
1555
|
+
},
|
|
1556
|
+
],
|
|
1557
|
+
},
|
|
1558
|
+
});
|
|
1559
|
+
});
|
|
1560
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
1561
|
+
{
|
|
1562
|
+
type: "tool_call",
|
|
1563
|
+
tool_call: {
|
|
1564
|
+
tool_call_id: "tool-grep",
|
|
1565
|
+
title: "Grep",
|
|
1566
|
+
kind: "search",
|
|
1567
|
+
status: "in_progress",
|
|
1568
|
+
source_message_uuid: "assistant-stream",
|
|
1569
|
+
content: [],
|
|
1570
|
+
raw_input: {},
|
|
1571
|
+
locations: [],
|
|
1572
|
+
meta: { claudeCode: { toolName: "Grep", parentToolUseId: null } },
|
|
1573
|
+
},
|
|
1574
|
+
},
|
|
1575
|
+
{
|
|
1576
|
+
type: "tool_call_update",
|
|
1577
|
+
tool_call_update: {
|
|
1578
|
+
tool_call_id: "tool-grep",
|
|
1579
|
+
source_message_uuid: "assistant-final",
|
|
1580
|
+
fields: {
|
|
1581
|
+
title: "Grep <rare string> (content)",
|
|
1582
|
+
kind: "search",
|
|
1583
|
+
status: "in_progress",
|
|
1584
|
+
raw_input: { pattern: "<rare string>", output_mode: "content", "-n": true },
|
|
1585
|
+
locations: [],
|
|
1586
|
+
meta: { claudeCode: { toolName: "Grep", parentToolUseId: null } },
|
|
1587
|
+
},
|
|
1588
|
+
},
|
|
1589
|
+
},
|
|
1590
|
+
]);
|
|
1591
|
+
});
|
|
1592
|
+
test("handleSdkMessage refreshes Agent title when final assistant snapshot carries input", () => {
|
|
1593
|
+
const session = makeSessionState();
|
|
1594
|
+
const events = captureBridgeEvents(() => {
|
|
1595
|
+
handleSdkMessage(session, {
|
|
1596
|
+
type: "stream_event",
|
|
1597
|
+
uuid: "assistant-stream",
|
|
1598
|
+
session_id: "session-1",
|
|
1599
|
+
event: {
|
|
1600
|
+
type: "content_block_start",
|
|
1601
|
+
content_block: {
|
|
1602
|
+
type: "tool_use",
|
|
1603
|
+
id: "tool-agent",
|
|
1604
|
+
name: "Agent",
|
|
1605
|
+
input: {},
|
|
1606
|
+
},
|
|
1607
|
+
},
|
|
1608
|
+
});
|
|
1609
|
+
handleSdkMessage(session, {
|
|
1610
|
+
type: "assistant",
|
|
1611
|
+
uuid: "assistant-final",
|
|
1612
|
+
session_id: "session-1",
|
|
1613
|
+
message: {
|
|
1614
|
+
role: "assistant",
|
|
1615
|
+
content: [
|
|
1616
|
+
{
|
|
1617
|
+
type: "tool_use",
|
|
1618
|
+
id: "tool-agent",
|
|
1619
|
+
name: "Agent",
|
|
1620
|
+
input: {
|
|
1621
|
+
description: "review changes",
|
|
1622
|
+
prompt: "Review the branch",
|
|
1623
|
+
name: "review-worker",
|
|
1624
|
+
subagent_type: "general-purpose",
|
|
1625
|
+
model: "opus",
|
|
1626
|
+
},
|
|
1627
|
+
},
|
|
1628
|
+
],
|
|
1629
|
+
},
|
|
1630
|
+
});
|
|
1631
|
+
});
|
|
1632
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
1633
|
+
{
|
|
1634
|
+
type: "tool_call",
|
|
1635
|
+
tool_call: {
|
|
1636
|
+
tool_call_id: "tool-agent",
|
|
1637
|
+
title: "Agent",
|
|
1638
|
+
kind: "think",
|
|
1639
|
+
status: "in_progress",
|
|
1640
|
+
source_message_uuid: "assistant-stream",
|
|
1641
|
+
content: [],
|
|
1642
|
+
raw_input: {},
|
|
1643
|
+
locations: [],
|
|
1644
|
+
meta: { claudeCode: { toolName: "Agent", parentToolUseId: null } },
|
|
1645
|
+
},
|
|
1646
|
+
},
|
|
1647
|
+
{
|
|
1648
|
+
type: "tool_call_update",
|
|
1649
|
+
tool_call_update: {
|
|
1650
|
+
tool_call_id: "tool-agent",
|
|
1651
|
+
source_message_uuid: "assistant-final",
|
|
1652
|
+
fields: {
|
|
1653
|
+
title: "Agent: review-worker",
|
|
1654
|
+
kind: "think",
|
|
1655
|
+
status: "in_progress",
|
|
1656
|
+
raw_input: {
|
|
1657
|
+
description: "review changes",
|
|
1658
|
+
prompt: "Review the branch",
|
|
1659
|
+
name: "review-worker",
|
|
1660
|
+
subagent_type: "general-purpose",
|
|
1661
|
+
model: "opus",
|
|
1662
|
+
},
|
|
1663
|
+
locations: [],
|
|
1664
|
+
meta: { claudeCode: { toolName: "Agent", parentToolUseId: null } },
|
|
1665
|
+
},
|
|
1666
|
+
},
|
|
1667
|
+
},
|
|
1668
|
+
]);
|
|
1669
|
+
assert.deepEqual(session.toolCalls.get("tool-agent")?.raw_input, {
|
|
1670
|
+
description: "review changes",
|
|
1671
|
+
prompt: "Review the branch",
|
|
1672
|
+
name: "review-worker",
|
|
1673
|
+
subagent_type: "general-purpose",
|
|
1674
|
+
model: "opus",
|
|
1675
|
+
});
|
|
1676
|
+
});
|
|
1677
|
+
test("handleTaskSystemMessage applies task_updated description patches to the linked task", () => {
|
|
1678
|
+
const session = makeSessionState();
|
|
1679
|
+
const events = captureBridgeEvents(() => {
|
|
1680
|
+
handleTaskSystemMessage(session, "task_started", {
|
|
1681
|
+
task_id: "task-1",
|
|
1682
|
+
tool_use_id: "tool-1",
|
|
1683
|
+
description: "Initial task description",
|
|
1684
|
+
});
|
|
1685
|
+
handleTaskSystemMessage(session, "task_updated", {
|
|
1686
|
+
task_id: "task-1",
|
|
1687
|
+
patch: {
|
|
1688
|
+
status: "running",
|
|
1689
|
+
description: "Refining the migration plan",
|
|
1690
|
+
is_backgrounded: true,
|
|
1691
|
+
},
|
|
1692
|
+
});
|
|
1693
|
+
});
|
|
1694
|
+
const lastEvent = events.at(-1);
|
|
1695
|
+
assert.ok(lastEvent);
|
|
1696
|
+
assert.equal(lastEvent.event, "session_update");
|
|
1697
|
+
assert.deepEqual(lastEvent.update, {
|
|
1698
|
+
type: "tool_call_update",
|
|
1699
|
+
tool_call_update: {
|
|
1700
|
+
tool_call_id: "tool-1",
|
|
1701
|
+
fields: {
|
|
1702
|
+
status: "in_progress",
|
|
1703
|
+
raw_output: "Refining the migration plan",
|
|
1704
|
+
content: [
|
|
1705
|
+
{
|
|
1706
|
+
type: "content",
|
|
1707
|
+
content: { type: "text", text: "Refining the migration plan" },
|
|
1708
|
+
},
|
|
1709
|
+
],
|
|
1710
|
+
task_metadata: {
|
|
1711
|
+
is_backgrounded: true,
|
|
1712
|
+
},
|
|
1713
|
+
},
|
|
1714
|
+
},
|
|
1715
|
+
});
|
|
1716
|
+
});
|
|
1717
|
+
test("handleTaskSystemMessage uses task_updated terminal error text when description is absent", () => {
|
|
1718
|
+
const session = makeSessionState();
|
|
1719
|
+
const events = captureBridgeEvents(() => {
|
|
1720
|
+
handleTaskSystemMessage(session, "task_started", {
|
|
1721
|
+
task_id: "task-1",
|
|
1722
|
+
tool_use_id: "tool-1",
|
|
1723
|
+
description: "Initial task description",
|
|
1724
|
+
});
|
|
1725
|
+
handleTaskSystemMessage(session, "task_updated", {
|
|
1726
|
+
task_id: "task-1",
|
|
1727
|
+
patch: {
|
|
1728
|
+
status: "killed",
|
|
1729
|
+
error: "Task stopped by parent agent",
|
|
1730
|
+
end_time: 1234,
|
|
1731
|
+
total_paused_ms: 250,
|
|
1732
|
+
},
|
|
1733
|
+
});
|
|
1734
|
+
});
|
|
1735
|
+
const lastEvent = events.at(-1);
|
|
1736
|
+
assert.ok(lastEvent);
|
|
941
1737
|
assert.equal(lastEvent.event, "session_update");
|
|
942
1738
|
assert.deepEqual(lastEvent.update, {
|
|
943
1739
|
type: "tool_call_update",
|
|
@@ -955,6 +1751,7 @@ test("handleTaskSystemMessage uses task_updated terminal error text when descrip
|
|
|
955
1751
|
task_metadata: {
|
|
956
1752
|
error: "Task stopped by parent agent",
|
|
957
1753
|
end_time: 1234,
|
|
1754
|
+
terminal_status: "killed",
|
|
958
1755
|
total_paused_ms: 250,
|
|
959
1756
|
},
|
|
960
1757
|
},
|
|
@@ -990,44 +1787,879 @@ test("handleTaskSystemMessage merges task metadata patches into the linked task
|
|
|
990
1787
|
end_time: 1234,
|
|
991
1788
|
});
|
|
992
1789
|
});
|
|
993
|
-
test("
|
|
1790
|
+
test("Monitor launch links task id and accepts task lifecycle updates", () => {
|
|
994
1791
|
const session = makeSessionState();
|
|
995
|
-
|
|
1792
|
+
captureBridgeEvents(() => {
|
|
1793
|
+
emitToolCall(session, "tool-monitor", "Monitor", {
|
|
1794
|
+
description: "watch deploy logs",
|
|
1795
|
+
timeout_ms: 30000,
|
|
1796
|
+
persistent: false,
|
|
1797
|
+
command: "tail -f deploy.log",
|
|
1798
|
+
});
|
|
1799
|
+
emitToolResultUpdate(session, "tool-monitor", false, {
|
|
1800
|
+
taskId: "monitor-1",
|
|
1801
|
+
timeoutMs: 30000,
|
|
1802
|
+
persistent: false,
|
|
1803
|
+
});
|
|
1804
|
+
});
|
|
1805
|
+
assert.equal(session.taskToolUseIds.get("monitor-1"), "tool-monitor");
|
|
1806
|
+
assert.equal(session.taskIdsByToolUseId.get("tool-monitor"), "monitor-1");
|
|
1807
|
+
assert.equal(session.toolCalls.get("tool-monitor")?.status, "in_progress");
|
|
1808
|
+
captureBridgeEvents(() => {
|
|
996
1809
|
handleTaskSystemMessage(session, "task_updated", {
|
|
997
|
-
task_id: "
|
|
1810
|
+
task_id: "monitor-1",
|
|
998
1811
|
patch: {
|
|
999
1812
|
status: "running",
|
|
1000
|
-
description: "
|
|
1813
|
+
description: "Monitor observed deploy log output",
|
|
1814
|
+
is_backgrounded: true,
|
|
1001
1815
|
},
|
|
1002
1816
|
});
|
|
1003
1817
|
});
|
|
1004
|
-
|
|
1818
|
+
const toolCall = session.toolCalls.get("tool-monitor");
|
|
1819
|
+
assert.equal(toolCall?.status, "in_progress");
|
|
1820
|
+
assert.equal(toolCall?.raw_output, "Monitor observed deploy log output");
|
|
1821
|
+
assert.equal(toolCall?.task_metadata?.is_backgrounded, true);
|
|
1822
|
+
assert.equal(session.taskToolUseIds.get("monitor-1"), "tool-monitor");
|
|
1823
|
+
assert.equal(session.taskIdsByToolUseId.get("tool-monitor"), "monitor-1");
|
|
1005
1824
|
});
|
|
1006
|
-
test("
|
|
1825
|
+
test("Monitor launch stays in progress after successful assistant turn until final lifecycle notification", () => {
|
|
1007
1826
|
const session = makeSessionState();
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1827
|
+
captureBridgeEvents(() => {
|
|
1828
|
+
emitToolCall(session, "tool-monitor", "Monitor", {
|
|
1829
|
+
description: "watch deploy logs",
|
|
1830
|
+
timeout_ms: 30000,
|
|
1831
|
+
persistent: false,
|
|
1832
|
+
command: "tail -f deploy.log",
|
|
1833
|
+
});
|
|
1834
|
+
emitToolResultUpdate(session, "tool-monitor", false, {
|
|
1835
|
+
taskId: "monitor-1",
|
|
1836
|
+
timeoutMs: 30000,
|
|
1837
|
+
persistent: false,
|
|
1838
|
+
});
|
|
1839
|
+
handleResultMessage(session, {
|
|
1840
|
+
type: "result",
|
|
1841
|
+
subtype: "success",
|
|
1842
|
+
terminal_reason: "completed",
|
|
1843
|
+
});
|
|
1016
1844
|
});
|
|
1845
|
+
assert.equal(session.toolCalls.get("tool-monitor")?.status, "in_progress");
|
|
1846
|
+
assert.equal(session.taskToolUseIds.get("monitor-1"), "tool-monitor");
|
|
1847
|
+
assert.equal(session.taskIdsByToolUseId.get("tool-monitor"), "monitor-1");
|
|
1848
|
+
captureBridgeEvents(() => {
|
|
1849
|
+
handleTaskSystemMessage(session, "task_notification", {
|
|
1850
|
+
task_id: "monitor-1",
|
|
1851
|
+
tool_use_id: "tool-monitor",
|
|
1852
|
+
status: "completed",
|
|
1853
|
+
output_file: "C:/tmp/monitor-1.output",
|
|
1854
|
+
summary: "Monitor completed",
|
|
1855
|
+
});
|
|
1856
|
+
});
|
|
1857
|
+
const toolCall = session.toolCalls.get("tool-monitor");
|
|
1858
|
+
assert.equal(toolCall?.status, "completed");
|
|
1859
|
+
assert.equal(toolCall?.raw_output, "Monitor completed");
|
|
1860
|
+
assert.equal(toolCall?.task_metadata?.output_file, "C:/tmp/monitor-1.output");
|
|
1861
|
+
assert.equal(session.taskToolUseIds.has("monitor-1"), false);
|
|
1862
|
+
assert.equal(session.taskIdsByToolUseId.has("tool-monitor"), false);
|
|
1863
|
+
});
|
|
1864
|
+
test("Workflow task notifications finish the linked root tool", () => {
|
|
1865
|
+
for (const [sdkStatus, expectedStatus] of [
|
|
1866
|
+
["completed", "completed"],
|
|
1867
|
+
["stopped", "killed"],
|
|
1868
|
+
["failed", "failed"],
|
|
1869
|
+
]) {
|
|
1870
|
+
const session = makeSessionState();
|
|
1871
|
+
captureBridgeEvents(() => {
|
|
1872
|
+
emitToolCall(session, `tool-workflow-${sdkStatus}`, "Workflow", {
|
|
1873
|
+
name: "spec",
|
|
1874
|
+
});
|
|
1875
|
+
emitToolResultUpdate(session, `tool-workflow-${sdkStatus}`, false, {
|
|
1876
|
+
status: "async_launched",
|
|
1877
|
+
taskId: `workflow-${sdkStatus}`,
|
|
1878
|
+
runId: `run-${sdkStatus}`,
|
|
1879
|
+
});
|
|
1880
|
+
handleTaskSystemMessage(session, "task_notification", {
|
|
1881
|
+
task_id: `workflow-${sdkStatus}`,
|
|
1882
|
+
status: sdkStatus,
|
|
1883
|
+
output_file: `C:/tmp/workflow-${sdkStatus}.output`,
|
|
1884
|
+
summary: `Workflow ${sdkStatus}`,
|
|
1885
|
+
});
|
|
1886
|
+
});
|
|
1887
|
+
const toolCall = session.toolCalls.get(`tool-workflow-${sdkStatus}`);
|
|
1888
|
+
assert.equal(toolCall?.status, expectedStatus);
|
|
1889
|
+
assert.equal(toolCall?.raw_output, `Workflow ${sdkStatus}`);
|
|
1890
|
+
assert.equal(toolCall?.task_metadata?.output_file, `C:/tmp/workflow-${sdkStatus}.output`);
|
|
1891
|
+
assert.equal(toolCall?.task_metadata?.summary, `Workflow ${sdkStatus}`);
|
|
1892
|
+
assert.equal(toolCall?.task_metadata?.terminal_status, sdkStatus);
|
|
1893
|
+
assert.equal(session.taskToolUseIds.has(`workflow-${sdkStatus}`), false);
|
|
1894
|
+
assert.equal(session.taskIdsByToolUseId.has(`tool-workflow-${sdkStatus}`), false);
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
test("TaskCreate output emits task state and links lifecycle task id", () => {
|
|
1898
|
+
const session = makeSessionState();
|
|
1017
1899
|
const events = captureBridgeEvents(() => {
|
|
1018
|
-
|
|
1900
|
+
emitToolCall(session, "tool-create", "TaskCreate", {
|
|
1901
|
+
subject: "Audit state",
|
|
1902
|
+
description: "Check task reducer",
|
|
1903
|
+
activeForm: "Auditing state",
|
|
1904
|
+
metadata: { phase: "6A" },
|
|
1905
|
+
});
|
|
1906
|
+
emitToolResultUpdate(session, "tool-create", false, {
|
|
1907
|
+
task: { id: "task-1", subject: "Audit state" },
|
|
1908
|
+
});
|
|
1019
1909
|
});
|
|
1020
|
-
|
|
1021
|
-
assert.equal(
|
|
1910
|
+
const updates = events.map((event) => event.update);
|
|
1911
|
+
assert.equal(updates.some((update) => update.type === "tool_call"), true);
|
|
1912
|
+
const toolResult = updates.find((update) => {
|
|
1913
|
+
const toolCallUpdate = update.tool_call_update;
|
|
1914
|
+
return toolCallUpdate?.tool_call_id === "tool-create";
|
|
1915
|
+
})?.tool_call_update;
|
|
1916
|
+
const resultFields = toolResult?.fields;
|
|
1917
|
+
assert.equal(resultFields?.status, "completed");
|
|
1918
|
+
assert.equal(Object.hasOwn(resultFields ?? {}, "content"), false);
|
|
1919
|
+
assert.equal(Object.hasOwn(resultFields ?? {}, "raw_output"), false);
|
|
1920
|
+
const taskUpdate = updates.find((update) => update.type === "task_state_update");
|
|
1921
|
+
assert.deepEqual(taskUpdate, {
|
|
1922
|
+
type: "task_state_update",
|
|
1923
|
+
source: "task_create",
|
|
1924
|
+
tasks: [
|
|
1925
|
+
{
|
|
1926
|
+
task_id: "task-1",
|
|
1927
|
+
subject: "Audit state",
|
|
1928
|
+
description: "Check task reducer",
|
|
1929
|
+
active_form: "Auditing state",
|
|
1930
|
+
status: "pending",
|
|
1931
|
+
blocks: [],
|
|
1932
|
+
blocked_by: [],
|
|
1933
|
+
metadata: { phase: "6A" },
|
|
1934
|
+
source_tool_call_id: "tool-create",
|
|
1935
|
+
},
|
|
1936
|
+
],
|
|
1937
|
+
removed_task_ids: [],
|
|
1938
|
+
is_complete_snapshot: false,
|
|
1939
|
+
});
|
|
1940
|
+
assert.equal(session.taskToolUseIds.get("task-1"), "tool-create");
|
|
1022
1941
|
});
|
|
1023
|
-
test("
|
|
1024
|
-
const
|
|
1025
|
-
const
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1942
|
+
test("TaskCreate transcript toolUseResult object emits typed task state", () => {
|
|
1943
|
+
const session = makeSessionState();
|
|
1944
|
+
const events = captureBridgeEvents(() => {
|
|
1945
|
+
handleSdkMessage(session, {
|
|
1946
|
+
type: "assistant",
|
|
1947
|
+
message: {
|
|
1948
|
+
role: "assistant",
|
|
1949
|
+
content: [
|
|
1950
|
+
{
|
|
1951
|
+
type: "tool_use",
|
|
1952
|
+
id: "tool-create-transcript",
|
|
1953
|
+
name: "TaskCreate",
|
|
1954
|
+
input: {
|
|
1955
|
+
subject: "Scaffold Next.js app",
|
|
1956
|
+
description: "Run create-next-app",
|
|
1957
|
+
activeForm: "Scaffolding Next.js app",
|
|
1958
|
+
},
|
|
1959
|
+
},
|
|
1960
|
+
],
|
|
1961
|
+
},
|
|
1962
|
+
uuid: "message-task-create",
|
|
1963
|
+
session_id: "session-1",
|
|
1964
|
+
});
|
|
1965
|
+
handleSdkMessage(session, {
|
|
1966
|
+
type: "user",
|
|
1967
|
+
message: {
|
|
1968
|
+
role: "user",
|
|
1969
|
+
content: [
|
|
1970
|
+
{
|
|
1971
|
+
type: "tool_result",
|
|
1972
|
+
tool_use_id: "tool-create-transcript",
|
|
1973
|
+
content: "Task #1 created successfully: Scaffold Next.js app",
|
|
1974
|
+
},
|
|
1975
|
+
],
|
|
1976
|
+
},
|
|
1977
|
+
toolUseResult: { task: { id: "1", subject: "Scaffold Next.js app" } },
|
|
1978
|
+
uuid: "message-task-create-result",
|
|
1979
|
+
session_id: "session-1",
|
|
1980
|
+
});
|
|
1981
|
+
});
|
|
1982
|
+
const updates = events.map((event) => event.update);
|
|
1983
|
+
const result = updates.find((update) => {
|
|
1984
|
+
const toolCallUpdate = update.tool_call_update;
|
|
1985
|
+
return toolCallUpdate?.tool_call_id === "tool-create-transcript";
|
|
1986
|
+
})?.tool_call_update;
|
|
1987
|
+
const resultFields = result?.fields;
|
|
1988
|
+
assert.equal(resultFields?.status, "completed");
|
|
1989
|
+
assert.equal(Object.hasOwn(resultFields ?? {}, "content"), false);
|
|
1990
|
+
assert.equal(Object.hasOwn(resultFields ?? {}, "raw_output"), false);
|
|
1991
|
+
assert.deepEqual(updates.find((update) => update.type === "task_state_update"), {
|
|
1992
|
+
type: "task_state_update",
|
|
1993
|
+
source: "task_create",
|
|
1994
|
+
tasks: [
|
|
1995
|
+
{
|
|
1996
|
+
task_id: "1",
|
|
1997
|
+
subject: "Scaffold Next.js app",
|
|
1998
|
+
description: "Run create-next-app",
|
|
1999
|
+
active_form: "Scaffolding Next.js app",
|
|
2000
|
+
status: "pending",
|
|
2001
|
+
blocks: [],
|
|
2002
|
+
blocked_by: [],
|
|
2003
|
+
source_tool_call_id: "tool-create-transcript",
|
|
2004
|
+
},
|
|
2005
|
+
],
|
|
2006
|
+
removed_task_ids: [],
|
|
2007
|
+
is_complete_snapshot: false,
|
|
2008
|
+
});
|
|
2009
|
+
});
|
|
2010
|
+
test("TodoWrite tool use remains generic and emits no plan or task state", () => {
|
|
2011
|
+
const session = makeSessionState();
|
|
2012
|
+
const events = captureBridgeEvents(() => {
|
|
2013
|
+
handleSdkMessage(session, {
|
|
2014
|
+
type: "assistant",
|
|
2015
|
+
message: {
|
|
2016
|
+
role: "assistant",
|
|
2017
|
+
content: [
|
|
2018
|
+
{
|
|
2019
|
+
type: "tool_use",
|
|
2020
|
+
id: "tool-todo",
|
|
2021
|
+
name: "TodoWrite",
|
|
2022
|
+
input: {
|
|
2023
|
+
todos: [
|
|
2024
|
+
{
|
|
2025
|
+
content: "Legacy todo",
|
|
2026
|
+
status: "in_progress",
|
|
2027
|
+
activeForm: "Working legacy todo",
|
|
2028
|
+
},
|
|
2029
|
+
],
|
|
2030
|
+
},
|
|
2031
|
+
},
|
|
2032
|
+
],
|
|
2033
|
+
},
|
|
2034
|
+
uuid: "message-todo",
|
|
2035
|
+
session_id: "session-1",
|
|
2036
|
+
});
|
|
2037
|
+
});
|
|
2038
|
+
const updates = events.map((event) => event.update);
|
|
2039
|
+
assert.equal(updates.some((update) => update.type === "tool_call"), true);
|
|
2040
|
+
assert.equal(updates.some((update) => update.type === "plan"), false);
|
|
2041
|
+
assert.equal(updates.some((update) => update.type === "task_state_update"), false);
|
|
2042
|
+
assert.equal(session.tasksById.size, 0);
|
|
2043
|
+
});
|
|
2044
|
+
test("TaskUpdate success patches one task by task id", () => {
|
|
2045
|
+
const session = makeSessionState();
|
|
2046
|
+
session.tasksById.set("task-1", {
|
|
2047
|
+
task_id: "task-1",
|
|
2048
|
+
subject: "Old",
|
|
2049
|
+
status: "pending",
|
|
2050
|
+
blocks: [],
|
|
2051
|
+
blocked_by: [],
|
|
2052
|
+
});
|
|
2053
|
+
session.taskOrder.push("task-1");
|
|
2054
|
+
const events = captureBridgeEvents(() => {
|
|
2055
|
+
emitToolCall(session, "tool-update", "TaskUpdate", {
|
|
2056
|
+
taskId: "task-1",
|
|
2057
|
+
subject: "New",
|
|
2058
|
+
status: "in_progress",
|
|
2059
|
+
addBlocks: ["task-2"],
|
|
2060
|
+
metadata: { mode: "patch" },
|
|
2061
|
+
});
|
|
2062
|
+
emitToolResultUpdate(session, "tool-update", false, {
|
|
2063
|
+
success: true,
|
|
2064
|
+
taskId: "task-1",
|
|
2065
|
+
updatedFields: ["subject", "status", "addBlocks", "metadata"],
|
|
2066
|
+
});
|
|
2067
|
+
});
|
|
2068
|
+
const updates = events.map((event) => event.update);
|
|
2069
|
+
const result = updates.find((update) => {
|
|
2070
|
+
const toolCallUpdate = update.tool_call_update;
|
|
2071
|
+
return toolCallUpdate?.tool_call_id === "tool-update";
|
|
2072
|
+
})?.tool_call_update;
|
|
2073
|
+
const resultFields = result?.fields;
|
|
2074
|
+
assert.equal(resultFields?.status, "completed");
|
|
2075
|
+
assert.equal(Object.hasOwn(resultFields ?? {}, "content"), false);
|
|
2076
|
+
assert.equal(Object.hasOwn(resultFields ?? {}, "raw_output"), false);
|
|
2077
|
+
const taskUpdate = events
|
|
2078
|
+
.map((event) => event.update)
|
|
2079
|
+
.find((update) => update.type === "task_state_update");
|
|
2080
|
+
assert.deepEqual(taskUpdate, {
|
|
2081
|
+
type: "task_state_update",
|
|
2082
|
+
source: "task_update",
|
|
2083
|
+
tasks: [
|
|
2084
|
+
{
|
|
2085
|
+
task_id: "task-1",
|
|
2086
|
+
subject: "New",
|
|
2087
|
+
status: "in_progress",
|
|
2088
|
+
blocks: ["task-2"],
|
|
2089
|
+
blocked_by: [],
|
|
2090
|
+
metadata: { mode: "patch" },
|
|
2091
|
+
},
|
|
2092
|
+
],
|
|
2093
|
+
removed_task_ids: [],
|
|
2094
|
+
is_complete_snapshot: false,
|
|
2095
|
+
});
|
|
2096
|
+
});
|
|
2097
|
+
test("TaskUpdate title uses known subject when input only has task id", () => {
|
|
2098
|
+
const session = makeSessionState();
|
|
2099
|
+
session.tasksById.set("task-1", {
|
|
2100
|
+
task_id: "task-1",
|
|
2101
|
+
subject: "Scaffold Next.js app via create-next-app CLI",
|
|
2102
|
+
status: "pending",
|
|
2103
|
+
blocks: [],
|
|
2104
|
+
blocked_by: [],
|
|
2105
|
+
});
|
|
2106
|
+
session.taskOrder.push("task-1");
|
|
2107
|
+
const events = captureBridgeEvents(() => {
|
|
2108
|
+
emitToolCall(session, "tool-update-title", "TaskUpdate", {
|
|
2109
|
+
taskId: "task-1",
|
|
2110
|
+
status: "in_progress",
|
|
2111
|
+
});
|
|
2112
|
+
});
|
|
2113
|
+
const toolCall = events
|
|
2114
|
+
.map((event) => event.update)
|
|
2115
|
+
.find((update) => update.type === "tool_call")?.tool_call;
|
|
2116
|
+
assert.equal(toolCall?.title, "Update task: Scaffold Next.js app via create-next-app CLI");
|
|
2117
|
+
});
|
|
2118
|
+
test("TaskOutput renders structured fields and deduplicates XML content without mutating task state", () => {
|
|
2119
|
+
const session = makeSessionState();
|
|
2120
|
+
session.tasksById.set("task-1", {
|
|
2121
|
+
task_id: "task-1",
|
|
2122
|
+
subject: "Watch build",
|
|
2123
|
+
status: "in_progress",
|
|
2124
|
+
blocks: [],
|
|
2125
|
+
blocked_by: [],
|
|
2126
|
+
});
|
|
2127
|
+
session.taskOrder.push("task-1");
|
|
2128
|
+
const events = captureBridgeEvents(() => {
|
|
2129
|
+
emitToolCall(session, "tool-output", "TaskOutput", {
|
|
2130
|
+
task_id: "task-1",
|
|
2131
|
+
block: true,
|
|
2132
|
+
timeout: 1000,
|
|
2133
|
+
});
|
|
2134
|
+
emitToolResultUpdate(session, "tool-output", false, "<retrieval_status>not_ready</retrieval_status>\n\n<task_id>task-1</task_id>\n\n<task_type>local_bash</task_type>\n\n<status>running</status>", {
|
|
2135
|
+
retrieval_status: "not_ready",
|
|
2136
|
+
task: {
|
|
2137
|
+
task_id: "task-1",
|
|
2138
|
+
task_type: "local_bash",
|
|
2139
|
+
status: "running",
|
|
2140
|
+
description: "Run a ticking loop in the background",
|
|
2141
|
+
output: "",
|
|
2142
|
+
exitCode: null,
|
|
2143
|
+
},
|
|
2144
|
+
});
|
|
2145
|
+
});
|
|
2146
|
+
const updates = events.map((event) => event.update);
|
|
2147
|
+
const toolCall = updates.find((update) => update.type === "tool_call")?.tool_call;
|
|
2148
|
+
assert.equal(toolCall?.kind, "other");
|
|
2149
|
+
assert.equal(toolCall?.title, "Task output: Watch build");
|
|
2150
|
+
const result = updates.find((update) => {
|
|
2151
|
+
const toolCallUpdate = update.tool_call_update;
|
|
2152
|
+
return toolCallUpdate?.tool_call_id === "tool-output";
|
|
2153
|
+
})?.tool_call_update;
|
|
2154
|
+
const fields = result?.fields;
|
|
2155
|
+
assert.equal(fields?.status, "completed");
|
|
2156
|
+
assert.equal(Object.hasOwn(fields ?? {}, "raw_output"), false);
|
|
2157
|
+
assert.deepEqual(fields?.content, [
|
|
2158
|
+
{
|
|
2159
|
+
type: "content",
|
|
2160
|
+
content: {
|
|
2161
|
+
type: "text",
|
|
2162
|
+
text: "Retrieval status: not ready\nTask type: local bash\nStatus: running\nDescription: Run a ticking loop in the background",
|
|
2163
|
+
},
|
|
2164
|
+
},
|
|
2165
|
+
]);
|
|
2166
|
+
const text = (fields?.content?.[0]?.content?.text ?? "");
|
|
2167
|
+
assert.equal(text.includes("<retrieval_status>"), false);
|
|
2168
|
+
assert.equal(text.includes("Task ID: task-1"), false);
|
|
2169
|
+
assert.equal(updates.some((update) => update.type === "task_state_update"), false);
|
|
2170
|
+
assert.equal(session.tasksById.get("task-1")?.status, "in_progress");
|
|
2171
|
+
});
|
|
2172
|
+
test("TaskOutput parses XML leaf fields when structured result is unavailable", () => {
|
|
2173
|
+
const session = makeSessionState();
|
|
2174
|
+
const events = captureBridgeEvents(() => {
|
|
2175
|
+
emitToolCall(session, "tool-output-xml", "TaskOutput", {
|
|
2176
|
+
task_id: "task-xml",
|
|
2177
|
+
block: false,
|
|
2178
|
+
timeout: 4000,
|
|
2179
|
+
});
|
|
2180
|
+
emitToolResultUpdate(session, "tool-output-xml", false, "<retrieval_status>not_ready</retrieval_status>\n\n<task_id>task-xml</task_id>\n\n<task_type>local_bash</task_type>\n\n<status>running</status>");
|
|
2181
|
+
});
|
|
2182
|
+
const result = events
|
|
2183
|
+
.map((event) => event.update)
|
|
2184
|
+
.find((update) => {
|
|
2185
|
+
const toolCallUpdate = update.tool_call_update;
|
|
2186
|
+
return toolCallUpdate?.tool_call_id === "tool-output-xml";
|
|
2187
|
+
})?.tool_call_update;
|
|
2188
|
+
const fields = result?.fields;
|
|
2189
|
+
assert.equal(fields?.status, "completed");
|
|
2190
|
+
assert.equal(Object.hasOwn(fields ?? {}, "raw_output"), false);
|
|
2191
|
+
assert.deepEqual(fields?.content, [
|
|
2192
|
+
{
|
|
2193
|
+
type: "content",
|
|
2194
|
+
content: {
|
|
2195
|
+
type: "text",
|
|
2196
|
+
text: "Retrieval status: not ready\nTask type: local bash\nStatus: running",
|
|
2197
|
+
},
|
|
2198
|
+
},
|
|
2199
|
+
]);
|
|
2200
|
+
});
|
|
2201
|
+
test("TaskStop renders structured output and marks the task terminal", () => {
|
|
2202
|
+
const session = makeSessionState();
|
|
2203
|
+
session.tasksById.set("task-1", {
|
|
2204
|
+
task_id: "task-1",
|
|
2205
|
+
subject: "Watch build",
|
|
2206
|
+
status: "in_progress",
|
|
2207
|
+
blocks: [],
|
|
2208
|
+
blocked_by: [],
|
|
2209
|
+
});
|
|
2210
|
+
session.taskOrder.push("task-1");
|
|
2211
|
+
linkTaskToolUse(session, "task-1", "tool-agent");
|
|
2212
|
+
const events = captureBridgeEvents(() => {
|
|
2213
|
+
emitToolCall(session, "tool-stop", "TaskStop", {
|
|
2214
|
+
task_id: "task-1",
|
|
2215
|
+
});
|
|
2216
|
+
emitToolResultUpdate(session, "tool-stop", false, {
|
|
2217
|
+
message: "Stopped task",
|
|
2218
|
+
task_id: "task-1",
|
|
2219
|
+
task_type: "bash",
|
|
2220
|
+
command: "npm run watch",
|
|
2221
|
+
});
|
|
2222
|
+
});
|
|
2223
|
+
const updates = events.map((event) => event.update);
|
|
2224
|
+
const toolCall = updates.find((update) => update.type === "tool_call")?.tool_call;
|
|
2225
|
+
assert.equal(toolCall?.kind, "other");
|
|
2226
|
+
assert.equal(toolCall?.title, "Stop task: Watch build");
|
|
2227
|
+
const result = updates.find((update) => {
|
|
2228
|
+
const toolCallUpdate = update.tool_call_update;
|
|
2229
|
+
return toolCallUpdate?.tool_call_id === "tool-stop";
|
|
2230
|
+
})?.tool_call_update;
|
|
2231
|
+
const fields = result?.fields;
|
|
2232
|
+
assert.equal(fields?.status, "completed");
|
|
2233
|
+
assert.equal(Object.hasOwn(fields ?? {}, "raw_output"), false);
|
|
2234
|
+
assert.deepEqual(fields?.content, [
|
|
2235
|
+
{
|
|
2236
|
+
type: "content",
|
|
2237
|
+
content: {
|
|
2238
|
+
type: "text",
|
|
2239
|
+
text: "Message: Stopped task\nTask ID: task-1\nTask type: bash\nCommand: npm run watch",
|
|
2240
|
+
},
|
|
2241
|
+
},
|
|
2242
|
+
]);
|
|
2243
|
+
assert.deepEqual(updates.find((update) => update.type === "task_state_update"), {
|
|
2244
|
+
type: "task_state_update",
|
|
2245
|
+
source: "task_lifecycle",
|
|
2246
|
+
tasks: [
|
|
2247
|
+
{
|
|
2248
|
+
task_id: "task-1",
|
|
2249
|
+
subject: "Watch build",
|
|
2250
|
+
status: "completed",
|
|
2251
|
+
blocks: [],
|
|
2252
|
+
blocked_by: [],
|
|
2253
|
+
metadata: {
|
|
2254
|
+
terminal_status: "stopped",
|
|
2255
|
+
task_type: "bash",
|
|
2256
|
+
command: "npm run watch",
|
|
2257
|
+
},
|
|
2258
|
+
source_tool_call_id: "tool-agent",
|
|
2259
|
+
},
|
|
2260
|
+
],
|
|
2261
|
+
removed_task_ids: [],
|
|
2262
|
+
is_complete_snapshot: false,
|
|
2263
|
+
});
|
|
2264
|
+
assert.equal(session.taskToolUseIds.has("task-1"), false);
|
|
2265
|
+
});
|
|
2266
|
+
test("TaskStop result for an already-gone task does not create stale task state", () => {
|
|
2267
|
+
const session = makeSessionState();
|
|
2268
|
+
const events = captureBridgeEvents(() => {
|
|
2269
|
+
emitToolCall(session, "tool-stop-missing", "TaskStop", {
|
|
2270
|
+
task_id: "task-missing",
|
|
2271
|
+
});
|
|
2272
|
+
emitToolResultUpdate(session, "tool-stop-missing", false, {
|
|
2273
|
+
message: "Task was already stopped",
|
|
2274
|
+
task_id: "task-missing",
|
|
2275
|
+
task_type: "bash",
|
|
2276
|
+
command: "npm run watch",
|
|
2277
|
+
});
|
|
2278
|
+
});
|
|
2279
|
+
const updates = events.map((event) => event.update);
|
|
2280
|
+
const result = updates.find((update) => {
|
|
2281
|
+
const toolCallUpdate = update.tool_call_update;
|
|
2282
|
+
return toolCallUpdate?.tool_call_id === "tool-stop-missing";
|
|
2283
|
+
})?.tool_call_update;
|
|
2284
|
+
const fields = result?.fields;
|
|
2285
|
+
assert.equal(fields?.status, "completed");
|
|
2286
|
+
assert.equal(updates.some((update) => update.type === "task_state_update"), false);
|
|
2287
|
+
assert.equal(session.tasksById.has("task-missing"), false);
|
|
2288
|
+
});
|
|
2289
|
+
test("TaskUpdate in-progress result leaves activity rendering to app task state", () => {
|
|
2290
|
+
const session = makeSessionState();
|
|
2291
|
+
session.tasksById.set("task-1", {
|
|
2292
|
+
task_id: "task-1",
|
|
2293
|
+
subject: "Scaffold Next.js app via create-next-app CLI",
|
|
2294
|
+
active_form: "Scaffolding Next.js app",
|
|
2295
|
+
status: "pending",
|
|
2296
|
+
blocks: [],
|
|
2297
|
+
blocked_by: [],
|
|
2298
|
+
});
|
|
2299
|
+
session.taskOrder.push("task-1");
|
|
2300
|
+
const events = captureBridgeEvents(() => {
|
|
2301
|
+
emitToolCall(session, "tool-activity", "TaskUpdate", {
|
|
2302
|
+
taskId: "task-1",
|
|
2303
|
+
status: "in_progress",
|
|
2304
|
+
});
|
|
2305
|
+
emitToolResultUpdate(session, "tool-activity", false, {
|
|
2306
|
+
success: true,
|
|
2307
|
+
taskId: "task-1",
|
|
2308
|
+
updatedFields: ["status"],
|
|
2309
|
+
});
|
|
2310
|
+
});
|
|
2311
|
+
const updates = events.map((event) => event.update);
|
|
2312
|
+
const result = updates.find((update) => {
|
|
2313
|
+
const toolCallUpdate = update.tool_call_update;
|
|
2314
|
+
return toolCallUpdate?.tool_call_id === "tool-activity";
|
|
2315
|
+
})?.tool_call_update;
|
|
2316
|
+
const fields = result?.fields;
|
|
2317
|
+
assert.equal(fields?.status, "completed");
|
|
2318
|
+
assert.equal(Object.hasOwn(fields ?? {}, "raw_output"), false);
|
|
2319
|
+
assert.equal(Object.hasOwn(fields ?? {}, "content"), false);
|
|
2320
|
+
const taskUpdate = updates.find((update) => update.type === "task_state_update");
|
|
2321
|
+
assert.deepEqual(taskUpdate, {
|
|
2322
|
+
type: "task_state_update",
|
|
2323
|
+
source: "task_update",
|
|
2324
|
+
tasks: [
|
|
2325
|
+
{
|
|
2326
|
+
task_id: "task-1",
|
|
2327
|
+
subject: "Scaffold Next.js app via create-next-app CLI",
|
|
2328
|
+
active_form: "Scaffolding Next.js app",
|
|
2329
|
+
status: "in_progress",
|
|
2330
|
+
blocks: [],
|
|
2331
|
+
blocked_by: [],
|
|
2332
|
+
},
|
|
2333
|
+
],
|
|
2334
|
+
removed_task_ids: [],
|
|
2335
|
+
is_complete_snapshot: false,
|
|
2336
|
+
});
|
|
2337
|
+
});
|
|
2338
|
+
test("TaskUpdate in-progress result omits activity when none is known", () => {
|
|
2339
|
+
const session = makeSessionState();
|
|
2340
|
+
session.tasksById.set("task-1", {
|
|
2341
|
+
task_id: "task-1",
|
|
2342
|
+
subject: "Scaffold Next.js app",
|
|
2343
|
+
status: "pending",
|
|
2344
|
+
blocks: [],
|
|
2345
|
+
blocked_by: [],
|
|
2346
|
+
});
|
|
2347
|
+
session.taskOrder.push("task-1");
|
|
2348
|
+
const events = captureBridgeEvents(() => {
|
|
2349
|
+
emitToolCall(session, "tool-no-activity", "TaskUpdate", {
|
|
2350
|
+
taskId: "task-1",
|
|
2351
|
+
status: "in_progress",
|
|
2352
|
+
});
|
|
2353
|
+
emitToolResultUpdate(session, "tool-no-activity", false, {
|
|
2354
|
+
success: true,
|
|
2355
|
+
taskId: "task-1",
|
|
2356
|
+
updatedFields: ["status"],
|
|
2357
|
+
});
|
|
2358
|
+
});
|
|
2359
|
+
const result = events
|
|
2360
|
+
.map((event) => event.update)
|
|
2361
|
+
.find((update) => {
|
|
2362
|
+
const toolCallUpdate = update.tool_call_update;
|
|
2363
|
+
return toolCallUpdate?.tool_call_id === "tool-no-activity";
|
|
2364
|
+
})?.tool_call_update;
|
|
2365
|
+
const fields = result?.fields;
|
|
2366
|
+
assert.equal(fields?.status, "completed");
|
|
2367
|
+
assert.equal(Object.hasOwn(fields ?? {}, "content"), false);
|
|
2368
|
+
assert.equal(Object.hasOwn(fields ?? {}, "raw_output"), false);
|
|
2369
|
+
});
|
|
2370
|
+
test("TaskUpdate deleted removes task without persisting deleted status", () => {
|
|
2371
|
+
const session = makeSessionState();
|
|
2372
|
+
session.tasksById.set("task-1", {
|
|
2373
|
+
task_id: "task-1",
|
|
2374
|
+
subject: "Delete me",
|
|
2375
|
+
status: "pending",
|
|
2376
|
+
blocks: [],
|
|
2377
|
+
blocked_by: [],
|
|
2378
|
+
});
|
|
2379
|
+
session.taskOrder.push("task-1");
|
|
2380
|
+
const events = captureBridgeEvents(() => {
|
|
2381
|
+
emitToolCall(session, "tool-delete", "TaskUpdate", {
|
|
2382
|
+
taskId: "task-1",
|
|
2383
|
+
status: "deleted",
|
|
2384
|
+
});
|
|
2385
|
+
emitToolResultUpdate(session, "tool-delete", false, {
|
|
2386
|
+
success: true,
|
|
2387
|
+
taskId: "task-1",
|
|
2388
|
+
updatedFields: ["status"],
|
|
2389
|
+
});
|
|
2390
|
+
});
|
|
2391
|
+
const updates = events.map((event) => event.update);
|
|
2392
|
+
const toolResult = updates.find((update) => {
|
|
2393
|
+
const toolCallUpdate = update.tool_call_update;
|
|
2394
|
+
return toolCallUpdate?.tool_call_id === "tool-delete";
|
|
2395
|
+
})?.tool_call_update;
|
|
2396
|
+
const resultFields = toolResult?.fields;
|
|
2397
|
+
assert.equal(resultFields?.status, "completed");
|
|
2398
|
+
assert.equal(Object.hasOwn(resultFields ?? {}, "raw_output"), false);
|
|
2399
|
+
assert.equal(Object.hasOwn(resultFields ?? {}, "content"), false);
|
|
2400
|
+
assert.deepEqual(updates.find((update) => update.type === "task_state_update"), {
|
|
2401
|
+
type: "task_state_update",
|
|
2402
|
+
source: "task_update",
|
|
2403
|
+
tasks: [],
|
|
2404
|
+
removed_task_ids: ["task-1"],
|
|
2405
|
+
is_complete_snapshot: false,
|
|
2406
|
+
});
|
|
2407
|
+
assert.equal(session.tasksById.has("task-1"), false);
|
|
2408
|
+
});
|
|
2409
|
+
test("failed TaskUpdate output renders failure but does not mutate task state", () => {
|
|
2410
|
+
const session = makeSessionState();
|
|
2411
|
+
session.tasksById.set("task-1", {
|
|
2412
|
+
task_id: "task-1",
|
|
2413
|
+
subject: "Stable",
|
|
2414
|
+
status: "pending",
|
|
2415
|
+
blocks: [],
|
|
2416
|
+
blocked_by: [],
|
|
2417
|
+
});
|
|
2418
|
+
session.taskOrder.push("task-1");
|
|
2419
|
+
const events = captureBridgeEvents(() => {
|
|
2420
|
+
emitToolCall(session, "tool-failed-update", "TaskUpdate", {
|
|
2421
|
+
taskId: "task-1",
|
|
2422
|
+
status: "completed",
|
|
2423
|
+
});
|
|
2424
|
+
emitToolResultUpdate(session, "tool-failed-update", false, {
|
|
2425
|
+
success: false,
|
|
2426
|
+
taskId: "task-1",
|
|
2427
|
+
updatedFields: [],
|
|
2428
|
+
error: "Task missing",
|
|
2429
|
+
});
|
|
2430
|
+
});
|
|
2431
|
+
const updates = events.map((event) => event.update);
|
|
2432
|
+
assert.equal(updates.some((update) => update.type === "task_state_update"), false);
|
|
2433
|
+
const toolResult = updates.find((update) => {
|
|
2434
|
+
const toolCallUpdate = update.tool_call_update;
|
|
2435
|
+
return toolCallUpdate?.tool_call_id === "tool-failed-update";
|
|
2436
|
+
})?.tool_call_update;
|
|
2437
|
+
assert.equal(toolResult?.fields?.status, "failed");
|
|
2438
|
+
assert.equal(session.tasksById.get("task-1")?.status, "pending");
|
|
2439
|
+
});
|
|
2440
|
+
test("TaskList complete snapshot preserves richer retained fields", () => {
|
|
2441
|
+
const session = makeSessionState();
|
|
2442
|
+
session.tasksById.set("task-1", {
|
|
2443
|
+
task_id: "task-1",
|
|
2444
|
+
subject: "Existing",
|
|
2445
|
+
description: "Keep this",
|
|
2446
|
+
active_form: "Working",
|
|
2447
|
+
status: "in_progress",
|
|
2448
|
+
blocks: ["task-9"],
|
|
2449
|
+
blocked_by: [],
|
|
2450
|
+
});
|
|
2451
|
+
session.tasksById.set("task-2", {
|
|
2452
|
+
task_id: "task-2",
|
|
2453
|
+
subject: "Removed",
|
|
2454
|
+
status: "pending",
|
|
2455
|
+
blocks: [],
|
|
2456
|
+
blocked_by: [],
|
|
2457
|
+
});
|
|
2458
|
+
session.taskOrder.push("task-1", "task-2");
|
|
2459
|
+
const events = captureBridgeEvents(() => {
|
|
2460
|
+
emitToolCall(session, "tool-list", "TaskList", {});
|
|
2461
|
+
emitToolResultUpdate(session, "tool-list", false, {
|
|
2462
|
+
tasks: [
|
|
2463
|
+
{
|
|
2464
|
+
id: "task-1",
|
|
2465
|
+
subject: "Listed",
|
|
2466
|
+
status: "completed",
|
|
2467
|
+
owner: "agent",
|
|
2468
|
+
blockedBy: ["task-3"],
|
|
2469
|
+
},
|
|
2470
|
+
],
|
|
2471
|
+
});
|
|
2472
|
+
});
|
|
2473
|
+
const taskUpdate = events
|
|
2474
|
+
.map((event) => event.update)
|
|
2475
|
+
.find((update) => update.type === "task_state_update");
|
|
2476
|
+
assert.deepEqual(taskUpdate, {
|
|
2477
|
+
type: "task_state_update",
|
|
2478
|
+
source: "task_list",
|
|
2479
|
+
tasks: [
|
|
2480
|
+
{
|
|
2481
|
+
task_id: "task-1",
|
|
2482
|
+
subject: "Listed",
|
|
2483
|
+
description: "Keep this",
|
|
2484
|
+
active_form: "Working",
|
|
2485
|
+
status: "completed",
|
|
2486
|
+
owner: "agent",
|
|
2487
|
+
blocks: ["task-9"],
|
|
2488
|
+
blocked_by: ["task-3"],
|
|
2489
|
+
},
|
|
2490
|
+
],
|
|
2491
|
+
removed_task_ids: ["task-2"],
|
|
2492
|
+
is_complete_snapshot: true,
|
|
2493
|
+
});
|
|
2494
|
+
});
|
|
2495
|
+
test("TaskGet null emits removal or confirmed absence", () => {
|
|
2496
|
+
const session = makeSessionState();
|
|
2497
|
+
session.tasksById.set("task-1", {
|
|
2498
|
+
task_id: "task-1",
|
|
2499
|
+
subject: "Maybe gone",
|
|
2500
|
+
status: "pending",
|
|
2501
|
+
blocks: [],
|
|
2502
|
+
blocked_by: [],
|
|
2503
|
+
});
|
|
2504
|
+
session.taskOrder.push("task-1");
|
|
2505
|
+
const events = captureBridgeEvents(() => {
|
|
2506
|
+
emitToolCall(session, "tool-get", "TaskGet", { taskId: "task-1" });
|
|
2507
|
+
emitToolResultUpdate(session, "tool-get", false, { task: null });
|
|
2508
|
+
});
|
|
2509
|
+
assert.deepEqual(events.map((event) => event.update).find((update) => update.type === "task_state_update"), {
|
|
2510
|
+
type: "task_state_update",
|
|
2511
|
+
source: "task_get",
|
|
2512
|
+
tasks: [],
|
|
2513
|
+
removed_task_ids: ["task-1"],
|
|
2514
|
+
is_complete_snapshot: false,
|
|
2515
|
+
});
|
|
2516
|
+
assert.equal(session.tasksById.has("task-1"), false);
|
|
2517
|
+
});
|
|
2518
|
+
test("handleTaskSystemMessage emits task state for unlinked task_updated messages", () => {
|
|
2519
|
+
const session = makeSessionState();
|
|
2520
|
+
const events = captureBridgeEvents(() => {
|
|
2521
|
+
handleTaskSystemMessage(session, "task_updated", {
|
|
2522
|
+
task_id: "task-missing",
|
|
2523
|
+
patch: {
|
|
2524
|
+
status: "running",
|
|
2525
|
+
description: "This should not be emitted",
|
|
2526
|
+
},
|
|
2527
|
+
});
|
|
2528
|
+
});
|
|
2529
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
2530
|
+
{
|
|
2531
|
+
type: "task_state_update",
|
|
2532
|
+
source: "task_lifecycle",
|
|
2533
|
+
tasks: [
|
|
2534
|
+
{
|
|
2535
|
+
task_id: "task-missing",
|
|
2536
|
+
subject: "This should not be emitted",
|
|
2537
|
+
description: "This should not be emitted",
|
|
2538
|
+
status: "in_progress",
|
|
2539
|
+
blocks: [],
|
|
2540
|
+
blocked_by: [],
|
|
2541
|
+
},
|
|
2542
|
+
],
|
|
2543
|
+
removed_task_ids: [],
|
|
2544
|
+
is_complete_snapshot: false,
|
|
2545
|
+
},
|
|
2546
|
+
]);
|
|
2547
|
+
});
|
|
2548
|
+
test("handleTaskSystemMessage maps stopped notifications to terminal task state", () => {
|
|
2549
|
+
const session = makeSessionState();
|
|
2550
|
+
session.tasksById.set("task-1", {
|
|
2551
|
+
task_id: "task-1",
|
|
2552
|
+
subject: "Watch build",
|
|
2553
|
+
status: "in_progress",
|
|
2554
|
+
blocks: [],
|
|
2555
|
+
blocked_by: [],
|
|
2556
|
+
});
|
|
2557
|
+
session.taskOrder.push("task-1");
|
|
2558
|
+
const events = captureBridgeEvents(() => {
|
|
2559
|
+
handleTaskSystemMessage(session, "task_notification", {
|
|
2560
|
+
task_id: "task-1",
|
|
2561
|
+
status: "stopped",
|
|
2562
|
+
output_file: "C:/tmp/task-1.txt",
|
|
2563
|
+
summary: "Stopped background watch",
|
|
2564
|
+
});
|
|
2565
|
+
});
|
|
2566
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
2567
|
+
{
|
|
2568
|
+
type: "task_state_update",
|
|
2569
|
+
source: "task_lifecycle",
|
|
2570
|
+
tasks: [
|
|
2571
|
+
{
|
|
2572
|
+
task_id: "task-1",
|
|
2573
|
+
subject: "Watch build",
|
|
2574
|
+
description: "Stopped background watch",
|
|
2575
|
+
status: "completed",
|
|
2576
|
+
blocks: [],
|
|
2577
|
+
blocked_by: [],
|
|
2578
|
+
metadata: {
|
|
2579
|
+
output_file: "C:/tmp/task-1.txt",
|
|
2580
|
+
summary: "Stopped background watch",
|
|
2581
|
+
terminal_status: "stopped",
|
|
2582
|
+
},
|
|
2583
|
+
},
|
|
2584
|
+
],
|
|
2585
|
+
removed_task_ids: [],
|
|
2586
|
+
is_complete_snapshot: false,
|
|
2587
|
+
},
|
|
2588
|
+
]);
|
|
2589
|
+
});
|
|
2590
|
+
test("handleSdkMessage emits MCP snapshot from init status payload", () => {
|
|
2591
|
+
const session = makeSessionState();
|
|
2592
|
+
session.query = {
|
|
2593
|
+
supportedCommands: async () => [],
|
|
2594
|
+
};
|
|
2595
|
+
const events = captureBridgeEvents(() => {
|
|
2596
|
+
handleSdkMessage(session, {
|
|
2597
|
+
type: "system",
|
|
2598
|
+
subtype: "init",
|
|
2599
|
+
session_id: "session-1",
|
|
2600
|
+
model: "sonnet",
|
|
2601
|
+
mcp_servers: [
|
|
2602
|
+
{
|
|
2603
|
+
name: "docs",
|
|
2604
|
+
status: "pending",
|
|
2605
|
+
config: {
|
|
2606
|
+
type: "stdio",
|
|
2607
|
+
command: "npx",
|
|
2608
|
+
args: ["-y", "@anthropic-ai/mcp-docs"],
|
|
2609
|
+
timeout: 3000,
|
|
2610
|
+
alwaysLoad: true,
|
|
2611
|
+
},
|
|
2612
|
+
tools: [],
|
|
2613
|
+
},
|
|
2614
|
+
],
|
|
2615
|
+
});
|
|
2616
|
+
});
|
|
2617
|
+
const snapshot = events.find((event) => event.event === "mcp_snapshot");
|
|
2618
|
+
assert.deepEqual(snapshot, {
|
|
2619
|
+
event: "mcp_snapshot",
|
|
2620
|
+
session_id: "session-1",
|
|
2621
|
+
source: "init",
|
|
2622
|
+
servers: [
|
|
2623
|
+
{
|
|
2624
|
+
name: "docs",
|
|
2625
|
+
status: "pending",
|
|
2626
|
+
config: {
|
|
2627
|
+
type: "stdio",
|
|
2628
|
+
command: "npx",
|
|
2629
|
+
args: ["-y", "@anthropic-ai/mcp-docs"],
|
|
2630
|
+
timeout: 3000,
|
|
2631
|
+
always_load: true,
|
|
2632
|
+
},
|
|
2633
|
+
tools: [],
|
|
2634
|
+
},
|
|
2635
|
+
],
|
|
2636
|
+
});
|
|
2637
|
+
});
|
|
2638
|
+
test("emitToolProgressUpdate does not reopen completed tools", () => {
|
|
2639
|
+
const session = makeSessionState();
|
|
2640
|
+
session.toolCalls.set("tool-1", {
|
|
2641
|
+
tool_call_id: "tool-1",
|
|
2642
|
+
title: "Bash",
|
|
2643
|
+
kind: "execute",
|
|
2644
|
+
status: "completed",
|
|
2645
|
+
content: [],
|
|
2646
|
+
locations: [],
|
|
2647
|
+
meta: { claudeCode: { toolName: "Bash", parentToolUseId: null } },
|
|
2648
|
+
});
|
|
2649
|
+
const events = captureBridgeEvents(() => {
|
|
2650
|
+
emitToolProgressUpdate(session, "tool-1", "Bash");
|
|
2651
|
+
});
|
|
2652
|
+
assert.equal(events.length, 0);
|
|
2653
|
+
assert.equal(session.toolCalls.get("tool-1")?.status, "completed");
|
|
2654
|
+
});
|
|
2655
|
+
test("buildQueryOptions trims language before appending system prompt", () => {
|
|
2656
|
+
const input = new AsyncQueue();
|
|
2657
|
+
const options = buildQueryOptions({
|
|
2658
|
+
cwd: "C:/work",
|
|
2659
|
+
launchSettings: {
|
|
2660
|
+
language: " German ",
|
|
2661
|
+
},
|
|
2662
|
+
provisionalSessionId: "session-4",
|
|
1031
2663
|
input,
|
|
1032
2664
|
canUseTool: async () => ({ behavior: "deny", message: "not used" }),
|
|
1033
2665
|
enableSdkDebug: false,
|
|
@@ -1072,6 +2704,52 @@ test("parseCommandEnvelope validates question_response command", () => {
|
|
|
1072
2704
|
},
|
|
1073
2705
|
});
|
|
1074
2706
|
});
|
|
2707
|
+
test("parseCommandEnvelope validates user_dialog_response selected command", () => {
|
|
2708
|
+
const parsed = parseCommandEnvelope(JSON.stringify({
|
|
2709
|
+
command: "user_dialog_response",
|
|
2710
|
+
session_id: "session-1",
|
|
2711
|
+
request_id: "dialog-1",
|
|
2712
|
+
outcome: {
|
|
2713
|
+
outcome: "selected",
|
|
2714
|
+
option_id: "retry_fallback",
|
|
2715
|
+
},
|
|
2716
|
+
}));
|
|
2717
|
+
assert.equal(parsed.requestId, "dialog-1");
|
|
2718
|
+
assert.equal(parsed.command.command, "user_dialog_response");
|
|
2719
|
+
if (parsed.command.command !== "user_dialog_response") {
|
|
2720
|
+
throw new Error("unexpected command variant");
|
|
2721
|
+
}
|
|
2722
|
+
assert.equal(parsed.command.session_id, "session-1");
|
|
2723
|
+
assert.equal(parsed.command.request_id, "dialog-1");
|
|
2724
|
+
assert.deepEqual(parsed.command.outcome, {
|
|
2725
|
+
outcome: "selected",
|
|
2726
|
+
option_id: "retry_fallback",
|
|
2727
|
+
});
|
|
2728
|
+
});
|
|
2729
|
+
test("parseCommandEnvelope validates user_dialog_response cancelled command", () => {
|
|
2730
|
+
const parsed = parseCommandEnvelope(JSON.stringify({
|
|
2731
|
+
command: "user_dialog_response",
|
|
2732
|
+
session_id: "session-1",
|
|
2733
|
+
request_id: "dialog-1",
|
|
2734
|
+
outcome: { outcome: "cancelled" },
|
|
2735
|
+
}));
|
|
2736
|
+
assert.equal(parsed.command.command, "user_dialog_response");
|
|
2737
|
+
if (parsed.command.command !== "user_dialog_response") {
|
|
2738
|
+
throw new Error("unexpected command variant");
|
|
2739
|
+
}
|
|
2740
|
+
assert.deepEqual(parsed.command.outcome, { outcome: "cancelled" });
|
|
2741
|
+
});
|
|
2742
|
+
test("parseCommandEnvelope rejects unsupported user_dialog_response choices", () => {
|
|
2743
|
+
assert.throws(() => parseCommandEnvelope(JSON.stringify({
|
|
2744
|
+
command: "user_dialog_response",
|
|
2745
|
+
session_id: "session-1",
|
|
2746
|
+
request_id: "dialog-1",
|
|
2747
|
+
outcome: {
|
|
2748
|
+
outcome: "selected",
|
|
2749
|
+
option_id: "future_choice",
|
|
2750
|
+
},
|
|
2751
|
+
})), /user_dialog_response\.outcome\.option_id must be 'retry_fallback' or 'edit_prompt'/);
|
|
2752
|
+
});
|
|
1075
2753
|
test("requestAskUserQuestionAnswers preserves previews and annotations in updated input", async () => {
|
|
1076
2754
|
const session = makeSessionState();
|
|
1077
2755
|
const baseToolCall = {
|
|
@@ -1213,10 +2891,27 @@ test("normalizeToolKind maps known tool names", () => {
|
|
|
1213
2891
|
assert.equal(normalizeToolKind("Bash"), "execute");
|
|
1214
2892
|
assert.equal(normalizeToolKind("Delete"), "delete");
|
|
1215
2893
|
assert.equal(normalizeToolKind("Move"), "move");
|
|
2894
|
+
assert.equal(normalizeToolKind("EnterWorktree"), "other");
|
|
2895
|
+
assert.equal(normalizeToolKind("ExitWorktree"), "other");
|
|
2896
|
+
assert.equal(normalizeToolKind("CronCreate"), "other");
|
|
2897
|
+
assert.equal(normalizeToolKind("CronDelete"), "other");
|
|
2898
|
+
assert.equal(normalizeToolKind("CronList"), "other");
|
|
2899
|
+
assert.equal(normalizeToolKind("ScheduleWakeup"), "other");
|
|
2900
|
+
assert.equal(normalizeToolKind("PushNotification"), "other");
|
|
2901
|
+
assert.equal(normalizeToolKind("RemoteTrigger"), "other");
|
|
2902
|
+
assert.equal(normalizeToolKind("REPL"), "other");
|
|
2903
|
+
assert.equal(normalizeToolKind("Monitor"), "other");
|
|
2904
|
+
assert.equal(normalizeToolKind("Workflow"), "other");
|
|
2905
|
+
assert.equal(normalizeToolKind("Projects"), "other");
|
|
2906
|
+
assert.equal(normalizeToolKind("Artifact"), "other");
|
|
2907
|
+
assert.equal(normalizeToolKind("ShowOnboardingRolePicker"), "other");
|
|
2908
|
+
assert.equal(normalizeToolKind("TaskOutput"), "other");
|
|
2909
|
+
assert.equal(normalizeToolKind("TaskStop"), "other");
|
|
1216
2910
|
assert.equal(normalizeToolKind("Task"), "think");
|
|
1217
2911
|
assert.equal(normalizeToolKind("Agent"), "think");
|
|
2912
|
+
assert.equal(normalizeToolKind("EnterPlanMode"), "switch_mode");
|
|
1218
2913
|
assert.equal(normalizeToolKind("ExitPlanMode"), "switch_mode");
|
|
1219
|
-
assert.equal(normalizeToolKind("TodoWrite"), "
|
|
2914
|
+
assert.equal(normalizeToolKind("TodoWrite"), normalizeToolKind("FutureUnknownTool"));
|
|
1220
2915
|
});
|
|
1221
2916
|
test("parseFastModeState accepts known values and rejects unknown values", () => {
|
|
1222
2917
|
assert.equal(parseFastModeState("off"), "off");
|
|
@@ -1264,6 +2959,25 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
|
|
|
1264
2959
|
surpassed_threshold: 0.9,
|
|
1265
2960
|
});
|
|
1266
2961
|
});
|
|
2962
|
+
test("buildRateLimitUpdate normalizes SDK overage boolean spellings", () => {
|
|
2963
|
+
const cases = [
|
|
2964
|
+
["old spelling true", { isUsingOverage: true }, true],
|
|
2965
|
+
["old spelling false", { isUsingOverage: false }, false],
|
|
2966
|
+
["new spelling true", { overageInUse: true }, true],
|
|
2967
|
+
["new spelling false", { overageInUse: false }, false],
|
|
2968
|
+
["both spellings true", { isUsingOverage: true, overageInUse: true }, true],
|
|
2969
|
+
["both spellings false", { isUsingOverage: false, overageInUse: false }, false],
|
|
2970
|
+
["conflicting spellings prefer new true", { isUsingOverage: false, overageInUse: true }, true],
|
|
2971
|
+
["conflicting spellings prefer new false", { isUsingOverage: true, overageInUse: false }, false],
|
|
2972
|
+
];
|
|
2973
|
+
for (const [name, fields, expected] of cases) {
|
|
2974
|
+
const update = buildRateLimitUpdate({
|
|
2975
|
+
status: "allowed",
|
|
2976
|
+
...fields,
|
|
2977
|
+
});
|
|
2978
|
+
assert.equal(update?.is_using_overage, expected, name);
|
|
2979
|
+
}
|
|
2980
|
+
});
|
|
1267
2981
|
test("buildRateLimitUpdate rejects invalid payloads", () => {
|
|
1268
2982
|
assert.equal(buildRateLimitUpdate(null), null);
|
|
1269
2983
|
assert.equal(buildRateLimitUpdate({}), null);
|
|
@@ -1288,6 +3002,26 @@ test("buildApiRetryUpdate maps SDK api_retry messages to wire shape", () => {
|
|
|
1288
3002
|
error_status: 529,
|
|
1289
3003
|
error: "server_error",
|
|
1290
3004
|
});
|
|
3005
|
+
for (const error of [
|
|
3006
|
+
"model_not_found",
|
|
3007
|
+
"oauth_org_not_allowed",
|
|
3008
|
+
"overloaded",
|
|
3009
|
+
]) {
|
|
3010
|
+
assert.deepEqual(buildApiRetryUpdate({
|
|
3011
|
+
attempt: 2,
|
|
3012
|
+
max_retries: 4,
|
|
3013
|
+
retry_delay_ms: 1500,
|
|
3014
|
+
error_status: 529,
|
|
3015
|
+
error,
|
|
3016
|
+
}), {
|
|
3017
|
+
type: "api_retry_update",
|
|
3018
|
+
attempt: 2,
|
|
3019
|
+
max_retries: 4,
|
|
3020
|
+
retry_delay_ms: 1500,
|
|
3021
|
+
error_status: 529,
|
|
3022
|
+
error,
|
|
3023
|
+
});
|
|
3024
|
+
}
|
|
1291
3025
|
assert.deepEqual(buildApiRetryUpdate({
|
|
1292
3026
|
attempt: 1,
|
|
1293
3027
|
maxRetries: 4,
|
|
@@ -1342,47 +3076,490 @@ test("normalizeSettingsParseError accepts only SDK-shaped errors", () => {
|
|
|
1342
3076
|
assert.equal(normalizeSettingsParseError({ path: "", message: "" }), null);
|
|
1343
3077
|
assert.equal(normalizeSettingsParseError("Invalid JSON"), null);
|
|
1344
3078
|
});
|
|
1345
|
-
test("handleSdkMessage emits lifecycle compatibility session updates", () => {
|
|
3079
|
+
test("handleSdkMessage emits lifecycle compatibility session updates", () => {
|
|
3080
|
+
const session = makeSessionState();
|
|
3081
|
+
const events = captureBridgeEvents(() => {
|
|
3082
|
+
handleSdkMessage(session, {
|
|
3083
|
+
type: "prompt_suggestion",
|
|
3084
|
+
suggestion: "Write tests for this change",
|
|
3085
|
+
uuid: "message-1",
|
|
3086
|
+
session_id: "session-1",
|
|
3087
|
+
});
|
|
3088
|
+
handleSdkMessage(session, {
|
|
3089
|
+
type: "system",
|
|
3090
|
+
subtype: "api_retry",
|
|
3091
|
+
attempt: 1,
|
|
3092
|
+
max_retries: 4,
|
|
3093
|
+
retry_delay_ms: 1000,
|
|
3094
|
+
error_status: null,
|
|
3095
|
+
error: "server_error",
|
|
3096
|
+
uuid: "message-2",
|
|
3097
|
+
session_id: "session-1",
|
|
3098
|
+
});
|
|
3099
|
+
handleSdkMessage(session, {
|
|
3100
|
+
type: "system",
|
|
3101
|
+
subtype: "session_state_changed",
|
|
3102
|
+
state: "idle",
|
|
3103
|
+
uuid: "message-3",
|
|
3104
|
+
session_id: "session-1",
|
|
3105
|
+
});
|
|
3106
|
+
handleSdkMessage(session, {
|
|
3107
|
+
type: "system",
|
|
3108
|
+
subtype: "status",
|
|
3109
|
+
status: "requesting",
|
|
3110
|
+
uuid: "message-4",
|
|
3111
|
+
session_id: "session-1",
|
|
3112
|
+
});
|
|
3113
|
+
});
|
|
3114
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3115
|
+
{ type: "prompt_suggestion_update", suggestion: "Write tests for this change" },
|
|
3116
|
+
{
|
|
3117
|
+
type: "api_retry_update",
|
|
3118
|
+
attempt: 1,
|
|
3119
|
+
max_retries: 4,
|
|
3120
|
+
retry_delay_ms: 1000,
|
|
3121
|
+
error_status: null,
|
|
3122
|
+
error: "server_error",
|
|
3123
|
+
},
|
|
3124
|
+
{ type: "runtime_session_state_update", state: "idle" },
|
|
3125
|
+
{ type: "session_status_update", status: "requesting" },
|
|
3126
|
+
]);
|
|
3127
|
+
});
|
|
3128
|
+
test("classifyTurnErrorKind prefers SDK assistant error codes", () => {
|
|
3129
|
+
assert.equal(classifyTurnErrorKind("error_during_execution", [], "model_not_found"), "model_unavailable");
|
|
3130
|
+
assert.equal(classifyTurnErrorKind("error_during_execution", [], "oauth_org_not_allowed"), "account_access");
|
|
3131
|
+
assert.equal(classifyTurnErrorKind("error_during_execution", [], "overloaded"), "transient_service");
|
|
3132
|
+
assert.equal(classifyTurnErrorKind("error_during_execution", [], "server_error"), "transient_service");
|
|
3133
|
+
assert.equal(classifyTurnErrorKind("error_during_execution", [], "authentication_failed"), "auth_required");
|
|
3134
|
+
assert.equal(classifyTurnErrorKind("error_during_execution", [], "billing_error"), "plan_limit");
|
|
3135
|
+
assert.equal(classifyTurnErrorKind("error_during_execution", [], "rate_limit"), "plan_limit");
|
|
3136
|
+
});
|
|
3137
|
+
test("handleSdkMessage replaces available commands from commands_changed", () => {
|
|
3138
|
+
const session = makeSessionState();
|
|
3139
|
+
const events = captureBridgeEvents(() => {
|
|
3140
|
+
handleSdkMessage(session, {
|
|
3141
|
+
type: "system",
|
|
3142
|
+
subtype: "commands_changed",
|
|
3143
|
+
commands: [
|
|
3144
|
+
{ name: "/one", description: "First command", argumentHint: "<value>" },
|
|
3145
|
+
{ name: "/two", description: undefined, argumentHint: undefined },
|
|
3146
|
+
],
|
|
3147
|
+
uuid: "message-commands",
|
|
3148
|
+
session_id: "session-1",
|
|
3149
|
+
});
|
|
3150
|
+
});
|
|
3151
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3152
|
+
{
|
|
3153
|
+
type: "available_commands_update",
|
|
3154
|
+
commands: [
|
|
3155
|
+
{ name: "/one", description: "First command", input_hint: "<value>" },
|
|
3156
|
+
{ name: "/two", description: "" },
|
|
3157
|
+
],
|
|
3158
|
+
source: "commands_changed",
|
|
3159
|
+
generation: 1,
|
|
3160
|
+
},
|
|
3161
|
+
]);
|
|
3162
|
+
});
|
|
3163
|
+
test("handleSdkMessage accepts empty commands_changed replacement list", () => {
|
|
3164
|
+
const session = makeSessionState();
|
|
3165
|
+
const events = captureBridgeEvents(() => {
|
|
3166
|
+
handleSdkMessage(session, {
|
|
3167
|
+
type: "system",
|
|
3168
|
+
subtype: "commands_changed",
|
|
3169
|
+
commands: [],
|
|
3170
|
+
uuid: "message-commands-empty",
|
|
3171
|
+
session_id: "session-1",
|
|
3172
|
+
});
|
|
3173
|
+
});
|
|
3174
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3175
|
+
{
|
|
3176
|
+
type: "available_commands_update",
|
|
3177
|
+
commands: [],
|
|
3178
|
+
source: "commands_changed",
|
|
3179
|
+
generation: 1,
|
|
3180
|
+
},
|
|
3181
|
+
]);
|
|
3182
|
+
});
|
|
3183
|
+
test("available command registry blocks stale supportedCommands after dynamic updates", () => {
|
|
3184
|
+
const session = makeSessionState();
|
|
3185
|
+
const events = captureBridgeEvents(() => {
|
|
3186
|
+
assert.equal(updateAvailableCommands(session, "session_result_commands", [
|
|
3187
|
+
{ name: "base", description: "Base command" },
|
|
3188
|
+
]), true);
|
|
3189
|
+
assert.equal(updateAvailableCommands(session, "commands_changed", [
|
|
3190
|
+
{ name: "base", description: "Base command" },
|
|
3191
|
+
{ name: "project-plugin", description: "Project plugin command" },
|
|
3192
|
+
]), true);
|
|
3193
|
+
assert.equal(updateAvailableCommands(session, "supportedCommands", [
|
|
3194
|
+
{ name: "base", description: "Base command" },
|
|
3195
|
+
]), false);
|
|
3196
|
+
});
|
|
3197
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3198
|
+
{
|
|
3199
|
+
type: "available_commands_update",
|
|
3200
|
+
commands: [{ name: "base", description: "Base command" }],
|
|
3201
|
+
source: "session_result_commands",
|
|
3202
|
+
generation: 1,
|
|
3203
|
+
},
|
|
3204
|
+
{
|
|
3205
|
+
type: "available_commands_update",
|
|
3206
|
+
commands: [
|
|
3207
|
+
{ name: "base", description: "Base command" },
|
|
3208
|
+
{ name: "project-plugin", description: "Project plugin command" },
|
|
3209
|
+
],
|
|
3210
|
+
source: "commands_changed",
|
|
3211
|
+
generation: 2,
|
|
3212
|
+
},
|
|
3213
|
+
]);
|
|
3214
|
+
assert.equal(session.availableCommands?.generation, 2);
|
|
3215
|
+
assert.equal(session.availableCommands?.source, "commands_changed");
|
|
3216
|
+
assert.deepEqual(session.availableCommands?.commands.map((command) => command.name), ["base", "project-plugin"]);
|
|
3217
|
+
});
|
|
3218
|
+
test("available command registry lets authoritative snapshots remove commands", () => {
|
|
3219
|
+
const session = makeSessionState();
|
|
3220
|
+
const events = captureBridgeEvents(() => {
|
|
3221
|
+
updateAvailableCommands(session, "reload_plugins", [
|
|
3222
|
+
{ name: "base", description: "Base command" },
|
|
3223
|
+
{ name: "removed-plugin", description: "Removed plugin command" },
|
|
3224
|
+
]);
|
|
3225
|
+
updateAvailableCommands(session, "commands_changed", [
|
|
3226
|
+
{ name: "base", description: "Base command" },
|
|
3227
|
+
]);
|
|
3228
|
+
});
|
|
3229
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3230
|
+
{
|
|
3231
|
+
type: "available_commands_update",
|
|
3232
|
+
commands: [
|
|
3233
|
+
{ name: "base", description: "Base command" },
|
|
3234
|
+
{ name: "removed-plugin", description: "Removed plugin command" },
|
|
3235
|
+
],
|
|
3236
|
+
source: "reload_plugins",
|
|
3237
|
+
generation: 1,
|
|
3238
|
+
},
|
|
3239
|
+
{
|
|
3240
|
+
type: "available_commands_update",
|
|
3241
|
+
commands: [{ name: "base", description: "Base command" }],
|
|
3242
|
+
source: "commands_changed",
|
|
3243
|
+
generation: 2,
|
|
3244
|
+
},
|
|
3245
|
+
]);
|
|
3246
|
+
});
|
|
3247
|
+
test("handleSdkMessage emits system notices for notifications and plugin failures", () => {
|
|
3248
|
+
const session = makeSessionState();
|
|
3249
|
+
const events = captureBridgeEvents(() => {
|
|
3250
|
+
handleSdkMessage(session, {
|
|
3251
|
+
type: "system",
|
|
3252
|
+
subtype: "notification",
|
|
3253
|
+
key: "sync",
|
|
3254
|
+
text: "Sync completed",
|
|
3255
|
+
priority: "low",
|
|
3256
|
+
uuid: "message-notification",
|
|
3257
|
+
session_id: "session-1",
|
|
3258
|
+
});
|
|
3259
|
+
handleSdkMessage(session, {
|
|
3260
|
+
type: "system",
|
|
3261
|
+
subtype: "plugin_install",
|
|
3262
|
+
status: "failed",
|
|
3263
|
+
name: "acme",
|
|
3264
|
+
error: "download failed",
|
|
3265
|
+
uuid: "message-plugin",
|
|
3266
|
+
session_id: "session-1",
|
|
3267
|
+
});
|
|
3268
|
+
});
|
|
3269
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3270
|
+
{ type: "system_notice_update", severity: "info", message: "Sync completed" },
|
|
3271
|
+
{ type: "system_notice_update", severity: "warning", message: "Plugin install failed acme: download failed" },
|
|
3272
|
+
]);
|
|
3273
|
+
});
|
|
3274
|
+
test("handleSdkMessage treats mirror errors as log-only diagnostics", () => {
|
|
3275
|
+
const session = makeSessionState();
|
|
3276
|
+
const events = captureBridgeEvents(() => {
|
|
3277
|
+
handleSdkMessage(session, {
|
|
3278
|
+
type: "system",
|
|
3279
|
+
subtype: "mirror_error",
|
|
3280
|
+
error: "append timed out",
|
|
3281
|
+
key: { projectKey: "project", sessionId: "session-1", subpath: "subagents/agent-1" },
|
|
3282
|
+
uuid: "message-mirror",
|
|
3283
|
+
session_id: "session-1",
|
|
3284
|
+
});
|
|
3285
|
+
});
|
|
3286
|
+
assert.deepEqual(events, []);
|
|
3287
|
+
});
|
|
3288
|
+
test("handleSdkMessage keeps log-only system messages non-emitting", () => {
|
|
3289
|
+
const session = makeSessionState();
|
|
3290
|
+
const events = captureBridgeEvents(() => {
|
|
3291
|
+
handleSdkMessage(session, {
|
|
3292
|
+
type: "system",
|
|
3293
|
+
subtype: "plugin_install",
|
|
3294
|
+
status: "completed",
|
|
3295
|
+
uuid: "message-plugin-complete",
|
|
3296
|
+
session_id: "session-1",
|
|
3297
|
+
});
|
|
3298
|
+
handleSdkMessage(session, {
|
|
3299
|
+
type: "system",
|
|
3300
|
+
subtype: "permission_denied",
|
|
3301
|
+
tool_name: "Bash",
|
|
3302
|
+
tool_use_id: "tool-1",
|
|
3303
|
+
message: "denied",
|
|
3304
|
+
uuid: "message-permission",
|
|
3305
|
+
session_id: "session-1",
|
|
3306
|
+
});
|
|
3307
|
+
handleSdkMessage(session, {
|
|
3308
|
+
type: "system",
|
|
3309
|
+
subtype: "memory_recall",
|
|
3310
|
+
mode: "select",
|
|
3311
|
+
memories: [],
|
|
3312
|
+
uuid: "message-memory",
|
|
3313
|
+
session_id: "session-1",
|
|
3314
|
+
});
|
|
3315
|
+
handleSdkMessage(session, {
|
|
3316
|
+
type: "system",
|
|
3317
|
+
subtype: "thinking_tokens",
|
|
3318
|
+
estimated_tokens: 120,
|
|
3319
|
+
estimated_tokens_delta: 20,
|
|
3320
|
+
uuid: "message-thinking",
|
|
3321
|
+
session_id: "session-1",
|
|
3322
|
+
});
|
|
3323
|
+
});
|
|
3324
|
+
assert.deepEqual(events, []);
|
|
3325
|
+
});
|
|
3326
|
+
test("handleSdkMessage accepts auto-continuation message origin without user output", () => {
|
|
3327
|
+
const session = makeSessionState();
|
|
3328
|
+
const events = captureBridgeEvents(() => {
|
|
3329
|
+
handleSdkMessage(session, {
|
|
3330
|
+
type: "user",
|
|
3331
|
+
message: { role: "user", content: [{ type: "text", text: "continue" }] },
|
|
3332
|
+
parent_tool_use_id: null,
|
|
3333
|
+
origin: { kind: "auto-continuation" },
|
|
3334
|
+
uuid: "message-auto-continuation",
|
|
3335
|
+
session_id: "session-1",
|
|
3336
|
+
});
|
|
3337
|
+
});
|
|
3338
|
+
assert.deepEqual(events, []);
|
|
3339
|
+
});
|
|
3340
|
+
test("handleSdkMessage preserves assistant correlation metadata on tool calls", () => {
|
|
1346
3341
|
const session = makeSessionState();
|
|
1347
3342
|
const events = captureBridgeEvents(() => {
|
|
1348
3343
|
handleSdkMessage(session, {
|
|
1349
|
-
type: "
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
max_retries: 4,
|
|
1359
|
-
retry_delay_ms: 1000,
|
|
1360
|
-
error_status: null,
|
|
1361
|
-
error: "server_error",
|
|
1362
|
-
uuid: "message-2",
|
|
3344
|
+
type: "assistant",
|
|
3345
|
+
message: {
|
|
3346
|
+
content: [{ type: "tool_use", id: "tool-1", name: "Bash", input: { command: "npm test" } }],
|
|
3347
|
+
},
|
|
3348
|
+
parent_tool_use_id: null,
|
|
3349
|
+
request_id: "request-1",
|
|
3350
|
+
subagent_type: "code-review",
|
|
3351
|
+
task_description: "Review the bridge",
|
|
3352
|
+
uuid: "message-assistant",
|
|
1363
3353
|
session_id: "session-1",
|
|
1364
3354
|
});
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
3355
|
+
});
|
|
3356
|
+
assert.deepEqual(events.map((event) => event.update.tool_call), [
|
|
3357
|
+
{
|
|
3358
|
+
tool_call_id: "tool-1",
|
|
3359
|
+
title: "npm test",
|
|
3360
|
+
kind: "execute",
|
|
3361
|
+
status: "in_progress",
|
|
3362
|
+
source_message_uuid: "message-assistant",
|
|
3363
|
+
content: [],
|
|
3364
|
+
raw_input: { command: "npm test" },
|
|
3365
|
+
locations: [],
|
|
3366
|
+
meta: {
|
|
3367
|
+
claudeCode: {
|
|
3368
|
+
toolName: "Bash",
|
|
3369
|
+
parentToolUseId: null,
|
|
3370
|
+
requestId: "request-1",
|
|
3371
|
+
subagentType: "code-review",
|
|
3372
|
+
taskDescription: "Review the bridge",
|
|
3373
|
+
},
|
|
3374
|
+
},
|
|
3375
|
+
},
|
|
3376
|
+
]);
|
|
3377
|
+
});
|
|
3378
|
+
test("handleTaskSystemMessage preserves task correlation metadata", () => {
|
|
3379
|
+
const session = makeSessionState();
|
|
3380
|
+
const events = captureBridgeEvents(() => {
|
|
3381
|
+
handleTaskSystemMessage(session, "task_started", {
|
|
3382
|
+
task_id: "task-1",
|
|
3383
|
+
tool_use_id: "tool-1",
|
|
3384
|
+
description: "Run checks",
|
|
3385
|
+
request_id: "request-1",
|
|
3386
|
+
subagent_type: "tester",
|
|
3387
|
+
task_description: "Validate the branch",
|
|
1371
3388
|
});
|
|
1372
3389
|
});
|
|
1373
3390
|
assert.deepEqual(events.map((event) => event.update), [
|
|
1374
|
-
{ type: "prompt_suggestion_update", suggestion: "Write tests for this change" },
|
|
1375
3391
|
{
|
|
1376
|
-
type: "
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
3392
|
+
type: "tool_call",
|
|
3393
|
+
tool_call: {
|
|
3394
|
+
tool_call_id: "tool-1",
|
|
3395
|
+
title: "Agent",
|
|
3396
|
+
kind: "think",
|
|
3397
|
+
status: "pending",
|
|
3398
|
+
content: [],
|
|
3399
|
+
raw_input: {},
|
|
3400
|
+
locations: [],
|
|
3401
|
+
meta: { claudeCode: { toolName: "Agent", parentToolUseId: null } },
|
|
3402
|
+
},
|
|
3403
|
+
},
|
|
3404
|
+
{
|
|
3405
|
+
type: "task_state_update",
|
|
3406
|
+
source: "task_lifecycle",
|
|
3407
|
+
tasks: [
|
|
3408
|
+
{
|
|
3409
|
+
task_id: "task-1",
|
|
3410
|
+
subject: "Validate the branch",
|
|
3411
|
+
description: "Run checks",
|
|
3412
|
+
status: "in_progress",
|
|
3413
|
+
blocks: [],
|
|
3414
|
+
blocked_by: [],
|
|
3415
|
+
metadata: {
|
|
3416
|
+
request_id: "request-1",
|
|
3417
|
+
subagent_type: "tester",
|
|
3418
|
+
task_description: "Validate the branch",
|
|
3419
|
+
},
|
|
3420
|
+
source_tool_call_id: "tool-1",
|
|
3421
|
+
},
|
|
3422
|
+
],
|
|
3423
|
+
removed_task_ids: [],
|
|
3424
|
+
is_complete_snapshot: false,
|
|
3425
|
+
},
|
|
3426
|
+
{
|
|
3427
|
+
type: "tool_call_update",
|
|
3428
|
+
tool_call_update: {
|
|
3429
|
+
tool_call_id: "tool-1",
|
|
3430
|
+
fields: {
|
|
3431
|
+
status: "in_progress",
|
|
3432
|
+
},
|
|
3433
|
+
},
|
|
3434
|
+
},
|
|
3435
|
+
{
|
|
3436
|
+
type: "tool_call_update",
|
|
3437
|
+
tool_call_update: {
|
|
3438
|
+
tool_call_id: "tool-1",
|
|
3439
|
+
fields: {
|
|
3440
|
+
status: "in_progress",
|
|
3441
|
+
raw_output: "Run checks",
|
|
3442
|
+
content: [{ type: "content", content: { type: "text", text: "Run checks" } }],
|
|
3443
|
+
task_metadata: {
|
|
3444
|
+
request_id: "request-1",
|
|
3445
|
+
subagent_type: "tester",
|
|
3446
|
+
task_description: "Validate the branch",
|
|
3447
|
+
},
|
|
3448
|
+
},
|
|
3449
|
+
},
|
|
1382
3450
|
},
|
|
1383
|
-
{ type: "runtime_session_state_update", state: "idle" },
|
|
1384
3451
|
]);
|
|
1385
3452
|
});
|
|
3453
|
+
test("parseCommandEnvelope validates set_effort command", () => {
|
|
3454
|
+
for (const effort of ["low", "medium", "high", "xhigh", "max"]) {
|
|
3455
|
+
const parsed = parseCommandEnvelope(JSON.stringify({
|
|
3456
|
+
request_id: "req-effort",
|
|
3457
|
+
command: "set_effort",
|
|
3458
|
+
session_id: "session-1",
|
|
3459
|
+
effort,
|
|
3460
|
+
}));
|
|
3461
|
+
assert.equal(parsed.requestId, "req-effort");
|
|
3462
|
+
assert.equal(parsed.command.command, "set_effort");
|
|
3463
|
+
if (parsed.command.command !== "set_effort") {
|
|
3464
|
+
throw new Error("unexpected command variant");
|
|
3465
|
+
}
|
|
3466
|
+
assert.equal(parsed.command.session_id, "session-1");
|
|
3467
|
+
assert.equal(parsed.command.effort, effort);
|
|
3468
|
+
}
|
|
3469
|
+
});
|
|
3470
|
+
test("parseCommandEnvelope rejects unsupported set_effort values", () => {
|
|
3471
|
+
assert.throws(() => parseCommandEnvelope(JSON.stringify({
|
|
3472
|
+
command: "set_effort",
|
|
3473
|
+
session_id: "session-1",
|
|
3474
|
+
effort: "banana",
|
|
3475
|
+
})), /set_effort\.effort must be one of low, medium, high, xhigh, max/);
|
|
3476
|
+
});
|
|
3477
|
+
test("parseCommandEnvelope validates set_agent command", () => {
|
|
3478
|
+
const parsed = parseCommandEnvelope(JSON.stringify({
|
|
3479
|
+
request_id: "req-agent",
|
|
3480
|
+
command: "set_agent",
|
|
3481
|
+
session_id: "session-1",
|
|
3482
|
+
agent: "reviewer",
|
|
3483
|
+
}));
|
|
3484
|
+
assert.equal(parsed.requestId, "req-agent");
|
|
3485
|
+
assert.equal(parsed.command.command, "set_agent");
|
|
3486
|
+
if (parsed.command.command !== "set_agent") {
|
|
3487
|
+
throw new Error("unexpected command variant");
|
|
3488
|
+
}
|
|
3489
|
+
assert.equal(parsed.command.session_id, "session-1");
|
|
3490
|
+
assert.equal(parsed.command.agent, "reviewer");
|
|
3491
|
+
});
|
|
3492
|
+
test("parseCommandEnvelope validates set_agent reset", () => {
|
|
3493
|
+
const parsed = parseCommandEnvelope(JSON.stringify({
|
|
3494
|
+
command: "set_agent",
|
|
3495
|
+
session_id: "session-1",
|
|
3496
|
+
agent: null,
|
|
3497
|
+
}));
|
|
3498
|
+
assert.equal(parsed.command.command, "set_agent");
|
|
3499
|
+
if (parsed.command.command !== "set_agent") {
|
|
3500
|
+
throw new Error("unexpected command variant");
|
|
3501
|
+
}
|
|
3502
|
+
assert.equal(parsed.command.agent, null);
|
|
3503
|
+
});
|
|
3504
|
+
test("parseCommandEnvelope rejects invalid set_agent values", () => {
|
|
3505
|
+
for (const agent of [undefined, "", " ", 42, {}, []]) {
|
|
3506
|
+
assert.throws(() => parseCommandEnvelope(JSON.stringify({
|
|
3507
|
+
command: "set_agent",
|
|
3508
|
+
session_id: "session-1",
|
|
3509
|
+
...(agent !== undefined ? { agent } : {}),
|
|
3510
|
+
})), /set_agent\.agent must be a non-empty string or null/);
|
|
3511
|
+
}
|
|
3512
|
+
});
|
|
3513
|
+
test("applySessionEffort uses live flag settings for xhigh and max", async () => {
|
|
3514
|
+
const calls = [];
|
|
3515
|
+
const query = {
|
|
3516
|
+
async applyFlagSettings(settings) {
|
|
3517
|
+
calls.push(settings);
|
|
3518
|
+
},
|
|
3519
|
+
};
|
|
3520
|
+
await applySessionEffort(query, "xhigh");
|
|
3521
|
+
await applySessionEffort(query, "max");
|
|
3522
|
+
assert.deepEqual(calls, [{ effortLevel: "xhigh" }, { effortLevel: "max" }]);
|
|
3523
|
+
});
|
|
3524
|
+
test("applySessionAgent uses live flag settings for agent switch and reset", async () => {
|
|
3525
|
+
const calls = [];
|
|
3526
|
+
const query = {
|
|
3527
|
+
async applyFlagSettings(settings) {
|
|
3528
|
+
calls.push(settings);
|
|
3529
|
+
},
|
|
3530
|
+
};
|
|
3531
|
+
await applySessionAgent(query, "reviewer");
|
|
3532
|
+
await applySessionAgent(query, null);
|
|
3533
|
+
assert.deepEqual(calls, [{ agent: "reviewer" }, { agent: null }]);
|
|
3534
|
+
});
|
|
3535
|
+
test("emitEffortConfigOptionUpdate publishes effortLevel config option", () => {
|
|
3536
|
+
const events = captureBridgeEvents(() => {
|
|
3537
|
+
emitEffortConfigOptionUpdate("session-1", "max");
|
|
3538
|
+
});
|
|
3539
|
+
assert.deepEqual(events.at(-1), {
|
|
3540
|
+
event: "session_update",
|
|
3541
|
+
session_id: "session-1",
|
|
3542
|
+
update: {
|
|
3543
|
+
type: "config_option_update",
|
|
3544
|
+
option_id: "effortLevel",
|
|
3545
|
+
value: "max",
|
|
3546
|
+
},
|
|
3547
|
+
});
|
|
3548
|
+
});
|
|
3549
|
+
test("emitAgentConfigOptionUpdate publishes agent config option", () => {
|
|
3550
|
+
const events = captureBridgeEvents(() => {
|
|
3551
|
+
emitAgentConfigOptionUpdate("session-1", null);
|
|
3552
|
+
});
|
|
3553
|
+
assert.deepEqual(events.at(-1), {
|
|
3554
|
+
event: "session_update",
|
|
3555
|
+
session_id: "session-1",
|
|
3556
|
+
update: {
|
|
3557
|
+
type: "config_option_update",
|
|
3558
|
+
option_id: "agent",
|
|
3559
|
+
value: null,
|
|
3560
|
+
},
|
|
3561
|
+
});
|
|
3562
|
+
});
|
|
1386
3563
|
test("shouldEmitStartupAuthRequiredForAccount keeps legacy first-party behavior", () => {
|
|
1387
3564
|
assert.equal(shouldEmitStartupAuthRequiredForAccount({}), true);
|
|
1388
3565
|
assert.equal(shouldEmitStartupAuthRequiredForAccount({ apiProvider: "firstParty" }), true);
|
|
@@ -1400,12 +3577,30 @@ test("shouldEmitStartupAuthRequiredForAccount skips Claude OAuth hint for extern
|
|
|
1400
3577
|
"bedrock",
|
|
1401
3578
|
"vertex",
|
|
1402
3579
|
"foundry",
|
|
3580
|
+
"gateway",
|
|
1403
3581
|
"anthropicAws",
|
|
1404
3582
|
"mantle",
|
|
1405
3583
|
]) {
|
|
1406
3584
|
assert.equal(shouldEmitStartupAuthRequiredForAccount({ apiProvider }), false);
|
|
1407
3585
|
}
|
|
1408
3586
|
});
|
|
3587
|
+
test("mapSdkAccountInfo normalizes SDK account metadata through one bridge DTO", () => {
|
|
3588
|
+
assert.deepEqual(mapSdkAccountInfo({
|
|
3589
|
+
email: " user@example.com ",
|
|
3590
|
+
organization: " org-1 ",
|
|
3591
|
+
subscriptionType: " Claude Max ",
|
|
3592
|
+
tokenSource: " oauth ",
|
|
3593
|
+
apiKeySource: " user ",
|
|
3594
|
+
apiProvider: "gateway",
|
|
3595
|
+
}), {
|
|
3596
|
+
email: "user@example.com",
|
|
3597
|
+
organization: "org-1",
|
|
3598
|
+
subscription_type: "Claude Max",
|
|
3599
|
+
token_source: "oauth",
|
|
3600
|
+
api_key_source: "user",
|
|
3601
|
+
api_provider: "gateway",
|
|
3602
|
+
});
|
|
3603
|
+
});
|
|
1409
3604
|
test("handleSdkMessage emits settings parse errors from defensive payloads", () => {
|
|
1410
3605
|
const session = makeSessionState();
|
|
1411
3606
|
const events = captureBridgeEvents(() => {
|
|
@@ -1479,12 +3674,182 @@ test("createToolCall builds write preview diff content", () => {
|
|
|
1479
3674
|
},
|
|
1480
3675
|
]);
|
|
1481
3676
|
});
|
|
1482
|
-
test("createToolCall includes
|
|
3677
|
+
test("createToolCall includes search and webfetch context in title", () => {
|
|
1483
3678
|
const glob = createToolCall("tc-g", "Glob", { pattern: "**/*.md", path: "notes" });
|
|
1484
3679
|
assert.equal(glob.title, "Glob **/*.md in notes");
|
|
3680
|
+
const grep = createToolCall("tc-grep", "Grep", {
|
|
3681
|
+
pattern: "TODO",
|
|
3682
|
+
path: "src",
|
|
3683
|
+
glob: "**/*.rs",
|
|
3684
|
+
output_mode: "content",
|
|
3685
|
+
"-i": true,
|
|
3686
|
+
"-C": 2,
|
|
3687
|
+
type: "rust",
|
|
3688
|
+
head_limit: 10,
|
|
3689
|
+
offset: 5,
|
|
3690
|
+
multiline: true,
|
|
3691
|
+
});
|
|
3692
|
+
assert.equal(grep.title, "Grep TODO in src (glob **/*.rs, type rust, content, case-insensitive, context 2, limit 10, offset 5, multiline)");
|
|
1485
3693
|
const fetch = createToolCall("tc-f", "WebFetch", { url: "https://example.com" });
|
|
1486
3694
|
assert.equal(fetch.title, "WebFetch https://example.com");
|
|
1487
3695
|
});
|
|
3696
|
+
test("createToolCall builds Agent title from name and type without description fallback", () => {
|
|
3697
|
+
const named = createToolCall("tc-agent-name", "Agent", {
|
|
3698
|
+
description: "review changes",
|
|
3699
|
+
prompt: "Review the branch",
|
|
3700
|
+
name: " review-worker ",
|
|
3701
|
+
subagent_type: " general-purpose ",
|
|
3702
|
+
model: " opus ",
|
|
3703
|
+
});
|
|
3704
|
+
const typed = createToolCall("tc-agent-type", "Agent", {
|
|
3705
|
+
description: "inspect state",
|
|
3706
|
+
prompt: "Inspect the runtime",
|
|
3707
|
+
subagent_type: " general-purpose ",
|
|
3708
|
+
model: " sonnet ",
|
|
3709
|
+
});
|
|
3710
|
+
const describedOnly = createToolCall("tc-agent-description", "Agent", {
|
|
3711
|
+
description: "should not become title",
|
|
3712
|
+
prompt: "Review",
|
|
3713
|
+
});
|
|
3714
|
+
assert.equal(named.title, "Agent: review-worker");
|
|
3715
|
+
assert.equal(typed.title, "Agent: general-purpose");
|
|
3716
|
+
assert.equal(describedOnly.title, "Agent");
|
|
3717
|
+
});
|
|
3718
|
+
test("createToolCall builds worktree titles from input rules", () => {
|
|
3719
|
+
const namedEnter = createToolCall("tc-enter-name", "EnterWorktree", { name: "feature-auth" });
|
|
3720
|
+
assert.equal(namedEnter.kind, "other");
|
|
3721
|
+
assert.equal(namedEnter.title, "feature-auth");
|
|
3722
|
+
const pathEnter = createToolCall("tc-enter-path", "EnterWorktree", {
|
|
3723
|
+
path: "C:\\repo\\.worktrees\\feature-auth",
|
|
3724
|
+
});
|
|
3725
|
+
assert.equal(pathEnter.kind, "other");
|
|
3726
|
+
assert.equal(pathEnter.title, "EnterWorktree");
|
|
3727
|
+
const exit = createToolCall("tc-exit", "ExitWorktree", {
|
|
3728
|
+
action: "remove",
|
|
3729
|
+
discard_changes: true,
|
|
3730
|
+
});
|
|
3731
|
+
assert.equal(exit.kind, "other");
|
|
3732
|
+
assert.equal(exit.title, "ExitWorktree");
|
|
3733
|
+
});
|
|
3734
|
+
test("createToolCall maps cron tools to other kind with stable titles", () => {
|
|
3735
|
+
for (const toolName of ["CronCreate", "CronDelete", "CronList"]) {
|
|
3736
|
+
const toolCall = createToolCall(`tc-${toolName}`, toolName, {
|
|
3737
|
+
cron: "30 9 * * 1",
|
|
3738
|
+
prompt: "Send weekly status",
|
|
3739
|
+
id: "schedule-1",
|
|
3740
|
+
});
|
|
3741
|
+
assert.equal(toolCall.kind, "other");
|
|
3742
|
+
assert.equal(toolCall.title, toolName);
|
|
3743
|
+
}
|
|
3744
|
+
});
|
|
3745
|
+
test("createToolCall maps ScheduleWakeup to other kind with stable title", () => {
|
|
3746
|
+
const toolCall = createToolCall("tc-wakeup", "ScheduleWakeup", {
|
|
3747
|
+
delaySeconds: 90,
|
|
3748
|
+
reason: "Poll again after warmup",
|
|
3749
|
+
prompt: "/loop check status",
|
|
3750
|
+
});
|
|
3751
|
+
assert.equal(toolCall.kind, "other");
|
|
3752
|
+
assert.equal(toolCall.title, "ScheduleWakeup");
|
|
3753
|
+
});
|
|
3754
|
+
test("createToolCall maps PushNotification to other kind with stable title", () => {
|
|
3755
|
+
const toolCall = createToolCall("tc-push-notification", "PushNotification", {
|
|
3756
|
+
message: "Build finished",
|
|
3757
|
+
status: "proactive",
|
|
3758
|
+
});
|
|
3759
|
+
assert.equal(toolCall.kind, "other");
|
|
3760
|
+
assert.equal(toolCall.title, "PushNotification");
|
|
3761
|
+
});
|
|
3762
|
+
test("createToolCall maps RemoteTrigger to other kind and action title", () => {
|
|
3763
|
+
const toolCall = createToolCall("tc-remote-trigger", "RemoteTrigger", {
|
|
3764
|
+
action: " run ",
|
|
3765
|
+
trigger_id: "deploy-prod",
|
|
3766
|
+
});
|
|
3767
|
+
assert.equal(toolCall.kind, "other");
|
|
3768
|
+
assert.equal(toolCall.title, "RemoteTrigger: run");
|
|
3769
|
+
});
|
|
3770
|
+
test("createToolCall uses RemoteTrigger fallback title without action", () => {
|
|
3771
|
+
const toolCall = createToolCall("tc-remote-trigger-fallback", "RemoteTrigger", {
|
|
3772
|
+
trigger_id: "deploy-prod",
|
|
3773
|
+
});
|
|
3774
|
+
assert.equal(toolCall.kind, "other");
|
|
3775
|
+
assert.equal(toolCall.title, "RemoteTrigger");
|
|
3776
|
+
});
|
|
3777
|
+
test("createToolCall maps REPL to other kind and code title", () => {
|
|
3778
|
+
const toolCall = createToolCall("tc-repl", "REPL", {
|
|
3779
|
+
code: " await inspectState() ",
|
|
3780
|
+
description: "Inspect runtime state",
|
|
3781
|
+
timeout: 45_000,
|
|
3782
|
+
});
|
|
3783
|
+
assert.equal(toolCall.kind, "other");
|
|
3784
|
+
assert.equal(toolCall.title, "REPL: await inspectState()");
|
|
3785
|
+
});
|
|
3786
|
+
test("createToolCall uses REPL fallback title instead of description", () => {
|
|
3787
|
+
const toolCall = createToolCall("tc-repl-fallback", "REPL", {
|
|
3788
|
+
description: "Inspect runtime state",
|
|
3789
|
+
});
|
|
3790
|
+
assert.equal(toolCall.kind, "other");
|
|
3791
|
+
assert.equal(toolCall.title, "REPL");
|
|
3792
|
+
});
|
|
3793
|
+
test("createToolCall maps Monitor to other kind and description title", () => {
|
|
3794
|
+
const toolCall = createToolCall("tc-monitor", "Monitor", {
|
|
3795
|
+
description: "watch deploy logs",
|
|
3796
|
+
timeout_ms: 30000,
|
|
3797
|
+
persistent: false,
|
|
3798
|
+
command: "tail -f deploy.log",
|
|
3799
|
+
});
|
|
3800
|
+
assert.equal(toolCall.kind, "other");
|
|
3801
|
+
assert.equal(toolCall.title, "Monitor: watch deploy logs");
|
|
3802
|
+
});
|
|
3803
|
+
test("createToolCall maps Workflow to other kind and name title", () => {
|
|
3804
|
+
const namedWorkflow = createToolCall("tc-workflow", "Workflow", {
|
|
3805
|
+
name: "spec",
|
|
3806
|
+
args: { topic: "rendering" },
|
|
3807
|
+
});
|
|
3808
|
+
const fallbackWorkflow = createToolCall("tc-workflow-fallback", "Workflow", {
|
|
3809
|
+
script: "export const meta = { name: 'inline', description: 'Run', phases: [] };",
|
|
3810
|
+
});
|
|
3811
|
+
assert.equal(namedWorkflow.kind, "other");
|
|
3812
|
+
assert.equal(namedWorkflow.title, "Workflow: spec");
|
|
3813
|
+
assert.equal(fallbackWorkflow.kind, "other");
|
|
3814
|
+
assert.equal(fallbackWorkflow.title, "Workflow");
|
|
3815
|
+
});
|
|
3816
|
+
test("createToolCall maps project and artifact tools to compact titles", () => {
|
|
3817
|
+
const projectInfo = createToolCall("tc-project-info", "Projects", {
|
|
3818
|
+
method: "project_info",
|
|
3819
|
+
});
|
|
3820
|
+
const projectRead = createToolCall("tc-project-read", "Projects", {
|
|
3821
|
+
method: "project_read",
|
|
3822
|
+
path: "claude/notes.md",
|
|
3823
|
+
});
|
|
3824
|
+
const projectSearch = createToolCall("tc-project-search", "Projects", {
|
|
3825
|
+
method: "project_search",
|
|
3826
|
+
query: "migration",
|
|
3827
|
+
});
|
|
3828
|
+
const artifactWithLabel = createToolCall("tc-artifact-label", "Artifact", {
|
|
3829
|
+
file_path: "C:/work/report.html",
|
|
3830
|
+
favicon: "R",
|
|
3831
|
+
label: "report-v2",
|
|
3832
|
+
});
|
|
3833
|
+
const artifactFallback = createToolCall("tc-artifact-path", "Artifact", {
|
|
3834
|
+
file_path: "C:/work/report.html",
|
|
3835
|
+
favicon: "R",
|
|
3836
|
+
});
|
|
3837
|
+
const rolePicker = createToolCall("tc-role-picker", "ShowOnboardingRolePicker", {});
|
|
3838
|
+
assert.equal(projectInfo.kind, "other");
|
|
3839
|
+
assert.equal(projectInfo.title, "Projects: info");
|
|
3840
|
+
assert.equal(projectRead.title, "Projects: read claude/notes.md");
|
|
3841
|
+
assert.equal(projectSearch.title, "Projects: search migration");
|
|
3842
|
+
assert.equal(artifactWithLabel.kind, "other");
|
|
3843
|
+
assert.equal(artifactWithLabel.title, "Artifact: report-v2");
|
|
3844
|
+
assert.equal(artifactFallback.title, "Artifact: C:/work/report.html");
|
|
3845
|
+
assert.equal(rolePicker.kind, "other");
|
|
3846
|
+
assert.equal(rolePicker.title, "ShowOnboardingRolePicker");
|
|
3847
|
+
});
|
|
3848
|
+
test("createToolCall maps EnterPlanMode to switch_mode kind with stable title", () => {
|
|
3849
|
+
const toolCall = createToolCall("tc-enter-plan-mode", "EnterPlanMode", {});
|
|
3850
|
+
assert.equal(toolCall.kind, "switch_mode");
|
|
3851
|
+
assert.equal(toolCall.title, "EnterPlanMode");
|
|
3852
|
+
});
|
|
1488
3853
|
test("buildToolResultFields extracts plain-text output", () => {
|
|
1489
3854
|
const fields = buildToolResultFields(false, [{ text: "line 1" }, { text: "line 2" }]);
|
|
1490
3855
|
assert.equal(fields.status, "completed");
|
|
@@ -1493,6 +3858,61 @@ test("buildToolResultFields extracts plain-text output", () => {
|
|
|
1493
3858
|
{ type: "content", content: { type: "text", text: "line 1\nline 2" } },
|
|
1494
3859
|
]);
|
|
1495
3860
|
});
|
|
3861
|
+
test("buildToolResultFields renders structured Grep output", () => {
|
|
3862
|
+
const base = createToolCall("tc-grep", "Grep", {
|
|
3863
|
+
pattern: "TODO",
|
|
3864
|
+
path: "src",
|
|
3865
|
+
output_mode: "content",
|
|
3866
|
+
});
|
|
3867
|
+
const fields = buildToolResultFields(false, "raw SDK text", base, {
|
|
3868
|
+
mode: "content",
|
|
3869
|
+
numFiles: 2,
|
|
3870
|
+
filenames: ["src/a.rs", "src/b.rs"],
|
|
3871
|
+
content: "src/a.rs:1:TODO\nsrc/b.rs:2:TODO",
|
|
3872
|
+
numLines: 2,
|
|
3873
|
+
numMatches: 2,
|
|
3874
|
+
appliedLimit: 250,
|
|
3875
|
+
});
|
|
3876
|
+
const expected = "src/a.rs:1:TODO\nsrc/b.rs:2:TODO\nSummary: 2 files, 2 matches, 2 lines, mode content, limit 250";
|
|
3877
|
+
assert.equal(fields.status, "completed");
|
|
3878
|
+
assert.equal(fields.raw_output, expected);
|
|
3879
|
+
assert.deepEqual(fields.content, [
|
|
3880
|
+
{ type: "content", content: { type: "text", text: expected } },
|
|
3881
|
+
]);
|
|
3882
|
+
});
|
|
3883
|
+
test("buildToolResultFields renders structured empty Grep output", () => {
|
|
3884
|
+
const base = createToolCall("tc-grep-empty", "Grep", {
|
|
3885
|
+
pattern: "<rare string>",
|
|
3886
|
+
output_mode: "content",
|
|
3887
|
+
});
|
|
3888
|
+
const fields = buildToolResultFields(false, "No matches found", base, {
|
|
3889
|
+
mode: "content",
|
|
3890
|
+
numFiles: 0,
|
|
3891
|
+
filenames: [],
|
|
3892
|
+
content: "",
|
|
3893
|
+
numLines: 0,
|
|
3894
|
+
});
|
|
3895
|
+
const expected = "No matches found\nSummary: 0 files, 0 lines, mode content";
|
|
3896
|
+
assert.equal(fields.raw_output, expected);
|
|
3897
|
+
assert.deepEqual(fields.content, [
|
|
3898
|
+
{ type: "content", content: { type: "text", text: expected } },
|
|
3899
|
+
]);
|
|
3900
|
+
});
|
|
3901
|
+
test("buildToolResultFields renders structured Glob output", () => {
|
|
3902
|
+
const base = createToolCall("tc-glob", "Glob", { pattern: "**/*.rs", path: "src" });
|
|
3903
|
+
const fields = buildToolResultFields(false, "", base, {
|
|
3904
|
+
durationMs: 12,
|
|
3905
|
+
numFiles: 2,
|
|
3906
|
+
filenames: ["src/main.rs", "src/lib.rs"],
|
|
3907
|
+
truncated: false,
|
|
3908
|
+
});
|
|
3909
|
+
const expected = "2 files found\nsrc/main.rs\nsrc/lib.rs\nDuration: 12ms";
|
|
3910
|
+
assert.equal(fields.status, "completed");
|
|
3911
|
+
assert.equal(fields.raw_output, expected);
|
|
3912
|
+
assert.deepEqual(fields.content, [
|
|
3913
|
+
{ type: "content", content: { type: "text", text: expected } },
|
|
3914
|
+
]);
|
|
3915
|
+
});
|
|
1496
3916
|
test("normalizeToolResultText collapses persisted-output payload to first meaningful line", () => {
|
|
1497
3917
|
const normalized = normalizeToolResultText(`
|
|
1498
3918
|
<persisted-output>
|
|
@@ -1719,6 +4139,106 @@ test("buildToolResultFields restores ReadMcpResource blob paths from transcript
|
|
|
1719
4139
|
},
|
|
1720
4140
|
]);
|
|
1721
4141
|
});
|
|
4142
|
+
test("buildToolResultFields marks ReadMcpResource error output as failed", () => {
|
|
4143
|
+
const base = createToolCall("tc-mcp-error", "ReadMcpResource", {
|
|
4144
|
+
server: "docs",
|
|
4145
|
+
uri: "file://missing.md",
|
|
4146
|
+
});
|
|
4147
|
+
const fields = buildToolResultFields(false, {
|
|
4148
|
+
contents: [],
|
|
4149
|
+
error: "resource not found",
|
|
4150
|
+
}, base);
|
|
4151
|
+
assert.equal(fields.status, "failed");
|
|
4152
|
+
assert.equal(fields.raw_output, "Error: resource not found");
|
|
4153
|
+
assert.deepEqual(fields.content, [
|
|
4154
|
+
{
|
|
4155
|
+
type: "content",
|
|
4156
|
+
content: { type: "text", text: "Error: resource not found" },
|
|
4157
|
+
},
|
|
4158
|
+
]);
|
|
4159
|
+
});
|
|
4160
|
+
test("buildToolResultFields preserves WebFetch artifactRead only as metadata", () => {
|
|
4161
|
+
const base = createToolCall("tc-web-fetch-artifact", "WebFetch", {
|
|
4162
|
+
url: "https://artifact.local/dashboard",
|
|
4163
|
+
});
|
|
4164
|
+
const fields = buildToolResultFields(false, {
|
|
4165
|
+
bytes: 128,
|
|
4166
|
+
code: 200,
|
|
4167
|
+
codeText: "OK",
|
|
4168
|
+
durationMs: 42,
|
|
4169
|
+
result: "Artifact content summary",
|
|
4170
|
+
url: "https://artifact.local/dashboard",
|
|
4171
|
+
artifactRead: {
|
|
4172
|
+
slug: "dashboard",
|
|
4173
|
+
ver: "v3",
|
|
4174
|
+
},
|
|
4175
|
+
}, base);
|
|
4176
|
+
assert.equal(fields.raw_output, "Artifact content summary");
|
|
4177
|
+
assert.equal(fields.raw_output?.includes("artifactRead"), false);
|
|
4178
|
+
assert.deepEqual(fields.output_metadata, {
|
|
4179
|
+
web_fetch: {
|
|
4180
|
+
artifact_read: {
|
|
4181
|
+
slug: "dashboard",
|
|
4182
|
+
ver: "v3",
|
|
4183
|
+
},
|
|
4184
|
+
},
|
|
4185
|
+
});
|
|
4186
|
+
});
|
|
4187
|
+
test("buildToolResultFields preserves Agent resolvedModel metadata", () => {
|
|
4188
|
+
const base = createToolCall("tc-agent-model", "Agent", {
|
|
4189
|
+
description: "review changes",
|
|
4190
|
+
prompt: "Review the branch",
|
|
4191
|
+
});
|
|
4192
|
+
const fields = buildToolResultFields(false, {
|
|
4193
|
+
agentId: "agent-1",
|
|
4194
|
+
agentType: "reviewer",
|
|
4195
|
+
resolvedModel: "claude-sonnet-4-7",
|
|
4196
|
+
content: [{ type: "text", text: "Done" }],
|
|
4197
|
+
totalToolUseCount: 1,
|
|
4198
|
+
totalDurationMs: 100,
|
|
4199
|
+
totalTokens: 25,
|
|
4200
|
+
usage: {
|
|
4201
|
+
input_tokens: 10,
|
|
4202
|
+
output_tokens: 15,
|
|
4203
|
+
cache_creation_input_tokens: null,
|
|
4204
|
+
cache_read_input_tokens: null,
|
|
4205
|
+
server_tool_use: null,
|
|
4206
|
+
service_tier: null,
|
|
4207
|
+
cache_creation: null,
|
|
4208
|
+
},
|
|
4209
|
+
status: "completed",
|
|
4210
|
+
prompt: "Review the branch",
|
|
4211
|
+
}, base);
|
|
4212
|
+
assert.equal(fields.title, "Agent: reviewer");
|
|
4213
|
+
assert.deepEqual(fields.output_metadata, {
|
|
4214
|
+
agent: {
|
|
4215
|
+
resolved_model: "claude-sonnet-4-7",
|
|
4216
|
+
},
|
|
4217
|
+
});
|
|
4218
|
+
});
|
|
4219
|
+
test("buildToolResultFields keeps Agent input name while preserving resolvedModel metadata", () => {
|
|
4220
|
+
const base = createToolCall("tc-agent-named-model", "Agent", {
|
|
4221
|
+
description: "review changes",
|
|
4222
|
+
prompt: "Review the branch",
|
|
4223
|
+
name: "review-worker",
|
|
4224
|
+
subagent_type: "general-purpose",
|
|
4225
|
+
model: "opus",
|
|
4226
|
+
});
|
|
4227
|
+
const fields = buildToolResultFields(false, {
|
|
4228
|
+
agentId: "agent-1",
|
|
4229
|
+
agentType: "general-purpose",
|
|
4230
|
+
resolvedModel: "claude-opus-4-8",
|
|
4231
|
+
content: [{ type: "text", text: "Done" }],
|
|
4232
|
+
status: "completed",
|
|
4233
|
+
prompt: "Review the branch",
|
|
4234
|
+
}, base);
|
|
4235
|
+
assert.equal(fields.title, undefined);
|
|
4236
|
+
assert.deepEqual(fields.output_metadata, {
|
|
4237
|
+
agent: {
|
|
4238
|
+
resolved_model: "claude-opus-4-8",
|
|
4239
|
+
},
|
|
4240
|
+
});
|
|
4241
|
+
});
|
|
1722
4242
|
test("unwrapToolUseResult extracts error/content payload", () => {
|
|
1723
4243
|
const parsed = unwrapToolUseResult({
|
|
1724
4244
|
is_error: true,
|
|
@@ -1857,7 +4377,7 @@ test("looksLikeAuthRequired detects login hints", () => {
|
|
|
1857
4377
|
assert.equal(looksLikeAuthRequired("normal tool output"), false);
|
|
1858
4378
|
});
|
|
1859
4379
|
test("agent sdk version compatibility check matches pinned version", () => {
|
|
1860
|
-
assert.equal(resolveInstalledAgentSdkVersion(), "0.3.
|
|
4380
|
+
assert.equal(resolveInstalledAgentSdkVersion(), "0.3.177");
|
|
1861
4381
|
assert.equal(agentSdkVersionCompatibilityError(), undefined);
|
|
1862
4382
|
});
|
|
1863
4383
|
test("mapSessionMessagesToUpdates maps message content blocks", () => {
|
|
@@ -1918,6 +4438,14 @@ test("mapSessionMessagesToUpdates maps message content blocks", () => {
|
|
|
1918
4438
|
assert.equal(variantCounts.get("agent_message_chunk"), 1);
|
|
1919
4439
|
assert.equal(variantCounts.get("tool_call"), 1);
|
|
1920
4440
|
assert.equal(variantCounts.get("tool_call_update"), 1);
|
|
4441
|
+
const userChunk = updates.find((update) => update.type === "user_message_chunk");
|
|
4442
|
+
const agentChunk = updates.find((update) => update.type === "agent_message_chunk");
|
|
4443
|
+
const toolCall = updates.find((update) => update.type === "tool_call");
|
|
4444
|
+
const toolCallUpdate = updates.find((update) => update.type === "tool_call_update");
|
|
4445
|
+
assert.equal(userChunk?.source_message_uuid, "u1");
|
|
4446
|
+
assert.equal(agentChunk?.source_message_uuid, "a1");
|
|
4447
|
+
assert.equal(toolCall?.tool_call.source_message_uuid, "a1");
|
|
4448
|
+
assert.equal(toolCallUpdate?.tool_call_update.source_message_uuid, "u2");
|
|
1921
4449
|
});
|
|
1922
4450
|
test("mapSessionMessagesToUpdates suppresses ToolSearch history blocks", () => {
|
|
1923
4451
|
const updates = mapSessionMessagesToUpdates([
|
|
@@ -2007,11 +4535,157 @@ test("mapSessionMessagesToUpdates preserves parallel tool results", () => {
|
|
|
2007
4535
|
},
|
|
2008
4536
|
},
|
|
2009
4537
|
]);
|
|
2010
|
-
const toolCalls = updates.filter((update) => update.type === "tool_call");
|
|
2011
|
-
const toolUpdates = updates.filter((update) => update.type === "tool_call_update");
|
|
2012
|
-
assert.deepEqual(toolCalls.map((update) => update.tool_call.tool_call_id), ["tool-a", "tool-b"]);
|
|
2013
|
-
assert.deepEqual(toolUpdates.map((update) => update.tool_call_update.tool_call_id), ["tool-b", "tool-a"]);
|
|
2014
|
-
assert.deepEqual(toolUpdates.map((update) => update.tool_call_update.fields.raw_output), ["result b", "result a"]);
|
|
4538
|
+
const toolCalls = updates.filter((update) => update.type === "tool_call");
|
|
4539
|
+
const toolUpdates = updates.filter((update) => update.type === "tool_call_update");
|
|
4540
|
+
assert.deepEqual(toolCalls.map((update) => update.tool_call.tool_call_id), ["tool-a", "tool-b"]);
|
|
4541
|
+
assert.deepEqual(toolUpdates.map((update) => update.tool_call_update.tool_call_id), ["tool-b", "tool-a"]);
|
|
4542
|
+
assert.deepEqual(toolUpdates.map((update) => update.tool_call_update.fields.raw_output), ["result b", "result a"]);
|
|
4543
|
+
});
|
|
4544
|
+
test("mapSessionMessagesToUpdates maps task system records from resume history", () => {
|
|
4545
|
+
const updates = mapSessionMessagesToUpdates([
|
|
4546
|
+
{
|
|
4547
|
+
type: "assistant",
|
|
4548
|
+
uuid: "assistant-agent",
|
|
4549
|
+
session_id: "s1",
|
|
4550
|
+
parent_tool_use_id: null,
|
|
4551
|
+
message: {
|
|
4552
|
+
role: "assistant",
|
|
4553
|
+
content: [
|
|
4554
|
+
{
|
|
4555
|
+
type: "tool_use",
|
|
4556
|
+
id: "tool-agent",
|
|
4557
|
+
name: "Agent",
|
|
4558
|
+
input: { prompt: "Inspect the migration smoke" },
|
|
4559
|
+
},
|
|
4560
|
+
],
|
|
4561
|
+
},
|
|
4562
|
+
},
|
|
4563
|
+
{
|
|
4564
|
+
type: "system",
|
|
4565
|
+
uuid: "system-bg-start",
|
|
4566
|
+
session_id: "s1",
|
|
4567
|
+
parent_tool_use_id: null,
|
|
4568
|
+
message: {
|
|
4569
|
+
type: "system",
|
|
4570
|
+
subtype: "task_started",
|
|
4571
|
+
task_id: "task-bg",
|
|
4572
|
+
tool_use_id: "tool-agent",
|
|
4573
|
+
description: "Inspect the migration smoke",
|
|
4574
|
+
subagent_type: "general-purpose",
|
|
4575
|
+
},
|
|
4576
|
+
},
|
|
4577
|
+
{
|
|
4578
|
+
type: "system",
|
|
4579
|
+
uuid: "system-bg-update",
|
|
4580
|
+
session_id: "s1",
|
|
4581
|
+
parent_tool_use_id: null,
|
|
4582
|
+
message: {
|
|
4583
|
+
type: "system",
|
|
4584
|
+
subtype: "task_updated",
|
|
4585
|
+
task_id: "task-bg",
|
|
4586
|
+
patch: {
|
|
4587
|
+
status: "running",
|
|
4588
|
+
description: "Checking runtime MCP resources",
|
|
4589
|
+
is_backgrounded: true,
|
|
4590
|
+
},
|
|
4591
|
+
},
|
|
4592
|
+
},
|
|
4593
|
+
{
|
|
4594
|
+
type: "system",
|
|
4595
|
+
uuid: "system-remote-start",
|
|
4596
|
+
session_id: "s1",
|
|
4597
|
+
parent_tool_use_id: null,
|
|
4598
|
+
message: {
|
|
4599
|
+
type: "system",
|
|
4600
|
+
subtype: "task_started",
|
|
4601
|
+
task_id: "task-remote",
|
|
4602
|
+
description: "Remote agent is still running",
|
|
4603
|
+
task_type: "remote_agent",
|
|
4604
|
+
task_description: "Remote agent smoke",
|
|
4605
|
+
prompt: "Continue remotely",
|
|
4606
|
+
},
|
|
4607
|
+
},
|
|
4608
|
+
{
|
|
4609
|
+
type: "system",
|
|
4610
|
+
uuid: "system-mcp-start",
|
|
4611
|
+
session_id: "s1",
|
|
4612
|
+
parent_tool_use_id: null,
|
|
4613
|
+
message: {
|
|
4614
|
+
type: "system",
|
|
4615
|
+
subtype: "task_started",
|
|
4616
|
+
task_id: "task-mcp",
|
|
4617
|
+
description: "MCP task is still running",
|
|
4618
|
+
task_type: "mcp",
|
|
4619
|
+
workflow_name: "docs",
|
|
4620
|
+
prompt: "Read resource",
|
|
4621
|
+
},
|
|
4622
|
+
},
|
|
4623
|
+
]);
|
|
4624
|
+
const taskUpdates = updates.filter((update) => update.type === "task_state_update");
|
|
4625
|
+
assert.equal(taskUpdates.length, 4);
|
|
4626
|
+
assert.deepEqual(taskUpdates.at(1), {
|
|
4627
|
+
type: "task_state_update",
|
|
4628
|
+
source: "task_lifecycle",
|
|
4629
|
+
tasks: [
|
|
4630
|
+
{
|
|
4631
|
+
task_id: "task-bg",
|
|
4632
|
+
subject: "Inspect the migration smoke",
|
|
4633
|
+
description: "Checking runtime MCP resources",
|
|
4634
|
+
status: "in_progress",
|
|
4635
|
+
blocks: [],
|
|
4636
|
+
blocked_by: [],
|
|
4637
|
+
metadata: {
|
|
4638
|
+
subagent_type: "general-purpose",
|
|
4639
|
+
is_backgrounded: true,
|
|
4640
|
+
},
|
|
4641
|
+
source_tool_call_id: "tool-agent",
|
|
4642
|
+
},
|
|
4643
|
+
],
|
|
4644
|
+
removed_task_ids: [],
|
|
4645
|
+
is_complete_snapshot: false,
|
|
4646
|
+
});
|
|
4647
|
+
assert.deepEqual(taskUpdates.at(2), {
|
|
4648
|
+
type: "task_state_update",
|
|
4649
|
+
source: "task_lifecycle",
|
|
4650
|
+
tasks: [
|
|
4651
|
+
{
|
|
4652
|
+
task_id: "task-remote",
|
|
4653
|
+
subject: "Remote agent smoke",
|
|
4654
|
+
description: "Remote agent is still running",
|
|
4655
|
+
status: "in_progress",
|
|
4656
|
+
blocks: [],
|
|
4657
|
+
blocked_by: [],
|
|
4658
|
+
metadata: {
|
|
4659
|
+
task_description: "Remote agent smoke",
|
|
4660
|
+
task_type: "remote_agent",
|
|
4661
|
+
prompt: "Continue remotely",
|
|
4662
|
+
},
|
|
4663
|
+
},
|
|
4664
|
+
],
|
|
4665
|
+
removed_task_ids: [],
|
|
4666
|
+
is_complete_snapshot: false,
|
|
4667
|
+
});
|
|
4668
|
+
assert.deepEqual(taskUpdates.at(3), {
|
|
4669
|
+
type: "task_state_update",
|
|
4670
|
+
source: "task_lifecycle",
|
|
4671
|
+
tasks: [
|
|
4672
|
+
{
|
|
4673
|
+
task_id: "task-mcp",
|
|
4674
|
+
subject: "docs",
|
|
4675
|
+
description: "MCP task is still running",
|
|
4676
|
+
status: "in_progress",
|
|
4677
|
+
blocks: [],
|
|
4678
|
+
blocked_by: [],
|
|
4679
|
+
metadata: {
|
|
4680
|
+
task_type: "mcp",
|
|
4681
|
+
workflow_name: "docs",
|
|
4682
|
+
prompt: "Read resource",
|
|
4683
|
+
},
|
|
4684
|
+
},
|
|
4685
|
+
],
|
|
4686
|
+
removed_task_ids: [],
|
|
4687
|
+
is_complete_snapshot: false,
|
|
4688
|
+
});
|
|
2015
4689
|
});
|
|
2016
4690
|
test("handleResultMessage emits terminal reason on successful turn completion", () => {
|
|
2017
4691
|
const session = makeSessionState();
|
|
@@ -2029,6 +4703,63 @@ test("handleResultMessage emits terminal reason on successful turn completion",
|
|
|
2029
4703
|
terminal_reason: "completed",
|
|
2030
4704
|
});
|
|
2031
4705
|
});
|
|
4706
|
+
test("handleResultMessage ignores success result telemetry fields", () => {
|
|
4707
|
+
const session = makeSessionState();
|
|
4708
|
+
const events = captureBridgeEvents(() => {
|
|
4709
|
+
handleResultMessage(session, {
|
|
4710
|
+
type: "result",
|
|
4711
|
+
subtype: "success",
|
|
4712
|
+
ttft_stream_ms: 42,
|
|
4713
|
+
time_to_request_ms: 12,
|
|
4714
|
+
time_to_request_from_spawn_ms: 7,
|
|
4715
|
+
warm_spare_claimed: true,
|
|
4716
|
+
});
|
|
4717
|
+
});
|
|
4718
|
+
assert.deepEqual(events.at(-1), {
|
|
4719
|
+
event: "turn_complete",
|
|
4720
|
+
session_id: "session-1",
|
|
4721
|
+
});
|
|
4722
|
+
});
|
|
4723
|
+
test("handleResultMessage emits repeated turn_complete while background work remains open", () => {
|
|
4724
|
+
const session = makeSessionState();
|
|
4725
|
+
const events = captureBridgeEvents(() => {
|
|
4726
|
+
emitToolCall(session, "tool-monitor", "Monitor", {
|
|
4727
|
+
description: "watch deploy logs",
|
|
4728
|
+
timeout_ms: 30000,
|
|
4729
|
+
persistent: false,
|
|
4730
|
+
command: "tail -f deploy.log",
|
|
4731
|
+
});
|
|
4732
|
+
emitToolResultUpdate(session, "tool-monitor", false, {
|
|
4733
|
+
taskId: "monitor-1",
|
|
4734
|
+
timeoutMs: 30000,
|
|
4735
|
+
persistent: false,
|
|
4736
|
+
});
|
|
4737
|
+
handleResultMessage(session, {
|
|
4738
|
+
type: "result",
|
|
4739
|
+
subtype: "success",
|
|
4740
|
+
terminal_reason: "completed",
|
|
4741
|
+
});
|
|
4742
|
+
handleResultMessage(session, {
|
|
4743
|
+
type: "result",
|
|
4744
|
+
subtype: "success",
|
|
4745
|
+
terminal_reason: "completed",
|
|
4746
|
+
});
|
|
4747
|
+
});
|
|
4748
|
+
assert.deepEqual(events.filter((event) => event.event === "turn_complete"), [
|
|
4749
|
+
{
|
|
4750
|
+
event: "turn_complete",
|
|
4751
|
+
session_id: "session-1",
|
|
4752
|
+
terminal_reason: "completed",
|
|
4753
|
+
},
|
|
4754
|
+
{
|
|
4755
|
+
event: "turn_complete",
|
|
4756
|
+
session_id: "session-1",
|
|
4757
|
+
terminal_reason: "completed",
|
|
4758
|
+
},
|
|
4759
|
+
]);
|
|
4760
|
+
assert.equal(session.toolCalls.get("tool-monitor")?.status, "in_progress");
|
|
4761
|
+
assert.equal(session.taskToolUseIds.get("monitor-1"), "tool-monitor");
|
|
4762
|
+
});
|
|
2032
4763
|
test("handleResultMessage emits terminal reason on turn errors", () => {
|
|
2033
4764
|
const session = makeSessionState();
|
|
2034
4765
|
const events = captureBridgeEvents(() => {
|
|
@@ -2049,6 +4780,53 @@ test("handleResultMessage emits terminal reason on turn errors", () => {
|
|
|
2049
4780
|
terminal_reason: "max_turns",
|
|
2050
4781
|
});
|
|
2051
4782
|
});
|
|
4783
|
+
test("handleResultMessage emits typed turn error classifications for SDK assistant errors", () => {
|
|
4784
|
+
const cases = [
|
|
4785
|
+
["model_not_found", "model_unavailable"],
|
|
4786
|
+
["oauth_org_not_allowed", "account_access"],
|
|
4787
|
+
["overloaded", "transient_service"],
|
|
4788
|
+
];
|
|
4789
|
+
for (const [assistantError, errorKind] of cases) {
|
|
4790
|
+
const session = makeSessionState();
|
|
4791
|
+
session.lastAssistantError = assistantError;
|
|
4792
|
+
const events = captureBridgeEvents(() => {
|
|
4793
|
+
handleResultMessage(session, {
|
|
4794
|
+
type: "result",
|
|
4795
|
+
subtype: "error_during_execution",
|
|
4796
|
+
errors: [`failed with ${assistantError}`],
|
|
4797
|
+
});
|
|
4798
|
+
});
|
|
4799
|
+
assert.deepEqual(events.at(-1), {
|
|
4800
|
+
event: "turn_error",
|
|
4801
|
+
session_id: "session-1",
|
|
4802
|
+
message: `failed with ${assistantError}`,
|
|
4803
|
+
error_kind: errorKind,
|
|
4804
|
+
sdk_result_subtype: "error_during_execution",
|
|
4805
|
+
assistant_error: assistantError,
|
|
4806
|
+
});
|
|
4807
|
+
}
|
|
4808
|
+
});
|
|
4809
|
+
test("handleResultMessage preserves result api error status", () => {
|
|
4810
|
+
const session = makeSessionState();
|
|
4811
|
+
session.lastAssistantError = "overloaded";
|
|
4812
|
+
const events = captureBridgeEvents(() => {
|
|
4813
|
+
handleResultMessage(session, {
|
|
4814
|
+
type: "result",
|
|
4815
|
+
subtype: "error_during_execution",
|
|
4816
|
+
errors: ["service overloaded"],
|
|
4817
|
+
api_error_status: 529,
|
|
4818
|
+
});
|
|
4819
|
+
});
|
|
4820
|
+
assert.deepEqual(events.at(-1), {
|
|
4821
|
+
event: "turn_error",
|
|
4822
|
+
session_id: "session-1",
|
|
4823
|
+
message: "service overloaded",
|
|
4824
|
+
error_kind: "transient_service",
|
|
4825
|
+
sdk_result_subtype: "error_during_execution",
|
|
4826
|
+
assistant_error: "overloaded",
|
|
4827
|
+
api_error_status: 529,
|
|
4828
|
+
});
|
|
4829
|
+
});
|
|
2052
4830
|
test("mapSessionMessagesToUpdates ignores unsupported records", () => {
|
|
2053
4831
|
const updates = mapSessionMessagesToUpdates([
|
|
2054
4832
|
{
|
|
@@ -2061,115 +4839,594 @@ test("mapSessionMessagesToUpdates ignores unsupported records", () => {
|
|
|
2061
4839
|
content: [{ type: "thinking", thinking: "h" }],
|
|
2062
4840
|
},
|
|
2063
4841
|
},
|
|
4842
|
+
{
|
|
4843
|
+
type: "system",
|
|
4844
|
+
uuid: "system-unsupported",
|
|
4845
|
+
session_id: "s1",
|
|
4846
|
+
parent_tool_use_id: null,
|
|
4847
|
+
message: {
|
|
4848
|
+
type: "system",
|
|
4849
|
+
subtype: "compact_boundary",
|
|
4850
|
+
content: [{ type: "text", text: "internal system text" }],
|
|
4851
|
+
},
|
|
4852
|
+
},
|
|
4853
|
+
]);
|
|
4854
|
+
assert.equal(updates.length, 0);
|
|
4855
|
+
});
|
|
4856
|
+
test("mapSdkSessions normalizes and sorts sessions", () => {
|
|
4857
|
+
const mapped = mapSdkSessions([
|
|
4858
|
+
{
|
|
4859
|
+
sessionId: "older",
|
|
4860
|
+
summary: " Older summary ",
|
|
4861
|
+
lastModified: 100,
|
|
4862
|
+
fileSize: 10,
|
|
4863
|
+
cwd: "C:/work",
|
|
4864
|
+
},
|
|
4865
|
+
{
|
|
4866
|
+
sessionId: "latest",
|
|
4867
|
+
summary: "",
|
|
4868
|
+
lastModified: 200,
|
|
4869
|
+
fileSize: 20,
|
|
4870
|
+
customTitle: "Custom title",
|
|
4871
|
+
gitBranch: "main",
|
|
4872
|
+
firstPrompt: "hello",
|
|
4873
|
+
},
|
|
4874
|
+
]);
|
|
4875
|
+
assert.deepEqual(mapped, [
|
|
4876
|
+
{
|
|
4877
|
+
session_id: "latest",
|
|
4878
|
+
summary: "Custom title",
|
|
4879
|
+
last_modified_ms: 200,
|
|
4880
|
+
file_size_bytes: 20,
|
|
4881
|
+
git_branch: "main",
|
|
4882
|
+
custom_title: "Custom title",
|
|
4883
|
+
first_prompt: "hello",
|
|
4884
|
+
},
|
|
4885
|
+
{
|
|
4886
|
+
session_id: "older",
|
|
4887
|
+
summary: "Older summary",
|
|
4888
|
+
last_modified_ms: 100,
|
|
4889
|
+
file_size_bytes: 10,
|
|
4890
|
+
cwd: "C:/work",
|
|
4891
|
+
},
|
|
4892
|
+
]);
|
|
4893
|
+
});
|
|
4894
|
+
test("buildSessionListOptions scopes repo-local listings to worktrees", () => {
|
|
4895
|
+
assert.deepEqual(buildSessionListOptions("C:/repo"), {
|
|
4896
|
+
dir: "C:/repo",
|
|
4897
|
+
includeWorktrees: true,
|
|
4898
|
+
limit: 50,
|
|
4899
|
+
});
|
|
4900
|
+
assert.deepEqual(buildSessionListOptions(undefined), {
|
|
4901
|
+
limit: 50,
|
|
4902
|
+
});
|
|
4903
|
+
});
|
|
4904
|
+
test("buildToolResultFields renders file_unchanged Read results compactly", () => {
|
|
4905
|
+
const base = createToolCall("tc-read", "Read", { file_path: "src/main.rs" });
|
|
4906
|
+
const fields = buildToolResultFields(false, {
|
|
4907
|
+
type: "file_unchanged",
|
|
4908
|
+
file: { filePath: "src/main.rs" },
|
|
4909
|
+
}, base, {
|
|
4910
|
+
result: {
|
|
4911
|
+
type: "file_unchanged",
|
|
4912
|
+
file: { filePath: "src/main.rs" },
|
|
4913
|
+
},
|
|
4914
|
+
});
|
|
4915
|
+
assert.equal(fields.raw_output, "File unchanged: src/main.rs");
|
|
4916
|
+
assert.deepEqual(fields.content, [
|
|
4917
|
+
{ type: "content", content: { type: "text", text: "File unchanged: src/main.rs" } },
|
|
4918
|
+
]);
|
|
4919
|
+
});
|
|
4920
|
+
test("buildToolResultFields renders array-wrapped file_unchanged Read results compactly", () => {
|
|
4921
|
+
const base = createToolCall("tc-read", "Read", { file_path: "src/lib.rs" });
|
|
4922
|
+
const fields = buildToolResultFields(false, [], base, {
|
|
4923
|
+
result: [
|
|
4924
|
+
{
|
|
4925
|
+
type: "file_unchanged",
|
|
4926
|
+
file: { filePath: "src/lib.rs" },
|
|
4927
|
+
},
|
|
4928
|
+
],
|
|
4929
|
+
});
|
|
4930
|
+
assert.equal(fields.raw_output, "File unchanged: src/lib.rs");
|
|
4931
|
+
});
|
|
4932
|
+
test("buildToolResultFields uses Agent output agentType as task title", () => {
|
|
4933
|
+
const base = createToolCall("tc-agent", "Agent", { prompt: "Review tests" });
|
|
4934
|
+
const fields = buildToolResultFields(false, {
|
|
4935
|
+
agentId: "agent-1",
|
|
4936
|
+
agentType: "reviewer",
|
|
4937
|
+
content: [{ type: "text", text: "Done" }],
|
|
4938
|
+
totalToolUseCount: 0,
|
|
4939
|
+
totalDurationMs: 10,
|
|
4940
|
+
totalTokens: 20,
|
|
4941
|
+
usage: {},
|
|
4942
|
+
status: "completed",
|
|
4943
|
+
prompt: "Review tests",
|
|
4944
|
+
}, base);
|
|
4945
|
+
assert.equal(fields.title, "Agent: reviewer");
|
|
4946
|
+
});
|
|
4947
|
+
test("buildToolResultFields reads array-wrapped Agent output agentType", () => {
|
|
4948
|
+
const base = createToolCall("tc-agent", "Agent", { prompt: "Review tests" });
|
|
4949
|
+
const fields = buildToolResultFields(false, [], base, {
|
|
4950
|
+
result: [
|
|
4951
|
+
{
|
|
4952
|
+
agentId: "agent-1",
|
|
4953
|
+
agentType: "planner",
|
|
4954
|
+
content: [{ type: "text", text: "Done" }],
|
|
4955
|
+
status: "completed",
|
|
4956
|
+
},
|
|
4957
|
+
],
|
|
4958
|
+
});
|
|
4959
|
+
assert.equal(fields.title, "Agent: planner");
|
|
4960
|
+
});
|
|
4961
|
+
test("buildToolResultFields leaves worktree title unchanged on completed output", () => {
|
|
4962
|
+
const enterBase = createToolCall("tc-enter", "EnterWorktree", { name: "feature-auth" });
|
|
4963
|
+
const enterFields = buildToolResultFields(false, {
|
|
4964
|
+
message: "Entered worktree feature-auth",
|
|
4965
|
+
worktreeBranch: "feature-auth",
|
|
4966
|
+
worktreePath: "C:\\repo\\.worktrees\\feature-auth",
|
|
4967
|
+
}, enterBase);
|
|
4968
|
+
assert.equal(enterFields.title, undefined);
|
|
4969
|
+
const exitBase = createToolCall("tc-exit", "ExitWorktree", { action: "keep" });
|
|
4970
|
+
const exitFields = buildToolResultFields(false, {
|
|
4971
|
+
message: "Exited worktree feature-auth",
|
|
4972
|
+
worktreePath: "C:\\repo\\.worktrees\\feature-auth",
|
|
4973
|
+
}, exitBase);
|
|
4974
|
+
assert.equal(exitFields.title, undefined);
|
|
4975
|
+
});
|
|
4976
|
+
test("buildToolResultFields renders worktree location without raw JSON", () => {
|
|
4977
|
+
const enterBase = createToolCall("tc-enter", "EnterWorktree", { name: "feature-auth" });
|
|
4978
|
+
const enterFields = buildToolResultFields(false, {
|
|
4979
|
+
message: "Entered worktree feature-auth",
|
|
4980
|
+
worktreeBranch: "feature-auth",
|
|
4981
|
+
worktreePath: "C:\\repo\\.worktrees\\feature-auth",
|
|
4982
|
+
}, enterBase);
|
|
4983
|
+
assert.equal(enterFields.raw_output, "Branch: feature-auth");
|
|
4984
|
+
assert.deepEqual(enterFields.content, [
|
|
4985
|
+
{ type: "content", content: { type: "text", text: "Branch: feature-auth" } },
|
|
4986
|
+
]);
|
|
4987
|
+
const exitBase = createToolCall("tc-exit", "ExitWorktree", { action: "keep" });
|
|
4988
|
+
const exitFields = buildToolResultFields(false, {
|
|
4989
|
+
message: "Exited worktree feature-auth",
|
|
4990
|
+
worktreePath: "C:\\repo\\.worktrees\\feature-auth",
|
|
4991
|
+
}, exitBase);
|
|
4992
|
+
assert.equal(exitFields.raw_output, "Path: C:\\repo\\.worktrees\\feature-auth");
|
|
4993
|
+
assert.deepEqual(exitFields.content, [
|
|
4994
|
+
{
|
|
4995
|
+
type: "content",
|
|
4996
|
+
content: { type: "text", text: "Path: C:\\repo\\.worktrees\\feature-auth" },
|
|
4997
|
+
},
|
|
4998
|
+
]);
|
|
4999
|
+
});
|
|
5000
|
+
test("buildToolResultFields renders cron outputs as structured text without raw JSON", () => {
|
|
5001
|
+
const createBase = createToolCall("tc-cron-create", "CronCreate", {
|
|
5002
|
+
cron: "30 9 * * 1",
|
|
5003
|
+
prompt: "Send weekly status",
|
|
5004
|
+
});
|
|
5005
|
+
const createFields = buildToolResultFields(false, {
|
|
5006
|
+
id: "schedule-1",
|
|
5007
|
+
humanSchedule: "every Monday at 09:30",
|
|
5008
|
+
recurring: true,
|
|
5009
|
+
durable: false,
|
|
5010
|
+
}, createBase);
|
|
5011
|
+
assert.equal(createFields.raw_output, "Schedule ID: schedule-1\nSchedule: Every Monday at 09:30\nRecurring: yes\nDurable: no");
|
|
5012
|
+
assert.deepEqual(createFields.content, [
|
|
5013
|
+
{
|
|
5014
|
+
type: "content",
|
|
5015
|
+
content: {
|
|
5016
|
+
type: "text",
|
|
5017
|
+
text: "Schedule ID: schedule-1\nSchedule: Every Monday at 09:30\nRecurring: yes\nDurable: no",
|
|
5018
|
+
},
|
|
5019
|
+
},
|
|
5020
|
+
]);
|
|
5021
|
+
assert.equal(createFields.raw_output?.includes("{"), false);
|
|
5022
|
+
const deleteBase = createToolCall("tc-cron-delete", "CronDelete", { id: "schedule-1" });
|
|
5023
|
+
const deleteFields = buildToolResultFields(false, { id: "schedule-1" }, deleteBase);
|
|
5024
|
+
assert.equal(deleteFields.raw_output, "Schedule ID: schedule-1");
|
|
5025
|
+
const listBase = createToolCall("tc-cron-list", "CronList", {});
|
|
5026
|
+
const listFields = buildToolResultFields(false, { jobs: [] }, listBase);
|
|
5027
|
+
assert.equal(listFields.raw_output, "Jobs: none");
|
|
5028
|
+
const singleListFields = buildToolResultFields(false, {
|
|
5029
|
+
jobs: [
|
|
5030
|
+
{
|
|
5031
|
+
id: "schedule-2",
|
|
5032
|
+
cron: "7 * * * *",
|
|
5033
|
+
humanSchedule: "Every hour at :07",
|
|
5034
|
+
prompt: "Send hourly tick",
|
|
5035
|
+
recurring: true,
|
|
5036
|
+
durable: false,
|
|
5037
|
+
},
|
|
5038
|
+
],
|
|
5039
|
+
}, listBase);
|
|
5040
|
+
assert.equal(singleListFields.raw_output, "Schedule ID: schedule-2\nCron: 7 * * * *\nSchedule: Every hour at minute 07\nPrompt: Send hourly tick\nRecurring: yes\nDurable: no");
|
|
5041
|
+
});
|
|
5042
|
+
test("buildToolResultFields preserves full CronList prompt from transcript JSON", () => {
|
|
5043
|
+
const base = createToolCall("tc-cron-list-history", "CronList", {});
|
|
5044
|
+
const fullPrompt = `Review the branch and write a status update. ${"Keep every detail. ".repeat(80)}END`;
|
|
5045
|
+
const transcriptJson = JSON.stringify({
|
|
5046
|
+
jobs: [
|
|
5047
|
+
{
|
|
5048
|
+
id: "schedule-long",
|
|
5049
|
+
cron: "*/5 * * * *",
|
|
5050
|
+
humanSchedule: "every 5 minutes",
|
|
5051
|
+
prompt: fullPrompt,
|
|
5052
|
+
recurring: false,
|
|
5053
|
+
durable: true,
|
|
5054
|
+
},
|
|
5055
|
+
],
|
|
5056
|
+
});
|
|
5057
|
+
const fields = buildToolResultFields(false, transcriptJson, base, {
|
|
5058
|
+
type: "tool_result",
|
|
5059
|
+
tool_use_id: "tc-cron-list-history",
|
|
5060
|
+
content: transcriptJson,
|
|
5061
|
+
});
|
|
5062
|
+
assert.equal(fields.raw_output?.includes(fullPrompt), true);
|
|
5063
|
+
assert.equal(fields.raw_output?.includes("END"), true);
|
|
5064
|
+
assert.equal(fields.raw_output?.includes('"jobs"'), false);
|
|
5065
|
+
assert.deepEqual(fields.content, [
|
|
5066
|
+
{
|
|
5067
|
+
type: "content",
|
|
5068
|
+
content: {
|
|
5069
|
+
type: "text",
|
|
5070
|
+
text: `Schedule ID: schedule-long\nCron: */5 * * * *\nSchedule: Every 5 minutes\nPrompt: ${fullPrompt}\nRecurring: no\nDurable: yes`,
|
|
5071
|
+
},
|
|
5072
|
+
},
|
|
5073
|
+
]);
|
|
5074
|
+
});
|
|
5075
|
+
test("buildToolResultFields renders readable cron schedule text from common cron expressions", () => {
|
|
5076
|
+
const base = createToolCall("tc-cron-readable", "CronList", {});
|
|
5077
|
+
const fields = buildToolResultFields(false, {
|
|
5078
|
+
jobs: [
|
|
5079
|
+
{ id: "every-minute", cron: "* * * * *", prompt: "minute", recurring: true },
|
|
5080
|
+
{ id: "every-five-minutes", cron: "*/5 * * * *", prompt: "minutes", recurring: true },
|
|
5081
|
+
{
|
|
5082
|
+
id: "hourly-minute",
|
|
5083
|
+
cron: "7 * * * *",
|
|
5084
|
+
humanSchedule: "Every hour at :07",
|
|
5085
|
+
prompt: "hourly",
|
|
5086
|
+
recurring: true,
|
|
5087
|
+
},
|
|
5088
|
+
{ id: "every-two-hours", cron: "0 */2 * * *", prompt: "hours", recurring: true },
|
|
5089
|
+
{ id: "daily", cron: "30 9 * * *", prompt: "daily", recurring: true },
|
|
5090
|
+
{ id: "weekly", cron: "30 9 * * 1", prompt: "weekly", recurring: true },
|
|
5091
|
+
{ id: "monthly", cron: "30 9 15 * *", prompt: "monthly", recurring: true },
|
|
5092
|
+
{ id: "yearly", cron: "30 9 15 6 *", prompt: "yearly", recurring: true },
|
|
5093
|
+
{ id: "complex", cron: "0 9 1 * 1", prompt: "complex", recurring: true },
|
|
5094
|
+
],
|
|
5095
|
+
}, base);
|
|
5096
|
+
assert.equal(fields.raw_output?.includes("Cron: 7 * * * *"), false);
|
|
5097
|
+
assert.equal(fields.raw_output?.includes("Recurring:"), false);
|
|
5098
|
+
assert.equal(fields.raw_output?.includes("Durable:"), false);
|
|
5099
|
+
assert.equal(fields.raw_output?.includes("Schedule: Every minute"), true);
|
|
5100
|
+
assert.equal(fields.raw_output?.includes("Schedule: Every 5 minutes"), true);
|
|
5101
|
+
assert.equal(fields.raw_output?.includes("Schedule: Every hour at minute 07"), true);
|
|
5102
|
+
assert.equal(fields.raw_output?.includes("Schedule: Every 2 hours on the hour"), true);
|
|
5103
|
+
assert.equal(fields.raw_output?.includes("Schedule: Every day at 09:30"), true);
|
|
5104
|
+
assert.equal(fields.raw_output?.includes("Schedule: Every Monday at 09:30"), true);
|
|
5105
|
+
assert.equal(fields.raw_output?.includes("Schedule: Every month on day 15 at 09:30"), true);
|
|
5106
|
+
assert.equal(fields.raw_output?.includes("Schedule: Every June 15 at 09:30"), true);
|
|
5107
|
+
assert.equal(fields.raw_output?.includes("Cron: 0 9 1 * 1"), true);
|
|
5108
|
+
assert.equal(fields.raw_output?.split("__cron_list_job_divider__").length, 9);
|
|
5109
|
+
});
|
|
5110
|
+
test("buildToolResultFields renders ScheduleWakeup output as structured text", () => {
|
|
5111
|
+
const base = createToolCall("tc-wakeup", "ScheduleWakeup", {
|
|
5112
|
+
delaySeconds: 30,
|
|
5113
|
+
reason: "Retry after runtime clamp",
|
|
5114
|
+
prompt: "/loop keep checking",
|
|
5115
|
+
});
|
|
5116
|
+
const fields = buildToolResultFields(false, {
|
|
5117
|
+
scheduledFor: 1_779_990_000_000,
|
|
5118
|
+
clampedDelaySeconds: 90,
|
|
5119
|
+
wasClamped: true,
|
|
5120
|
+
}, base);
|
|
5121
|
+
assert.match(fields.raw_output ?? "", /^Scheduled for: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} local\nActual delay: 1m 30s\nClamped: yes$/);
|
|
5122
|
+
assert.equal(fields.raw_output?.includes("{"), false);
|
|
5123
|
+
assert.equal(fields.raw_output?.includes("1779990000000"), false);
|
|
5124
|
+
assert.deepEqual(fields.content, [
|
|
5125
|
+
{
|
|
5126
|
+
type: "content",
|
|
5127
|
+
content: {
|
|
5128
|
+
type: "text",
|
|
5129
|
+
text: fields.raw_output,
|
|
5130
|
+
},
|
|
5131
|
+
},
|
|
5132
|
+
]);
|
|
5133
|
+
});
|
|
5134
|
+
test("buildToolResultFields parses ScheduleWakeup transcript JSON", () => {
|
|
5135
|
+
const base = createToolCall("tc-wakeup-history", "ScheduleWakeup", {
|
|
5136
|
+
delaySeconds: 3600,
|
|
5137
|
+
reason: "Wake at the next loop interval",
|
|
5138
|
+
prompt: "/loop continue",
|
|
5139
|
+
});
|
|
5140
|
+
const transcriptJson = JSON.stringify({
|
|
5141
|
+
scheduledFor: 1_779_990_000_000,
|
|
5142
|
+
clampedDelaySeconds: 3600,
|
|
5143
|
+
wasClamped: false,
|
|
5144
|
+
});
|
|
5145
|
+
const fields = buildToolResultFields(false, transcriptJson, base, {
|
|
5146
|
+
type: "tool_result",
|
|
5147
|
+
tool_use_id: "tc-wakeup-history",
|
|
5148
|
+
content: transcriptJson,
|
|
5149
|
+
});
|
|
5150
|
+
assert.match(fields.raw_output ?? "", /Actual delay: 1h\nClamped: no$/);
|
|
5151
|
+
assert.equal(fields.raw_output?.includes('"scheduledFor"'), false);
|
|
5152
|
+
});
|
|
5153
|
+
test("buildToolResultFields renders PushNotification output as structured text", () => {
|
|
5154
|
+
const base = createToolCall("tc-push-notification", "PushNotification", {
|
|
5155
|
+
message: "Build finished",
|
|
5156
|
+
status: "proactive",
|
|
5157
|
+
});
|
|
5158
|
+
const fields = buildToolResultFields(false, {
|
|
5159
|
+
message: "Build finished",
|
|
5160
|
+
pushSent: false,
|
|
5161
|
+
localSent: true,
|
|
5162
|
+
disabledReason: "config_off",
|
|
5163
|
+
idleSec: 90,
|
|
5164
|
+
hasFocus: false,
|
|
5165
|
+
sentAt: "2026-06-05T12:34:56.000Z",
|
|
5166
|
+
}, base);
|
|
5167
|
+
assert.match(fields.raw_output ?? "", /^Push sent: no\nLocal sent: yes\nDisabled reason: notifications disabled\nIdle time: 1m 30s\nApp focused: no\nSent at: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} local$/);
|
|
5168
|
+
assert.equal(fields.raw_output?.includes("Result:"), false);
|
|
5169
|
+
assert.equal(fields.raw_output?.includes("{"), false);
|
|
5170
|
+
assert.deepEqual(fields.content, [
|
|
5171
|
+
{
|
|
5172
|
+
type: "content",
|
|
5173
|
+
content: {
|
|
5174
|
+
type: "text",
|
|
5175
|
+
text: fields.raw_output,
|
|
5176
|
+
},
|
|
5177
|
+
},
|
|
2064
5178
|
]);
|
|
2065
|
-
assert.equal(updates.length, 0);
|
|
2066
5179
|
});
|
|
2067
|
-
test("
|
|
2068
|
-
const
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
5180
|
+
test("buildToolResultFields parses PushNotification transcript JSON", () => {
|
|
5181
|
+
const base = createToolCall("tc-push-notification-history", "PushNotification", {
|
|
5182
|
+
message: "Deploy completed",
|
|
5183
|
+
status: "proactive",
|
|
5184
|
+
});
|
|
5185
|
+
const transcriptJson = JSON.stringify({
|
|
5186
|
+
message: "Notification queued",
|
|
5187
|
+
pushSent: true,
|
|
5188
|
+
localSent: false,
|
|
5189
|
+
disabledReason: "no_transport",
|
|
5190
|
+
idleSec: 3600,
|
|
5191
|
+
hasFocus: true,
|
|
5192
|
+
sentAt: "not an iso timestamp",
|
|
5193
|
+
});
|
|
5194
|
+
const fields = buildToolResultFields(false, transcriptJson, base, {
|
|
5195
|
+
type: "tool_result",
|
|
5196
|
+
tool_use_id: "tc-push-notification-history",
|
|
5197
|
+
content: transcriptJson,
|
|
5198
|
+
});
|
|
5199
|
+
assert.equal(fields.raw_output, "Result: Notification queued\nPush sent: yes\nLocal sent: no\nDisabled reason: no notification transport\nIdle time: 1h\nApp focused: yes\nSent at: not an iso timestamp");
|
|
5200
|
+
assert.equal(fields.raw_output?.includes('"pushSent"'), false);
|
|
5201
|
+
});
|
|
5202
|
+
test("buildToolResultFields renders RemoteTrigger summary without raw JSON", () => {
|
|
5203
|
+
const base = createToolCall("tc-remote-trigger", "RemoteTrigger", {
|
|
5204
|
+
action: "run",
|
|
5205
|
+
trigger_id: "deploy-prod",
|
|
5206
|
+
});
|
|
5207
|
+
const fields = buildToolResultFields(false, {
|
|
5208
|
+
status: 200,
|
|
5209
|
+
json: '{\n "ok": true,\n "run_id": "run-1"\n}',
|
|
5210
|
+
summary: "Trigger completed",
|
|
5211
|
+
}, base);
|
|
5212
|
+
assert.equal(fields.status, "completed");
|
|
5213
|
+
assert.equal(fields.raw_output, "Status: 200\nSummary: Trigger completed");
|
|
5214
|
+
assert.equal(fields.raw_output?.includes("run_id"), false);
|
|
5215
|
+
assert.deepEqual(fields.content, [
|
|
2076
5216
|
{
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
gitBranch: "main",
|
|
2083
|
-
firstPrompt: "hello",
|
|
5217
|
+
type: "content",
|
|
5218
|
+
content: {
|
|
5219
|
+
type: "text",
|
|
5220
|
+
text: "Status: 200\nSummary: Trigger completed",
|
|
5221
|
+
},
|
|
2084
5222
|
},
|
|
2085
5223
|
]);
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
5224
|
+
});
|
|
5225
|
+
test("buildToolResultFields renders RemoteTrigger response when summary is absent", () => {
|
|
5226
|
+
const base = createToolCall("tc-remote-trigger-response", "RemoteTrigger", {
|
|
5227
|
+
action: "get",
|
|
5228
|
+
trigger_id: "deploy-prod",
|
|
5229
|
+
});
|
|
5230
|
+
const fields = buildToolResultFields(false, {
|
|
5231
|
+
status: 200,
|
|
5232
|
+
json: '{\n "ok": true,\n "trigger_id": "deploy-prod"\n}',
|
|
5233
|
+
}, base);
|
|
5234
|
+
assert.equal(fields.status, "completed");
|
|
5235
|
+
assert.equal(fields.raw_output, 'Status: 200\nResponse: {"ok":true,"trigger_id":"deploy-prod"}');
|
|
5236
|
+
assert.equal(fields.raw_output?.includes('"json"'), false);
|
|
5237
|
+
});
|
|
5238
|
+
test("buildToolResultFields marks RemoteTrigger 4xx output failed", () => {
|
|
5239
|
+
const base = createToolCall("tc-remote-trigger-error", "RemoteTrigger", {
|
|
5240
|
+
action: "run",
|
|
5241
|
+
trigger_id: "missing-trigger",
|
|
5242
|
+
});
|
|
5243
|
+
const fields = buildToolResultFields(false, {
|
|
5244
|
+
status: 404,
|
|
5245
|
+
json: '{"error":"not_found"}',
|
|
5246
|
+
summary: "Trigger not found",
|
|
5247
|
+
}, base);
|
|
5248
|
+
assert.equal(fields.status, "failed");
|
|
5249
|
+
assert.equal(fields.raw_output, "Status: 404\nSummary: Trigger not found");
|
|
5250
|
+
});
|
|
5251
|
+
test("buildToolResultFields parses RemoteTrigger transcript JSON", () => {
|
|
5252
|
+
const base = createToolCall("tc-remote-trigger-history", "RemoteTrigger", {
|
|
5253
|
+
action: "get",
|
|
5254
|
+
trigger_id: "deploy-prod",
|
|
5255
|
+
});
|
|
5256
|
+
const transcriptJson = JSON.stringify({
|
|
5257
|
+
status: 200,
|
|
5258
|
+
json: '{\n "enabled": true,\n "name": "Deploy prod"\n}',
|
|
5259
|
+
});
|
|
5260
|
+
const fields = buildToolResultFields(false, transcriptJson, base, {
|
|
5261
|
+
type: "tool_result",
|
|
5262
|
+
tool_use_id: "tc-remote-trigger-history",
|
|
5263
|
+
content: transcriptJson,
|
|
5264
|
+
});
|
|
5265
|
+
assert.equal(fields.raw_output, 'Status: 200\nResponse: {"enabled":true,"name":"Deploy prod"}');
|
|
5266
|
+
assert.equal(fields.raw_output?.includes('"json"'), false);
|
|
5267
|
+
});
|
|
5268
|
+
test("buildToolResultFields renders REPL output as structured text without raw JSON", () => {
|
|
5269
|
+
const base = createToolCall("tc-repl", "REPL", {
|
|
5270
|
+
code: "await main()",
|
|
5271
|
+
description: "Run main function",
|
|
5272
|
+
});
|
|
5273
|
+
const fields = buildToolResultFields(false, {
|
|
5274
|
+
code: "await main()",
|
|
5275
|
+
stdout: "done",
|
|
5276
|
+
stderr: "warning",
|
|
5277
|
+
result: { ok: true },
|
|
5278
|
+
registeredTools: ["fetchDocs", "parse"],
|
|
5279
|
+
images: [
|
|
5280
|
+
{ base64: "image-one-base64", mediaType: "image/png" },
|
|
5281
|
+
{ base64: "image-two-base64", mediaType: "image/png" },
|
|
5282
|
+
],
|
|
5283
|
+
documents: [{ base64: "document-base64" }],
|
|
5284
|
+
}, base);
|
|
5285
|
+
assert.equal(fields.status, "completed");
|
|
5286
|
+
assert.equal(fields.raw_output, "Stdout: done\nStderr: warning\nResult: {\"ok\":true}\nRegistered tools: fetchDocs, parse\nImages: 2\nDocuments: 1");
|
|
5287
|
+
assert.equal(fields.raw_output?.includes("await main()"), false);
|
|
5288
|
+
assert.equal(fields.raw_output?.includes("image-one-base64"), false);
|
|
5289
|
+
assert.equal(fields.raw_output?.includes("document-base64"), false);
|
|
5290
|
+
assert.equal(fields.raw_output?.includes("{\"code\""), false);
|
|
5291
|
+
assert.deepEqual(fields.content, [
|
|
2096
5292
|
{
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
5293
|
+
type: "content",
|
|
5294
|
+
content: {
|
|
5295
|
+
type: "text",
|
|
5296
|
+
text: fields.raw_output,
|
|
5297
|
+
},
|
|
2102
5298
|
},
|
|
2103
5299
|
]);
|
|
2104
5300
|
});
|
|
2105
|
-
test("
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
includeWorktrees: true,
|
|
2109
|
-
limit: 50,
|
|
5301
|
+
test("buildToolResultFields marks REPL error output failed", () => {
|
|
5302
|
+
const base = createToolCall("tc-repl-error", "REPL", {
|
|
5303
|
+
code: "throw new Error('boom')",
|
|
2110
5304
|
});
|
|
2111
|
-
|
|
2112
|
-
|
|
5305
|
+
const fields = buildToolResultFields(false, {
|
|
5306
|
+
code: "throw new Error('boom')",
|
|
5307
|
+
error: "boom",
|
|
5308
|
+
stdout: "",
|
|
5309
|
+
stderr: "stack trace",
|
|
5310
|
+
result: {},
|
|
5311
|
+
}, base);
|
|
5312
|
+
assert.equal(fields.status, "failed");
|
|
5313
|
+
assert.equal(fields.raw_output, "Error: boom\nStderr: stack trace");
|
|
5314
|
+
});
|
|
5315
|
+
test("buildToolResultFields parses REPL transcript JSON", () => {
|
|
5316
|
+
const base = createToolCall("tc-repl-history", "REPL", {
|
|
5317
|
+
code: "await load()",
|
|
5318
|
+
});
|
|
5319
|
+
const transcriptJson = JSON.stringify({
|
|
5320
|
+
code: "await load()",
|
|
5321
|
+
stdout: "loaded",
|
|
5322
|
+
stderr: "",
|
|
5323
|
+
result: { count: 2 },
|
|
5324
|
+
registeredTools: ["lookup"],
|
|
5325
|
+
images: [{ base64: "hidden-image", mediaType: "image/png" }],
|
|
5326
|
+
documents: [{ base64: "hidden-document" }, { base64: "hidden-document-2" }],
|
|
5327
|
+
});
|
|
5328
|
+
const fields = buildToolResultFields(false, transcriptJson, base, {
|
|
5329
|
+
type: "tool_result",
|
|
5330
|
+
tool_use_id: "tc-repl-history",
|
|
5331
|
+
content: transcriptJson,
|
|
2113
5332
|
});
|
|
5333
|
+
assert.equal(fields.raw_output, "Stdout: loaded\nResult: {\"count\":2}\nRegistered tools: lookup\nImages: 1\nDocuments: 2");
|
|
5334
|
+
assert.equal(fields.raw_output?.includes('"code"'), false);
|
|
5335
|
+
assert.equal(fields.raw_output?.includes("hidden-image"), false);
|
|
5336
|
+
assert.equal(fields.raw_output?.includes("hidden-document"), false);
|
|
2114
5337
|
});
|
|
2115
|
-
test("buildToolResultFields renders
|
|
2116
|
-
const base = createToolCall("tc-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
result: {
|
|
2122
|
-
type: "file_unchanged",
|
|
2123
|
-
file: { filePath: "src/main.rs" },
|
|
2124
|
-
},
|
|
5338
|
+
test("buildToolResultFields renders Monitor launch output as structured text", () => {
|
|
5339
|
+
const base = createToolCall("tc-monitor", "Monitor", {
|
|
5340
|
+
description: "watch deploy logs",
|
|
5341
|
+
timeout_ms: 30000,
|
|
5342
|
+
persistent: false,
|
|
5343
|
+
command: "tail -f deploy.log",
|
|
2125
5344
|
});
|
|
2126
|
-
|
|
5345
|
+
const fields = buildToolResultFields(false, { taskId: "monitor-1", timeoutMs: 30000, persistent: false }, base);
|
|
5346
|
+
assert.equal(fields.status, "in_progress");
|
|
5347
|
+
assert.equal(fields.raw_output, "Task ID: monitor-1\nPersistent: no\nTimeout: 30s");
|
|
5348
|
+
assert.equal(fields.raw_output?.includes("{"), false);
|
|
2127
5349
|
assert.deepEqual(fields.content, [
|
|
2128
|
-
{
|
|
5350
|
+
{
|
|
5351
|
+
type: "content",
|
|
5352
|
+
content: {
|
|
5353
|
+
type: "text",
|
|
5354
|
+
text: fields.raw_output,
|
|
5355
|
+
},
|
|
5356
|
+
},
|
|
2129
5357
|
]);
|
|
2130
5358
|
});
|
|
2131
|
-
test("buildToolResultFields renders
|
|
2132
|
-
const base = createToolCall("tc-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
{
|
|
2136
|
-
type: "file_unchanged",
|
|
2137
|
-
file: { filePath: "src/lib.rs" },
|
|
2138
|
-
},
|
|
2139
|
-
],
|
|
5359
|
+
test("buildToolResultFields renders Workflow launch output as structured text", () => {
|
|
5360
|
+
const base = createToolCall("tc-workflow", "Workflow", {
|
|
5361
|
+
name: "spec",
|
|
5362
|
+
args: { topic: "rendering" },
|
|
2140
5363
|
});
|
|
2141
|
-
|
|
5364
|
+
const fields = buildToolResultFields(false, {
|
|
5365
|
+
status: "async_launched",
|
|
5366
|
+
taskId: "workflow-1",
|
|
5367
|
+
taskType: "local_workflow",
|
|
5368
|
+
workflowName: "spec",
|
|
5369
|
+
runId: "run-1",
|
|
5370
|
+
summary: "Workflow started",
|
|
5371
|
+
transcriptDir: "C:/tmp/transcripts",
|
|
5372
|
+
scriptPath: "C:/tmp/workflow.js",
|
|
5373
|
+
warning: "branch diverged",
|
|
5374
|
+
}, base);
|
|
5375
|
+
assert.equal(fields.status, "in_progress");
|
|
5376
|
+
assert.equal(fields.raw_output, "Status: async launched\nTask ID: workflow-1\nTask type: local_workflow\nWorkflow name: spec\nRun ID: run-1\nSummary: Workflow started\nTranscript dir: C:/tmp/transcripts\nScript path: C:/tmp/workflow.js\nWarning: branch diverged");
|
|
5377
|
+
assert.equal(fields.raw_output?.includes("{"), false);
|
|
5378
|
+
assert.equal(fields.raw_output?.includes('"status"'), false);
|
|
2142
5379
|
});
|
|
2143
|
-
test("buildToolResultFields
|
|
2144
|
-
const base = createToolCall("tc-
|
|
5380
|
+
test("buildToolResultFields marks Workflow output with error as failed", () => {
|
|
5381
|
+
const base = createToolCall("tc-workflow-error", "Workflow", {
|
|
5382
|
+
script: "bad workflow script",
|
|
5383
|
+
});
|
|
2145
5384
|
const fields = buildToolResultFields(false, {
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
totalToolUseCount: 0,
|
|
2150
|
-
totalDurationMs: 10,
|
|
2151
|
-
totalTokens: 20,
|
|
2152
|
-
usage: {},
|
|
2153
|
-
status: "completed",
|
|
2154
|
-
prompt: "Review tests",
|
|
5385
|
+
status: "async_launched",
|
|
5386
|
+
taskId: "workflow-err",
|
|
5387
|
+
error: "Syntax check failed",
|
|
2155
5388
|
}, base);
|
|
2156
|
-
assert.equal(fields.
|
|
5389
|
+
assert.equal(fields.status, "failed");
|
|
5390
|
+
assert.equal(fields.raw_output, "Status: async launched\nTask ID: workflow-err\nError: Syntax check failed");
|
|
5391
|
+
assert.equal(fields.raw_output?.includes("bad workflow script"), false);
|
|
2157
5392
|
});
|
|
2158
|
-
test("buildToolResultFields
|
|
2159
|
-
const base = createToolCall("tc-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
5393
|
+
test("buildToolResultFields parses Workflow transcript JSON", () => {
|
|
5394
|
+
const base = createToolCall("tc-workflow-history", "Workflow", {
|
|
5395
|
+
name: "remote-spec",
|
|
5396
|
+
});
|
|
5397
|
+
const transcriptJson = JSON.stringify({
|
|
5398
|
+
status: "remote_launched",
|
|
5399
|
+
taskId: "workflow-remote",
|
|
5400
|
+
sessionUrl: "https://claude.ai/session/remote",
|
|
5401
|
+
});
|
|
5402
|
+
const fields = buildToolResultFields(false, transcriptJson, base, {
|
|
5403
|
+
type: "tool_result",
|
|
5404
|
+
tool_use_id: "tc-workflow-history",
|
|
5405
|
+
content: transcriptJson,
|
|
5406
|
+
});
|
|
5407
|
+
assert.equal(fields.raw_output, "Status: remote launched\nTask ID: workflow-remote\nSession URL: https://claude.ai/session/remote");
|
|
5408
|
+
assert.equal(fields.raw_output?.includes('"taskId"'), false);
|
|
5409
|
+
});
|
|
5410
|
+
test("buildToolResultFields suppresses EnterPlanMode structured output body", () => {
|
|
5411
|
+
const base = createToolCall("tc-enter-plan-mode", "EnterPlanMode", {});
|
|
5412
|
+
const fields = buildToolResultFields(false, { message: "Plan mode entered" }, base);
|
|
5413
|
+
assert.equal(fields.status, "completed");
|
|
5414
|
+
assert.equal(fields.raw_output, undefined);
|
|
5415
|
+
assert.equal(fields.content, undefined);
|
|
5416
|
+
});
|
|
5417
|
+
test("buildToolResultFields suppresses EnterPlanMode transcript JSON body", () => {
|
|
5418
|
+
const base = createToolCall("tc-enter-plan-mode-history", "EnterPlanMode", {});
|
|
5419
|
+
const transcriptJson = JSON.stringify({ message: "Entered plan mode" });
|
|
5420
|
+
const fields = buildToolResultFields(false, transcriptJson, base, {
|
|
5421
|
+
type: "tool_result",
|
|
5422
|
+
tool_use_id: "tc-enter-plan-mode-history",
|
|
5423
|
+
content: transcriptJson,
|
|
2169
5424
|
});
|
|
2170
|
-
assert.equal(fields.
|
|
5425
|
+
assert.equal(fields.status, "completed");
|
|
5426
|
+
assert.equal(fields.raw_output, undefined);
|
|
5427
|
+
assert.equal(fields.content, undefined);
|
|
2171
5428
|
});
|
|
2172
|
-
test("buildToolResultFields
|
|
5429
|
+
test("buildToolResultFields ignores removed TodoWrite verification metadata", () => {
|
|
2173
5430
|
const base = createToolCall("tc-todo", "TodoWrite", {
|
|
2174
5431
|
todos: [{ content: "Verify changes", status: "pending", activeForm: "Verifying changes" }],
|
|
2175
5432
|
});
|
|
@@ -2180,11 +5437,7 @@ test("buildToolResultFields extracts TodoWrite verification metadata from struct
|
|
|
2180
5437
|
verificationNudgeNeeded: true,
|
|
2181
5438
|
},
|
|
2182
5439
|
});
|
|
2183
|
-
assert.
|
|
2184
|
-
todo_write: {
|
|
2185
|
-
verification_nudge_needed: true,
|
|
2186
|
-
},
|
|
2187
|
-
});
|
|
5440
|
+
assert.equal(fields.output_metadata, undefined);
|
|
2188
5441
|
});
|
|
2189
5442
|
test("mapAvailableModels preserves optional fast and auto mode metadata", () => {
|
|
2190
5443
|
const mapped = mapAvailableModels([
|
|
@@ -2193,7 +5446,7 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
|
|
|
2193
5446
|
displayName: "Claude Sonnet",
|
|
2194
5447
|
description: "Balanced model",
|
|
2195
5448
|
supportsEffort: true,
|
|
2196
|
-
supportedEffortLevels: ["low", "medium", "high", "max"],
|
|
5449
|
+
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
|
|
2197
5450
|
supportsAdaptiveThinking: true,
|
|
2198
5451
|
supportsFastMode: true,
|
|
2199
5452
|
supportsAutoMode: false,
|
|
@@ -2211,7 +5464,7 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
|
|
|
2211
5464
|
display_name: "Claude Sonnet",
|
|
2212
5465
|
description: "Balanced model",
|
|
2213
5466
|
supports_effort: true,
|
|
2214
|
-
supported_effort_levels: ["low", "medium", "high"],
|
|
5467
|
+
supported_effort_levels: ["low", "medium", "high", "xhigh", "max"],
|
|
2215
5468
|
supports_adaptive_thinking: true,
|
|
2216
5469
|
supports_fast_mode: true,
|
|
2217
5470
|
supports_auto_mode: false,
|
|
@@ -2225,6 +5478,43 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
|
|
|
2225
5478
|
},
|
|
2226
5479
|
]);
|
|
2227
5480
|
});
|
|
5481
|
+
test("mapAvailableModels filters unavailable Fable models while preserving unknown ids", () => {
|
|
5482
|
+
const mapped = mapAvailableModels([
|
|
5483
|
+
{
|
|
5484
|
+
value: "fable",
|
|
5485
|
+
displayName: "Claude Fable",
|
|
5486
|
+
description: "Unavailable model alias",
|
|
5487
|
+
supportsEffort: true,
|
|
5488
|
+
},
|
|
5489
|
+
{
|
|
5490
|
+
value: "claude-fable-5",
|
|
5491
|
+
displayName: "Claude Fable 5",
|
|
5492
|
+
description: "Unavailable model",
|
|
5493
|
+
supportsEffort: true,
|
|
5494
|
+
},
|
|
5495
|
+
{
|
|
5496
|
+
value: "claude-fable-5-20260612",
|
|
5497
|
+
displayName: "Claude Fable 5 dated",
|
|
5498
|
+
description: "Unavailable dated model",
|
|
5499
|
+
supportsEffort: true,
|
|
5500
|
+
},
|
|
5501
|
+
{
|
|
5502
|
+
value: "claude-unknown-1",
|
|
5503
|
+
displayName: "Claude Unknown",
|
|
5504
|
+
description: "Unrecognized but available model",
|
|
5505
|
+
supportsEffort: false,
|
|
5506
|
+
},
|
|
5507
|
+
]);
|
|
5508
|
+
assert.deepEqual(mapped, [
|
|
5509
|
+
{
|
|
5510
|
+
id: "claude-unknown-1",
|
|
5511
|
+
display_name: "Claude Unknown",
|
|
5512
|
+
description: "Unrecognized but available model",
|
|
5513
|
+
supports_effort: false,
|
|
5514
|
+
supported_effort_levels: [],
|
|
5515
|
+
},
|
|
5516
|
+
]);
|
|
5517
|
+
});
|
|
2228
5518
|
test("resolveCurrentModel keeps 1M context suffix in short and long display names", () => {
|
|
2229
5519
|
const session = makeSessionState();
|
|
2230
5520
|
session.resolvedRuntimeModelId = "claude-opus-4-7[1m]";
|
|
@@ -2330,7 +5620,7 @@ test("emitCurrentModelUpdate can publish catalog-enriched current model metadata
|
|
|
2330
5620
|
current_model: {
|
|
2331
5621
|
resolved_id: "sonnet",
|
|
2332
5622
|
display_name_short: "Sonnet",
|
|
2333
|
-
display_name_long: "Sonnet",
|
|
5623
|
+
display_name_long: "Claude Sonnet",
|
|
2334
5624
|
catalog_id: "sonnet",
|
|
2335
5625
|
supports_effort: true,
|
|
2336
5626
|
supported_effort_levels: ["low", "medium", "high"],
|
|
@@ -2368,49 +5658,170 @@ test("resolveCurrentModel falls back to the requested model immediately after st
|
|
|
2368
5658
|
const currentModel = resolveCurrentModel(session);
|
|
2369
5659
|
assert.equal(currentModel.resolved_id, "sonnet");
|
|
2370
5660
|
assert.equal(currentModel.display_name_short, "Sonnet");
|
|
2371
|
-
assert.equal(currentModel.display_name_long, "Sonnet");
|
|
5661
|
+
assert.equal(currentModel.display_name_long, "Claude Sonnet");
|
|
2372
5662
|
assert.equal(currentModel.catalog_id, "sonnet");
|
|
2373
5663
|
assert.equal(currentModel.supports_effort, true);
|
|
2374
5664
|
});
|
|
2375
|
-
test("
|
|
2376
|
-
const
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
request: {
|
|
2387
|
-
subtype: "request_user_dialog",
|
|
2388
|
-
dialog_kind: "computer_use_approval",
|
|
2389
|
-
payload: { title: "Need approval", kind: "computer_use_approval" },
|
|
2390
|
-
tool_use_id: "tool-1",
|
|
5665
|
+
test("resolveCurrentModel keeps runtime version in short display while using catalog capabilities", () => {
|
|
5666
|
+
const session = makeSessionState();
|
|
5667
|
+
session.model = "opus";
|
|
5668
|
+
session.requestedModelId = "opus";
|
|
5669
|
+
session.resolvedRuntimeModelId = "claude-opus-4-7-20260101";
|
|
5670
|
+
session.availableModels = [
|
|
5671
|
+
{
|
|
5672
|
+
id: "opus",
|
|
5673
|
+
display_name: "Opus",
|
|
5674
|
+
supports_effort: true,
|
|
5675
|
+
supported_effort_levels: ["low", "medium", "high", "xhigh"],
|
|
2391
5676
|
},
|
|
2392
|
-
|
|
2393
|
-
|
|
5677
|
+
];
|
|
5678
|
+
const currentModel = resolveCurrentModel(session);
|
|
5679
|
+
assert.equal(currentModel.display_name_short, "Opus 4.7");
|
|
5680
|
+
assert.equal(currentModel.display_name_long, "Opus");
|
|
5681
|
+
assert.equal(currentModel.catalog_id, "opus");
|
|
5682
|
+
assert.equal(currentModel.supports_effort, true);
|
|
2394
5683
|
});
|
|
2395
|
-
|
|
2396
|
-
const
|
|
2397
|
-
const
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
5684
|
+
function userDialogHandlerForTest() {
|
|
5685
|
+
const input = new AsyncQueue();
|
|
5686
|
+
const options = buildQueryOptions({
|
|
5687
|
+
cwd: "C:/work",
|
|
5688
|
+
launchSettings: { language: "English" },
|
|
5689
|
+
provisionalSessionId: "session-dialog",
|
|
5690
|
+
input,
|
|
5691
|
+
canUseTool: async () => ({ behavior: "deny", message: "not used" }),
|
|
5692
|
+
enableSdkDebug: false,
|
|
5693
|
+
enableSpawnDebug: false,
|
|
5694
|
+
sessionIdForLogs: () => "session-dialog",
|
|
5695
|
+
});
|
|
5696
|
+
assert.deepEqual(options.supportedDialogKinds, ["refusal_fallback_prompt"]);
|
|
5697
|
+
const handler = options.onUserDialog;
|
|
5698
|
+
assert.ok(handler, "expected buildQueryOptions to declare onUserDialog");
|
|
5699
|
+
return handler;
|
|
5700
|
+
}
|
|
5701
|
+
function registerDialogSession() {
|
|
5702
|
+
const session = makeSessionState();
|
|
5703
|
+
session.sessionId = "session-dialog";
|
|
5704
|
+
sessions.set("session-dialog", session);
|
|
5705
|
+
return session;
|
|
5706
|
+
}
|
|
5707
|
+
test("onUserDialog round-trips a retry_fallback selection", async () => {
|
|
5708
|
+
const handler = userDialogHandlerForTest();
|
|
5709
|
+
const session = registerDialogSession();
|
|
5710
|
+
try {
|
|
5711
|
+
const events = await captureBridgeEventsAsync(async () => {
|
|
5712
|
+
const resultPromise = handler({
|
|
5713
|
+
dialogKind: "refusal_fallback_prompt",
|
|
5714
|
+
payload: {
|
|
5715
|
+
originalModel: "claude-opus-4-8",
|
|
5716
|
+
fallbackModel: "claude-sonnet-4-6",
|
|
5717
|
+
guidanceText: "This request was declined.",
|
|
5718
|
+
},
|
|
5719
|
+
}, { signal: new AbortController().signal });
|
|
5720
|
+
const requestId = [...session.pendingUserDialogs.keys()][0];
|
|
5721
|
+
assert.ok(requestId, "expected a pending user dialog resolver");
|
|
5722
|
+
handleUserDialogResponse({
|
|
5723
|
+
command: "user_dialog_response",
|
|
5724
|
+
session_id: "session-dialog",
|
|
5725
|
+
request_id: requestId,
|
|
5726
|
+
outcome: { outcome: "selected", option_id: "retry_fallback" },
|
|
5727
|
+
});
|
|
5728
|
+
assert.deepEqual(await resultPromise, {
|
|
5729
|
+
behavior: "completed",
|
|
5730
|
+
result: "retry_fallback",
|
|
5731
|
+
});
|
|
5732
|
+
});
|
|
5733
|
+
const dialogEvent = events.find((event) => event.event === "user_dialog_request");
|
|
5734
|
+
assert.ok(dialogEvent, "expected a user_dialog_request event");
|
|
5735
|
+
const request = dialogEvent.request;
|
|
5736
|
+
assert.equal(request.dialog_kind, "refusal_fallback_prompt");
|
|
5737
|
+
assert.equal(request.payload.original_model, "claude-opus-4-8");
|
|
5738
|
+
assert.equal(request.payload.fallback_model, "claude-sonnet-4-6");
|
|
5739
|
+
assert.equal(request.payload.guidance_text, "This request was declined.");
|
|
5740
|
+
assert.deepEqual(request.options.map((option) => option.option_id), ["retry_fallback", "edit_prompt"]);
|
|
5741
|
+
assert.equal(request.options[0].label, "Switch to claude-sonnet-4-6");
|
|
5742
|
+
assert.equal(request.options[1].label, "Edit prompt and retry with claude-opus-4-8");
|
|
5743
|
+
}
|
|
5744
|
+
finally {
|
|
5745
|
+
sessions.delete("session-dialog");
|
|
5746
|
+
}
|
|
5747
|
+
});
|
|
5748
|
+
test("onUserDialog round-trips an edit_prompt selection", async () => {
|
|
5749
|
+
const handler = userDialogHandlerForTest();
|
|
5750
|
+
const session = registerDialogSession();
|
|
5751
|
+
try {
|
|
5752
|
+
const resultPromise = handler({
|
|
5753
|
+
dialogKind: "refusal_fallback_prompt",
|
|
5754
|
+
payload: { originalModel: "claude-opus-4-8", fallbackModel: "claude-sonnet-4-6" },
|
|
5755
|
+
}, { signal: new AbortController().signal });
|
|
5756
|
+
const requestId = [...session.pendingUserDialogs.keys()][0];
|
|
5757
|
+
handleUserDialogResponse({
|
|
5758
|
+
command: "user_dialog_response",
|
|
5759
|
+
session_id: "session-dialog",
|
|
5760
|
+
request_id: requestId,
|
|
5761
|
+
outcome: { outcome: "selected", option_id: "edit_prompt" },
|
|
5762
|
+
});
|
|
5763
|
+
assert.deepEqual(await resultPromise, { behavior: "completed", result: "edit_prompt" });
|
|
5764
|
+
}
|
|
5765
|
+
finally {
|
|
5766
|
+
sessions.delete("session-dialog");
|
|
5767
|
+
}
|
|
5768
|
+
});
|
|
5769
|
+
test("onUserDialog cancels when the dialog is aborted", async () => {
|
|
5770
|
+
const handler = userDialogHandlerForTest();
|
|
5771
|
+
const session = registerDialogSession();
|
|
5772
|
+
const controller = new AbortController();
|
|
5773
|
+
try {
|
|
5774
|
+
const resultPromise = handler({
|
|
5775
|
+
dialogKind: "refusal_fallback_prompt",
|
|
5776
|
+
payload: { originalModel: "claude-opus-4-8", fallbackModel: "claude-sonnet-4-6" },
|
|
5777
|
+
}, { signal: controller.signal });
|
|
5778
|
+
assert.equal(session.pendingUserDialogs.size, 1);
|
|
5779
|
+
controller.abort();
|
|
5780
|
+
assert.deepEqual(await resultPromise, { behavior: "cancelled" });
|
|
5781
|
+
assert.equal(session.pendingUserDialogs.size, 0);
|
|
5782
|
+
}
|
|
5783
|
+
finally {
|
|
5784
|
+
sessions.delete("session-dialog");
|
|
5785
|
+
}
|
|
5786
|
+
});
|
|
5787
|
+
test("onUserDialog fails closed on an unknown dialog kind without emitting", async () => {
|
|
5788
|
+
const handler = userDialogHandlerForTest();
|
|
5789
|
+
const session = registerDialogSession();
|
|
5790
|
+
try {
|
|
5791
|
+
const events = await captureBridgeEventsAsync(async () => {
|
|
5792
|
+
const result = await handler({ dialogKind: "some_future_dialog_kind", payload: { anything: true } }, { signal: new AbortController().signal });
|
|
5793
|
+
assert.deepEqual(result, { behavior: "cancelled" });
|
|
5794
|
+
});
|
|
5795
|
+
assert.equal(events.find((event) => event.event === "user_dialog_request"), undefined);
|
|
5796
|
+
assert.equal(session.pendingUserDialogs.size, 0);
|
|
5797
|
+
}
|
|
5798
|
+
finally {
|
|
5799
|
+
sessions.delete("session-dialog");
|
|
5800
|
+
}
|
|
5801
|
+
});
|
|
5802
|
+
test("handleUserDialogResponse ignores a duplicate response for a resolved request", async () => {
|
|
5803
|
+
const handler = userDialogHandlerForTest();
|
|
5804
|
+
const session = registerDialogSession();
|
|
5805
|
+
try {
|
|
5806
|
+
const resultPromise = handler({
|
|
5807
|
+
dialogKind: "refusal_fallback_prompt",
|
|
5808
|
+
payload: { originalModel: "claude-opus-4-8", fallbackModel: "claude-sonnet-4-6" },
|
|
5809
|
+
}, { signal: new AbortController().signal });
|
|
5810
|
+
const requestId = [...session.pendingUserDialogs.keys()][0];
|
|
5811
|
+
const response = {
|
|
5812
|
+
command: "user_dialog_response",
|
|
5813
|
+
session_id: "session-dialog",
|
|
5814
|
+
request_id: requestId,
|
|
5815
|
+
outcome: { outcome: "selected", option_id: "retry_fallback" },
|
|
5816
|
+
};
|
|
5817
|
+
handleUserDialogResponse(response);
|
|
5818
|
+
assert.deepEqual(await resultPromise, { behavior: "completed", result: "retry_fallback" });
|
|
5819
|
+
// A replayed pending_user_dialog_requests entry with the same id must be a
|
|
5820
|
+
// no-op now that the resolver is gone.
|
|
5821
|
+
assert.equal(session.pendingUserDialogs.has(requestId), false);
|
|
5822
|
+
assert.doesNotThrow(() => handleUserDialogResponse(response));
|
|
5823
|
+
}
|
|
5824
|
+
finally {
|
|
5825
|
+
sessions.delete("session-dialog");
|
|
5826
|
+
}
|
|
2416
5827
|
});
|