claude-code-rust 0.10.0 → 0.11.1

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,8 +1,11 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { AsyncQueue, CACHE_SPLIT_POLICY, buildRateLimitUpdate, buildQueryOptions, canGenerateSessionTitle, generatePersistedSessionTitle, buildSessionMutationOptions, buildSessionListOptions, buildToolResultFields, createToolCall, handleTaskSystemMessage, mapAvailableAgents, mapAvailableModels, mapSessionMessagesToUpdates, mapSdkSessions, agentSdkVersionCompatibilityError, looksLikeAuthRequired, normalizeToolResultText, parseFastModeState, parseRateLimitStatus, normalizeToolKind, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, staleMcpAuthCandidates, resolveInstalledAgentSdkVersion, unwrapToolUseResult, } from "./bridge.js";
3
+ import { AsyncQueue, CACHE_SPLIT_POLICY, buildApiRetryUpdate, buildRateLimitUpdate, buildQueryOptions, canGenerateSessionTitle, generatePersistedSessionTitle, buildSessionMutationOptions, buildSessionListOptions, buildToolResultFields, createToolCall, handleTaskSystemMessage, handleSdkMessage, mapAvailableAgents, mapAvailableModels, mapSessionMessagesToUpdates, mapSdkSessions, agentSdkVersionCompatibilityError, attachRequestUserDialogInterceptor, looksLikeAuthRequired, normalizeToolResultText, parseFastModeState, parseRuntimeSessionState, parseRateLimitStatus, normalizeSettingsParseError, normalizeToolKind, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, staleMcpAuthCandidates, resolveInstalledAgentSdkVersion, unwrapToolUseResult, } from "./bridge.js";
4
+ import { availableModesForSession, buildModeState, markModeUnavailableForSession, permissionModeFailureLooksUnsupported, refreshSupportedModesForSession, } from "./bridge/commands.js";
5
+ import { emitCurrentModelUpdate, refreshCurrentModel, resolveCurrentModel, shouldInvalidateResolvedRuntimeModel, shouldEmitStartupAuthRequiredForAccount, } from "./bridge/session_lifecycle.js";
4
6
  import { emitToolProgressUpdate } from "./bridge/tool_calls.js";
5
7
  import { requestAskUserQuestionAnswers } from "./bridge/user_interaction.js";
8
+ import { handleResultMessage } from "./bridge/message_handlers.js";
6
9
  function makeSessionState() {
7
10
  const input = new AsyncQueue();
8
11
  return {
@@ -11,6 +14,9 @@ function makeSessionState() {
11
14
  model: "haiku",
12
15
  availableModels: [],
13
16
  mode: null,
17
+ supportedModeIds: [],
18
+ runtimeUnavailableModeIds: [],
19
+ supportsBypassPermissionsMode: false,
14
20
  fastModeState: "off",
15
21
  query: {},
16
22
  input,
@@ -25,6 +31,80 @@ function makeSessionState() {
25
31
  authHintSent: false,
26
32
  };
27
33
  }
34
+ test("availableModesForSession omits conditional modes when unsupported", () => {
35
+ const session = makeSessionState();
36
+ refreshSupportedModesForSession(session);
37
+ assert.deepEqual(availableModesForSession(session).map((entry) => entry.id), ["default", "acceptEdits", "plan", "dontAsk"]);
38
+ });
39
+ test("buildModeState includes auto and bypassPermissions when supported", () => {
40
+ const session = makeSessionState();
41
+ session.mode = "default";
42
+ session.model = "sonnet";
43
+ session.supportsBypassPermissionsMode = true;
44
+ session.availableModels = [
45
+ {
46
+ id: "sonnet",
47
+ display_name: "Sonnet",
48
+ supports_effort: true,
49
+ supported_effort_levels: ["low", "medium", "high"],
50
+ supports_auto_mode: true,
51
+ },
52
+ ];
53
+ refreshSupportedModesForSession(session);
54
+ const mode = buildModeState(session, "default");
55
+ assert.deepEqual(mode.available_modes.map((entry) => entry.id), ["default", "auto", "acceptEdits", "plan", "dontAsk", "bypassPermissions"]);
56
+ });
57
+ test("refreshSupportedModesForSession uses resolved current model for auto-mode eligibility", () => {
58
+ const session = makeSessionState();
59
+ session.model = "sonnet";
60
+ session.availableModels = [
61
+ {
62
+ id: "sonnet",
63
+ display_name: "Claude Sonnet",
64
+ supports_effort: true,
65
+ supported_effort_levels: ["low", "medium", "high"],
66
+ supports_auto_mode: true,
67
+ },
68
+ ];
69
+ session.currentModel = {
70
+ resolved_id: "claude-sonnet-4-6[1m]",
71
+ display_name_short: "Sonnet [1M]",
72
+ display_name_long: "Sonnet 4.6 [1M]",
73
+ supports_effort: true,
74
+ supported_effort_levels: ["low", "medium", "high"],
75
+ supports_auto_mode: false,
76
+ is_authoritative: true,
77
+ };
78
+ refreshSupportedModesForSession(session);
79
+ assert.deepEqual(availableModesForSession(session).map((entry) => entry.id), ["default", "acceptEdits", "plan", "dontAsk"]);
80
+ });
81
+ test("refreshSupportedModesForSession retains current mode before capability data arrives", () => {
82
+ const session = makeSessionState();
83
+ session.mode = "auto";
84
+ refreshSupportedModesForSession(session);
85
+ assert.deepEqual(session.supportedModeIds, ["default", "auto", "acceptEdits", "plan", "dontAsk"]);
86
+ });
87
+ test("markModeUnavailableForSession prunes rejected runtime mode from session list", () => {
88
+ const session = makeSessionState();
89
+ session.model = "sonnet";
90
+ session.availableModels = [
91
+ {
92
+ id: "sonnet",
93
+ display_name: "Sonnet",
94
+ supports_effort: true,
95
+ supported_effort_levels: ["low", "medium", "high"],
96
+ supports_auto_mode: true,
97
+ },
98
+ ];
99
+ refreshSupportedModesForSession(session);
100
+ assert.equal(markModeUnavailableForSession(session, "auto"), true);
101
+ assert.deepEqual(availableModesForSession(session).map((entry) => entry.id), ["default", "acceptEdits", "plan", "dontAsk"]);
102
+ });
103
+ test("permissionModeFailureLooksUnsupported detects SDK capability rejections", () => {
104
+ assert.equal(permissionModeFailureLooksUnsupported("auto", "Cannot set permission mode to auto: not available in my plan"), true);
105
+ assert.equal(permissionModeFailureLooksUnsupported("bypassPermissions", "Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions"), true);
106
+ assert.equal(permissionModeFailureLooksUnsupported("auto", "bridge disconnected before request completed"), false);
107
+ });
28
108
  function captureBridgeEvents(run) {
29
109
  const writes = [];
30
110
  const originalWrite = process.stdout.write;
@@ -221,6 +301,30 @@ test("parseCommandEnvelope validates mcp_set_servers command", () => {
221
301
  },
222
302
  });
223
303
  });
