claude-code-rust 0.12.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,14 @@
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, 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";
3
+ import { AsyncQueue, CACHE_SPLIT_POLICY, buildApiRetryUpdate, buildRateLimitUpdate, buildRewindConversationPlan, 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, rewindTargetsFromSessionMessages, unwrapToolUseResult, updateAvailableCommands, handleReloadPluginsCommand, } from "./bridge.js";
4
4
  import { availableModesForSession, buildModeState, markModeUnavailableForSession, permissionModeFailureLooksUnsupported, refreshSupportedModesForSession, } from "./bridge/commands.js";
5
5
  import { handleMcpSetServersCommand } from "./bridge/mcp.js";
6
- import { emitCurrentModelUpdate, handleUserDialogResponse, refreshCurrentModel, resolveCurrentModel, sessions, shouldInvalidateResolvedRuntimeModel, shouldEmitStartupAuthRequiredForAccount, } from "./bridge/session_lifecycle.js";
6
+ import { emitCurrentModelUpdate, handleUserDialogResponse, closeSessionsBeforeRegister, refreshCurrentModel, resolveCurrentModel, sessions, shouldInvalidateResolvedRuntimeModel, shouldEmitStartupAuthRequiredForAccount, } from "./bridge/session_lifecycle.js";
7
7
  import { classifyTurnErrorKind } from "./bridge/error_classification.js";
8
8
  import { emitToolCall, emitToolProgressUpdate, emitToolResultUpdate } from "./bridge/tool_calls.js";
9
9
  import { linkTaskToolUse } from "./bridge/task_links.js";
10
10
  import { requestAskUserQuestionAnswers } from "./bridge/user_interaction.js";
11
- import { handleResultMessage } from "./bridge/message_handlers.js";
11
+ import { flushPendingWorkerShutdown, handleResultMessage } from "./bridge/message_handlers.js";
12
12
  const BRIDGE_RUNTIME_PROCESS_NAME = process.platform === "win32" ? "claude-rs-bridge-node.exe" : "claude-rs-bridge-node";
13
13
  const BRIDGE_RUNTIME_GUARD_PROMPT = `Do not terminate the Claude Rust bridge runtime process \`${BRIDGE_RUNTIME_PROCESS_NAME}\`; ` +
14
14
  "when cleaning up development servers, only stop processes by explicit PIDs you started in this session.";
@@ -39,11 +39,34 @@ function makeSessionState() {
39
39
  pendingQuestions: new Map(),
40
40
  pendingUserDialogs: new Map(),
41
41
  pendingElicitations: new Map(),
42
+ informationalDedupKeys: new Set(),
42
43
  mcpStatusRevalidatedAt: new Map(),
43
44
  hiddenToolUseIds: new Set(),
44
45
  authHintSent: false,
45
46
  };
46
47
  }
