@poncho-ai/harness 0.55.0 → 0.58.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,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,96 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { InMemoryConversationStore } from "../src/state.js";
3
+ import {
4
+ buildLlmContext,
5
+ buildDisplaySnapshot,
6
+ type NewConversationEntry,
7
+ } from "../src/storage/entries.js";
8
+ import type { Message } from "@poncho-ai/sdk";
9
+
10
+ const msg = (role: Message["role"], content: string): Message => ({ role, content });
11
+
12
+ // A turn's worth of entries: a user display message, the harness (LLM
13
+ // transcript) messages for that turn, and the final assistant bubble.
14
+ function turnEntries(): NewConversationEntry[] {
15
+ return [
16
+ { type: "user_message", id: "u1", message: msg("user", "hello"), turnId: "t1" },
17
+ { type: "harness_message", id: "h1", message: msg("user", "hello"), turnId: "t1" },
18
+ { type: "harness_message", id: "h2", message: msg("assistant", "hi there"), turnId: "t1" },
19
+ {
20
+ type: "assistant_message",
21
+ id: "a1",
22
+ message: msg("assistant", "hi there"),
23
+ turnId: "t1",
24
+ runId: "r1",
25
+ },
26
+ ];
27
+ }
28
+
29
+ describe("Phase 3c read cutover", () => {
30
+ const prevFlag = process.env.PONCHO_READ_ENTRIES;
31
+
32
+ afterEach(() => {
33
+ if (prevFlag === undefined) delete process.env.PONCHO_READ_ENTRIES;
34
+ else process.env.PONCHO_READ_ENTRIES = prevFlag;
35
+ });
36
+
37
+ it("get() rebuilds _harnessMessages/messages from entries when present", async () => {
38
+ delete process.env.PONCHO_READ_ENTRIES; // ON by default
39
+ const store = new InMemoryConversationStore();
40
+ const conv = await store.create("owner", "title", null);
41
+
42
+ // Seed the blob with stale messages so we can prove the override happened.
43
+ conv.messages = [msg("assistant", "STALE BLOB")];
44
+ conv._harnessMessages = [msg("assistant", "STALE BLOB HARNESS")];
45
+ await store.update(conv);
46
+
47
+ const entries = await store.appendEntries(conv.conversationId, "agent", null, turnEntries());
48
+
49
+ const loaded = await store.get(conv.conversationId);
50
+ expect(loaded).toBeDefined();
51
+ expect(loaded!._harnessMessages).toEqual(buildLlmContext(entries));
52
+ expect(loaded!.messages).toEqual(buildDisplaySnapshot(entries, 100000).messages);
53
+ // Display transcript drops the harness-only messages; keeps user + assistant bubble.
54
+ expect(loaded!.messages.map((m) => m.content)).toEqual(["hello", "hi there"]);
55
+ });
56
+
57
+ it("get() falls back to the blob when there are no entries", async () => {
58
+ delete process.env.PONCHO_READ_ENTRIES;
59
+ const store = new InMemoryConversationStore();
60
+ const conv = await store.create("owner", "title", null);
61
+ conv.messages = [msg("user", "blob only")];
62
+ conv._harnessMessages = [msg("user", "blob only harness")];
63
+ await store.update(conv);
64
+
65
+ const loaded = await store.get(conv.conversationId);
66
+ expect(loaded!.messages).toEqual([msg("user", "blob only")]);
67
+ expect(loaded!._harnessMessages).toEqual([msg("user", "blob only harness")]);
68
+ });
69
+
70
+ it("kill-switch PONCHO_READ_ENTRIES=0 reverts to blob reads even with entries", async () => {
71
+ process.env.PONCHO_READ_ENTRIES = "0";
72
+ const store = new InMemoryConversationStore();
73
+ const conv = await store.create("owner", "title", null);
74
+ conv.messages = [msg("user", "blob wins")];
75
+ await store.update(conv);
76
+ await store.appendEntries(conv.conversationId, "agent", null, turnEntries());
77
+
78
+ const loaded = await store.get(conv.conversationId);
79
+ expect(loaded!.messages).toEqual([msg("user", "blob wins")]);
80
+ });
81
+
82
+ it("get() does not mutate the stored blob conversation (clone)", async () => {
83
+ delete process.env.PONCHO_READ_ENTRIES;
84
+ const store = new InMemoryConversationStore();
85
+ const conv = await store.create("owner", "title", null);
86
+ conv.messages = [msg("assistant", "STALE BLOB")];
87
+ await store.update(conv);
88
+ await store.appendEntries(conv.conversationId, "agent", null, turnEntries());
89
+
90
+ await store.get(conv.conversationId);
91
+ // Re-read with the kill-switch on: should still see the untouched blob.
92
+ process.env.PONCHO_READ_ENTRIES = "0";
93
+ const blob = await store.get(conv.conversationId);
94
+ expect(blob!.messages).toEqual([msg("assistant", "STALE BLOB")]);
95
+ });
96
+ });
@@ -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
+ });