304
+ test("parseCommandEnvelope validates reload_plugins command", () => {
305
+ const parsed = parseCommandEnvelope(JSON.stringify({
306
+ request_id: "req-reload",
307
+ command: "reload_plugins",
308
+ session_id: "session-123",
309
+ }));
310
+ assert.equal(parsed.requestId, "req-reload");
311
+ assert.deepEqual(parsed.command, {
312
+ command: "reload_plugins",
313
+ session_id: "session-123",
314
+ });
315
+ });
316
+ test("parseCommandEnvelope validates get_context_usage command", () => {
317
+ const parsed = parseCommandEnvelope(JSON.stringify({
318
+ request_id: "req-usage",
319
+ command: "get_context_usage",
320
+ session_id: "session-123",
321
+ }));
322
+ assert.equal(parsed.requestId, "req-usage");
323
+ assert.deepEqual(parsed.command, {
324
+ command: "get_context_usage",
325
+ session_id: "session-123",
326
+ });
327
+ });
224
328
  test("staleMcpAuthCandidates selects previously connected servers that regressed to needs-auth", () => {
225
329
  const candidates = staleMcpAuthCandidates([
226
330
  {
@@ -330,6 +434,7 @@ test("buildQueryOptions maps launch settings into sdk query options", () => {
330
434
  assert.equal("thinking" in options, false);
331
435
  assert.equal("effort" in options, false);
332
436
  assert.equal(options.agentProgressSummaries, true);
437
+ assert.equal(options.promptSuggestions, true);
333
438
  assert.equal(options.sessionId, "session-1");
334
439
  assert.deepEqual(options.settingSources, ["user", "project", "local"]);
335
440
  assert.deepEqual(options.toolConfig, {
@@ -393,6 +498,25 @@ test("buildQueryOptions trims startup model before passing sdk option", () => {
393
498
  assert.equal(options.model, "claude-opus-4-6");
394
499
  assert.equal(options.permissionMode, "plan");
395
500
  });
501
+ test("buildQueryOptions maps auto startup permission mode", () => {
502
+ const input = new AsyncQueue();
503
+ const options = buildQueryOptions({
504
+ cwd: "C:/work",
505
+ launchSettings: {
506
+ settings: {
507
+ permissions: { defaultMode: "auto" },
508
+ },
509
+ },
510
+ provisionalSessionId: "session-auto",
511
+ input,
512
+ canUseTool: async () => ({ behavior: "deny", message: "not used" }),
513
+ enableSdkDebug: false,
514
+ enableSpawnDebug: false,
515
+ sessionIdForLogs: () => "session-auto",
516
+ });
517
+ assert.equal(options.permissionMode, "auto");
518
+ assert.equal("allowDangerouslySkipPermissions" in options, false);
519
+ });
396
520
  test("buildQueryOptions enables dangerous skip flag for bypass permissions startup mode", () => {
397
521
  const input = new AsyncQueue();
398
522
  const options = buildQueryOptions({
@@ -430,6 +554,57 @@ test("buildQueryOptions omits startup overrides for default logout path", () =>
430
554
  assert.equal("systemPrompt" in options, false);
431
555
  assert.equal("agentProgressSummaries" in options, false);
432
556
  });
557
+ test("buildQueryOptions makes sandbox fallback explicit when enabled", () => {
558
+ const input = new AsyncQueue();
559
+ const options = buildQueryOptions({
560
+ cwd: "C:/work",
561
+ launchSettings: {
562
+ settings: {
563
+ sandbox: {
564
+ enabled: true,
565
+ },
566
+ },
567
+ },
568
+ provisionalSessionId: "session-sandbox",
569
+ input,
570
+ canUseTool: async () => ({ behavior: "deny", message: "not used" }),
571
+ enableSdkDebug: false,
572
+ enableSpawnDebug: false,
573
+ sessionIdForLogs: () => "session-sandbox",
574
+ });
575
+ assert.deepEqual(options.settings, {
576
+ sandbox: {
577
+ enabled: true,
578
+ failIfUnavailable: false,
579
+ },
580
+ });
581
+ });
582
+ test("buildQueryOptions preserves explicit sandbox failIfUnavailable setting", () => {
583
+ const input = new AsyncQueue();
584
+ const options = buildQueryOptions({
585
+ cwd: "C:/work",
586
+ launchSettings: {
587
+ settings: {
588
+ sandbox: {
589
+ enabled: true,
590
+ failIfUnavailable: true,
591
+ },
592
+ },
593
+ },
594
+ provisionalSessionId: "session-sandbox-explicit",
595
+ input,
596
+ canUseTool: async () => ({ behavior: "deny", message: "not used" }),
597
+ enableSdkDebug: false,
598
+ enableSpawnDebug: false,
599
+ sessionIdForLogs: () => "session-sandbox-explicit",
600
+ });
601
+ assert.deepEqual(options.settings, {
602
+ sandbox: {
603
+ enabled: true,
604
+ failIfUnavailable: true,
605
+ },
606
+ });
607
+ });
433
608
  test("handleTaskSystemMessage prefers task_progress summary over fallback text", () => {
434
609
  const session = makeSessionState();
435
610
  const events = captureBridgeEvents(() => {
@@ -539,6 +714,131 @@ test("handleTaskSystemMessage final summary replaces prior task content and fina
539
714
  });
540
715
  assert.equal(session.taskToolUseIds.has("task-1"), false);
541
716
  });
717
+ test("handleTaskSystemMessage applies task_updated description patches to the linked task", () => {
718
+ const session = makeSessionState();
719
+ const events = captureBridgeEvents(() => {
720
+ handleTaskSystemMessage(session, "task_started", {
721
+ task_id: "task-1",
722
+ tool_use_id: "tool-1",
723
+ description: "Initial task description",
724
+ });
725
+ handleTaskSystemMessage(session, "task_updated", {
726
+ task_id: "task-1",
727
+ patch: {
728
+ status: "running",
729
+ description: "Refining the migration plan",
730
+ is_backgrounded: true,
731
+ },
732
+ });
733
+ });
734
+ const lastEvent = events.at(-1);
735
+ assert.ok(lastEvent);
736
+ assert.equal(lastEvent.event, "session_update");
737
+ assert.deepEqual(lastEvent.update, {
738
+ type: "tool_call_update",
739
+ tool_call_update: {
740
+ tool_call_id: "tool-1",
741
+ fields: {
742
+ status: "in_progress",
743
+ raw_output: "Refining the migration plan",
744
+ content: [
745
+ {
746
+ type: "content",
747
+ content: { type: "text", text: "Refining the migration plan" },
748
+ },
749
+ ],
750
+ task_metadata: {
751
+ is_backgrounded: true,
752
+ },
753
+ },
754
+ },
755
+ });
756
+ });
757
+ test("handleTaskSystemMessage uses task_updated terminal error text when description is absent", () => {
758
+ const session = makeSessionState();
759
+ const events = captureBridgeEvents(() => {
760
+ handleTaskSystemMessage(session, "task_started", {
761
+ task_id: "task-1",
762
+ tool_use_id: "tool-1",
763
+ description: "Initial task description",
764
+ });
765
+ handleTaskSystemMessage(session, "task_updated", {
766
+ task_id: "task-1",
767
+ patch: {
768
+ status: "killed",
769
+ error: "Task stopped by parent agent",
770
+ end_time: 1234,
771
+ total_paused_ms: 250,
772
+ },
773
+ });
774
+ });
775
+ const lastEvent = events.at(-1);
776
+ assert.ok(lastEvent);
777
+ assert.equal(lastEvent.event, "session_update");
778
+ assert.deepEqual(lastEvent.update, {
779
+ type: "tool_call_update",
780
+ tool_call_update: {
781
+ tool_call_id: "tool-1",
782
+ fields: {
783
+ status: "killed",
784
+ raw_output: "Task stopped by parent agent",
785
+ content: [
786
+ {
787
+ type: "content",
788
+ content: { type: "text", text: "Task stopped by parent agent" },
789
+ },
790
+ ],
791
+ task_metadata: {
792
+ error: "Task stopped by parent agent",
793
+ end_time: 1234,
794
+ total_paused_ms: 250,
795
+ },
796
+ },
797
+ },
798
+ });
799
+ });
800
+ test("handleTaskSystemMessage merges task metadata patches into the linked task state", () => {
801
+ const session = makeSessionState();
802
+ captureBridgeEvents(() => {
803
+ handleTaskSystemMessage(session, "task_started", {
804
+ task_id: "task-1",
805
+ tool_use_id: "tool-1",
806
+ description: "Initial task description",
807
+ });
808
+ handleTaskSystemMessage(session, "task_updated", {
809
+ task_id: "task-1",
810
+ patch: {
811
+ status: "running",
812
+ is_backgrounded: true,
813
+ },
814
+ });
815
+ handleTaskSystemMessage(session, "task_updated", {
816
+ task_id: "task-1",
817
+ patch: {
818
+ error: "Task stopped by parent agent",
819
+ end_time: 1234,
820
+ },
821
+ });
822
+ });
823
+ assert.deepEqual(session.toolCalls.get("tool-1")?.task_metadata, {
824
+ is_backgrounded: true,
825
+ error: "Task stopped by parent agent",
826
+ end_time: 1234,
827
+ });
828
+ });
829
+ test("handleTaskSystemMessage skips unlinked task_updated messages", () => {
830
+ const session = makeSessionState();
831
+ const events = captureBridgeEvents(() => {
832
+ handleTaskSystemMessage(session, "task_updated", {
833
+ task_id: "task-missing",
834
+ patch: {
835
+ status: "running",
836
+ description: "This should not be emitted",
837
+ },
838
+ });
839
+ });
840
+ assert.equal(events.length, 0);
841
+ });
542
842
  test("emitToolProgressUpdate does not reopen completed tools", () => {
543
843
  const session = makeSessionState();
544
844
  session.toolCalls.set("tool-1", {
@@ -548,7 +848,7 @@ test("emitToolProgressUpdate does not reopen completed tools", () => {
548
848
  status: "completed",
549
849
  content: [],
550
850
  locations: [],
551
- meta: { claudeCode: { toolName: "Bash" } },
851
+ meta: { claudeCode: { toolName: "Bash", parentToolUseId: null } },
552
852
  });
553
853
  const events = captureBridgeEvents(() => {
554
854
  emitToolProgressUpdate(session, "tool-1", "Bash");
@@ -618,7 +918,7 @@ test("requestAskUserQuestionAnswers preserves previews and annotations in update
618
918
  status: "in_progress",
619
919
  content: [],
620
920
  locations: [],
621
- meta: { claudeCode: { toolName: "AskUserQuestion" } },
921
+ meta: { claudeCode: { toolName: "AskUserQuestion", parentToolUseId: null } },
622
922
  };
623
923
  const events = await captureBridgeEventsAsync(async () => {
624
924
  const resultPromise = requestAskUserQuestionAnswers(session, "tool-question", {
@@ -698,7 +998,7 @@ test("requestAskUserQuestionAnswers preserves previews and annotations in update
698
998
  status: "in_progress",
699
999
  content: [],
700
1000
  locations: [],
701
- meta: { claudeCode: { toolName: "AskUserQuestion" } },
1001
+ meta: { claudeCode: { toolName: "AskUserQuestion", parentToolUseId: null } },
702
1002
  raw_input: {
703
1003
  prompt: {
704
1004
  question: "Pick deployment target",
@@ -769,6 +1069,13 @@ test("parseRateLimitStatus accepts known values and rejects unknown values", ()
769
1069
  assert.equal(parseRateLimitStatus("warn"), null);
770
1070
  assert.equal(parseRateLimitStatus(undefined), null);
771
1071
  });
1072
+ test("parseRuntimeSessionState accepts known values and rejects unknown values", () => {
1073
+ assert.equal(parseRuntimeSessionState("idle"), "idle");
1074
+ assert.equal(parseRuntimeSessionState("running"), "running");
1075
+ assert.equal(parseRuntimeSessionState("requires_action"), "requires_action");
1076
+ assert.equal(parseRuntimeSessionState("blocked"), null);
1077
+ assert.equal(parseRuntimeSessionState(undefined), null);
1078
+ });
772
1079
  test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
773
1080
  const update = buildRateLimitUpdate({
774
1081
  status: "allowed_warning",
@@ -803,6 +1110,135 @@ test("buildRateLimitUpdate rejects invalid payloads", () => {
803
1110
  overageStatus: "bad_status",
804
1111
  }), { type: "rate_limit_update", status: "rejected" });
805
1112
  });
1113
+ test("buildApiRetryUpdate maps SDK api_retry messages to wire shape", () => {
1114
+ assert.deepEqual(buildApiRetryUpdate({
1115
+ attempt: 2,
1116
+ max_retries: 4,
1117
+ retry_delay_ms: 1500,
1118
+ error_status: 529,
1119
+ error: "server_error",
1120
+ }), {
1121
+ type: "api_retry_update",
1122
+ attempt: 2,
1123
+ max_retries: 4,
1124
+ retry_delay_ms: 1500,
1125
+ error_status: 529,
1126
+ error: "server_error",
1127
+ });
1128
+ assert.deepEqual(buildApiRetryUpdate({
1129
+ attempt: 1,
1130
+ maxRetries: 4,
1131
+ retryDelayMs: 1000,
1132
+ errorStatus: null,
1133
+ error: "unexpected",
1134
+ }), {
1135
+ type: "api_retry_update",
1136
+ attempt: 1,
1137
+ max_retries: 4,
1138
+ retry_delay_ms: 1000,
1139
+ error_status: null,
1140
+ error: "unknown",
1141
+ });
1142
+ assert.equal(buildApiRetryUpdate({ attempt: 1 }), null);
1143
+ });
1144
+ test("normalizeSettingsParseError accepts only SDK-shaped errors", () => {
1145
+ assert.deepEqual(normalizeSettingsParseError({
1146
+ file: "C:/work/.claude/settings.json",
1147
+ path: "permissions.allow",
1148
+ message: "Expected array",
1149
+ }), {
1150
+ file: "C:/work/.claude/settings.json",
1151
+ path: "permissions.allow",
1152
+ message: "Expected array",
1153
+ });
1154
+ assert.deepEqual(normalizeSettingsParseError({ path: "", message: "Invalid JSON" }), {
1155
+ path: "",
1156
+ message: "Invalid JSON",
1157
+ });
1158
+ assert.equal(normalizeSettingsParseError({ path: "", message: "" }), null);
1159
+ assert.equal(normalizeSettingsParseError("Invalid JSON"), null);
1160
+ });
1161
+ test("handleSdkMessage emits lifecycle compatibility session updates", () => {
1162
+ const session = makeSessionState();
1163
+ const events = captureBridgeEvents(() => {
1164
+ handleSdkMessage(session, {
1165
+ type: "prompt_suggestion",
1166
+ suggestion: "Write tests for this change",
1167
+ uuid: "message-1",
1168
+ session_id: "session-1",
1169
+ });
1170
+ handleSdkMessage(session, {
1171
+ type: "system",
1172
+ subtype: "api_retry",
1173
+ attempt: 1,
1174
+ max_retries: 4,
1175
+ retry_delay_ms: 1000,
1176
+ error_status: null,
1177
+ error: "server_error",
1178
+ uuid: "message-2",
1179
+ session_id: "session-1",
1180
+ });
1181
+ handleSdkMessage(session, {
1182
+ type: "system",
1183
+ subtype: "session_state_changed",
1184
+ state: "idle",
1185
+ uuid: "message-3",
1186
+ session_id: "session-1",
1187
+ });
1188
+ });
1189
+ assert.deepEqual(events.map((event) => event.update), [
1190
+ { type: "prompt_suggestion_update", suggestion: "Write tests for this change" },
1191
+ {
1192
+ type: "api_retry_update",
1193
+ attempt: 1,
1194
+ max_retries: 4,
1195
+ retry_delay_ms: 1000,
1196
+ error_status: null,
1197
+ error: "server_error",
1198
+ },
1199
+ { type: "runtime_session_state_update", state: "idle" },
1200
+ ]);
1201
+ });
1202
+ test("shouldEmitStartupAuthRequiredForAccount keeps legacy first-party behavior", () => {
1203
+ assert.equal(shouldEmitStartupAuthRequiredForAccount({}), true);
1204
+ assert.equal(shouldEmitStartupAuthRequiredForAccount({ apiProvider: "firstParty" }), true);
1205
+ assert.equal(shouldEmitStartupAuthRequiredForAccount({
1206
+ apiProvider: "firstParty",
1207
+ apiKeySource: "oauth",
1208
+ }), false);
1209
+ assert.equal(shouldEmitStartupAuthRequiredForAccount({
1210
+ apiProvider: "firstParty",
1211
+ email: "user@example.com",
1212
+ }), false);
1213
+ });
1214
+ test("shouldEmitStartupAuthRequiredForAccount skips Claude OAuth hint for external providers", () => {
1215
+ for (const apiProvider of [
1216
+ "bedrock",
1217
+ "vertex",
1218
+ "foundry",
1219
+ "anthropicAws",
1220
+ "mantle",
1221
+ ]) {
1222
+ assert.equal(shouldEmitStartupAuthRequiredForAccount({ apiProvider }), false);
1223
+ }
1224
+ });
1225
+ test("handleSdkMessage emits settings parse errors from defensive payloads", () => {
1226
+ const session = makeSessionState();
1227
+ const events = captureBridgeEvents(() => {
1228
+ handleSdkMessage(session, {
1229
+ type: "settings_parse_error",
1230
+ file: "C:/work/.claude/settings.json",
1231
+ path: "permissions.allow",
1232
+ message: "Expected array",
1233
+ });
1234
+ });
1235
+ assert.deepEqual(events.at(-1)?.update, {
1236
+ type: "settings_parse_error",
1237
+ file: "C:/work/.claude/settings.json",
1238
+ path: "permissions.allow",
1239
+ message: "Expected array",
1240
+ });
1241
+ });
806
1242
  test("mapAvailableAgents normalizes and deduplicates agents", () => {
807
1243
  const agents = mapAvailableAgents([
808
1244
  { name: "reviewer", description: "", model: "" },
@@ -835,7 +1271,13 @@ test("createToolCall builds edit diff content", () => {
835
1271
  old: "old",
836
1272
  new: "new",
837
1273
  });
838
- assert.deepEqual(toolCall.meta, { claudeCode: { toolName: "Edit" } });
1274
+ assert.deepEqual(toolCall.meta, { claudeCode: { toolName: "Edit", parentToolUseId: null } });
1275
+ });
1276
+ test("createToolCall preserves parent tool linkage metadata", () => {
1277
+ const toolCall = createToolCall("tc-child", "Bash", { command: "echo hi" }, "tc-parent");
1278
+ assert.deepEqual(toolCall.meta, {
1279
+ claudeCode: { toolName: "Bash", parentToolUseId: "tc-parent" },
1280
+ });
839
1281
  });
840
1282
  test("createToolCall builds write preview diff content", () => {
841
1283
  const toolCall = createToolCall("tc-w", "Write", {
@@ -984,27 +1426,23 @@ test("buildToolResultFields preserves Edit diff content from input and structure
984
1426
  },
985
1427
  ]);
986
1428
  });
987
- test("buildToolResultFields prefers structured Bash stdout over token-saver output", () => {
1429
+ test("buildToolResultFields ignores model-facing Bash stale read hints", () => {
988
1430
  const base = createToolCall("tc-bash", "Bash", { command: "npm test" });
989
1431
  const fields = buildToolResultFields(false, {
990
1432
  stdout: "real stdout",
991
1433
  stderr: "",
992
1434
  interrupted: false,
993
- tokenSaverOutput: "compressed output for model",
1435
+ staleReadFileStateHint: "src/main.rs changed while command ran",
994
1436
  }, base, {
995
1437
  result: {
996
1438
  stdout: "real stdout",
997
1439
  stderr: "",
998
1440
  interrupted: false,
999
- tokenSaverOutput: "compressed output for model",
1441
+ staleReadFileStateHint: "src/main.rs changed while command ran",
1000
1442
  },
1001
1443
  });
1002
1444
  assert.equal(fields.raw_output, "real stdout");
1003
- assert.deepEqual(fields.output_metadata, {
1004
- bash: {
1005
- token_saver_active: true,
1006
- },
1007
- });
1445
+ assert.equal(fields.output_metadata, undefined);
1008
1446
  });
1009
1447
  test("buildToolResultFields adds Bash auto-backgrounded metadata and message", () => {
1010
1448
  const base = createToolCall("tc-bash-bg", "Bash", { command: "npm run watch" });
@@ -1235,7 +1673,7 @@ test("looksLikeAuthRequired detects login hints", () => {
1235
1673
  assert.equal(looksLikeAuthRequired("normal tool output"), false);
1236
1674
  });
1237
1675
  test("agent sdk version compatibility check matches pinned version", () => {
1238
- assert.equal(resolveInstalledAgentSdkVersion(), "0.2.74");
1676
+ assert.equal(resolveInstalledAgentSdkVersion(), "0.2.104");
1239
1677
  assert.equal(agentSdkVersionCompatibilityError(), undefined);
1240
1678
  });
1241
1679
  test("mapSessionMessagesToUpdates maps message content blocks", () => {
@@ -1297,6 +1735,87 @@ test("mapSessionMessagesToUpdates maps message content blocks", () => {
1297
1735
  assert.equal(variantCounts.get("tool_call"), 1);
1298
1736
  assert.equal(variantCounts.get("tool_call_update"), 1);
1299
1737
  });
1738
+ test("mapSessionMessagesToUpdates preserves parallel tool results", () => {
1739
+ const updates = mapSessionMessagesToUpdates([
1740
+ {
1741
+ type: "assistant",
1742
+ uuid: "a1",
1743
+ session_id: "s1",
1744
+ parent_tool_use_id: null,
1745
+ message: {
1746
+ role: "assistant",
1747
+ content: [
1748
+ { type: "tool_use", id: "tool-a", name: "Bash", input: { command: "echo a" } },
1749
+ { type: "tool_use", id: "tool-b", name: "Bash", input: { command: "echo b" } },
1750
+ ],
1751
+ },
1752
+ },
1753
+ {
1754
+ type: "user",
1755
+ uuid: "u1",
1756
+ session_id: "s1",
1757
+ parent_tool_use_id: null,
1758
+ message: {
1759
+ role: "user",
1760
+ content: [
1761
+ {
1762
+ type: "tool_result",
1763
+ tool_use_id: "tool-b",
1764
+ content: "result b",
1765
+ is_error: false,
1766
+ },
1767
+ {
1768
+ type: "tool_result",
1769
+ tool_use_id: "tool-a",
1770
+ content: "result a",
1771
+ is_error: false,
1772
+ },
1773
+ ],
1774
+ },
1775
+ },
1776
+ ]);
1777
+ const toolCalls = updates.filter((update) => update.type === "tool_call");
1778
+ const toolUpdates = updates.filter((update) => update.type === "tool_call_update");
1779
+ assert.deepEqual(toolCalls.map((update) => update.tool_call.tool_call_id), ["tool-a", "tool-b"]);
1780
+ assert.deepEqual(toolUpdates.map((update) => update.tool_call_update.tool_call_id), ["tool-b", "tool-a"]);
1781
+ assert.deepEqual(toolUpdates.map((update) => update.tool_call_update.fields.raw_output), ["result b", "result a"]);
1782
+ });
1783
+ test("handleResultMessage emits terminal reason on successful turn completion", () => {
1784
+ const session = makeSessionState();
1785
+ const events = captureBridgeEvents(() => {
1786
+ handleResultMessage(session, {
1787
+ type: "result",
1788
+ subtype: "success",
1789
+ terminal_reason: "completed",
1790
+ });
1791
+ });
1792
+ const lastEvent = events.at(-1);
1793
+ assert.deepEqual(lastEvent, {
1794
+ event: "turn_complete",
1795
+ session_id: "session-1",
1796
+ terminal_reason: "completed",
1797
+ });
1798
+ });
1799
+ test("handleResultMessage emits terminal reason on turn errors", () => {
1800
+ const session = makeSessionState();
1801
+ const events = captureBridgeEvents(() => {
1802
+ handleResultMessage(session, {
1803
+ type: "result",
1804
+ subtype: "error_max_turns",
1805
+ terminal_reason: "max_turns",
1806
+ errors: ["max turns exceeded"],
1807
+ });
1808
+ });
1809
+ const lastEvent = events.at(-1);
1810
+ assert.deepEqual(lastEvent, {
1811
+ event: "turn_error",
1812
+ session_id: "session-1",
1813
+ message: "max turns exceeded",
1814
+ error_kind: "plan_limit",
1815
+ sdk_result_subtype: "error_max_turns",
1816
+ terminal_reason: "max_turns",
1817
+ });
1818
+ });
1300
1819
  test("mapSessionMessagesToUpdates ignores unsupported records", () => {
1301
1820
  const updates = mapSessionMessagesToUpdates([
1302
1821
  {
@@ -1360,19 +1879,62 @@ test("buildSessionListOptions scopes repo-local listings to worktrees", () => {
1360
1879
  limit: 50,
1361
1880
  });
1362
1881
  });
1363
- test("buildToolResultFields extracts ExitPlanMode ultraplan metadata from structured results", () => {
1364
- const base = createToolCall("tc-plan", "ExitPlanMode", {});
1365
- const fields = buildToolResultFields(false, [{ text: "Plan ready for approval" }], base, {
1882
+ test("buildToolResultFields renders file_unchanged Read results compactly", () => {
1883
+ const base = createToolCall("tc-read", "Read", { file_path: "src/main.rs" });
1884
+ const fields = buildToolResultFields(false, {
1885
+ type: "file_unchanged",
1886
+ file: { filePath: "src/main.rs" },
1887
+ }, base, {
1366
1888
  result: {
1367
- plan: "Plan contents",
1368
- isUltraplan: true,
1889
+ type: "file_unchanged",
1890
+ file: { filePath: "src/main.rs" },
1369
1891
  },
1370
1892
  });
1371
- assert.deepEqual(fields.output_metadata, {
1372
- exit_plan_mode: {
1373
- is_ultraplan: true,
1374
- },
1893
+ assert.equal(fields.raw_output, "File unchanged: src/main.rs");
1894
+ assert.deepEqual(fields.content, [
1895
+ { type: "content", content: { type: "text", text: "File unchanged: src/main.rs" } },
1896
+ ]);
1897
+ });
1898
+ test("buildToolResultFields renders array-wrapped file_unchanged Read results compactly", () => {
1899
+ const base = createToolCall("tc-read", "Read", { file_path: "src/lib.rs" });
1900
+ const fields = buildToolResultFields(false, [], base, {
1901
+ result: [
1902
+ {
1903
+ type: "file_unchanged",
1904
+ file: { filePath: "src/lib.rs" },
1905
+ },
1906
+ ],
1375
1907
  });
1908
+ assert.equal(fields.raw_output, "File unchanged: src/lib.rs");
1909
+ });
1910
+ test("buildToolResultFields uses Agent output agentType as task title", () => {
1911
+ const base = createToolCall("tc-agent", "Agent", { prompt: "Review tests" });
1912
+ const fields = buildToolResultFields(false, {
1913
+ agentId: "agent-1",
1914
+ agentType: "reviewer",
1915
+ content: [{ type: "text", text: "Done" }],
1916
+ totalToolUseCount: 0,
1917
+ totalDurationMs: 10,
1918
+ totalTokens: 20,
1919
+ usage: {},
1920
+ status: "completed",
1921
+ prompt: "Review tests",
1922
+ }, base);
1923
+ assert.equal(fields.title, "reviewer");
1924
+ });
1925
+ test("buildToolResultFields reads array-wrapped Agent output agentType", () => {
1926
+ const base = createToolCall("tc-agent", "Agent", { prompt: "Review tests" });
1927
+ const fields = buildToolResultFields(false, [], base, {
1928
+ result: [
1929
+ {
1930
+ agentId: "agent-1",
1931
+ agentType: "planner",
1932
+ content: [{ type: "text", text: "Done" }],
1933
+ status: "completed",
1934
+ },
1935
+ ],
1936
+ });
1937
+ assert.equal(fields.title, "planner");
1376
1938
  });
1377
1939
  test("buildToolResultFields extracts TodoWrite verification metadata from structured results", () => {
1378
1940
  const base = createToolCall("tc-todo", "TodoWrite", {
@@ -1430,3 +1992,183 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () =>
1430
1992
  },
1431
1993
  ]);
1432
1994
  });
1995
+ test("resolveCurrentModel keeps 1M context suffix in short and long display names", () => {
1996
+ const session = makeSessionState();
1997
+ session.resolvedRuntimeModelId = "claude-opus-4-6[1m]";
1998
+ const currentModel = resolveCurrentModel(session);
1999
+ assert.equal(currentModel.display_name_short, "Opus [1M]");
2000
+ assert.equal(currentModel.display_name_long, "Opus 4.6 [1M]");
2001
+ });
2002
+ test("resolveCurrentModel does not inherit standard Opus capabilities for 1M when sibling variants exist", () => {
2003
+ const session = makeSessionState();
2004
+ session.requestedModelId = "claude-opus-4-6";
2005
+ session.resolvedRuntimeModelId = "claude-opus-4-6[1m]";
2006
+ session.availableModels = [
2007
+ {
2008
+ id: "claude-opus-4-6",
2009
+ display_name: "Claude Opus",
2010
+ supports_effort: true,
2011
+ supported_effort_levels: ["low", "medium", "high"],
2012
+ },
2013
+ {
2014
+ id: "claude-opus-4-6[1m]",
2015
+ display_name: "Claude Opus 1M",
2016
+ supports_effort: false,
2017
+ supported_effort_levels: [],
2018
+ },
2019
+ ];
2020
+ const currentModel = resolveCurrentModel(session);
2021
+ assert.equal(currentModel.catalog_id, "claude-opus-4-6[1m]");
2022
+ assert.equal(currentModel.supports_effort, false);
2023
+ });
2024
+ test("resolveCurrentModel avoids suffix-insensitive fallback when sibling variants make it ambiguous", () => {
2025
+ const session = makeSessionState();
2026
+ session.requestedModelId = "claude-opus-4-6";
2027
+ session.resolvedRuntimeModelId = "claude-opus-4-6[1m]";
2028
+ session.availableModels = [
2029
+ {
2030
+ id: "claude-opus-4-6",
2031
+ display_name: "Claude Opus",
2032
+ supports_effort: true,
2033
+ supported_effort_levels: ["low", "medium", "high"],
2034
+ },
2035
+ {
2036
+ id: "claude-opus-4-6-alt[1m]",
2037
+ display_name: "Claude Opus Alt 1M",
2038
+ supports_effort: false,
2039
+ supported_effort_levels: [],
2040
+ },
2041
+ ];
2042
+ const currentModel = resolveCurrentModel(session);
2043
+ assert.equal(currentModel.catalog_id, undefined);
2044
+ assert.equal(currentModel.supports_effort, false);
2045
+ });
2046
+ test("emitCurrentModelUpdate can acknowledge a successful no-op set_model", () => {
2047
+ const session = makeSessionState();
2048
+ session.model = "default";
2049
+ session.requestedModelId = "default";
2050
+ session.resolvedRuntimeModelId = "claude-opus-4-6[1m]";
2051
+ refreshCurrentModel(session);
2052
+ const events = captureBridgeEvents(() => {
2053
+ const changed = refreshCurrentModel(session, true);
2054
+ const forced = !changed && emitCurrentModelUpdate(session);
2055
+ assert.equal(changed, false);
2056
+ assert.equal(forced, true);
2057
+ });
2058
+ const lastEvent = events.at(-1);
2059
+ assert.ok(lastEvent);
2060
+ assert.equal(lastEvent.event, "session_update");
2061
+ assert.deepEqual(lastEvent.update, {
2062
+ type: "current_model_update",
2063
+ current_model: {
2064
+ requested_id: "default",
2065
+ resolved_id: "claude-opus-4-6[1m]",
2066
+ display_name_short: "Opus [1M]",
2067
+ display_name_long: "Opus 4.6 [1M]",
2068
+ supports_effort: false,
2069
+ supported_effort_levels: [],
2070
+ is_authoritative: true,
2071
+ },
2072
+ });
2073
+ });
2074
+ test("emitCurrentModelUpdate can publish catalog-enriched current model metadata after connect", () => {
2075
+ const session = makeSessionState();
2076
+ session.model = "sonnet";
2077
+ refreshCurrentModel(session);
2078
+ session.availableModels = [
2079
+ {
2080
+ id: "sonnet",
2081
+ display_name: "Claude Sonnet",
2082
+ supports_effort: true,
2083
+ supported_effort_levels: ["low", "medium", "high"],
2084
+ supports_auto_mode: true,
2085
+ },
2086
+ ];
2087
+ const events = captureBridgeEvents(() => {
2088
+ const changed = refreshCurrentModel(session, false);
2089
+ assert.equal(changed, true);
2090
+ assert.equal(emitCurrentModelUpdate(session), true);
2091
+ });
2092
+ const lastEvent = events.at(-1);
2093
+ assert.ok(lastEvent);
2094
+ assert.equal(lastEvent.event, "session_update");
2095
+ assert.deepEqual(lastEvent.update, {
2096
+ type: "current_model_update",
2097
+ current_model: {
2098
+ resolved_id: "sonnet",
2099
+ display_name_short: "Sonnet",
2100
+ display_name_long: "Sonnet",
2101
+ catalog_id: "sonnet",
2102
+ supports_effort: true,
2103
+ supported_effort_levels: ["low", "medium", "high"],
2104
+ supports_auto_mode: true,
2105
+ is_authoritative: true,
2106
+ },
2107
+ });
2108
+ });
2109
+ test("shouldInvalidateResolvedRuntimeModel invalidates stale runtime identity only when the request changes", () => {
2110
+ assert.equal(shouldInvalidateResolvedRuntimeModel("default", "default", "sonnet"), true);
2111
+ assert.equal(shouldInvalidateResolvedRuntimeModel("sonnet", "sonnet", "haiku"), true);
2112
+ assert.equal(shouldInvalidateResolvedRuntimeModel("default", "default", "default"), false);
2113
+ });
2114
+ test("resolveCurrentModel falls back to the requested model immediately after stale runtime identity is cleared", () => {
2115
+ const session = makeSessionState();
2116
+ session.requestedModelId = "sonnet";
2117
+ session.model = "sonnet";
2118
+ session.availableModels = [
2119
+ {
2120
+ id: "sonnet",
2121
+ display_name: "Claude Sonnet",
2122
+ supports_effort: true,
2123
+ supported_effort_levels: ["low", "medium", "high"],
2124
+ },
2125
+ ];
2126
+ const currentModel = resolveCurrentModel(session);
2127
+ assert.equal(currentModel.resolved_id, "sonnet");
2128
+ assert.equal(currentModel.display_name_short, "Sonnet");
2129
+ assert.equal(currentModel.display_name_long, "Sonnet");
2130
+ assert.equal(currentModel.catalog_id, "sonnet");
2131
+ assert.equal(currentModel.supports_effort, true);
2132
+ });
2133
+ test("attachRequestUserDialogInterceptor rejects request_user_dialog with a stable error", async () => {
2134
+ const calls = [];
2135
+ const fakeQuery = {
2136
+ async processControlRequest(request, _signal) {
2137
+ calls.push(request);
2138
+ return { ok: true };
2139
+ },
2140
+ };
2141
+ assert.equal(attachRequestUserDialogInterceptor(fakeQuery, () => "session-test"), true);
2142
+ await assert.rejects(fakeQuery.processControlRequest({
2143
+ request_id: "dialog-1",
2144
+ request: {
2145
+ subtype: "request_user_dialog",
2146
+ dialog_kind: "computer_use_approval",
2147
+ payload: { title: "Need approval", kind: "computer_use_approval" },
2148
+ tool_use_id: "tool-1",
2149
+ },
2150
+ }, new AbortController().signal), /request_user_dialog is not supported by claude-rs yet \(dialog_kind: computer_use_approval\)/);
2151
+ assert.equal(calls.length, 0);
2152
+ });
2153
+ test("attachRequestUserDialogInterceptor preserves non-dialog control requests", async () => {
2154
+ const calls = [];
2155
+ const fakeQuery = {
2156
+ async processControlRequest(request, _signal) {
2157
+ calls.push(request);
2158
+ return { ok: true };
2159
+ },
2160
+ };
2161
+ attachRequestUserDialogInterceptor(fakeQuery, () => "session-test");
2162
+ const result = await fakeQuery.processControlRequest({
2163
+ request_id: "permission-1",
2164
+ request: {
2165
+ subtype: "can_use_tool",
2166
+ tool_name: "Bash",
2167
+ input: { command: "dir" },
2168
+ tool_use_id: "tool-1",
2169
+ },
2170
+ }, new AbortController().signal);
2171
+ assert.deepEqual(result, { ok: true });
2172
+ assert.equal(calls.length, 1);
2173
+ assert.equal(calls[0]?.request.subtype, "can_use_tool");
2174
+ });