@cueloop/daemon 0.1.0-alpha.97 → 0.1.0-alpha.99

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.
@@ -28,6 +28,7 @@ describe("JSONL thread store", () => {
28
28
  test("a thread is written under a per-project bucket keyed by root commit", () => {
29
29
  const home = tmpHome();
30
30
  const store = new ThreadStore(home);
31
+
31
32
  store.recover();
32
33
  store.upsert(
33
34
  createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
@@ -36,6 +37,7 @@ describe("JSONL thread store", () => {
36
37
  );
37
38
 
38
39
  const bucket = threadBucket("abcdef0123456789", home);
40
+
39
41
  expect(readdirSync(bucket)).toContain("ses_a.jsonl");
40
42
  });
41
43
 
@@ -52,6 +54,7 @@ describe("JSONL thread store", () => {
52
54
  test("a repo-less thread lands in the standalone bucket", () => {
53
55
  const home = tmpHome();
54
56
  const store = new ThreadStore(home);
57
+
55
58
  store.recover();
56
59
  store.upsert(createTestThreadRecord("ses_s", "2026-09-01T00:00:00.000Z"));
57
60
 
@@ -60,8 +63,10 @@ describe("JSONL thread store", () => {
60
63
 
61
64
  test("boot migrates the old whole-record file and parks the original", () => {
62
65
  const home = tmpHome();
66
+
63
67
  mkdirSync(sessionsDir(home), { recursive: true });
64
68
  const legacy = createTestThreadRecord("ses_old", "2026-09-01T00:00:00.000Z");
69
+
65
70
  writeFileSync(join(sessionsDir(home), "ses_old.json"), JSON.stringify(legacy));
66
71
 
67
72
  const store = new ThreadStore(home);
@@ -80,6 +85,7 @@ describe("JSONL thread store", () => {
80
85
  const home = tmpHome();
81
86
  // a newer JSONL already holds this thread (e.g. written after a rollback)
82
87
  const store = new ThreadStore(home);
88
+
83
89
  store.recover();
84
90
  store.upsert(
85
91
  createTestThreadRecord("ses_x", "2026-09-01T00:00:00.000Z", {
@@ -99,6 +105,7 @@ describe("JSONL thread store", () => {
99
105
  );
100
106
 
101
107
  const reopened = new ThreadStore(home);
108
+
102
109
  reopened.recover();
103
110
  // the newer JSONL wins; the legacy snapshot is parked, not applied
104
111
  expect(reopened.get("ses_x")?.workspace.branch).toBe("newer");
@@ -108,6 +115,7 @@ describe("JSONL thread store", () => {
108
115
  test("a long log compacts to a single line, and the record survives it", () => {
109
116
  const home = tmpHome();
110
117
  const store = new ThreadStore(home);
118
+
111
119
  store.recover();
112
120
  // more upserts than the compaction threshold
113
121
  for (let iteration = 0; iteration < 60; iteration++) {
@@ -123,9 +131,11 @@ describe("JSONL thread store", () => {
123
131
  const lines = readFileSync(file, "utf8")
124
132
  .split("\n")
125
133
  .filter((l) => l.trim());
134
+
126
135
  expect(lines.length).toBeLessThan(60);
127
136
  // the latest snapshot survives the compaction and a restart
128
137
  const reopened = new ThreadStore(home);
138
+
129
139
  reopened.recover();
130
140
  expect(reopened.get("ses_a")?.workspace.branch).toBe("b59");
131
141
  });
@@ -133,15 +143,18 @@ describe("JSONL thread store", () => {
133
143
  test("a crash's torn trailing line is skipped; the last valid snapshot wins", () => {
134
144
  const home = tmpHome();
135
145
  const store = new ThreadStore(home);
146
+
136
147
  store.recover();
137
148
  store.upsert(createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z"));
138
149
 
139
150
  // simulate a crash mid-append: a half-written line at the end
140
151
  const file = join(threadBucket(undefined, home), "ses_a.jsonl");
152
+
141
153
  appendFileSync(file, '{"id":"ses_a","half');
142
154
 
143
155
  const reopened = new ThreadStore(home);
144
156
  const report = reopened.recover();
157
+
145
158
  expect(report.recovered).toEqual(["ses_a"]);
146
159
  expect(reopened.get("ses_a")?.id).toBe("ses_a");
147
160
 
@@ -153,6 +166,7 @@ describe("JSONL thread store", () => {
153
166
  }),
154
167
  );
155
168
  const restarted = new ThreadStore(home);
169
+
156
170
  restarted.recover();
157
171
  expect(restarted.get("ses_a")?.workspace.branch).toBe("after-tear");
158
172
  });
package/src/store.ts CHANGED
@@ -94,6 +94,7 @@ export class ThreadStore implements ThreadRepository {
94
94
  for (const file of readdirSync(bucketPath)) {
95
95
  if (!file.endsWith(".jsonl")) continue;
96
96
  const filePath = join(bucketPath, file);
97
+
97
98
  try {
98
99
  const { session, lines, torn } = readThread(filePath);
99
100
 
@@ -181,19 +182,25 @@ export class ThreadStore implements ThreadRepository {
181
182
  }
182
183
 
183
184
  upsert(session: Thread): void {
184
- this.sessions.set(session.id, session);
185
185
  const filePath = this.files.get(session.id);
186
186
  const lines = this.lineCounts.get(session.id) ?? 0;
187
187
 
188
188
  // the first write, an over-long log, and a resolved (now immutable) thread all compact to one
189
189
  // line; every other mutation is a cheap append that a torn tail on crash simply drops on read
190
- if (filePath === undefined || lines >= COMPACT_THRESHOLD || session.status === "resolved") {
190
+ if (
191
+ filePath === undefined ||
192
+ lines >= COMPACT_THRESHOLD ||
193
+ session.status === "resolved" ||
194
+ session.messageOperations?.length
195
+ ) {
191
196
  this.writeWhole(session);
197
+ this.sessions.set(session.id, session);
192
198
 
193
199
  return;
194
200
  }
195
201
  appendFileSync(filePath, JSON.stringify(session) + "\n");
196
202
  this.lineCounts.set(session.id, lines + 1);
203
+ this.sessions.set(session.id, session);
197
204
  }
198
205
 
199
206
  delete(id: string): boolean {
@@ -290,9 +297,11 @@ export class MemoryThreadStore implements ThreadRepository {
290
297
  export type SessionRepository = ThreadRepository;
291
298
  /** @deprecated use ThreadStore */
292
299
  export const SessionStore = ThreadStore;
300
+
293
301
  /** @deprecated use ThreadStore */
294
302
  export type SessionStore = ThreadStore;
295
303
  /** @deprecated use MemoryThreadStore */
296
304
  export const MemorySessionStore = MemoryThreadStore;
305
+
297
306
  /** @deprecated use MemoryThreadStore */
298
307
  export type MemorySessionStore = MemoryThreadStore;
@@ -0,0 +1,119 @@
1
+ import * as v from "valibot";
2
+ import type { ThreadAgentState } from "@cueloop/schema";
3
+
4
+ const AgentAnchorSchema = v.object({
5
+ quote: v.pipe(v.string(), v.minLength(1), v.maxLength(32_768)),
6
+ prefix: v.string(),
7
+ suffix: v.string(),
8
+ blockIndex: v.number(),
9
+ start: v.number(),
10
+ end: v.number(),
11
+ endBlockIndex: v.optional(v.number()),
12
+ });
13
+
14
+ /** Validate agent comments at the socket and persisted transcript boundaries. */
15
+ export const AgentCommentSchema = v.object({
16
+ id: v.string(),
17
+ messageId: v.string(),
18
+ anchor: AgentAnchorSchema,
19
+ body: v.pipe(v.string(), v.minLength(1), v.maxLength(32_768)),
20
+ sent: v.boolean(),
21
+ replyTo: v.optional(v.string()),
22
+ author: v.optional(v.string()),
23
+ });
24
+
25
+ /** Validate persisted agent transcripts independently of submitted artifacts. */
26
+ const StoredAgentSchema = v.object({
27
+ threadId: v.string(),
28
+ fxSessionId: v.optional(v.string()),
29
+ harness: v.optional(
30
+ v.object({ id: v.string(), label: v.string(), sessionId: v.optional(v.string()) }),
31
+ ),
32
+ phase: v.variant("kind", [
33
+ v.object({ kind: v.literal("idle") }),
34
+ v.object({ kind: v.literal("running") }),
35
+ v.object({ kind: v.literal("failed"), error: v.string() }),
36
+ v.object({
37
+ kind: v.literal("permission"),
38
+ permission: v.object({
39
+ id: v.string(),
40
+ title: v.string(),
41
+ options: v.array(
42
+ v.object({
43
+ optionId: v.string(),
44
+ name: v.string(),
45
+ kind: v.string(),
46
+ }),
47
+ ),
48
+ }),
49
+ }),
50
+ ]),
51
+ messages: v.array(
52
+ v.object({
53
+ id: v.string(),
54
+ role: v.picklist(["user", "agent"]),
55
+ text: v.string(),
56
+ complete: v.boolean(),
57
+ revision: v.number(),
58
+ submissionId: v.optional(v.string()),
59
+ }),
60
+ ),
61
+ tools: v.array(
62
+ v.object({
63
+ id: v.string(),
64
+ turnId: v.string(),
65
+ title: v.string(),
66
+ kind: v.string(),
67
+ status: v.picklist(["pending", "in_progress", "completed", "failed", "cancelled"]),
68
+ output: v.string(),
69
+ locations: v.array(v.object({ path: v.string(), line: v.optional(v.number()) })),
70
+ }),
71
+ ),
72
+ comments: v.array(AgentCommentSchema),
73
+ submissions: v.optional(
74
+ v.array(
75
+ v.object({
76
+ id: v.string(),
77
+ commentId: v.optional(v.string()),
78
+ messageId: v.optional(v.string()),
79
+ prompt: v.string(),
80
+ quote: v.optional(v.string()),
81
+ context: v.optional(v.string()),
82
+ status: v.picklist(["queued", "running", "completed", "failed"]),
83
+ cancelled: v.optional(v.boolean()),
84
+ }),
85
+ ),
86
+ ),
87
+ promptOperations: v.optional(
88
+ v.array(
89
+ v.object({
90
+ operationId: v.string(),
91
+ fingerprint: v.string(),
92
+ result: v.array(v.string()),
93
+ outcome: v.optional(v.picklist(["completed", "failed", "cancelled"])),
94
+ }),
95
+ ),
96
+ ),
97
+ configOptions: v.optional(
98
+ v.array(
99
+ v.object({
100
+ id: v.string(),
101
+ name: v.string(),
102
+ category: v.optional(v.string()),
103
+ currentValue: v.string(),
104
+ options: v.array(v.object({ value: v.string(), name: v.string() })),
105
+ }),
106
+ ),
107
+ ),
108
+ });
109
+
110
+ /** Read the first fx prototype's records without discarding their session identity. */
111
+ export const ThreadAgentSchema: v.GenericSchema<ThreadAgentState> = v.pipe(
112
+ StoredAgentSchema,
113
+ v.transform(({ fxSessionId, ...state }) => ({
114
+ ...state,
115
+ harness:
116
+ state.harness ??
117
+ (fxSessionId ? { id: "fx", label: "fx", sessionId: fxSessionId } : undefined),
118
+ })),
119
+ );
@@ -0,0 +1,412 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } 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
+ function createTestThread(home: string): Thread {
9
+ return {
10
+ schemaVersion: SCHEMA_VERSION,
11
+ id: "adapter-test",
12
+ workspace: { repoRoot: home, branch: "main" },
13
+ artifact: { type: "plan", content: "Review the retry", meta: {} },
14
+ revisions: [],
15
+ annotations: [],
16
+ message: null,
17
+ status: "pending",
18
+ createdAt: "2026-10-04",
19
+ };
20
+ }
21
+
22
+ test("a non-ACP harness streams and resumes through the same Thread manager", async () => {
23
+ const home = mkdtempSync(join(tmpdir(), "cueloop-harness-"));
24
+ const thread = createTestThread(home);
25
+ const sessions: (string | undefined)[] = [];
26
+ const adapter: AgentHarnessAdapter = {
27
+ id: "test-native",
28
+ label: "Native agent",
29
+ connect(options) {
30
+ sessions.push(options.sessionId);
31
+
32
+ return {
33
+ start: async () => options.sessionId ?? "native-session",
34
+ prompt: async () => {
35
+ options.onEvent({
36
+ kind: "tool",
37
+ id: "read",
38
+ title: "Read retry",
39
+ toolKind: "read",
40
+ status: "completed",
41
+ });
42
+ options.onEvent({ kind: "message", id: crypto.randomUUID(), text: "Native answer" });
43
+
44
+ return { outcome: "completed" as const };
45
+ },
46
+ cancel() {},
47
+ permission() {},
48
+ close() {},
49
+ };
50
+ },
51
+ };
52
+ let manager = new ThreadAgentManager({
53
+ home,
54
+ enabled: true,
55
+ adapter,
56
+ getThread: () => thread,
57
+ onChange() {},
58
+ });
59
+ const finish = async () => {
60
+ for (let attempt = 0; manager.get(thread.id).phase.kind !== "idle" && attempt < 100; attempt++)
61
+ await Bun.sleep(1);
62
+ expect(manager.get(thread.id).phase).toEqual({ kind: "idle" });
63
+ };
64
+
65
+ try {
66
+ manager.prompt({ id: thread.id, text: "Explain" });
67
+ await finish();
68
+ expect(manager.get(thread.id).harness).toEqual({
69
+ id: "test-native",
70
+ label: "Native agent",
71
+ sessionId: "native-session",
72
+ });
73
+ expect(manager.get(thread.id).messages[1]?.text).toBe("Native answer");
74
+ const promptDiscussion = { id: manager.get(thread.id).submissions![0]!.id };
75
+
76
+ expect(manager.isReadOnly(thread.id, promptDiscussion.id)).toBe(true);
77
+ const promptReply = manager.reply(thread.id, promptDiscussion.id, "The prompt is clear");
78
+
79
+ expect(promptReply.comments.at(-1)?.replyTo).toBe(promptDiscussion.id);
80
+ const answer = manager.get(thread.id).messages[1]!;
81
+
82
+ manager.comment({
83
+ id: thread.id,
84
+ comment: {
85
+ id: "answer-question",
86
+ messageId: answer.id,
87
+ anchor: { quote: "Native", prefix: "", suffix: " answer", blockIndex: 0, start: 0, end: 6 },
88
+ body: "Explain this",
89
+ sent: false,
90
+ },
91
+ });
92
+ const replied = manager.reply(thread.id, "answer-question", "This is the explanation");
93
+
94
+ expect(replied.comments.at(-1)).toMatchObject({
95
+ replyTo: "answer-question",
96
+ messageId: answer.id,
97
+ sent: true,
98
+ author: "embedded-agent",
99
+ });
100
+ manager.comment({
101
+ id: thread.id,
102
+ comment: {
103
+ ...replied.comments.find((entry) => entry.id === "answer-question")!,
104
+ id: "answer-followup",
105
+ replyTo: "answer-question",
106
+ body: "Why?",
107
+ sent: false,
108
+ },
109
+ });
110
+ manager.prompt({ id: thread.id, text: "" });
111
+ expect(
112
+ manager.get(thread.id).submissions?.find((entry) => entry.commentId === "answer-followup")
113
+ ?.context,
114
+ ).toContain("This is the explanation");
115
+ await finish();
116
+ manager.dispose();
117
+ manager = new ThreadAgentManager({
118
+ home,
119
+ enabled: true,
120
+ adapter,
121
+ getThread: () => thread,
122
+ onChange() {},
123
+ });
124
+ manager.prompt({ id: thread.id, text: "Continue" });
125
+ await finish();
126
+ expect(sessions).toEqual([undefined, "native-session"]);
127
+ const other = new ThreadAgentManager({
128
+ home,
129
+ enabled: true,
130
+ adapter: { ...adapter, id: "another-agent" },
131
+ getThread: () => thread,
132
+ onChange() {},
133
+ });
134
+
135
+ expect(() => other.prompt({ id: thread.id, text: "Mix sessions" })).toThrow(
136
+ "different harness",
137
+ );
138
+ other.dispose();
139
+ } finally {
140
+ manager.dispose();
141
+ rmSync(home, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ test("the first prototype's fx records migrate without losing session identity", () => {
146
+ const home = mkdtempSync(join(tmpdir(), "cueloop-harness-migrate-"));
147
+ const thread = createTestThread(home);
148
+ const path = join(home, "thread-agents");
149
+
150
+ mkdirSync(path);
151
+ writeFileSync(
152
+ join(path, `${thread.id}.json`),
153
+ JSON.stringify({
154
+ threadId: thread.id,
155
+ fxSessionId: "saved-fx-session",
156
+ phase: { kind: "idle" },
157
+ messages: [],
158
+ tools: [],
159
+ comments: [],
160
+ }),
161
+ );
162
+ const manager = new ThreadAgentManager({
163
+ home,
164
+ enabled: false,
165
+ getThread: () => thread,
166
+ onChange() {},
167
+ });
168
+
169
+ try {
170
+ expect(manager.get(thread.id).harness).toEqual({
171
+ id: "fx",
172
+ label: "fx",
173
+ sessionId: "saved-fx-session",
174
+ });
175
+ } finally {
176
+ manager.dispose();
177
+ rmSync(home, { recursive: true, force: true });
178
+ }
179
+ });
180
+
181
+ test("empty input is a no-op; queued comments freeze once and retries reuse their mirrors", async () => {
182
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-queue-"));
183
+ const thread = createTestThread(home);
184
+ const prompts: string[] = [];
185
+ const finishes: ((outcome: "completed" | "cancelled") => void)[] = [];
186
+ const adapter: AgentHarnessAdapter = {
187
+ id: "test",
188
+ label: "Test",
189
+ connect(options) {
190
+ return {
191
+ start: async () => "session",
192
+ prompt: (text) => {
193
+ prompts.push(text);
194
+ options.onEvent({
195
+ kind: "message",
196
+ id: `answer-${prompts.length}`,
197
+ text: `Reply ${prompts.length}`,
198
+ });
199
+
200
+ return new Promise((resolve) => finishes.push((outcome) => resolve({ outcome })));
201
+ },
202
+ cancel() {
203
+ finishes.at(-1)?.("cancelled");
204
+ },
205
+ permission() {},
206
+ close() {},
207
+ };
208
+ },
209
+ };
210
+ const manager = new ThreadAgentManager({
211
+ home,
212
+ enabled: true,
213
+ adapter,
214
+ getThread: () => thread,
215
+ onChange() {},
216
+ });
217
+ const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
218
+
219
+ try {
220
+ manager.prompt({ id: thread.id, text: "" });
221
+ expect(prompts).toEqual([]);
222
+ expect(manager.get(thread.id).messages).toEqual([]);
223
+ thread.annotations.push({
224
+ id: "first",
225
+ kind: "comment",
226
+ body: "Explain first",
227
+ anchor: {
228
+ quote: "Review",
229
+ prefix: "",
230
+ suffix: " the retry",
231
+ blockIndex: 0,
232
+ start: 0,
233
+ end: 6,
234
+ },
235
+ createdAt: thread.createdAt,
236
+ });
237
+ manager.prompt({ id: thread.id, text: "" });
238
+ await tick();
239
+ expect(manager.isReadOnly(thread.id, "first")).toBe(true);
240
+ thread.annotations.push({ ...thread.annotations[0]!, id: "second", body: "Explain second" });
241
+ manager.prompt({ id: thread.id, text: "" });
242
+ expect(prompts).toHaveLength(1);
243
+ expect(manager.get(thread.id).submissions?.map((entry) => entry.status)).toEqual([
244
+ "running",
245
+ "queued",
246
+ ]);
247
+ finishes[0]!("completed");
248
+ await tick();
249
+ expect(prompts).toHaveLength(2);
250
+ expect(prompts[1]).toContain("Input: Explain second");
251
+ expect(prompts[1]).toContain("Reply 1");
252
+ finishes[1]!("cancelled");
253
+ await tick();
254
+ const failed = manager.get(thread.id).submissions![1]!;
255
+ const mirrors = manager.get(thread.id).messages.filter((entry) => entry.role === "user");
256
+
257
+ manager.prompt({ id: thread.id, text: "", retry: failed.id });
258
+ await tick();
259
+ expect(manager.get(thread.id).messages.filter((entry) => entry.role === "user")).toEqual(
260
+ mirrors,
261
+ );
262
+ expect(manager.isReadOnly(thread.id, "second")).toBe(true);
263
+ finishes[2]!("completed");
264
+ await tick();
265
+ expect(manager.get(thread.id).submissions?.map((entry) => entry.status)).toEqual([
266
+ "completed",
267
+ "completed",
268
+ ]);
269
+ manager.prompt({ id: thread.id, text: "" });
270
+ expect(prompts).toHaveLength(3);
271
+ } finally {
272
+ manager.dispose();
273
+ rmSync(home, { recursive: true, force: true });
274
+ }
275
+ });
276
+
277
+ test("rejected first batches leave originals editable and create no queued turn", () => {
278
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-rejected-"));
279
+ const thread = createTestThread(home);
280
+ const manager = new ThreadAgentManager({
281
+ home,
282
+ enabled: true,
283
+ adapter: {
284
+ id: "test",
285
+ label: "Test",
286
+ connect() {
287
+ throw new Error("A rejected batch must not start");
288
+ },
289
+ },
290
+ getThread: () => thread,
291
+ onChange() {},
292
+ });
293
+ const original = {
294
+ id: "valid",
295
+ kind: "comment",
296
+ body: "Explain",
297
+ anchor: { quote: "Review", prefix: "", suffix: " the retry", blockIndex: 0, start: 0, end: 6 },
298
+ createdAt: thread.createdAt,
299
+ };
300
+
301
+ try {
302
+ thread.annotations.push(original, { ...original, id: "oversized", body: "x".repeat(320001) });
303
+ expect(() => manager.prompt({ id: thread.id, text: "" })).toThrow("320 KiB");
304
+ expect(manager.get(thread.id).messages).toEqual([]);
305
+ expect(manager.get(thread.id).submissions).toBeUndefined();
306
+ expect(manager.isReadOnly(thread.id, "valid")).toBe(false);
307
+ } finally {
308
+ manager.dispose();
309
+ rmSync(home, { recursive: true, force: true });
310
+ }
311
+ });
312
+
313
+ test("prompt operation receipts survive restart without requeueing accepted inputs", () => {
314
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-receipt-"));
315
+ const thread = createTestThread(home);
316
+ const adapter: AgentHarnessAdapter = {
317
+ id: "receipt-test",
318
+ label: "Receipt test",
319
+ connect: () => ({
320
+ start: async () => "session",
321
+ prompt: () => new Promise(() => {}),
322
+ cancel() {},
323
+ permission() {},
324
+ close() {},
325
+ }),
326
+ };
327
+ const options = { home, enabled: true, adapter, getThread: () => thread, onChange() {} };
328
+ let manager = new ThreadAgentManager(options);
329
+
330
+ try {
331
+ const input = { id: thread.id, text: "Once", operationId: "once" };
332
+ const accepted = manager.prompt(input);
333
+
334
+ manager.dispose();
335
+ manager = new ThreadAgentManager(options);
336
+ const replay = manager.prompt(input);
337
+
338
+ expect(replay.promptOperations?.[0]?.result).toEqual(accepted.promptOperations?.[0]?.result);
339
+ expect(replay.submissions).toHaveLength(1);
340
+ expect(replay.promptOperations?.[0]?.outcome).toBe("failed");
341
+ expect(() => manager.prompt({ ...input, text: "Different" })).toThrow(
342
+ "Operation payload conflict",
343
+ );
344
+ manager.prompt({
345
+ id: thread.id,
346
+ text: "",
347
+ retry: replay.submissions![0]!.id,
348
+ operationId: "retry",
349
+ });
350
+ expect(manager.get(thread.id).promptOperations?.[0]?.outcome).toBe("failed");
351
+ } finally {
352
+ manager.dispose();
353
+ rmSync(home, { recursive: true, force: true });
354
+ }
355
+ });
356
+
357
+ test("retrying accepted work after a failed running-state save starts the harness once", async () => {
358
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-start-save-"));
359
+ const thread = createTestThread(home);
360
+ const blockedPath = join(home, "thread-agents", `${thread.id}.json.tmp`);
361
+ let saves = 0;
362
+ let prompts = 0;
363
+ const started = Promise.withResolvers<void>();
364
+ const adapter: AgentHarnessAdapter = {
365
+ id: "start-save-test",
366
+ label: "Start save test",
367
+ connect: () => ({
368
+ start: async () => "session",
369
+ prompt: () => {
370
+ prompts++;
371
+ started.resolve();
372
+
373
+ return new Promise(() => {});
374
+ },
375
+ cancel() {},
376
+ permission() {},
377
+ close() {},
378
+ }),
379
+ };
380
+ const manager = new ThreadAgentManager({
381
+ home,
382
+ enabled: true,
383
+ adapter,
384
+ getThread: () => thread,
385
+ onChange() {
386
+ if (++saves === 1) mkdirSync(blockedPath);
387
+ },
388
+ });
389
+
390
+ try {
391
+ const input = { id: thread.id, text: "Start once", operationId: "start-once" };
392
+
393
+ expect(() => manager.prompt(input)).toThrow();
394
+ expect(manager.get(thread.id).phase.kind).toBe("idle");
395
+ expect(manager.get(thread.id).submissions?.[0]?.status).toBe("queued");
396
+ expect(prompts).toBe(0);
397
+ const acceptedIds = manager.get(thread.id).promptOperations?.[0]?.result;
398
+
399
+ rmSync(blockedPath, { recursive: true });
400
+ const replay = manager.prompt(input);
401
+
402
+ expect(replay.promptOperations?.[0]?.result).toEqual(acceptedIds);
403
+ expect(replay.submissions).toHaveLength(1);
404
+ await started.promise;
405
+ expect(prompts).toBe(1);
406
+ manager.prompt(input);
407
+ expect(prompts).toBe(1);
408
+ } finally {
409
+ manager.dispose();
410
+ rmSync(home, { recursive: true, force: true });
411
+ }
412
+ });