claude-code-rust 0.12.3 → 0.12.4

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);
@@ -706,6 +729,150 @@ test("parseCommandEnvelope validates get_context_usage command", () => {
706
729
  session_id: "session-123",
707
730
  });
708
731
  });
732
+ test("parseCommandEnvelope validates get_rewind_targets command", () => {
733
+ const parsed = parseCommandEnvelope(JSON.stringify({
734
+ request_id: "req-rewind-targets",
735
+ command: "get_rewind_targets",
736
+ session_id: "session-123",
737
+ }));
738
+ assert.equal(parsed.requestId, "req-rewind-targets");
739
+ assert.deepEqual(parsed.command, {
740
+ command: "get_rewind_targets",
741
+ session_id: "session-123",
742
+ });
743
+ });
744
+ test("parseCommandEnvelope validates rewind command modes", () => {
745
+ for (const restoreMode of ["both", "conversation", "code"]) {
746
+ const parsed = parseCommandEnvelope(JSON.stringify({
747
+ request_id: "req-rewind",
748
+ command: "rewind",
749
+ session_id: "session-123",
750
+ target_user_message_id: "user-1",
751
+ restore_mode: restoreMode,
752
+ launch_settings: {
753
+ language: "German",
754
+ },
755
+ }));
756
+ assert.equal(parsed.requestId, "req-rewind");
757
+ assert.deepEqual(parsed.command, {
758
+ command: "rewind",
759
+ session_id: "session-123",
760
+ target_user_message_id: "user-1",
761
+ restore_mode: restoreMode,
762
+ launch_settings: { language: "German" },
763
+ });
764
+ }
765
+ });
766
+ test("parseCommandEnvelope rejects invalid rewind mode", () => {
767
+ assert.throws(() => parseCommandEnvelope(JSON.stringify({
768
+ command: "rewind",
769
+ session_id: "session-123",
770
+ target_user_message_id: "user-1",
771
+ restore_mode: "files",
772
+ })), /rewind\.restore_mode must be one of both, conversation, code/);
773
+ });
774
+ test("rewindTargetsFromSessionMessages filters user text messages with UUIDs", () => {
775
+ const messages = [
776
+ {
777
+ type: "user",
778
+ uuid: "user-1",
779
+ message: { role: "user", content: [{ type: "text", text: " first prompt\nline " }] },
780
+ },
781
+ {
782
+ type: "assistant",
783
+ uuid: "assistant-1",
784
+ message: { role: "assistant", content: [{ type: "text", text: "ignored" }] },
785
+ },
786
+ {
787
+ type: "user",
788
+ uuid: "tool-result",
789
+ message: { role: "user", content: [{ type: "tool_result", content: "ignored" }] },
790
+ },
791
+ {
792
+ type: "user",
793
+ uuid: "user-2",
794
+ message: { role: "user", content: [{ type: "text", text: "second prompt" }] },
795
+ },
796
+ ];
797
+ assert.deepEqual(rewindTargetsFromSessionMessages(messages), [
798
+ {
799
+ uuid: "user-2",
800
+ first_text: "second prompt",
801
+ input_text: "second prompt",
802
+ index: 3,
803
+ previous_assistant_uuid: "assistant-1",
804
+ },
805
+ {
806
+ uuid: "user-1",
807
+ first_text: "first prompt line",
808
+ input_text: "first prompt\nline",
809
+ index: 0,
810
+ },
811
+ ]);
812
+ });
813
+ test("buildRewindConversationPlan anchors at previous assistant message", () => {
814
+ const messages = [
815
+ {
816
+ type: "user",
817
+ uuid: "user-1",
818
+ message: { role: "user", content: "first prompt" },
819
+ },
820
+ {
821
+ type: "assistant",
822
+ uuid: "assistant-1",
823
+ message: { role: "assistant", content: [{ type: "text", text: "reply" }] },
824
+ },
825
+ {
826
+ type: "user",
827
+ uuid: "user-2",
828
+ message: { role: "user", content: "second prompt" },
829
+ },
830
+ ];
831
+ const plan = buildRewindConversationPlan(messages, "user-2");
832
+ assert.ok(plan);
833
+ assert.equal(plan.inputText, "second prompt");
834
+ assert.equal(plan.previousAssistantUuid, "assistant-1");
835
+ assert.equal(plan.targetIndex, 2);
836
+ assert.deepEqual(plan.retainedMessages.map((message) => message.uuid), ["user-1", "assistant-1"]);
837
+ assert.ok(plan.resumeUpdates.length > 0);
838
+ });
839
+ test("buildRewindConversationPlan treats first user message as fresh replacement", () => {
840
+ const messages = [
841
+ {
842
+ type: "user",
843
+ uuid: "user-1",
844
+ message: { role: "user", content: " first prompt\nline " },
845
+ },
846
+ {
847
+ type: "assistant",
848
+ uuid: "assistant-1",
849
+ message: { role: "assistant", content: [{ type: "text", text: "reply" }] },
850
+ },
851
+ ];
852
+ const plan = buildRewindConversationPlan(messages, "user-1");
853
+ assert.ok(plan);
854
+ assert.equal(plan.inputText, " first prompt\nline ");
855
+ assert.equal(plan.previousAssistantUuid, undefined);
856
+ assert.equal(plan.targetIndex, 0);
857
+ assert.deepEqual(plan.retainedMessages, []);
858
+ assert.deepEqual(plan.resumeUpdates, []);
859
+ });
860
+ test("buildRewindConversationPlan rejects stale or inconsistent targets", () => {
861
+ const messages = [
862
+ {
863
+ type: "user",
864
+ uuid: "user-1",
865
+ message: { role: "user", content: "first prompt" },
866
+ },
867
+ {
868
+ type: "user",
869
+ uuid: "user-2",
870
+ message: { role: "user", content: "second prompt" },
871
+ },
872
+ ];
873
+ assert.equal(buildRewindConversationPlan(messages, "missing-user"), null);
874
+ assert.equal(buildRewindConversationPlan(messages, "user-2"), null);
875
+ });
709
876
  test("staleMcpAuthCandidates selects previously connected servers that regressed to needs-auth", () => {
710
877
  const candidates = staleMcpAuthCandidates([
711
878
  {
@@ -817,12 +984,31 @@ test("buildQueryOptions maps launch settings into sdk query options", () => {
817
984
  assert.equal("effort" in options, false);
818
985
  assert.equal(options.agentProgressSummaries, true);
819
986
  assert.equal(options.promptSuggestions, true);
987
+ assert.equal(options.enableFileCheckpointing, true);
820
988
  assert.equal(options.sessionId, "session-1");
821
989
  assert.deepEqual(options.settingSources, ["user", "project", "local"]);
822
990
  assert.deepEqual(options.toolConfig, {
823
991
  askUserQuestion: { previewFormat: "markdown" },
824
992
  });
825
993
  });
994
+ test("buildQueryOptions includes resumeSessionAt when provided", () => {
995
+ const input = new AsyncQueue();
996
+ const options = buildQueryOptions({
997
+ cwd: "C:/work",
998
+ resume: "session-1",
999
+ resumeSessionAt: "assistant-1",
1000
+ launchSettings: {},
1001
+ provisionalSessionId: "session-1",
1002
+ input,
1003
+ canUseTool: async () => ({ behavior: "deny", message: "not used" }),
1004
+ enableSdkDebug: false,
1005
+ enableSpawnDebug: false,
1006
+ sessionIdForLogs: () => "session-1",
1007
+ });
1008
+ assert.equal(options.resume, "session-1");
1009
+ assert.equal(options.resumeSessionAt, "assistant-1");
1010
+ assert.equal("sessionId" in options, false);
1011
+ });
826
1012
  test("buildQueryOptions forwards settings and maps startup model and permission mode", () => {
827
1013
  const input = new AsyncQueue();
828
1014
  const options = buildQueryOptions({
@@ -1389,6 +1575,96 @@ test("handleSdkMessage emits transcript retraction for model_refusal_fallback",
1389
1575
  ]);
1390
1576
  assert.equal(events.some((event) => event.update?.type === "system_notice_update"), false);
1391
1577
  });
1578
+ test("handleSdkMessage emits warning notice for model_refusal_no_fallback with explanation", () => {
1579
+ const session = makeSessionState();
1580
+ const events = captureBridgeEvents(() => {
1581
+ handleSdkMessage(session, {
1582
+ type: "system",
1583
+ subtype: "model_refusal_no_fallback",
1584
+ original_model: "claude-opus-4-1",
1585
+ request_id: "req-1",
1586
+ api_refusal_category: "cyber",
1587
+ api_refusal_explanation: "policy text",
1588
+ refused_user_message_uuid: "user-1",
1589
+ content: "raw content",
1590
+ uuid: "refusal-notice",
1591
+ session_id: "session-1",
1592
+ });
1593
+ });
1594
+ assert.deepEqual(events.map((event) => event.update), [
1595
+ {
1596
+ type: "system_notice_update",
1597
+ severity: "warning",
1598
+ message: "Could not continue with claude-opus-4-1: model refused the request and no fallback model is configured. Reason: policy text.",
1599
+ },
1600
+ ]);
1601
+ });
1602
+ test("handleSdkMessage emits warning notice for model_refusal_no_fallback with category only", () => {
1603
+ const session = makeSessionState();
1604
+ const events = captureBridgeEvents(() => {
1605
+ handleSdkMessage(session, {
1606
+ type: "system",
1607
+ subtype: "model_refusal_no_fallback",
1608
+ original_model: "claude-opus-4-1",
1609
+ request_id: "req-1",
1610
+ api_refusal_category: "cyber",
1611
+ uuid: "refusal-notice",
1612
+ session_id: "session-1",
1613
+ });
1614
+ });
1615
+ assert.deepEqual(events.map((event) => event.update), [
1616
+ {
1617
+ type: "system_notice_update",
1618
+ severity: "warning",
1619
+ message: "Could not continue with claude-opus-4-1: model refused the request and no fallback model is configured. Refusal category: cyber.",
1620
+ },
1621
+ ]);
1622
+ });
1623
+ test("handleSdkMessage emits warning notice for model_refusal_no_fallback with content detail", () => {
1624
+ const session = makeSessionState();
1625
+ const events = captureBridgeEvents(() => {
1626
+ handleSdkMessage(session, {
1627
+ type: "system",
1628
+ subtype: "model_refusal_no_fallback",
1629
+ original_model: "claude-opus-4-1",
1630
+ request_id: "req-1",
1631
+ content: "Refused by policy!",
1632
+ uuid: "refusal-notice",
1633
+ session_id: "session-1",
1634
+ });
1635
+ });
1636
+ assert.deepEqual(events.map((event) => event.update), [
1637
+ {
1638
+ type: "system_notice_update",
1639
+ severity: "warning",
1640
+ message: "Could not continue with claude-opus-4-1: model refused the request and no fallback model is configured. Refused by policy!",
1641
+ },
1642
+ ]);
1643
+ });
1644
+ test("handleSdkMessage emits readable model_refusal_no_fallback notice for empty metadata", () => {
1645
+ const session = makeSessionState();
1646
+ const events = captureBridgeEvents(() => {
1647
+ handleSdkMessage(session, {
1648
+ type: "system",
1649
+ subtype: "model_refusal_no_fallback",
1650
+ original_model: " ",
1651
+ request_id: null,
1652
+ api_refusal_category: " ",
1653
+ api_refusal_explanation: null,
1654
+ refused_user_message_uuid: null,
1655
+ content: " ",
1656
+ uuid: "refusal-notice",
1657
+ session_id: "session-1",
1658
+ });
1659
+ });
1660
+ assert.deepEqual(events.map((event) => event.update), [
1661
+ {
1662
+ type: "system_notice_update",
1663
+ severity: "warning",
1664
+ message: "Could not continue with the selected model: model refused the request and no fallback model is configured.",
1665
+ },
1666
+ ]);
1667
+ });
1392
1668
  test("handleSdkMessage emits tolerant transcript retraction for model_fallback", () => {
1393
1669
  const session = makeSessionState();
1394
1670
  const events = captureBridgeEvents(() => {
@@ -2908,6 +3184,7 @@ test("normalizeToolKind maps known tool names", () => {
2908
3184
  assert.equal(normalizeToolKind("ShowOnboardingRolePicker"), "other");
2909
3185
  assert.equal(normalizeToolKind("TaskOutput"), "other");
2910
3186
  assert.equal(normalizeToolKind("TaskStop"), "other");
3187
+ assert.equal(normalizeToolKind("ReadMcpResourceDir"), "read");
2911
3188
  assert.equal(normalizeToolKind("Task"), "think");
2912
3189
  assert.equal(normalizeToolKind("Agent"), "think");
2913
3190
  assert.equal(normalizeToolKind("EnterPlanMode"), "switch_mode");
@@ -2925,6 +3202,16 @@ test("shell tool titles use input command", () => {
2925
3202
  assert.equal(createToolCall("tc-powershell-title", "PowerShell", { command: "Get-ChildItem" }).title, "Get-ChildItem");
2926
3203
  assert.equal(createToolCall("tc-powershell-empty", "PowerShell", {}).title, "Terminal");
2927
3204
  });
3205
+ test("ReadMcpResourceDir titles include server and URI context", () => {
3206
+ assert.equal(createToolCall("tc-mcp-dir-title", "ReadMcpResourceDir", {
3207
+ server: "docs",
3208
+ uri: "file://manuals/",
3209
+ }).title, "ReadMcpResourceDir docs file://manuals/");
3210
+ assert.equal(createToolCall("tc-mcp-dir-uri-title", "ReadMcpResourceDir", {
3211
+ uri: "file://manuals/",
3212
+ }).title, "ReadMcpResourceDir file://manuals/");
3213
+ assert.equal(createToolCall("tc-mcp-dir-fallback-title", "ReadMcpResourceDir", {}).title, "ReadMcpResourceDir");
3214
+ });
2928
3215
  test("parseFastModeState accepts known values and rejects unknown values", () => {
2929
3216
  assert.equal(parseFastModeState("off"), "off");
2930
3217
  assert.equal(parseFastModeState("cooldown"), "cooldown");
@@ -2957,10 +3244,14 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
2957
3244
  overageDisabledReason: "out_of_credits",
2958
3245
  isUsingOverage: false,
2959
3246
  surpassedThreshold: 0.9,
3247
+ errorCode: "credits_required",
3248
+ canUserPurchaseCredits: true,
3249
+ hasChargeableSavedPaymentMethod: false,
2960
3250
  });
2961
3251
  assert.deepEqual(update, {
2962
3252
  type: "rate_limit_update",
2963
3253
  status: "allowed_warning",
3254
+ error_code: "credits_required",
2964
3255
  resets_at: 1_741_280_000,
2965
3256
  utilization: 0.92,
2966
3257
  rate_limit_type: "five_hour",
@@ -2969,6 +3260,8 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
2969
3260
  overage_disabled_reason: "out_of_credits",
2970
3261
  is_using_overage: false,
2971
3262
  surpassed_threshold: 0.9,
3263
+ can_user_purchase_credits: true,
3264
+ has_chargeable_saved_payment_method: false,
2972
3265
  });
2973
3266
  });
2974
3267
  test("buildRateLimitUpdate normalizes SDK overage boolean spellings", () => {
@@ -2998,6 +3291,10 @@ test("buildRateLimitUpdate rejects invalid payloads", () => {
2998
3291
  status: "rejected",
2999
3292
  overageStatus: "bad_status",
3000
3293
  }), { type: "rate_limit_update", status: "rejected" });
3294
+ assert.deepEqual(buildRateLimitUpdate({ status: "rejected", errorCode: "other" }), {
3295
+ type: "rate_limit_update",
3296
+ status: "rejected",
3297
+ });
3001
3298
  });
3002
3299
  test("buildApiRetryUpdate maps SDK api_retry messages to wire shape", () => {
3003
3300
  assert.deepEqual(buildApiRetryUpdate({
@@ -3283,6 +3580,197 @@ test("handleSdkMessage emits system notices for notifications and plugin failure
3283
3580
  { type: "system_notice_update", severity: "warning", message: "Plugin install failed acme: download failed" },
3284
3581
  ]);
3285
3582
  });
3583
+ test("handleSdkMessage maps informational system messages to notices by level", () => {
3584
+ const session = makeSessionState();
3585
+ const events = captureBridgeEvents(() => {
3586
+ handleSdkMessage(session, {
3587
+ type: "system",
3588
+ subtype: "informational",
3589
+ content: " Sync ready ",
3590
+ level: "notice",
3591
+ uuid: "message-info-notice",
3592
+ session_id: "session-1",
3593
+ });
3594
+ handleSdkMessage(session, {
3595
+ type: "system",
3596
+ subtype: "informational",
3597
+ content: "Try /compact",
3598
+ level: "suggestion",
3599
+ uuid: "message-info-suggestion",
3600
+ session_id: "session-1",
3601
+ });
3602
+ handleSdkMessage(session, {
3603
+ type: "system",
3604
+ subtype: "informational",
3605
+ content: "Hook blocked continuation",
3606
+ level: "warning",
3607
+ uuid: "message-info-warning",
3608
+ session_id: "session-1",
3609
+ });
3610
+ });
3611
+ assert.deepEqual(events.map((event) => event.update), [
3612
+ { type: "system_notice_update", severity: "info", message: "Sync ready" },
3613
+ { type: "system_notice_update", severity: "info", message: "Suggestion: Try /compact" },
3614
+ { type: "system_notice_update", severity: "warning", message: "Hook blocked continuation" },
3615
+ ]);
3616
+ });
3617
+ test("handleSdkMessage keeps informational info log-only unless continuation is prevented", () => {
3618
+ const session = makeSessionState();
3619
+ const events = captureBridgeEvents(() => {
3620
+ handleSdkMessage(session, {
3621
+ type: "system",
3622
+ subtype: "informational",
3623
+ content: "Transcript-only progress",
3624
+ level: "info",
3625
+ uuid: "message-info-log-only",
3626
+ session_id: "session-1",
3627
+ });
3628
+ handleSdkMessage(session, {
3629
+ type: "system",
3630
+ subtype: "informational",
3631
+ content: "Stop hook denied continuation",
3632
+ level: "info",
3633
+ prevent_continuation: true,
3634
+ uuid: "message-info-prevented",
3635
+ session_id: "session-1",
3636
+ });
3637
+ });
3638
+ assert.deepEqual(events.map((event) => event.update), [
3639
+ {
3640
+ type: "system_notice_update",
3641
+ severity: "warning",
3642
+ message: "Stop hook denied continuation",
3643
+ },
3644
+ ]);
3645
+ });
3646
+ test("handleSdkMessage deduplicates informational messages by tool use, level, and content", () => {
3647
+ const session = makeSessionState();
3648
+ const events = captureBridgeEvents(() => {
3649
+ handleSdkMessage(session, {
3650
+ type: "system",
3651
+ subtype: "informational",
3652
+ content: "Progress",
3653
+ level: "notice",
3654
+ tool_use_id: "tool-1",
3655
+ uuid: "message-info-1",
3656
+ session_id: "session-1",
3657
+ });
3658
+ handleSdkMessage(session, {
3659
+ type: "system",
3660
+ subtype: "informational",
3661
+ content: " Progress ",
3662
+ level: "notice",
3663
+ tool_use_id: "tool-1",
3664
+ uuid: "message-info-2",
3665
+ session_id: "session-1",
3666
+ });
3667
+ handleSdkMessage(session, {
3668
+ type: "system",
3669
+ subtype: "informational",
3670
+ content: "Progress updated",
3671
+ level: "notice",
3672
+ tool_use_id: "tool-1",
3673
+ uuid: "message-info-3",
3674
+ session_id: "session-1",
3675
+ });
3676
+ });
3677
+ assert.deepEqual(events.map((event) => event.update), [
3678
+ { type: "system_notice_update", severity: "info", message: "Progress" },
3679
+ { type: "system_notice_update", severity: "info", message: "Progress updated" },
3680
+ ]);
3681
+ });
3682
+ test("handleSdkMessage does not deduplicate informational messages without a tool use id", () => {
3683
+ const session = makeSessionState();
3684
+ const events = captureBridgeEvents(() => {
3685
+ for (const uuid of ["message-info-1", "message-info-2"]) {
3686
+ handleSdkMessage(session, {
3687
+ type: "system",
3688
+ subtype: "informational",
3689
+ content: "Repeated global notice",
3690
+ level: "notice",
3691
+ uuid,
3692
+ session_id: "session-1",
3693
+ });
3694
+ }
3695
+ });
3696
+ assert.deepEqual(events.map((event) => event.update), [
3697
+ { type: "system_notice_update", severity: "info", message: "Repeated global notice" },
3698
+ { type: "system_notice_update", severity: "info", message: "Repeated global notice" },
3699
+ ]);
3700
+ });
3701
+ test("handleSdkMessage treats worker shutdown before connect as log-only", () => {
3702
+ const session = makeSessionState();
3703
+ session.connected = false;
3704
+ const events = captureBridgeEvents(() => {
3705
+ handleSdkMessage(session, {
3706
+ type: "system",
3707
+ subtype: "worker_shutting_down",
3708
+ reason: "host_exit",
3709
+ uuid: "message-worker-shutdown",
3710
+ session_id: "session-1",
3711
+ });
3712
+ flushPendingWorkerShutdown(session);
3713
+ });
3714
+ assert.deepEqual(events, []);
3715
+ assert.equal(session.pendingWorkerShutdown, undefined);
3716
+ });
3717
+ test("handleSdkMessage flushes connected worker shutdown only when stream ends", () => {
3718
+ const session = makeSessionState();
3719
+ const events = captureBridgeEvents(() => {
3720
+ handleSdkMessage(session, {
3721
+ type: "system",
3722
+ subtype: "worker_shutting_down",
3723
+ reason: "host_exit",
3724
+ uuid: "message-worker-shutdown",
3725
+ session_id: "session-1",
3726
+ });
3727
+ flushPendingWorkerShutdown(session);
3728
+ });
3729
+ assert.deepEqual(events.map((event) => event.update), [
3730
+ {
3731
+ type: "system_notice_update",
3732
+ severity: "warning",
3733
+ message: "Claude worker is shutting down: host_exit",
3734
+ },
3735
+ ]);
3736
+ });
3737
+ test("handleSdkMessage cancels pending worker shutdown after later SDK activity", () => {
3738
+ const session = makeSessionState();
3739
+ const events = captureBridgeEvents(() => {
3740
+ handleSdkMessage(session, {
3741
+ type: "system",
3742
+ subtype: "worker_shutting_down",
3743
+ reason: "host_exit",
3744
+ uuid: "message-worker-shutdown",
3745
+ session_id: "session-1",
3746
+ });
3747
+ handleSdkMessage(session, {
3748
+ type: "system",
3749
+ subtype: "notification",
3750
+ text: "Still running",
3751
+ priority: "low",
3752
+ uuid: "message-notification",
3753
+ session_id: "session-1",
3754
+ });
3755
+ flushPendingWorkerShutdown(session);
3756
+ });
3757
+ assert.deepEqual(events.map((event) => event.update), [
3758
+ { type: "system_notice_update", severity: "info", message: "Still running" },
3759
+ ]);
3760
+ });
3761
+ test("handleSdkMessage ignores unknown future system subtypes", () => {
3762
+ const session = makeSessionState();
3763
+ const events = captureBridgeEvents(() => {
3764
+ handleSdkMessage(session, {
3765
+ type: "system",
3766
+ subtype: "future_subtype",
3767
+ content: "Unknown",
3768
+ uuid: "message-future",
3769
+ session_id: "session-1",
3770
+ });
3771
+ });
3772
+ assert.deepEqual(events, []);
3773
+ });
3286
3774
  test("handleSdkMessage treats mirror errors as log-only diagnostics", () => {
3287
3775
  const session = makeSessionState();
3288
3776
  const events = captureBridgeEvents(() => {
@@ -4185,6 +4673,133 @@ test("buildToolResultFields marks ReadMcpResource error output as failed", () =>
4185
4673
  },
4186
4674
  ]);
4187
4675
  });
4676
+ test("buildToolResultFields renders structured ReadMcpResourceDir listings", () => {
4677
+ const base = createToolCall("tc-mcp-dir", "ReadMcpResourceDir", {
4678
+ server: "docs",
4679
+ uri: "file://manuals/",
4680
+ });
4681
+ const fields = buildToolResultFields(false, {
4682
+ resources: [
4683
+ {
4684
+ name: "guide.md",
4685
+ uri: "file://manuals/guide.md",
4686
+ mimeType: "text/markdown",
4687
+ },
4688
+ {
4689
+ name: "images",
4690
+ uri: "file://manuals/images",
4691
+ mimeType: "inode/directory",
4692
+ },
4693
+ {
4694
+ name: "readme",
4695
+ uri: "file://manuals/readme",
4696
+ },
4697
+ ],
4698
+ }, base);
4699
+ const expected = "guide.md - file://manuals/guide.md (text/markdown)\n" +
4700
+ "images - file://manuals/images (directory)\n" +
4701
+ "readme - file://manuals/readme";
4702
+ assert.equal(fields.status, "completed");
4703
+ assert.equal(fields.raw_output, expected);
4704
+ assert.deepEqual(fields.content, [
4705
+ {
4706
+ type: "content",
4707
+ content: { type: "text", text: expected },
4708
+ },
4709
+ ]);
4710
+ });
4711
+ test("buildToolResultFields renders empty ReadMcpResourceDir listings", () => {
4712
+ const base = createToolCall("tc-mcp-dir-empty", "ReadMcpResourceDir", {
4713
+ server: "docs",
4714
+ uri: "file://empty/",
4715
+ });
4716
+ const fields = buildToolResultFields(false, { resources: [] }, base);
4717
+ assert.equal(fields.status, "completed");
4718
+ assert.equal(fields.raw_output, "No resources found.");
4719
+ assert.deepEqual(fields.content, [
4720
+ {
4721
+ type: "content",
4722
+ content: { type: "text", text: "No resources found." },
4723
+ },
4724
+ ]);
4725
+ });
4726
+ test("buildToolResultFields marks ReadMcpResourceDir error output as failed", () => {
4727
+ const base = createToolCall("tc-mcp-dir-error", "ReadMcpResourceDir", {
4728
+ server: "docs",
4729
+ uri: "file://missing/",
4730
+ });
4731
+ const fields = buildToolResultFields(false, {
4732
+ resources: [],
4733
+ error: "directory not found",
4734
+ }, base);
4735
+ assert.equal(fields.status, "failed");
4736
+ assert.equal(fields.raw_output, "Error: directory not found");
4737
+ assert.deepEqual(fields.content, [
4738
+ {
4739
+ type: "content",
4740
+ content: { type: "text", text: "Error: directory not found" },
4741
+ },
4742
+ ]);
4743
+ });
4744
+ test("buildToolResultFields parses ReadMcpResourceDir transcript JSON", () => {
4745
+ const base = createToolCall("tc-mcp-dir-history", "ReadMcpResourceDir", {
4746
+ server: "docs",
4747
+ uri: "file://manuals/",
4748
+ });
4749
+ const transcriptJson = JSON.stringify({
4750
+ resources: [
4751
+ {
4752
+ name: "api.json",
4753
+ uri: "file://manuals/api.json",
4754
+ mimeType: "application/json",
4755
+ },
4756
+ ],
4757
+ });
4758
+ const fields = buildToolResultFields(false, transcriptJson, base, {
4759
+ type: "tool_result",
4760
+ tool_use_id: "tc-mcp-dir-history",
4761
+ content: transcriptJson,
4762
+ });
4763
+ assert.equal(fields.raw_output, "api.json - file://manuals/api.json (application/json)");
4764
+ assert.deepEqual(fields.content, [
4765
+ {
4766
+ type: "content",
4767
+ content: {
4768
+ type: "text",
4769
+ text: "api.json - file://manuals/api.json (application/json)",
4770
+ },
4771
+ },
4772
+ ]);
4773
+ });
4774
+ test("buildToolResultFields skips invalid ReadMcpResourceDir entries", () => {
4775
+ const base = createToolCall("tc-mcp-dir-invalid", "ReadMcpResourceDir", {
4776
+ server: "docs",
4777
+ uri: "file://manuals/",
4778
+ });
4779
+ const fields = buildToolResultFields(false, {
4780
+ resources: [
4781
+ { name: "missing-uri" },
4782
+ { uri: "file://manuals/missing-name" },
4783
+ null,
4784
+ {
4785
+ name: "valid.txt",
4786
+ uri: "file://manuals/valid.txt",
4787
+ mimeType: "text/plain",
4788
+ },
4789
+ ],
4790
+ }, base);
4791
+ assert.equal(fields.status, "completed");
4792
+ assert.equal(fields.raw_output, "valid.txt - file://manuals/valid.txt (text/plain)");
4793
+ assert.deepEqual(fields.content, [
4794
+ {
4795
+ type: "content",
4796
+ content: {
4797
+ type: "text",
4798
+ text: "valid.txt - file://manuals/valid.txt (text/plain)",
4799
+ },
4800
+ },
4801
+ ]);
4802
+ });
4188
4803
  test("buildToolResultFields preserves WebFetch artifactRead only as metadata", () => {
4189
4804
  const base = createToolCall("tc-web-fetch-artifact", "WebFetch", {
4190
4805
  url: "https://artifact.local/dashboard",
@@ -4405,7 +5020,7 @@ test("looksLikeAuthRequired detects login hints", () => {
4405
5020
  assert.equal(looksLikeAuthRequired("normal tool output"), false);
4406
5021
  });
4407
5022
  test("agent sdk version compatibility check matches pinned version", () => {
4408
- assert.equal(resolveInstalledAgentSdkVersion(), "0.3.177");
5023
+ assert.equal(resolveInstalledAgentSdkVersion(), "0.3.193");
4409
5024
  assert.equal(agentSdkVersionCompatibilityError(), undefined);
4410
5025
  });
4411
5026
  test("mapSessionMessagesToUpdates maps message content blocks", () => {
@@ -4919,13 +5534,15 @@ test("mapSdkSessions normalizes and sorts sessions", () => {
4919
5534
  },
4920
5535
  ]);
4921
5536
  });
4922
- test("buildSessionListOptions scopes repo-local listings to worktrees", () => {
5537
+ test("buildSessionListOptions includes SDK-created sessions for resume listings", () => {
4923
5538
  assert.deepEqual(buildSessionListOptions("C:/repo"), {
4924
5539
  dir: "C:/repo",
5540
+ includeProgrammatic: true,
4925
5541
  includeWorktrees: true,
4926
5542
  limit: 50,
4927
5543
  });
4928
5544
  assert.deepEqual(buildSessionListOptions(undefined), {
5545
+ includeProgrammatic: true,
4929
5546
  limit: 50,
4930
5547
  });
4931
5548
  });