@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 +347 -27
- package/dist/tests/acp-agent-fork.test.js +338 -0
- package/dist/tests/acp-agent.test.js +16 -16
- package/dist/tools.js +59 -38
- package/package.json +1 -1
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
|
|
84
|
-
//
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
*
|
|
153
|
+
* Get the directory where session files are stored for a given cwd.
|
|
97
154
|
*/
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
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.
|
|
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 }
|
|
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 }
|
|
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
|
-
//
|
|
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
|
-
//
|
|
546
|
-
const
|
|
547
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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", {}, {},
|
|
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 {
|
|
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
|
|
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": {
|