claude-code-rust 0.12.2 → 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, 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(() => {
@@ -2889,6 +3165,7 @@ test("requestAskUserQuestionAnswers preserves previews and annotations in update
2889
3165
  });
2890
3166
  test("normalizeToolKind maps known tool names", () => {
2891
3167
  assert.equal(normalizeToolKind("Bash"), "execute");
3168
+ assert.equal(normalizeToolKind("PowerShell"), "execute");
2892
3169
  assert.equal(normalizeToolKind("Delete"), "delete");
2893
3170
  assert.equal(normalizeToolKind("Move"), "move");
2894
3171
  assert.equal(normalizeToolKind("EnterWorktree"), "other");
@@ -2907,12 +3184,34 @@ test("normalizeToolKind maps known tool names", () => {
2907
3184
  assert.equal(normalizeToolKind("ShowOnboardingRolePicker"), "other");
2908
3185
  assert.equal(normalizeToolKind("TaskOutput"), "other");
2909
3186
  assert.equal(normalizeToolKind("TaskStop"), "other");
3187
+ assert.equal(normalizeToolKind("ReadMcpResourceDir"), "read");
2910
3188
  assert.equal(normalizeToolKind("Task"), "think");
2911
3189
  assert.equal(normalizeToolKind("Agent"), "think");
2912
3190
  assert.equal(normalizeToolKind("EnterPlanMode"), "switch_mode");
2913
3191
  assert.equal(normalizeToolKind("ExitPlanMode"), "switch_mode");
2914
3192
  assert.equal(normalizeToolKind("TodoWrite"), normalizeToolKind("FutureUnknownTool"));
2915
3193
  });
3194
+ test("isShellToolName recognizes only supported shell tools", () => {
3195
+ assert.equal(isShellToolName("Bash"), true);
3196
+ assert.equal(isShellToolName("PowerShell"), true);
3197
+ assert.equal(isShellToolName("Shell"), false);
3198
+ assert.equal(isShellToolName("bash"), false);
3199
+ });
3200
+ test("shell tool titles use input command", () => {
3201
+ assert.equal(createToolCall("tc-bash-title", "Bash", { command: "git status" }).title, "git status");
3202
+ assert.equal(createToolCall("tc-powershell-title", "PowerShell", { command: "Get-ChildItem" }).title, "Get-ChildItem");
3203
+ assert.equal(createToolCall("tc-powershell-empty", "PowerShell", {}).title, "Terminal");
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
+ });
2916
3215
  test("parseFastModeState accepts known values and rejects unknown values", () => {
2917
3216
  assert.equal(parseFastModeState("off"), "off");
2918
3217
  assert.equal(parseFastModeState("cooldown"), "cooldown");
@@ -2945,10 +3244,14 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
2945
3244
  overageDisabledReason: "out_of_credits",
2946
3245
  isUsingOverage: false,
2947
3246
  surpassedThreshold: 0.9,
3247
+ errorCode: "credits_required",
3248
+ canUserPurchaseCredits: true,
3249
+ hasChargeableSavedPaymentMethod: false,
2948
3250
  });
2949
3251
  assert.deepEqual(update, {
2950
3252
  type: "rate_limit_update",
2951
3253
  status: "allowed_warning",
3254
+ error_code: "credits_required",
2952
3255
  resets_at: 1_741_280_000,
2953
3256
  utilization: 0.92,
2954
3257
  rate_limit_type: "five_hour",
@@ -2957,6 +3260,8 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
2957
3260
  overage_disabled_reason: "out_of_credits",
2958
3261
  is_using_overage: false,
2959
3262
  surpassed_threshold: 0.9,
3263
+ can_user_purchase_credits: true,
3264
+ has_chargeable_saved_payment_method: false,
2960
3265
  });
2961
3266
  });
2962
3267
  test("buildRateLimitUpdate normalizes SDK overage boolean spellings", () => {
@@ -2986,6 +3291,10 @@ test("buildRateLimitUpdate rejects invalid payloads", () => {
2986
3291
  status: "rejected",
2987
3292
  overageStatus: "bad_status",
2988
3293
  }), { type: "rate_limit_update", status: "rejected" });
3294
+ assert.deepEqual(buildRateLimitUpdate({ status: "rejected", errorCode: "other" }), {
3295
+ type: "rate_limit_update",
3296
+ status: "rejected",
3297
+ });
2989
3298
  });
