claude-code-rust 0.7.1 → 0.8.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,6 +1,96 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { AsyncQueue, CACHE_SPLIT_POLICY, buildRateLimitUpdate, buildQueryOptions, buildToolResultFields, createToolCall, mapAvailableAgents, mapSessionMessagesToUpdates, mapSdkSessions, agentSdkVersionCompatibilityError, looksLikeAuthRequired, normalizeToolResultText, parseFastModeState, parseRateLimitStatus, normalizeToolKind, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, resolveInstalledAgentSdkVersion, unwrapToolUseResult, } from "./bridge.js";
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";
4
+ import { emitToolProgressUpdate } from "./bridge/tool_calls.js";
5
+ import { requestAskUserQuestionAnswers } from "./bridge/user_interaction.js";
6
+ function makeSessionState() {
7
+ const input = new AsyncQueue();
8
+ return {
9
+ sessionId: "session-1",
10
+ cwd: "C:/work",
11
+ model: "haiku",
12
+ availableModels: [],
13
+ mode: null,
14
+ fastModeState: "off",
15
+ query: {},
16
+ input,
17
+ connected: true,
18
+ connectEvent: "connected",
19
+ toolCalls: new Map(),
20
+ taskToolUseIds: new Map(),
21
+ pendingPermissions: new Map(),
22
+ pendingQuestions: new Map(),
23
+ pendingElicitations: new Map(),
24
+ mcpStatusRevalidatedAt: new Map(),
25
+ authHintSent: false,
26
+ };
27
+ }
28
+ function captureBridgeEvents(run) {
29
+ const writes = [];
30
+ const originalWrite = process.stdout.write;
31
+ process.stdout.write = (chunk) => {
32
+ if (typeof chunk === "string") {
33
+ writes.push(chunk);
34
+ }
35
+ else if (Buffer.isBuffer(chunk)) {
36
+ writes.push(chunk.toString("utf8"));
37
+ }
38
+ else {
39
+ writes.push(String(chunk));
40
+ }
41
+ return true;
42
+ };
43
+ try {
44
+ run();
45
+ }
46
+ finally {
47
+ process.stdout.write = originalWrite;
48
+ }
49
+ return writes
50
+ .map((line) => line.trim())
51
+ .filter((line) => line.startsWith("{"))
52
+ .flatMap((line) => {
53
+ try {
54
+ return [JSON.parse(line)];
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ });
60
+ }
61
+ async function captureBridgeEventsAsync(run) {
62
+ const writes = [];
63
+ const originalWrite = process.stdout.write;
64
+ process.stdout.write = (chunk) => {
65
+ if (typeof chunk === "string") {
66
+ writes.push(chunk);
67
+ }
68
+ else if (Buffer.isBuffer(chunk)) {
69
+ writes.push(chunk.toString("utf8"));
70
+ }
71
+ else {
72
+ writes.push(String(chunk));
73
+ }
74
+ return true;
75
+ };
76
+ try {
77
+ await run();
78
+ }
79
+ finally {
80
+ process.stdout.write = originalWrite;
81
+ }
82
+ return writes
83
+ .map((line) => line.trim())
84
+ .filter((line) => line.startsWith("{"))
85
+ .flatMap((line) => {
86
+ try {
87
+ return [JSON.parse(line)];
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ });
93
+ }
4
94
  test("parseCommandEnvelope validates initialize command", () => {
5
95
  const parsed = parseCommandEnvelope(JSON.stringify({
6
96
  request_id: "req-1",
@@ -20,11 +110,18 @@ test("parseCommandEnvelope validates resume_session command without cwd", () =>
20
110
  command: "resume_session",
21
111
  session_id: "session-123",
22
112
  launch_settings: {
23
- model: "haiku",
24
113
  language: "German",
25
- permission_mode: "plan",
26
- thinking_mode: "adaptive",
27
- effort_level: "high",
114
+ settings: {
115
+ alwaysThinkingEnabled: true,
116
+ model: "haiku",
117
+ permissions: { defaultMode: "plan" },
118
+ fastMode: false,
119
+ effortLevel: "high",
120
+ outputStyle: "Default",
121
+ spinnerTipsEnabled: true,
122
+ terminalProgressBarEnabled: true,
123
+ },
124
+ agent_progress_summaries: true,
28
125
  },
29
126
  }));
30
127
  assert.equal(parsed.requestId, "req-2");
@@ -33,22 +130,176 @@ test("parseCommandEnvelope validates resume_session command without cwd", () =>
33
130
  throw new Error("unexpected command variant");
34
131
  }
35
132
  assert.equal(parsed.command.session_id, "session-123");
36
- assert.equal(parsed.command.launch_settings.model, "haiku");
37
133
  assert.equal(parsed.command.launch_settings.language, "German");
38
- assert.equal(parsed.command.launch_settings.permission_mode, "plan");
39
- assert.equal(parsed.command.launch_settings.thinking_mode, "adaptive");
40
- assert.equal(parsed.command.launch_settings.effort_level, "high");
134
+ assert.deepEqual(parsed.command.launch_settings.settings, {
135
+ alwaysThinkingEnabled: true,
136
+ model: "haiku",
137
+ permissions: { defaultMode: "plan" },
138
+ fastMode: false,
139
+ effortLevel: "high",
140
+ outputStyle: "Default",
141
+ spinnerTipsEnabled: true,
142
+ terminalProgressBarEnabled: true,
143
+ });
144
+ assert.equal(parsed.command.launch_settings.agent_progress_summaries, true);
145
+ });
146
+ test("parseCommandEnvelope validates rename_session command", () => {
147
+ const parsed = parseCommandEnvelope(JSON.stringify({
148
+ request_id: "req-rename",
149
+ command: "rename_session",
150
+ session_id: "session-123",
151
+ title: "Renamed session",
152
+ }));
153
+ assert.equal(parsed.requestId, "req-rename");
154
+ assert.equal(parsed.command.command, "rename_session");
155
+ if (parsed.command.command !== "rename_session") {
156
+ throw new Error("unexpected command variant");
157
+ }
158
+ assert.equal(parsed.command.session_id, "session-123");
159
+ assert.equal(parsed.command.title, "Renamed session");
160
+ });
161
+ test("parseCommandEnvelope validates generate_session_title command", () => {
162
+ const parsed = parseCommandEnvelope(JSON.stringify({
163
+ request_id: "req-generate",
164
+ command: "generate_session_title",
165
+ session_id: "session-123",
166
+ description: "Current custom title",
167
+ }));
168
+ assert.equal(parsed.requestId, "req-generate");
169
+ assert.equal(parsed.command.command, "generate_session_title");
170
+ if (parsed.command.command !== "generate_session_title") {
171
+ throw new Error("unexpected command variant");
172
+ }
173
+ assert.equal(parsed.command.session_id, "session-123");
174
+ assert.equal(parsed.command.description, "Current custom title");
175
+ });
176
+ test("parseCommandEnvelope validates mcp_toggle command", () => {
177
+ const parsed = parseCommandEnvelope(JSON.stringify({
178
+ request_id: "req-mcp-toggle",
179
+ command: "mcp_toggle",
180
+ session_id: "session-123",
181
+ server_name: "notion",
182
+ enabled: false,
183
+ }));
184
+ assert.equal(parsed.requestId, "req-mcp-toggle");
185
+ assert.equal(parsed.command.command, "mcp_toggle");
186
+ if (parsed.command.command !== "mcp_toggle") {
187
+ throw new Error("unexpected command variant");
188
+ }
189
+ assert.equal(parsed.command.session_id, "session-123");
190
+ assert.equal(parsed.command.server_name, "notion");
191
+ assert.equal(parsed.command.enabled, false);
192
+ });
193
+ test("parseCommandEnvelope validates mcp_set_servers command", () => {
194
+ const parsed = parseCommandEnvelope(JSON.stringify({
195
+ request_id: "req-mcp-set",
196
+ command: "mcp_set_servers",
197
+ session_id: "session-123",
198
+ servers: {
199
+ notion: {
200
+ type: "http",
201
+ url: "https://mcp.notion.com/mcp",
202
+ headers: {
203
+ "X-Test": "1",
204
+ },
205
+ },
206
+ },
207
+ }));
208
+ assert.equal(parsed.requestId, "req-mcp-set");
209
+ assert.equal(parsed.command.command, "mcp_set_servers");
210
+ if (parsed.command.command !== "mcp_set_servers") {
211
+ throw new Error("unexpected command variant");
212
+ }
213
+ assert.equal(parsed.command.session_id, "session-123");
214
+ assert.deepEqual(parsed.command.servers, {
215
+ notion: {
216
+ type: "http",
217
+ url: "https://mcp.notion.com/mcp",
218
+ headers: {
219
+ "X-Test": "1",
220
+ },
221
+ },
222
+ });
223
+ });
224
+ test("staleMcpAuthCandidates selects previously connected servers that regressed to needs-auth", () => {
225
+ const candidates = staleMcpAuthCandidates([
226
+ {
227
+ name: "supabase",
228
+ status: "needs-auth",
229
+ server_info: undefined,
230
+ error: undefined,
231
+ config: undefined,
232
+ scope: undefined,
233
+ tools: [],
234
+ },
235
+ {
236
+ name: "notion",
237
+ status: "needs-auth",
238
+ server_info: undefined,
239
+ error: undefined,
240
+ config: undefined,
241
+ scope: undefined,
242
+ tools: [],
243
+ },
244
+ ], new Set(["supabase"]), new Map(), 10_000, 1_000);
245
+ assert.deepEqual(candidates, ["supabase"]);
246
+ });
247
+ test("staleMcpAuthCandidates respects the revalidation cooldown", () => {
248
+ const candidates = staleMcpAuthCandidates([
249
+ {
250
+ name: "supabase",
251
+ status: "needs-auth",
252
+ server_info: undefined,
253
+ error: undefined,
254
+ config: undefined,
255
+ scope: undefined,
256
+ tools: [],
257
+ },
258
+ ], new Set(["supabase"]), new Map([["supabase", 9_500]]), 10_000, 1_000);
259
+ assert.deepEqual(candidates, []);
260
+ });
261
+ test("buildSessionMutationOptions scopes rename requests to the session cwd", () => {
262
+ assert.deepEqual(buildSessionMutationOptions("C:/worktree"), { dir: "C:/worktree" });
263
+ assert.equal(buildSessionMutationOptions(undefined), undefined);
264
+ });
265
+ test("canGenerateSessionTitle detects supported query objects", () => {
266
+ const query = {
267
+ async generateSessionTitle() {
268
+ return "Generated";
269
+ },
270
+ };
271
+ assert.equal(canGenerateSessionTitle(query), true);
272
+ assert.equal(canGenerateSessionTitle({}), false);
273
+ });
274
+ test("generatePersistedSessionTitle calls sdk query with persist true", async () => {
275
+ const calls = [];
276
+ const query = {
277
+ async generateSessionTitle(description, options) {
278
+ calls.push({ description, persist: options?.persist });
279
+ return "Generated title";
280
+ },
281
+ };
282
+ const title = await generatePersistedSessionTitle(query, "Current summary");
283
+ assert.equal(title, "Generated title");
284
+ assert.deepEqual(calls, [{ description: "Current summary", persist: true }]);
41
285
  });
42
286
  test("buildQueryOptions maps launch settings into sdk query options", () => {
43
287
  const input = new AsyncQueue();
44
288
  const options = buildQueryOptions({
45
289
  cwd: "C:/work",
46
290
  launchSettings: {
47
- model: "haiku",
48
291
  language: "German",
49
- permission_mode: "plan",
50
- thinking_mode: "adaptive",
51
- effort_level: "medium",
292
+ settings: {
293
+ alwaysThinkingEnabled: true,
294
+ model: "haiku",
295
+ permissions: { defaultMode: "plan" },
296
+ fastMode: false,
297
+ effortLevel: "medium",
298
+ outputStyle: "Default",
299
+ spinnerTipsEnabled: true,
300
+ terminalProgressBarEnabled: true,
301
+ },
302
+ agent_progress_summaries: true,
52
303
  },
53
304
  provisionalSessionId: "session-1",
54
305
  input,
@@ -57,26 +308,48 @@ test("buildQueryOptions maps launch settings into sdk query options", () => {
57
308
  enableSpawnDebug: false,
58
309
  sessionIdForLogs: () => "session-1",
59
310
  });
60
- assert.equal(options.model, "haiku");
311
+ assert.deepEqual(options.settings, {
312
+ alwaysThinkingEnabled: true,
313
+ model: "haiku",
314
+ permissions: { defaultMode: "plan" },
315
+ fastMode: false,
316
+ effortLevel: "medium",
317
+ outputStyle: "Default",
318
+ spinnerTipsEnabled: true,
319
+ terminalProgressBarEnabled: true,
320
+ });
61
321
  assert.deepEqual(options.systemPrompt, {
62
322
  type: "preset",
63
323
  preset: "claude_code",
64
324
  append: "Always respond to the user in German unless the user explicitly asks for a different language. " +
65
325
  "Keep code, shell commands, file paths, API names, tool names, and raw error text unchanged unless the user explicitly asks for translation.",
66
326
  });
327
+ assert.equal(options.model, "haiku");
67
328
  assert.equal(options.permissionMode, "plan");
68
- assert.deepEqual(options.thinking, { type: "adaptive" });
69
- assert.equal(options.effort, "medium");
329
+ assert.equal("allowDangerouslySkipPermissions" in options, false);
330
+ assert.equal("thinking" in options, false);
331
+ assert.equal("effort" in options, false);
332
+ assert.equal(options.agentProgressSummaries, true);
70
333
  assert.equal(options.sessionId, "session-1");
71
334
  assert.deepEqual(options.settingSources, ["user", "project", "local"]);
335
+ assert.deepEqual(options.toolConfig, {
336
+ askUserQuestion: { previewFormat: "markdown" },
337
+ });
72
338
  });
73
- test("buildQueryOptions maps disabled thinking mode into sdk query options", () => {
339
+ test("buildQueryOptions forwards settings and maps startup model and permission mode", () => {
74
340
  const input = new AsyncQueue();
75
341
  const options = buildQueryOptions({
76
342
  cwd: "C:/work",
77
343
  launchSettings: {
78
- thinking_mode: "disabled",
79
- effort_level: "high",
344
+ settings: {
345
+ alwaysThinkingEnabled: false,
346
+ permissions: { defaultMode: "default" },
347
+ fastMode: true,
348
+ effortLevel: "high",
349
+ outputStyle: "Learning",
350
+ spinnerTipsEnabled: false,
351
+ terminalProgressBarEnabled: false,
352
+ },
80
353
  },
81
354
  provisionalSessionId: "session-3",
82
355
  input,
@@ -85,9 +358,60 @@ test("buildQueryOptions maps disabled thinking mode into sdk query options", ()
85
358
  enableSpawnDebug: false,
86
359
  sessionIdForLogs: () => "session-3",
87
360
  });
88
- assert.deepEqual(options.thinking, { type: "disabled" });
361
+ assert.deepEqual(options.settings, {
362
+ alwaysThinkingEnabled: false,
363
+ permissions: { defaultMode: "default" },
364
+ fastMode: true,
365
+ effortLevel: "high",
366
+ outputStyle: "Learning",
367
+ spinnerTipsEnabled: false,
368
+ terminalProgressBarEnabled: false,
369
+ });
370
+ assert.equal("model" in options, false);
371
+ assert.equal(options.permissionMode, "default");
372
+ assert.equal("allowDangerouslySkipPermissions" in options, false);
373
+ assert.equal("thinking" in options, false);
89
374
  assert.equal("effort" in options, false);
90
375
  });
376
+ test("buildQueryOptions trims startup model before passing sdk option", () => {
377
+ const input = new AsyncQueue();
378
+ const options = buildQueryOptions({
379
+ cwd: "C:/work",
380
+ launchSettings: {
381
+ settings: {
382
+ model: " claude-opus-4-6 ",
383
+ permissions: { defaultMode: "plan" },
384
+ },
385
+ },
386
+ provisionalSessionId: "session-model",
387
+ input,
388
+ canUseTool: async () => ({ behavior: "deny", message: "not used" }),
389
+ enableSdkDebug: false,
390
+ enableSpawnDebug: false,
391
+ sessionIdForLogs: () => "session-model",
392
+ });
393
+ assert.equal(options.model, "claude-opus-4-6");
394
+ assert.equal(options.permissionMode, "plan");
395
+ });
396
+ test("buildQueryOptions enables dangerous skip flag for bypass permissions startup mode", () => {
397
+ const input = new AsyncQueue();
398
+ const options = buildQueryOptions({
399
+ cwd: "C:/work",
400
+ launchSettings: {
401
+ settings: {
402
+ permissions: { defaultMode: "bypassPermissions" },
403
+ },
404
+ },
405
+ provisionalSessionId: "session-4",
406
+ input,
407
+ canUseTool: async () => ({ behavior: "deny", message: "not used" }),
408
+ enableSdkDebug: false,
409
+ enableSpawnDebug: false,
410
+ sessionIdForLogs: () => "session-4",
411
+ });
412
+ assert.equal(options.permissionMode, "bypassPermissions");
413
+ assert.equal(options.allowDangerouslySkipPermissions, true);
414
+ });
91
415
  test("buildQueryOptions omits startup overrides for default logout path", () => {
92
416
  const input = new AsyncQueue();
93
417
  const options = buildQueryOptions({
@@ -102,7 +426,135 @@ test("buildQueryOptions omits startup overrides for default logout path", () =>
102
426
  });
103
427
  assert.equal("model" in options, false);
104
428
  assert.equal("permissionMode" in options, false);
429
+ assert.equal("allowDangerouslySkipPermissions" in options, false);
105
430
  assert.equal("systemPrompt" in options, false);
431
+ assert.equal("agentProgressSummaries" in options, false);
432
+ });
433
+ test("handleTaskSystemMessage prefers task_progress summary over fallback text", () => {
434
+ const session = makeSessionState();
435
+ const events = captureBridgeEvents(() => {
436
+ handleTaskSystemMessage(session, "task_started", {
437
+ task_id: "task-1",
438
+ tool_use_id: "tool-1",
439
+ description: "Initial task description",
440
+ });
441
+ handleTaskSystemMessage(session, "task_progress", {
442
+ task_id: "task-1",
443
+ summary: "Analyzing authentication flow",
444
+ description: "Should not be shown",
445
+ last_tool_name: "Read",
446
+ });
447
+ });
448
+ const lastEvent = events.at(-1);
449
+ assert.ok(lastEvent);
450
+ assert.equal(lastEvent.event, "session_update");
451
+ assert.deepEqual(lastEvent.update, {
452
+ type: "tool_call_update",
453
+ tool_call_update: {
454
+ tool_call_id: "tool-1",
455
+ fields: {
456
+ status: "in_progress",
457
+ raw_output: "Analyzing authentication flow",
458
+ content: [
459
+ {
460
+ type: "content",
461
+ content: { type: "text", text: "Analyzing authentication flow" },
462
+ },
463
+ ],
464
+ },
465
+ },
466
+ });
467
+ });
468
+ test("handleTaskSystemMessage falls back to description and last tool when progress summary is absent", () => {
469
+ const session = makeSessionState();
470
+ const events = captureBridgeEvents(() => {
471
+ handleTaskSystemMessage(session, "task_started", {
472
+ task_id: "task-1",
473
+ tool_use_id: "tool-1",
474
+ description: "Initial task description",
475
+ });
476
+ handleTaskSystemMessage(session, "task_progress", {
477
+ task_id: "task-1",
478
+ description: "Inspecting auth code",
479
+ last_tool_name: "Read",
480
+ });
481
+ });
482
+ const lastEvent = events.at(-1);
483
+ assert.ok(lastEvent);
484
+ assert.equal(lastEvent.event, "session_update");
485
+ assert.deepEqual(lastEvent.update, {
486
+ type: "tool_call_update",
487
+ tool_call_update: {
488
+ tool_call_id: "tool-1",
489
+ fields: {
490
+ status: "in_progress",
491
+ raw_output: "Inspecting auth code (last tool: Read)",
492
+ content: [
493
+ {
494
+ type: "content",
495
+ content: { type: "text", text: "Inspecting auth code (last tool: Read)" },
496
+ },
497
+ ],
498
+ },
499
+ },
500
+ });
501
+ });
502
+ test("handleTaskSystemMessage final summary replaces prior task content and finalizes status", () => {
503
+ const session = makeSessionState();
504
+ const events = captureBridgeEvents(() => {
505
+ handleTaskSystemMessage(session, "task_started", {
506
+ task_id: "task-1",
507
+ tool_use_id: "tool-1",
508
+ description: "Initial task description",
509
+ });
510
+ handleTaskSystemMessage(session, "task_progress", {
511
+ task_id: "task-1",
512
+ summary: "Analyzing authentication flow",
513
+ description: "Should not be shown",
514
+ });
515
+ handleTaskSystemMessage(session, "task_notification", {
516
+ task_id: "task-1",
517
+ status: "completed",
518
+ summary: "Found the auth bug and prepared the fix",
519
+ });
520
+ });
521
+ const lastEvent = events.at(-1);
522
+ assert.ok(lastEvent);
523
+ assert.equal(lastEvent.event, "session_update");
524
+ assert.deepEqual(lastEvent.update, {
525
+ type: "tool_call_update",
526
+ tool_call_update: {
527
+ tool_call_id: "tool-1",
528
+ fields: {
529
+ status: "completed",
530
+ raw_output: "Found the auth bug and prepared the fix",
531
+ content: [
532
+ {
533
+ type: "content",
534
+ content: { type: "text", text: "Found the auth bug and prepared the fix" },
535
+ },
536
+ ],
537
+ },
538
+ },
539
+ });
540
+ assert.equal(session.taskToolUseIds.has("task-1"), false);
541
+ });
542
+ test("emitToolProgressUpdate does not reopen completed tools", () => {
543
+ const session = makeSessionState();
544
+ session.toolCalls.set("tool-1", {
545
+ tool_call_id: "tool-1",
546
+ title: "Bash",
547
+ kind: "execute",
548
+ status: "completed",
549
+ content: [],
550
+ locations: [],
551
+ meta: { claudeCode: { toolName: "Bash" } },
552
+ });
553
+ const events = captureBridgeEvents(() => {
554
+ emitToolProgressUpdate(session, "tool-1", "Bash");
555
+ });
556
+ assert.equal(events.length, 0);
557
+ assert.equal(session.toolCalls.get("tool-1")?.status, "completed");
106
558
  });
107
559
  test("buildQueryOptions trims language before appending system prompt", () => {
108
560
  const input = new AsyncQueue();
@@ -128,6 +580,172 @@ test("buildQueryOptions trims language before appending system prompt", () => {
128
580
  test("parseCommandEnvelope rejects missing required fields", () => {
129
581
  assert.throws(() => parseCommandEnvelope(JSON.stringify({ command: "set_model", session_id: "s1" })), /set_model\.model must be a string/);
130
582
  });
583
+ test("parseCommandEnvelope validates question_response command", () => {
584
+ const parsed = parseCommandEnvelope(JSON.stringify({
585
+ request_id: "req-question",
586
+ command: "question_response",
587
+ session_id: "session-1",
588
+ tool_call_id: "tool-1",
589
+ outcome: {
590
+ outcome: "answered",
591
+ selected_option_ids: ["question_0", "question_2"],
592
+ annotation: {
593
+ preview: "Rendered preview",
594
+ notes: "User note",
595
+ },
596
+ },
597
+ }));
598
+ assert.equal(parsed.requestId, "req-question");
599
+ assert.equal(parsed.command.command, "question_response");
600
+ if (parsed.command.command !== "question_response") {
601
+ throw new Error("unexpected command variant");
602
+ }
603
+ assert.deepEqual(parsed.command.outcome, {
604
+ outcome: "answered",
605
+ selected_option_ids: ["question_0", "question_2"],
606
+ annotation: {
607
+ preview: "Rendered preview",
608
+ notes: "User note",
609
+ },
610
+ });
611
+ });
612
+ test("requestAskUserQuestionAnswers preserves previews and annotations in updated input", async () => {
613
+ const session = makeSessionState();
614
+ const baseToolCall = {
615
+ tool_call_id: "tool-question",
616
+ title: "AskUserQuestion",
617
+ kind: "other",
618
+ status: "in_progress",
619
+ content: [],
620
+ locations: [],
621
+ meta: { claudeCode: { toolName: "AskUserQuestion" } },
622
+ };
623
+ const events = await captureBridgeEventsAsync(async () => {
624
+ const resultPromise = requestAskUserQuestionAnswers(session, "tool-question", {
625
+ questions: [
626
+ {
627
+ question: "Pick deployment target",
628
+ header: "Target",
629
+ multiSelect: true,
630
+ options: [
631
+ {
632
+ label: "Staging",
633
+ description: "Low-risk validation",
634
+ preview: "Deploy to staging first.",
635
+ },
636
+ {
637
+ label: "Production",
638
+ description: "Customer-facing rollout",
639
+ preview: "Deploy to production after approval.",
640
+ },
641
+ ],
642
+ },
643
+ ],
644
+ }, baseToolCall);
645
+ await new Promise((resolve) => setImmediate(resolve));
646
+ const pending = session.pendingQuestions.get("tool-question");
647
+ assert.ok(pending, "expected pending question");
648
+ pending.onOutcome({
649
+ outcome: "answered",
650
+ selected_option_ids: ["question_0", "question_1"],
651
+ annotation: {
652
+ notes: "Roll out in both environments",
653
+ },
654
+ });
655
+ const result = await resultPromise;
656
+ assert.equal(result.behavior, "allow");
657
+ if (result.behavior !== "allow") {
658
+ throw new Error("expected allow result");
659
+ }
660
+ assert.deepEqual(result.updatedInput, {
661
+ questions: [
662
+ {
663
+ question: "Pick deployment target",
664
+ header: "Target",
665
+ multiSelect: true,
666
+ options: [
667
+ {
668
+ label: "Staging",
669
+ description: "Low-risk validation",
670
+ preview: "Deploy to staging first.",
671
+ },
672
+ {
673
+ label: "Production",
674
+ description: "Customer-facing rollout",
675
+ preview: "Deploy to production after approval.",
676
+ },
677
+ ],
678
+ },
679
+ ],
680
+ answers: {
681
+ "Pick deployment target": "Staging, Production",
682
+ },
683
+ annotations: {
684
+ "Pick deployment target": {
685
+ preview: "Deploy to staging first.\n\nDeploy to production after approval.",
686
+ notes: "Roll out in both environments",
687
+ },
688
+ },
689
+ });
690
+ });
691
+ const questionEvent = events.find((event) => event.event === "question_request");
692
+ assert.ok(questionEvent, "expected question request event");
693
+ assert.deepEqual(questionEvent.request, {
694
+ tool_call: {
695
+ tool_call_id: "tool-question",
696
+ title: "Pick deployment target",
697
+ kind: "other",
698
+ status: "in_progress",
699
+ content: [],
700
+ locations: [],
701
+ meta: { claudeCode: { toolName: "AskUserQuestion" } },
702
+ raw_input: {
703
+ prompt: {
704
+ question: "Pick deployment target",
705
+ header: "Target",
706
+ multi_select: true,
707
+ options: [
708
+ {
709
+ option_id: "question_0",
710
+ label: "Staging",
711
+ description: "Low-risk validation",
712
+ preview: "Deploy to staging first.",
713
+ },
714
+ {
715
+ option_id: "question_1",
716
+ label: "Production",
717
+ description: "Customer-facing rollout",
718
+ preview: "Deploy to production after approval.",
719
+ },
720
+ ],
721
+ },
722
+ question_index: 0,
723
+ total_questions: 1,
724
+ },
725
+ },
726
+ prompt: {
727
+ question: "Pick deployment target",
728
+ header: "Target",
729
+ multi_select: true,
730
+ options: [
731
+ {
732
+ option_id: "question_0",
733
+ label: "Staging",
734
+ description: "Low-risk validation",
735
+ preview: "Deploy to staging first.",
736
+ },
737
+ {
738
+ option_id: "question_1",
739
+ label: "Production",
740
+ description: "Customer-facing rollout",
741
+ preview: "Deploy to production after approval.",
742
+ },
743
+ ],
744
+ },
745
+ question_index: 0,
746
+ total_questions: 1,
747
+ });
748
+ });
131
749
  test("normalizeToolKind maps known tool names", () => {
132
750
  assert.equal(normalizeToolKind("Bash"), "execute");
133
751
  assert.equal(normalizeToolKind("Delete"), "delete");
@@ -324,6 +942,9 @@ test("buildToolResultFields maps structured Write output to diff content", () =>
324
942
  content: "new",
325
943
  originalFile: "old",
326
944
  structuredPatch: [],
945
+ gitDiff: {
946
+ repository: "acme/project",
947
+ },
327
948
  }, base);
328
949
  assert.equal(fields.status, "completed");
329
950
  assert.deepEqual(fields.content, [
@@ -333,16 +954,24 @@ test("buildToolResultFields maps structured Write output to diff content", () =>
333
954
  new_path: "src/main.ts",
334
955
  old: "old",
335
956
  new: "new",
957
+ repository: "acme/project",
336
958
  },
337
959
  ]);
338
960
  });
339
- test("buildToolResultFields preserves Edit diff content from input", () => {
961
+ test("buildToolResultFields preserves Edit diff content from input and structured repository", () => {
340
962
  const base = createToolCall("tc-e", "Edit", {
341
963
  file_path: "src/main.ts",
342
964
  old_string: "old",
343
965
  new_string: "new",
344
966
  });
345
- const fields = buildToolResultFields(false, [{ text: "Updated successfully" }], base);
967
+ const fields = buildToolResultFields(false, [{ text: "Updated successfully" }], base, {
968
+ result: {
969
+ filePath: "src/main.ts",
970
+ gitDiff: {
971
+ repository: "acme/project",
972
+ },
973
+ },
974
+ });
346
975
  assert.equal(fields.status, "completed");
347
976
  assert.deepEqual(fields.content, [
348
977
  {
@@ -351,6 +980,120 @@ test("buildToolResultFields preserves Edit diff content from input", () => {
351
980
  new_path: "src/main.ts",
352
981
  old: "old",
353
982
  new: "new",
983
+ repository: "acme/project",
984
+ },
985
+ ]);
986
+ });
987
+ test("buildToolResultFields prefers structured Bash stdout over token-saver output", () => {
988
+ const base = createToolCall("tc-bash", "Bash", { command: "npm test" });
989
+ const fields = buildToolResultFields(false, {
990
+ stdout: "real stdout",
991
+ stderr: "",
992
+ interrupted: false,
993
+ tokenSaverOutput: "compressed output for model",
994
+ }, base, {
995
+ result: {
996
+ stdout: "real stdout",
997
+ stderr: "",
998
+ interrupted: false,
999
+ tokenSaverOutput: "compressed output for model",
1000
+ },
1001
+ });
1002
+ assert.equal(fields.raw_output, "real stdout");
1003
+ assert.deepEqual(fields.output_metadata, {
1004
+ bash: {
1005
+ token_saver_active: true,
1006
+ },
1007
+ });
1008
+ });
1009
+ test("buildToolResultFields adds Bash auto-backgrounded metadata and message", () => {
1010
+ const base = createToolCall("tc-bash-bg", "Bash", { command: "npm run watch" });
1011
+ const fields = buildToolResultFields(false, {
1012
+ stdout: "",
1013
+ stderr: "",
1014
+ interrupted: false,
1015
+ backgroundTaskId: "task-42",
1016
+ assistantAutoBackgrounded: true,
1017
+ }, base, {
1018
+ result: {
1019
+ stdout: "",
1020
+ stderr: "",
1021
+ interrupted: false,
1022
+ backgroundTaskId: "task-42",
1023
+ assistantAutoBackgrounded: true,
1024
+ },
1025
+ });
1026
+ assert.equal(fields.raw_output, "Command was auto-backgrounded by assistant mode with ID: task-42.");
1027
+ assert.deepEqual(fields.output_metadata, {
1028
+ bash: {
1029
+ assistant_auto_backgrounded: true,
1030
+ },
1031
+ });
1032
+ });
1033
+ test("buildToolResultFields maps structured ReadMcpResource output to typed resource content", () => {
1034
+ const base = createToolCall("tc-mcp", "ReadMcpResource", {
1035
+ server: "docs",
1036
+ uri: "file://manual.pdf",
1037
+ });
1038
+ const fields = buildToolResultFields(false, {
1039
+ contents: [
1040
+ {
1041
+ uri: "file://manual.pdf",
1042
+ mimeType: "application/pdf",
1043
+ text: "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf",
1044
+ blobSavedTo: "C:\\tmp\\manual.pdf",
1045
+ },
1046
+ ],
1047
+ }, base, {
1048
+ result: {
1049
+ contents: [
1050
+ {
1051
+ uri: "file://manual.pdf",
1052
+ mimeType: "application/pdf",
1053
+ text: "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf",
1054
+ blobSavedTo: "C:\\tmp\\manual.pdf",
1055
+ },
1056
+ ],
1057
+ },
1058
+ });
1059
+ assert.equal(fields.status, "completed");
1060
+ assert.deepEqual(fields.content, [
1061
+ {
1062
+ type: "mcp_resource",
1063
+ uri: "file://manual.pdf",
1064
+ mime_type: "application/pdf",
1065
+ text: "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf",
1066
+ blob_saved_to: "C:\\tmp\\manual.pdf",
1067
+ },
1068
+ ]);
1069
+ });
1070
+ test("buildToolResultFields restores ReadMcpResource blob paths from transcript JSON text", () => {
1071
+ const base = createToolCall("tc-mcp-history", "ReadMcpResource", {
1072
+ server: "docs",
1073
+ uri: "file://manual.pdf",
1074
+ });
1075
+ const transcriptJson = JSON.stringify({
1076
+ contents: [
1077
+ {
1078
+ uri: "file://manual.pdf",
1079
+ mimeType: "application/pdf",
1080
+ text: "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf",
1081
+ blobSavedTo: "C:\\tmp\\manual.pdf",
1082
+ },
1083
+ ],
1084
+ });
1085
+ const fields = buildToolResultFields(false, transcriptJson, base, {
1086
+ type: "tool_result",
1087
+ tool_use_id: "tc-mcp-history",
1088
+ content: transcriptJson,
1089
+ });
1090
+ assert.deepEqual(fields.content, [
1091
+ {
1092
+ type: "mcp_resource",
1093
+ uri: "file://manual.pdf",
1094
+ mime_type: "application/pdf",
1095
+ text: "[Resource from docs at file://manual.pdf] Saved to C:\\tmp\\manual.pdf",
1096
+ blob_saved_to: "C:\\tmp\\manual.pdf",
354
1097
  },
355
1098
  ]);
356
1099
  });
@@ -492,7 +1235,7 @@ test("looksLikeAuthRequired detects login hints", () => {
492
1235
  assert.equal(looksLikeAuthRequired("normal tool output"), false);
493
1236
  });
494
1237
  test("agent sdk version compatibility check matches pinned version", () => {
495
- assert.equal(resolveInstalledAgentSdkVersion(), "0.2.63");
1238
+ assert.equal(resolveInstalledAgentSdkVersion(), "0.2.74");
496
1239
  assert.equal(agentSdkVersionCompatibilityError(), undefined);
497
1240
  });
498
1241
  test("mapSessionMessagesToUpdates maps message content blocks", () => {
@@ -607,3 +1350,83 @@ test("mapSdkSessions normalizes and sorts sessions", () => {
607
1350
  },
608
1351
  ]);
609
1352
  });
1353
+ test("buildSessionListOptions scopes repo-local listings to worktrees", () => {
1354
+ assert.deepEqual(buildSessionListOptions("C:/repo"), {
1355
+ dir: "C:/repo",
1356
+ includeWorktrees: true,
1357
+ limit: 50,
1358
+ });
1359
+ assert.deepEqual(buildSessionListOptions(undefined), {
1360
+ limit: 50,
1361
+ });
1362
+ });
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, {
1366
+ result: {
1367
+ plan: "Plan contents",
1368
+ isUltraplan: true,
1369
+ },
1370
+ });
1371
+ assert.deepEqual(fields.output_metadata, {
1372
+ exit_plan_mode: {
1373
+ is_ultraplan: true,
1374
+ },
1375
+ });
1376
+ });
1377
+ test("buildToolResultFields extracts TodoWrite verification metadata from structured results", () => {
1378
+ const base = createToolCall("tc-todo", "TodoWrite", {
1379
+ todos: [{ content: "Verify changes", status: "pending", activeForm: "Verifying changes" }],
1380
+ });
1381
+ const fields = buildToolResultFields(false, [{ text: "Todos have been modified successfully." }], base, {
1382
+ data: {
1383
+ oldTodos: [],
1384
+ newTodos: [],
1385
+ verificationNudgeNeeded: true,
1386
+ },
1387
+ });
1388
+ assert.deepEqual(fields.output_metadata, {
1389
+ todo_write: {
1390
+ verification_nudge_needed: true,
1391
+ },
1392
+ });
1393
+ });
1394
+ test("mapAvailableModels preserves optional fast and auto mode metadata", () => {
1395
+ const mapped = mapAvailableModels([
1396
+ {
1397
+ value: "sonnet",
1398
+ displayName: "Claude Sonnet",
1399
+ description: "Balanced model",
1400
+ supportsEffort: true,
1401
+ supportedEffortLevels: ["low", "medium", "high", "max"],
1402
+ supportsAdaptiveThinking: true,
1403
+ supportsFastMode: true,
1404
+ supportsAutoMode: false,
1405
+ },
1406
+ {
1407
+ value: "haiku",
1408
+ displayName: "Claude Haiku",
1409
+ description: "Fast model",
1410
+ supportsEffort: false,
1411
+ },
1412
+ ]);
1413
+ assert.deepEqual(mapped, [
1414
+ {
1415
+ id: "sonnet",
1416
+ display_name: "Claude Sonnet",
1417
+ description: "Balanced model",
1418
+ supports_effort: true,
1419
+ supported_effort_levels: ["low", "medium", "high"],
1420
+ supports_adaptive_thinking: true,
1421
+ supports_fast_mode: true,
1422
+ supports_auto_mode: false,
1423
+ },
1424
+ {
1425
+ id: "haiku",
1426
+ display_name: "Claude Haiku",
1427
+ description: "Fast model",
1428
+ supports_effort: false,
1429
+ supported_effort_levels: [],
1430
+ },
1431
+ ]);
1432
+ });