@sudocode-ai/claude-code-acp 0.12.8 → 0.12.10

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.
package/dist/acp-agent.js CHANGED
@@ -20,7 +20,6 @@ export class ClaudeAcpAgent {
20
20
  this.sessions = {};
21
21
  this.client = client;
22
22
  this.toolUseCache = {};
23
- this.fileContentCache = {};
24
23
  this.logger = logger ?? console;
25
24
  }
26
25
  async initialize(request) {
@@ -79,24 +78,242 @@ export class ClaudeAcpAgent {
79
78
  /**
80
79
  * Fork an existing session to create a new independent session.
81
80
  * This is the ACP protocol method handler for session/fork.
81
+ * Named unstable_forkSession to match SDK expectations (session/fork routes to this method).
82
82
  */
83
- async forkSession(params) {
84
- // cwd and mcpServers are passed via _meta since they're not in the SDK type yet
85
- const meta = params._meta;
86
- return await this.createSession({
87
- cwd: meta?.cwd ?? process.cwd(),
88
- mcpServers: meta?.mcpServers ?? [],
83
+ async unstable_forkSession(params) {
84
+ // Get the session directory to track new files
85
+ const sessionDir = this.getSessionDirPath(params.cwd);
86
+ const beforeFiles = new Set(fs.existsSync(sessionDir)
87
+ ? fs.readdirSync(sessionDir).filter(f => f.endsWith('.jsonl'))
88
+ : []);
89
+ const result = await this.createSession({
90
+ cwd: params.cwd,
91
+ mcpServers: params.mcpServers ?? [],
89
92
  _meta: params._meta,
90
93
  }, {
91
94
  resume: params.sessionId,
92
95
  forkSession: true,
93
96
  });
97
+ // Wait briefly for CLI to create the session file
98
+ await new Promise(resolve => setTimeout(resolve, 200));
99
+ // Find the CLI-assigned session ID by looking for new session files
100
+ const cliSessionId = await this.discoverCliSessionId(sessionDir, beforeFiles, result.sessionId);
101
+ if (cliSessionId && cliSessionId !== result.sessionId) {
102
+ // Check if the CLI assigned a non-UUID session ID (e.g., "agent-xxx")
103
+ // If so, we need to extract the internal sessionId from the file
104
+ const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(cliSessionId);
105
+ if (!isUuid) {
106
+ // Read the session file to extract the internal sessionId
107
+ const oldFilePath = path.join(sessionDir, `${cliSessionId}.jsonl`);
108
+ const internalSessionId = this.extractInternalSessionId(oldFilePath);
109
+ if (internalSessionId) {
110
+ this.logger.log(`[claude-code-acp] Fork: extracted internal sessionId ${internalSessionId} from ${cliSessionId}`);
111
+ // Check if target file already exists (CLI reuses session IDs for forks from same parent)
112
+ // If so, generate a new unique session ID to avoid collisions
113
+ let finalSessionId = internalSessionId;
114
+ let newFilePath = path.join(sessionDir, `${finalSessionId}.jsonl`);
115
+ if (fs.existsSync(newFilePath)) {
116
+ // Session ID collision - CLI created a fork with the same internal ID
117
+ // Generate a new UUID and update the file's internal session ID
118
+ finalSessionId = randomUUID();
119
+ newFilePath = path.join(sessionDir, `${finalSessionId}.jsonl`);
120
+ this.logger.log(`[claude-code-acp] Fork: session ID collision detected, using new ID: ${finalSessionId}`);
121
+ // Update the internal session ID in the file before renaming
122
+ this.updateSessionIdInFile(oldFilePath, finalSessionId);
123
+ }
124
+ // Rename the file to match the session ID so CLI can find it
125
+ try {
126
+ fs.renameSync(oldFilePath, newFilePath);
127
+ this.logger.log(`[claude-code-acp] Fork: renamed ${cliSessionId}.jsonl -> ${finalSessionId}.jsonl`);
128
+ // Promote sidechain to full session so it can be resumed/forked again
129
+ this.promoteToFullSession(newFilePath);
130
+ }
131
+ catch (err) {
132
+ this.logger.error(`[claude-code-acp] Failed to rename session file: ${err}`);
133
+ // Continue anyway - the session might still work
134
+ }
135
+ // Re-register session with the final session ID
136
+ const session = this.sessions[result.sessionId];
137
+ this.sessions[finalSessionId] = session;
138
+ delete this.sessions[result.sessionId];
139
+ return { ...result, sessionId: finalSessionId };
140
+ }
141
+ // Fall through if we couldn't extract the internal ID
142
+ this.logger.error(`[claude-code-acp] Could not extract internal sessionId from ${oldFilePath}`);
143
+ }
144
+ // Re-register session with the CLI's session ID (if it's already a UUID or extraction failed)
145
+ this.logger.log(`[claude-code-acp] Fork: remapping session ${result.sessionId} -> ${cliSessionId}`);
146
+ this.sessions[cliSessionId] = this.sessions[result.sessionId];
147
+ delete this.sessions[result.sessionId];
148
+ return { ...result, sessionId: cliSessionId };
149
+ }
150
+ return result;
94
151
  }
95
152
  /**
96
- * @deprecated Use forkSession instead. This is kept for backward compatibility.
153
+ * Get the directory where session files are stored for a given cwd.
97
154
  */
98
- async unstable_forkSession(params) {
99
- return this.forkSession(params);
155
+ getSessionDirPath(cwd) {
156
+ const realCwd = fs.realpathSync(cwd);
157
+ const cwdHash = realCwd.replace(/[/_]/g, "-");
158
+ return path.join(os.homedir(), ".claude", "projects", cwdHash);
159
+ }
160
+ /**
161
+ * Extract the internal sessionId from a session JSONL file.
162
+ * The CLI stores the actual session ID inside the file, which may differ from the filename.
163
+ * For forked sessions, the filename is "agent-xxx" but the internal sessionId is a UUID.
164
+ */
165
+ extractInternalSessionId(filePath) {
166
+ try {
167
+ if (!fs.existsSync(filePath)) {
168
+ return null;
169
+ }
170
+ const content = fs.readFileSync(filePath, 'utf-8');
171
+ const firstLine = content.split('\n').find(line => line.trim().length > 0);
172
+ if (!firstLine) {
173
+ return null;
174
+ }
175
+ const parsed = JSON.parse(firstLine);
176
+ if (parsed.sessionId && typeof parsed.sessionId === 'string') {
177
+ // Verify it's a UUID format
178
+ const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(parsed.sessionId);
179
+ if (isUuid) {
180
+ return parsed.sessionId;
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+ catch (err) {
186
+ this.logger.error(`[claude-code-acp] Failed to extract sessionId from ${filePath}: ${err}`);
187
+ return null;
188
+ }
189
+ }
190
+ /**
191
+ * Promote a sidechain session to a regular session by modifying the session file.
192
+ * Forked sessions have "isSidechain": true which prevents them from being resumed.
193
+ * This method changes it to false so the session can be resumed/forked again.
194
+ */
195
+ promoteToFullSession(filePath) {
196
+ try {
197
+ if (!fs.existsSync(filePath)) {
198
+ return false;
199
+ }
200
+ const content = fs.readFileSync(filePath, 'utf-8');
201
+ const lines = content.split('\n');
202
+ const modifiedLines = [];
203
+ for (const line of lines) {
204
+ if (!line.trim()) {
205
+ modifiedLines.push(line);
206
+ continue;
207
+ }
208
+ try {
209
+ const parsed = JSON.parse(line);
210
+ // Change isSidechain from true to false
211
+ if (parsed.isSidechain === true) {
212
+ parsed.isSidechain = false;
213
+ }
214
+ modifiedLines.push(JSON.stringify(parsed));
215
+ }
216
+ catch {
217
+ // Keep line as-is if it can't be parsed
218
+ modifiedLines.push(line);
219
+ }
220
+ }
221
+ fs.writeFileSync(filePath, modifiedLines.join('\n'), 'utf-8');
222
+ this.logger.log(`[claude-code-acp] Promoted sidechain to full session: ${filePath}`);
223
+ return true;
224
+ }
225
+ catch (err) {
226
+ this.logger.error(`[claude-code-acp] Failed to promote session: ${err}`);
227
+ return false;
228
+ }
229
+ }
230
+ /**
231
+ * Update the sessionId in all lines of a session JSONL file.
232
+ * This is used when we need to assign a new unique session ID to avoid collisions.
233
+ */
234
+ updateSessionIdInFile(filePath, newSessionId) {
235
+ try {
236
+ if (!fs.existsSync(filePath)) {
237
+ return false;
238
+ }
239
+ const content = fs.readFileSync(filePath, 'utf-8');
240
+ const lines = content.split('\n');
241
+ const modifiedLines = [];
242
+ for (const line of lines) {
243
+ if (!line.trim()) {
244
+ modifiedLines.push(line);
245
+ continue;
246
+ }
247
+ try {
248
+ const parsed = JSON.parse(line);
249
+ // Update the sessionId in each line
250
+ if (parsed.sessionId && typeof parsed.sessionId === 'string') {
251
+ parsed.sessionId = newSessionId;
252
+ }
253
+ modifiedLines.push(JSON.stringify(parsed));
254
+ }
255
+ catch {
256
+ // Keep line as-is if it can't be parsed
257
+ modifiedLines.push(line);
258
+ }
259
+ }
260
+ fs.writeFileSync(filePath, modifiedLines.join('\n'), 'utf-8');
261
+ this.logger.log(`[claude-code-acp] Updated session ID in file: ${filePath}`);
262
+ return true;
263
+ }
264
+ catch (err) {
265
+ this.logger.error(`[claude-code-acp] Failed to update session ID in file: ${err}`);
266
+ return false;
267
+ }
268
+ }
269
+ /**
270
+ * Discover the CLI-assigned session ID by looking for new session files.
271
+ * Returns the CLI's session ID if found, or the original sessionId if not.
272
+ */
273
+ async discoverCliSessionId(sessionDir, beforeFiles, fallbackId, timeout = 2000) {
274
+ const start = Date.now();
275
+ // Pattern for CLI-assigned fork session IDs (agent-xxxxxxx)
276
+ const agentPattern = /^agent-[a-f0-9]+\.jsonl$/;
277
+ while (Date.now() - start < timeout) {
278
+ if (fs.existsSync(sessionDir)) {
279
+ const currentFiles = fs.readdirSync(sessionDir).filter(f => f.endsWith('.jsonl'));
280
+ // Only look for new files that match the agent-xxx pattern
281
+ // This prevents picking up renamed UUID files from previous forks
282
+ const newFiles = currentFiles.filter(f => !beforeFiles.has(f) && agentPattern.test(f));
283
+ if (newFiles.length === 1) {
284
+ // Found exactly one new agent session file - this is our fork
285
+ this.logger.log(`[claude-code-acp] Discovered fork session file: ${newFiles[0]}`);
286
+ return newFiles[0].replace('.jsonl', '');
287
+ }
288
+ else if (newFiles.length > 1) {
289
+ // Multiple new agent files - try to find the most recent one
290
+ let newestFile = '';
291
+ let newestMtime = 0;
292
+ for (const file of newFiles) {
293
+ const filePath = path.join(sessionDir, file);
294
+ const stat = fs.statSync(filePath);
295
+ if (stat.mtimeMs > newestMtime) {
296
+ newestMtime = stat.mtimeMs;
297
+ newestFile = file;
298
+ }
299
+ }
300
+ if (newestFile) {
301
+ this.logger.log(`[claude-code-acp] Discovered fork session file (newest of ${newFiles.length}): ${newestFile}`);
302
+ return newestFile.replace('.jsonl', '');
303
+ }
304
+ }
305
+ }
306
+ await new Promise(resolve => setTimeout(resolve, 100));
307
+ }
308
+ // Timeout - return fallback
309
+ this.logger.log(`[claude-code-acp] Could not discover CLI session ID, using fallback: ${fallbackId}`);
310
+ return fallbackId;
311
+ }
312
+ /**
313
+ * Alias for unstable_forkSession for convenience.
314
+ */
315
+ async forkSession(params) {
316
+ return this.unstable_forkSession(params);
100
317
  }
101
318
  /**
102
319
  * Load an existing session to resume a previous conversation.
@@ -190,7 +407,7 @@ export class ClaudeAcpAgent {
190
407
  break;
191
408
  }
192
409
  case "stream_event": {
193
- for (const notification of streamEventToAcpNotifications(message, params.sessionId, this.toolUseCache, this.fileContentCache, this.client, this.logger)) {
410
+ for (const notification of streamEventToAcpNotifications(message, params.sessionId, this.toolUseCache, this.client, this.logger)) {
194
411
  await this.client.sessionUpdate(notification);
195
412
  }
196
413
  break;
@@ -232,7 +449,7 @@ export class ClaudeAcpAgent {
232
449
  ? // Handled by stream events above
233
450
  message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
234
451
  : message.message.content;
235
- for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, this.toolUseCache, this.fileContentCache, this.client, this.logger)) {
452
+ for (const notification of toAcpNotifications(content, message.message.role, params.sessionId, this.toolUseCache, this.client, this.logger)) {
236
453
  await this.client.sessionUpdate(notification);
237
454
  }
238
455
  break;
@@ -255,6 +472,106 @@ export class ClaudeAcpAgent {
255
472
  this.sessions[params.sessionId].cancelled = true;
256
473
  await this.sessions[params.sessionId].query.interrupt();
257
474
  }
475
+ /**
476
+ * Handle extension methods from the client.
477
+ *
478
+ * Currently supports:
479
+ * - `_session/flush`: Flush a session to disk for fork-with-flush support
480
+ */
481
+ async extMethod(method, params) {
482
+ if (method === "_session/flush") {
483
+ return this.handleSessionFlush(params);
484
+ }
485
+ throw RequestError.methodNotFound(method);
486
+ }
487
+ /**
488
+ * Flush a session to disk by aborting its query subprocess.
489
+ *
490
+ * This is used by the fork-with-flush mechanism to ensure session data
491
+ * is persisted to disk before forking. When the Claude SDK subprocess
492
+ * exits (via abort), it writes the session data to:
493
+ * ~/.claude/projects/<cwd-hash>/<sessionId>.jsonl
494
+ *
495
+ * After this method completes, the session is removed from memory and
496
+ * must be reloaded via loadSession() to continue using it.
497
+ */
498
+ async handleSessionFlush(params) {
499
+ const { sessionId, persistTimeout = 5000 } = params;
500
+ const session = this.sessions[sessionId];
501
+ if (!session) {
502
+ return { success: false, error: `Session ${sessionId} not found` };
503
+ }
504
+ try {
505
+ // Step 1: Mark session as cancelled to stop processing
506
+ session.cancelled = true;
507
+ // Step 2: Interrupt any ongoing query work
508
+ await session.query.interrupt();
509
+ // Step 3: End the input stream to signal no more input
510
+ session.input.end();
511
+ // Step 4: Abort the session using the AbortController
512
+ // This forces the Claude SDK subprocess to exit, which triggers disk persistence
513
+ session.abortController.abort();
514
+ // Step 5: Wait for the session file to appear on disk
515
+ // Use stored sessionFilePath for forked sessions (where filename differs from sessionId)
516
+ const sessionFilePath = session.sessionFilePath ?? this.getSessionFilePath(sessionId, session.cwd);
517
+ this.logger.log(`[claude-code-acp] Waiting for session file at: ${sessionFilePath}`);
518
+ this.logger.log(`[claude-code-acp] Session cwd: ${session.cwd}`);
519
+ const persisted = await this.waitForSessionFile(sessionFilePath, persistTimeout);
520
+ if (!persisted) {
521
+ this.logger.error(`[claude-code-acp] Session file not found at ${sessionFilePath} after ${persistTimeout}ms`);
522
+ // Check if file exists at the path
523
+ const exists = fs.existsSync(sessionFilePath);
524
+ this.logger.error(`[claude-code-acp] File exists check: ${exists}`);
525
+ // Still remove the session from memory
526
+ delete this.sessions[sessionId];
527
+ return { success: false, error: `Session file not created within timeout` };
528
+ }
529
+ // Step 6: Remove session from our map
530
+ // The client will call loadSession() to reload it from disk
531
+ delete this.sessions[sessionId];
532
+ this.logger.log(`[claude-code-acp] Session ${sessionId} flushed to disk at ${sessionFilePath}`);
533
+ return { success: true, filePath: sessionFilePath };
534
+ }
535
+ catch (error) {
536
+ this.logger.error(`[claude-code-acp] Failed to flush session ${sessionId}:`, error);
537
+ // Clean up session on error
538
+ delete this.sessions[sessionId];
539
+ return { success: false, error: String(error) };
540
+ }
541
+ }
542
+ /**
543
+ * Get the file path where Claude Code stores session data.
544
+ *
545
+ * Claude Code stores sessions at:
546
+ * ~/.claude/projects/<cwd-hash>/<sessionId>.jsonl
547
+ *
548
+ * Where <cwd-hash> is the cwd with `/` replaced by `-`
549
+ * Note: We resolve the real path to handle macOS symlinks like /var -> /private/var
550
+ */
551
+ getSessionFilePath(sessionId, cwd) {
552
+ // Resolve the real path to handle macOS symlinks like /var -> /private/var
553
+ const realCwd = fs.realpathSync(cwd);
554
+ // Claude Code replaces both / and _ with - in the cwd hash
555
+ const cwdHash = realCwd.replace(/[/_]/g, "-");
556
+ return path.join(os.homedir(), ".claude", "projects", cwdHash, `${sessionId}.jsonl`);
557
+ }
558
+ /**
559
+ * Wait for a session file to appear on disk.
560
+ *
561
+ * @param filePath - Path to the session file
562
+ * @param timeout - Maximum time to wait in milliseconds
563
+ * @returns true if file appears, false if timeout
564
+ */
565
+ async waitForSessionFile(filePath, timeout) {
566
+ const start = Date.now();
567
+ while (Date.now() - start < timeout) {
568
+ if (fs.existsSync(filePath)) {
569
+ return true;
570
+ }
571
+ await new Promise(resolve => setTimeout(resolve, 100));
572
+ }
573
+ return false;
574
+ }
258
575
  async unstable_setSessionModel(params) {
259
576
  if (!this.sessions[params.sessionId]) {
260
577
  throw new Error("Session not found");
@@ -286,14 +603,10 @@ export class ClaudeAcpAgent {
286
603
  }
287
604
  async readTextFile(params) {
288
605
  const response = await this.client.readTextFile(params);
289
- if (!params.limit && !params.line) {
290
- this.fileContentCache[params.path] = response.content;
291
- }
292
606
  return response;
293
607
  }
294
608
  async writeTextFile(params) {
295
609
  const response = await this.client.writeTextFile(params);
296
- this.fileContentCache[params.path] = params.content;
297
610
  return response;
298
611
  }
299
612
  canUseTool(sessionId) {
@@ -321,7 +634,7 @@ export class ClaudeAcpAgent {
321
634
  toolCall: {
322
635
  toolCallId: toolUseID,
323
636
  rawInput: toolInput,
324
- title: toolInfoFromToolUse({ name: toolName, input: toolInput }, this.fileContentCache, this.logger).title,
637
+ title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
325
638
  },
326
639
  });
327
640
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
@@ -377,7 +690,7 @@ export class ClaudeAcpAgent {
377
690
  toolCall: {
378
691
  toolCallId: toolUseID,
379
692
  rawInput: toolInput,
380
- title: toolInfoFromToolUse({ name: toolName, input: toolInput }, this.fileContentCache, this.logger).title,
693
+ title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
381
694
  },
382
695
  });
383
696
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
@@ -474,7 +787,8 @@ export class ClaudeAcpAgent {
474
787
  const extraArgs = { ...userProvidedOptions?.extraArgs };
475
788
  if (creationOpts?.resume === undefined) {
476
789
  // Set our own session id if not resuming an existing session.
477
- // TODO: find a way to make this work for fork
790
+ // Note: For forked sessions (resume + fork), Claude CLI assigns its own session ID
791
+ // which means chain forking (fork of a fork) is not currently supported.
478
792
  extraArgs["session-id"] = sessionId;
479
793
  }
480
794
  const options = {
@@ -542,11 +856,15 @@ export class ClaudeAcpAgent {
542
856
  if (disallowedTools.length > 0) {
543
857
  options.disallowedTools = disallowedTools;
544
858
  }
545
- // Handle abort controller from meta options
546
- const abortController = userProvidedOptions?.abortController;
547
- if (abortController?.signal.aborted) {
859
+ // Create our own AbortController for session management
860
+ const sessionAbortController = new AbortController();
861
+ // Handle abort controller from meta options (user can still provide one)
862
+ const userAbortController = userProvidedOptions?.abortController;
863
+ if (userAbortController?.signal.aborted) {
548
864
  throw new Error("Cancelled");
549
865
  }
866
+ // Pass the abort controller to the query options
867
+ options.abortController = sessionAbortController;
550
868
  const q = query({
551
869
  prompt: input,
552
870
  options,
@@ -557,6 +875,8 @@ export class ClaudeAcpAgent {
557
875
  cancelled: false,
558
876
  permissionMode,
559
877
  settingsManager,
878
+ abortController: sessionAbortController,
879
+ cwd: params.cwd,
560
880
  };
561
881
  const availableCommands = await getAvailableSlashCommands(q);
562
882
  const models = await getAvailableModels(q);
@@ -749,7 +1069,7 @@ export function promptToClaude(prompt) {
749
1069
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
750
1070
  * Only handles text, image, and thinking chunks for now.
751
1071
  */
752
- export function toAcpNotifications(content, role, sessionId, toolUseCache, fileContentCache, client, logger) {
1072
+ export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger) {
753
1073
  if (typeof content === "string") {
754
1074
  return [
755
1075
  {
@@ -856,7 +1176,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, fileC
856
1176
  sessionUpdate: "tool_call",
857
1177
  rawInput,
858
1178
  status: "pending",
859
- ...toolInfoFromToolUse(chunk, fileContentCache, logger),
1179
+ ...toolInfoFromToolUse(chunk),
860
1180
  };
861
1181
  }
862
1182
  break;
@@ -907,13 +1227,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, fileC
907
1227
  }
908
1228
  return output;
909
1229
  }
910
- export function streamEventToAcpNotifications(message, sessionId, toolUseCache, fileContentCache, client, logger) {
1230
+ export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger) {
911
1231
  const event = message.event;
912
1232
  switch (event.type) {
913
1233
  case "content_block_start":
914
- return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, fileContentCache, client, logger);
1234
+ return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger);
915
1235
  case "content_block_delta":
916
- return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, fileContentCache, client, logger);
1236
+ return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger);
917
1237
  // No content
918
1238
  case "message_start":
919
1239
  case "message_delta":
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Tests for fork-related helper methods in ClaudeAcpAgent.
3
+ *
4
+ * Since the methods are private, we test them by:
5
+ * 1. Creating a test subclass that exposes the private methods
6
+ * 2. Testing the file manipulation logic directly
7
+ */
8
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
9
+ import { ClaudeAcpAgent } from "../acp-agent.js";
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import * as os from "node:os";
13
+ // Create a test subclass that exposes private methods for testing
14
+ class TestableClaudeAcpAgent extends ClaudeAcpAgent {
15
+ // Expose private methods for testing
16
+ testExtractInternalSessionId(filePath) {
17
+ return this.extractInternalSessionId(filePath);
18
+ }
19
+ testPromoteToFullSession(filePath) {
20
+ return this.promoteToFullSession(filePath);
21
+ }
22
+ testUpdateSessionIdInFile(filePath, newSessionId) {
23
+ return this.updateSessionIdInFile(filePath, newSessionId);
24
+ }
25
+ testGetSessionDirPath(cwd) {
26
+ return this.getSessionDirPath(cwd);
27
+ }
28
+ testGetSessionFilePath(sessionId, cwd) {
29
+ return this.getSessionFilePath(sessionId, cwd);
30
+ }
31
+ async testDiscoverCliSessionId(sessionDir, beforeFiles, fallbackId, timeout) {
32
+ return this.discoverCliSessionId(sessionDir, beforeFiles, fallbackId, timeout);
33
+ }
34
+ async testWaitForSessionFile(filePath, timeout) {
35
+ return this.waitForSessionFile(filePath, timeout);
36
+ }
37
+ }
38
+ describe("ClaudeAcpAgent fork helpers", () => {
39
+ let tempDir;
40
+ let agent;
41
+ const mockLogger = {
42
+ log: vi.fn(),
43
+ error: vi.fn(),
44
+ };
45
+ // Create a minimal mock client
46
+ const mockClient = {
47
+ sessionUpdate: vi.fn(),
48
+ readTextFile: vi.fn(),
49
+ writeTextFile: vi.fn(),
50
+ requestPermission: vi.fn(),
51
+ };
52
+ beforeEach(async () => {
53
+ tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "acp-agent-fork-test-"));
54
+ agent = new TestableClaudeAcpAgent(mockClient, mockLogger);
55
+ vi.clearAllMocks();
56
+ });
57
+ afterEach(async () => {
58
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
59
+ });
60
+ describe("extractInternalSessionId", () => {
61
+ it("should extract UUID sessionId from valid JSONL file", async () => {
62
+ const filePath = path.join(tempDir, "session.jsonl");
63
+ const sessionId = "12345678-1234-1234-1234-123456789abc";
64
+ await fs.promises.writeFile(filePath, JSON.stringify({ sessionId, type: "init" }) + "\n");
65
+ const result = agent.testExtractInternalSessionId(filePath);
66
+ expect(result).toBe(sessionId);
67
+ });
68
+ it("should return null for non-existent file", () => {
69
+ const result = agent.testExtractInternalSessionId(path.join(tempDir, "nonexistent.jsonl"));
70
+ expect(result).toBeNull();
71
+ });
72
+ it("should return null for file without sessionId", async () => {
73
+ const filePath = path.join(tempDir, "session.jsonl");
74
+ await fs.promises.writeFile(filePath, JSON.stringify({ type: "init" }) + "\n");
75
+ const result = agent.testExtractInternalSessionId(filePath);
76
+ expect(result).toBeNull();
77
+ });
78
+ it("should return null for non-UUID sessionId", async () => {
79
+ const filePath = path.join(tempDir, "session.jsonl");
80
+ await fs.promises.writeFile(filePath, JSON.stringify({ sessionId: "not-a-uuid", type: "init" }) + "\n");
81
+ const result = agent.testExtractInternalSessionId(filePath);
82
+ expect(result).toBeNull();
83
+ });
84
+ it("should handle empty file", async () => {
85
+ const filePath = path.join(tempDir, "session.jsonl");
86
+ await fs.promises.writeFile(filePath, "");
87
+ const result = agent.testExtractInternalSessionId(filePath);
88
+ expect(result).toBeNull();
89
+ });
90
+ it("should find sessionId from first non-empty line", async () => {
91
+ const filePath = path.join(tempDir, "session.jsonl");
92
+ const sessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
93
+ await fs.promises.writeFile(filePath, "\n\n" + JSON.stringify({ sessionId, type: "init" }) + "\n");
94
+ const result = agent.testExtractInternalSessionId(filePath);
95
+ expect(result).toBe(sessionId);
96
+ });
97
+ });
98
+ describe("promoteToFullSession", () => {
99
+ it("should change isSidechain from true to false", async () => {
100
+ const filePath = path.join(tempDir, "session.jsonl");
101
+ const lines = [
102
+ JSON.stringify({ sessionId: "test-id", isSidechain: true }),
103
+ JSON.stringify({ type: "message", content: "hello" }),
104
+ ];
105
+ await fs.promises.writeFile(filePath, lines.join("\n"));
106
+ const result = agent.testPromoteToFullSession(filePath);
107
+ expect(result).toBe(true);
108
+ const content = await fs.promises.readFile(filePath, "utf-8");
109
+ const parsedLines = content.split("\n").map(line => JSON.parse(line));
110
+ expect(parsedLines[0].isSidechain).toBe(false);
111
+ expect(parsedLines[1].content).toBe("hello");
112
+ });
113
+ it("should return false for non-existent file", () => {
114
+ const result = agent.testPromoteToFullSession(path.join(tempDir, "nonexistent.jsonl"));
115
+ expect(result).toBe(false);
116
+ });
117
+ it("should preserve other fields when promoting", async () => {
118
+ const filePath = path.join(tempDir, "session.jsonl");
119
+ const original = {
120
+ sessionId: "test-id",
121
+ isSidechain: true,
122
+ cwd: "/some/path",
123
+ model: "claude-3",
124
+ };
125
+ await fs.promises.writeFile(filePath, JSON.stringify(original));
126
+ agent.testPromoteToFullSession(filePath);
127
+ const content = await fs.promises.readFile(filePath, "utf-8");
128
+ const parsed = JSON.parse(content);
129
+ expect(parsed.sessionId).toBe("test-id");
130
+ expect(parsed.isSidechain).toBe(false);
131
+ expect(parsed.cwd).toBe("/some/path");
132
+ expect(parsed.model).toBe("claude-3");
133
+ });
134
+ it("should handle file without isSidechain field", async () => {
135
+ const filePath = path.join(tempDir, "session.jsonl");
136
+ await fs.promises.writeFile(filePath, JSON.stringify({ sessionId: "test-id", type: "init" }));
137
+ const result = agent.testPromoteToFullSession(filePath);
138
+ expect(result).toBe(true);
139
+ const content = await fs.promises.readFile(filePath, "utf-8");
140
+ const parsed = JSON.parse(content);
141
+ expect(parsed.isSidechain).toBeUndefined();
142
+ });
143
+ it("should log success message", async () => {
144
+ const filePath = path.join(tempDir, "session.jsonl");
145
+ await fs.promises.writeFile(filePath, JSON.stringify({ sessionId: "test-id", isSidechain: true }));
146
+ agent.testPromoteToFullSession(filePath);
147
+ expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining("Promoted sidechain to full session"));
148
+ });
149
+ });
150
+ describe("updateSessionIdInFile", () => {
151
+ it("should update sessionId in all lines", async () => {
152
+ const filePath = path.join(tempDir, "session.jsonl");
153
+ const oldId = "old-session-id";
154
+ const newId = "new-session-id";
155
+ const lines = [
156
+ JSON.stringify({ sessionId: oldId, type: "init" }),
157
+ JSON.stringify({ sessionId: oldId, type: "message", content: "hello" }),
158
+ JSON.stringify({ sessionId: oldId, type: "tool_use", name: "Read" }),
159
+ ];
160
+ await fs.promises.writeFile(filePath, lines.join("\n"));
161
+ const result = agent.testUpdateSessionIdInFile(filePath, newId);
162
+ expect(result).toBe(true);
163
+ const content = await fs.promises.readFile(filePath, "utf-8");
164
+ const parsedLines = content.split("\n").map(line => JSON.parse(line));
165
+ for (const line of parsedLines) {
166
+ expect(line.sessionId).toBe(newId);
167
+ }
168
+ });
169
+ it("should return false for non-existent file", () => {
170
+ const result = agent.testUpdateSessionIdInFile(path.join(tempDir, "nonexistent.jsonl"), "new-id");
171
+ expect(result).toBe(false);
172
+ });
173
+ it("should preserve lines without sessionId", async () => {
174
+ const filePath = path.join(tempDir, "session.jsonl");
175
+ const lines = [
176
+ JSON.stringify({ sessionId: "old-id", type: "init" }),
177
+ JSON.stringify({ type: "comment", text: "no sessionId here" }),
178
+ ];
179
+ await fs.promises.writeFile(filePath, lines.join("\n"));
180
+ agent.testUpdateSessionIdInFile(filePath, "new-id");
181
+ const content = await fs.promises.readFile(filePath, "utf-8");
182
+ const parsedLines = content.split("\n").map(line => JSON.parse(line));
183
+ expect(parsedLines[0].sessionId).toBe("new-id");
184
+ expect(parsedLines[1].sessionId).toBeUndefined();
185
+ expect(parsedLines[1].text).toBe("no sessionId here");
186
+ });
187
+ it("should handle empty lines gracefully", async () => {
188
+ const filePath = path.join(tempDir, "session.jsonl");
189
+ const content = JSON.stringify({ sessionId: "old-id" }) + "\n\n" + JSON.stringify({ sessionId: "old-id" });
190
+ await fs.promises.writeFile(filePath, content);
191
+ const result = agent.testUpdateSessionIdInFile(filePath, "new-id");
192
+ expect(result).toBe(true);
193
+ const newContent = await fs.promises.readFile(filePath, "utf-8");
194
+ const lines = newContent.split("\n");
195
+ expect(JSON.parse(lines[0]).sessionId).toBe("new-id");
196
+ expect(lines[1]).toBe(""); // Empty line preserved
197
+ expect(JSON.parse(lines[2]).sessionId).toBe("new-id");
198
+ });
199
+ it("should log success message", async () => {
200
+ const filePath = path.join(tempDir, "session.jsonl");
201
+ await fs.promises.writeFile(filePath, JSON.stringify({ sessionId: "old-id" }));
202
+ agent.testUpdateSessionIdInFile(filePath, "new-id");
203
+ expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining("Updated session ID in file"));
204
+ });
205
+ });
206
+ describe("getSessionDirPath", () => {
207
+ it("should compute correct path with cwd hash", () => {
208
+ const result = agent.testGetSessionDirPath("/private/tmp");
209
+ const homeDir = os.homedir();
210
+ expect(result).toBe(`${homeDir}/.claude/projects/-private-tmp`);
211
+ });
212
+ it("should replace both / and _ with -", () => {
213
+ // Use tempDir which exists
214
+ const testDir = path.join(tempDir, "my_project");
215
+ fs.mkdirSync(testDir, { recursive: true });
216
+ const result = agent.testGetSessionDirPath(testDir);
217
+ const realPath = fs.realpathSync(testDir);
218
+ const expectedHash = realPath.replace(/[/_]/g, "-");
219
+ expect(result).toBe(`${os.homedir()}/.claude/projects/${expectedHash}`);
220
+ });
221
+ it("should resolve symlinks", () => {
222
+ // /var on macOS is a symlink to /private/var
223
+ const result = agent.testGetSessionDirPath("/var/tmp");
224
+ const homeDir = os.homedir();
225
+ // Should use the resolved path
226
+ expect(result).toBe(`${homeDir}/.claude/projects/-private-var-tmp`);
227
+ });
228
+ });
229
+ describe("getSessionFilePath", () => {
230
+ it("should append sessionId.jsonl to session dir", () => {
231
+ const result = agent.testGetSessionFilePath("my-session-id", "/private/tmp");
232
+ const homeDir = os.homedir();
233
+ expect(result).toBe(`${homeDir}/.claude/projects/-private-tmp/my-session-id.jsonl`);
234
+ });
235
+ });
236
+ describe("waitForSessionFile", () => {
237
+ it("should return true immediately if file exists", async () => {
238
+ const filePath = path.join(tempDir, "session.jsonl");
239
+ await fs.promises.writeFile(filePath, "{}");
240
+ const start = Date.now();
241
+ const result = await agent.testWaitForSessionFile(filePath, 1000);
242
+ const elapsed = Date.now() - start;
243
+ expect(result).toBe(true);
244
+ expect(elapsed).toBeLessThan(200);
245
+ });
246
+ it("should return true when file appears before timeout", async () => {
247
+ const filePath = path.join(tempDir, "session.jsonl");
248
+ // Create file after 200ms
249
+ setTimeout(async () => {
250
+ await fs.promises.writeFile(filePath, "{}");
251
+ }, 200);
252
+ const start = Date.now();
253
+ const result = await agent.testWaitForSessionFile(filePath, 2000);
254
+ const elapsed = Date.now() - start;
255
+ expect(result).toBe(true);
256
+ expect(elapsed).toBeGreaterThanOrEqual(200);
257
+ expect(elapsed).toBeLessThan(2000);
258
+ });
259
+ it("should return false when timeout expires", async () => {
260
+ const filePath = path.join(tempDir, "nonexistent.jsonl");
261
+ const start = Date.now();
262
+ const result = await agent.testWaitForSessionFile(filePath, 300);
263
+ const elapsed = Date.now() - start;
264
+ expect(result).toBe(false);
265
+ expect(elapsed).toBeGreaterThanOrEqual(300);
266
+ expect(elapsed).toBeLessThan(500);
267
+ });
268
+ });
269
+ describe("discoverCliSessionId", () => {
270
+ it("should find new agent-xxx file", async () => {
271
+ const sessionDir = path.join(tempDir, "sessions");
272
+ await fs.promises.mkdir(sessionDir, { recursive: true });
273
+ const beforeFiles = new Set();
274
+ // Create an agent-xxx file
275
+ const agentFile = "agent-abc123.jsonl";
276
+ await fs.promises.writeFile(path.join(sessionDir, agentFile), "{}");
277
+ const result = await agent.testDiscoverCliSessionId(sessionDir, beforeFiles, "fallback", 1000);
278
+ expect(result).toBe("agent-abc123");
279
+ });
280
+ it("should ignore non-agent files", async () => {
281
+ const sessionDir = path.join(tempDir, "sessions");
282
+ await fs.promises.mkdir(sessionDir, { recursive: true });
283
+ const beforeFiles = new Set();
284
+ // Create a UUID-named file (not agent-xxx)
285
+ const uuidFile = "12345678-1234-1234-1234-123456789abc.jsonl";
286
+ await fs.promises.writeFile(path.join(sessionDir, uuidFile), "{}");
287
+ const result = await agent.testDiscoverCliSessionId(sessionDir, beforeFiles, "fallback", 500);
288
+ expect(result).toBe("fallback");
289
+ });
290
+ it("should return fallback when no new files found", async () => {
291
+ const sessionDir = path.join(tempDir, "sessions");
292
+ await fs.promises.mkdir(sessionDir, { recursive: true });
293
+ const beforeFiles = new Set();
294
+ const result = await agent.testDiscoverCliSessionId(sessionDir, beforeFiles, "fallback-id", 300);
295
+ expect(result).toBe("fallback-id");
296
+ });
297
+ it("should ignore files that existed before", async () => {
298
+ const sessionDir = path.join(tempDir, "sessions");
299
+ await fs.promises.mkdir(sessionDir, { recursive: true });
300
+ // Create file before
301
+ const existingFile = "agent-existing.jsonl";
302
+ await fs.promises.writeFile(path.join(sessionDir, existingFile), "{}");
303
+ const beforeFiles = new Set([existingFile]);
304
+ const result = await agent.testDiscoverCliSessionId(sessionDir, beforeFiles, "fallback", 300);
305
+ expect(result).toBe("fallback");
306
+ });
307
+ it("should return newest file when multiple agent files appear", async () => {
308
+ const sessionDir = path.join(tempDir, "sessions");
309
+ await fs.promises.mkdir(sessionDir, { recursive: true });
310
+ const beforeFiles = new Set();
311
+ // Create first file - use hex chars to match agent-[a-f0-9]+ pattern
312
+ const firstPath = path.join(sessionDir, "agent-aaa111.jsonl");
313
+ await fs.promises.writeFile(firstPath, "{}");
314
+ // Wait a bit to ensure different mtime, then create second file
315
+ await new Promise(resolve => setTimeout(resolve, 100));
316
+ const secondPath = path.join(sessionDir, "agent-bbb222.jsonl");
317
+ await fs.promises.writeFile(secondPath, "{}");
318
+ // Verify files exist before calling discover
319
+ expect(fs.existsSync(firstPath)).toBe(true);
320
+ expect(fs.existsSync(secondPath)).toBe(true);
321
+ const result = await agent.testDiscoverCliSessionId(sessionDir, beforeFiles, "fallback", 1000);
322
+ expect(result).toBe("agent-bbb222");
323
+ });
324
+ it("should handle non-existent session directory initially", async () => {
325
+ const sessionDir = path.join(tempDir, "nonexistent-dir");
326
+ const beforeFiles = new Set();
327
+ // Start the discovery in parallel with file creation
328
+ const discoverPromise = agent.testDiscoverCliSessionId(sessionDir, beforeFiles, "fallback", 2000);
329
+ // Create directory and file after 200ms (within the 2000ms timeout)
330
+ // Use hex chars to match agent-[a-f0-9]+ pattern
331
+ await new Promise(resolve => setTimeout(resolve, 200));
332
+ await fs.promises.mkdir(sessionDir, { recursive: true });
333
+ await fs.promises.writeFile(path.join(sessionDir, "agent-ccc333.jsonl"), "{}");
334
+ const result = await discoverPromise;
335
+ expect(result).toBe("agent-ccc333");
336
+ });
337
+ });
338
+ });
@@ -192,7 +192,7 @@ describe("tool conversions", () => {
192
192
  description: "Delete README.md.rm file",
193
193
  },
194
194
  };
195
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
195
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
196
196
  kind: "execute",
197
197
  title: "`rm README.md.rm`",
198
198
  content: [
@@ -215,7 +215,7 @@ describe("tool conversions", () => {
215
215
  pattern: "*/**.ts",
216
216
  },
217
217
  };
218
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
218
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
219
219
  kind: "search",
220
220
  title: "Find `*/**.ts`",
221
221
  content: [],
@@ -233,7 +233,7 @@ describe("tool conversions", () => {
233
233
  subagent_type: "general-purpose",
234
234
  },
235
235
  };
236
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
236
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
237
237
  kind: "think",
238
238
  title: "Handle user's work request",
239
239
  content: [
@@ -256,7 +256,7 @@ describe("tool conversions", () => {
256
256
  path: "/Users/test/github/claude-code-acp",
257
257
  },
258
258
  };
259
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
259
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
260
260
  kind: "search",
261
261
  title: "List the `/Users/test/github/claude-code-acp` directory's contents",
262
262
  content: [],
@@ -272,7 +272,7 @@ describe("tool conversions", () => {
272
272
  pattern: ".*",
273
273
  },
274
274
  };
275
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
275
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
276
276
  kind: "search",
277
277
  title: 'grep ".*"',
278
278
  content: [],
@@ -288,7 +288,7 @@ describe("tool conversions", () => {
288
288
  content: "Hello, World!\nThis is test content.",
289
289
  },
290
290
  };
291
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
291
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
292
292
  kind: "edit",
293
293
  title: "Write /Users/test/project/example.txt",
294
294
  content: [
@@ -312,7 +312,7 @@ describe("tool conversions", () => {
312
312
  content: '{"version": "1.0.0"}',
313
313
  },
314
314
  };
315
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
315
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
316
316
  kind: "edit",
317
317
  title: "Write /Users/test/project/config.json",
318
318
  content: [
@@ -335,7 +335,7 @@ describe("tool conversions", () => {
335
335
  file_path: "/Users/test/project/readme.md",
336
336
  },
337
337
  };
338
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
338
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
339
339
  kind: "read",
340
340
  title: "Read File",
341
341
  content: [],
@@ -351,7 +351,7 @@ describe("tool conversions", () => {
351
351
  file_path: "/Users/test/project/data.json",
352
352
  },
353
353
  };
354
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
354
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
355
355
  kind: "read",
356
356
  title: "Read /Users/test/project/data.json",
357
357
  content: [],
@@ -368,7 +368,7 @@ describe("tool conversions", () => {
368
368
  limit: 100,
369
369
  },
370
370
  };
371
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
371
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
372
372
  kind: "read",
373
373
  title: "Read /Users/test/project/large.txt (1 - 100)",
374
374
  content: [],
@@ -386,7 +386,7 @@ describe("tool conversions", () => {
386
386
  limit: 100,
387
387
  },
388
388
  };
389
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
389
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
390
390
  kind: "read",
391
391
  title: "Read /Users/test/project/large.txt (51 - 150)",
392
392
  content: [],
@@ -403,7 +403,7 @@ describe("tool conversions", () => {
403
403
  offset: 200,
404
404
  },
405
405
  };
406
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
406
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
407
407
  kind: "read",
408
408
  title: "Read /Users/test/project/large.txt (from line 201)",
409
409
  content: [],
@@ -419,7 +419,7 @@ describe("tool conversions", () => {
419
419
  shell_id: "bash_1",
420
420
  },
421
421
  };
422
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
422
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
423
423
  kind: "execute",
424
424
  title: `Kill Process`,
425
425
  content: [],
@@ -434,7 +434,7 @@ describe("tool conversions", () => {
434
434
  bash_id: "bash_1",
435
435
  },
436
436
  };
437
- expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
437
+ expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
438
438
  kind: "execute",
439
439
  title: `Tail Logs`,
440
440
  content: [],
@@ -520,7 +520,7 @@ describe("tool conversions", () => {
520
520
  session_id: "d056596f-e328-41e9-badd-b07122ae5227",
521
521
  uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
522
522
  };
523
- expect(toAcpNotifications(received.message.content, received.message.role, "test", {}, {}, {}, console)).toStrictEqual([
523
+ expect(toAcpNotifications(received.message.content, received.message.role, "test", {}, {}, console)).toStrictEqual([
524
524
  {
525
525
  sessionId: "test",
526
526
  update: {
@@ -733,7 +733,7 @@ describe("permission requests", () => {
733
733
  ];
734
734
  for (const testCase of testCases) {
735
735
  // Get the tool info that would be used in requestPermission
736
- const toolInfo = toolInfoFromToolUse(testCase.toolUse, {});
736
+ const toolInfo = toolInfoFromToolUse(testCase.toolUse);
737
737
  // Verify toolInfo has a title
738
738
  expect(toolInfo.title).toBeDefined();
739
739
  expect(toolInfo.title).toContain(testCase.expectedTitlePart);
package/dist/tools.js CHANGED
@@ -1,4 +1,5 @@
1
- import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
1
+ import { SYSTEM_REMINDER } from "./mcp-server.js";
2
+ import * as diff from "diff";
2
3
  const acpUnqualifiedToolNames = {
3
4
  read: "Read",
4
5
  edit: "Edit",
@@ -17,7 +18,7 @@ export const acpToolNames = {
17
18
  bashOutput: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.bashOutput,
18
19
  };
19
20
  export const EDIT_TOOL_NAMES = [acpToolNames.edit, acpToolNames.write];
20
- export function toolInfoFromToolUse(toolUse, cachedFileContent, logger = console) {
21
+ export function toolInfoFromToolUse(toolUse) {
21
22
  const name = toolUse.name;
22
23
  const input = toolUse.input;
23
24
  switch (name) {
@@ -130,27 +131,6 @@ export function toolInfoFromToolUse(toolUse, cachedFileContent, logger = console
130
131
  case acpToolNames.edit:
131
132
  case "Edit": {
132
133
  const path = input?.file_path ?? input?.file_path;
133
- let oldText = input.old_string ?? null;
134
- let newText = input.new_string ?? "";
135
- let affectedLines = [];
136
- if (path && oldText) {
137
- try {
138
- const oldContent = cachedFileContent[path] || "";
139
- const newContent = replaceAndCalculateLocation(oldContent, [
140
- {
141
- oldText,
142
- newText,
143
- replaceAll: false,
144
- },
145
- ]);
146
- oldText = oldContent;
147
- newText = newContent.newContent;
148
- affectedLines = newContent.lineNumbers;
149
- }
150
- catch (e) {
151
- logger.error(e);
152
- }
153
- }
154
134
  return {
155
135
  title: path ? `Edit \`${path}\`` : "Edit",
156
136
  kind: "edit",
@@ -159,16 +139,12 @@ export function toolInfoFromToolUse(toolUse, cachedFileContent, logger = console
159
139
  {
160
140
  type: "diff",
161
141
  path,
162
- oldText,
163
- newText,
142
+ oldText: input.old_string ?? null,
143
+ newText: input.new_string ?? "",
164
144
  },
165
145
  ]
166
146
  : [],
167
- locations: path
168
- ? affectedLines.length > 0
169
- ? affectedLines.map((line) => ({ line, path }))
170
- : [{ path }]
171
- : [],
147
+ locations: path ? [{ path }] : undefined,
172
148
  };
173
149
  }
174
150
  case acpToolNames.write: {
@@ -355,6 +331,13 @@ export function toolInfoFromToolUse(toolUse, cachedFileContent, logger = console
355
331
  }
356
332
  }
357
333
  export function toolUpdateFromToolResult(toolResult, toolUse) {
334
+ if ("is_error" in toolResult &&
335
+ toolResult.is_error &&
336
+ toolResult.content &&
337
+ toolResult.content.length > 0) {
338
+ // Only return errors
339
+ return toAcpContentUpdate(toolResult.content, true);
340
+ }
358
341
  switch (toolUse?.name) {
359
342
  case "Read":
360
343
  case acpToolNames.read:
@@ -385,19 +368,57 @@ export function toolUpdateFromToolResult(toolResult, toolUse) {
385
368
  };
386
369
  }
387
370
  return {};
371
+ case acpToolNames.edit: {
372
+ const content = [];
373
+ const locations = [];
374
+ if (Array.isArray(toolResult.content) &&
375
+ toolResult.content.length > 0 &&
376
+ "text" in toolResult.content[0] &&
377
+ typeof toolResult.content[0].text === "string") {
378
+ const patches = diff.parsePatch(toolResult.content[0].text);
379
+ console.error(JSON.stringify(patches));
380
+ for (const { oldFileName, newFileName, hunks } of patches) {
381
+ for (const { lines, newStart } of hunks) {
382
+ const oldText = [];
383
+ const newText = [];
384
+ for (const line of lines) {
385
+ if (line.startsWith("-")) {
386
+ oldText.push(line.slice(1));
387
+ }
388
+ else if (line.startsWith("+")) {
389
+ newText.push(line.slice(1));
390
+ }
391
+ else {
392
+ oldText.push(line.slice(1));
393
+ newText.push(line.slice(1));
394
+ }
395
+ }
396
+ if (oldText.length > 0 || newText.length > 0) {
397
+ locations.push({ path: newFileName || oldFileName, line: newStart });
398
+ content.push({
399
+ type: "diff",
400
+ path: newFileName || oldFileName,
401
+ oldText: oldText.join("\n") || null,
402
+ newText: newText.join("\n"),
403
+ });
404
+ }
405
+ }
406
+ }
407
+ }
408
+ const result = {};
409
+ if (content.length > 0) {
410
+ result.content = content;
411
+ }
412
+ if (locations.length > 0) {
413
+ result.locations = locations;
414
+ }
415
+ return result;
416
+ }
388
417
  case acpToolNames.bash:
389
418
  case "edit":
390
419
  case "Edit":
391
- case acpToolNames.edit:
392
420
  case acpToolNames.write:
393
421
  case "Write": {
394
- if ("is_error" in toolResult &&
395
- toolResult.is_error &&
396
- toolResult.content &&
397
- toolResult.content.length > 0) {
398
- // Only return errors
399
- return toAcpContentUpdate(toolResult.content, true);
400
- }
401
422
  return {};
402
423
  }
403
424
  case "ExitPlanMode": {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.12.8",
6
+ "version": "0.12.10",
7
7
  "description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
8
8
  "main": "dist/lib.js",
9
9
  "bin": {