opencode-pilot 0.18.3 → 0.19.0
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/README.md +20 -2
- package/examples/config.yaml +7 -1
- package/package.json +4 -2
- package/service/actions.js +284 -0
- package/service/worktree.js +50 -6
- package/test/integration/session-reuse.test.js +347 -0
- package/test/unit/actions.test.js +295 -2
- package/test/unit/worktree.test.js +150 -9
package/README.md
CHANGED
|
@@ -74,6 +74,23 @@ Session names for `my-prs-attention` indicate the condition: "Conflicts: {title}
|
|
|
74
74
|
|
|
75
75
|
Create prompt templates as markdown files in `~/.config/opencode/pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
|
|
76
76
|
|
|
77
|
+
### Session and Sandbox Reuse
|
|
78
|
+
|
|
79
|
+
By default, pilot reuses existing sessions and sandboxes to avoid duplicates:
|
|
80
|
+
|
|
81
|
+
- **Session reuse**: If a non-archived session already exists for the target directory, pilot appends to it instead of creating a new session. Archived sessions are never reused.
|
|
82
|
+
- **Sandbox reuse**: When `worktree: "new"` with a `worktree_name`, pilot first checks if a sandbox with that name already exists and reuses it.
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
defaults:
|
|
86
|
+
# Disable session reuse (always create new sessions)
|
|
87
|
+
reuse_active_session: false
|
|
88
|
+
# Disable sandbox reuse (always create new worktrees)
|
|
89
|
+
prefer_existing_sandbox: false
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
When multiple sessions exist for the same directory, pilot prefers idle sessions over busy ones, then selects the most recently updated.
|
|
93
|
+
|
|
77
94
|
### Worktree Support
|
|
78
95
|
|
|
79
96
|
Run sessions in isolated git worktrees instead of the main project directory. This uses OpenCode's built-in worktree management API to create and manage worktrees.
|
|
@@ -81,7 +98,7 @@ Run sessions in isolated git worktrees instead of the main project directory. Th
|
|
|
81
98
|
```yaml
|
|
82
99
|
sources:
|
|
83
100
|
- preset: github/my-issues
|
|
84
|
-
# Create a fresh worktree for each session
|
|
101
|
+
# Create a fresh worktree for each session (or reuse if name matches)
|
|
85
102
|
worktree: "new"
|
|
86
103
|
worktree_name: "issue-{number}" # Optional: name template
|
|
87
104
|
|
|
@@ -91,9 +108,10 @@ sources:
|
|
|
91
108
|
```
|
|
92
109
|
|
|
93
110
|
**Options:**
|
|
94
|
-
- `worktree: "new"` - Create a new worktree via OpenCode's API
|
|
111
|
+
- `worktree: "new"` - Create a new worktree via OpenCode's API (or reuse existing if name matches)
|
|
95
112
|
- `worktree: "name"` - Look up existing worktree by name from project sandboxes
|
|
96
113
|
- `worktree_name` - Template for naming new worktrees (only with `worktree: "new"`)
|
|
114
|
+
- `prefer_existing_sandbox: false` - Disable sandbox reuse for this source
|
|
97
115
|
|
|
98
116
|
## CLI Commands
|
|
99
117
|
|
package/examples/config.yaml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Example config.yaml for opencode-pilot
|
|
2
|
-
# Copy to ~/.config/opencode
|
|
2
|
+
# Copy to ~/.config/opencode/pilot/config.yaml
|
|
3
3
|
|
|
4
4
|
# Preferred OpenCode server port for attaching sessions
|
|
5
5
|
# When multiple OpenCode instances are running, pilot will attach new sessions
|
|
@@ -19,6 +19,12 @@ repos_dir: ~/code
|
|
|
19
19
|
defaults:
|
|
20
20
|
agent: plan
|
|
21
21
|
prompt: default
|
|
22
|
+
# Session reuse: append to existing non-archived session instead of creating new
|
|
23
|
+
# Default: true. Set to false to always create new sessions.
|
|
24
|
+
# reuse_active_session: true
|
|
25
|
+
# Sandbox reuse: reuse existing worktree/sandbox with matching name
|
|
26
|
+
# Default: true. Set to false to always create new sandboxes.
|
|
27
|
+
# prefer_existing_sandbox: true
|
|
22
28
|
|
|
23
29
|
sources:
|
|
24
30
|
# Presets - common patterns with sensible defaults
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-pilot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"description": "Automation daemon for OpenCode - polls for work and spawns sessions",
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"opencode-pilot": "./bin/opencode-pilot"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node --test test/unit/*.test.js"
|
|
28
|
+
"test": "node --test test/unit/*.test.js",
|
|
29
|
+
"test:integration": "node --test test/integration/*.test.js",
|
|
30
|
+
"test:all": "node --test test/unit/*.test.js test/integration/*.test.js"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
|
31
33
|
"@semantic-release/git": "^10.0.1",
|
package/service/actions.js
CHANGED
|
@@ -306,6 +306,258 @@ export function buildCommand(item, config) {
|
|
|
306
306
|
return `[API] POST /session?directory=${cwd} (title: "${sessionName}")`;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* List sessions from the OpenCode server
|
|
311
|
+
*
|
|
312
|
+
* @param {string} serverUrl - Server URL (e.g., "http://localhost:4096")
|
|
313
|
+
* @param {object} [options] - Options
|
|
314
|
+
* @param {string} [options.directory] - Filter by directory
|
|
315
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
316
|
+
* @returns {Promise<Array>} Array of session objects
|
|
317
|
+
*/
|
|
318
|
+
export async function listSessions(serverUrl, options = {}) {
|
|
319
|
+
const fetchFn = options.fetch || fetch;
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const url = new URL('/session', serverUrl);
|
|
323
|
+
if (options.directory) {
|
|
324
|
+
url.searchParams.set('directory', options.directory);
|
|
325
|
+
}
|
|
326
|
+
// Only get root sessions (not child/forked sessions)
|
|
327
|
+
url.searchParams.set('roots', 'true');
|
|
328
|
+
|
|
329
|
+
const response = await fetchFn(url.toString());
|
|
330
|
+
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
debug(`listSessions: ${serverUrl} returned ${response.status}`);
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const sessions = await response.json();
|
|
337
|
+
return Array.isArray(sessions) ? sessions : [];
|
|
338
|
+
} catch (err) {
|
|
339
|
+
debug(`listSessions: error - ${err.message}`);
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Check if a session is archived
|
|
346
|
+
* A session is archived if time.archived is set (it's a timestamp)
|
|
347
|
+
*
|
|
348
|
+
* @param {object} session - Session object from API
|
|
349
|
+
* @returns {boolean} True if session is archived
|
|
350
|
+
*/
|
|
351
|
+
export function isSessionArchived(session) {
|
|
352
|
+
return session?.time?.archived !== undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Get session statuses from the OpenCode server
|
|
357
|
+
* Returns a map of sessionId -> status (idle, busy, retry)
|
|
358
|
+
* Sessions not in the map are considered idle
|
|
359
|
+
*
|
|
360
|
+
* @param {string} serverUrl - Server URL
|
|
361
|
+
* @param {object} [options] - Options
|
|
362
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
363
|
+
* @returns {Promise<object>} Map of sessionId -> status object
|
|
364
|
+
*/
|
|
365
|
+
export async function getSessionStatuses(serverUrl, options = {}) {
|
|
366
|
+
const fetchFn = options.fetch || fetch;
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const response = await fetchFn(`${serverUrl}/session/status`);
|
|
370
|
+
|
|
371
|
+
if (!response.ok) {
|
|
372
|
+
debug(`getSessionStatuses: ${serverUrl} returned ${response.status}`);
|
|
373
|
+
return {};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return await response.json();
|
|
377
|
+
} catch (err) {
|
|
378
|
+
debug(`getSessionStatuses: error - ${err.message}`);
|
|
379
|
+
return {};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Find the best session to reuse from a list of candidates
|
|
385
|
+
* Prefers idle sessions, then most recently updated
|
|
386
|
+
*
|
|
387
|
+
* @param {Array} sessions - Array of non-archived sessions
|
|
388
|
+
* @param {object} statuses - Map of sessionId -> status from /session/status
|
|
389
|
+
* @returns {object|null} Best session to reuse, or null if none
|
|
390
|
+
*/
|
|
391
|
+
export function selectBestSession(sessions, statuses) {
|
|
392
|
+
if (!sessions || sessions.length === 0) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Separate idle vs busy/retry sessions
|
|
397
|
+
const idle = [];
|
|
398
|
+
const other = [];
|
|
399
|
+
|
|
400
|
+
for (const session of sessions) {
|
|
401
|
+
const status = statuses[session.id];
|
|
402
|
+
// Sessions not in statuses map are idle (per OpenCode behavior)
|
|
403
|
+
if (!status || status.type === 'idle') {
|
|
404
|
+
idle.push(session);
|
|
405
|
+
} else {
|
|
406
|
+
other.push(session);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Sort by most recently updated (highest time.updated first)
|
|
411
|
+
const sortByUpdated = (a, b) => (b.time?.updated || 0) - (a.time?.updated || 0);
|
|
412
|
+
|
|
413
|
+
// Prefer idle sessions
|
|
414
|
+
if (idle.length > 0) {
|
|
415
|
+
idle.sort(sortByUpdated);
|
|
416
|
+
return idle[0];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Fall back to busy/retry sessions (sorted by most recent)
|
|
420
|
+
if (other.length > 0) {
|
|
421
|
+
other.sort(sortByUpdated);
|
|
422
|
+
return other[0];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Send a message to an existing session
|
|
430
|
+
*
|
|
431
|
+
* @param {string} serverUrl - Server URL
|
|
432
|
+
* @param {string} sessionId - Session ID to send message to
|
|
433
|
+
* @param {string} directory - Working directory
|
|
434
|
+
* @param {string} prompt - The prompt/message to send
|
|
435
|
+
* @param {object} [options] - Options
|
|
436
|
+
* @param {string} [options.title] - Update session title (optional)
|
|
437
|
+
* @param {string} [options.agent] - Agent to use
|
|
438
|
+
* @param {string} [options.model] - Model to use
|
|
439
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
440
|
+
* @returns {Promise<object>} Result with sessionId, success, error
|
|
441
|
+
*/
|
|
442
|
+
export async function sendMessageToSession(serverUrl, sessionId, directory, prompt, options = {}) {
|
|
443
|
+
const fetchFn = options.fetch || fetch;
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
// Step 1: Update session title if provided
|
|
447
|
+
if (options.title) {
|
|
448
|
+
const updateUrl = new URL(`/session/${sessionId}`, serverUrl);
|
|
449
|
+
updateUrl.searchParams.set('directory', directory);
|
|
450
|
+
await fetchFn(updateUrl.toString(), {
|
|
451
|
+
method: 'PATCH',
|
|
452
|
+
headers: { 'Content-Type': 'application/json' },
|
|
453
|
+
body: JSON.stringify({ title: options.title }),
|
|
454
|
+
});
|
|
455
|
+
debug(`sendMessageToSession: updated title for session ${sessionId}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Step 2: Send the message
|
|
459
|
+
const messageUrl = new URL(`/session/${sessionId}/message`, serverUrl);
|
|
460
|
+
messageUrl.searchParams.set('directory', directory);
|
|
461
|
+
|
|
462
|
+
const messageBody = {
|
|
463
|
+
parts: [{ type: 'text', text: prompt }],
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
if (options.agent) {
|
|
467
|
+
messageBody.agent = options.agent;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (options.model) {
|
|
471
|
+
const [providerID, modelID] = options.model.includes('/')
|
|
472
|
+
? options.model.split('/', 2)
|
|
473
|
+
: ['anthropic', options.model];
|
|
474
|
+
messageBody.providerID = providerID;
|
|
475
|
+
messageBody.modelID = modelID;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Use AbortController with timeout (same pattern as createSessionViaApi)
|
|
479
|
+
const controller = new AbortController();
|
|
480
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const messageResponse = await fetchFn(messageUrl.toString(), {
|
|
484
|
+
method: 'POST',
|
|
485
|
+
headers: { 'Content-Type': 'application/json' },
|
|
486
|
+
body: JSON.stringify(messageBody),
|
|
487
|
+
signal: controller.signal,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
clearTimeout(timeoutId);
|
|
491
|
+
|
|
492
|
+
if (!messageResponse.ok) {
|
|
493
|
+
const errorText = await messageResponse.text();
|
|
494
|
+
throw new Error(`Failed to send message: ${messageResponse.status} ${errorText}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
debug(`sendMessageToSession: sent message to session ${sessionId}`);
|
|
498
|
+
} catch (abortErr) {
|
|
499
|
+
clearTimeout(timeoutId);
|
|
500
|
+
if (abortErr.name === 'AbortError') {
|
|
501
|
+
debug(`sendMessageToSession: message request started for session ${sessionId} (response aborted as expected)`);
|
|
502
|
+
} else {
|
|
503
|
+
throw abortErr;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
success: true,
|
|
509
|
+
sessionId,
|
|
510
|
+
directory,
|
|
511
|
+
reused: true,
|
|
512
|
+
};
|
|
513
|
+
} catch (err) {
|
|
514
|
+
debug(`sendMessageToSession: error - ${err.message}`);
|
|
515
|
+
return {
|
|
516
|
+
success: false,
|
|
517
|
+
error: err.message,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Find an existing session to reuse for the given directory
|
|
524
|
+
* Returns null if no suitable session found (archived sessions are excluded)
|
|
525
|
+
*
|
|
526
|
+
* @param {string} serverUrl - Server URL
|
|
527
|
+
* @param {string} directory - Working directory to match
|
|
528
|
+
* @param {object} [options] - Options
|
|
529
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
530
|
+
* @returns {Promise<object|null>} Session to reuse, or null
|
|
531
|
+
*/
|
|
532
|
+
export async function findReusableSession(serverUrl, directory, options = {}) {
|
|
533
|
+
// Get sessions for this directory
|
|
534
|
+
const sessions = await listSessions(serverUrl, {
|
|
535
|
+
directory,
|
|
536
|
+
fetch: options.fetch
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (sessions.length === 0) {
|
|
540
|
+
debug(`findReusableSession: no sessions found for ${directory}`);
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Filter out archived sessions
|
|
545
|
+
const activeSessions = sessions.filter(s => !isSessionArchived(s));
|
|
546
|
+
|
|
547
|
+
if (activeSessions.length === 0) {
|
|
548
|
+
debug(`findReusableSession: all ${sessions.length} sessions are archived for ${directory}`);
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
debug(`findReusableSession: found ${activeSessions.length} active sessions for ${directory}`);
|
|
553
|
+
|
|
554
|
+
// Get statuses to prefer idle sessions
|
|
555
|
+
const statuses = await getSessionStatuses(serverUrl, { fetch: options.fetch });
|
|
556
|
+
|
|
557
|
+
// Select the best session
|
|
558
|
+
return selectBestSession(activeSessions, statuses);
|
|
559
|
+
}
|
|
560
|
+
|
|
309
561
|
/**
|
|
310
562
|
* Create a session via the OpenCode HTTP API
|
|
311
563
|
*
|
|
@@ -500,6 +752,8 @@ export async function executeAction(item, config, options = {}) {
|
|
|
500
752
|
worktree: worktreeMode,
|
|
501
753
|
// Expand worktree_name template with item fields (e.g., "issue-{number}")
|
|
502
754
|
worktreeName: config.worktree_name ? expandTemplate(config.worktree_name, item) : undefined,
|
|
755
|
+
// Config flag to control sandbox reuse (default true)
|
|
756
|
+
preferExistingSandbox: config.prefer_existing_sandbox,
|
|
503
757
|
};
|
|
504
758
|
|
|
505
759
|
const worktreeResult = await resolveWorktreeDirectory(
|
|
@@ -513,6 +767,8 @@ export async function executeAction(item, config, options = {}) {
|
|
|
513
767
|
|
|
514
768
|
if (worktreeResult.worktreeCreated) {
|
|
515
769
|
debug(`executeAction: created new worktree at ${cwd}`);
|
|
770
|
+
} else if (worktreeResult.worktreeReused) {
|
|
771
|
+
debug(`executeAction: reusing existing sandbox at ${cwd}`);
|
|
516
772
|
} else if (worktreeResult.error) {
|
|
517
773
|
debug(`executeAction: worktree resolution warning - ${worktreeResult.error}`);
|
|
518
774
|
}
|
|
@@ -527,6 +783,34 @@ export async function executeAction(item, config, options = {}) {
|
|
|
527
783
|
? buildSessionName(config.session.name, item)
|
|
528
784
|
: (item.title || `session-${Date.now()}`);
|
|
529
785
|
|
|
786
|
+
// Check if we should try to reuse an existing session
|
|
787
|
+
const reuseActiveSession = config.reuse_active_session !== false; // default true
|
|
788
|
+
|
|
789
|
+
if (reuseActiveSession && !options.dryRun) {
|
|
790
|
+
const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
|
|
791
|
+
|
|
792
|
+
if (existingSession) {
|
|
793
|
+
debug(`executeAction: found reusable session ${existingSession.id} for ${cwd}`);
|
|
794
|
+
|
|
795
|
+
const reuseCommand = `[API] POST ${serverUrl}/session/${existingSession.id}/message (reusing session)`;
|
|
796
|
+
|
|
797
|
+
const result = await sendMessageToSession(serverUrl, existingSession.id, cwd, prompt, {
|
|
798
|
+
title: sessionTitle,
|
|
799
|
+
agent: config.agent,
|
|
800
|
+
model: config.model,
|
|
801
|
+
fetch: options.fetch,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
return {
|
|
805
|
+
command: reuseCommand,
|
|
806
|
+
success: result.success,
|
|
807
|
+
sessionId: result.sessionId,
|
|
808
|
+
sessionReused: true,
|
|
809
|
+
error: result.error,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
530
814
|
const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
|
|
531
815
|
debug(`executeAction: using HTTP API - ${apiCommand}`);
|
|
532
816
|
|
package/service/worktree.js
CHANGED
|
@@ -8,18 +8,26 @@
|
|
|
8
8
|
import { debug } from "./logger.js";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* List
|
|
11
|
+
* List existing worktrees from the OpenCode server
|
|
12
12
|
*
|
|
13
13
|
* @param {string} serverUrl - OpenCode server URL (e.g., "http://localhost:4096")
|
|
14
14
|
* @param {object} [options] - Options
|
|
15
|
+
* @param {string} [options.directory] - Project directory (required for global server)
|
|
15
16
|
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
16
|
-
* @returns {Promise<string[]>} Array of worktree
|
|
17
|
+
* @returns {Promise<string[]>} Array of worktree paths
|
|
17
18
|
*/
|
|
18
19
|
export async function listWorktrees(serverUrl, options = {}) {
|
|
19
20
|
const fetchFn = options.fetch || fetch;
|
|
20
21
|
|
|
21
22
|
try {
|
|
22
|
-
|
|
23
|
+
// Build URL with directory parameter if provided
|
|
24
|
+
// This tells the global server which project to list worktrees for
|
|
25
|
+
let url = `${serverUrl}/experimental/worktree`;
|
|
26
|
+
if (options.directory) {
|
|
27
|
+
url += `?directory=${encodeURIComponent(options.directory)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const response = await fetchFn(url);
|
|
23
31
|
|
|
24
32
|
if (!response.ok) {
|
|
25
33
|
debug(`listWorktrees: ${serverUrl} returned ${response.status}`);
|
|
@@ -162,6 +170,25 @@ export async function getProjectInfoForDirectory(serverUrl, directory, options =
|
|
|
162
170
|
}
|
|
163
171
|
}
|
|
164
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Find an existing worktree by exact name match
|
|
175
|
+
*
|
|
176
|
+
* @param {string[]} worktrees - List of worktree paths
|
|
177
|
+
* @param {string} name - Name to match (final path component)
|
|
178
|
+
* @returns {string|null} Matching worktree path or null
|
|
179
|
+
*/
|
|
180
|
+
function findWorktreeByName(worktrees, name) {
|
|
181
|
+
for (const wt of worktrees) {
|
|
182
|
+
// Match exact final path component
|
|
183
|
+
const parts = wt.split('/');
|
|
184
|
+
const finalComponent = parts[parts.length - 1];
|
|
185
|
+
if (finalComponent === name) {
|
|
186
|
+
return wt;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
165
192
|
/**
|
|
166
193
|
* Resolve the working directory based on worktree configuration
|
|
167
194
|
*
|
|
@@ -174,9 +201,10 @@ export async function getProjectInfoForDirectory(serverUrl, directory, options =
|
|
|
174
201
|
* @param {object} worktreeConfig - Worktree configuration
|
|
175
202
|
* @param {string} [worktreeConfig.worktree] - Worktree mode: "new" or worktree name
|
|
176
203
|
* @param {string} [worktreeConfig.worktreeName] - Name for new worktree (only with "new")
|
|
204
|
+
* @param {boolean} [worktreeConfig.preferExistingSandbox] - If true (default), reuse existing sandbox with matching name
|
|
177
205
|
* @param {object} [options] - Options
|
|
178
206
|
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
179
|
-
* @returns {Promise<object>} Result with { directory, worktreeCreated?, error? }
|
|
207
|
+
* @returns {Promise<object>} Result with { directory, worktreeCreated?, worktreeReused?, error? }
|
|
180
208
|
*/
|
|
181
209
|
export async function resolveWorktreeDirectory(serverUrl, baseDir, worktreeConfig, options = {}) {
|
|
182
210
|
// No worktree config - use base directory
|
|
@@ -193,9 +221,25 @@ export async function resolveWorktreeDirectory(serverUrl, baseDir, worktreeConfi
|
|
|
193
221
|
}
|
|
194
222
|
|
|
195
223
|
const worktreeValue = worktreeConfig.worktree;
|
|
224
|
+
const preferExisting = worktreeConfig.preferExistingSandbox !== false; // default true
|
|
196
225
|
|
|
197
|
-
// "new" - create a fresh worktree via OpenCode API
|
|
226
|
+
// "new" - create a fresh worktree via OpenCode API (or reuse if matching name exists)
|
|
198
227
|
if (worktreeValue === "new") {
|
|
228
|
+
// If worktreeName is provided and preferExisting is true, try to reuse existing
|
|
229
|
+
if (worktreeConfig.worktreeName && preferExisting) {
|
|
230
|
+
const worktrees = await listWorktrees(serverUrl, { ...options, directory: baseDir });
|
|
231
|
+
const existingMatch = findWorktreeByName(worktrees, worktreeConfig.worktreeName);
|
|
232
|
+
|
|
233
|
+
if (existingMatch) {
|
|
234
|
+
debug(`resolveWorktreeDirectory: reusing existing sandbox "${worktreeConfig.worktreeName}" at ${existingMatch}`);
|
|
235
|
+
return {
|
|
236
|
+
directory: existingMatch,
|
|
237
|
+
worktreeReused: true,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// No existing match found (or not looking), create new
|
|
199
243
|
const result = await createWorktree(serverUrl, {
|
|
200
244
|
directory: baseDir,
|
|
201
245
|
name: worktreeConfig.worktreeName,
|
|
@@ -217,7 +261,7 @@ export async function resolveWorktreeDirectory(serverUrl, baseDir, worktreeConfi
|
|
|
217
261
|
}
|
|
218
262
|
|
|
219
263
|
// Named worktree - look it up from available sandboxes via OpenCode API
|
|
220
|
-
const worktrees = await listWorktrees(serverUrl, options);
|
|
264
|
+
const worktrees = await listWorktrees(serverUrl, { ...options, directory: baseDir });
|
|
221
265
|
const match = worktrees.find(w => w.includes(worktreeValue));
|
|
222
266
|
if (match) {
|
|
223
267
|
return { directory: match };
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for session and sandbox reuse
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the actual API interactions work correctly.
|
|
5
|
+
* They use a mock server that simulates OpenCode's behavior.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
8
|
+
import assert from "node:assert";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
listSessions,
|
|
13
|
+
findReusableSession,
|
|
14
|
+
isSessionArchived,
|
|
15
|
+
sendMessageToSession,
|
|
16
|
+
executeAction,
|
|
17
|
+
} from "../../service/actions.js";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
listWorktrees,
|
|
21
|
+
resolveWorktreeDirectory,
|
|
22
|
+
} from "../../service/worktree.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a mock OpenCode server for testing
|
|
26
|
+
*/
|
|
27
|
+
function createMockServer(handlers = {}) {
|
|
28
|
+
const server = createServer((req, res) => {
|
|
29
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
30
|
+
const path = url.pathname;
|
|
31
|
+
const method = req.method;
|
|
32
|
+
const directory = url.searchParams.get("directory");
|
|
33
|
+
|
|
34
|
+
// Collect request body
|
|
35
|
+
let body = "";
|
|
36
|
+
req.on("data", (chunk) => (body += chunk));
|
|
37
|
+
req.on("end", () => {
|
|
38
|
+
const request = {
|
|
39
|
+
method,
|
|
40
|
+
path,
|
|
41
|
+
directory,
|
|
42
|
+
query: Object.fromEntries(url.searchParams),
|
|
43
|
+
body: body ? JSON.parse(body) : null,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Find matching handler
|
|
47
|
+
const handlerKey = `${method} ${path}`;
|
|
48
|
+
const handler = handlers[handlerKey] || handlers.default;
|
|
49
|
+
|
|
50
|
+
if (handler) {
|
|
51
|
+
const result = handler(request);
|
|
52
|
+
res.writeHead(result.status || 200, { "Content-Type": "application/json" });
|
|
53
|
+
res.end(JSON.stringify(result.body));
|
|
54
|
+
} else {
|
|
55
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
56
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
server.listen(0, "127.0.0.1", () => {
|
|
63
|
+
const { port } = server.address();
|
|
64
|
+
resolve({
|
|
65
|
+
url: `http://127.0.0.1:${port}`,
|
|
66
|
+
server,
|
|
67
|
+
close: () => new Promise((r) => server.close(r)),
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("integration: session reuse", () => {
|
|
74
|
+
let mockServer;
|
|
75
|
+
|
|
76
|
+
afterEach(async () => {
|
|
77
|
+
if (mockServer) {
|
|
78
|
+
await mockServer.close();
|
|
79
|
+
mockServer = null;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("listSessions passes directory parameter to server", async () => {
|
|
84
|
+
let receivedDirectory = null;
|
|
85
|
+
|
|
86
|
+
mockServer = await createMockServer({
|
|
87
|
+
"GET /session": (req) => {
|
|
88
|
+
receivedDirectory = req.directory;
|
|
89
|
+
return {
|
|
90
|
+
body: [
|
|
91
|
+
{ id: "ses_1", directory: "/path/to/project", time: { created: 1000, updated: 2000 } },
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const sessions = await listSessions(mockServer.url, { directory: "/path/to/project" });
|
|
98
|
+
|
|
99
|
+
assert.strictEqual(sessions.length, 1);
|
|
100
|
+
assert.strictEqual(receivedDirectory, "/path/to/project", "Server should receive directory parameter");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("findReusableSession filters out archived sessions", async () => {
|
|
104
|
+
mockServer = await createMockServer({
|
|
105
|
+
"GET /session": () => ({
|
|
106
|
+
body: [
|
|
107
|
+
{ id: "ses_archived", directory: "/proj", time: { created: 1000, updated: 3000, archived: 4000 } },
|
|
108
|
+
{ id: "ses_active", directory: "/proj", time: { created: 2000, updated: 2500 } },
|
|
109
|
+
],
|
|
110
|
+
}),
|
|
111
|
+
"GET /session/status": () => ({ body: {} }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const session = await findReusableSession(mockServer.url, "/proj");
|
|
115
|
+
|
|
116
|
+
assert.ok(session, "Should find a session");
|
|
117
|
+
assert.strictEqual(session.id, "ses_active", "Should return the active session, not archived");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("findReusableSession prefers idle sessions over busy", async () => {
|
|
121
|
+
mockServer = await createMockServer({
|
|
122
|
+
"GET /session": () => ({
|
|
123
|
+
body: [
|
|
124
|
+
{ id: "ses_busy", directory: "/proj", time: { created: 1000, updated: 3000 } },
|
|
125
|
+
{ id: "ses_idle", directory: "/proj", time: { created: 2000, updated: 2000 } },
|
|
126
|
+
],
|
|
127
|
+
}),
|
|
128
|
+
"GET /session/status": () => ({
|
|
129
|
+
body: { ses_busy: { type: "busy" } },
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const session = await findReusableSession(mockServer.url, "/proj");
|
|
134
|
+
|
|
135
|
+
assert.strictEqual(session.id, "ses_idle", "Should prefer idle session even with older update time");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("sendMessageToSession updates title and posts message", async () => {
|
|
139
|
+
let titleUpdated = false;
|
|
140
|
+
let messagePosted = false;
|
|
141
|
+
let postedBody = null;
|
|
142
|
+
|
|
143
|
+
mockServer = await createMockServer({
|
|
144
|
+
"PATCH /session/ses_123": (req) => {
|
|
145
|
+
titleUpdated = req.body?.title === "New Title";
|
|
146
|
+
return { body: {} };
|
|
147
|
+
},
|
|
148
|
+
"POST /session/ses_123/message": (req) => {
|
|
149
|
+
messagePosted = true;
|
|
150
|
+
postedBody = req.body;
|
|
151
|
+
return { body: { success: true } };
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const result = await sendMessageToSession(
|
|
156
|
+
mockServer.url,
|
|
157
|
+
"ses_123",
|
|
158
|
+
"/proj",
|
|
159
|
+
"Hello world",
|
|
160
|
+
{ title: "New Title", agent: "plan" }
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
assert.ok(result.success);
|
|
164
|
+
assert.strictEqual(result.reused, true);
|
|
165
|
+
assert.ok(titleUpdated, "Should update session title");
|
|
166
|
+
assert.ok(messagePosted, "Should post message");
|
|
167
|
+
assert.strictEqual(postedBody.parts[0].text, "Hello world");
|
|
168
|
+
assert.strictEqual(postedBody.agent, "plan");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("executeAction reuses existing session when available", async () => {
|
|
172
|
+
let sessionCreated = false;
|
|
173
|
+
let messageSessionId = null;
|
|
174
|
+
|
|
175
|
+
mockServer = await createMockServer({
|
|
176
|
+
"GET /session": () => ({
|
|
177
|
+
body: [{ id: "ses_existing", directory: "/proj", time: { created: 1000, updated: 2000 } }],
|
|
178
|
+
}),
|
|
179
|
+
"GET /session/status": () => ({ body: {} }),
|
|
180
|
+
"POST /session": () => {
|
|
181
|
+
sessionCreated = true;
|
|
182
|
+
return { body: { id: "ses_new" } };
|
|
183
|
+
},
|
|
184
|
+
"PATCH /session/ses_existing": () => ({ body: {} }),
|
|
185
|
+
"POST /session/ses_existing/message": (req) => {
|
|
186
|
+
messageSessionId = "ses_existing";
|
|
187
|
+
return { body: { success: true } };
|
|
188
|
+
},
|
|
189
|
+
"GET /project/current": () => ({
|
|
190
|
+
body: { id: "proj_1", worktree: "/proj", time: { created: 1000, updated: 2000 }, sandboxes: [] },
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const result = await executeAction(
|
|
195
|
+
{ number: 123, title: "Test issue" },
|
|
196
|
+
{ path: "/proj", prompt: "default" },
|
|
197
|
+
{ discoverServer: async () => mockServer.url }
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
assert.ok(result.success);
|
|
201
|
+
assert.strictEqual(result.sessionReused, true, "Should indicate session was reused");
|
|
202
|
+
assert.strictEqual(sessionCreated, false, "Should NOT create new session");
|
|
203
|
+
assert.strictEqual(messageSessionId, "ses_existing", "Should post to existing session");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("executeAction creates new session when all existing are archived", async () => {
|
|
207
|
+
let sessionCreated = false;
|
|
208
|
+
|
|
209
|
+
mockServer = await createMockServer({
|
|
210
|
+
"GET /session": () => ({
|
|
211
|
+
body: [{ id: "ses_archived", directory: "/proj", time: { created: 1000, archived: 2000 } }],
|
|
212
|
+
}),
|
|
213
|
+
"GET /session/status": () => ({ body: {} }),
|
|
214
|
+
"POST /session": () => {
|
|
215
|
+
sessionCreated = true;
|
|
216
|
+
return { body: { id: "ses_new" } };
|
|
217
|
+
},
|
|
218
|
+
"PATCH /session/ses_new": () => ({ body: {} }),
|
|
219
|
+
"POST /session/ses_new/message": () => ({ body: { success: true } }),
|
|
220
|
+
"GET /project/current": () => ({
|
|
221
|
+
body: { id: "proj_1", worktree: "/proj", time: { created: 1000, updated: 2000 }, sandboxes: [] },
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const result = await executeAction(
|
|
226
|
+
{ number: 456, title: "Test" },
|
|
227
|
+
{ path: "/proj", prompt: "default" },
|
|
228
|
+
{ discoverServer: async () => mockServer.url }
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
assert.ok(result.success);
|
|
232
|
+
assert.strictEqual(result.sessionReused, undefined, "Should NOT indicate session was reused");
|
|
233
|
+
assert.ok(sessionCreated, "Should create new session when existing is archived");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("integration: sandbox reuse", () => {
|
|
238
|
+
let mockServer;
|
|
239
|
+
|
|
240
|
+
afterEach(async () => {
|
|
241
|
+
if (mockServer) {
|
|
242
|
+
await mockServer.close();
|
|
243
|
+
mockServer = null;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("listWorktrees passes directory parameter to server", async () => {
|
|
248
|
+
let receivedDirectory = null;
|
|
249
|
+
|
|
250
|
+
mockServer = await createMockServer({
|
|
251
|
+
"GET /experimental/worktree": (req) => {
|
|
252
|
+
receivedDirectory = req.directory;
|
|
253
|
+
return { body: ["/worktree/branch-1"] };
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const worktrees = await listWorktrees(mockServer.url, { directory: "/path/to/project" });
|
|
258
|
+
|
|
259
|
+
assert.strictEqual(worktrees.length, 1);
|
|
260
|
+
assert.strictEqual(receivedDirectory, "/path/to/project", "Server should receive directory parameter");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("resolveWorktreeDirectory reuses existing sandbox when name matches", async () => {
|
|
264
|
+
let postCalled = false;
|
|
265
|
+
let listDirectory = null;
|
|
266
|
+
|
|
267
|
+
mockServer = await createMockServer({
|
|
268
|
+
"GET /experimental/worktree": (req) => {
|
|
269
|
+
listDirectory = req.directory;
|
|
270
|
+
return {
|
|
271
|
+
body: [
|
|
272
|
+
"/worktree/other-branch",
|
|
273
|
+
"/worktree/my-feature",
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
"POST /experimental/worktree": () => {
|
|
278
|
+
postCalled = true;
|
|
279
|
+
return { body: { name: "my-feature", directory: "/worktree/my-feature-new" } };
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const result = await resolveWorktreeDirectory(
|
|
284
|
+
mockServer.url,
|
|
285
|
+
"/path/to/project",
|
|
286
|
+
{ worktree: "new", worktreeName: "my-feature" }
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
assert.strictEqual(result.directory, "/worktree/my-feature");
|
|
290
|
+
assert.strictEqual(result.worktreeReused, true);
|
|
291
|
+
assert.strictEqual(postCalled, false, "Should NOT create new worktree");
|
|
292
|
+
assert.strictEqual(listDirectory, "/path/to/project", "Should pass directory to list worktrees");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("resolveWorktreeDirectory creates new sandbox when name doesn't match", async () => {
|
|
296
|
+
let postCalled = false;
|
|
297
|
+
let postDirectory = null;
|
|
298
|
+
|
|
299
|
+
mockServer = await createMockServer({
|
|
300
|
+
"GET /experimental/worktree": () => ({
|
|
301
|
+
body: ["/worktree/other-branch"],
|
|
302
|
+
}),
|
|
303
|
+
"POST /experimental/worktree": (req) => {
|
|
304
|
+
postCalled = true;
|
|
305
|
+
postDirectory = req.directory;
|
|
306
|
+
return {
|
|
307
|
+
body: {
|
|
308
|
+
name: "new-feature",
|
|
309
|
+
branch: "opencode/new-feature",
|
|
310
|
+
directory: "/worktree/new-feature",
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const result = await resolveWorktreeDirectory(
|
|
317
|
+
mockServer.url,
|
|
318
|
+
"/path/to/project",
|
|
319
|
+
{ worktree: "new", worktreeName: "new-feature" }
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
assert.strictEqual(result.directory, "/worktree/new-feature");
|
|
323
|
+
assert.strictEqual(result.worktreeCreated, true);
|
|
324
|
+
assert.ok(postCalled, "Should create new worktree");
|
|
325
|
+
assert.strictEqual(postDirectory, "/path/to/project", "Should pass directory to create worktree");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("resolveWorktreeDirectory passes directory when looking up named worktree", async () => {
|
|
329
|
+
let listDirectory = null;
|
|
330
|
+
|
|
331
|
+
mockServer = await createMockServer({
|
|
332
|
+
"GET /experimental/worktree": (req) => {
|
|
333
|
+
listDirectory = req.directory;
|
|
334
|
+
return { body: ["/worktree/my-branch"] };
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const result = await resolveWorktreeDirectory(
|
|
339
|
+
mockServer.url,
|
|
340
|
+
"/path/to/project",
|
|
341
|
+
{ worktree: "my-branch" }
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
assert.strictEqual(result.directory, "/worktree/my-branch");
|
|
345
|
+
assert.strictEqual(listDirectory, "/path/to/project", "Should pass directory when looking up named worktree");
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -567,9 +567,9 @@ describe('actions.js', () => {
|
|
|
567
567
|
// Mock server discovery
|
|
568
568
|
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
569
569
|
|
|
570
|
-
// Mock worktree list lookup
|
|
570
|
+
// Mock worktree list lookup - now includes directory param
|
|
571
571
|
const mockFetch = async (url) => {
|
|
572
|
-
if (url
|
|
572
|
+
if (url.includes('/experimental/worktree')) {
|
|
573
573
|
return {
|
|
574
574
|
ok: true,
|
|
575
575
|
json: async () => [
|
|
@@ -904,4 +904,297 @@ describe('actions.js', () => {
|
|
|
904
904
|
assert.ok(result.warning.includes('Failed to send message'), 'Warning should mention message failure');
|
|
905
905
|
});
|
|
906
906
|
});
|
|
907
|
+
|
|
908
|
+
describe('session reuse', () => {
|
|
909
|
+
test('isSessionArchived returns true when time.archived is set', async () => {
|
|
910
|
+
const { isSessionArchived } = await import('../../service/actions.js');
|
|
911
|
+
|
|
912
|
+
// Archived session (time.archived is a timestamp)
|
|
913
|
+
const archivedSession = { id: 'ses_1', time: { created: 1000, updated: 2000, archived: 3000 } };
|
|
914
|
+
assert.strictEqual(isSessionArchived(archivedSession), true);
|
|
915
|
+
|
|
916
|
+
// Active session (no time.archived)
|
|
917
|
+
const activeSession = { id: 'ses_2', time: { created: 1000, updated: 2000 } };
|
|
918
|
+
assert.strictEqual(isSessionArchived(activeSession), false);
|
|
919
|
+
|
|
920
|
+
// Handle edge cases
|
|
921
|
+
assert.strictEqual(isSessionArchived(null), false);
|
|
922
|
+
assert.strictEqual(isSessionArchived({}), false);
|
|
923
|
+
assert.strictEqual(isSessionArchived({ time: {} }), false);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test('selectBestSession prefers idle sessions', async () => {
|
|
927
|
+
const { selectBestSession } = await import('../../service/actions.js');
|
|
928
|
+
|
|
929
|
+
const sessions = [
|
|
930
|
+
{ id: 'ses_busy', time: { updated: 3000 } },
|
|
931
|
+
{ id: 'ses_idle', time: { updated: 2000 } },
|
|
932
|
+
{ id: 'ses_retry', time: { updated: 1000 } },
|
|
933
|
+
];
|
|
934
|
+
|
|
935
|
+
const statuses = {
|
|
936
|
+
'ses_busy': { type: 'busy' },
|
|
937
|
+
'ses_retry': { type: 'retry', attempt: 1, message: 'error', next: 5000 },
|
|
938
|
+
// ses_idle not in statuses = idle
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
const best = selectBestSession(sessions, statuses);
|
|
942
|
+
assert.strictEqual(best.id, 'ses_idle', 'Should prefer idle session even with older updated time');
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test('selectBestSession falls back to most recently updated when all busy', async () => {
|
|
946
|
+
const { selectBestSession } = await import('../../service/actions.js');
|
|
947
|
+
|
|
948
|
+
const sessions = [
|
|
949
|
+
{ id: 'ses_1', time: { updated: 1000 } },
|
|
950
|
+
{ id: 'ses_2', time: { updated: 3000 } }, // most recent
|
|
951
|
+
{ id: 'ses_3', time: { updated: 2000 } },
|
|
952
|
+
];
|
|
953
|
+
|
|
954
|
+
const statuses = {
|
|
955
|
+
'ses_1': { type: 'busy' },
|
|
956
|
+
'ses_2': { type: 'busy' },
|
|
957
|
+
'ses_3': { type: 'busy' },
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const best = selectBestSession(sessions, statuses);
|
|
961
|
+
assert.strictEqual(best.id, 'ses_2', 'Should select most recently updated when all busy');
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test('selectBestSession returns null for empty array', async () => {
|
|
965
|
+
const { selectBestSession } = await import('../../service/actions.js');
|
|
966
|
+
|
|
967
|
+
assert.strictEqual(selectBestSession([], {}), null);
|
|
968
|
+
assert.strictEqual(selectBestSession(null, {}), null);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test('listSessions fetches sessions filtered by directory', async () => {
|
|
972
|
+
const { listSessions } = await import('../../service/actions.js');
|
|
973
|
+
|
|
974
|
+
let calledUrl = null;
|
|
975
|
+
const mockFetch = async (url) => {
|
|
976
|
+
calledUrl = url;
|
|
977
|
+
return {
|
|
978
|
+
ok: true,
|
|
979
|
+
json: async () => [
|
|
980
|
+
{ id: 'ses_1', directory: '/path/to/project', time: { created: 1000 } },
|
|
981
|
+
],
|
|
982
|
+
};
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
const sessions = await listSessions('http://localhost:4096', {
|
|
986
|
+
directory: '/path/to/project',
|
|
987
|
+
fetch: mockFetch
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
assert.ok(calledUrl.includes('directory='), 'Should include directory param');
|
|
991
|
+
assert.ok(calledUrl.includes('roots=true'), 'Should only get root sessions');
|
|
992
|
+
assert.strictEqual(sessions.length, 1);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
test('findReusableSession filters out archived sessions', async () => {
|
|
996
|
+
const { findReusableSession } = await import('../../service/actions.js');
|
|
997
|
+
|
|
998
|
+
const mockFetch = async (url) => {
|
|
999
|
+
if (url.includes('/session/status')) {
|
|
1000
|
+
return { ok: true, json: async () => ({}) };
|
|
1001
|
+
}
|
|
1002
|
+
// GET /session
|
|
1003
|
+
return {
|
|
1004
|
+
ok: true,
|
|
1005
|
+
json: async () => [
|
|
1006
|
+
{ id: 'ses_archived', directory: '/path', time: { created: 1000, updated: 3000, archived: 4000 } },
|
|
1007
|
+
{ id: 'ses_active', directory: '/path', time: { created: 2000, updated: 2500 } },
|
|
1008
|
+
],
|
|
1009
|
+
};
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
const session = await findReusableSession('http://localhost:4096', '/path', { fetch: mockFetch });
|
|
1013
|
+
|
|
1014
|
+
assert.ok(session, 'Should find a session');
|
|
1015
|
+
assert.strictEqual(session.id, 'ses_active', 'Should return the active session, not archived');
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test('findReusableSession returns null when all sessions are archived', async () => {
|
|
1019
|
+
const { findReusableSession } = await import('../../service/actions.js');
|
|
1020
|
+
|
|
1021
|
+
const mockFetch = async (url) => {
|
|
1022
|
+
if (url.includes('/session/status')) {
|
|
1023
|
+
return { ok: true, json: async () => ({}) };
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
1026
|
+
ok: true,
|
|
1027
|
+
json: async () => [
|
|
1028
|
+
{ id: 'ses_1', directory: '/path', time: { created: 1000, archived: 2000 } },
|
|
1029
|
+
{ id: 'ses_2', directory: '/path', time: { created: 1500, archived: 2500 } },
|
|
1030
|
+
],
|
|
1031
|
+
};
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
const session = await findReusableSession('http://localhost:4096', '/path', { fetch: mockFetch });
|
|
1035
|
+
|
|
1036
|
+
assert.strictEqual(session, null, 'Should return null when all sessions are archived');
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
test('executeAction reuses existing session instead of creating new', async () => {
|
|
1040
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
1041
|
+
|
|
1042
|
+
const item = { number: 123, title: 'Fix bug' };
|
|
1043
|
+
const config = {
|
|
1044
|
+
path: tempDir,
|
|
1045
|
+
prompt: 'default',
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
let sessionCreated = false;
|
|
1049
|
+
let messagePosted = false;
|
|
1050
|
+
let messageSessionId = null;
|
|
1051
|
+
|
|
1052
|
+
const mockFetch = async (url, opts) => {
|
|
1053
|
+
// GET /session - return existing active session
|
|
1054
|
+
if (url.includes('/session') && !url.includes('/message') && !url.includes('/status') && (!opts || opts.method !== 'POST' && opts.method !== 'PATCH')) {
|
|
1055
|
+
return {
|
|
1056
|
+
ok: true,
|
|
1057
|
+
json: async () => [
|
|
1058
|
+
{ id: 'ses_existing', directory: tempDir, time: { created: 1000, updated: 2000 } },
|
|
1059
|
+
],
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
// GET /session/status
|
|
1063
|
+
if (url.includes('/session/status')) {
|
|
1064
|
+
return { ok: true, json: async () => ({}) }; // session is idle
|
|
1065
|
+
}
|
|
1066
|
+
// POST /session (create) - should NOT be called
|
|
1067
|
+
if (url.endsWith('/session') && opts?.method === 'POST') {
|
|
1068
|
+
sessionCreated = true;
|
|
1069
|
+
return { ok: true, json: async () => ({ id: 'ses_new' }) };
|
|
1070
|
+
}
|
|
1071
|
+
// PATCH /session/:id (update title)
|
|
1072
|
+
if (opts?.method === 'PATCH') {
|
|
1073
|
+
return { ok: true, json: async () => ({}) };
|
|
1074
|
+
}
|
|
1075
|
+
// POST /session/:id/message
|
|
1076
|
+
if (url.includes('/message') && opts?.method === 'POST') {
|
|
1077
|
+
messagePosted = true;
|
|
1078
|
+
messageSessionId = url.match(/session\/([^/]+)\/message/)?.[1];
|
|
1079
|
+
return { ok: true, json: async () => ({ success: true }) };
|
|
1080
|
+
}
|
|
1081
|
+
return { ok: false, text: async () => 'Not found' };
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
1085
|
+
|
|
1086
|
+
const result = await executeAction(item, config, {
|
|
1087
|
+
discoverServer: mockDiscoverServer,
|
|
1088
|
+
fetch: mockFetch
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
assert.ok(result.success, 'Should succeed');
|
|
1092
|
+
assert.strictEqual(result.sessionId, 'ses_existing', 'Should use existing session ID');
|
|
1093
|
+
assert.strictEqual(result.sessionReused, true, 'Should indicate session was reused');
|
|
1094
|
+
assert.strictEqual(sessionCreated, false, 'Should NOT create a new session');
|
|
1095
|
+
assert.strictEqual(messagePosted, true, 'Should post message to existing session');
|
|
1096
|
+
assert.strictEqual(messageSessionId, 'ses_existing', 'Should post to the existing session');
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
test('executeAction creates new session when existing is archived', async () => {
|
|
1100
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
1101
|
+
|
|
1102
|
+
const item = { number: 456, title: 'New feature' };
|
|
1103
|
+
const config = {
|
|
1104
|
+
path: tempDir,
|
|
1105
|
+
prompt: 'default',
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
let sessionCreated = false;
|
|
1109
|
+
|
|
1110
|
+
const mockFetch = async (url, opts) => {
|
|
1111
|
+
// GET /session - return only archived session
|
|
1112
|
+
if (url.includes('/session') && !url.includes('/message') && !url.includes('/status') && (!opts || opts.method !== 'POST' && opts.method !== 'PATCH')) {
|
|
1113
|
+
return {
|
|
1114
|
+
ok: true,
|
|
1115
|
+
json: async () => [
|
|
1116
|
+
{ id: 'ses_archived', directory: tempDir, time: { created: 1000, updated: 2000, archived: 3000 } },
|
|
1117
|
+
],
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
// GET /session/status
|
|
1121
|
+
if (url.includes('/session/status')) {
|
|
1122
|
+
return { ok: true, json: async () => ({}) };
|
|
1123
|
+
}
|
|
1124
|
+
// POST /session (create) - should be called since archived session can't be reused
|
|
1125
|
+
if (url.includes('/session') && !url.includes('/message') && opts?.method === 'POST') {
|
|
1126
|
+
sessionCreated = true;
|
|
1127
|
+
return { ok: true, json: async () => ({ id: 'ses_new' }) };
|
|
1128
|
+
}
|
|
1129
|
+
// PATCH /session/:id
|
|
1130
|
+
if (opts?.method === 'PATCH') {
|
|
1131
|
+
return { ok: true, json: async () => ({}) };
|
|
1132
|
+
}
|
|
1133
|
+
// POST /session/:id/message
|
|
1134
|
+
if (url.includes('/message') && opts?.method === 'POST') {
|
|
1135
|
+
return { ok: true, json: async () => ({ success: true }) };
|
|
1136
|
+
}
|
|
1137
|
+
return { ok: false, text: async () => 'Not found' };
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
1141
|
+
|
|
1142
|
+
const result = await executeAction(item, config, {
|
|
1143
|
+
discoverServer: mockDiscoverServer,
|
|
1144
|
+
fetch: mockFetch
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
assert.ok(result.success, 'Should succeed');
|
|
1148
|
+
assert.strictEqual(result.sessionId, 'ses_new', 'Should create new session');
|
|
1149
|
+
assert.strictEqual(result.sessionReused, undefined, 'Should NOT indicate session was reused');
|
|
1150
|
+
assert.strictEqual(sessionCreated, true, 'Should create a new session when existing is archived');
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
test('executeAction skips session reuse when reuse_active_session is false', async () => {
|
|
1154
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
1155
|
+
|
|
1156
|
+
const item = { number: 789, title: 'Forced new' };
|
|
1157
|
+
const config = {
|
|
1158
|
+
path: tempDir,
|
|
1159
|
+
prompt: 'default',
|
|
1160
|
+
reuse_active_session: false, // disable reuse
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
let sessionListCalled = false;
|
|
1164
|
+
let sessionCreated = false;
|
|
1165
|
+
|
|
1166
|
+
const mockFetch = async (url, opts) => {
|
|
1167
|
+
// GET /session - should NOT be called
|
|
1168
|
+
if (url.includes('/session') && !url.includes('/message') && !url.includes('/status') && (!opts || opts.method !== 'POST' && opts.method !== 'PATCH')) {
|
|
1169
|
+
sessionListCalled = true;
|
|
1170
|
+
return { ok: true, json: async () => [] };
|
|
1171
|
+
}
|
|
1172
|
+
// POST /session
|
|
1173
|
+
if (url.includes('/session') && !url.includes('/message') && opts?.method === 'POST') {
|
|
1174
|
+
sessionCreated = true;
|
|
1175
|
+
return { ok: true, json: async () => ({ id: 'ses_forced_new' }) };
|
|
1176
|
+
}
|
|
1177
|
+
// PATCH
|
|
1178
|
+
if (opts?.method === 'PATCH') {
|
|
1179
|
+
return { ok: true, json: async () => ({}) };
|
|
1180
|
+
}
|
|
1181
|
+
// POST message
|
|
1182
|
+
if (url.includes('/message') && opts?.method === 'POST') {
|
|
1183
|
+
return { ok: true, json: async () => ({ success: true }) };
|
|
1184
|
+
}
|
|
1185
|
+
return { ok: false, text: async () => 'Not found' };
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
1189
|
+
|
|
1190
|
+
const result = await executeAction(item, config, {
|
|
1191
|
+
discoverServer: mockDiscoverServer,
|
|
1192
|
+
fetch: mockFetch
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
assert.ok(result.success, 'Should succeed');
|
|
1196
|
+
assert.strictEqual(sessionListCalled, false, 'Should NOT list sessions when reuse disabled');
|
|
1197
|
+
assert.strictEqual(sessionCreated, true, 'Should create new session directly');
|
|
1198
|
+
});
|
|
1199
|
+
});
|
|
907
1200
|
});
|
|
@@ -22,6 +22,22 @@ describe("worktree", () => {
|
|
|
22
22
|
assert.strictEqual(mockFetch.mock.calls[0].arguments[0], "http://localhost:4096/experimental/worktree");
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
it("passes directory parameter when provided", async () => {
|
|
26
|
+
const mockFetch = mock.fn(async () => ({
|
|
27
|
+
ok: true,
|
|
28
|
+
json: async () => ["/path/to/worktree"],
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
await listWorktrees("http://localhost:4096", {
|
|
32
|
+
fetch: mockFetch,
|
|
33
|
+
directory: "/path/to/project"
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const calledUrl = mockFetch.mock.calls[0].arguments[0];
|
|
37
|
+
assert.ok(calledUrl.includes("directory="), "Should include directory param");
|
|
38
|
+
assert.ok(calledUrl.includes(encodeURIComponent("/path/to/project")), "Should encode directory path");
|
|
39
|
+
});
|
|
40
|
+
|
|
25
41
|
it("returns empty array on error", async () => {
|
|
26
42
|
const mockFetch = mock.fn(async () => ({
|
|
27
43
|
ok: false,
|
|
@@ -189,14 +205,24 @@ describe("worktree", () => {
|
|
|
189
205
|
});
|
|
190
206
|
|
|
191
207
|
it("passes worktreeName when creating new worktree", async () => {
|
|
192
|
-
const mockFetch = mock.fn(async () =>
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
208
|
+
const mockFetch = mock.fn(async (url, opts) => {
|
|
209
|
+
// First call: GET /experimental/worktree (list) - return empty to trigger creation
|
|
210
|
+
if (!opts || opts.method !== 'POST') {
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
json: async () => [],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
// Second call: POST /experimental/worktree (create)
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
json: async () => ({
|
|
220
|
+
name: "my-feature",
|
|
221
|
+
branch: "opencode/my-feature",
|
|
222
|
+
directory: "/data/worktree/abc123/my-feature",
|
|
223
|
+
}),
|
|
224
|
+
};
|
|
225
|
+
});
|
|
200
226
|
|
|
201
227
|
await resolveWorktreeDirectory(
|
|
202
228
|
"http://localhost:4096",
|
|
@@ -205,7 +231,11 @@ describe("worktree", () => {
|
|
|
205
231
|
{ fetch: mockFetch }
|
|
206
232
|
);
|
|
207
233
|
|
|
208
|
-
|
|
234
|
+
// Should have called both list (GET) and create (POST)
|
|
235
|
+
assert.strictEqual(mockFetch.mock.calls.length, 2);
|
|
236
|
+
// Second call should be POST with the worktree name
|
|
237
|
+
const postCall = mockFetch.mock.calls[1];
|
|
238
|
+
const body = JSON.parse(postCall.arguments[1].body);
|
|
209
239
|
assert.strictEqual(body.name, "my-feature");
|
|
210
240
|
});
|
|
211
241
|
|
|
@@ -250,6 +280,117 @@ describe("worktree", () => {
|
|
|
250
280
|
assert.strictEqual(result.directory, "/data/worktree/abc123/my-feature");
|
|
251
281
|
});
|
|
252
282
|
|
|
283
|
+
it("reuses existing sandbox when worktree='new' and worktreeName matches", async () => {
|
|
284
|
+
let postCalled = false;
|
|
285
|
+
let listUrl = null;
|
|
286
|
+
const mockFetch = mock.fn(async (url, opts) => {
|
|
287
|
+
// GET /experimental/worktree (list) - returns existing sandbox with matching name
|
|
288
|
+
if (!opts || opts.method !== 'POST') {
|
|
289
|
+
listUrl = url;
|
|
290
|
+
return {
|
|
291
|
+
ok: true,
|
|
292
|
+
json: async () => [
|
|
293
|
+
"/data/worktree/abc123/other-branch",
|
|
294
|
+
"/data/worktree/abc123/my-feature", // matches worktreeName
|
|
295
|
+
],
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
// POST /experimental/worktree (create) - should NOT be called
|
|
299
|
+
postCalled = true;
|
|
300
|
+
return { ok: true, json: async () => ({}) };
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const result = await resolveWorktreeDirectory(
|
|
304
|
+
"http://localhost:4096",
|
|
305
|
+
"/path/to/project",
|
|
306
|
+
{ worktree: "new", worktreeName: "my-feature" },
|
|
307
|
+
{ fetch: mockFetch }
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Should reuse existing sandbox
|
|
311
|
+
assert.strictEqual(result.directory, "/data/worktree/abc123/my-feature");
|
|
312
|
+
assert.strictEqual(result.worktreeReused, true);
|
|
313
|
+
assert.strictEqual(result.worktreeCreated, undefined);
|
|
314
|
+
// POST should NOT have been called
|
|
315
|
+
assert.strictEqual(postCalled, false, "Should not call POST when reusing existing sandbox");
|
|
316
|
+
// listWorktrees should be called with directory parameter
|
|
317
|
+
assert.ok(listUrl.includes("directory="), "Should pass directory to listWorktrees");
|
|
318
|
+
assert.ok(listUrl.includes(encodeURIComponent("/path/to/project")), "Should pass correct project directory");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("creates new sandbox when worktree='new' and no matching name exists", async () => {
|
|
322
|
+
let getCalled = false;
|
|
323
|
+
let postCalled = false;
|
|
324
|
+
const mockFetch = mock.fn(async (url, opts) => {
|
|
325
|
+
// GET /experimental/worktree (list) - no matching name
|
|
326
|
+
if (!opts || opts.method !== 'POST') {
|
|
327
|
+
getCalled = true;
|
|
328
|
+
return {
|
|
329
|
+
ok: true,
|
|
330
|
+
json: async () => ["/data/worktree/abc123/other-branch"],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// POST /experimental/worktree (create)
|
|
334
|
+
postCalled = true;
|
|
335
|
+
return {
|
|
336
|
+
ok: true,
|
|
337
|
+
json: async () => ({
|
|
338
|
+
name: "new-feature",
|
|
339
|
+
branch: "opencode/new-feature",
|
|
340
|
+
directory: "/data/worktree/abc123/new-feature",
|
|
341
|
+
}),
|
|
342
|
+
};
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const result = await resolveWorktreeDirectory(
|
|
346
|
+
"http://localhost:4096",
|
|
347
|
+
"/path/to/project",
|
|
348
|
+
{ worktree: "new", worktreeName: "new-feature" },
|
|
349
|
+
{ fetch: mockFetch }
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// Should create new sandbox
|
|
353
|
+
assert.strictEqual(result.directory, "/data/worktree/abc123/new-feature");
|
|
354
|
+
assert.strictEqual(result.worktreeCreated, true);
|
|
355
|
+
assert.strictEqual(result.worktreeReused, undefined);
|
|
356
|
+
// Both GET and POST should have been called
|
|
357
|
+
assert.strictEqual(getCalled, true, "Should call GET to list existing worktrees");
|
|
358
|
+
assert.strictEqual(postCalled, true, "Should call POST to create new worktree");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("skips sandbox reuse when preferExistingSandbox is false", async () => {
|
|
362
|
+
let getCalled = false;
|
|
363
|
+
const mockFetch = mock.fn(async (url, opts) => {
|
|
364
|
+
// GET /experimental/worktree - should NOT be called
|
|
365
|
+
if (!opts || opts.method !== 'POST') {
|
|
366
|
+
getCalled = true;
|
|
367
|
+
return { ok: true, json: async () => ["/data/worktree/abc123/my-feature"] };
|
|
368
|
+
}
|
|
369
|
+
// POST /experimental/worktree (create)
|
|
370
|
+
return {
|
|
371
|
+
ok: true,
|
|
372
|
+
json: async () => ({
|
|
373
|
+
name: "my-feature",
|
|
374
|
+
branch: "opencode/my-feature",
|
|
375
|
+
directory: "/data/worktree/abc123/my-feature-new",
|
|
376
|
+
}),
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const result = await resolveWorktreeDirectory(
|
|
381
|
+
"http://localhost:4096",
|
|
382
|
+
"/path/to/project",
|
|
383
|
+
{ worktree: "new", worktreeName: "my-feature", preferExistingSandbox: false },
|
|
384
|
+
{ fetch: mockFetch }
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// Should create new sandbox, not reuse
|
|
388
|
+
assert.strictEqual(result.directory, "/data/worktree/abc123/my-feature-new");
|
|
389
|
+
assert.strictEqual(result.worktreeCreated, true);
|
|
390
|
+
// GET should NOT have been called (skipped reuse check)
|
|
391
|
+
assert.strictEqual(getCalled, false, "Should skip GET when preferExistingSandbox is false");
|
|
392
|
+
});
|
|
393
|
+
|
|
253
394
|
it("returns error when named worktree not found", async () => {
|
|
254
395
|
const mockFetch = mock.fn(async () => ({
|
|
255
396
|
ok: true,
|