@sudocode-ai/claude-code-acp 0.12.9 → 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 +342 -23
- 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) {
|
|
@@ -82,16 +81,233 @@ export class ClaudeAcpAgent {
|
|
|
82
81
|
* Named unstable_forkSession to match SDK expectations (session/fork routes to this method).
|
|
83
82
|
*/
|
|
84
83
|
async unstable_forkSession(params) {
|
|
85
|
-
//
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 ?? [],
|
|
90
92
|
_meta: params._meta,
|
|
91
93
|
}, {
|
|
92
94
|
resume: params.sessionId,
|
|
93
95
|
forkSession: true,
|
|
94
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;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Get the directory where session files are stored for a given cwd.
|
|
154
|
+
*/
|
|
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;
|
|
95
311
|
}
|
|
96
312
|
/**
|
|
97
313
|
* Alias for unstable_forkSession for convenience.
|
|
@@ -191,7 +407,7 @@ export class ClaudeAcpAgent {
|
|
|
191
407
|
break;
|
|
192
408
|
}
|
|
193
409
|
case "stream_event": {
|
|
194
|
-
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)) {
|
|
195
411
|
await this.client.sessionUpdate(notification);
|
|
196
412
|
}
|
|
197
413
|
break;
|
|
@@ -233,7 +449,7 @@ export class ClaudeAcpAgent {
|
|
|
233
449
|
? // Handled by stream events above
|
|
234
450
|
message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
|
|
235
451
|
: message.message.content;
|
|
236
|
-
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)) {
|
|
237
453
|
await this.client.sessionUpdate(notification);
|
|
238
454
|
}
|
|
239
455
|
break;
|
|
@@ -256,6 +472,106 @@ export class ClaudeAcpAgent {
|
|
|
256
472
|
this.sessions[params.sessionId].cancelled = true;
|
|
257
473
|
await this.sessions[params.sessionId].query.interrupt();
|
|
258
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
|
+
}
|
|
259
575
|
async unstable_setSessionModel(params) {
|
|
260
576
|
if (!this.sessions[params.sessionId]) {
|
|
261
577
|
throw new Error("Session not found");
|
|
@@ -287,14 +603,10 @@ export class ClaudeAcpAgent {
|
|
|
287
603
|
}
|
|
288
604
|
async readTextFile(params) {
|
|
289
605
|
const response = await this.client.readTextFile(params);
|
|
290
|
-
if (!params.limit && !params.line) {
|
|
291
|
-
this.fileContentCache[params.path] = response.content;
|
|
292
|
-
}
|
|
293
606
|
return response;
|
|
294
607
|
}
|
|
295
608
|
async writeTextFile(params) {
|
|
296
609
|
const response = await this.client.writeTextFile(params);
|
|
297
|
-
this.fileContentCache[params.path] = params.content;
|
|
298
610
|
return response;
|
|
299
611
|
}
|
|
300
612
|
canUseTool(sessionId) {
|
|
@@ -322,7 +634,7 @@ export class ClaudeAcpAgent {
|
|
|
322
634
|
toolCall: {
|
|
323
635
|
toolCallId: toolUseID,
|
|
324
636
|
rawInput: toolInput,
|
|
325
|
-
title: toolInfoFromToolUse({ name: toolName, input: toolInput }
|
|
637
|
+
title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
|
|
326
638
|
},
|
|
327
639
|
});
|
|
328
640
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
@@ -378,7 +690,7 @@ export class ClaudeAcpAgent {
|
|
|
378
690
|
toolCall: {
|
|
379
691
|
toolCallId: toolUseID,
|
|
380
692
|
rawInput: toolInput,
|
|
381
|
-
title: toolInfoFromToolUse({ name: toolName, input: toolInput }
|
|
693
|
+
title: toolInfoFromToolUse({ name: toolName, input: toolInput }).title,
|
|
382
694
|
},
|
|
383
695
|
});
|
|
384
696
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
@@ -475,7 +787,8 @@ export class ClaudeAcpAgent {
|
|
|
475
787
|
const extraArgs = { ...userProvidedOptions?.extraArgs };
|
|
476
788
|
if (creationOpts?.resume === undefined) {
|
|
477
789
|
// Set our own session id if not resuming an existing session.
|
|
478
|
-
//
|
|
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.
|
|
479
792
|
extraArgs["session-id"] = sessionId;
|
|
480
793
|
}
|
|
481
794
|
const options = {
|
|
@@ -543,11 +856,15 @@ export class ClaudeAcpAgent {
|
|
|
543
856
|
if (disallowedTools.length > 0) {
|
|
544
857
|
options.disallowedTools = disallowedTools;
|
|
545
858
|
}
|
|
546
|
-
//
|
|
547
|
-
const
|
|
548
|
-
|
|
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) {
|
|
549
864
|
throw new Error("Cancelled");
|
|
550
865
|
}
|
|
866
|
+
// Pass the abort controller to the query options
|
|
867
|
+
options.abortController = sessionAbortController;
|
|
551
868
|
const q = query({
|
|
552
869
|
prompt: input,
|
|
553
870
|
options,
|
|
@@ -558,6 +875,8 @@ export class ClaudeAcpAgent {
|
|
|
558
875
|
cancelled: false,
|
|
559
876
|
permissionMode,
|
|
560
877
|
settingsManager,
|
|
878
|
+
abortController: sessionAbortController,
|
|
879
|
+
cwd: params.cwd,
|
|
561
880
|
};
|
|
562
881
|
const availableCommands = await getAvailableSlashCommands(q);
|
|
563
882
|
const models = await getAvailableModels(q);
|
|
@@ -750,7 +1069,7 @@ export function promptToClaude(prompt) {
|
|
|
750
1069
|
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
751
1070
|
* Only handles text, image, and thinking chunks for now.
|
|
752
1071
|
*/
|
|
753
|
-
export function toAcpNotifications(content, role, sessionId, toolUseCache,
|
|
1072
|
+
export function toAcpNotifications(content, role, sessionId, toolUseCache, client, logger) {
|
|
754
1073
|
if (typeof content === "string") {
|
|
755
1074
|
return [
|
|
756
1075
|
{
|
|
@@ -857,7 +1176,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, fileC
|
|
|
857
1176
|
sessionUpdate: "tool_call",
|
|
858
1177
|
rawInput,
|
|
859
1178
|
status: "pending",
|
|
860
|
-
...toolInfoFromToolUse(chunk
|
|
1179
|
+
...toolInfoFromToolUse(chunk),
|
|
861
1180
|
};
|
|
862
1181
|
}
|
|
863
1182
|
break;
|
|
@@ -908,13 +1227,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, fileC
|
|
|
908
1227
|
}
|
|
909
1228
|
return output;
|
|
910
1229
|
}
|
|
911
|
-
export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
|
|
1230
|
+
export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger) {
|
|
912
1231
|
const event = message.event;
|
|
913
1232
|
switch (event.type) {
|
|
914
1233
|
case "content_block_start":
|
|
915
|
-
return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache,
|
|
1234
|
+
return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger);
|
|
916
1235
|
case "content_block_delta":
|
|
917
|
-
return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache,
|
|
1236
|
+
return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger);
|
|
918
1237
|
// No content
|
|
919
1238
|
case "message_start":
|
|
920
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": {
|