48
+ test("closeSessionsBeforeRegister closes same-key stale session before replacement registration", async () => {
49
+ sessions.clear();
50
+ let staleClosed = 0;
51
+ const stale = makeSessionState();
52
+ stale.sessionId = "session-1";
53
+ stale.query = {
54
+ close: () => {
55
+ staleClosed += 1;
56
+ },
57
+ };
58
+ const replacement = makeSessionState();
59
+ replacement.sessionId = "session-1";
60
+ sessions.set(stale.sessionId, stale);
61
+ await closeSessionsBeforeRegister(replacement, [stale], "req-1");
62
+ assert.equal(staleClosed, 1);
63
+ assert.equal(sessions.has("session-1"), false);
64
+ sessions.set(replacement.sessionId, replacement);
65
+ await closeSessionsBeforeRegister(replacement, [stale], "req-2");
66
+ assert.equal(staleClosed, 2);
67
+ assert.equal(sessions.get("session-1"), replacement);
68
+ sessions.clear();
69
+ });
47
70
  test("availableModesForSession omits conditional modes when unsupported", () => {
48
71
  const session = makeSessionState();
49
72
  refreshSupportedModesForSession(session);
@@ -296,6 +319,7 @@ test("parseCommandEnvelope validates mcp_set_servers command", () => {
296
319
  "X-Test": "1",
297
320
  },
298
321
  timeout: 5000,
322
+ request_timeout_ms: 30000,
299
323
  always_load: true,
300
324
  tools: [
301
325
  {
@@ -328,6 +352,7 @@ test("parseCommandEnvelope validates mcp_set_servers command", () => {
328
352
  "X-Test": "1",
329
353
  },
330
354
  timeout: 5000,
355
+ request_timeout_ms: 30000,
331
356
  always_load: true,
332
357
  tools: [
333
358
  {
@@ -363,6 +388,17 @@ test("parseCommandEnvelope rejects invalid latest MCP config fields", () => {
363
388
  session_id: "session-123",
364
389
  servers: { bad: { type: "http", url: "https://mcp.example.com", timeout: 999 } },
365
390
  })), /mcp_set_servers\.servers\.bad\.timeout must be an integer >= 1000/);
391
+ assert.throws(() => parseCommandEnvelope(JSON.stringify({
392
+ command: "mcp_set_servers",
393
+ session_id: "session-123",
394
+ servers: {
395
+ bad: {
396
+ type: "http",
397
+ url: "https://mcp.example.com",
398
+ request_timeout_ms: 999,
399
+ },
400
+ },
401
+ })), /mcp_set_servers\.servers\.bad\.request_timeout_ms must be an integer >= 1000/);
366
402
  assert.throws(() => parseCommandEnvelope(JSON.stringify({
367
403
  command: "mcp_set_servers",
368
404
  session_id: "session-123",
@@ -430,6 +466,7 @@ test("handleMcpSetServersCommand emits SDK result", async () => {
430
466
  type: "http",
431
467
  url: "https://example.test/mcp",
432
468
  always_load: true,
469
+ request_timeout_ms: 30000,
433
470
  },
434
471
  },
435
472
  }, "req-mcp-set");
@@ -439,6 +476,7 @@ test("handleMcpSetServersCommand emits SDK result", async () => {
439
476
  type: "http",
440
477
  url: "https://example.test/mcp",
441
478
  alwaysLoad: true,
479
+ requestTimeoutMs: 30000,
442
480
  },
443
481
  });
444
482
  assert.deepEqual(events, [
@@ -514,6 +552,7 @@ test("bridgeMcpConfigToSdk maps latest MCP fields to SDK casing", () => {
514
552
  type: "sse",
515
553
  url: "https://mcp.example.com/sse",
516
554
  timeout: 2500,
555
+ request_timeout_ms: 30000,
517
556
  always_load: true,
518
557
  tools: [
519
558
  { name: "search" },
@@ -523,6 +562,7 @@ test("bridgeMcpConfigToSdk maps latest MCP fields to SDK casing", () => {
523
562
  type: "sse",
524
563
  url: "https://mcp.example.com/sse",
525
564
  timeout: 2500,
565
+ requestTimeoutMs: 30000,
526
566
  alwaysLoad: true,
527
567
  tools: [
528
568
  { name: "search" },
@@ -539,6 +579,7 @@ test("mapMcpServerStatus preserves latest MCP status config fields", () => {
539
579
  url: "https://mcp.notion.com/mcp",
540
580
  headers: { Authorization: "Bearer token" },
541
581
  timeout: 5000,
582
+ requestTimeoutMs: 30000,
542
583
  alwaysLoad: true,
543
584
  tools: [
544
585
  { name: "search" },
@@ -552,6 +593,7 @@ test("mapMcpServerStatus preserves latest MCP status config fields", () => {
552
593
  url: "https://mcp.notion.com/mcp",
553
594
  headers: { Authorization: "Bearer token" },
554
595
  timeout: 5000,
596
+ request_timeout_ms: 30000,
555
597
  always_load: true,
556
598
  tools: [
557
599
  { name: "search" },
@@ -706,6 +748,150 @@ test("parseCommandEnvelope validates get_context_usage command", () => {
706
748
  session_id: "session-123",
707
749
  });
708
750
  });
751
+ test("parseCommandEnvelope validates get_rewind_targets command", () => {
752
+ const parsed = parseCommandEnvelope(JSON.stringify({
753
+ request_id: "req-rewind-targets",
754
+ command: "get_rewind_targets",
755
+ session_id: "session-123",
756
+ }));
757
+ assert.equal(parsed.requestId, "req-rewind-targets");
758
+ assert.deepEqual(parsed.command, {
759
+ command: "get_rewind_targets",
760
+ session_id: "session-123",
761
+ });
762
+ });
763
+ test("parseCommandEnvelope validates rewind command modes", () => {
764
+ for (const restoreMode of ["both", "conversation", "code"]) {
765
+ const parsed = parseCommandEnvelope(JSON.stringify({
766
+ request_id: "req-rewind",
767
+ command: "rewind",
768
+ session_id: "session-123",
769
+ target_user_message_id: "user-1",
770
+ restore_mode: restoreMode,
771
+ launch_settings: {
772
+ language: "German",
773
+ },
774
+ }));
775
+ assert.equal(parsed.requestId, "req-rewind");
776
+ assert.deepEqual(parsed.command, {
777
+ command: "rewind",
778
+ session_id: "session-123",
779
+ target_user_message_id: "user-1",
780
+ restore_mode: restoreMode,
781
+ launch_settings: { language: "German" },
782
+ });
783
+ }
784
+ });
785
+ test("parseCommandEnvelope rejects invalid rewind mode", () => {
786
+ assert.throws(() => parseCommandEnvelope(JSON.stringify({
787
+ command: "rewind",
788
+ session_id: "session-123",
789
+ target_user_message_id: "user-1",
790
+ restore_mode: "files",
791
+ })), /rewind\.restore_mode must be one of both, conversation, code/);
792
+ });
793
+ test("rewindTargetsFromSessionMessages filters user text messages with UUIDs", () => {
794
+ const messages = [
795
+ {
796
+ type: "user",
797
+ uuid: "user-1",
798
+ message: { role: "user", content: [{ type: "text", text: " first prompt\nline " }] },
799
+ },
800
+ {
801
+ type: "assistant",
802
+ uuid: "assistant-1",
803
+ message: { role: "assistant", content: [{ type: "text", text: "ignored" }] },
804
+ },
805
+ {
806
+ type: "user",
807
+ uuid: "tool-result",
808
+ message: { role: "user", content: [{ type: "tool_result", content: "ignored" }] },
809
+ },
810
+ {
811
+ type: "user",
812
+ uuid: "user-2",
813
+ message: { role: "user", content: [{ type: "text", text: "second prompt" }] },
814
+ },
815
+ ];
816
+ assert.deepEqual(rewindTargetsFromSessionMessages(messages), [
817
+ {
818
+ uuid: "user-2",
819
+ first_text: "second prompt",
820
+ input_text: "second prompt",
821
+ index: 3,
822
+ previous_assistant_uuid: "assistant-1",
823
+ },
824
+ {
825
+ uuid: "user-1",
826
+ first_text: "first prompt line",
827
+ input_text: "first prompt\nline",
828
+ index: 0,
829
+ },
830
+ ]);
831
+ });
832
+ test("buildRewindConversationPlan anchors at previous assistant message", () => {
833
+ const messages = [
834
+ {
835
+ type: "user",
836
+ uuid: "user-1",
837
+ message: { role: "user", content: "first prompt" },
838
+ },
839
+ {
840
+ type: "assistant",
841
+ uuid: "assistant-1",
842
+ message: { role: "assistant", content: [{ type: "text", text: "reply" }] },
843
+ },
844
+ {
845
+ type: "user",
846
+ uuid: "user-2",
847
+ message: { role: "user", content: "second prompt" },
848
+ },
849
+ ];
850
+ const plan = buildRewindConversationPlan(messages, "user-2");
851
+ assert.ok(plan);
852
+ assert.equal(plan.inputText, "second prompt");
853
+ assert.equal(plan.previousAssistantUuid, "assistant-1");
854
+ assert.equal(plan.targetIndex, 2);
855
+ assert.deepEqual(plan.retainedMessages.map((message) => message.uuid), ["user-1", "assistant-1"]);
856
+ assert.ok(plan.resumeUpdates.length > 0);
857
+ });
858
+ test("buildRewindConversationPlan treats first user message as fresh replacement", () => {
859
+ const messages = [
860
+ {
861
+ type: "user",
862
+ uuid: "user-1",
863
+ message: { role: "user", content: " first prompt\nline " },
864
+ },
865
+ {
866
+ type: "assistant",
867
+ uuid: "assistant-1",
868
+ message: { role: "assistant", content: [{ type: "text", text: "reply" }] },
869
+ },
870
+ ];
871
+ const plan = buildRewindConversationPlan(messages, "user-1");
872
+ assert.ok(plan);
873
+ assert.equal(plan.inputText, " first prompt\nline ");
874
+ assert.equal(plan.previousAssistantUuid, undefined);
875
+ assert.equal(plan.targetIndex, 0);
876
+ assert.deepEqual(plan.retainedMessages, []);
877
+ assert.deepEqual(plan.resumeUpdates, []);
878
+ });
879
+ test("buildRewindConversationPlan rejects stale or inconsistent targets", () => {
880
+ const messages = [
881
+ {
882
+ type: "user",
883
+ uuid: "user-1",
884
+ message: { role: "user", content: "first prompt" },
885
+ },
886
+ {
887
+ type: "user",
888
+ uuid: "user-2",
889
+ message: { role: "user", content: "second prompt" },
890
+ },
891
+ ];
892
+ assert.equal(buildRewindConversationPlan(messages, "missing-user"), null);
893
+ assert.equal(buildRewindConversationPlan(messages, "user-2"), null);
894
+ });
709
895
  test("staleMcpAuthCandidates selects previously connected servers that regressed to needs-auth", () => {
710
896
  const candidates = staleMcpAuthCandidates([
711
897
  {
@@ -817,12 +1003,31 @@ test("buildQueryOptions maps launch settings into sdk query options", () => {
817
1003
  assert.equal("effort" in options, false);
818
1004
  assert.equal(options.agentProgressSummaries, true);
819
1005
  assert.equal(options.promptSuggestions, true);
1006
+ assert.equal(options.enableFileCheckpointing, true);
820
1007
  assert.equal(options.sessionId, "session-1");
821
1008
  assert.deepEqual(options.settingSources, ["user", "project", "local"]);
822
1009
  assert.deepEqual(options.toolConfig, {
823
1010
  askUserQuestion: { previewFormat: "markdown" },
824
1011
  });
825
1012
  });
1013
+ test("buildQueryOptions includes resumeSessionAt when provided", () => {
1014
+ const input = new AsyncQueue();
1015
+ const options = buildQueryOptions({
1016
+ cwd: "C:/work",
1017
+ resume: "session-1",
1018
+ resumeSessionAt: "assistant-1",
1019
+ launchSettings: {},
1020
+ provisionalSessionId: "session-1",
1021
+ input,
1022
+ canUseTool: async () => ({ behavior: "deny", message: "not used" }),
1023
+ enableSdkDebug: false,
1024
+ enableSpawnDebug: false,
1025
+ sessionIdForLogs: () => "session-1",
1026
+ });
1027
+ assert.equal(options.resume, "session-1");
1028
+ assert.equal(options.resumeSessionAt, "assistant-1");
1029
+ assert.equal("sessionId" in options, false);
1030
+ });
826
1031
  test("buildQueryOptions forwards settings and maps startup model and permission mode", () => {
827
1032
  const input = new AsyncQueue();
828
1033
  const options = buildQueryOptions({
@@ -917,6 +1122,7 @@ test("buildQueryOptions enables dangerous skip flag for bypass permissions start
917
1122
  });
918
1123
  assert.equal(options.permissionMode, "bypassPermissions");
919
1124
  assert.equal(options.allowDangerouslySkipPermissions, true);
1125
+ assert.equal("canUseTool" in options, false);
920
1126
  });
921
1127
  test("buildQueryOptions omits optional startup overrides but keeps bridge guard prompt", () => {
922
1128
  const input = new AsyncQueue();
@@ -1389,6 +1595,96 @@ test("handleSdkMessage emits transcript retraction for model_refusal_fallback",
1389
1595
  ]);
1390
1596
  assert.equal(events.some((event) => event.update?.type === "system_notice_update"), false);
1391
1597
  });
1598
+ test("handleSdkMessage emits warning notice for model_refusal_no_fallback with explanation", () => {
1599
+ const session = makeSessionState();
1600
+ const events = captureBridgeEvents(() => {
1601
+ handleSdkMessage(session, {
1602
+ type: "system",
1603
+ subtype: "model_refusal_no_fallback",
1604
+ original_model: "claude-opus-4-1",
1605
+ request_id: "req-1",
1606
+ api_refusal_category: "cyber",
1607
+ api_refusal_explanation: "policy text",
1608
+ refused_user_message_uuid: "user-1",
1609
+ content: "raw content",
1610
+ uuid: "refusal-notice",
1611
+ session_id: "session-1",
1612
+ });
1613
+ });
1614
+ assert.deepEqual(events.map((event) => event.update), [
1615
+ {
1616
+ type: "system_notice_update",
1617
+ severity: "warning",
1618
+ message: "Could not continue with claude-opus-4-1: model refused the request and no fallback model is configured. Reason: policy text.",
1619
+ },
1620
+ ]);
1621
+ });
1622
+ test("handleSdkMessage emits warning notice for model_refusal_no_fallback with category only", () => {
1623
+ const session = makeSessionState();
1624
+ const events = captureBridgeEvents(() => {
1625
+ handleSdkMessage(session, {
1626
+ type: "system",
1627
+ subtype: "model_refusal_no_fallback",
1628
+ original_model: "claude-opus-4-1",
1629
+ request_id: "req-1",
1630
+ api_refusal_category: "cyber",
1631
+ uuid: "refusal-notice",
1632
+ session_id: "session-1",
1633
+ });
1634
+ });
1635
+ assert.deepEqual(events.map((event) => event.update), [
1636
+ {
1637
+ type: "system_notice_update",
1638
+ severity: "warning",
1639
+ message: "Could not continue with claude-opus-4-1: model refused the request and no fallback model is configured. Refusal category: cyber.",
1640
+ },
1641
+ ]);
1642
+ });
1643
+ test("handleSdkMessage emits warning notice for model_refusal_no_fallback with content detail", () => {
1644
+ const session = makeSessionState();
1645
+ const events = captureBridgeEvents(() => {
1646
+ handleSdkMessage(session, {
1647
+ type: "system",
1648
+ subtype: "model_refusal_no_fallback",
1649
+ original_model: "claude-opus-4-1",
1650
+ request_id: "req-1",
1651
+ content: "Refused by policy!",
1652
+ uuid: "refusal-notice",
1653
+ session_id: "session-1",
1654
+ });
1655
+ });
1656
+ assert.deepEqual(events.map((event) => event.update), [
1657
+ {
1658
+ type: "system_notice_update",
1659
+ severity: "warning",
1660
+ message: "Could not continue with claude-opus-4-1: model refused the request and no fallback model is configured. Refused by policy!",
1661
+ },
1662
+ ]);
1663
+ });
1664
+ test("handleSdkMessage emits readable model_refusal_no_fallback notice for empty metadata", () => {
1665
+ const session = makeSessionState();
1666
+ const events = captureBridgeEvents(() => {
1667
+ handleSdkMessage(session, {
1668
+ type: "system",
1669
+ subtype: "model_refusal_no_fallback",
1670
+ original_model: " ",
1671
+ request_id: null,
1672
+ api_refusal_category: " ",
1673
+ api_refusal_explanation: null,
1674
+ refused_user_message_uuid: null,
1675
+ content: " ",
1676
+ uuid: "refusal-notice",
1677
+ session_id: "session-1",
1678
+ });
1679
+ });
1680
+ assert.deepEqual(events.map((event) => event.update), [
1681
+ {
1682
+ type: "system_notice_update",
1683
+ severity: "warning",
1684
+ message: "Could not continue with the selected model: model refused the request and no fallback model is configured.",
1685
+ },
1686
+ ]);
1687
+ });
1392
1688
  test("handleSdkMessage emits tolerant transcript retraction for model_fallback", () => {
1393
1689
  const session = makeSessionState();
1394
1690
  const events = captureBridgeEvents(() => {
@@ -2886,6 +3182,70 @@ test("requestAskUserQuestionAnswers preserves previews and annotations in update
2886
3182
  question_index: 0,
2887
3183
  total_questions: 1,
2888
3184
  });
3185
+ const completedQuestionUpdate = events
3186
+ .map((event) => (event.event === "session_update" ? event.update : undefined))
3187
+ .find((update) => {
3188
+ const toolCallUpdate = update?.tool_call_update;
3189
+ const fields = toolCallUpdate?.fields;
3190
+ return toolCallUpdate?.tool_call_id === "tool-question" && fields?.status === "completed";
3191
+ })?.tool_call_update;
3192
+ const completedFields = completedQuestionUpdate?.fields;
3193
+ assert.deepEqual(completedFields?.raw_input, {
3194
+ questions: [
3195
+ {
3196
+ question: "Pick deployment target",
3197
+ header: "Target",
3198
+ multiSelect: true,
3199
+ options: [
3200
+ {
3201
+ label: "Staging",
3202
+ description: "Low-risk validation",
3203
+ preview: "Deploy to staging first.",
3204
+ },
3205
+ {
3206
+ label: "Production",
3207
+ description: "Customer-facing rollout",
3208
+ preview: "Deploy to production after approval.",
3209
+ },
3210
+ ],
3211
+ },
3212
+ ],
3213
+ answers: {
3214
+ "Pick deployment target": "Staging, Production",
3215
+ },
3216
+ annotations: {
3217
+ "Pick deployment target": {
3218
+ preview: "Deploy to staging first.\n\nDeploy to production after approval.",
3219
+ notes: "Roll out in both environments",
3220
+ },
3221
+ },
3222
+ question_results: [
3223
+ {
3224
+ question: "Pick deployment target",
3225
+ header: "Target",
3226
+ question_index: 0,
3227
+ total_questions: 1,
3228
+ selected_options: [
3229
+ {
3230
+ option_id: "question_0",
3231
+ label: "Staging",
3232
+ description: "Low-risk validation",
3233
+ preview: "Deploy to staging first.",
3234
+ },
3235
+ {
3236
+ option_id: "question_1",
3237
+ label: "Production",
3238
+ description: "Customer-facing rollout",
3239
+ preview: "Deploy to production after approval.",
3240
+ },
3241
+ ],
3242
+ annotation: {
3243
+ preview: "Deploy to staging first.\n\nDeploy to production after approval.",
3244
+ notes: "Roll out in both environments",
3245
+ },
3246
+ },
3247
+ ],
3248
+ });
2889
3249
  });
2890
3250
  test("normalizeToolKind maps known tool names", () => {
2891
3251
  assert.equal(normalizeToolKind("Bash"), "execute");
@@ -2908,6 +3268,7 @@ test("normalizeToolKind maps known tool names", () => {
2908
3268
  assert.equal(normalizeToolKind("ShowOnboardingRolePicker"), "other");
2909
3269
  assert.equal(normalizeToolKind("TaskOutput"), "other");
2910
3270
  assert.equal(normalizeToolKind("TaskStop"), "other");
3271
+ assert.equal(normalizeToolKind("ReadMcpResourceDir"), "read");
2911
3272
  assert.equal(normalizeToolKind("Task"), "think");
2912
3273
  assert.equal(normalizeToolKind("Agent"), "think");
2913
3274
  assert.equal(normalizeToolKind("EnterPlanMode"), "switch_mode");
@@ -2925,6 +3286,16 @@ test("shell tool titles use input command", () => {
2925
3286
  assert.equal(createToolCall("tc-powershell-title", "PowerShell", { command: "Get-ChildItem" }).title, "Get-ChildItem");
2926
3287
  assert.equal(createToolCall("tc-powershell-empty", "PowerShell", {}).title, "Terminal");
2927
3288
  });
3289
+ test("ReadMcpResourceDir titles include server and URI context", () => {
3290
+ assert.equal(createToolCall("tc-mcp-dir-title", "ReadMcpResourceDir", {
3291
+ server: "docs",
3292
+ uri: "file://manuals/",
3293
+ }).title, "ReadMcpResourceDir docs file://manuals/");
3294
+ assert.equal(createToolCall("tc-mcp-dir-uri-title", "ReadMcpResourceDir", {
3295
+ uri: "file://manuals/",
3296
+ }).title, "ReadMcpResourceDir file://manuals/");
3297
+ assert.equal(createToolCall("tc-mcp-dir-fallback-title", "ReadMcpResourceDir", {}).title, "ReadMcpResourceDir");
3298
+ });
2928
3299
  test("parseFastModeState accepts known values and rejects unknown values", () => {
2929
3300
  assert.equal(parseFastModeState("off"), "off");
2930
3301
  assert.equal(parseFastModeState("cooldown"), "cooldown");
@@ -2957,10 +3328,14 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
2957
3328
  overageDisabledReason: "out_of_credits",
2958
3329
  isUsingOverage: false,
2959
3330
  surpassedThreshold: 0.9,
3331
+ errorCode: "credits_required",
3332
+ canUserPurchaseCredits: true,
3333
+ hasChargeableSavedPaymentMethod: false,
2960
3334
  });
2961
3335
  assert.deepEqual(update, {
2962
3336
  type: "rate_limit_update",
2963
3337
  status: "allowed_warning",
3338
+ error_code: "credits_required",
2964
3339
  resets_at: 1_741_280_000,
2965
3340
  utilization: 0.92,
2966
3341
  rate_limit_type: "five_hour",
@@ -2969,6 +3344,8 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
2969
3344
  overage_disabled_reason: "out_of_credits",
2970
3345
  is_using_overage: false,
2971
3346
  surpassed_threshold: 0.9,
3347
+ can_user_purchase_credits: true,
3348
+ has_chargeable_saved_payment_method: false,
2972
3349
  });
2973
3350
  });
2974
3351
  test("buildRateLimitUpdate normalizes SDK overage boolean spellings", () => {
@@ -2998,6 +3375,10 @@ test("buildRateLimitUpdate rejects invalid payloads", () => {
2998
3375
  status: "rejected",
2999
3376
  overageStatus: "bad_status",
3000
3377
  }), { type: "rate_limit_update", status: "rejected" });
3378
+ assert.deepEqual(buildRateLimitUpdate({ status: "rejected", errorCode: "other" }), {
3379
+ type: "rate_limit_update",
3380
+ status: "rejected",
3381
+ });
3001
3382
  });
3002
3383
  test("buildApiRetryUpdate maps SDK api_retry messages to wire shape", () => {
3003
3384
  assert.deepEqual(buildApiRetryUpdate({
@@ -3283,6 +3664,197 @@ test("handleSdkMessage emits system notices for notifications and plugin failure
3283
3664
  { type: "system_notice_update", severity: "warning", message: "Plugin install failed acme: download failed" },
3284
3665
  ]);
3285
3666
  });
3667
+ test("handleSdkMessage maps informational system messages to notices by level", () => {
3668
+ const session = makeSessionState();
3669
+ const events = captureBridgeEvents(() => {
3670
+ handleSdkMessage(session, {
3671
+ type: "system",
3672
+ subtype: "informational",
3673
+ content: " Sync ready ",
3674
+ level: "notice",
3675
+ uuid: "message-info-notice",
3676
+ session_id: "session-1",
3677
+ });
3678
+ handleSdkMessage(session, {
3679
+ type: "system",
3680
+ subtype: "informational",
3681
+ content: "Try /compact",
3682
+ level: "suggestion",
3683
+ uuid: "message-info-suggestion",
3684
+ session_id: "session-1",
3685
+ });
3686
+ handleSdkMessage(session, {
3687
+ type: "system",
3688
+ subtype: "informational",
3689
+ content: "Hook blocked continuation",
3690
+ level: "warning",
3691
+ uuid: "message-info-warning",
3692
+ session_id: "session-1",
3693
+ });
3694
+ });
3695
+ assert.deepEqual(events.map((event) => event.update), [
3696
+ { type: "system_notice_update", severity: "info", message: "Sync ready" },
3697
+ { type: "system_notice_update", severity: "info", message: "Suggestion: Try /compact" },
3698
+ { type: "system_notice_update", severity: "warning", message: "Hook blocked continuation" },
3699
+ ]);
3700
+ });
3701
+ test("handleSdkMessage keeps informational info log-only unless continuation is prevented", () => {
3702
+ const session = makeSessionState();
3703
+ const events = captureBridgeEvents(() => {
3704
+ handleSdkMessage(session, {
3705
+ type: "system",
3706
+ subtype: "informational",
3707
+ content: "Transcript-only progress",
3708
+ level: "info",
3709
+ uuid: "message-info-log-only",
3710
+ session_id: "session-1",
3711
+ });
3712
+ handleSdkMessage(session, {
3713
+ type: "system",
3714
+ subtype: "informational",
3715
+ content: "Stop hook denied continuation",
3716
+ level: "info",
3717
+ prevent_continuation: true,
3718
+ uuid: "message-info-prevented",
3719
+ session_id: "session-1",
3720
+ });
3721
+ });
3722
+ assert.deepEqual(events.map((event) => event.update), [
3723
+ {
3724
+ type: "system_notice_update",
3725
+ severity: "warning",
3726
+ message: "Stop hook denied continuation",
3727
+ },
3728
+ ]);
3729
+ });
3730
+ test("handleSdkMessage deduplicates informational messages by tool use, level, and content", () => {
3731
+ const session = makeSessionState();
3732
+ const events = captureBridgeEvents(() => {
3733
+ handleSdkMessage(session, {
3734
+ type: "system",
3735
+ subtype: "informational",
3736
+ content: "Progress",
3737
+ level: "notice",
3738
+ tool_use_id: "tool-1",
3739
+ uuid: "message-info-1",
3740
+ session_id: "session-1",
3741
+ });
3742
+ handleSdkMessage(session, {
3743
+ type: "system",
3744
+ subtype: "informational",
3745
+ content: " Progress ",
3746
+ level: "notice",
3747
+ tool_use_id: "tool-1",
3748
+ uuid: "message-info-2",
3749
+ session_id: "session-1",
3750
+ });
3751
+ handleSdkMessage(session, {
3752
+ type: "system",
3753
+ subtype: "informational",
3754
+ content: "Progress updated",
3755
+ level: "notice",
3756
+ tool_use_id: "tool-1",
3757
+ uuid: "message-info-3",
3758
+ session_id: "session-1",
3759
+ });
3760
+ });
3761
+ assert.deepEqual(events.map((event) => event.update), [
3762
+ { type: "system_notice_update", severity: "info", message: "Progress" },
3763
+ { type: "system_notice_update", severity: "info", message: "Progress updated" },
3764
+ ]);
3765
+ });
3766
+ test("handleSdkMessage does not deduplicate informational messages without a tool use id", () => {
3767
+ const session = makeSessionState();
3768
+ const events = captureBridgeEvents(() => {
3769
+ for (const uuid of ["message-info-1", "message-info-2"]) {
3770
+ handleSdkMessage(session, {
3771
+ type: "system",
3772
+ subtype: "informational",
3773
+ content: "Repeated global notice",
3774
+ level: "notice",
3775
+ uuid,
3776
+ session_id: "session-1",
3777
+ });
3778
+ }
3779
+ });
3780
+ assert.deepEqual(events.map((event) => event.update), [
3781
+ { type: "system_notice_update", severity: "info", message: "Repeated global notice" },
3782
+ { type: "system_notice_update", severity: "info", message: "Repeated global notice" },
3783
+ ]);
3784
+ });
3785
+ test("handleSdkMessage treats worker shutdown before connect as log-only", () => {
3786
+ const session = makeSessionState();
3787
+ session.connected = false;
3788
+ const events = captureBridgeEvents(() => {
3789
+ handleSdkMessage(session, {
3790
+ type: "system",
3791
+ subtype: "worker_shutting_down",
3792
+ reason: "host_exit",
3793
+ uuid: "message-worker-shutdown",
3794
+ session_id: "session-1",
3795
+ });
3796
+ flushPendingWorkerShutdown(session);
3797
+ });
3798
+ assert.deepEqual(events, []);
3799
+ assert.equal(session.pendingWorkerShutdown, undefined);
3800
+ });
3801
+ test("handleSdkMessage flushes connected worker shutdown only when stream ends", () => {
3802
+ const session = makeSessionState();
3803
+ const events = captureBridgeEvents(() => {
3804
+ handleSdkMessage(session, {
3805
+ type: "system",
3806
+ subtype: "worker_shutting_down",
3807
+ reason: "host_exit",
3808
+ uuid: "message-worker-shutdown",
3809
+ session_id: "session-1",
3810
+ });
3811
+ flushPendingWorkerShutdown(session);
3812
+ });
3813
+ assert.deepEqual(events.map((event) => event.update), [
3814
+ {
3815
+ type: "system_notice_update",
3816
+ severity: "warning",
3817
+ message: "Claude worker is shutting down: host_exit",
3818
+ },
3819
+ ]);
3820
+ });
3821
+ test("handleSdkMessage cancels pending worker shutdown after later SDK activity", () => {
3822
+ const session = makeSessionState();
3823
+ const events = captureBridgeEvents(() => {
3824
+ handleSdkMessage(session, {
3825
+ type: "system",
3826
+ subtype: "worker_shutting_down",
3827
+ reason: "host_exit",
3828
+ uuid: "message-worker-shutdown",
3829
+ session_id: "session-1",
3830
+ });
3831
+ handleSdkMessage(session, {
3832
+ type: "system",
3833
+ subtype: "notification",
3834
+ text: "Still running",
3835
+ priority: "low",
3836
+ uuid: "message-notification",
3837
+ session_id: "session-1",
3838
+ });
3839
+ flushPendingWorkerShutdown(session);
3840
+ });
3841
+ assert.deepEqual(events.map((event) => event.update), [
3842
+ { type: "system_notice_update", severity: "info", message: "Still running" },
3843
+ ]);
3844
+ });
3845
+ test("handleSdkMessage ignores unknown future system subtypes", () => {
3846
+ const session = makeSessionState();
3847
+ const events = captureBridgeEvents(() => {
3848
+ handleSdkMessage(session, {
3849
+ type: "system",
3850
+ subtype: "future_subtype",
3851
+ content: "Unknown",
3852
+ uuid: "message-future",
3853
+ session_id: "session-1",
3854
+ });
3855
+ });
3856
+ assert.deepEqual(events, []);
3857
+ });
3286
3858
  test("handleSdkMessage treats mirror errors as log-only diagnostics", () => {
3287
3859
  const session = makeSessionState();
3288
3860
  const events = captureBridgeEvents(() => {
@@ -3841,6 +4413,7 @@ test("createToolCall maps project and artifact tools to compact titles", () => {
3841
4413
  file_path: "C:/work/report.html",
3842
4414
  favicon: "R",
3843
4415
  label: "report-v2",
4416
+ description: "Quarterly report",
3844
4417
  });
3845
4418
  const artifactFallback = createToolCall("tc-artifact-path", "Artifact", {
3846
4419
  file_path: "C:/work/report.html",
@@ -3853,6 +4426,7 @@ test("createToolCall maps project and artifact tools to compact titles", () => {
3853
4426
  assert.equal(projectSearch.title, "Projects: search migration");
3854
4427
  assert.equal(artifactWithLabel.kind, "other");
3855
4428
  assert.equal(artifactWithLabel.title, "Artifact: report-v2");
4429
+ assert.equal(artifactWithLabel.raw_input.description, "Quarterly report");
3856
4430
  assert.equal(artifactFallback.title, "Artifact: C:/work/report.html");
3857
4431
  assert.equal(rolePicker.kind, "other");
3858
4432
  assert.equal(rolePicker.title, "ShowOnboardingRolePicker");
@@ -4185,6 +4759,133 @@ test("buildToolResultFields marks ReadMcpResource error output as failed", () =>
4185
4759
  },
4186
4760
  ]);
4187
4761
  });
4762
+ test("buildToolResultFields renders structured ReadMcpResourceDir listings", () => {
4763
+ const base = createToolCall("tc-mcp-dir", "ReadMcpResourceDir", {
4764
+ server: "docs",
4765
+ uri: "file://manuals/",
4766
+ });
4767
+ const fields = buildToolResultFields(false, {
4768
+ resources: [
4769
+ {
4770
+ name: "guide.md",
4771
+ uri: "file://manuals/guide.md",
4772
+ mimeType: "text/markdown",
4773
+ },
4774
+ {
4775
+ name: "images",
4776
+ uri: "file://manuals/images",
4777
+ mimeType: "inode/directory",
4778
+ },
4779
+ {
4780
+ name: "readme",
4781
+ uri: "file://manuals/readme",
4782
+ },
4783
+ ],
4784
+ }, base);
4785
+ const expected = "guide.md - file://manuals/guide.md (text/markdown)\n" +
4786
+ "images - file://manuals/images (directory)\n" +
4787
+ "readme - file://manuals/readme";
4788
+ assert.equal(fields.status, "completed");
4789
+ assert.equal(fields.raw_output, expected);
4790
+ assert.deepEqual(fields.content, [
4791
+ {
4792
+ type: "content",
4793
+ content: { type: "text", text: expected },
4794
+ },
4795
+ ]);
4796
+ });
4797
+ test("buildToolResultFields renders empty ReadMcpResourceDir listings", () => {
4798
+ const base = createToolCall("tc-mcp-dir-empty", "ReadMcpResourceDir", {
4799
+ server: "docs",
4800
+ uri: "file://empty/",
4801
+ });
4802
+ const fields = buildToolResultFields(false, { resources: [] }, base);
4803
+ assert.equal(fields.status, "completed");
4804
+ assert.equal(fields.raw_output, "No resources found.");
4805
+ assert.deepEqual(fields.content, [
4806
+ {
4807
+ type: "content",
4808
+ content: { type: "text", text: "No resources found." },
4809
+ },
4810
+ ]);
4811
+ });
4812
+ test("buildToolResultFields marks ReadMcpResourceDir error output as failed", () => {
4813
+ const base = createToolCall("tc-mcp-dir-error", "ReadMcpResourceDir", {
4814
+ server: "docs",
4815
+ uri: "file://missing/",
4816
+ });
4817
+ const fields = buildToolResultFields(false, {
4818
+ resources: [],
4819
+ error: "directory not found",
4820
+ }, base);
4821
+ assert.equal(fields.status, "failed");
4822
+ assert.equal(fields.raw_output, "Error: directory not found");
4823
+ assert.deepEqual(fields.content, [
4824
+ {
4825
+ type: "content",
4826
+ content: { type: "text", text: "Error: directory not found" },
4827
+ },
4828
+ ]);
4829
+ });
4830
+ test("buildToolResultFields parses ReadMcpResourceDir transcript JSON", () => {
4831
+ const base = createToolCall("tc-mcp-dir-history", "ReadMcpResourceDir", {
4832
+ server: "docs",
4833
+ uri: "file://manuals/",
4834
+ });
4835
+ const transcriptJson = JSON.stringify({
4836
+ resources: [
4837
+ {
4838
+ name: "api.json",
4839
+ uri: "file://manuals/api.json",
4840
+ mimeType: "application/json",
4841
+ },
4842
+ ],
4843
+ });
4844
+ const fields = buildToolResultFields(false, transcriptJson, base, {
4845
+ type: "tool_result",
4846
+ tool_use_id: "tc-mcp-dir-history",
4847
+ content: transcriptJson,
4848
+ });
4849
+ assert.equal(fields.raw_output, "api.json - file://manuals/api.json (application/json)");
4850
+ assert.deepEqual(fields.content, [
4851
+ {
4852
+ type: "content",
4853
+ content: {
4854
+ type: "text",
4855
+ text: "api.json - file://manuals/api.json (application/json)",
4856
+ },
4857
+ },
4858
+ ]);
4859
+ });
4860
+ test("buildToolResultFields skips invalid ReadMcpResourceDir entries", () => {
4861
+ const base = createToolCall("tc-mcp-dir-invalid", "ReadMcpResourceDir", {
4862
+ server: "docs",
4863
+ uri: "file://manuals/",
4864
+ });
4865
+ const fields = buildToolResultFields(false, {
4866
+ resources: [
4867
+ { name: "missing-uri" },
4868
+ { uri: "file://manuals/missing-name" },
4869
+ null,
4870
+ {
4871
+ name: "valid.txt",
4872
+ uri: "file://manuals/valid.txt",
4873
+ mimeType: "text/plain",
4874
+ },
4875
+ ],
4876
+ }, base);
4877
+ assert.equal(fields.status, "completed");
4878
+ assert.equal(fields.raw_output, "valid.txt - file://manuals/valid.txt (text/plain)");
4879
+ assert.deepEqual(fields.content, [
4880
+ {
4881
+ type: "content",
4882
+ content: {
4883
+ type: "text",
4884
+ text: "valid.txt - file://manuals/valid.txt (text/plain)",
4885
+ },
4886
+ },
4887
+ ]);
4888
+ });
4188
4889
  test("buildToolResultFields preserves WebFetch artifactRead only as metadata", () => {
4189
4890
  const base = createToolCall("tc-web-fetch-artifact", "WebFetch", {
4190
4891
  url: "https://artifact.local/dashboard",
@@ -4405,7 +5106,7 @@ test("looksLikeAuthRequired detects login hints", () => {
4405
5106
  assert.equal(looksLikeAuthRequired("normal tool output"), false);
4406
5107
  });
4407
5108
  test("agent sdk version compatibility check matches pinned version", () => {
4408
- assert.equal(resolveInstalledAgentSdkVersion(), "0.3.177");
5109
+ assert.equal(resolveInstalledAgentSdkVersion(), "0.3.198");
4409
5110
  assert.equal(agentSdkVersionCompatibilityError(), undefined);
4410
5111
  });
4411
5112
  test("mapSessionMessagesToUpdates maps message content blocks", () => {
@@ -4919,13 +5620,15 @@ test("mapSdkSessions normalizes and sorts sessions", () => {
4919
5620
  },
4920
5621
  ]);
4921
5622
  });
4922
- test("buildSessionListOptions scopes repo-local listings to worktrees", () => {
5623
+ test("buildSessionListOptions includes SDK-created sessions for resume listings", () => {
4923
5624
  assert.deepEqual(buildSessionListOptions("C:/repo"), {
4924
5625
  dir: "C:/repo",
5626
+ includeProgrammatic: true,
4925
5627
  includeWorktrees: true,
4926
5628
  limit: 50,
4927
5629
  });
4928
5630
  assert.deepEqual(buildSessionListOptions(undefined), {
5631
+ includeProgrammatic: true,
4929
5632
  limit: 50,
4930
5633
  });
4931
5634
  });
@@ -5471,6 +6174,7 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
5471
6174
  const mapped = mapAvailableModels([
5472
6175
  {
5473
6176
  value: "sonnet",
6177
+ resolvedModel: "claude-sonnet-5",
5474
6178
  displayName: "Claude Sonnet",
5475
6179
  description: "Balanced model",
5476
6180
  supportsEffort: true,
@@ -5489,6 +6193,7 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
5489
6193
  assert.deepEqual(mapped, [
5490
6194
  {
5491
6195
  id: "sonnet",
6196
+ resolved_model: "claude-sonnet-5",
5492
6197
  display_name: "Claude Sonnet",
5493
6198
  description: "Balanced model",
5494
6199
  supports_effort: true,
@@ -5506,12 +6211,13 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
5506
6211
  },
5507
6212
  ]);
5508
6213
  });
5509
- test("mapAvailableModels filters unavailable Fable models while preserving unknown ids", () => {
6214
+ test("mapAvailableModels preserves Fable models and unknown ids", () => {
5510
6215
  const mapped = mapAvailableModels([
5511
6216
  {
5512
6217
  value: "fable",
6218
+ resolvedModel: "claude-fable-5",
5513
6219
  displayName: "Claude Fable",
5514
- description: "Unavailable model alias",
6220
+ description: "Default model alias",
5515
6221
  supportsEffort: true,
5516
6222
  },
5517
6223
  {
@@ -5534,6 +6240,28 @@ test("mapAvailableModels filters unavailable Fable models while preserving unkno
5534
6240
  },
5535
6241
  ]);
5536
6242
  assert.deepEqual(mapped, [
6243
+ {
6244
+ id: "fable",
6245
+ resolved_model: "claude-fable-5",
6246
+ display_name: "Claude Fable",
6247
+ description: "Default model alias",
6248
+ supports_effort: true,
6249
+ supported_effort_levels: [],
6250
+ },
6251
+ {
6252
+ id: "claude-fable-5",
6253
+ display_name: "Claude Fable 5",
6254
+ description: "Unavailable model",
6255
+ supports_effort: true,
6256
+ supported_effort_levels: [],
6257
+ },
6258
+ {
6259
+ id: "claude-fable-5-20260612",
6260
+ display_name: "Claude Fable 5 dated",
6261
+ description: "Unavailable dated model",
6262
+ supports_effort: true,
6263
+ supported_effort_levels: [],
6264
+ },
5537
6265
  {
5538
6266
  id: "claude-unknown-1",
5539
6267
  display_name: "Claude Unknown",
@@ -5543,6 +6271,26 @@ test("mapAvailableModels filters unavailable Fable models while preserving unkno
5543
6271
  },
5544
6272
  ]);
5545
6273
  });
6274
+ test("resolveCurrentModel matches full Fable runtime ids to the fable alias", () => {
6275
+ const session = makeSessionState();
6276
+ session.model = "fable";
6277
+ session.requestedModelId = "fable";
6278
+ session.resolvedRuntimeModelId = "claude-fable-5-20260612";
6279
+ session.availableModels = [
6280
+ {
6281
+ id: "fable",
6282
+ resolved_model: "claude-fable-5",
6283
+ display_name: "Claude Fable 5",
6284
+ supports_effort: true,
6285
+ supported_effort_levels: ["low", "medium", "high", "xhigh", "max"],
6286
+ },
6287
+ ];
6288
+ const currentModel = resolveCurrentModel(session);
6289
+ assert.equal(currentModel.display_name_short, "Fable 5");
6290
+ assert.equal(currentModel.display_name_long, "Claude Fable 5");
6291
+ assert.equal(currentModel.catalog_id, "fable");
6292
+ assert.equal(currentModel.supports_effort, true);
6293
+ });
5546
6294
  test("resolveCurrentModel keeps 1M context suffix in short and long display names", () => {
5547
6295
  const session = makeSessionState();
5548
6296
  session.resolvedRuntimeModelId = "claude-opus-4-7[1m]";