2990
3299
  test("buildApiRetryUpdate maps SDK api_retry messages to wire shape", () => {
2991
3300
  assert.deepEqual(buildApiRetryUpdate({
@@ -3271,6 +3580,197 @@ test("handleSdkMessage emits system notices for notifications and plugin failure
3271
3580
  { type: "system_notice_update", severity: "warning", message: "Plugin install failed acme: download failed" },
3272
3581
  ]);
3273
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
+ });
3274
3774
  test("handleSdkMessage treats mirror errors as log-only diagnostics", () => {
3275
3775
  const session = makeSessionState();
3276
3776
  const events = captureBridgeEvents(() => {
@@ -4048,6 +4548,22 @@ test("buildToolResultFields ignores model-facing Bash stale read hints", () => {
4048
4548
  assert.equal(fields.raw_output, "real stdout");
4049
4549
  assert.equal(fields.output_metadata, undefined);
4050
4550
  });
4551
+ test("buildToolResultFields maps PowerShell structured output like shell output", () => {
4552
+ const base = createToolCall("tc-powershell", "PowerShell", { command: "Get-ChildItem" });
4553
+ const fields = buildToolResultFields(false, {
4554
+ stdout: "stdout line",
4555
+ stderr: "stderr line",
4556
+ interrupted: true,
4557
+ }, base, {
4558
+ result: {
4559
+ stdout: "stdout line",
4560
+ stderr: "stderr line",
4561
+ interrupted: true,
4562
+ },
4563
+ });
4564
+ assert.equal(fields.raw_output, "stdout line\nstderr line\nCommand was aborted before completion.");
4565
+ assert.equal(fields.output_metadata, undefined);
4566
+ });
4051
4567
  test("buildToolResultFields adds Bash auto-backgrounded metadata and message", () => {
4052
4568
  const base = createToolCall("tc-bash-bg", "Bash", { command: "npm run watch" });
4053
4569
  const fields = buildToolResultFields(false, {
@@ -4157,6 +4673,133 @@ test("buildToolResultFields marks ReadMcpResource error output as failed", () =>
4157
4673
  },
4158
4674
  ]);
4159
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
+ });
4160
4803
  test("buildToolResultFields preserves WebFetch artifactRead only as metadata", () => {
4161
4804
  const base = createToolCall("tc-web-fetch-artifact", "WebFetch", {
4162
4805
  url: "https://artifact.local/dashboard",
@@ -4377,7 +5020,7 @@ test("looksLikeAuthRequired detects login hints", () => {
4377
5020
  assert.equal(looksLikeAuthRequired("normal tool output"), false);
4378
5021
  });
4379
5022
  test("agent sdk version compatibility check matches pinned version", () => {
4380
- assert.equal(resolveInstalledAgentSdkVersion(), "0.3.177");
5023
+ assert.equal(resolveInstalledAgentSdkVersion(), "0.3.193");
4381
5024
  assert.equal(agentSdkVersionCompatibilityError(), undefined);
4382
5025
  });
4383
5026
  test("mapSessionMessagesToUpdates maps message content blocks", () => {
@@ -4891,13 +5534,15 @@ test("mapSdkSessions normalizes and sorts sessions", () => {
4891
5534
  },
4892
5535
  ]);
4893
5536
  });
4894
- test("buildSessionListOptions scopes repo-local listings to worktrees", () => {
5537
+ test("buildSessionListOptions includes SDK-created sessions for resume listings", () => {
4895
5538
  assert.deepEqual(buildSessionListOptions("C:/repo"), {
4896
5539
  dir: "C:/repo",
5540
+ includeProgrammatic: true,
4897
5541
  includeWorktrees: true,
4898
5542
  limit: 50,
4899
5543
  });
4900
5544
  assert.deepEqual(buildSessionListOptions(undefined), {
5545
+ includeProgrammatic: true,
4901
5546
  limit: 50,
4902
5547
  });
4903
5548
  });