@cueloop/daemon 0.1.0-alpha.102 → 0.1.0-alpha.103
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/package.json +3 -3
- package/src/agent-tools.ts +6 -0
- package/src/api.ts +63 -0
- package/src/client-reliability.test.ts +2 -0
- package/src/client.ts +24 -2
- package/src/curate.ts +2 -0
- package/src/daemon-sdk.ts +8 -0
- package/src/diff-hot-reload.test.ts +4 -0
- package/src/diff-watcher.test.ts +2 -0
- package/src/diff-watcher.ts +33 -0
- package/src/examples/daemon-sdk.ts +2 -0
- package/src/gh.ts +4 -0
- package/src/ghostty-thread-surface-store.ts +3 -0
- package/src/ghostty-thread-surface.test.ts +2 -0
- package/src/ghostty-thread-surface.ts +6 -0
- package/src/harness-diagnostics.ts +1 -0
- package/src/harness-handoff.test.ts +725 -0
- package/src/harness-handoff.ts +27 -0
- package/src/harness-state-store.ts +5 -0
- package/src/herdr-split.ts +5 -0
- package/src/herdr-thread-surface.ts +14 -0
- package/src/interruptible-wait.ts +3 -0
- package/src/jj-working-tree.ts +9 -0
- package/src/main.ts +1 -0
- package/src/operation-receipts.ts +6 -0
- package/src/pr-poller.ts +7 -0
- package/src/project-files.ts +4 -0
- package/src/protocol.ts +4 -0
- package/src/retention.ts +8 -0
- package/src/server.ts +36 -7
- package/src/share-blob.test.ts +2 -0
- package/src/single-instance.test.ts +3 -0
- package/src/store.ts +11 -0
- package/src/thread-agent-validation.ts +20 -0
- package/src/thread-agent.test.ts +14 -1
- package/src/thread-agent.ts +353 -24
- package/src/thread-review.test.ts +4 -0
- package/src/thread-review.ts +8 -0
- package/src/thread-subscription.test.ts +3 -0
- package/src/thread-subscription.ts +9 -0
- package/src/validate.test.ts +4 -0
- package/src/validate.ts +3 -0
- package/src/vcs-source.ts +6 -0
- package/src/working-tree.ts +4 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { SCHEMA_VERSION, type Thread, type AgentHarnessAdapter } from "@cueloop/schema";
|
|
6
|
+
import { ThreadAgentManager } from "./thread-agent";
|
|
7
|
+
|
|
8
|
+
async function waitForTestIdle(manager: ThreadAgentManager, id: string) {
|
|
9
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
10
|
+
const state = manager.get(id);
|
|
11
|
+
|
|
12
|
+
if (state.phase.kind === "idle" || state.phase.kind === "failed") return state;
|
|
13
|
+
|
|
14
|
+
await Bun.sleep(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
throw new Error("Test harness did not settle");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const first of ["pi", "fx"] as const) {
|
|
21
|
+
test(`handoff ${first} preserves transcript and runtime identity in both directions`, async () => {
|
|
22
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-switch-"));
|
|
23
|
+
const thread: Thread = {
|
|
24
|
+
schemaVersion: SCHEMA_VERSION,
|
|
25
|
+
id: "switch-test",
|
|
26
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
27
|
+
artifact: { type: "plan", content: "Review retry behavior", meta: {} },
|
|
28
|
+
revisions: [],
|
|
29
|
+
annotations: [],
|
|
30
|
+
message: null,
|
|
31
|
+
status: "pending",
|
|
32
|
+
createdAt: "2026-10-07",
|
|
33
|
+
};
|
|
34
|
+
const prompts: { backend: string; text: string; session?: string }[] = [];
|
|
35
|
+
const adapter = (id: string): AgentHarnessAdapter => ({
|
|
36
|
+
id,
|
|
37
|
+
label: id,
|
|
38
|
+
recovery: "durable",
|
|
39
|
+
connect(options) {
|
|
40
|
+
return {
|
|
41
|
+
start: async () => options.sessionId ?? "shared-runtime",
|
|
42
|
+
async prompt(text) {
|
|
43
|
+
prompts.push({ backend: id, text, session: options.sessionId });
|
|
44
|
+
|
|
45
|
+
if (text.startsWith("Prepare a continuation")) {
|
|
46
|
+
options.onEvent({
|
|
47
|
+
kind: "tool",
|
|
48
|
+
id: "summary-read",
|
|
49
|
+
title: "read",
|
|
50
|
+
status: "pending",
|
|
51
|
+
});
|
|
52
|
+
options.onEvent({
|
|
53
|
+
kind: "tool",
|
|
54
|
+
id: "summary-read",
|
|
55
|
+
title: "read",
|
|
56
|
+
status: "completed",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
options.onEvent({
|
|
61
|
+
kind: "message",
|
|
62
|
+
id: crypto.randomUUID(),
|
|
63
|
+
text: text.startsWith("Prepare a continuation")
|
|
64
|
+
? "Goal: fix retries. Next: test cancellation."
|
|
65
|
+
: `${id} answer`,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return { outcome: "completed" };
|
|
69
|
+
},
|
|
70
|
+
cancel() {},
|
|
71
|
+
permission() {},
|
|
72
|
+
close() {},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const adapters = { pi: adapter("pi"), fx: adapter("fx") };
|
|
77
|
+
const manager = new ThreadAgentManager({
|
|
78
|
+
home,
|
|
79
|
+
enabled: true,
|
|
80
|
+
adapter: adapters[first],
|
|
81
|
+
adapters,
|
|
82
|
+
getThread: () => thread,
|
|
83
|
+
onChange() {},
|
|
84
|
+
});
|
|
85
|
+
const target = first === "pi" ? "fx" : "pi";
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
manager.prompt({ id: thread.id, text: "Fix retries" });
|
|
89
|
+
await waitForTestIdle(manager, thread.id);
|
|
90
|
+
const before = manager.get(thread.id).messages;
|
|
91
|
+
const tools = manager.get(thread.id).tools;
|
|
92
|
+
|
|
93
|
+
await manager.configure({ id: thread.id, configId: "harness", value: target });
|
|
94
|
+
const switched = await waitForTestIdle(manager, thread.id);
|
|
95
|
+
|
|
96
|
+
expect(switched.harness).toMatchObject({ id: target, sessionId: "shared-runtime" });
|
|
97
|
+
expect(switched.messages).toEqual(before);
|
|
98
|
+
expect(switched.tools).toEqual(tools);
|
|
99
|
+
expect(switched.continuation).toContain("Next: test cancellation");
|
|
100
|
+
manager.prompt({ id: thread.id, text: "Continue with cancellation" });
|
|
101
|
+
await waitForTestIdle(manager, thread.id);
|
|
102
|
+
const received = prompts.at(-1)!;
|
|
103
|
+
|
|
104
|
+
expect(received.backend).toBe(target);
|
|
105
|
+
expect(received.session).toBe("shared-runtime");
|
|
106
|
+
expect(received.text).toContain("Next: test cancellation");
|
|
107
|
+
expect(received.text).toContain("Current request:");
|
|
108
|
+
expect(manager.get(thread.id).messages.at(-1)?.text).toBe(`${target} answer`);
|
|
109
|
+
} finally {
|
|
110
|
+
await manager.dispose();
|
|
111
|
+
rmSync(home, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test("an active turn finishes before handoff and queued input runs on the new harness", async () => {
|
|
117
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-switch-busy-"));
|
|
118
|
+
const thread: Thread = {
|
|
119
|
+
schemaVersion: SCHEMA_VERSION,
|
|
120
|
+
id: "busy-switch",
|
|
121
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
122
|
+
artifact: { type: "plan", content: "Retry plan", meta: {} },
|
|
123
|
+
revisions: [],
|
|
124
|
+
annotations: [],
|
|
125
|
+
message: null,
|
|
126
|
+
status: "pending",
|
|
127
|
+
createdAt: "2026-10-07",
|
|
128
|
+
};
|
|
129
|
+
let finish!: () => void;
|
|
130
|
+
let started!: () => void;
|
|
131
|
+
const running = new Promise<void>((resolve) => {
|
|
132
|
+
started = resolve;
|
|
133
|
+
});
|
|
134
|
+
const paused = new Promise<void>((resolve) => {
|
|
135
|
+
finish = resolve;
|
|
136
|
+
});
|
|
137
|
+
const calls: string[] = [];
|
|
138
|
+
const adapter = (id: string): AgentHarnessAdapter => ({
|
|
139
|
+
id,
|
|
140
|
+
label: id,
|
|
141
|
+
recovery: "durable",
|
|
142
|
+
connect(options) {
|
|
143
|
+
return {
|
|
144
|
+
start: async () => options.sessionId ?? "runtime-busy",
|
|
145
|
+
async prompt(text) {
|
|
146
|
+
calls.push(id);
|
|
147
|
+
|
|
148
|
+
if (calls.length === 1) {
|
|
149
|
+
started();
|
|
150
|
+
await paused;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
options.onEvent({
|
|
154
|
+
kind: "message",
|
|
155
|
+
id: crypto.randomUUID(),
|
|
156
|
+
text: text.startsWith("Prepare a continuation")
|
|
157
|
+
? "Continue the retry work"
|
|
158
|
+
: `${id} response`,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return { outcome: "completed" };
|
|
162
|
+
},
|
|
163
|
+
cancel() {},
|
|
164
|
+
permission() {},
|
|
165
|
+
close() {},
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const adapters = { pi: adapter("pi"), fx: adapter("fx") };
|
|
170
|
+
const manager = new ThreadAgentManager({
|
|
171
|
+
home,
|
|
172
|
+
enabled: true,
|
|
173
|
+
adapter: adapters.pi,
|
|
174
|
+
adapters,
|
|
175
|
+
getThread: () => thread,
|
|
176
|
+
onChange() {},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
manager.prompt({ id: thread.id, text: "First" });
|
|
181
|
+
await running;
|
|
182
|
+
const queued = await manager.configure({ id: thread.id, configId: "harness", value: "fx" });
|
|
183
|
+
|
|
184
|
+
expect(queued.handoff?.status).toBe("queued");
|
|
185
|
+
expect(calls).toEqual(["pi"]);
|
|
186
|
+
manager.prompt({ id: thread.id, text: "Follow-up" });
|
|
187
|
+
finish();
|
|
188
|
+
const done = await waitForTestIdle(manager, thread.id);
|
|
189
|
+
|
|
190
|
+
expect(done.phase.kind).toBe("idle");
|
|
191
|
+
expect(done.harness?.id).toBe("fx");
|
|
192
|
+
expect(calls).toEqual(["pi", "pi", "fx"]);
|
|
193
|
+
expect(done.submissions?.map((submission) => submission.status)).toEqual([
|
|
194
|
+
"completed",
|
|
195
|
+
"completed",
|
|
196
|
+
]);
|
|
197
|
+
} finally {
|
|
198
|
+
finish();
|
|
199
|
+
await manager.dispose();
|
|
200
|
+
rmSync(home, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("failed incoming initialization keeps the outgoing session and surfaces the failure", async () => {
|
|
205
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-switch-failure-"));
|
|
206
|
+
const thread: Thread = {
|
|
207
|
+
schemaVersion: SCHEMA_VERSION,
|
|
208
|
+
id: "failed-switch",
|
|
209
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
210
|
+
artifact: { type: "plan", content: "Retry plan", meta: {} },
|
|
211
|
+
revisions: [],
|
|
212
|
+
annotations: [],
|
|
213
|
+
message: null,
|
|
214
|
+
status: "pending",
|
|
215
|
+
createdAt: "2026-10-07",
|
|
216
|
+
};
|
|
217
|
+
const pi: AgentHarnessAdapter = {
|
|
218
|
+
id: "pi",
|
|
219
|
+
label: "pi",
|
|
220
|
+
recovery: "durable",
|
|
221
|
+
connect(options) {
|
|
222
|
+
return {
|
|
223
|
+
start: async () => options.sessionId ?? "original-runtime",
|
|
224
|
+
async prompt() {
|
|
225
|
+
options.onEvent({ kind: "message", id: crypto.randomUUID(), text: "Continue retries" });
|
|
226
|
+
|
|
227
|
+
return { outcome: "completed" };
|
|
228
|
+
},
|
|
229
|
+
cancel() {},
|
|
230
|
+
permission() {},
|
|
231
|
+
close() {},
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
const fx: AgentHarnessAdapter = {
|
|
236
|
+
id: "fx",
|
|
237
|
+
label: "fx",
|
|
238
|
+
recovery: "durable",
|
|
239
|
+
connect() {
|
|
240
|
+
return {
|
|
241
|
+
start: async () => {
|
|
242
|
+
throw new Error("Fx executable is unavailable");
|
|
243
|
+
},
|
|
244
|
+
prompt: async () => ({ outcome: "completed" }),
|
|
245
|
+
cancel() {},
|
|
246
|
+
permission() {},
|
|
247
|
+
close() {},
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
const manager = new ThreadAgentManager({
|
|
252
|
+
home,
|
|
253
|
+
enabled: true,
|
|
254
|
+
adapter: pi,
|
|
255
|
+
adapters: { pi, fx },
|
|
256
|
+
getThread: () => thread,
|
|
257
|
+
onChange() {},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
manager.prompt({ id: thread.id, text: "First" });
|
|
262
|
+
await waitForTestIdle(manager, thread.id);
|
|
263
|
+
const messages = manager.get(thread.id).messages;
|
|
264
|
+
|
|
265
|
+
await manager.configure({ id: thread.id, configId: "harness", value: "fx" });
|
|
266
|
+
const failed = await waitForTestIdle(manager, thread.id);
|
|
267
|
+
|
|
268
|
+
expect(failed.phase).toEqual({ kind: "failed", error: "Fx executable is unavailable" });
|
|
269
|
+
expect(failed.harness).toMatchObject({ id: "pi", sessionId: "original-runtime" });
|
|
270
|
+
expect(failed.messages).toEqual(messages);
|
|
271
|
+
await expect(
|
|
272
|
+
manager.configure({ id: thread.id, configId: "harness", value: "missing" }),
|
|
273
|
+
).rejects.toThrow("unavailable");
|
|
274
|
+
} finally {
|
|
275
|
+
await manager.dispose();
|
|
276
|
+
rmSync(home, { recursive: true, force: true });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("a recovered prepared handoff restores its source when target startup fails", async () => {
|
|
281
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-switch-recovery-"));
|
|
282
|
+
const thread: Thread = {
|
|
283
|
+
schemaVersion: SCHEMA_VERSION,
|
|
284
|
+
id: "recover",
|
|
285
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
286
|
+
artifact: { type: "plan", content: "Review retries", meta: {} },
|
|
287
|
+
revisions: [],
|
|
288
|
+
annotations: [],
|
|
289
|
+
message: null,
|
|
290
|
+
status: "pending",
|
|
291
|
+
createdAt: "2026-10-07",
|
|
292
|
+
};
|
|
293
|
+
const source = { id: "pi", label: "pi", sessionId: "runtime-recovery" };
|
|
294
|
+
const adapters = {
|
|
295
|
+
pi: {
|
|
296
|
+
id: "pi",
|
|
297
|
+
label: "pi",
|
|
298
|
+
recovery: "durable",
|
|
299
|
+
connect() {
|
|
300
|
+
throw new Error("Source must not summarize a prepared handoff");
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
fx: {
|
|
304
|
+
id: "fx",
|
|
305
|
+
label: "fx",
|
|
306
|
+
recovery: "durable",
|
|
307
|
+
connect() {
|
|
308
|
+
return {
|
|
309
|
+
start: async () => {
|
|
310
|
+
throw new Error("Target failed after restart");
|
|
311
|
+
},
|
|
312
|
+
prompt: async () => ({ outcome: "completed" }),
|
|
313
|
+
cancel() {},
|
|
314
|
+
permission() {},
|
|
315
|
+
close() {},
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
} satisfies Record<string, AgentHarnessAdapter>;
|
|
320
|
+
|
|
321
|
+
mkdirSync(join(home, "thread-agents"));
|
|
322
|
+
writeFileSync(
|
|
323
|
+
join(home, "thread-agents/recover.json"),
|
|
324
|
+
JSON.stringify({
|
|
325
|
+
threadId: thread.id,
|
|
326
|
+
harness: { ...source, id: "fx", label: "fx" },
|
|
327
|
+
phase: { kind: "running" },
|
|
328
|
+
messages: [],
|
|
329
|
+
tools: [],
|
|
330
|
+
comments: [],
|
|
331
|
+
handoff: {
|
|
332
|
+
target: "fx",
|
|
333
|
+
source,
|
|
334
|
+
operationId: "switch-recovery",
|
|
335
|
+
status: "prepared",
|
|
336
|
+
text: "Continue retry work",
|
|
337
|
+
},
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
const manager = new ThreadAgentManager({
|
|
341
|
+
home,
|
|
342
|
+
enabled: true,
|
|
343
|
+
adapter: adapters.pi,
|
|
344
|
+
adapters,
|
|
345
|
+
getThread: () => thread,
|
|
346
|
+
onChange() {},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
await manager.configure({ id: thread.id });
|
|
351
|
+
const state = await waitForTestIdle(manager, thread.id);
|
|
352
|
+
|
|
353
|
+
expect(state.phase).toEqual({ kind: "failed", error: "Target failed after restart" });
|
|
354
|
+
expect(state.harness).toEqual(source);
|
|
355
|
+
expect(state.handoff).toBeUndefined();
|
|
356
|
+
} finally {
|
|
357
|
+
await manager.dispose();
|
|
358
|
+
rmSync(home, { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a queued switch refreshes its rollback binding after delayed initial startup", async () => {
|
|
363
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-switch-start-"));
|
|
364
|
+
const thread: Thread = {
|
|
365
|
+
schemaVersion: SCHEMA_VERSION,
|
|
366
|
+
id: "delayed",
|
|
367
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
368
|
+
artifact: { type: "plan", content: "Review retries", meta: {} },
|
|
369
|
+
revisions: [],
|
|
370
|
+
annotations: [],
|
|
371
|
+
message: null,
|
|
372
|
+
status: "pending",
|
|
373
|
+
createdAt: "2026-10-07",
|
|
374
|
+
};
|
|
375
|
+
let ready!: (id: string) => void;
|
|
376
|
+
let started!: () => void;
|
|
377
|
+
const initializing = new Promise<void>((resolve) => {
|
|
378
|
+
started = resolve;
|
|
379
|
+
});
|
|
380
|
+
const initialized = new Promise<string>((resolve) => {
|
|
381
|
+
ready = resolve;
|
|
382
|
+
});
|
|
383
|
+
const adapters = {
|
|
384
|
+
pi: {
|
|
385
|
+
id: "pi",
|
|
386
|
+
label: "pi",
|
|
387
|
+
recovery: "durable",
|
|
388
|
+
connect(options) {
|
|
389
|
+
return {
|
|
390
|
+
start: () => {
|
|
391
|
+
started();
|
|
392
|
+
|
|
393
|
+
return initialized;
|
|
394
|
+
},
|
|
395
|
+
async prompt() {
|
|
396
|
+
options.onEvent({
|
|
397
|
+
kind: "message",
|
|
398
|
+
id: crypto.randomUUID(),
|
|
399
|
+
text: "Continue retry work",
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
return { outcome: "completed" };
|
|
403
|
+
},
|
|
404
|
+
cancel() {},
|
|
405
|
+
permission() {},
|
|
406
|
+
close() {},
|
|
407
|
+
};
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
fx: {
|
|
411
|
+
id: "fx",
|
|
412
|
+
label: "fx",
|
|
413
|
+
recovery: "durable",
|
|
414
|
+
connect() {
|
|
415
|
+
return {
|
|
416
|
+
start: async () => {
|
|
417
|
+
throw new Error("Target unavailable");
|
|
418
|
+
},
|
|
419
|
+
prompt: async () => ({ outcome: "completed" }),
|
|
420
|
+
cancel() {},
|
|
421
|
+
permission() {},
|
|
422
|
+
close() {},
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
} satisfies Record<string, AgentHarnessAdapter>;
|
|
427
|
+
const manager = new ThreadAgentManager({
|
|
428
|
+
home,
|
|
429
|
+
enabled: true,
|
|
430
|
+
adapter: adapters.pi,
|
|
431
|
+
adapters,
|
|
432
|
+
getThread: () => thread,
|
|
433
|
+
onChange() {},
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
manager.prompt({ id: thread.id, text: "Review" });
|
|
438
|
+
await initializing;
|
|
439
|
+
await manager.configure({ id: thread.id, configId: "harness", value: "fx" });
|
|
440
|
+
ready("original-runtime");
|
|
441
|
+
const failed = await waitForTestIdle(manager, thread.id);
|
|
442
|
+
|
|
443
|
+
expect(failed.phase).toEqual({ kind: "failed", error: "Target unavailable" });
|
|
444
|
+
expect(failed.harness).toEqual({ id: "pi", label: "pi", sessionId: "original-runtime" });
|
|
445
|
+
} finally {
|
|
446
|
+
ready("original-runtime");
|
|
447
|
+
await manager.dispose();
|
|
448
|
+
rmSync(home, { recursive: true, force: true });
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("a resumed summary reuses its frozen prompt despite later queued messages", async () => {
|
|
453
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-switch-frozen-"));
|
|
454
|
+
const thread: Thread = {
|
|
455
|
+
schemaVersion: SCHEMA_VERSION,
|
|
456
|
+
id: "frozen",
|
|
457
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
458
|
+
artifact: { type: "plan", content: "Review retries", meta: {} },
|
|
459
|
+
revisions: [],
|
|
460
|
+
annotations: [],
|
|
461
|
+
message: null,
|
|
462
|
+
status: "pending",
|
|
463
|
+
createdAt: "2026-10-07",
|
|
464
|
+
};
|
|
465
|
+
const source = { id: "pi", label: "pi", sessionId: "frozen-runtime" };
|
|
466
|
+
const prompts: string[] = [];
|
|
467
|
+
const adapter = (id: string): AgentHarnessAdapter => ({
|
|
468
|
+
id,
|
|
469
|
+
label: id,
|
|
470
|
+
recovery: "durable",
|
|
471
|
+
connect(options) {
|
|
472
|
+
return {
|
|
473
|
+
start: async () => options.sessionId!,
|
|
474
|
+
async prompt(text) {
|
|
475
|
+
prompts.push(text);
|
|
476
|
+
options.onEvent({ kind: "message", id: "summary", text: "Continue" });
|
|
477
|
+
|
|
478
|
+
return { outcome: "completed" };
|
|
479
|
+
},
|
|
480
|
+
cancel() {},
|
|
481
|
+
permission() {},
|
|
482
|
+
close() {},
|
|
483
|
+
};
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
const adapters = { pi: adapter("pi"), fx: adapter("fx") };
|
|
487
|
+
|
|
488
|
+
mkdirSync(join(home, "thread-agents"));
|
|
489
|
+
writeFileSync(
|
|
490
|
+
join(home, "thread-agents/frozen.json"),
|
|
491
|
+
JSON.stringify({
|
|
492
|
+
threadId: thread.id,
|
|
493
|
+
harness: source,
|
|
494
|
+
phase: { kind: "running" },
|
|
495
|
+
messages: [
|
|
496
|
+
{
|
|
497
|
+
id: "queued-message",
|
|
498
|
+
role: "user",
|
|
499
|
+
text: "Added after summary began",
|
|
500
|
+
complete: true,
|
|
501
|
+
revision: 1,
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
tools: [],
|
|
505
|
+
comments: [],
|
|
506
|
+
handoff: {
|
|
507
|
+
target: "fx",
|
|
508
|
+
source,
|
|
509
|
+
operationId: "frozen-summary",
|
|
510
|
+
status: "summarizing",
|
|
511
|
+
prompt: "Frozen handoff instruction",
|
|
512
|
+
},
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
const manager = new ThreadAgentManager({
|
|
516
|
+
home,
|
|
517
|
+
enabled: true,
|
|
518
|
+
adapter: adapters.pi,
|
|
519
|
+
adapters,
|
|
520
|
+
getThread: () => thread,
|
|
521
|
+
onChange() {},
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
await manager.configure({ id: thread.id });
|
|
526
|
+
const state = await waitForTestIdle(manager, thread.id);
|
|
527
|
+
|
|
528
|
+
expect(state.harness?.id).toBe("fx");
|
|
529
|
+
expect(prompts).toEqual(["Frozen handoff instruction"]);
|
|
530
|
+
expect(state.continuation).toBe("Continue");
|
|
531
|
+
} finally {
|
|
532
|
+
await manager.dispose();
|
|
533
|
+
rmSync(home, { recursive: true, force: true });
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("explicit retry starts a fresh attempt while preserving submission and mirror identity", async () => {
|
|
538
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-attempt-"));
|
|
539
|
+
const thread: Thread = {
|
|
540
|
+
schemaVersion: SCHEMA_VERSION,
|
|
541
|
+
id: "retry",
|
|
542
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
543
|
+
artifact: { type: "plan", content: "Review retries", meta: {} },
|
|
544
|
+
revisions: [],
|
|
545
|
+
annotations: [],
|
|
546
|
+
message: null,
|
|
547
|
+
status: "pending",
|
|
548
|
+
createdAt: "2026-10-07",
|
|
549
|
+
};
|
|
550
|
+
const attempts: (string | undefined)[] = [];
|
|
551
|
+
const adapter: AgentHarnessAdapter = {
|
|
552
|
+
id: "fx",
|
|
553
|
+
label: "fx",
|
|
554
|
+
recovery: "durable",
|
|
555
|
+
connect(options) {
|
|
556
|
+
return {
|
|
557
|
+
start: async () => "retry-runtime",
|
|
558
|
+
async prompt(_text, requestId) {
|
|
559
|
+
attempts.push(requestId);
|
|
560
|
+
|
|
561
|
+
if (attempts.length === 1) {
|
|
562
|
+
options.onEvent({ kind: "message", text: "Interrupted partial" });
|
|
563
|
+
throw new Error("Interrupted dispatch");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
options.onEvent({ kind: "message", text: "Retry completed" });
|
|
567
|
+
|
|
568
|
+
return { outcome: "completed" };
|
|
569
|
+
},
|
|
570
|
+
cancel() {},
|
|
571
|
+
permission() {},
|
|
572
|
+
close() {},
|
|
573
|
+
};
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
const manager = new ThreadAgentManager({
|
|
577
|
+
home,
|
|
578
|
+
enabled: true,
|
|
579
|
+
adapter,
|
|
580
|
+
getThread: () => thread,
|
|
581
|
+
onChange() {},
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
manager.prompt({ id: thread.id, text: "Review" });
|
|
586
|
+
const first = await waitForTestIdle(manager, thread.id);
|
|
587
|
+
const submissionId = first.submissions![0]!.id;
|
|
588
|
+
|
|
589
|
+
manager.prompt({ id: thread.id, text: "", retry: submissionId });
|
|
590
|
+
const retried = await waitForTestIdle(manager, thread.id);
|
|
591
|
+
|
|
592
|
+
expect(attempts[0]).toBe(submissionId);
|
|
593
|
+
expect(attempts[1]).not.toBe(submissionId);
|
|
594
|
+
expect(attempts[1]).toBe(retried.submissions![0]!.attemptId);
|
|
595
|
+
expect(retried.messages.filter((message) => message.role === "agent")).toMatchObject([
|
|
596
|
+
{ text: "Interrupted partial", complete: false },
|
|
597
|
+
{ text: "Retry completed", complete: true },
|
|
598
|
+
]);
|
|
599
|
+
expect(retried.submissions).toHaveLength(1);
|
|
600
|
+
expect(retried.submissions![0]!.status).toBe("completed");
|
|
601
|
+
expect(retried.messages.filter((message) => message.role === "user")).toHaveLength(1);
|
|
602
|
+
} finally {
|
|
603
|
+
await manager.dispose();
|
|
604
|
+
rmSync(home, { recursive: true, force: true });
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
for (const pauseAt of ["summary", "target"] as const) {
|
|
609
|
+
for (const finalTarget of ["pi", "fx"] as const) {
|
|
610
|
+
test(`rapid harness toggles during ${pauseAt} finish before settling on ${finalTarget}`, async () => {
|
|
611
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-rapid-handoff-"));
|
|
612
|
+
const thread: Thread = {
|
|
613
|
+
schemaVersion: SCHEMA_VERSION,
|
|
614
|
+
id: "rapid-switch",
|
|
615
|
+
workspace: { repoRoot: home, branch: "main" },
|
|
616
|
+
artifact: { type: "plan", content: "Continue the work", meta: {} },
|
|
617
|
+
revisions: [],
|
|
618
|
+
annotations: [],
|
|
619
|
+
message: null,
|
|
620
|
+
status: "pending",
|
|
621
|
+
createdAt: "2026-10-07",
|
|
622
|
+
};
|
|
623
|
+
const started = Promise.withResolvers<void>();
|
|
624
|
+
const release = Promise.withResolvers<void>();
|
|
625
|
+
const connections: string[] = [];
|
|
626
|
+
const summaries: string[] = [];
|
|
627
|
+
let activeSummaries = 0;
|
|
628
|
+
let maximumSummaries = 0;
|
|
629
|
+
const adapter = (id: string): AgentHarnessAdapter => ({
|
|
630
|
+
id,
|
|
631
|
+
label: id,
|
|
632
|
+
recovery: "durable",
|
|
633
|
+
connect(options) {
|
|
634
|
+
connections.push(id);
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
async start() {
|
|
638
|
+
if (id === "fx" && pauseAt === "target") {
|
|
639
|
+
started.resolve();
|
|
640
|
+
await release.promise;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return options.sessionId ?? "rapid-runtime";
|
|
644
|
+
},
|
|
645
|
+
async prompt(text) {
|
|
646
|
+
const summary = text.startsWith("Prepare a continuation");
|
|
647
|
+
|
|
648
|
+
if (summary) {
|
|
649
|
+
summaries.push(id);
|
|
650
|
+
maximumSummaries = Math.max(maximumSummaries, ++activeSummaries);
|
|
651
|
+
|
|
652
|
+
if (summaries.length === 1 && pauseAt === "summary") {
|
|
653
|
+
started.resolve();
|
|
654
|
+
await release.promise;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
activeSummaries--;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
options.onEvent({
|
|
661
|
+
kind: "message",
|
|
662
|
+
text: summary
|
|
663
|
+
? id === "pi"
|
|
664
|
+
? "Continue safely: preserve private decision 42"
|
|
665
|
+
: text.includes("preserve private decision 42")
|
|
666
|
+
? "Carried context: preserve private decision 42"
|
|
667
|
+
: "Missing prior context"
|
|
668
|
+
: "Initial reply",
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
return { outcome: "completed" };
|
|
672
|
+
},
|
|
673
|
+
cancel() {},
|
|
674
|
+
permission() {},
|
|
675
|
+
close() {},
|
|
676
|
+
};
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
const adapters = { pi: adapter("pi"), fx: adapter("fx") };
|
|
680
|
+
const manager = new ThreadAgentManager({
|
|
681
|
+
home,
|
|
682
|
+
enabled: true,
|
|
683
|
+
adapter: adapters.pi,
|
|
684
|
+
adapters,
|
|
685
|
+
getThread: () => thread,
|
|
686
|
+
onChange() {},
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
try {
|
|
690
|
+
manager.prompt({ id: thread.id, text: "Start" });
|
|
691
|
+
await waitForTestIdle(manager, thread.id);
|
|
692
|
+
const transcript = manager.get(thread.id).messages;
|
|
693
|
+
|
|
694
|
+
await manager.configure({ id: thread.id, configId: "harness", value: "fx" });
|
|
695
|
+
await started.promise;
|
|
696
|
+
const operation = manager.get(thread.id).handoff?.operationId;
|
|
697
|
+
|
|
698
|
+
for (const target of ["pi", "fx", finalTarget]) {
|
|
699
|
+
const state = await manager.configure({
|
|
700
|
+
id: thread.id,
|
|
701
|
+
configId: "harness",
|
|
702
|
+
value: target,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
expect(state.handoff?.operationId).toBe(operation);
|
|
706
|
+
expect(state.handoff?.requestedTarget).toBe(target);
|
|
707
|
+
}
|
|
708
|
+
release.resolve();
|
|
709
|
+
const settled = await waitForTestIdle(manager, thread.id);
|
|
710
|
+
|
|
711
|
+
expect(settled.harness?.id).toBe(finalTarget);
|
|
712
|
+
expect(settled.handoff).toBeUndefined();
|
|
713
|
+
expect(settled.continuation).toContain("preserve private decision 42");
|
|
714
|
+
expect(settled.messages).toEqual(transcript);
|
|
715
|
+
expect(maximumSummaries).toBe(1);
|
|
716
|
+
expect(connections).toEqual(finalTarget === "pi" ? ["pi", "fx", "pi"] : ["pi", "fx"]);
|
|
717
|
+
expect(summaries).toEqual(finalTarget === "pi" ? ["pi", "fx"] : ["pi"]);
|
|
718
|
+
} finally {
|
|
719
|
+
release.resolve();
|
|
720
|
+
await manager.dispose();
|
|
721
|
+
rmSync(home, { recursive: true, force: true });
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|