@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.
- package/README.md +7 -0
- package/SDK.md +102 -0
- package/package.json +12 -8
- package/src/agent-tools.ts +98 -0
- package/src/agent-turn.test.ts +24 -0
- package/src/agent-turn.ts +34 -0
- package/src/api.ts +19 -1
- package/src/capabilities.ts +7 -0
- package/src/client-errors.ts +24 -0
- package/src/client-reliability.test.ts +189 -0
- package/src/client.test.ts +13 -0
- package/src/client.ts +179 -23
- package/src/daemon-sdk-effect.ts +113 -0
- package/src/daemon-sdk.test.ts +306 -0
- package/src/daemon-sdk.ts +397 -0
- package/src/examples/daemon-sdk.ts +55 -0
- package/src/harness-delivery.test.ts +1 -0
- package/src/harness-diagnostics.test.ts +26 -0
- package/src/harness-diagnostics.ts +30 -0
- package/src/operation-receipts.test.ts +111 -0
- package/src/operation-receipts.ts +91 -0
- package/src/pr-poller.ts +1 -0
- package/src/sdk-identities.ts +35 -0
- package/src/server.ts +181 -2
- package/src/share-blob.ts +3 -1
- package/src/store.jsonl.test.ts +14 -0
- package/src/store.ts +11 -2
- package/src/thread-agent-validation.ts +119 -0
- package/src/thread-agent.test.ts +412 -0
- package/src/thread-agent.ts +714 -0
- package/src/thread-review.ts +1 -0
- package/src/thread-subscription.test.ts +174 -0
- package/src/thread-subscription.ts +127 -0
- package/src/validate.test.ts +1 -0
- package/src/validate.ts +26 -0
- package/src/vcs-source.ts +1 -0
- package/src/working-tree.ts +2 -0
package/src/store.jsonl.test.ts
CHANGED
|
@@ -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 (
|
|
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
|
+
});
|