@poncho-ai/harness 0.59.2 → 0.59.4
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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +30 -0
- package/dist/index.d.ts +45 -118
- package/dist/index.js +34 -327
- package/package.json +2 -2
- package/src/harness.ts +7 -2
- package/src/index.ts +3 -11
- package/src/orchestrator/entries-dual-write.ts +20 -209
- package/src/orchestrator/index.ts +1 -6
- package/src/orchestrator/orchestrator.ts +22 -115
- package/src/orchestrator/run-conversation-turn.ts +7 -108
- package/src/state.ts +3 -1
- package/src/storage/entries.ts +47 -182
- package/src/storage/memory-engine.ts +2 -2
- package/src/storage/sql-dialect.ts +18 -8
- package/test/entries-dual-write.test.ts +21 -144
- package/test/entries-store.test.ts +43 -53
- package/test/entries.test.ts +37 -105
package/src/storage/entries.ts
CHANGED
|
@@ -1,27 +1,42 @@
|
|
|
1
|
-
import { createLogger
|
|
1
|
+
import { createLogger } from "@poncho-ai/sdk";
|
|
2
2
|
import type { Conversation, PendingSubagentResult } from "../state.js";
|
|
3
3
|
|
|
4
4
|
const entriesReadLog = createLogger("entries-read");
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* The subagent delivery queue: append-only `conversation_entries` rows that
|
|
8
|
+
* carry a finished subagent's result to its parent conversation.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Why this exists: subagent results are the ONE conversation field with
|
|
11
|
+
* concurrent writers. A subagent finishes whenever it finishes — possibly
|
|
12
|
+
* while the parent turn is mid-stream doing whole-blob writes — so a
|
|
13
|
+
* read-modify-write on the mutable conversation row could serialize a stale
|
|
14
|
+
* snapshot over the result (the historical "lost subagent result" clobber).
|
|
15
|
+
* An append-only INSERT can't express that race. Everything single-writer
|
|
16
|
+
* (message history, metadata) stays on the conversation row, where the
|
|
17
|
+
* orchestrator's per-conversation turn serialization already makes mutation
|
|
18
|
+
* safe.
|
|
14
19
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* once this rebuild logic is proven.
|
|
20
|
+
* Two entry types:
|
|
21
|
+
* - `subagent_result`: a finished subagent's result, appended by the
|
|
22
|
+
* orchestrator's result-delivery path.
|
|
23
|
+
* - `callback_started`: marks which result entries a callback turn
|
|
24
|
+
* consumed (by seq). Consumption is an append, never a delete — a
|
|
25
|
+
* result is "pending" until a later callback_started lists its seq.
|
|
22
26
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
27
|
+
* Historical note: this module once defined a full transcript's worth of
|
|
28
|
+
* entry types (user/assistant/harness messages, compaction overlays) as
|
|
29
|
+
* groundwork for replacing the conversation blob entirely. The full read
|
|
30
|
+
* cutover shipped briefly (harness 0.58.0), proved unfaithful for callback
|
|
31
|
+
* turns, and was reverted; the unread types + dual-writes were then deleted
|
|
32
|
+
* rather than maintained as drift-prone dead weight. If a future feature
|
|
33
|
+
* needs real history semantics (editing, branching, audit), design that
|
|
34
|
+
* migration fresh — and remember the 0.58.0 lesson: an append-only log is
|
|
35
|
+
* only as good as the completeness of its writers.
|
|
36
|
+
*
|
|
37
|
+
* Ordering: every entry carries a monotonic per-conversation `seq`,
|
|
38
|
+
* assigned by the engine at append time. Entries are sorted by `seq`
|
|
39
|
+
* ascending when passed to the rebuild fn.
|
|
25
40
|
*/
|
|
26
41
|
|
|
27
42
|
interface BaseEntry {
|
|
@@ -32,53 +47,6 @@ interface BaseEntry {
|
|
|
32
47
|
createdAt: number;
|
|
33
48
|
}
|
|
34
49
|
|
|
35
|
-
/** A user-role display message (incl. typed subagent-callback messages). */
|
|
36
|
-
export interface UserMessageEntry extends BaseEntry {
|
|
37
|
-
type: "user_message";
|
|
38
|
-
message: Message;
|
|
39
|
-
turnId: string;
|
|
40
|
-
/** Hidden from the display transcript (e.g. a framed job prompt, an
|
|
41
|
-
* onboarding seed, or an injected subagent-result message). Still part
|
|
42
|
-
* of the record; just not rendered as a chat bubble. */
|
|
43
|
-
hidden?: boolean;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** The final assistant bubble for a completed/cancelled/errored turn. */
|
|
47
|
-
export interface AssistantMessageEntry extends BaseEntry {
|
|
48
|
-
type: "assistant_message";
|
|
49
|
-
message: Message;
|
|
50
|
-
turnId: string;
|
|
51
|
-
runId: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** A post-hoc edit to an already-emitted assistant message — replaces the
|
|
55
|
-
* orchestrator/resume "mutate the last assistant message in place" writes
|
|
56
|
-
* with an append. Applied at rebuild time. */
|
|
57
|
-
export interface AssistantAmendmentEntry extends BaseEntry {
|
|
58
|
-
type: "assistant_amendment";
|
|
59
|
-
targetEntryId: string;
|
|
60
|
-
appendText?: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** One LLM-transcript message (the model-visible form). Appended from the
|
|
64
|
-
* run loop per step — never diffed from an array. */
|
|
65
|
-
export interface HarnessMessageEntry extends BaseEntry {
|
|
66
|
-
type: "harness_message";
|
|
67
|
-
message: Message;
|
|
68
|
-
turnId: string;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Compaction overlay: nothing is deleted. At rebuild, the LLM context is
|
|
72
|
-
* the latest compaction's `summaryMessage` followed by the harness
|
|
73
|
-
* messages from `firstKeptSeq` onward. */
|
|
74
|
-
export interface CompactionEntry extends BaseEntry {
|
|
75
|
-
type: "compaction";
|
|
76
|
-
summaryMessage: Message;
|
|
77
|
-
firstKeptSeq: number;
|
|
78
|
-
tokensBefore?: number;
|
|
79
|
-
tokensAfter?: number;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
50
|
/** A finished subagent's result arriving for the parent. Pending = a
|
|
83
51
|
* subagent_result whose seq is not listed in any later callback_started. */
|
|
84
52
|
export interface SubagentResultEntry extends BaseEntry {
|
|
@@ -93,21 +61,14 @@ export interface CallbackStartedEntry extends BaseEntry {
|
|
|
93
61
|
consumedSeqs: number[];
|
|
94
62
|
}
|
|
95
63
|
|
|
96
|
-
export type ConversationEntry =
|
|
97
|
-
| UserMessageEntry
|
|
98
|
-
| AssistantMessageEntry
|
|
99
|
-
| AssistantAmendmentEntry
|
|
100
|
-
| HarnessMessageEntry
|
|
101
|
-
| CompactionEntry
|
|
102
|
-
| SubagentResultEntry
|
|
103
|
-
| CallbackStartedEntry;
|
|
64
|
+
export type ConversationEntry = SubagentResultEntry | CallbackStartedEntry;
|
|
104
65
|
|
|
105
66
|
/**
|
|
106
67
|
* An entry to append, before the engine assigns `seq` and `createdAt`. This
|
|
107
68
|
* is a DISTRIBUTIVE omit — `Omit<ConversationEntry, K>` over a union would
|
|
108
|
-
* collapse to only the keys common to every member
|
|
109
|
-
*
|
|
110
|
-
*
|
|
69
|
+
* collapse to only the keys common to every member, so we distribute over
|
|
70
|
+
* the union with a conditional type to omit those fields from each member
|
|
71
|
+
* individually.
|
|
111
72
|
*/
|
|
112
73
|
export type NewConversationEntry = ConversationEntry extends infer T
|
|
113
74
|
? T extends ConversationEntry
|
|
@@ -115,88 +76,6 @@ export type NewConversationEntry = ConversationEntry extends infer T
|
|
|
115
76
|
: never
|
|
116
77
|
: never;
|
|
117
78
|
|
|
118
|
-
/**
|
|
119
|
-
* Rebuild the LLM-visible message context from the entry log.
|
|
120
|
-
*
|
|
121
|
-
* If a compaction overlay exists, the context is its summary message
|
|
122
|
-
* followed by every harness message with seq >= firstKeptSeq (a later
|
|
123
|
-
* compaction's firstKeptSeq can point at an earlier summary that was
|
|
124
|
-
* itself appended as a harness message, so layered compactions just work).
|
|
125
|
-
* With no compaction, it's every harness message in order.
|
|
126
|
-
*/
|
|
127
|
-
export function buildLlmContext(entries: ConversationEntry[]): Message[] {
|
|
128
|
-
let latestCompaction: CompactionEntry | undefined;
|
|
129
|
-
for (const e of entries) {
|
|
130
|
-
if (e.type === "compaction" && (!latestCompaction || e.seq > latestCompaction.seq)) {
|
|
131
|
-
latestCompaction = e;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const harnessMsgs = entries.filter(
|
|
136
|
-
(e): e is HarnessMessageEntry => e.type === "harness_message",
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
if (latestCompaction) {
|
|
140
|
-
const kept = harnessMsgs
|
|
141
|
-
.filter((e) => e.seq >= latestCompaction!.firstKeptSeq)
|
|
142
|
-
.map((e) => e.message);
|
|
143
|
-
return [latestCompaction.summaryMessage, ...kept];
|
|
144
|
-
}
|
|
145
|
-
return harnessMsgs.map((e) => e.message);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export interface DisplaySnapshot {
|
|
149
|
-
messages: Message[];
|
|
150
|
-
/** Total display messages available (for pagination UIs). */
|
|
151
|
-
totalMessages: number;
|
|
152
|
-
/** seq of the first message returned (a `beforeSeq` pagination cursor). */
|
|
153
|
-
headSeq: number | null;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Rebuild the display transcript (the user-visible chat) from the entry
|
|
158
|
-
* log, returning the trailing `tailN` messages. Amendments are folded into
|
|
159
|
-
* their target assistant message; hidden user messages are dropped.
|
|
160
|
-
*/
|
|
161
|
-
export function buildDisplaySnapshot(
|
|
162
|
-
entries: ConversationEntry[],
|
|
163
|
-
tailN: number,
|
|
164
|
-
): DisplaySnapshot {
|
|
165
|
-
const amendmentsByTarget = new Map<string, AssistantAmendmentEntry[]>();
|
|
166
|
-
for (const e of entries) {
|
|
167
|
-
if (e.type === "assistant_amendment") {
|
|
168
|
-
const list = amendmentsByTarget.get(e.targetEntryId) ?? [];
|
|
169
|
-
list.push(e);
|
|
170
|
-
amendmentsByTarget.set(e.targetEntryId, list);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const built: { seq: number; message: Message }[] = [];
|
|
175
|
-
for (const e of entries) {
|
|
176
|
-
if (e.type === "user_message") {
|
|
177
|
-
if (e.hidden) continue;
|
|
178
|
-
built.push({ seq: e.seq, message: e.message });
|
|
179
|
-
} else if (e.type === "assistant_message") {
|
|
180
|
-
let content = typeof e.message.content === "string" ? e.message.content : "";
|
|
181
|
-
const amendments = amendmentsByTarget.get(e.id);
|
|
182
|
-
if (amendments) {
|
|
183
|
-
for (const a of amendments.sort((x, y) => x.seq - y.seq)) {
|
|
184
|
-
if (a.appendText) content += a.appendText;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
built.push({ seq: e.seq, message: { ...e.message, content } });
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const total = built.length;
|
|
192
|
-
const tail = tailN >= total ? built : built.slice(total - tailN);
|
|
193
|
-
return {
|
|
194
|
-
messages: tail.map((b) => b.message),
|
|
195
|
-
totalMessages: total,
|
|
196
|
-
headSeq: tail.length > 0 ? tail[0]!.seq : null,
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
79
|
/**
|
|
201
80
|
* Subagent results that have arrived but not yet been consumed by a
|
|
202
81
|
* callback turn — the append-only replacement for the mutable
|
|
@@ -219,25 +98,20 @@ export function getPendingSubagentResults(
|
|
|
219
98
|
}
|
|
220
99
|
|
|
221
100
|
/**
|
|
222
|
-
*
|
|
223
|
-
* the append-only entry log, with a blob fallback for conversations that
|
|
224
|
-
* predate dual-write.
|
|
225
|
-
*
|
|
226
|
-
* Call this in every conversation `get`/`getWithArchive` path AFTER the
|
|
227
|
-
* Conversation has been constructed from the stored row/blob. It:
|
|
228
|
-
* - reads the entry log via `readEntries`,
|
|
229
|
-
* - if NON-EMPTY, overrides `_harnessMessages`, `messages`, and
|
|
230
|
-
* `pendingSubagentResults` with entry-derived values,
|
|
231
|
-
* - if EMPTY (un-migrated conversation), leaves the blob-derived fields
|
|
232
|
-
* untouched (fallback),
|
|
233
|
-
* - on ANY error, logs and falls back to the blob (never throws — this is
|
|
234
|
-
* a hot read path).
|
|
101
|
+
* Read-path override: rebuild `pendingSubagentResults` from the queue.
|
|
235
102
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
103
|
+
* Called in every conversation `get`/`getWithArchive` path AFTER the
|
|
104
|
+
* Conversation has been constructed from the stored row/blob. Only
|
|
105
|
+
* `pendingSubagentResults` is overridden — it's the only field with a write
|
|
106
|
+
* race; message history is written solely by the serialized turn finalize
|
|
107
|
+
* and stays on the blob. If the queue is EMPTY (conversation predates it,
|
|
108
|
+
* or simply has no subagent traffic recorded) the blob-derived value is
|
|
109
|
+
* left untouched; on ANY error this logs and falls back to the blob (hot
|
|
110
|
+
* read path — never throws).
|
|
238
111
|
*
|
|
239
112
|
* Kill-switch: set `PONCHO_READ_ENTRIES=0` to instantly revert to pure blob
|
|
240
|
-
* reads without a deploy (
|
|
113
|
+
* reads without a deploy (queue reads are ON by default). The blob field is
|
|
114
|
+
* still dual-written for exactly this reason.
|
|
241
115
|
*
|
|
242
116
|
* NOTE: mutates `conversation` in place and returns it. Callers that hand
|
|
243
117
|
* back a shared/mutable Conversation reference (the in-memory stores) MUST
|
|
@@ -247,20 +121,11 @@ export async function rebuildConversationFromEntries(
|
|
|
247
121
|
conversation: Conversation,
|
|
248
122
|
readEntries: (conversationId: string) => Promise<ConversationEntry[]>,
|
|
249
123
|
): Promise<Conversation> {
|
|
250
|
-
// Targeted append-only: only `pendingSubagentResults` is read from the
|
|
251
|
-
// entry log, because it's the ONLY conversation field with a write race
|
|
252
|
-
// (a subagent finishing mid-turn vs. the parent turn's whole-blob write).
|
|
253
|
-
// The message history (`messages` / `_harnessMessages`) is written solely
|
|
254
|
-
// by the turn finalize, which the orchestrator serializes per
|
|
255
|
-
// conversation — never raced — so it stays on the blob (known-good, and
|
|
256
|
-
// far simpler than faithfully rebuilding the LLM transcript from entries).
|
|
257
|
-
//
|
|
258
|
-
// Kill-switch: ON by default; PONCHO_READ_ENTRIES="0" reverts to the blob.
|
|
259
124
|
if (process.env.PONCHO_READ_ENTRIES === "0") return conversation;
|
|
260
125
|
|
|
261
126
|
try {
|
|
262
127
|
const entries = await readEntries(conversation.conversationId);
|
|
263
|
-
if (entries.length === 0) return conversation; // fallback: pre-
|
|
128
|
+
if (entries.length === 0) return conversation; // fallback: pre-queue conversations
|
|
264
129
|
conversation.pendingSubagentResults = getPendingSubagentResults(entries);
|
|
265
130
|
return conversation;
|
|
266
131
|
} catch (err) {
|
|
@@ -113,7 +113,7 @@ export class InMemoryEngine implements StorageEngine {
|
|
|
113
113
|
// log (blob fallback for un-migrated conversations). Clone first — the
|
|
114
114
|
// map holds a live mutable reference and the rebuild overrides fields.
|
|
115
115
|
return rebuildConversationFromEntries({ ...c }, (id) =>
|
|
116
|
-
this.conversations.readEntries(id),
|
|
116
|
+
this.conversations.readEntries(id, { types: ["subagent_result", "callback_started"] }),
|
|
117
117
|
);
|
|
118
118
|
},
|
|
119
119
|
|
|
@@ -123,7 +123,7 @@ export class InMemoryEngine implements StorageEngine {
|
|
|
123
123
|
const c = this.convs.get(conversationId);
|
|
124
124
|
if (!c) return undefined;
|
|
125
125
|
return rebuildConversationFromEntries({ ...c }, (id) =>
|
|
126
|
-
this.conversations.readEntries(id),
|
|
126
|
+
this.conversations.readEntries(id, { types: ["subagent_result", "callback_started"] }),
|
|
127
127
|
);
|
|
128
128
|
},
|
|
129
129
|
|
|
@@ -333,7 +333,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
333
333
|
// append-only entry log, falling back to the blob for un-migrated
|
|
334
334
|
// conversations. parseConversation returns a fresh object, so no clone.
|
|
335
335
|
return rebuildConversationFromEntries(conv, (id) =>
|
|
336
|
-
this.conversations.readEntries(id),
|
|
336
|
+
this.conversations.readEntries(id, { types: ["subagent_result", "callback_started"] }),
|
|
337
337
|
);
|
|
338
338
|
},
|
|
339
339
|
|
|
@@ -420,7 +420,7 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
420
420
|
// Phase 3c read cutover: rebuild reader-facing fields from the entry
|
|
421
421
|
// log (blob fallback for un-migrated conversations).
|
|
422
422
|
return rebuildConversationFromEntries(conv, (id) =>
|
|
423
|
-
this.conversations.readEntries(id),
|
|
423
|
+
this.conversations.readEntries(id, { types: ["subagent_result", "callback_started"] }),
|
|
424
424
|
);
|
|
425
425
|
},
|
|
426
426
|
|
|
@@ -557,19 +557,29 @@ export abstract class SqlStorageEngine implements StorageEngine {
|
|
|
557
557
|
conversationId: string,
|
|
558
558
|
title: string,
|
|
559
559
|
): Promise<Conversation | undefined> => {
|
|
560
|
-
// Targeted
|
|
561
|
-
//
|
|
560
|
+
// Targeted update — deliberately NOT get→mutate→update(). The
|
|
561
|
+
// whole-row read-modify-write races a streaming turn's per-step
|
|
562
562
|
// draft persist: rename reads the row at T0, the turn persists step
|
|
563
563
|
// N's draft at T1, rename writes T0's stale blob back at T2 and
|
|
564
|
-
// silently reverts the turn's progress.
|
|
565
|
-
//
|
|
564
|
+
// silently reverts the turn's progress.
|
|
565
|
+
//
|
|
566
|
+
// Title lives in BOTH the `title` column and the `data` blob (reads
|
|
567
|
+
// parse the blob), so update both — the blob via the database's own
|
|
568
|
+
// JSON-set function INSIDE the same UPDATE. That keeps the write
|
|
569
|
+
// atomic and server-side: no stale snapshot is ever serialized back.
|
|
566
570
|
const normalized = normalizeTitle(title);
|
|
571
|
+
// Distinct placeholders for the two title occurrences: rewrite()
|
|
572
|
+
// converts $N → ? positionally for sqlite, so reusing $1 would
|
|
573
|
+
// desync the param array.
|
|
574
|
+
const dataExpr = this.dialect.tag === "sqlite"
|
|
575
|
+
? `json_set(data, '$.title', $2)`
|
|
576
|
+
: `jsonb_set(data, '{title}', to_jsonb($2::text))`;
|
|
567
577
|
await this.executor.run(
|
|
568
578
|
rewrite(
|
|
569
|
-
`UPDATE conversations SET title = $1, updated_at = $
|
|
579
|
+
`UPDATE conversations SET title = $1, data = ${dataExpr}, updated_at = $3 WHERE id = $4`,
|
|
570
580
|
this.dialect,
|
|
571
581
|
),
|
|
572
|
-
[normalized, new Date().toISOString(), conversationId],
|
|
582
|
+
[normalized, normalized, new Date().toISOString(), conversationId],
|
|
573
583
|
);
|
|
574
584
|
return this.conversations.get(conversationId);
|
|
575
585
|
},
|
|
@@ -1,172 +1,49 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import type { Message } from "@poncho-ai/sdk";
|
|
3
|
-
import { createLogger } from "@poncho-ai/sdk";
|
|
4
2
|
import { InMemoryConversationStore } from "../src/state.js";
|
|
5
|
-
import {
|
|
6
|
-
buildDisplaySnapshot,
|
|
7
|
-
buildLlmContext,
|
|
8
|
-
getPendingSubagentResults,
|
|
9
|
-
} from "../src/storage/entries.js";
|
|
3
|
+
import { createLogger } from "@poncho-ai/sdk";
|
|
10
4
|
import {
|
|
11
5
|
appendEntriesSafe,
|
|
12
|
-
assistantMessageEntry,
|
|
13
6
|
callbackStartedEntry,
|
|
14
|
-
compactionEntry,
|
|
15
|
-
harnessMessageEntries,
|
|
16
|
-
newHarnessMessagesThisTurn,
|
|
17
7
|
subagentResultEntry,
|
|
18
|
-
userMessageEntry,
|
|
19
8
|
} from "../src/orchestrator/entries-dual-write.js";
|
|
20
9
|
|
|
21
10
|
const log = createLogger("test");
|
|
22
|
-
const
|
|
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
|
-
});
|
|
11
|
+
const convRef = { conversationId: "c1", ownerId: "owner", tenantId: null };
|
|
33
12
|
|
|
34
|
-
describe("
|
|
35
|
-
it("
|
|
13
|
+
describe("appendEntriesSafe (queue writer)", () => {
|
|
14
|
+
it("stamps a uuid id and returns stored entries with seq/createdAt", async () => {
|
|
36
15
|
const store = new InMemoryConversationStore();
|
|
37
|
-
const
|
|
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(
|
|
16
|
+
const stored = await appendEntriesSafe(
|
|
49
17
|
store,
|
|
50
|
-
|
|
18
|
+
convRef,
|
|
51
19
|
[
|
|
52
|
-
|
|
53
|
-
|
|
20
|
+
subagentResultEntry({ subagentId: "s1", task: "t", status: "completed", timestamp: 1 }),
|
|
21
|
+
callbackStartedEntry([1]),
|
|
54
22
|
],
|
|
55
23
|
log,
|
|
56
24
|
);
|
|
57
|
-
|
|
58
|
-
|
|
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);
|
|
25
|
+
expect(stored).toHaveLength(2);
|
|
26
|
+
expect(stored.every((e) => typeof e.id === "string" && e.id.length > 0)).toBe(true);
|
|
27
|
+
expect(stored.map((e) => e.seq)).toEqual([1, 2]);
|
|
71
28
|
});
|
|
72
29
|
|
|
73
|
-
it("
|
|
30
|
+
it("returns [] on empty input without touching the store", async () => {
|
|
74
31
|
const store = new InMemoryConversationStore();
|
|
75
|
-
|
|
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"]);
|
|
32
|
+
expect(await appendEntriesSafe(store, convRef, [], log)).toEqual([]);
|
|
33
|
+
expect(await store.readEntries("c1")).toEqual([]);
|
|
98
34
|
});
|
|
99
35
|
|
|
100
|
-
it("
|
|
36
|
+
it("never throws — swallows store failures and returns []", async () => {
|
|
101
37
|
const store = new InMemoryConversationStore();
|
|
102
|
-
|
|
103
|
-
|
|
38
|
+
store.appendEntries = async () => {
|
|
39
|
+
throw new Error("boom");
|
|
40
|
+
};
|
|
104
41
|
const stored = await appendEntriesSafe(
|
|
105
42
|
store,
|
|
106
|
-
|
|
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")],
|
|
43
|
+
convRef,
|
|
44
|
+
[callbackStartedEntry([1])],
|
|
168
45
|
log,
|
|
169
46
|
);
|
|
170
|
-
expect(
|
|
47
|
+
expect(stored).toEqual([]);
|
|
171
48
|
});
|
|
172
49
|
});
|