@poncho-ai/harness 0.53.0 → 0.57.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.
@@ -0,0 +1,274 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { LanguageModel } from "ai";
3
+ import { MockLanguageModelV3 } from "ai/test";
4
+ import type { Message } from "@poncho-ai/sdk";
5
+ import {
6
+ compactMessages,
7
+ findSafeSplitPoint,
8
+ resolveCompactionConfig,
9
+ } from "../src/compaction.js";
10
+
11
+ // ── Fake model ──────────────────────────────────────────────────────────
12
+ // A MockLanguageModelV3 whose doGenerate returns a fixed text and records the
13
+ // prompt it was handed, so tests can assert what was sent to the summarizer.
14
+ function fakeModel(summaryText: string): {
15
+ model: LanguageModel;
16
+ prompts: string[];
17
+ } {
18
+ const prompts: string[] = [];
19
+ const model = new MockLanguageModelV3({
20
+ doGenerate: async (options) => {
21
+ // Flatten the prompt text we were given (the user message content).
22
+ for (const m of options.prompt) {
23
+ if (Array.isArray(m.content)) {
24
+ for (const part of m.content) {
25
+ if (part.type === "text") prompts.push(part.text);
26
+ }
27
+ }
28
+ }
29
+ return {
30
+ content: [{ type: "text", text: summaryText }],
31
+ finishReason: "stop",
32
+ usage: { inputTokens: 10, outputTokens: 10, totalTokens: 20 },
33
+ warnings: [],
34
+ };
35
+ },
36
+ });
37
+ return { model: model as unknown as LanguageModel, prompts };
38
+ }
39
+
40
+ const userMsg = (text: string, metadata?: Message["metadata"]): Message => ({
41
+ role: "user",
42
+ content: text,
43
+ ...(metadata ? { metadata } : {}),
44
+ });
45
+ const assistantText = (text: string): Message => ({
46
+ role: "assistant",
47
+ content: text,
48
+ });
49
+ const assistantToolCall = (text: string, toolName: string): Message => ({
50
+ role: "assistant",
51
+ content: JSON.stringify({
52
+ text,
53
+ tool_calls: [{ id: "call_1", name: toolName, arguments: {} }],
54
+ }),
55
+ });
56
+ const toolResult = (text: string): Message => ({ role: "tool", content: text });
57
+
58
+ describe("findSafeSplitPoint", () => {
59
+ it("splits at a normal user-message boundary", () => {
60
+ const messages: Message[] = [
61
+ userMsg("u0"),
62
+ assistantText("a0"),
63
+ userMsg("u1"),
64
+ assistantText("a1"),
65
+ userMsg("u2"), // index 4 — a clean user boundary
66
+ assistantText("a2"),
67
+ userMsg("u3"),
68
+ assistantText("a3"),
69
+ ];
70
+ const idx = findSafeSplitPoint(messages, 4);
71
+ // candidate = 8 - 4 = 4, which is already a user message → split there.
72
+ expect(idx).toBe(4);
73
+ expect(messages[idx]!.role).toBe("user");
74
+ });
75
+
76
+ it("returns -1 when there are too few messages", () => {
77
+ const messages: Message[] = [userMsg("u0"), assistantText("a0")];
78
+ expect(findSafeSplitPoint(messages, 4)).toBe(-1);
79
+ });
80
+
81
+ it("walks earlier when the split would orphan tool_calls being moved", () => {
82
+ // The candidate user boundary sits right after an assistant tool-call
83
+ // message whose tool result is on the preserved side — splitting there
84
+ // would strand the tool_calls in the summary. Guard must walk earlier to
85
+ // the next clean user boundary (which is still >= MIN_COMPACTABLE_MESSAGES).
86
+ const messages: Message[] = [
87
+ userMsg("u0"), // 0
88
+ assistantText("a0"), // 1
89
+ userMsg("u1"), // 2
90
+ assistantText("a1"), // 3
91
+ userMsg("u2"), // 4 <- safe earlier boundary (>= MIN_COMPACTABLE_MESSAGES)
92
+ assistantText("a2"), // 5
93
+ assistantToolCall("calling tool", "search"), // 6 <- would be last-compacted if split at 7
94
+ userMsg("u3 (tool result delivered as user)"), // 7 <- candidate boundary
95
+ toolResult("result"), // 8
96
+ assistantText("a3"), // 9
97
+ ];
98
+ // candidate = 10 - 3 = 7 (a user message), but messages[6] is an assistant
99
+ // with tool_calls → orphan. Must walk back to index 4.
100
+ const idx = findSafeSplitPoint(messages, 3);
101
+ expect(idx).toBe(4);
102
+ // Confirm the chosen split does NOT end the compacted side on a dangling
103
+ // assistant-with-tool_calls.
104
+ const lastCompacted = messages[idx - 1]!;
105
+ expect(
106
+ typeof lastCompacted.content === "string" &&
107
+ lastCompacted.content.includes('"tool_calls"'),
108
+ ).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe("compactMessages", () => {
113
+ const config = resolveCompactionConfig({ keepRecentMessages: 2 });
114
+
115
+ it("compacts older messages into a summary continuation message", async () => {
116
+ const { model } = fakeModel("SUMMARY TEXT");
117
+ const messages: Message[] = [
118
+ userMsg("u0"),
119
+ assistantText("a0"),
120
+ userMsg("u1"),
121
+ assistantText("a1"),
122
+ userMsg("u2"),
123
+ assistantText("a2"),
124
+ ];
125
+ const res = await compactMessages(model, messages, config);
126
+ expect(res.compacted).toBe(true);
127
+ expect(res.messages[0]!.metadata?.isCompactionSummary).toBe(true);
128
+ expect(res.messages[0]!.content).toContain("SUMMARY TEXT");
129
+ // No subagents → no ledger block.
130
+ expect(res.messages[0]!.content).not.toContain("## Subagents");
131
+ });
132
+
133
+ it("appends a verbatim subagent ledger after the LLM summary", async () => {
134
+ const { model } = fakeModel("SUMMARY TEXT");
135
+ const messages: Message[] = [
136
+ userMsg("u0"),
137
+ assistantText("a0"),
138
+ userMsg(
139
+ '[Subagent Result] Subagent "research the API" (sub_abc) completed:\n\nFound that the endpoint returns JSON with a data array. Use /v2/items.',
140
+ {
141
+ _subagentCallback: true,
142
+ subagentId: "sub_abc",
143
+ task: "research the API",
144
+ } as Message["metadata"],
145
+ ),
146
+ assistantText("a1"),
147
+ userMsg("u2"),
148
+ assistantText("a2"),
149
+ ];
150
+ const res = await compactMessages(model, messages, config);
151
+ expect(res.compacted).toBe(true);
152
+ const content = res.messages[0]!.content as string;
153
+ expect(content).toContain("## Subagents");
154
+ expect(content).toContain("sub_abc");
155
+ expect(content).toContain("research the API");
156
+ // Digest carries the verbatim result body.
157
+ expect(content).toContain("endpoint returns JSON");
158
+ // Ledger comes AFTER the summary text.
159
+ expect(content.indexOf("SUMMARY TEXT")).toBeLessThan(
160
+ content.indexOf("## Subagents"),
161
+ );
162
+ });
163
+
164
+ it("detects subagent callbacks by text marker even without metadata", async () => {
165
+ const { model } = fakeModel("S");
166
+ const messages: Message[] = [
167
+ userMsg("u0"),
168
+ assistantText("a0"),
169
+ userMsg(
170
+ '[Subagent Result] Subagent "compile report" (sub_xyz) completed:\n\nThe report is ready.',
171
+ ),
172
+ assistantText("a1"),
173
+ userMsg("u2"),
174
+ assistantText("a2"),
175
+ ];
176
+ const res = await compactMessages(model, messages, config);
177
+ const content = res.messages[0]!.content as string;
178
+ expect(content).toContain("sub_xyz");
179
+ expect(content).toContain("compile report");
180
+ });
181
+
182
+ it("carries forward a prior ledger and dedupes by subagentId", async () => {
183
+ const { model } = fakeModel("NEW SUMMARY");
184
+ // First compacted message is itself a prior compaction summary that
185
+ // already embeds a ## Subagents block for sub_abc and sub_old.
186
+ const priorSummary: Message = {
187
+ role: "user",
188
+ content: [
189
+ "[CONTEXT COMPACTION] prior.",
190
+ "<summary>",
191
+ "Earlier work done.",
192
+ "",
193
+ "## Subagents",
194
+ "- **research the API** (sub_abc) — completed",
195
+ " Old digest about the API.",
196
+ "- **legacy task** (sub_old) — completed",
197
+ " Legacy digest text.",
198
+ "</summary>",
199
+ ].join("\n"),
200
+ metadata: { isCompactionSummary: true },
201
+ };
202
+ const messages: Message[] = [
203
+ priorSummary,
204
+ assistantText("a0"),
205
+ // A fresh callback for sub_abc should OVERRIDE the prior entry.
206
+ userMsg(
207
+ '[Subagent Result] Subagent "research the API" (sub_abc) completed:\n\nUpdated finding: the endpoint moved to /v3/items.',
208
+ {
209
+ _subagentCallback: true,
210
+ subagentId: "sub_abc",
211
+ task: "research the API",
212
+ } as Message["metadata"],
213
+ ),
214
+ assistantText("a1"),
215
+ userMsg("u2"),
216
+ assistantText("a2"),
217
+ ];
218
+ const res = await compactMessages(model, messages, config);
219
+ const content = res.messages[0]!.content as string;
220
+ // Both subagents present.
221
+ expect(content).toContain("sub_abc");
222
+ expect(content).toContain("sub_old");
223
+ // sub_abc appears exactly once (deduped).
224
+ const occurrences = content.split("sub_abc").length - 1;
225
+ expect(occurrences).toBe(1);
226
+ // The newer digest won.
227
+ expect(content).toContain("/v3/items");
228
+ expect(content).not.toContain("Old digest about the API");
229
+ });
230
+
231
+ it("passes a prior summary in full (no 1200-char truncation) and adds the merge instruction", async () => {
232
+ const { model, prompts } = fakeModel("MERGED");
233
+ const longPrior = "PRIOR-STATE ".repeat(200); // ~2400 chars, > 1200
234
+ const priorSummary: Message = {
235
+ role: "user",
236
+ content: longPrior,
237
+ metadata: { isCompactionSummary: true },
238
+ };
239
+ const messages: Message[] = [
240
+ priorSummary,
241
+ assistantText("a0"),
242
+ userMsg("u1"),
243
+ assistantText("a1"),
244
+ userMsg("u2"),
245
+ assistantText("a2"),
246
+ ];
247
+ await compactMessages(model, messages, config);
248
+ const sentPrompt = prompts.join("\n");
249
+ // The whole prior summary text was sent, untruncated.
250
+ expect(sentPrompt).toContain(longPrior.trim());
251
+ expect(sentPrompt).not.toContain("[truncated]");
252
+ // Tagged as prior-summary, with the merge-and-update instruction.
253
+ expect(sentPrompt).toContain("[prior-summary]");
254
+ expect(sentPrompt).toContain("MERGE AND UPDATE");
255
+ });
256
+
257
+ it("still truncates non-prior-summary long messages to 1200 chars", async () => {
258
+ const { model, prompts } = fakeModel("S");
259
+ const longUser = "X".repeat(3000);
260
+ const messages: Message[] = [
261
+ userMsg(longUser),
262
+ assistantText("a0"),
263
+ userMsg("u1"),
264
+ assistantText("a1"),
265
+ userMsg("u2"),
266
+ assistantText("a2"),
267
+ ];
268
+ await compactMessages(model, messages, config);
269
+ const sentPrompt = prompts.join("\n");
270
+ expect(sentPrompt).toContain("[truncated]");
271
+ // The first message was NOT a prior summary, so no merge instruction.
272
+ expect(sentPrompt).not.toContain("MERGE AND UPDATE");
273
+ });
274
+ });
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { Message } from "@poncho-ai/sdk";
3
+ import { createLogger } from "@poncho-ai/sdk";
4
+ import { InMemoryConversationStore } from "../src/state.js";
5
+ import {
6
+ buildDisplaySnapshot,
7
+ buildLlmContext,
8
+ getPendingSubagentResults,
9
+ } from "../src/storage/entries.js";
10
+ import {
11
+ appendEntriesSafe,
12
+ assistantMessageEntry,
13
+ callbackStartedEntry,
14
+ compactionEntry,
15
+ harnessMessageEntries,
16
+ newHarnessMessagesThisTurn,
17
+ subagentResultEntry,
18
+ userMessageEntry,
19
+ } from "../src/orchestrator/entries-dual-write.js";
20
+
21
+ const log = createLogger("test");
22
+ const msg = (role: Message["role"], content: string): Message => ({
23
+ role,
24
+ content,
25
+ metadata: { id: `${role}-${content}` },
26
+ });
27
+
28
+ const conv = (id: string) => ({
29
+ conversationId: id,
30
+ ownerId: "owner-1",
31
+ tenantId: null as string | null,
32
+ });
33
+
34
+ describe("entries dual-write", () => {
35
+ it("rebuilds llm context + display from a simulated chat turn's appends", async () => {
36
+ const store = new InMemoryConversationStore();
37
+ const c = conv("c1");
38
+ const turnId = "turn-1";
39
+
40
+ // Turn start: user message.
41
+ await appendEntriesSafe(store, c, [userMessageEntry(msg("user", "hi"), turnId)], log);
42
+
43
+ // During the turn the harness produced two model-visible messages and a
44
+ // final assistant bubble.
45
+ const harness1 = msg("user", "hi");
46
+ const harness2 = msg("assistant", "hello there");
47
+ const finalAssistant = msg("assistant", "hello there");
48
+ await appendEntriesSafe(
49
+ store,
50
+ c,
51
+ [
52
+ ...harnessMessageEntries([harness1, harness2], turnId),
53
+ assistantMessageEntry(finalAssistant, turnId, "run-1"),
54
+ ],
55
+ log,
56
+ );
57
+
58
+ const entries = await store.readEntries("c1");
59
+
60
+ // LLM context == the harness messages in order.
61
+ const llm = buildLlmContext(entries);
62
+ expect(llm.map((m) => m.content)).toEqual(["hi", "hello there"]);
63
+
64
+ // Display == [user, assistant] (final assistant bubble; harness msgs hidden).
65
+ const snap = buildDisplaySnapshot(entries, 100);
66
+ expect(snap.messages.map((m) => [m.role, m.content])).toEqual([
67
+ ["user", "hi"],
68
+ ["assistant", "hello there"],
69
+ ]);
70
+ expect(snap.totalMessages).toBe(2);
71
+ });
72
+
73
+ it("compaction overlay keeps summary + tail at rebuild", async () => {
74
+ const store = new InMemoryConversationStore();
75
+ const c = conv("c2");
76
+
77
+ await appendEntriesSafe(
78
+ store,
79
+ c,
80
+ harnessMessageEntries(
81
+ [msg("user", "m1"), msg("assistant", "m2"), msg("user", "m3")],
82
+ "t",
83
+ ),
84
+ log,
85
+ );
86
+ const before = await store.readEntries("c2");
87
+ // Keep only the last harness message (seq 3) after compaction.
88
+ const firstKeptSeq = before[before.length - 1]!.seq;
89
+ await appendEntriesSafe(
90
+ store,
91
+ c,
92
+ [compactionEntry(msg("assistant", "SUMMARY"), firstKeptSeq)],
93
+ log,
94
+ );
95
+
96
+ const llm = buildLlmContext(await store.readEntries("c2"));
97
+ expect(llm.map((m) => m.content)).toEqual(["SUMMARY", "m3"]);
98
+ });
99
+
100
+ it("subagent_result + callback_started track pending consumption", async () => {
101
+ const store = new InMemoryConversationStore();
102
+ const c = conv("c3");
103
+
104
+ const stored = await appendEntriesSafe(
105
+ store,
106
+ c,
107
+ [
108
+ subagentResultEntry({
109
+ subagentId: "sa-1",
110
+ task: "do thing",
111
+ status: "completed",
112
+ timestamp: 1,
113
+ }),
114
+ ],
115
+ log,
116
+ );
117
+ const resultSeq = stored[0]!.seq;
118
+
119
+ // Before consumption: pending.
120
+ expect(getPendingSubagentResults(await store.readEntries("c3"))).toHaveLength(1);
121
+
122
+ // The callback consumes it + injects a hidden user message.
123
+ await appendEntriesSafe(
124
+ store,
125
+ c,
126
+ [
127
+ callbackStartedEntry([resultSeq]),
128
+ userMessageEntry(msg("user", "[Subagent Result] ..."), "cb-1", { hidden: true }),
129
+ ],
130
+ log,
131
+ );
132
+
133
+ const after = await store.readEntries("c3");
134
+ expect(getPendingSubagentResults(after)).toHaveLength(0);
135
+ // Hidden injected message does not appear in the display transcript.
136
+ expect(buildDisplaySnapshot(after, 100).messages).toHaveLength(0);
137
+ });
138
+
139
+ it("newHarnessMessagesThisTurn diffs the suffix and flags shrinks", () => {
140
+ const a = msg("user", "a");
141
+ const b = msg("assistant", "b");
142
+ const cc = msg("user", "c");
143
+
144
+ expect(newHarnessMessagesThisTurn(undefined, [a, b])).toEqual({
145
+ messages: [a, b],
146
+ approximate: false,
147
+ });
148
+ expect(newHarnessMessagesThisTurn([a], [a, b, cc])).toEqual({
149
+ messages: [b, cc],
150
+ approximate: false,
151
+ });
152
+ // Shrink (compaction reshaped the array) → approximate, returns full next.
153
+ const shrink = newHarnessMessagesThisTurn([a, b, cc], [a]);
154
+ expect(shrink.approximate).toBe(true);
155
+ expect(shrink.messages).toEqual([a]);
156
+ });
157
+
158
+ it("appendEntriesSafe swallows store errors and returns []", async () => {
159
+ const brokenStore = {
160
+ appendEntries: async () => {
161
+ throw new Error("boom");
162
+ },
163
+ } as unknown as InMemoryConversationStore;
164
+ const result = await appendEntriesSafe(
165
+ brokenStore,
166
+ conv("c4"),
167
+ [userMessageEntry(msg("user", "x"), "t")],
168
+ log,
169
+ );
170
+ expect(result).toEqual([]);
171
+ });
172
+ });
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { InMemoryConversationStore } from "../src/state.js";
6
+ import type { ConversationStore } from "../src/state.js";
7
+ import type { ConversationEntry, NewConversationEntry } from "../src/storage/entries.js";
8
+ import { SqliteEngine } from "../src/storage/sqlite-engine.js";
9
+ import { createConversationStoreFromEngine } from "../src/storage/store-adapters.js";
10
+ import type { Message } from "@poncho-ai/sdk";
11
+
12
+ const msg = (role: Message["role"], content: string): Message => ({ role, content });
13
+
14
+ // Entry factories (without seq/createdAt — those are assigned by the store).
15
+ const userEntry = (id: string, content: string): NewConversationEntry => ({
16
+ type: "user_message",
17
+ id,
18
+ message: msg("user", content),
19
+ turnId: "t1",
20
+ });
21
+
22
+ const harnessEntry = (id: string, content: string): NewConversationEntry => ({
23
+ type: "harness_message",
24
+ id,
25
+ message: msg("assistant", content),
26
+ turnId: "t1",
27
+ });
28
+
29
+ const compactionEntry = (id: string): NewConversationEntry => ({
30
+ type: "compaction",
31
+ id,
32
+ summaryMessage: msg("user", "summary so far"),
33
+ firstKeptSeq: 2,
34
+ });
35
+
36
+ // Shared suite run against both InMemory and SQLite-backed stores.
37
+ function runSuite(name: string, factory: () => Promise<{ store: ConversationStore; cleanup: () => Promise<void> }>) {
38
+ describe(name, () => {
39
+ let store: ConversationStore;
40
+ let cleanup: () => Promise<void>;
41
+
42
+ beforeEach(async () => {
43
+ const ctx = await factory();
44
+ store = ctx.store;
45
+ cleanup = ctx.cleanup;
46
+ });
47
+
48
+ afterEach(async () => {
49
+ await cleanup();
50
+ });
51
+
52
+ it("assigns consecutive per-conversation seqs starting at 1", async () => {
53
+ const stored = await store.appendEntries("c1", "agent", null, [
54
+ userEntry("u1", "hi"),
55
+ harnessEntry("h1", "hello"),
56
+ harnessEntry("h2", "how can I help?"),
57
+ ]);
58
+ expect(stored.map((e) => e.seq)).toEqual([1, 2, 3]);
59
+ expect(stored.every((e) => typeof e.createdAt === "number")).toBe(true);
60
+ });
61
+
62
+ it("continues seq across multiple appendEntries calls", async () => {
63
+ await store.appendEntries("c1", "agent", null, [userEntry("u1", "a")]);
64
+ const second = await store.appendEntries("c1", "agent", null, [
65
+ harnessEntry("h1", "b"),
66
+ harnessEntry("h2", "c"),
67
+ ]);
68
+ expect(second.map((e) => e.seq)).toEqual([2, 3]);
69
+ });
70
+
71
+ it("keeps seq spaces independent per conversation", async () => {
72
+ await store.appendEntries("c1", "agent", null, [userEntry("u1", "a")]);
73
+ const other = await store.appendEntries("c2", "agent", null, [userEntry("u2", "b")]);
74
+ expect(other[0].seq).toBe(1);
75
+ });
76
+
77
+ it("reads entries ordered by seq ascending", async () => {
78
+ await store.appendEntries("c1", "agent", null, [
79
+ userEntry("u1", "one"),
80
+ harnessEntry("h1", "two"),
81
+ harnessEntry("h2", "three"),
82
+ ]);
83
+ const all = await store.readEntries("c1");
84
+ expect(all.map((e) => e.seq)).toEqual([1, 2, 3]);
85
+ expect(all.map((e) => e.id)).toEqual(["u1", "h1", "h2"]);
86
+ });
87
+
88
+ it("filters by type", async () => {
89
+ await store.appendEntries("c1", "agent", null, [
90
+ userEntry("u1", "one"),
91
+ harnessEntry("h1", "two"),
92
+ harnessEntry("h2", "three"),
93
+ ]);
94
+ const harnessOnly = await store.readEntries("c1", { types: ["harness_message"] });
95
+ expect(harnessOnly.map((e) => e.id)).toEqual(["h1", "h2"]);
96
+ });
97
+
98
+ it("filters by afterSeq", async () => {
99
+ await store.appendEntries("c1", "agent", null, [
100
+ userEntry("u1", "one"),
101
+ harnessEntry("h1", "two"),
102
+ harnessEntry("h2", "three"),
103
+ ]);
104
+ const after1 = await store.readEntries("c1", { afterSeq: 1 });
105
+ expect(after1.map((e) => e.seq)).toEqual([2, 3]);
106
+ });
107
+
108
+ it("respects limit", async () => {
109
+ await store.appendEntries("c1", "agent", null, [
110
+ userEntry("u1", "one"),
111
+ harnessEntry("h1", "two"),
112
+ harnessEntry("h2", "three"),
113
+ ]);
114
+ const limited = await store.readEntries("c1", { limit: 2 });
115
+ expect(limited.map((e) => e.seq)).toEqual([1, 2]);
116
+ });
117
+
118
+ it("round-trips distinct entry types with their payloads intact", async () => {
119
+ await store.appendEntries("c1", "agent", null, [
120
+ userEntry("u1", "question"),
121
+ compactionEntry("cmp1"),
122
+ ]);
123
+ const all = await store.readEntries("c1");
124
+
125
+ const user = all.find((e) => e.id === "u1");
126
+ expect(user?.type).toBe("user_message");
127
+ expect(user?.type === "user_message" && user.message.content).toBe("question");
128
+ expect(user?.type === "user_message" && user.turnId).toBe("t1");
129
+
130
+ const cmp = all.find((e) => e.id === "cmp1");
131
+ expect(cmp?.type).toBe("compaction");
132
+ expect(cmp?.type === "compaction" && cmp.firstKeptSeq).toBe(2);
133
+ expect(cmp?.type === "compaction" && cmp.summaryMessage.content).toBe("summary so far");
134
+ });
135
+
136
+ it("returns an empty array for an unknown conversation", async () => {
137
+ expect(await store.readEntries("nope")).toEqual([]);
138
+ });
139
+
140
+ it("treats an empty append as a no-op", async () => {
141
+ const stored = await store.appendEntries("c1", "agent", null, []);
142
+ expect(stored).toEqual([]);
143
+ expect(await store.readEntries("c1")).toEqual([]);
144
+ });
145
+ });
146
+ }
147
+
148
+ runSuite("InMemoryConversationStore entries", async () => {
149
+ const store = new InMemoryConversationStore();
150
+ return { store, cleanup: async () => {} };
151
+ });
152
+
153
+ runSuite("SqliteEngine entries (via adapter)", async () => {
154
+ const dir = await mkdtemp(join(tmpdir(), "entries-store-"));
155
+ const engine = new SqliteEngine({ workingDir: dir, agentId: "agent", dbPath: join(dir, "test.db") });
156
+ await engine.initialize();
157
+ const store = createConversationStoreFromEngine(engine);
158
+ return {
159
+ store,
160
+ cleanup: async () => {
161
+ await engine.close();
162
+ await rm(dir, { recursive: true, force: true });
163
+ },
164
+ };
165
+ });