opencode-pilot 0.18.2 → 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/AGENTS.md +6 -5
- package/README.md +20 -2
- package/examples/config.yaml +7 -1
- package/package.json +4 -2
- package/service/actions.js +284 -0
- package/service/poller.js +43 -55
- 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
|
@@ -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
|
});
|