claude-code-rust 0.12.1 → 0.12.3

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.
@@ -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, attachRequestUserDialogInterceptor, looksLikeAuthRequired, normalizeToolResultText, parseFastModeState, parseRuntimeSessionState, parseRateLimitStatus, normalizeSettingsParseError, normalizeToolKind, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, staleMcpAuthCandidates, resolveInstalledAgentSdkVersion, unwrapToolUseResult, } from "./bridge.js";
3
+ import { AsyncQueue, CACHE_SPLIT_POLICY, buildApiRetryUpdate, buildRateLimitUpdate, buildQueryOptions, canGenerateSessionTitle, generatePersistedSessionTitle, buildSessionMutationOptions, buildSessionListOptions, buildToolResultFields, createToolCall, applySessionAgent, applySessionEffort, emitAgentConfigOptionUpdate, emitEffortConfigOptionUpdate, handleTaskSystemMessage, handleSdkMessage, isShellToolName, 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 { emitCurrentModelUpdate, refreshCurrentModel, resolveCurrentModel, shouldInvalidateResolvedRuntimeModel, shouldEmitStartupAuthRequiredForAccount, } from "./bridge/session_lifecycle.js";
6
- import { emitToolProgressUpdate } from "./bridge/tool_calls.js";
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
- if (typeof chunk === "string") {
119
- writes.push(chunk);
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
- else if (Buffer.isBuffer(chunk)) {
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
- if (typeof chunk === "string") {
152
- writes.push(chunk);
153
- }
154
- else if (Buffer.isBuffer(chunk)) {
155
- writes.push(chunk.toString("utf8"));
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
- else {
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 final summary replaces prior task content and finalizes status", () => {
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("handleTaskSystemMessage applies task_updated description patches to the linked task", () => {
1356
+ test("handleSdkMessage emits transcript retraction for model_refusal_fallback", () => {
882
1357
  const session = makeSessionState();
883
1358
  const events = captureBridgeEvents(() => {
884
- handleTaskSystemMessage(session, "task_started", {
885
- task_id: "task-1",
886
- tool_use_id: "tool-1",
887
- description: "Initial task description",
888
- });
889
- handleTaskSystemMessage(session, "task_updated", {
890
- task_id: "task-1",
891
- patch: {
892
- status: "running",
893
- description: "Refining the migration plan",
894
- is_backgrounded: true,
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
- const lastEvent = events.at(-1);
899
- assert.ok(lastEvent);
900
- assert.equal(lastEvent.event, "session_update");
901
- assert.deepEqual(lastEvent.update, {
902
- type: "tool_call_update",
903
- tool_call_update: {
904
- tool_call_id: "tool-1",
905
- fields: {
906
- status: "in_progress",
907
- raw_output: "Refining the migration plan",
908
- content: [
909
- {
910
- type: "content",
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("handleTaskSystemMessage uses task_updated terminal error text when description is absent", () => {
1392
+ test("handleSdkMessage emits tolerant transcript retraction for model_fallback", () => {
922
1393
  const session = makeSessionState();
923
1394
  const events = captureBridgeEvents(() => {
924
- handleTaskSystemMessage(session, "task_started", {
925
- task_id: "task-1",
926
- tool_use_id: "tool-1",
927
- description: "Initial task description",
928
- });
929
- handleTaskSystemMessage(session, "task_updated", {
930
- task_id: "task-1",
931
- patch: {
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
- const lastEvent = events.at(-1);
940
- assert.ok(lastEvent);
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("handleTaskSystemMessage skips unlinked task_updated messages", () => {
1790
+ test("Monitor launch links task id and accepts task lifecycle updates", () => {
994
1791
  const session = makeSessionState();
995
- const events = captureBridgeEvents(() => {
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: "task-missing",
1810
+ task_id: "monitor-1",
998
1811
  patch: {
999
1812
  status: "running",
1000
- description: "This should not be emitted",
1813
+ description: "Monitor observed deploy log output",
1814
+ is_backgrounded: true,
1001
1815
  },
1002
1816
  });
1003
1817
  });
1004
- assert.equal(events.length, 0);
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("emitToolProgressUpdate does not reopen completed tools", () => {
1825
+ test("Monitor launch stays in progress after successful assistant turn until final lifecycle notification", () => {
1007
1826
  const session = makeSessionState();
1008
- session.toolCalls.set("tool-1", {
1009
- tool_call_id: "tool-1",
1010
- title: "Bash",
1011
- kind: "execute",
1012
- status: "completed",
1013
- content: [],
1014
- locations: [],
1015
- meta: { claudeCode: { toolName: "Bash", parentToolUseId: null } },
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
- emitToolProgressUpdate(session, "tool-1", "Bash");
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
- assert.equal(events.length, 0);
1021
- assert.equal(session.toolCalls.get("tool-1")?.status, "completed");
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("buildQueryOptions trims language before appending system prompt", () => {
1024
- const input = new AsyncQueue();
1025
- const options = buildQueryOptions({
1026
- cwd: "C:/work",
1027
- launchSettings: {
1028
- language: " German ",
1029
- },
1030
- provisionalSessionId: "session-4",
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 = {
@@ -1211,12 +2889,41 @@ test("requestAskUserQuestionAnswers preserves previews and annotations in update
1211
2889
  });
1212
2890
  test("normalizeToolKind maps known tool names", () => {
1213
2891
  assert.equal(normalizeToolKind("Bash"), "execute");
2892
+ assert.equal(normalizeToolKind("PowerShell"), "execute");
1214
2893
  assert.equal(normalizeToolKind("Delete"), "delete");
1215
2894
  assert.equal(normalizeToolKind("Move"), "move");
2895
+ assert.equal(normalizeToolKind("EnterWorktree"), "other");
2896
+ assert.equal(normalizeToolKind("ExitWorktree"), "other");
2897
+ assert.equal(normalizeToolKind("CronCreate"), "other");
2898
+ assert.equal(normalizeToolKind("CronDelete"), "other");
2899
+ assert.equal(normalizeToolKind("CronList"), "other");
2900
+ assert.equal(normalizeToolKind("ScheduleWakeup"), "other");
2901
+ assert.equal(normalizeToolKind("PushNotification"), "other");
2902
+ assert.equal(normalizeToolKind("RemoteTrigger"), "other");
2903
+ assert.equal(normalizeToolKind("REPL"), "other");
2904
+ assert.equal(normalizeToolKind("Monitor"), "other");
2905
+ assert.equal(normalizeToolKind("Workflow"), "other");
2906
+ assert.equal(normalizeToolKind("Projects"), "other");
2907
+ assert.equal(normalizeToolKind("Artifact"), "other");
2908
+ assert.equal(normalizeToolKind("ShowOnboardingRolePicker"), "other");
2909
+ assert.equal(normalizeToolKind("TaskOutput"), "other");
2910
+ assert.equal(normalizeToolKind("TaskStop"), "other");
1216
2911
  assert.equal(normalizeToolKind("Task"), "think");
1217
2912
  assert.equal(normalizeToolKind("Agent"), "think");
2913
+ assert.equal(normalizeToolKind("EnterPlanMode"), "switch_mode");
1218
2914
  assert.equal(normalizeToolKind("ExitPlanMode"), "switch_mode");
1219
- assert.equal(normalizeToolKind("TodoWrite"), "other");
2915
+ assert.equal(normalizeToolKind("TodoWrite"), normalizeToolKind("FutureUnknownTool"));
2916
+ });
2917
+ test("isShellToolName recognizes only supported shell tools", () => {
2918
+ assert.equal(isShellToolName("Bash"), true);
2919
+ assert.equal(isShellToolName("PowerShell"), true);
2920
+ assert.equal(isShellToolName("Shell"), false);
2921
+ assert.equal(isShellToolName("bash"), false);
2922
+ });
2923
+ test("shell tool titles use input command", () => {
2924
+ assert.equal(createToolCall("tc-bash-title", "Bash", { command: "git status" }).title, "git status");
2925
+ assert.equal(createToolCall("tc-powershell-title", "PowerShell", { command: "Get-ChildItem" }).title, "Get-ChildItem");
2926
+ assert.equal(createToolCall("tc-powershell-empty", "PowerShell", {}).title, "Terminal");
1220
2927
  });
1221
2928
  test("parseFastModeState accepts known values and rejects unknown values", () => {
1222
2929
  assert.equal(parseFastModeState("off"), "off");
@@ -1264,6 +2971,25 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
1264
2971
  surpassed_threshold: 0.9,
1265
2972
  });
1266
2973
  });
2974
+ test("buildRateLimitUpdate normalizes SDK overage boolean spellings", () => {
2975
+ const cases = [
2976
+ ["old spelling true", { isUsingOverage: true }, true],
2977
+ ["old spelling false", { isUsingOverage: false }, false],
2978
+ ["new spelling true", { overageInUse: true }, true],
2979
+ ["new spelling false", { overageInUse: false }, false],
2980
+ ["both spellings true", { isUsingOverage: true, overageInUse: true }, true],
2981
+ ["both spellings false", { isUsingOverage: false, overageInUse: false }, false],
2982
+ ["conflicting spellings prefer new true", { isUsingOverage: false, overageInUse: true }, true],
2983
+ ["conflicting spellings prefer new false", { isUsingOverage: true, overageInUse: false }, false],
2984
+ ];
2985
+ for (const [name, fields, expected] of cases) {
2986
+ const update = buildRateLimitUpdate({
2987
+ status: "allowed",
2988
+ ...fields,
2989
+ });
2990
+ assert.equal(update?.is_using_overage, expected, name);
2991
+ }
2992
+ });
1267
2993
  test("buildRateLimitUpdate rejects invalid payloads", () => {
1268
2994
  assert.equal(buildRateLimitUpdate(null), null);
1269
2995
  assert.equal(buildRateLimitUpdate({}), null);
@@ -1288,6 +3014,26 @@ test("buildApiRetryUpdate maps SDK api_retry messages to wire shape", () => {
1288
3014
  error_status: 529,
1289
3015
  error: "server_error",
1290
3016
  });
3017
+ for (const error of [
3018
+ "model_not_found",
3019
+ "oauth_org_not_allowed",
3020
+ "overloaded",
3021
+ ]) {
3022
+ assert.deepEqual(buildApiRetryUpdate({
3023
+ attempt: 2,
3024
+ max_retries: 4,
3025
+ retry_delay_ms: 1500,
3026
+ error_status: 529,
3027
+ error,
3028
+ }), {
3029
+ type: "api_retry_update",
3030
+ attempt: 2,
3031
+ max_retries: 4,
3032
+ retry_delay_ms: 1500,
3033
+ error_status: 529,
3034
+ error,
3035
+ });
3036
+ }
1291
3037
  assert.deepEqual(buildApiRetryUpdate({
1292
3038
  attempt: 1,
1293
3039
  maxRetries: 4,
@@ -1363,26 +3109,469 @@ test("handleSdkMessage emits lifecycle compatibility session updates", () => {
1363
3109
  session_id: "session-1",
1364
3110
  });
1365
3111
  handleSdkMessage(session, {
1366
- type: "system",
1367
- subtype: "session_state_changed",
1368
- state: "idle",
1369
- uuid: "message-3",
3112
+ type: "system",
3113
+ subtype: "session_state_changed",
3114
+ state: "idle",
3115
+ uuid: "message-3",
3116
+ session_id: "session-1",
3117
+ });
3118
+ handleSdkMessage(session, {
3119
+ type: "system",
3120
+ subtype: "status",
3121
+ status: "requesting",
3122
+ uuid: "message-4",
3123
+ session_id: "session-1",
3124
+ });
3125
+ });
3126
+ assert.deepEqual(events.map((event) => event.update), [
3127
+ { type: "prompt_suggestion_update", suggestion: "Write tests for this change" },
3128
+ {
3129
+ type: "api_retry_update",
3130
+ attempt: 1,
3131
+ max_retries: 4,
3132
+ retry_delay_ms: 1000,
3133
+ error_status: null,
3134
+ error: "server_error",
3135
+ },
3136
+ { type: "runtime_session_state_update", state: "idle" },
3137
+ { type: "session_status_update", status: "requesting" },
3138
+ ]);
3139
+ });
3140
+ test("classifyTurnErrorKind prefers SDK assistant error codes", () => {
3141
+ assert.equal(classifyTurnErrorKind("error_during_execution", [], "model_not_found"), "model_unavailable");
3142
+ assert.equal(classifyTurnErrorKind("error_during_execution", [], "oauth_org_not_allowed"), "account_access");
3143
+ assert.equal(classifyTurnErrorKind("error_during_execution", [], "overloaded"), "transient_service");
3144
+ assert.equal(classifyTurnErrorKind("error_during_execution", [], "server_error"), "transient_service");
3145
+ assert.equal(classifyTurnErrorKind("error_during_execution", [], "authentication_failed"), "auth_required");
3146
+ assert.equal(classifyTurnErrorKind("error_during_execution", [], "billing_error"), "plan_limit");
3147
+ assert.equal(classifyTurnErrorKind("error_during_execution", [], "rate_limit"), "plan_limit");
3148
+ });
3149
+ test("handleSdkMessage replaces available commands from commands_changed", () => {
3150
+ const session = makeSessionState();
3151
+ const events = captureBridgeEvents(() => {
3152
+ handleSdkMessage(session, {
3153
+ type: "system",
3154
+ subtype: "commands_changed",
3155
+ commands: [
3156
+ { name: "/one", description: "First command", argumentHint: "<value>" },
3157
+ { name: "/two", description: undefined, argumentHint: undefined },
3158
+ ],
3159
+ uuid: "message-commands",
3160
+ session_id: "session-1",
3161
+ });
3162
+ });
3163
+ assert.deepEqual(events.map((event) => event.update), [
3164
+ {
3165
+ type: "available_commands_update",
3166
+ commands: [
3167
+ { name: "/one", description: "First command", input_hint: "<value>" },
3168
+ { name: "/two", description: "" },
3169
+ ],
3170
+ source: "commands_changed",
3171
+ generation: 1,
3172
+ },
3173
+ ]);
3174
+ });
3175
+ test("handleSdkMessage accepts empty commands_changed replacement list", () => {
3176
+ const session = makeSessionState();
3177
+ const events = captureBridgeEvents(() => {
3178
+ handleSdkMessage(session, {
3179
+ type: "system",
3180
+ subtype: "commands_changed",
3181
+ commands: [],
3182
+ uuid: "message-commands-empty",
3183
+ session_id: "session-1",
3184
+ });
3185
+ });
3186
+ assert.deepEqual(events.map((event) => event.update), [
3187
+ {
3188
+ type: "available_commands_update",
3189
+ commands: [],
3190
+ source: "commands_changed",
3191
+ generation: 1,
3192
+ },
3193
+ ]);
3194
+ });
3195
+ test("available command registry blocks stale supportedCommands after dynamic updates", () => {
3196
+ const session = makeSessionState();
3197
+ const events = captureBridgeEvents(() => {
3198
+ assert.equal(updateAvailableCommands(session, "session_result_commands", [
3199
+ { name: "base", description: "Base command" },
3200
+ ]), true);
3201
+ assert.equal(updateAvailableCommands(session, "commands_changed", [
3202
+ { name: "base", description: "Base command" },
3203
+ { name: "project-plugin", description: "Project plugin command" },
3204
+ ]), true);
3205
+ assert.equal(updateAvailableCommands(session, "supportedCommands", [
3206
+ { name: "base", description: "Base command" },
3207
+ ]), false);
3208
+ });
3209
+ assert.deepEqual(events.map((event) => event.update), [
3210
+ {
3211
+ type: "available_commands_update",
3212
+ commands: [{ name: "base", description: "Base command" }],
3213
+ source: "session_result_commands",
3214
+ generation: 1,
3215
+ },
3216
+ {
3217
+ type: "available_commands_update",
3218
+ commands: [
3219
+ { name: "base", description: "Base command" },
3220
+ { name: "project-plugin", description: "Project plugin command" },
3221
+ ],
3222
+ source: "commands_changed",
3223
+ generation: 2,
3224
+ },
3225
+ ]);
3226
+ assert.equal(session.availableCommands?.generation, 2);
3227
+ assert.equal(session.availableCommands?.source, "commands_changed");
3228
+ assert.deepEqual(session.availableCommands?.commands.map((command) => command.name), ["base", "project-plugin"]);
3229
+ });
3230
+ test("available command registry lets authoritative snapshots remove commands", () => {
3231
+ const session = makeSessionState();
3232
+ const events = captureBridgeEvents(() => {
3233
+ updateAvailableCommands(session, "reload_plugins", [
3234
+ { name: "base", description: "Base command" },
3235
+ { name: "removed-plugin", description: "Removed plugin command" },
3236
+ ]);
3237
+ updateAvailableCommands(session, "commands_changed", [
3238
+ { name: "base", description: "Base command" },
3239
+ ]);
3240
+ });
3241
+ assert.deepEqual(events.map((event) => event.update), [
3242
+ {
3243
+ type: "available_commands_update",
3244
+ commands: [
3245
+ { name: "base", description: "Base command" },
3246
+ { name: "removed-plugin", description: "Removed plugin command" },
3247
+ ],
3248
+ source: "reload_plugins",
3249
+ generation: 1,
3250
+ },
3251
+ {
3252
+ type: "available_commands_update",
3253
+ commands: [{ name: "base", description: "Base command" }],
3254
+ source: "commands_changed",
3255
+ generation: 2,
3256
+ },
3257
+ ]);
3258
+ });
3259
+ test("handleSdkMessage emits system notices for notifications and plugin failures", () => {
3260
+ const session = makeSessionState();
3261
+ const events = captureBridgeEvents(() => {
3262
+ handleSdkMessage(session, {
3263
+ type: "system",
3264
+ subtype: "notification",
3265
+ key: "sync",
3266
+ text: "Sync completed",
3267
+ priority: "low",
3268
+ uuid: "message-notification",
3269
+ session_id: "session-1",
3270
+ });
3271
+ handleSdkMessage(session, {
3272
+ type: "system",
3273
+ subtype: "plugin_install",
3274
+ status: "failed",
3275
+ name: "acme",
3276
+ error: "download failed",
3277
+ uuid: "message-plugin",
3278
+ session_id: "session-1",
3279
+ });
3280
+ });
3281
+ assert.deepEqual(events.map((event) => event.update), [
3282
+ { type: "system_notice_update", severity: "info", message: "Sync completed" },
3283
+ { type: "system_notice_update", severity: "warning", message: "Plugin install failed acme: download failed" },
3284
+ ]);
3285
+ });
3286
+ test("handleSdkMessage treats mirror errors as log-only diagnostics", () => {
3287
+ const session = makeSessionState();
3288
+ const events = captureBridgeEvents(() => {
3289
+ handleSdkMessage(session, {
3290
+ type: "system",
3291
+ subtype: "mirror_error",
3292
+ error: "append timed out",
3293
+ key: { projectKey: "project", sessionId: "session-1", subpath: "subagents/agent-1" },
3294
+ uuid: "message-mirror",
3295
+ session_id: "session-1",
3296
+ });
3297
+ });
3298
+ assert.deepEqual(events, []);
3299
+ });
3300
+ test("handleSdkMessage keeps log-only system messages non-emitting", () => {
3301
+ const session = makeSessionState();
3302
+ const events = captureBridgeEvents(() => {
3303
+ handleSdkMessage(session, {
3304
+ type: "system",
3305
+ subtype: "plugin_install",
3306
+ status: "completed",
3307
+ uuid: "message-plugin-complete",
3308
+ session_id: "session-1",
3309
+ });
3310
+ handleSdkMessage(session, {
3311
+ type: "system",
3312
+ subtype: "permission_denied",
3313
+ tool_name: "Bash",
3314
+ tool_use_id: "tool-1",
3315
+ message: "denied",
3316
+ uuid: "message-permission",
3317
+ session_id: "session-1",
3318
+ });
3319
+ handleSdkMessage(session, {
3320
+ type: "system",
3321
+ subtype: "memory_recall",
3322
+ mode: "select",
3323
+ memories: [],
3324
+ uuid: "message-memory",
3325
+ session_id: "session-1",
3326
+ });
3327
+ handleSdkMessage(session, {
3328
+ type: "system",
3329
+ subtype: "thinking_tokens",
3330
+ estimated_tokens: 120,
3331
+ estimated_tokens_delta: 20,
3332
+ uuid: "message-thinking",
3333
+ session_id: "session-1",
3334
+ });
3335
+ });
3336
+ assert.deepEqual(events, []);
3337
+ });
3338
+ test("handleSdkMessage accepts auto-continuation message origin without user output", () => {
3339
+ const session = makeSessionState();
3340
+ const events = captureBridgeEvents(() => {
3341
+ handleSdkMessage(session, {
3342
+ type: "user",
3343
+ message: { role: "user", content: [{ type: "text", text: "continue" }] },
3344
+ parent_tool_use_id: null,
3345
+ origin: { kind: "auto-continuation" },
3346
+ uuid: "message-auto-continuation",
3347
+ session_id: "session-1",
3348
+ });
3349
+ });
3350
+ assert.deepEqual(events, []);
3351
+ });
3352
+ test("handleSdkMessage preserves assistant correlation metadata on tool calls", () => {
3353
+ const session = makeSessionState();
3354
+ const events = captureBridgeEvents(() => {
3355
+ handleSdkMessage(session, {
3356
+ type: "assistant",
3357
+ message: {
3358
+ content: [{ type: "tool_use", id: "tool-1", name: "Bash", input: { command: "npm test" } }],
3359
+ },
3360
+ parent_tool_use_id: null,
3361
+ request_id: "request-1",
3362
+ subagent_type: "code-review",
3363
+ task_description: "Review the bridge",
3364
+ uuid: "message-assistant",
1370
3365
  session_id: "session-1",
1371
3366
  });
1372
3367
  });
3368
+ assert.deepEqual(events.map((event) => event.update.tool_call), [
3369
+ {
3370
+ tool_call_id: "tool-1",
3371
+ title: "npm test",
3372
+ kind: "execute",
3373
+ status: "in_progress",
3374
+ source_message_uuid: "message-assistant",
3375
+ content: [],
3376
+ raw_input: { command: "npm test" },
3377
+ locations: [],
3378
+ meta: {
3379
+ claudeCode: {
3380
+ toolName: "Bash",
3381
+ parentToolUseId: null,
3382
+ requestId: "request-1",
3383
+ subagentType: "code-review",
3384
+ taskDescription: "Review the bridge",
3385
+ },
3386
+ },
3387
+ },
3388
+ ]);
3389
+ });
3390
+ test("handleTaskSystemMessage preserves task correlation metadata", () => {
3391
+ const session = makeSessionState();
3392
+ const events = captureBridgeEvents(() => {
3393
+ handleTaskSystemMessage(session, "task_started", {
3394
+ task_id: "task-1",
3395
+ tool_use_id: "tool-1",
3396
+ description: "Run checks",
3397
+ request_id: "request-1",
3398
+ subagent_type: "tester",
3399
+ task_description: "Validate the branch",
3400
+ });
3401
+ });
1373
3402
  assert.deepEqual(events.map((event) => event.update), [
1374
- { type: "prompt_suggestion_update", suggestion: "Write tests for this change" },
1375
3403
  {
1376
- type: "api_retry_update",
1377
- attempt: 1,
1378
- max_retries: 4,
1379
- retry_delay_ms: 1000,
1380
- error_status: null,
1381
- error: "server_error",
3404
+ type: "tool_call",
3405
+ tool_call: {
3406
+ tool_call_id: "tool-1",
3407
+ title: "Agent",
3408
+ kind: "think",
3409
+ status: "pending",
3410
+ content: [],
3411
+ raw_input: {},
3412
+ locations: [],
3413
+ meta: { claudeCode: { toolName: "Agent", parentToolUseId: null } },
3414
+ },
3415
+ },
3416
+ {
3417
+ type: "task_state_update",
3418
+ source: "task_lifecycle",
3419
+ tasks: [
3420
+ {
3421
+ task_id: "task-1",
3422
+ subject: "Validate the branch",
3423
+ description: "Run checks",
3424
+ status: "in_progress",
3425
+ blocks: [],
3426
+ blocked_by: [],
3427
+ metadata: {
3428
+ request_id: "request-1",
3429
+ subagent_type: "tester",
3430
+ task_description: "Validate the branch",
3431
+ },
3432
+ source_tool_call_id: "tool-1",
3433
+ },
3434
+ ],
3435
+ removed_task_ids: [],
3436
+ is_complete_snapshot: false,
3437
+ },
3438
+ {
3439
+ type: "tool_call_update",
3440
+ tool_call_update: {
3441
+ tool_call_id: "tool-1",
3442
+ fields: {
3443
+ status: "in_progress",
3444
+ },
3445
+ },
3446
+ },
3447
+ {
3448
+ type: "tool_call_update",
3449
+ tool_call_update: {
3450
+ tool_call_id: "tool-1",
3451
+ fields: {
3452
+ status: "in_progress",
3453
+ raw_output: "Run checks",
3454
+ content: [{ type: "content", content: { type: "text", text: "Run checks" } }],
3455
+ task_metadata: {
3456
+ request_id: "request-1",
3457
+ subagent_type: "tester",
3458
+ task_description: "Validate the branch",
3459
+ },
3460
+ },
3461
+ },
1382
3462
  },
1383
- { type: "runtime_session_state_update", state: "idle" },
1384
3463
  ]);
1385
3464
  });
3465
+ test("parseCommandEnvelope validates set_effort command", () => {
3466
+ for (const effort of ["low", "medium", "high", "xhigh", "max"]) {
3467
+ const parsed = parseCommandEnvelope(JSON.stringify({
3468
+ request_id: "req-effort",
3469
+ command: "set_effort",
3470
+ session_id: "session-1",
3471
+ effort,
3472
+ }));
3473
+ assert.equal(parsed.requestId, "req-effort");
3474
+ assert.equal(parsed.command.command, "set_effort");
3475
+ if (parsed.command.command !== "set_effort") {
3476
+ throw new Error("unexpected command variant");
3477
+ }
3478
+ assert.equal(parsed.command.session_id, "session-1");
3479
+ assert.equal(parsed.command.effort, effort);
3480
+ }
3481
+ });
3482
+ test("parseCommandEnvelope rejects unsupported set_effort values", () => {
3483
+ assert.throws(() => parseCommandEnvelope(JSON.stringify({
3484
+ command: "set_effort",
3485
+ session_id: "session-1",
3486
+ effort: "banana",
3487
+ })), /set_effort\.effort must be one of low, medium, high, xhigh, max/);
3488
+ });
3489
+ test("parseCommandEnvelope validates set_agent command", () => {
3490
+ const parsed = parseCommandEnvelope(JSON.stringify({
3491
+ request_id: "req-agent",
3492
+ command: "set_agent",
3493
+ session_id: "session-1",
3494
+ agent: "reviewer",
3495
+ }));
3496
+ assert.equal(parsed.requestId, "req-agent");
3497
+ assert.equal(parsed.command.command, "set_agent");
3498
+ if (parsed.command.command !== "set_agent") {
3499
+ throw new Error("unexpected command variant");
3500
+ }
3501
+ assert.equal(parsed.command.session_id, "session-1");
3502
+ assert.equal(parsed.command.agent, "reviewer");
3503
+ });
3504
+ test("parseCommandEnvelope validates set_agent reset", () => {
3505
+ const parsed = parseCommandEnvelope(JSON.stringify({
3506
+ command: "set_agent",
3507
+ session_id: "session-1",
3508
+ agent: null,
3509
+ }));
3510
+ assert.equal(parsed.command.command, "set_agent");
3511
+ if (parsed.command.command !== "set_agent") {
3512
+ throw new Error("unexpected command variant");
3513
+ }
3514
+ assert.equal(parsed.command.agent, null);
3515
+ });
3516
+ test("parseCommandEnvelope rejects invalid set_agent values", () => {
3517
+ for (const agent of [undefined, "", " ", 42, {}, []]) {
3518
+ assert.throws(() => parseCommandEnvelope(JSON.stringify({
3519
+ command: "set_agent",
3520
+ session_id: "session-1",
3521
+ ...(agent !== undefined ? { agent } : {}),
3522
+ })), /set_agent\.agent must be a non-empty string or null/);
3523
+ }
3524
+ });
3525
+ test("applySessionEffort uses live flag settings for xhigh and max", async () => {
3526
+ const calls = [];
3527
+ const query = {
3528
+ async applyFlagSettings(settings) {
3529
+ calls.push(settings);
3530
+ },
3531
+ };
3532
+ await applySessionEffort(query, "xhigh");
3533
+ await applySessionEffort(query, "max");
3534
+ assert.deepEqual(calls, [{ effortLevel: "xhigh" }, { effortLevel: "max" }]);
3535
+ });
3536
+ test("applySessionAgent uses live flag settings for agent switch and reset", async () => {
3537
+ const calls = [];
3538
+ const query = {
3539
+ async applyFlagSettings(settings) {
3540
+ calls.push(settings);
3541
+ },
3542
+ };
3543
+ await applySessionAgent(query, "reviewer");
3544
+ await applySessionAgent(query, null);
3545
+ assert.deepEqual(calls, [{ agent: "reviewer" }, { agent: null }]);
3546
+ });
3547
+ test("emitEffortConfigOptionUpdate publishes effortLevel config option", () => {
3548
+ const events = captureBridgeEvents(() => {
3549
+ emitEffortConfigOptionUpdate("session-1", "max");
3550
+ });
3551
+ assert.deepEqual(events.at(-1), {
3552
+ event: "session_update",
3553
+ session_id: "session-1",
3554
+ update: {
3555
+ type: "config_option_update",
3556
+ option_id: "effortLevel",
3557
+ value: "max",
3558
+ },
3559
+ });
3560
+ });
3561
+ test("emitAgentConfigOptionUpdate publishes agent config option", () => {
3562
+ const events = captureBridgeEvents(() => {
3563
+ emitAgentConfigOptionUpdate("session-1", null);
3564
+ });
3565
+ assert.deepEqual(events.at(-1), {
3566
+ event: "session_update",
3567
+ session_id: "session-1",
3568
+ update: {
3569
+ type: "config_option_update",
3570
+ option_id: "agent",
3571
+ value: null,
3572
+ },
3573
+ });
3574
+ });
1386
3575
  test("shouldEmitStartupAuthRequiredForAccount keeps legacy first-party behavior", () => {
1387
3576
  assert.equal(shouldEmitStartupAuthRequiredForAccount({}), true);
1388
3577
  assert.equal(shouldEmitStartupAuthRequiredForAccount({ apiProvider: "firstParty" }), true);
@@ -1400,12 +3589,30 @@ test("shouldEmitStartupAuthRequiredForAccount skips Claude OAuth hint for extern
1400
3589
  "bedrock",
1401
3590
  "vertex",
1402
3591
  "foundry",
3592
+ "gateway",
1403
3593
  "anthropicAws",
1404
3594
  "mantle",
1405
3595
  ]) {
1406
3596
  assert.equal(shouldEmitStartupAuthRequiredForAccount({ apiProvider }), false);
1407
3597
  }
1408
3598
  });
3599
+ test("mapSdkAccountInfo normalizes SDK account metadata through one bridge DTO", () => {
3600
+ assert.deepEqual(mapSdkAccountInfo({
3601
+ email: " user@example.com ",
3602
+ organization: " org-1 ",
3603
+ subscriptionType: " Claude Max ",
3604
+ tokenSource: " oauth ",
3605
+ apiKeySource: " user ",
3606
+ apiProvider: "gateway",
3607
+ }), {
3608
+ email: "user@example.com",
3609
+ organization: "org-1",
3610
+ subscription_type: "Claude Max",
3611
+ token_source: "oauth",
3612
+ api_key_source: "user",
3613
+ api_provider: "gateway",
3614
+ });
3615
+ });
1409
3616
  test("handleSdkMessage emits settings parse errors from defensive payloads", () => {
1410
3617
  const session = makeSessionState();
1411
3618
  const events = captureBridgeEvents(() => {
@@ -1479,12 +3686,182 @@ test("createToolCall builds write preview diff content", () => {
1479
3686
  },
1480
3687
  ]);
1481
3688
  });
1482
- test("createToolCall includes glob and webfetch context in title", () => {
3689
+ test("createToolCall includes search and webfetch context in title", () => {
1483
3690
  const glob = createToolCall("tc-g", "Glob", { pattern: "**/*.md", path: "notes" });
1484
3691
  assert.equal(glob.title, "Glob **/*.md in notes");
3692
+ const grep = createToolCall("tc-grep", "Grep", {
3693
+ pattern: "TODO",
3694
+ path: "src",
3695
+ glob: "**/*.rs",
3696
+ output_mode: "content",
3697
+ "-i": true,
3698
+ "-C": 2,
3699
+ type: "rust",
3700
+ head_limit: 10,
3701
+ offset: 5,
3702
+ multiline: true,
3703
+ });
3704
+ assert.equal(grep.title, "Grep TODO in src (glob **/*.rs, type rust, content, case-insensitive, context 2, limit 10, offset 5, multiline)");
1485
3705
  const fetch = createToolCall("tc-f", "WebFetch", { url: "https://example.com" });
1486
3706
  assert.equal(fetch.title, "WebFetch https://example.com");
1487
3707
  });
3708
+ test("createToolCall builds Agent title from name and type without description fallback", () => {
3709
+ const named = createToolCall("tc-agent-name", "Agent", {
3710
+ description: "review changes",
3711
+ prompt: "Review the branch",
3712
+ name: " review-worker ",
3713
+ subagent_type: " general-purpose ",
3714
+ model: " opus ",
3715
+ });
3716
+ const typed = createToolCall("tc-agent-type", "Agent", {
3717
+ description: "inspect state",
3718
+ prompt: "Inspect the runtime",
3719
+ subagent_type: " general-purpose ",
3720
+ model: " sonnet ",
3721
+ });
3722
+ const describedOnly = createToolCall("tc-agent-description", "Agent", {
3723
+ description: "should not become title",
3724
+ prompt: "Review",
3725
+ });
3726
+ assert.equal(named.title, "Agent: review-worker");
3727
+ assert.equal(typed.title, "Agent: general-purpose");
3728
+ assert.equal(describedOnly.title, "Agent");
3729
+ });
3730
+ test("createToolCall builds worktree titles from input rules", () => {
3731
+ const namedEnter = createToolCall("tc-enter-name", "EnterWorktree", { name: "feature-auth" });
3732
+ assert.equal(namedEnter.kind, "other");
3733
+ assert.equal(namedEnter.title, "feature-auth");
3734
+ const pathEnter = createToolCall("tc-enter-path", "EnterWorktree", {
3735
+ path: "C:\\repo\\.worktrees\\feature-auth",
3736
+ });
3737
+ assert.equal(pathEnter.kind, "other");
3738
+ assert.equal(pathEnter.title, "EnterWorktree");
3739
+ const exit = createToolCall("tc-exit", "ExitWorktree", {
3740
+ action: "remove",
3741
+ discard_changes: true,
3742
+ });
3743
+ assert.equal(exit.kind, "other");
3744
+ assert.equal(exit.title, "ExitWorktree");
3745
+ });
3746
+ test("createToolCall maps cron tools to other kind with stable titles", () => {
3747
+ for (const toolName of ["CronCreate", "CronDelete", "CronList"]) {
3748
+ const toolCall = createToolCall(`tc-${toolName}`, toolName, {
3749
+ cron: "30 9 * * 1",
3750
+ prompt: "Send weekly status",
3751
+ id: "schedule-1",
3752
+ });
3753
+ assert.equal(toolCall.kind, "other");
3754
+ assert.equal(toolCall.title, toolName);
3755
+ }
3756
+ });
3757
+ test("createToolCall maps ScheduleWakeup to other kind with stable title", () => {
3758
+ const toolCall = createToolCall("tc-wakeup", "ScheduleWakeup", {
3759
+ delaySeconds: 90,
3760
+ reason: "Poll again after warmup",
3761
+ prompt: "/loop check status",
3762
+ });
3763
+ assert.equal(toolCall.kind, "other");
3764
+ assert.equal(toolCall.title, "ScheduleWakeup");
3765
+ });
3766
+ test("createToolCall maps PushNotification to other kind with stable title", () => {
3767
+ const toolCall = createToolCall("tc-push-notification", "PushNotification", {
3768
+ message: "Build finished",
3769
+ status: "proactive",
3770
+ });
3771
+ assert.equal(toolCall.kind, "other");
3772
+ assert.equal(toolCall.title, "PushNotification");
3773
+ });
3774
+ test("createToolCall maps RemoteTrigger to other kind and action title", () => {
3775
+ const toolCall = createToolCall("tc-remote-trigger", "RemoteTrigger", {
3776
+ action: " run ",
3777
+ trigger_id: "deploy-prod",
3778
+ });
3779
+ assert.equal(toolCall.kind, "other");
3780
+ assert.equal(toolCall.title, "RemoteTrigger: run");
3781
+ });
3782
+ test("createToolCall uses RemoteTrigger fallback title without action", () => {
3783
+ const toolCall = createToolCall("tc-remote-trigger-fallback", "RemoteTrigger", {
3784
+ trigger_id: "deploy-prod",
3785
+ });
3786
+ assert.equal(toolCall.kind, "other");
3787
+ assert.equal(toolCall.title, "RemoteTrigger");
3788
+ });
3789
+ test("createToolCall maps REPL to other kind and code title", () => {
3790
+ const toolCall = createToolCall("tc-repl", "REPL", {
3791
+ code: " await inspectState() ",
3792
+ description: "Inspect runtime state",
3793
+ timeout: 45_000,
3794
+ });
3795
+ assert.equal(toolCall.kind, "other");
3796
+ assert.equal(toolCall.title, "REPL: await inspectState()");
3797
+ });
3798
+ test("createToolCall uses REPL fallback title instead of description", () => {
3799
+ const toolCall = createToolCall("tc-repl-fallback", "REPL", {
3800
+ description: "Inspect runtime state",
3801
+ });
3802
+ assert.equal(toolCall.kind, "other");
3803
+ assert.equal(toolCall.title, "REPL");
3804
+ });
3805
+ test("createToolCall maps Monitor to other kind and description title", () => {
3806
+ const toolCall = createToolCall("tc-monitor", "Monitor", {
3807
+ description: "watch deploy logs",
3808
+ timeout_ms: 30000,
3809
+ persistent: false,
3810
+ command: "tail -f deploy.log",
3811
+ });
3812
+ assert.equal(toolCall.kind, "other");
3813
+ assert.equal(toolCall.title, "Monitor: watch deploy logs");
3814
+ });
3815
+ test("createToolCall maps Workflow to other kind and name title", () => {
3816
+ const namedWorkflow = createToolCall("tc-workflow", "Workflow", {
3817
+ name: "spec",
3818
+ args: { topic: "rendering" },
3819
+ });
3820
+ const fallbackWorkflow = createToolCall("tc-workflow-fallback", "Workflow", {
3821
+ script: "export const meta = { name: 'inline', description: 'Run', phases: [] };",
3822
+ });
3823
+ assert.equal(namedWorkflow.kind, "other");
3824
+ assert.equal(namedWorkflow.title, "Workflow: spec");
3825
+ assert.equal(fallbackWorkflow.kind, "other");
3826
+ assert.equal(fallbackWorkflow.title, "Workflow");
3827
+ });
3828
+ test("createToolCall maps project and artifact tools to compact titles", () => {
3829
+ const projectInfo = createToolCall("tc-project-info", "Projects", {
3830
+ method: "project_info",
3831
+ });
3832
+ const projectRead = createToolCall("tc-project-read", "Projects", {
3833
+ method: "project_read",
3834
+ path: "claude/notes.md",
3835
+ });
3836
+ const projectSearch = createToolCall("tc-project-search", "Projects", {
3837
+ method: "project_search",
3838
+ query: "migration",
3839
+ });
3840
+ const artifactWithLabel = createToolCall("tc-artifact-label", "Artifact", {
3841
+ file_path: "C:/work/report.html",
3842
+ favicon: "R",
3843
+ label: "report-v2",
3844
+ });
3845
+ const artifactFallback = createToolCall("tc-artifact-path", "Artifact", {
3846
+ file_path: "C:/work/report.html",
3847
+ favicon: "R",
3848
+ });
3849
+ const rolePicker = createToolCall("tc-role-picker", "ShowOnboardingRolePicker", {});
3850
+ assert.equal(projectInfo.kind, "other");
3851
+ assert.equal(projectInfo.title, "Projects: info");
3852
+ assert.equal(projectRead.title, "Projects: read claude/notes.md");
3853
+ assert.equal(projectSearch.title, "Projects: search migration");
3854
+ assert.equal(artifactWithLabel.kind, "other");
3855
+ assert.equal(artifactWithLabel.title, "Artifact: report-v2");
3856
+ assert.equal(artifactFallback.title, "Artifact: C:/work/report.html");
3857
+ assert.equal(rolePicker.kind, "other");
3858
+ assert.equal(rolePicker.title, "ShowOnboardingRolePicker");
3859
+ });
3860
+ test("createToolCall maps EnterPlanMode to switch_mode kind with stable title", () => {
3861
+ const toolCall = createToolCall("tc-enter-plan-mode", "EnterPlanMode", {});
3862
+ assert.equal(toolCall.kind, "switch_mode");
3863
+ assert.equal(toolCall.title, "EnterPlanMode");
3864
+ });
1488
3865
  test("buildToolResultFields extracts plain-text output", () => {
1489
3866
  const fields = buildToolResultFields(false, [{ text: "line 1" }, { text: "line 2" }]);
1490
3867
  assert.equal(fields.status, "completed");
@@ -1493,6 +3870,61 @@ test("buildToolResultFields extracts plain-text output", () => {
1493
3870
  { type: "content", content: { type: "text", text: "line 1\nline 2" } },
1494
3871
  ]);
1495
3872
  });
3873
+ test("buildToolResultFields renders structured Grep output", () => {
3874
+ const base = createToolCall("tc-grep", "Grep", {
3875
+ pattern: "TODO",
3876
+ path: "src",
3877
+ output_mode: "content",
3878
+ });
3879
+ const fields = buildToolResultFields(false, "raw SDK text", base, {
3880
+ mode: "content",
3881
+ numFiles: 2,
3882
+ filenames: ["src/a.rs", "src/b.rs"],
3883
+ content: "src/a.rs:1:TODO\nsrc/b.rs:2:TODO",
3884
+ numLines: 2,
3885
+ numMatches: 2,
3886
+ appliedLimit: 250,
3887
+ });
3888
+ const expected = "src/a.rs:1:TODO\nsrc/b.rs:2:TODO\nSummary: 2 files, 2 matches, 2 lines, mode content, limit 250";
3889
+ assert.equal(fields.status, "completed");
3890
+ assert.equal(fields.raw_output, expected);
3891
+ assert.deepEqual(fields.content, [
3892
+ { type: "content", content: { type: "text", text: expected } },
3893
+ ]);
3894
+ });
3895
+ test("buildToolResultFields renders structured empty Grep output", () => {
3896
+ const base = createToolCall("tc-grep-empty", "Grep", {
3897
+ pattern: "<rare string>",
3898
+ output_mode: "content",
3899
+ });
3900
+ const fields = buildToolResultFields(false, "No matches found", base, {
3901
+ mode: "content",
3902
+ numFiles: 0,
3903
+ filenames: [],
3904
+ content: "",
3905
+ numLines: 0,
3906
+ });
3907
+ const expected = "No matches found\nSummary: 0 files, 0 lines, mode content";
3908
+ assert.equal(fields.raw_output, expected);
3909
+ assert.deepEqual(fields.content, [
3910
+ { type: "content", content: { type: "text", text: expected } },
3911
+ ]);
3912
+ });
3913
+ test("buildToolResultFields renders structured Glob output", () => {
3914
+ const base = createToolCall("tc-glob", "Glob", { pattern: "**/*.rs", path: "src" });
3915
+ const fields = buildToolResultFields(false, "", base, {
3916
+ durationMs: 12,
3917
+ numFiles: 2,
3918
+ filenames: ["src/main.rs", "src/lib.rs"],
3919
+ truncated: false,
3920
+ });
3921
+ const expected = "2 files found\nsrc/main.rs\nsrc/lib.rs\nDuration: 12ms";
3922
+ assert.equal(fields.status, "completed");
3923
+ assert.equal(fields.raw_output, expected);
3924
+ assert.deepEqual(fields.content, [
3925
+ { type: "content", content: { type: "text", text: expected } },
3926
+ ]);
3927
+ });
1496
3928
  test("normalizeToolResultText collapses persisted-output payload to first meaningful line", () => {
1497
3929
  const normalized = normalizeToolResultText(`
1498
3930
  <persisted-output>
@@ -1628,6 +4060,22 @@ test("buildToolResultFields ignores model-facing Bash stale read hints", () => {
1628
4060
  assert.equal(fields.raw_output, "real stdout");
1629
4061
  assert.equal(fields.output_metadata, undefined);
1630
4062
  });
4063
+ test("buildToolResultFields maps PowerShell structured output like shell output", () => {
4064
+ const base = createToolCall("tc-powershell", "PowerShell", { command: "Get-ChildItem" });
4065
+ const fields = buildToolResultFields(false, {
4066
+ stdout: "stdout line",
4067
+ stderr: "stderr line",
4068
+ interrupted: true,
4069
+ }, base, {
4070
+ result: {
4071
+ stdout: "stdout line",
4072
+ stderr: "stderr line",
4073
+ interrupted: true,
4074
+ },
4075
+ });
4076
+ assert.equal(fields.raw_output, "stdout line\nstderr line\nCommand was aborted before completion.");
4077
+ assert.equal(fields.output_metadata, undefined);
4078
+ });
1631
4079
  test("buildToolResultFields adds Bash auto-backgrounded metadata and message", () => {
1632
4080
  const base = createToolCall("tc-bash-bg", "Bash", { command: "npm run watch" });
1633
4081
  const fields = buildToolResultFields(false, {
@@ -1719,6 +4167,106 @@ test("buildToolResultFields restores ReadMcpResource blob paths from transcript
1719
4167
  },
1720
4168
  ]);
1721
4169
  });
4170
+ test("buildToolResultFields marks ReadMcpResource error output as failed", () => {
4171
+ const base = createToolCall("tc-mcp-error", "ReadMcpResource", {
4172
+ server: "docs",
4173
+ uri: "file://missing.md",
4174
+ });
4175
+ const fields = buildToolResultFields(false, {
4176
+ contents: [],
4177
+ error: "resource not found",
4178
+ }, base);
4179
+ assert.equal(fields.status, "failed");
4180
+ assert.equal(fields.raw_output, "Error: resource not found");
4181
+ assert.deepEqual(fields.content, [
4182
+ {
4183
+ type: "content",
4184
+ content: { type: "text", text: "Error: resource not found" },
4185
+ },
4186
+ ]);
4187
+ });
4188
+ test("buildToolResultFields preserves WebFetch artifactRead only as metadata", () => {
4189
+ const base = createToolCall("tc-web-fetch-artifact", "WebFetch", {
4190
+ url: "https://artifact.local/dashboard",
4191
+ });
4192
+ const fields = buildToolResultFields(false, {
4193
+ bytes: 128,
4194
+ code: 200,
4195
+ codeText: "OK",
4196
+ durationMs: 42,
4197
+ result: "Artifact content summary",
4198
+ url: "https://artifact.local/dashboard",
4199
+ artifactRead: {
4200
+ slug: "dashboard",
4201
+ ver: "v3",
4202
+ },
4203
+ }, base);
4204
+ assert.equal(fields.raw_output, "Artifact content summary");
4205
+ assert.equal(fields.raw_output?.includes("artifactRead"), false);
4206
+ assert.deepEqual(fields.output_metadata, {
4207
+ web_fetch: {
4208
+ artifact_read: {
4209
+ slug: "dashboard",
4210
+ ver: "v3",
4211
+ },
4212
+ },
4213
+ });
4214
+ });
4215
+ test("buildToolResultFields preserves Agent resolvedModel metadata", () => {
4216
+ const base = createToolCall("tc-agent-model", "Agent", {
4217
+ description: "review changes",
4218
+ prompt: "Review the branch",
4219
+ });
4220
+ const fields = buildToolResultFields(false, {
4221
+ agentId: "agent-1",
4222
+ agentType: "reviewer",
4223
+ resolvedModel: "claude-sonnet-4-7",
4224
+ content: [{ type: "text", text: "Done" }],
4225
+ totalToolUseCount: 1,
4226
+ totalDurationMs: 100,
4227
+ totalTokens: 25,
4228
+ usage: {
4229
+ input_tokens: 10,
4230
+ output_tokens: 15,
4231
+ cache_creation_input_tokens: null,
4232
+ cache_read_input_tokens: null,
4233
+ server_tool_use: null,
4234
+ service_tier: null,
4235
+ cache_creation: null,
4236
+ },
4237
+ status: "completed",
4238
+ prompt: "Review the branch",
4239
+ }, base);
4240
+ assert.equal(fields.title, "Agent: reviewer");
4241
+ assert.deepEqual(fields.output_metadata, {
4242
+ agent: {
4243
+ resolved_model: "claude-sonnet-4-7",
4244
+ },
4245
+ });
4246
+ });
4247
+ test("buildToolResultFields keeps Agent input name while preserving resolvedModel metadata", () => {
4248
+ const base = createToolCall("tc-agent-named-model", "Agent", {
4249
+ description: "review changes",
4250
+ prompt: "Review the branch",
4251
+ name: "review-worker",
4252
+ subagent_type: "general-purpose",
4253
+ model: "opus",
4254
+ });
4255
+ const fields = buildToolResultFields(false, {
4256
+ agentId: "agent-1",
4257
+ agentType: "general-purpose",
4258
+ resolvedModel: "claude-opus-4-8",
4259
+ content: [{ type: "text", text: "Done" }],
4260
+ status: "completed",
4261
+ prompt: "Review the branch",
4262
+ }, base);
4263
+ assert.equal(fields.title, undefined);
4264
+ assert.deepEqual(fields.output_metadata, {
4265
+ agent: {
4266
+ resolved_model: "claude-opus-4-8",
4267
+ },
4268
+ });
4269
+ });
1722
4270
  test("unwrapToolUseResult extracts error/content payload", () => {
1723
4271
  const parsed = unwrapToolUseResult({
1724
4272
  is_error: true,
@@ -1857,7 +4405,7 @@ test("looksLikeAuthRequired detects login hints", () => {
1857
4405
  assert.equal(looksLikeAuthRequired("normal tool output"), false);
1858
4406
  });
1859
4407
  test("agent sdk version compatibility check matches pinned version", () => {
1860
- assert.equal(resolveInstalledAgentSdkVersion(), "0.3.146");
4408
+ assert.equal(resolveInstalledAgentSdkVersion(), "0.3.177");
1861
4409
  assert.equal(agentSdkVersionCompatibilityError(), undefined);
1862
4410
  });
1863
4411
  test("mapSessionMessagesToUpdates maps message content blocks", () => {
@@ -1918,6 +4466,14 @@ test("mapSessionMessagesToUpdates maps message content blocks", () => {
1918
4466
  assert.equal(variantCounts.get("agent_message_chunk"), 1);
1919
4467
  assert.equal(variantCounts.get("tool_call"), 1);
1920
4468
  assert.equal(variantCounts.get("tool_call_update"), 1);
4469
+ const userChunk = updates.find((update) => update.type === "user_message_chunk");
4470
+ const agentChunk = updates.find((update) => update.type === "agent_message_chunk");
4471
+ const toolCall = updates.find((update) => update.type === "tool_call");
4472
+ const toolCallUpdate = updates.find((update) => update.type === "tool_call_update");
4473
+ assert.equal(userChunk?.source_message_uuid, "u1");
4474
+ assert.equal(agentChunk?.source_message_uuid, "a1");
4475
+ assert.equal(toolCall?.tool_call.source_message_uuid, "a1");
4476
+ assert.equal(toolCallUpdate?.tool_call_update.source_message_uuid, "u2");
1921
4477
  });
1922
4478
  test("mapSessionMessagesToUpdates suppresses ToolSearch history blocks", () => {
1923
4479
  const updates = mapSessionMessagesToUpdates([
@@ -2007,11 +4563,157 @@ test("mapSessionMessagesToUpdates preserves parallel tool results", () => {
2007
4563
  },
2008
4564
  },
2009
4565
  ]);
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"]);
4566
+ const toolCalls = updates.filter((update) => update.type === "tool_call");
4567
+ const toolUpdates = updates.filter((update) => update.type === "tool_call_update");
4568
+ assert.deepEqual(toolCalls.map((update) => update.tool_call.tool_call_id), ["tool-a", "tool-b"]);
4569
+ assert.deepEqual(toolUpdates.map((update) => update.tool_call_update.tool_call_id), ["tool-b", "tool-a"]);
4570
+ assert.deepEqual(toolUpdates.map((update) => update.tool_call_update.fields.raw_output), ["result b", "result a"]);
4571
+ });
4572
+ test("mapSessionMessagesToUpdates maps task system records from resume history", () => {
4573
+ const updates = mapSessionMessagesToUpdates([
4574
+ {
4575
+ type: "assistant",
4576
+ uuid: "assistant-agent",
4577
+ session_id: "s1",
4578
+ parent_tool_use_id: null,
4579
+ message: {
4580
+ role: "assistant",
4581
+ content: [
4582
+ {
4583
+ type: "tool_use",
4584
+ id: "tool-agent",
4585
+ name: "Agent",
4586
+ input: { prompt: "Inspect the migration smoke" },
4587
+ },
4588
+ ],
4589
+ },
4590
+ },
4591
+ {
4592
+ type: "system",
4593
+ uuid: "system-bg-start",
4594
+ session_id: "s1",
4595
+ parent_tool_use_id: null,
4596
+ message: {
4597
+ type: "system",
4598
+ subtype: "task_started",
4599
+ task_id: "task-bg",
4600
+ tool_use_id: "tool-agent",
4601
+ description: "Inspect the migration smoke",
4602
+ subagent_type: "general-purpose",
4603
+ },
4604
+ },
4605
+ {
4606
+ type: "system",
4607
+ uuid: "system-bg-update",
4608
+ session_id: "s1",
4609
+ parent_tool_use_id: null,
4610
+ message: {
4611
+ type: "system",
4612
+ subtype: "task_updated",
4613
+ task_id: "task-bg",
4614
+ patch: {
4615
+ status: "running",
4616
+ description: "Checking runtime MCP resources",
4617
+ is_backgrounded: true,
4618
+ },
4619
+ },
4620
+ },
4621
+ {
4622
+ type: "system",
4623
+ uuid: "system-remote-start",
4624
+ session_id: "s1",
4625
+ parent_tool_use_id: null,
4626
+ message: {
4627
+ type: "system",
4628
+ subtype: "task_started",
4629
+ task_id: "task-remote",
4630
+ description: "Remote agent is still running",
4631
+ task_type: "remote_agent",
4632
+ task_description: "Remote agent smoke",
4633
+ prompt: "Continue remotely",
4634
+ },
4635
+ },
4636
+ {
4637
+ type: "system",
4638
+ uuid: "system-mcp-start",
4639
+ session_id: "s1",
4640
+ parent_tool_use_id: null,
4641
+ message: {
4642
+ type: "system",
4643
+ subtype: "task_started",
4644
+ task_id: "task-mcp",
4645
+ description: "MCP task is still running",
4646
+ task_type: "mcp",
4647
+ workflow_name: "docs",
4648
+ prompt: "Read resource",
4649
+ },
4650
+ },
4651
+ ]);
4652
+ const taskUpdates = updates.filter((update) => update.type === "task_state_update");
4653
+ assert.equal(taskUpdates.length, 4);
4654
+ assert.deepEqual(taskUpdates.at(1), {
4655
+ type: "task_state_update",
4656
+ source: "task_lifecycle",
4657
+ tasks: [
4658
+ {
4659
+ task_id: "task-bg",
4660
+ subject: "Inspect the migration smoke",
4661
+ description: "Checking runtime MCP resources",
4662
+ status: "in_progress",
4663
+ blocks: [],
4664
+ blocked_by: [],
4665
+ metadata: {
4666
+ subagent_type: "general-purpose",
4667
+ is_backgrounded: true,
4668
+ },
4669
+ source_tool_call_id: "tool-agent",
4670
+ },
4671
+ ],
4672
+ removed_task_ids: [],
4673
+ is_complete_snapshot: false,
4674
+ });
4675
+ assert.deepEqual(taskUpdates.at(2), {
4676
+ type: "task_state_update",
4677
+ source: "task_lifecycle",
4678
+ tasks: [
4679
+ {
4680
+ task_id: "task-remote",
4681
+ subject: "Remote agent smoke",
4682
+ description: "Remote agent is still running",
4683
+ status: "in_progress",
4684
+ blocks: [],
4685
+ blocked_by: [],
4686
+ metadata: {
4687
+ task_description: "Remote agent smoke",
4688
+ task_type: "remote_agent",
4689
+ prompt: "Continue remotely",
4690
+ },
4691
+ },
4692
+ ],
4693
+ removed_task_ids: [],
4694
+ is_complete_snapshot: false,
4695
+ });
4696
+ assert.deepEqual(taskUpdates.at(3), {
4697
+ type: "task_state_update",
4698
+ source: "task_lifecycle",
4699
+ tasks: [
4700
+ {
4701
+ task_id: "task-mcp",
4702
+ subject: "docs",
4703
+ description: "MCP task is still running",
4704
+ status: "in_progress",
4705
+ blocks: [],
4706
+ blocked_by: [],
4707
+ metadata: {
4708
+ task_type: "mcp",
4709
+ workflow_name: "docs",
4710
+ prompt: "Read resource",
4711
+ },
4712
+ },
4713
+ ],
4714
+ removed_task_ids: [],
4715
+ is_complete_snapshot: false,
4716
+ });
2015
4717
  });
2016
4718
  test("handleResultMessage emits terminal reason on successful turn completion", () => {
2017
4719
  const session = makeSessionState();
@@ -2029,6 +4731,63 @@ test("handleResultMessage emits terminal reason on successful turn completion",
2029
4731
  terminal_reason: "completed",
2030
4732
  });
2031
4733
  });
4734
+ test("handleResultMessage ignores success result telemetry fields", () => {
4735
+ const session = makeSessionState();
4736
+ const events = captureBridgeEvents(() => {
4737
+ handleResultMessage(session, {
4738
+ type: "result",
4739
+ subtype: "success",
4740
+ ttft_stream_ms: 42,
4741
+ time_to_request_ms: 12,
4742
+ time_to_request_from_spawn_ms: 7,
4743
+ warm_spare_claimed: true,
4744
+ });
4745
+ });
4746
+ assert.deepEqual(events.at(-1), {
4747
+ event: "turn_complete",
4748
+ session_id: "session-1",
4749
+ });
4750
+ });
4751
+ test("handleResultMessage emits repeated turn_complete while background work remains open", () => {
4752
+ const session = makeSessionState();
4753
+ const events = captureBridgeEvents(() => {
4754
+ emitToolCall(session, "tool-monitor", "Monitor", {
4755
+ description: "watch deploy logs",
4756
+ timeout_ms: 30000,
4757
+ persistent: false,
4758
+ command: "tail -f deploy.log",
4759
+ });
4760
+ emitToolResultUpdate(session, "tool-monitor", false, {
4761
+ taskId: "monitor-1",
4762
+ timeoutMs: 30000,
4763
+ persistent: false,
4764
+ });
4765
+ handleResultMessage(session, {
4766
+ type: "result",
4767
+ subtype: "success",
4768
+ terminal_reason: "completed",
4769
+ });
4770
+ handleResultMessage(session, {
4771
+ type: "result",
4772
+ subtype: "success",
4773
+ terminal_reason: "completed",
4774
+ });
4775
+ });
4776
+ assert.deepEqual(events.filter((event) => event.event === "turn_complete"), [
4777
+ {
4778
+ event: "turn_complete",
4779
+ session_id: "session-1",
4780
+ terminal_reason: "completed",
4781
+ },
4782
+ {
4783
+ event: "turn_complete",
4784
+ session_id: "session-1",
4785
+ terminal_reason: "completed",
4786
+ },
4787
+ ]);
4788
+ assert.equal(session.toolCalls.get("tool-monitor")?.status, "in_progress");
4789
+ assert.equal(session.taskToolUseIds.get("monitor-1"), "tool-monitor");
4790
+ });
2032
4791
  test("handleResultMessage emits terminal reason on turn errors", () => {
2033
4792
  const session = makeSessionState();
2034
4793
  const events = captureBridgeEvents(() => {
@@ -2049,6 +4808,53 @@ test("handleResultMessage emits terminal reason on turn errors", () => {
2049
4808
  terminal_reason: "max_turns",
2050
4809
  });
2051
4810
  });
4811
+ test("handleResultMessage emits typed turn error classifications for SDK assistant errors", () => {
4812
+ const cases = [
4813
+ ["model_not_found", "model_unavailable"],
4814
+ ["oauth_org_not_allowed", "account_access"],
4815
+ ["overloaded", "transient_service"],
4816
+ ];
4817
+ for (const [assistantError, errorKind] of cases) {
4818
+ const session = makeSessionState();
4819
+ session.lastAssistantError = assistantError;
4820
+ const events = captureBridgeEvents(() => {
4821
+ handleResultMessage(session, {
4822
+ type: "result",
4823
+ subtype: "error_during_execution",
4824
+ errors: [`failed with ${assistantError}`],
4825
+ });
4826
+ });
4827
+ assert.deepEqual(events.at(-1), {
4828
+ event: "turn_error",
4829
+ session_id: "session-1",
4830
+ message: `failed with ${assistantError}`,
4831
+ error_kind: errorKind,
4832
+ sdk_result_subtype: "error_during_execution",
4833
+ assistant_error: assistantError,
4834
+ });
4835
+ }
4836
+ });
4837
+ test("handleResultMessage preserves result api error status", () => {
4838
+ const session = makeSessionState();
4839
+ session.lastAssistantError = "overloaded";
4840
+ const events = captureBridgeEvents(() => {
4841
+ handleResultMessage(session, {
4842
+ type: "result",
4843
+ subtype: "error_during_execution",
4844
+ errors: ["service overloaded"],
4845
+ api_error_status: 529,
4846
+ });
4847
+ });
4848
+ assert.deepEqual(events.at(-1), {
4849
+ event: "turn_error",
4850
+ session_id: "session-1",
4851
+ message: "service overloaded",
4852
+ error_kind: "transient_service",
4853
+ sdk_result_subtype: "error_during_execution",
4854
+ assistant_error: "overloaded",
4855
+ api_error_status: 529,
4856
+ });
4857
+ });
2052
4858
  test("mapSessionMessagesToUpdates ignores unsupported records", () => {
2053
4859
  const updates = mapSessionMessagesToUpdates([
2054
4860
  {
@@ -2061,115 +4867,594 @@ test("mapSessionMessagesToUpdates ignores unsupported records", () => {
2061
4867
  content: [{ type: "thinking", thinking: "h" }],
2062
4868
  },
2063
4869
  },
4870
+ {
4871
+ type: "system",
4872
+ uuid: "system-unsupported",
4873
+ session_id: "s1",
4874
+ parent_tool_use_id: null,
4875
+ message: {
4876
+ type: "system",
4877
+ subtype: "compact_boundary",
4878
+ content: [{ type: "text", text: "internal system text" }],
4879
+ },
4880
+ },
4881
+ ]);
4882
+ assert.equal(updates.length, 0);
4883
+ });
4884
+ test("mapSdkSessions normalizes and sorts sessions", () => {
4885
+ const mapped = mapSdkSessions([
4886
+ {
4887
+ sessionId: "older",
4888
+ summary: " Older summary ",
4889
+ lastModified: 100,
4890
+ fileSize: 10,
4891
+ cwd: "C:/work",
4892
+ },
4893
+ {
4894
+ sessionId: "latest",
4895
+ summary: "",
4896
+ lastModified: 200,
4897
+ fileSize: 20,
4898
+ customTitle: "Custom title",
4899
+ gitBranch: "main",
4900
+ firstPrompt: "hello",
4901
+ },
4902
+ ]);
4903
+ assert.deepEqual(mapped, [
4904
+ {
4905
+ session_id: "latest",
4906
+ summary: "Custom title",
4907
+ last_modified_ms: 200,
4908
+ file_size_bytes: 20,
4909
+ git_branch: "main",
4910
+ custom_title: "Custom title",
4911
+ first_prompt: "hello",
4912
+ },
4913
+ {
4914
+ session_id: "older",
4915
+ summary: "Older summary",
4916
+ last_modified_ms: 100,
4917
+ file_size_bytes: 10,
4918
+ cwd: "C:/work",
4919
+ },
4920
+ ]);
4921
+ });
4922
+ test("buildSessionListOptions scopes repo-local listings to worktrees", () => {
4923
+ assert.deepEqual(buildSessionListOptions("C:/repo"), {
4924
+ dir: "C:/repo",
4925
+ includeWorktrees: true,
4926
+ limit: 50,
4927
+ });
4928
+ assert.deepEqual(buildSessionListOptions(undefined), {
4929
+ limit: 50,
4930
+ });
4931
+ });
4932
+ test("buildToolResultFields renders file_unchanged Read results compactly", () => {
4933
+ const base = createToolCall("tc-read", "Read", { file_path: "src/main.rs" });
4934
+ const fields = buildToolResultFields(false, {
4935
+ type: "file_unchanged",
4936
+ file: { filePath: "src/main.rs" },
4937
+ }, base, {
4938
+ result: {
4939
+ type: "file_unchanged",
4940
+ file: { filePath: "src/main.rs" },
4941
+ },
4942
+ });
4943
+ assert.equal(fields.raw_output, "File unchanged: src/main.rs");
4944
+ assert.deepEqual(fields.content, [
4945
+ { type: "content", content: { type: "text", text: "File unchanged: src/main.rs" } },
4946
+ ]);
4947
+ });
4948
+ test("buildToolResultFields renders array-wrapped file_unchanged Read results compactly", () => {
4949
+ const base = createToolCall("tc-read", "Read", { file_path: "src/lib.rs" });
4950
+ const fields = buildToolResultFields(false, [], base, {
4951
+ result: [
4952
+ {
4953
+ type: "file_unchanged",
4954
+ file: { filePath: "src/lib.rs" },
4955
+ },
4956
+ ],
4957
+ });
4958
+ assert.equal(fields.raw_output, "File unchanged: src/lib.rs");
4959
+ });
4960
+ test("buildToolResultFields uses Agent output agentType as task title", () => {
4961
+ const base = createToolCall("tc-agent", "Agent", { prompt: "Review tests" });
4962
+ const fields = buildToolResultFields(false, {
4963
+ agentId: "agent-1",
4964
+ agentType: "reviewer",
4965
+ content: [{ type: "text", text: "Done" }],
4966
+ totalToolUseCount: 0,
4967
+ totalDurationMs: 10,
4968
+ totalTokens: 20,
4969
+ usage: {},
4970
+ status: "completed",
4971
+ prompt: "Review tests",
4972
+ }, base);
4973
+ assert.equal(fields.title, "Agent: reviewer");
4974
+ });
4975
+ test("buildToolResultFields reads array-wrapped Agent output agentType", () => {
4976
+ const base = createToolCall("tc-agent", "Agent", { prompt: "Review tests" });
4977
+ const fields = buildToolResultFields(false, [], base, {
4978
+ result: [
4979
+ {
4980
+ agentId: "agent-1",
4981
+ agentType: "planner",
4982
+ content: [{ type: "text", text: "Done" }],
4983
+ status: "completed",
4984
+ },
4985
+ ],
4986
+ });
4987
+ assert.equal(fields.title, "Agent: planner");
4988
+ });
4989
+ test("buildToolResultFields leaves worktree title unchanged on completed output", () => {
4990
+ const enterBase = createToolCall("tc-enter", "EnterWorktree", { name: "feature-auth" });
4991
+ const enterFields = buildToolResultFields(false, {
4992
+ message: "Entered worktree feature-auth",
4993
+ worktreeBranch: "feature-auth",
4994
+ worktreePath: "C:\\repo\\.worktrees\\feature-auth",
4995
+ }, enterBase);
4996
+ assert.equal(enterFields.title, undefined);
4997
+ const exitBase = createToolCall("tc-exit", "ExitWorktree", { action: "keep" });
4998
+ const exitFields = buildToolResultFields(false, {
4999
+ message: "Exited worktree feature-auth",
5000
+ worktreePath: "C:\\repo\\.worktrees\\feature-auth",
5001
+ }, exitBase);
5002
+ assert.equal(exitFields.title, undefined);
5003
+ });
5004
+ test("buildToolResultFields renders worktree location without raw JSON", () => {
5005
+ const enterBase = createToolCall("tc-enter", "EnterWorktree", { name: "feature-auth" });
5006
+ const enterFields = buildToolResultFields(false, {
5007
+ message: "Entered worktree feature-auth",
5008
+ worktreeBranch: "feature-auth",
5009
+ worktreePath: "C:\\repo\\.worktrees\\feature-auth",
5010
+ }, enterBase);
5011
+ assert.equal(enterFields.raw_output, "Branch: feature-auth");
5012
+ assert.deepEqual(enterFields.content, [
5013
+ { type: "content", content: { type: "text", text: "Branch: feature-auth" } },
5014
+ ]);
5015
+ const exitBase = createToolCall("tc-exit", "ExitWorktree", { action: "keep" });
5016
+ const exitFields = buildToolResultFields(false, {
5017
+ message: "Exited worktree feature-auth",
5018
+ worktreePath: "C:\\repo\\.worktrees\\feature-auth",
5019
+ }, exitBase);
5020
+ assert.equal(exitFields.raw_output, "Path: C:\\repo\\.worktrees\\feature-auth");
5021
+ assert.deepEqual(exitFields.content, [
5022
+ {
5023
+ type: "content",
5024
+ content: { type: "text", text: "Path: C:\\repo\\.worktrees\\feature-auth" },
5025
+ },
5026
+ ]);
5027
+ });
5028
+ test("buildToolResultFields renders cron outputs as structured text without raw JSON", () => {
5029
+ const createBase = createToolCall("tc-cron-create", "CronCreate", {
5030
+ cron: "30 9 * * 1",
5031
+ prompt: "Send weekly status",
5032
+ });
5033
+ const createFields = buildToolResultFields(false, {
5034
+ id: "schedule-1",
5035
+ humanSchedule: "every Monday at 09:30",
5036
+ recurring: true,
5037
+ durable: false,
5038
+ }, createBase);
5039
+ assert.equal(createFields.raw_output, "Schedule ID: schedule-1\nSchedule: Every Monday at 09:30\nRecurring: yes\nDurable: no");
5040
+ assert.deepEqual(createFields.content, [
5041
+ {
5042
+ type: "content",
5043
+ content: {
5044
+ type: "text",
5045
+ text: "Schedule ID: schedule-1\nSchedule: Every Monday at 09:30\nRecurring: yes\nDurable: no",
5046
+ },
5047
+ },
5048
+ ]);
5049
+ assert.equal(createFields.raw_output?.includes("{"), false);
5050
+ const deleteBase = createToolCall("tc-cron-delete", "CronDelete", { id: "schedule-1" });
5051
+ const deleteFields = buildToolResultFields(false, { id: "schedule-1" }, deleteBase);
5052
+ assert.equal(deleteFields.raw_output, "Schedule ID: schedule-1");
5053
+ const listBase = createToolCall("tc-cron-list", "CronList", {});
5054
+ const listFields = buildToolResultFields(false, { jobs: [] }, listBase);
5055
+ assert.equal(listFields.raw_output, "Jobs: none");
5056
+ const singleListFields = buildToolResultFields(false, {
5057
+ jobs: [
5058
+ {
5059
+ id: "schedule-2",
5060
+ cron: "7 * * * *",
5061
+ humanSchedule: "Every hour at :07",
5062
+ prompt: "Send hourly tick",
5063
+ recurring: true,
5064
+ durable: false,
5065
+ },
5066
+ ],
5067
+ }, listBase);
5068
+ 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");
5069
+ });
5070
+ test("buildToolResultFields preserves full CronList prompt from transcript JSON", () => {
5071
+ const base = createToolCall("tc-cron-list-history", "CronList", {});
5072
+ const fullPrompt = `Review the branch and write a status update. ${"Keep every detail. ".repeat(80)}END`;
5073
+ const transcriptJson = JSON.stringify({
5074
+ jobs: [
5075
+ {
5076
+ id: "schedule-long",
5077
+ cron: "*/5 * * * *",
5078
+ humanSchedule: "every 5 minutes",
5079
+ prompt: fullPrompt,
5080
+ recurring: false,
5081
+ durable: true,
5082
+ },
5083
+ ],
5084
+ });
5085
+ const fields = buildToolResultFields(false, transcriptJson, base, {
5086
+ type: "tool_result",
5087
+ tool_use_id: "tc-cron-list-history",
5088
+ content: transcriptJson,
5089
+ });
5090
+ assert.equal(fields.raw_output?.includes(fullPrompt), true);
5091
+ assert.equal(fields.raw_output?.includes("END"), true);
5092
+ assert.equal(fields.raw_output?.includes('"jobs"'), false);
5093
+ assert.deepEqual(fields.content, [
5094
+ {
5095
+ type: "content",
5096
+ content: {
5097
+ type: "text",
5098
+ text: `Schedule ID: schedule-long\nCron: */5 * * * *\nSchedule: Every 5 minutes\nPrompt: ${fullPrompt}\nRecurring: no\nDurable: yes`,
5099
+ },
5100
+ },
5101
+ ]);
5102
+ });
5103
+ test("buildToolResultFields renders readable cron schedule text from common cron expressions", () => {
5104
+ const base = createToolCall("tc-cron-readable", "CronList", {});
5105
+ const fields = buildToolResultFields(false, {
5106
+ jobs: [
5107
+ { id: "every-minute", cron: "* * * * *", prompt: "minute", recurring: true },
5108
+ { id: "every-five-minutes", cron: "*/5 * * * *", prompt: "minutes", recurring: true },
5109
+ {
5110
+ id: "hourly-minute",
5111
+ cron: "7 * * * *",
5112
+ humanSchedule: "Every hour at :07",
5113
+ prompt: "hourly",
5114
+ recurring: true,
5115
+ },
5116
+ { id: "every-two-hours", cron: "0 */2 * * *", prompt: "hours", recurring: true },
5117
+ { id: "daily", cron: "30 9 * * *", prompt: "daily", recurring: true },
5118
+ { id: "weekly", cron: "30 9 * * 1", prompt: "weekly", recurring: true },
5119
+ { id: "monthly", cron: "30 9 15 * *", prompt: "monthly", recurring: true },
5120
+ { id: "yearly", cron: "30 9 15 6 *", prompt: "yearly", recurring: true },
5121
+ { id: "complex", cron: "0 9 1 * 1", prompt: "complex", recurring: true },
5122
+ ],
5123
+ }, base);
5124
+ assert.equal(fields.raw_output?.includes("Cron: 7 * * * *"), false);
5125
+ assert.equal(fields.raw_output?.includes("Recurring:"), false);
5126
+ assert.equal(fields.raw_output?.includes("Durable:"), false);
5127
+ assert.equal(fields.raw_output?.includes("Schedule: Every minute"), true);
5128
+ assert.equal(fields.raw_output?.includes("Schedule: Every 5 minutes"), true);
5129
+ assert.equal(fields.raw_output?.includes("Schedule: Every hour at minute 07"), true);
5130
+ assert.equal(fields.raw_output?.includes("Schedule: Every 2 hours on the hour"), true);
5131
+ assert.equal(fields.raw_output?.includes("Schedule: Every day at 09:30"), true);
5132
+ assert.equal(fields.raw_output?.includes("Schedule: Every Monday at 09:30"), true);
5133
+ assert.equal(fields.raw_output?.includes("Schedule: Every month on day 15 at 09:30"), true);
5134
+ assert.equal(fields.raw_output?.includes("Schedule: Every June 15 at 09:30"), true);
5135
+ assert.equal(fields.raw_output?.includes("Cron: 0 9 1 * 1"), true);
5136
+ assert.equal(fields.raw_output?.split("__cron_list_job_divider__").length, 9);
5137
+ });
5138
+ test("buildToolResultFields renders ScheduleWakeup output as structured text", () => {
5139
+ const base = createToolCall("tc-wakeup", "ScheduleWakeup", {
5140
+ delaySeconds: 30,
5141
+ reason: "Retry after runtime clamp",
5142
+ prompt: "/loop keep checking",
5143
+ });
5144
+ const fields = buildToolResultFields(false, {
5145
+ scheduledFor: 1_779_990_000_000,
5146
+ clampedDelaySeconds: 90,
5147
+ wasClamped: true,
5148
+ }, base);
5149
+ 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$/);
5150
+ assert.equal(fields.raw_output?.includes("{"), false);
5151
+ assert.equal(fields.raw_output?.includes("1779990000000"), false);
5152
+ assert.deepEqual(fields.content, [
5153
+ {
5154
+ type: "content",
5155
+ content: {
5156
+ type: "text",
5157
+ text: fields.raw_output,
5158
+ },
5159
+ },
5160
+ ]);
5161
+ });
5162
+ test("buildToolResultFields parses ScheduleWakeup transcript JSON", () => {
5163
+ const base = createToolCall("tc-wakeup-history", "ScheduleWakeup", {
5164
+ delaySeconds: 3600,
5165
+ reason: "Wake at the next loop interval",
5166
+ prompt: "/loop continue",
5167
+ });
5168
+ const transcriptJson = JSON.stringify({
5169
+ scheduledFor: 1_779_990_000_000,
5170
+ clampedDelaySeconds: 3600,
5171
+ wasClamped: false,
5172
+ });
5173
+ const fields = buildToolResultFields(false, transcriptJson, base, {
5174
+ type: "tool_result",
5175
+ tool_use_id: "tc-wakeup-history",
5176
+ content: transcriptJson,
5177
+ });
5178
+ assert.match(fields.raw_output ?? "", /Actual delay: 1h\nClamped: no$/);
5179
+ assert.equal(fields.raw_output?.includes('"scheduledFor"'), false);
5180
+ });
5181
+ test("buildToolResultFields renders PushNotification output as structured text", () => {
5182
+ const base = createToolCall("tc-push-notification", "PushNotification", {
5183
+ message: "Build finished",
5184
+ status: "proactive",
5185
+ });
5186
+ const fields = buildToolResultFields(false, {
5187
+ message: "Build finished",
5188
+ pushSent: false,
5189
+ localSent: true,
5190
+ disabledReason: "config_off",
5191
+ idleSec: 90,
5192
+ hasFocus: false,
5193
+ sentAt: "2026-06-05T12:34:56.000Z",
5194
+ }, base);
5195
+ 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$/);
5196
+ assert.equal(fields.raw_output?.includes("Result:"), false);
5197
+ assert.equal(fields.raw_output?.includes("{"), false);
5198
+ assert.deepEqual(fields.content, [
5199
+ {
5200
+ type: "content",
5201
+ content: {
5202
+ type: "text",
5203
+ text: fields.raw_output,
5204
+ },
5205
+ },
2064
5206
  ]);
2065
- assert.equal(updates.length, 0);
2066
5207
  });
2067
- test("mapSdkSessions normalizes and sorts sessions", () => {
2068
- const mapped = mapSdkSessions([
2069
- {
2070
- sessionId: "older",
2071
- summary: " Older summary ",
2072
- lastModified: 100,
2073
- fileSize: 10,
2074
- cwd: "C:/work",
2075
- },
5208
+ test("buildToolResultFields parses PushNotification transcript JSON", () => {
5209
+ const base = createToolCall("tc-push-notification-history", "PushNotification", {
5210
+ message: "Deploy completed",
5211
+ status: "proactive",
5212
+ });
5213
+ const transcriptJson = JSON.stringify({
5214
+ message: "Notification queued",
5215
+ pushSent: true,
5216
+ localSent: false,
5217
+ disabledReason: "no_transport",
5218
+ idleSec: 3600,
5219
+ hasFocus: true,
5220
+ sentAt: "not an iso timestamp",
5221
+ });
5222
+ const fields = buildToolResultFields(false, transcriptJson, base, {
5223
+ type: "tool_result",
5224
+ tool_use_id: "tc-push-notification-history",
5225
+ content: transcriptJson,
5226
+ });
5227
+ 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");
5228
+ assert.equal(fields.raw_output?.includes('"pushSent"'), false);
5229
+ });
5230
+ test("buildToolResultFields renders RemoteTrigger summary without raw JSON", () => {
5231
+ const base = createToolCall("tc-remote-trigger", "RemoteTrigger", {
5232
+ action: "run",
5233
+ trigger_id: "deploy-prod",
5234
+ });
5235
+ const fields = buildToolResultFields(false, {
5236
+ status: 200,
5237
+ json: '{\n "ok": true,\n "run_id": "run-1"\n}',
5238
+ summary: "Trigger completed",
5239
+ }, base);
5240
+ assert.equal(fields.status, "completed");
5241
+ assert.equal(fields.raw_output, "Status: 200\nSummary: Trigger completed");
5242
+ assert.equal(fields.raw_output?.includes("run_id"), false);
5243
+ assert.deepEqual(fields.content, [
2076
5244
  {
2077
- sessionId: "latest",
2078
- summary: "",
2079
- lastModified: 200,
2080
- fileSize: 20,
2081
- customTitle: "Custom title",
2082
- gitBranch: "main",
2083
- firstPrompt: "hello",
5245
+ type: "content",
5246
+ content: {
5247
+ type: "text",
5248
+ text: "Status: 200\nSummary: Trigger completed",
5249
+ },
2084
5250
  },
2085
5251
  ]);
2086
- assert.deepEqual(mapped, [
2087
- {
2088
- session_id: "latest",
2089
- summary: "Custom title",
2090
- last_modified_ms: 200,
2091
- file_size_bytes: 20,
2092
- git_branch: "main",
2093
- custom_title: "Custom title",
2094
- first_prompt: "hello",
2095
- },
5252
+ });
5253
+ test("buildToolResultFields renders RemoteTrigger response when summary is absent", () => {
5254
+ const base = createToolCall("tc-remote-trigger-response", "RemoteTrigger", {
5255
+ action: "get",
5256
+ trigger_id: "deploy-prod",
5257
+ });
5258
+ const fields = buildToolResultFields(false, {
5259
+ status: 200,
5260
+ json: '{\n "ok": true,\n "trigger_id": "deploy-prod"\n}',
5261
+ }, base);
5262
+ assert.equal(fields.status, "completed");
5263
+ assert.equal(fields.raw_output, 'Status: 200\nResponse: {"ok":true,"trigger_id":"deploy-prod"}');
5264
+ assert.equal(fields.raw_output?.includes('"json"'), false);
5265
+ });
5266
+ test("buildToolResultFields marks RemoteTrigger 4xx output failed", () => {
5267
+ const base = createToolCall("tc-remote-trigger-error", "RemoteTrigger", {
5268
+ action: "run",
5269
+ trigger_id: "missing-trigger",
5270
+ });
5271
+ const fields = buildToolResultFields(false, {
5272
+ status: 404,
5273
+ json: '{"error":"not_found"}',
5274
+ summary: "Trigger not found",
5275
+ }, base);
5276
+ assert.equal(fields.status, "failed");
5277
+ assert.equal(fields.raw_output, "Status: 404\nSummary: Trigger not found");
5278
+ });
5279
+ test("buildToolResultFields parses RemoteTrigger transcript JSON", () => {
5280
+ const base = createToolCall("tc-remote-trigger-history", "RemoteTrigger", {
5281
+ action: "get",
5282
+ trigger_id: "deploy-prod",
5283
+ });
5284
+ const transcriptJson = JSON.stringify({
5285
+ status: 200,
5286
+ json: '{\n "enabled": true,\n "name": "Deploy prod"\n}',
5287
+ });
5288
+ const fields = buildToolResultFields(false, transcriptJson, base, {
5289
+ type: "tool_result",
5290
+ tool_use_id: "tc-remote-trigger-history",
5291
+ content: transcriptJson,
5292
+ });
5293
+ assert.equal(fields.raw_output, 'Status: 200\nResponse: {"enabled":true,"name":"Deploy prod"}');
5294
+ assert.equal(fields.raw_output?.includes('"json"'), false);
5295
+ });
5296
+ test("buildToolResultFields renders REPL output as structured text without raw JSON", () => {
5297
+ const base = createToolCall("tc-repl", "REPL", {
5298
+ code: "await main()",
5299
+ description: "Run main function",
5300
+ });
5301
+ const fields = buildToolResultFields(false, {
5302
+ code: "await main()",
5303
+ stdout: "done",
5304
+ stderr: "warning",
5305
+ result: { ok: true },
5306
+ registeredTools: ["fetchDocs", "parse"],
5307
+ images: [
5308
+ { base64: "image-one-base64", mediaType: "image/png" },
5309
+ { base64: "image-two-base64", mediaType: "image/png" },
5310
+ ],
5311
+ documents: [{ base64: "document-base64" }],
5312
+ }, base);
5313
+ assert.equal(fields.status, "completed");
5314
+ assert.equal(fields.raw_output, "Stdout: done\nStderr: warning\nResult: {\"ok\":true}\nRegistered tools: fetchDocs, parse\nImages: 2\nDocuments: 1");
5315
+ assert.equal(fields.raw_output?.includes("await main()"), false);
5316
+ assert.equal(fields.raw_output?.includes("image-one-base64"), false);
5317
+ assert.equal(fields.raw_output?.includes("document-base64"), false);
5318
+ assert.equal(fields.raw_output?.includes("{\"code\""), false);
5319
+ assert.deepEqual(fields.content, [
2096
5320
  {
2097
- session_id: "older",
2098
- summary: "Older summary",
2099
- last_modified_ms: 100,
2100
- file_size_bytes: 10,
2101
- cwd: "C:/work",
5321
+ type: "content",
5322
+ content: {
5323
+ type: "text",
5324
+ text: fields.raw_output,
5325
+ },
2102
5326
  },
2103
5327
  ]);
2104
5328
  });
2105
- test("buildSessionListOptions scopes repo-local listings to worktrees", () => {
2106
- assert.deepEqual(buildSessionListOptions("C:/repo"), {
2107
- dir: "C:/repo",
2108
- includeWorktrees: true,
2109
- limit: 50,
5329
+ test("buildToolResultFields marks REPL error output failed", () => {
5330
+ const base = createToolCall("tc-repl-error", "REPL", {
5331
+ code: "throw new Error('boom')",
2110
5332
  });
2111
- assert.deepEqual(buildSessionListOptions(undefined), {
2112
- limit: 50,
5333
+ const fields = buildToolResultFields(false, {
5334
+ code: "throw new Error('boom')",
5335
+ error: "boom",
5336
+ stdout: "",
5337
+ stderr: "stack trace",
5338
+ result: {},
5339
+ }, base);
5340
+ assert.equal(fields.status, "failed");
5341
+ assert.equal(fields.raw_output, "Error: boom\nStderr: stack trace");
5342
+ });
5343
+ test("buildToolResultFields parses REPL transcript JSON", () => {
5344
+ const base = createToolCall("tc-repl-history", "REPL", {
5345
+ code: "await load()",
5346
+ });
5347
+ const transcriptJson = JSON.stringify({
5348
+ code: "await load()",
5349
+ stdout: "loaded",
5350
+ stderr: "",
5351
+ result: { count: 2 },
5352
+ registeredTools: ["lookup"],
5353
+ images: [{ base64: "hidden-image", mediaType: "image/png" }],
5354
+ documents: [{ base64: "hidden-document" }, { base64: "hidden-document-2" }],
5355
+ });
5356
+ const fields = buildToolResultFields(false, transcriptJson, base, {
5357
+ type: "tool_result",
5358
+ tool_use_id: "tc-repl-history",
5359
+ content: transcriptJson,
2113
5360
  });
5361
+ assert.equal(fields.raw_output, "Stdout: loaded\nResult: {\"count\":2}\nRegistered tools: lookup\nImages: 1\nDocuments: 2");
5362
+ assert.equal(fields.raw_output?.includes('"code"'), false);
5363
+ assert.equal(fields.raw_output?.includes("hidden-image"), false);
5364
+ assert.equal(fields.raw_output?.includes("hidden-document"), false);
2114
5365
  });
2115
- test("buildToolResultFields renders file_unchanged Read results compactly", () => {
2116
- const base = createToolCall("tc-read", "Read", { file_path: "src/main.rs" });
2117
- const fields = buildToolResultFields(false, {
2118
- type: "file_unchanged",
2119
- file: { filePath: "src/main.rs" },
2120
- }, base, {
2121
- result: {
2122
- type: "file_unchanged",
2123
- file: { filePath: "src/main.rs" },
2124
- },
5366
+ test("buildToolResultFields renders Monitor launch output as structured text", () => {
5367
+ const base = createToolCall("tc-monitor", "Monitor", {
5368
+ description: "watch deploy logs",
5369
+ timeout_ms: 30000,
5370
+ persistent: false,
5371
+ command: "tail -f deploy.log",
2125
5372
  });
2126
- assert.equal(fields.raw_output, "File unchanged: src/main.rs");
5373
+ const fields = buildToolResultFields(false, { taskId: "monitor-1", timeoutMs: 30000, persistent: false }, base);
5374
+ assert.equal(fields.status, "in_progress");
5375
+ assert.equal(fields.raw_output, "Task ID: monitor-1\nPersistent: no\nTimeout: 30s");
5376
+ assert.equal(fields.raw_output?.includes("{"), false);
2127
5377
  assert.deepEqual(fields.content, [
2128
- { type: "content", content: { type: "text", text: "File unchanged: src/main.rs" } },
5378
+ {
5379
+ type: "content",
5380
+ content: {
5381
+ type: "text",
5382
+ text: fields.raw_output,
5383
+ },
5384
+ },
2129
5385
  ]);
2130
5386
  });
2131
- test("buildToolResultFields renders array-wrapped file_unchanged Read results compactly", () => {
2132
- const base = createToolCall("tc-read", "Read", { file_path: "src/lib.rs" });
2133
- const fields = buildToolResultFields(false, [], base, {
2134
- result: [
2135
- {
2136
- type: "file_unchanged",
2137
- file: { filePath: "src/lib.rs" },
2138
- },
2139
- ],
5387
+ test("buildToolResultFields renders Workflow launch output as structured text", () => {
5388
+ const base = createToolCall("tc-workflow", "Workflow", {
5389
+ name: "spec",
5390
+ args: { topic: "rendering" },
2140
5391
  });
2141
- assert.equal(fields.raw_output, "File unchanged: src/lib.rs");
5392
+ const fields = buildToolResultFields(false, {
5393
+ status: "async_launched",
5394
+ taskId: "workflow-1",
5395
+ taskType: "local_workflow",
5396
+ workflowName: "spec",
5397
+ runId: "run-1",
5398
+ summary: "Workflow started",
5399
+ transcriptDir: "C:/tmp/transcripts",
5400
+ scriptPath: "C:/tmp/workflow.js",
5401
+ warning: "branch diverged",
5402
+ }, base);
5403
+ assert.equal(fields.status, "in_progress");
5404
+ 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");
5405
+ assert.equal(fields.raw_output?.includes("{"), false);
5406
+ assert.equal(fields.raw_output?.includes('"status"'), false);
2142
5407
  });
2143
- test("buildToolResultFields uses Agent output agentType as task title", () => {
2144
- const base = createToolCall("tc-agent", "Agent", { prompt: "Review tests" });
5408
+ test("buildToolResultFields marks Workflow output with error as failed", () => {
5409
+ const base = createToolCall("tc-workflow-error", "Workflow", {
5410
+ script: "bad workflow script",
5411
+ });
2145
5412
  const fields = buildToolResultFields(false, {
2146
- agentId: "agent-1",
2147
- agentType: "reviewer",
2148
- content: [{ type: "text", text: "Done" }],
2149
- totalToolUseCount: 0,
2150
- totalDurationMs: 10,
2151
- totalTokens: 20,
2152
- usage: {},
2153
- status: "completed",
2154
- prompt: "Review tests",
5413
+ status: "async_launched",
5414
+ taskId: "workflow-err",
5415
+ error: "Syntax check failed",
2155
5416
  }, base);
2156
- assert.equal(fields.title, "reviewer");
5417
+ assert.equal(fields.status, "failed");
5418
+ assert.equal(fields.raw_output, "Status: async launched\nTask ID: workflow-err\nError: Syntax check failed");
5419
+ assert.equal(fields.raw_output?.includes("bad workflow script"), false);
2157
5420
  });
2158
- test("buildToolResultFields reads array-wrapped Agent output agentType", () => {
2159
- const base = createToolCall("tc-agent", "Agent", { prompt: "Review tests" });
2160
- const fields = buildToolResultFields(false, [], base, {
2161
- result: [
2162
- {
2163
- agentId: "agent-1",
2164
- agentType: "planner",
2165
- content: [{ type: "text", text: "Done" }],
2166
- status: "completed",
2167
- },
2168
- ],
5421
+ test("buildToolResultFields parses Workflow transcript JSON", () => {
5422
+ const base = createToolCall("tc-workflow-history", "Workflow", {
5423
+ name: "remote-spec",
5424
+ });
5425
+ const transcriptJson = JSON.stringify({
5426
+ status: "remote_launched",
5427
+ taskId: "workflow-remote",
5428
+ sessionUrl: "https://claude.ai/session/remote",
5429
+ });
5430
+ const fields = buildToolResultFields(false, transcriptJson, base, {
5431
+ type: "tool_result",
5432
+ tool_use_id: "tc-workflow-history",
5433
+ content: transcriptJson,
5434
+ });
5435
+ assert.equal(fields.raw_output, "Status: remote launched\nTask ID: workflow-remote\nSession URL: https://claude.ai/session/remote");
5436
+ assert.equal(fields.raw_output?.includes('"taskId"'), false);
5437
+ });
5438
+ test("buildToolResultFields suppresses EnterPlanMode structured output body", () => {
5439
+ const base = createToolCall("tc-enter-plan-mode", "EnterPlanMode", {});
5440
+ const fields = buildToolResultFields(false, { message: "Plan mode entered" }, base);
5441
+ assert.equal(fields.status, "completed");
5442
+ assert.equal(fields.raw_output, undefined);
5443
+ assert.equal(fields.content, undefined);
5444
+ });
5445
+ test("buildToolResultFields suppresses EnterPlanMode transcript JSON body", () => {
5446
+ const base = createToolCall("tc-enter-plan-mode-history", "EnterPlanMode", {});
5447
+ const transcriptJson = JSON.stringify({ message: "Entered plan mode" });
5448
+ const fields = buildToolResultFields(false, transcriptJson, base, {
5449
+ type: "tool_result",
5450
+ tool_use_id: "tc-enter-plan-mode-history",
5451
+ content: transcriptJson,
2169
5452
  });
2170
- assert.equal(fields.title, "planner");
5453
+ assert.equal(fields.status, "completed");
5454
+ assert.equal(fields.raw_output, undefined);
5455
+ assert.equal(fields.content, undefined);
2171
5456
  });
2172
- test("buildToolResultFields extracts TodoWrite verification metadata from structured results", () => {
5457
+ test("buildToolResultFields ignores removed TodoWrite verification metadata", () => {
2173
5458
  const base = createToolCall("tc-todo", "TodoWrite", {
2174
5459
  todos: [{ content: "Verify changes", status: "pending", activeForm: "Verifying changes" }],
2175
5460
  });
@@ -2180,11 +5465,7 @@ test("buildToolResultFields extracts TodoWrite verification metadata from struct
2180
5465
  verificationNudgeNeeded: true,
2181
5466
  },
2182
5467
  });
2183
- assert.deepEqual(fields.output_metadata, {
2184
- todo_write: {
2185
- verification_nudge_needed: true,
2186
- },
2187
- });
5468
+ assert.equal(fields.output_metadata, undefined);
2188
5469
  });
2189
5470
  test("mapAvailableModels preserves optional fast and auto mode metadata", () => {
2190
5471
  const mapped = mapAvailableModels([
@@ -2193,7 +5474,7 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
2193
5474
  displayName: "Claude Sonnet",
2194
5475
  description: "Balanced model",
2195
5476
  supportsEffort: true,
2196
- supportedEffortLevels: ["low", "medium", "high", "max"],
5477
+ supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
2197
5478
  supportsAdaptiveThinking: true,
2198
5479
  supportsFastMode: true,
2199
5480
  supportsAutoMode: false,
@@ -2211,7 +5492,7 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
2211
5492
  display_name: "Claude Sonnet",
2212
5493
  description: "Balanced model",
2213
5494
  supports_effort: true,
2214
- supported_effort_levels: ["low", "medium", "high"],
5495
+ supported_effort_levels: ["low", "medium", "high", "xhigh", "max"],
2215
5496
  supports_adaptive_thinking: true,
2216
5497
  supports_fast_mode: true,
2217
5498
  supports_auto_mode: false,
@@ -2225,6 +5506,43 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
2225
5506
  },
2226
5507
  ]);
2227
5508
  });
5509
+ test("mapAvailableModels filters unavailable Fable models while preserving unknown ids", () => {
5510
+ const mapped = mapAvailableModels([
5511
+ {
5512
+ value: "fable",
5513
+ displayName: "Claude Fable",
5514
+ description: "Unavailable model alias",
5515
+ supportsEffort: true,
5516
+ },
5517
+ {
5518
+ value: "claude-fable-5",
5519
+ displayName: "Claude Fable 5",
5520
+ description: "Unavailable model",
5521
+ supportsEffort: true,
5522
+ },
5523
+ {
5524
+ value: "claude-fable-5-20260612",
5525
+ displayName: "Claude Fable 5 dated",
5526
+ description: "Unavailable dated model",
5527
+ supportsEffort: true,
5528
+ },
5529
+ {
5530
+ value: "claude-unknown-1",
5531
+ displayName: "Claude Unknown",
5532
+ description: "Unrecognized but available model",
5533
+ supportsEffort: false,
5534
+ },
5535
+ ]);
5536
+ assert.deepEqual(mapped, [
5537
+ {
5538
+ id: "claude-unknown-1",
5539
+ display_name: "Claude Unknown",
5540
+ description: "Unrecognized but available model",
5541
+ supports_effort: false,
5542
+ supported_effort_levels: [],
5543
+ },
5544
+ ]);
5545
+ });
2228
5546
  test("resolveCurrentModel keeps 1M context suffix in short and long display names", () => {
2229
5547
  const session = makeSessionState();
2230
5548
  session.resolvedRuntimeModelId = "claude-opus-4-7[1m]";
@@ -2330,7 +5648,7 @@ test("emitCurrentModelUpdate can publish catalog-enriched current model metadata
2330
5648
  current_model: {
2331
5649
  resolved_id: "sonnet",
2332
5650
  display_name_short: "Sonnet",
2333
- display_name_long: "Sonnet",
5651
+ display_name_long: "Claude Sonnet",
2334
5652
  catalog_id: "sonnet",
2335
5653
  supports_effort: true,
2336
5654
  supported_effort_levels: ["low", "medium", "high"],
@@ -2368,49 +5686,170 @@ test("resolveCurrentModel falls back to the requested model immediately after st
2368
5686
  const currentModel = resolveCurrentModel(session);
2369
5687
  assert.equal(currentModel.resolved_id, "sonnet");
2370
5688
  assert.equal(currentModel.display_name_short, "Sonnet");
2371
- assert.equal(currentModel.display_name_long, "Sonnet");
5689
+ assert.equal(currentModel.display_name_long, "Claude Sonnet");
2372
5690
  assert.equal(currentModel.catalog_id, "sonnet");
2373
5691
  assert.equal(currentModel.supports_effort, true);
2374
5692
  });
2375
- test("attachRequestUserDialogInterceptor rejects request_user_dialog with a stable error", async () => {
2376
- const calls = [];
2377
- const fakeQuery = {
2378
- async processControlRequest(request, _signal) {
2379
- calls.push(request);
2380
- return { ok: true };
2381
- },
2382
- };
2383
- assert.equal(attachRequestUserDialogInterceptor(fakeQuery, () => "session-test"), true);
2384
- await assert.rejects(fakeQuery.processControlRequest({
2385
- request_id: "dialog-1",
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",
5693
+ test("resolveCurrentModel keeps runtime version in short display while using catalog capabilities", () => {
5694
+ const session = makeSessionState();
5695
+ session.model = "opus";
5696
+ session.requestedModelId = "opus";
5697
+ session.resolvedRuntimeModelId = "claude-opus-4-7-20260101";
5698
+ session.availableModels = [
5699
+ {
5700
+ id: "opus",
5701
+ display_name: "Opus",
5702
+ supports_effort: true,
5703
+ supported_effort_levels: ["low", "medium", "high", "xhigh"],
2391
5704
  },
2392
- }, new AbortController().signal), /request_user_dialog is not supported by claude-rs yet \(dialog_kind: computer_use_approval\)/);
2393
- assert.equal(calls.length, 0);
5705
+ ];
5706
+ const currentModel = resolveCurrentModel(session);
5707
+ assert.equal(currentModel.display_name_short, "Opus 4.7");
5708
+ assert.equal(currentModel.display_name_long, "Opus");
5709
+ assert.equal(currentModel.catalog_id, "opus");
5710
+ assert.equal(currentModel.supports_effort, true);
2394
5711
  });
2395
- test("attachRequestUserDialogInterceptor preserves non-dialog control requests", async () => {
2396
- const calls = [];
2397
- const fakeQuery = {
2398
- async processControlRequest(request, _signal) {
2399
- calls.push(request);
2400
- return { ok: true };
2401
- },
2402
- };
2403
- attachRequestUserDialogInterceptor(fakeQuery, () => "session-test");
2404
- const result = await fakeQuery.processControlRequest({
2405
- request_id: "permission-1",
2406
- request: {
2407
- subtype: "can_use_tool",
2408
- tool_name: "Bash",
2409
- input: { command: "dir" },
2410
- tool_use_id: "tool-1",
2411
- },
2412
- }, new AbortController().signal);
2413
- assert.deepEqual(result, { ok: true });
2414
- assert.equal(calls.length, 1);
2415
- assert.equal(calls[0]?.request.subtype, "can_use_tool");
5712
+ function userDialogHandlerForTest() {
5713
+ const input = new AsyncQueue();
5714
+ const options = buildQueryOptions({
5715
+ cwd: "C:/work",
5716
+ launchSettings: { language: "English" },
5717
+ provisionalSessionId: "session-dialog",
5718
+ input,
5719
+ canUseTool: async () => ({ behavior: "deny", message: "not used" }),
5720
+ enableSdkDebug: false,
5721
+ enableSpawnDebug: false,
5722
+ sessionIdForLogs: () => "session-dialog",
5723
+ });
5724
+ assert.deepEqual(options.supportedDialogKinds, ["refusal_fallback_prompt"]);
5725
+ const handler = options.onUserDialog;
5726
+ assert.ok(handler, "expected buildQueryOptions to declare onUserDialog");
5727
+ return handler;
5728
+ }
5729
+ function registerDialogSession() {
5730
+ const session = makeSessionState();
5731
+ session.sessionId = "session-dialog";
5732
+ sessions.set("session-dialog", session);
5733
+ return session;
5734
+ }
5735
+ test("onUserDialog round-trips a retry_fallback selection", async () => {
5736
+ const handler = userDialogHandlerForTest();
5737
+ const session = registerDialogSession();
5738
+ try {
5739
+ const events = await captureBridgeEventsAsync(async () => {
5740
+ const resultPromise = handler({
5741
+ dialogKind: "refusal_fallback_prompt",
5742
+ payload: {
5743
+ originalModel: "claude-opus-4-8",
5744
+ fallbackModel: "claude-sonnet-4-6",
5745
+ guidanceText: "This request was declined.",
5746
+ },
5747
+ }, { signal: new AbortController().signal });
5748
+ const requestId = [...session.pendingUserDialogs.keys()][0];
5749
+ assert.ok(requestId, "expected a pending user dialog resolver");
5750
+ handleUserDialogResponse({
5751
+ command: "user_dialog_response",
5752
+ session_id: "session-dialog",
5753
+ request_id: requestId,
5754
+ outcome: { outcome: "selected", option_id: "retry_fallback" },
5755
+ });
5756
+ assert.deepEqual(await resultPromise, {
5757
+ behavior: "completed",
5758
+ result: "retry_fallback",
5759
+ });
5760
+ });
5761
+ const dialogEvent = events.find((event) => event.event === "user_dialog_request");
5762
+ assert.ok(dialogEvent, "expected a user_dialog_request event");
5763
+ const request = dialogEvent.request;
5764
+ assert.equal(request.dialog_kind, "refusal_fallback_prompt");
5765
+ assert.equal(request.payload.original_model, "claude-opus-4-8");
5766
+ assert.equal(request.payload.fallback_model, "claude-sonnet-4-6");
5767
+ assert.equal(request.payload.guidance_text, "This request was declined.");
5768
+ assert.deepEqual(request.options.map((option) => option.option_id), ["retry_fallback", "edit_prompt"]);
5769
+ assert.equal(request.options[0].label, "Switch to claude-sonnet-4-6");
5770
+ assert.equal(request.options[1].label, "Edit prompt and retry with claude-opus-4-8");
5771
+ }
5772
+ finally {
5773
+ sessions.delete("session-dialog");
5774
+ }
5775
+ });
5776
+ test("onUserDialog round-trips an edit_prompt selection", async () => {
5777
+ const handler = userDialogHandlerForTest();
5778
+ const session = registerDialogSession();
5779
+ try {
5780
+ const resultPromise = handler({
5781
+ dialogKind: "refusal_fallback_prompt",
5782
+ payload: { originalModel: "claude-opus-4-8", fallbackModel: "claude-sonnet-4-6" },
5783
+ }, { signal: new AbortController().signal });
5784
+ const requestId = [...session.pendingUserDialogs.keys()][0];
5785
+ handleUserDialogResponse({
5786
+ command: "user_dialog_response",
5787
+ session_id: "session-dialog",
5788
+ request_id: requestId,
5789
+ outcome: { outcome: "selected", option_id: "edit_prompt" },
5790
+ });
5791
+ assert.deepEqual(await resultPromise, { behavior: "completed", result: "edit_prompt" });
5792
+ }
5793
+ finally {
5794
+ sessions.delete("session-dialog");
5795
+ }
5796
+ });
5797
+ test("onUserDialog cancels when the dialog is aborted", async () => {
5798
+ const handler = userDialogHandlerForTest();
5799
+ const session = registerDialogSession();
5800
+ const controller = new AbortController();
5801
+ try {
5802
+ const resultPromise = handler({
5803
+ dialogKind: "refusal_fallback_prompt",
5804
+ payload: { originalModel: "claude-opus-4-8", fallbackModel: "claude-sonnet-4-6" },
5805
+ }, { signal: controller.signal });
5806
+ assert.equal(session.pendingUserDialogs.size, 1);
5807
+ controller.abort();
5808
+ assert.deepEqual(await resultPromise, { behavior: "cancelled" });
5809
+ assert.equal(session.pendingUserDialogs.size, 0);
5810
+ }
5811
+ finally {
5812
+ sessions.delete("session-dialog");
5813
+ }
5814
+ });
5815
+ test("onUserDialog fails closed on an unknown dialog kind without emitting", async () => {
5816
+ const handler = userDialogHandlerForTest();
5817
+ const session = registerDialogSession();
5818
+ try {
5819
+ const events = await captureBridgeEventsAsync(async () => {
5820
+ const result = await handler({ dialogKind: "some_future_dialog_kind", payload: { anything: true } }, { signal: new AbortController().signal });
5821
+ assert.deepEqual(result, { behavior: "cancelled" });
5822
+ });
5823
+ assert.equal(events.find((event) => event.event === "user_dialog_request"), undefined);
5824
+ assert.equal(session.pendingUserDialogs.size, 0);
5825
+ }
5826
+ finally {
5827
+ sessions.delete("session-dialog");
5828
+ }
5829
+ });
5830
+ test("handleUserDialogResponse ignores a duplicate response for a resolved request", async () => {
5831
+ const handler = userDialogHandlerForTest();
5832
+ const session = registerDialogSession();
5833
+ try {
5834
+ const resultPromise = handler({
5835
+ dialogKind: "refusal_fallback_prompt",
5836
+ payload: { originalModel: "claude-opus-4-8", fallbackModel: "claude-sonnet-4-6" },
5837
+ }, { signal: new AbortController().signal });
5838
+ const requestId = [...session.pendingUserDialogs.keys()][0];
5839
+ const response = {
5840
+ command: "user_dialog_response",
5841
+ session_id: "session-dialog",
5842
+ request_id: requestId,
5843
+ outcome: { outcome: "selected", option_id: "retry_fallback" },
5844
+ };
5845
+ handleUserDialogResponse(response);
5846
+ assert.deepEqual(await resultPromise, { behavior: "completed", result: "retry_fallback" });
5847
+ // A replayed pending_user_dialog_requests entry with the same id must be a
5848
+ // no-op now that the resolver is gone.
5849
+ assert.equal(session.pendingUserDialogs.has(requestId), false);
5850
+ assert.doesNotThrow(() => handleUserDialogResponse(response));
5851
+ }
5852
+ finally {
5853
+ sessions.delete("session-dialog");
5854
+ }
2416
5855
  });