@guuey/threads 0.31.1 → 0.33.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.
package/dist/store.js CHANGED
@@ -8,11 +8,26 @@
8
8
  * DynamoDB).
9
9
  */
10
10
  import { randomUUID } from "node:crypto";
11
- import { agMessageToRow, agArtifactToCardRow, keepUnchangedThreadMemory, producingToolName, toolNamesByCallId, uiCardArtifactsFromMessages, storedTextParts, } from "./fold-rows.js";
11
+ import { agMessageToBoundedRow, agArtifactToCardRow, assertRowFits, cardUiDataByCallId, isStoredRowRefusal, StoredRowTooLargeError, keepUnchangedThreadMemory, producingMessageIndex, producingToolName, toolNamesByCallId, toolResultMessageIndexByCallId, uiCardArtifactsFromMessages, storedTextParts, } from "./fold-rows.js";
12
12
  import { classifyStoredRow } from "./stored-row.js";
13
+ import { threadClosure } from "./closed.js";
14
+ import { planSnapshot } from "./snapshot-plan.js";
15
+ import { writeTapLabels } from "@guuey/mcp-apps-host/narrowing";
13
16
  /** Max history messages fed back as context — bounds latency on long threads. */
14
17
  const DEFAULT_HISTORY_LIMIT = 40;
15
18
  const PREVIEW_MAX_LEN = 240;
19
+ /** The failure code of a turn stored as one row because it could not fit (guuey#1794). */
20
+ export const STORED_TURN_TOO_LARGE_CODE = 'stored_turn_too_large';
21
+ /**
22
+ * The failure code of a turn stored as one row because a row of it carried a
23
+ * number its store cannot hold, outside any tool-call value (guuey#1794).
24
+ */
25
+ export const STORED_TURN_UNSTORABLE_NUMBER_CODE = 'stored_turn_unstorable_number';
26
+ /**
27
+ * The seq a planned row is measured with before its real seq is allocated:
28
+ * no real seq has more digits, so the real row is never larger.
29
+ */
30
+ const PROVISIONAL_SEQ = Number.MAX_SAFE_INTEGER;
16
31
  // ───────────────────────────────────────────────────────────────────────
17
32
  // ThreadStore — the logic.
18
33
  // ───────────────────────────────────────────────────────────────────────
@@ -34,14 +49,17 @@ export class ThreadStore {
34
49
  * continues on a new conversation) AND safe — the caller never sees another
35
50
  * user's thread, and there's no existence oracle. The pod never honours a
36
51
  * client-chosen id, so squatting is impossible.
52
+ *
53
+ * A CLOSED thread ({@link ThreadRow.closedAt}) is answered the same way: it
54
+ * takes no new message, so a send on it starts a new conversation.
37
55
  */
38
56
  async ensureThread(input) {
39
57
  if (input.threadId) {
40
58
  const existing = await this.db.getThread(input.threadId);
41
- if (existing && existing.userId === input.userId) {
59
+ if (existing && existing.userId === input.userId && threadClosure(existing) === undefined) {
42
60
  return existing.id;
43
61
  }
44
- // Not found OR owned by a different identity → fall through, mint fresh.
62
+ // Not found, owned by a different identity, OR closed → fall through, mint fresh.
45
63
  }
46
64
  const now = new Date().toISOString();
47
65
  const row = {
@@ -68,11 +86,33 @@ export class ThreadStore {
68
86
  * exposed for doors that must BIND a client-echoed thread to the verified
69
87
  * caller without minting anything (the pod's consent-answer door binds a
70
88
  * `once` grant to a thread this way — guuey#207). No existence oracle: a
71
- * missing thread and another user's thread are the same `false`.
89
+ * missing thread and another user's thread are the same `false`, and so is
90
+ * a closed one: nothing binds to a conversation that has ended.
72
91
  */
73
92
  async ownsThread(threadId, userId) {
74
93
  const existing = await this.db.getThread(threadId);
75
- return existing !== undefined && existing.userId === userId;
94
+ return existing !== undefined && existing.userId === userId && threadClosure(existing) === undefined;
95
+ }
96
+ /**
97
+ * Whether this binding can close a thread ({@link ThreadPersistencePort.closeThread}
98
+ * present). A caller that writes something before closing (a closing
99
+ * message) reads this first, so a binding that cannot close is left as it was.
100
+ */
101
+ canCloseThreads() {
102
+ return this.db.closeThread !== undefined;
103
+ }
104
+ /**
105
+ * End the conversation (see {@link ThreadRow.closedAt}): `closed` when this
106
+ * call closed it, `already_closed` when it was closed before (the first
107
+ * closure is kept), `unsupported` when the binding cannot close
108
+ * ({@link ThreadPersistencePort.closeThread} absent; nothing changes).
109
+ * Rejects for a missing thread or a store failure.
110
+ */
111
+ async closeThread(threadId, closure) {
112
+ const close = this.db.closeThread;
113
+ if (close === undefined)
114
+ return "unsupported";
115
+ return close.call(this.db, threadId, closure);
76
116
  }
77
117
  /** Prior messages for the thread (seq-ASC, capped to the most recent N). */
78
118
  async loadHistory(threadId, limit = DEFAULT_HISTORY_LIMIT) {
@@ -91,7 +131,9 @@ export class ThreadStore {
91
131
  // since narration split out of it, so reading `text` here would change what
92
132
  // the model is replayed. For rows written before the split, `all` is the same
93
133
  // bytes their `text` holds. A row whose content is not a stored AgMessage
94
- // (an event, a managed-adapter row) keeps its `text`.
134
+ // (an event, a managed-adapter row) keeps its `text`. A stored message whose
135
+ // answer block is elided (its `text` the elided marker) replays
136
+ // the row's `text` as that answer, in place.
95
137
  //
96
138
  // Only CONVERSATION reaches this lane: a failed turn's reader line, a
97
139
  // framework notice and a blank agent message are stored rows, never words
@@ -107,9 +149,10 @@ export class ThreadStore {
107
149
  seq: r.seq,
108
150
  authorRole: r.authorRole,
109
151
  kind: r.kind,
110
- text: storedTextParts(r.content)?.all || (r.text ?? null),
152
+ text: storedTextParts(r.content, r.text)?.all || (r.text ?? null),
111
153
  content: r.content ?? null,
112
154
  at: r.at,
155
+ ...(r.inputKind !== undefined ? { inputKind: r.inputKind } : {}),
113
156
  }));
114
157
  return conversation;
115
158
  }
@@ -124,11 +167,22 @@ export class ThreadStore {
124
167
  return { seq: prior.seq, deduped: true };
125
168
  }
126
169
  const now = new Date().toISOString();
127
- const preview = input.text ? input.text.slice(0, PREVIEW_MAX_LEN) : '';
128
- const seq = await this.db.incrementSeq(input.threadId, preview, now);
129
- const row = {
170
+ // An event row (a hand-off, a report) is a record ABOUT the conversation,
171
+ // written from all of it (a model-written ask or summary), not a message in
172
+ // it: like a card row it leaves the prior preview standing (null), so the
173
+ // conversation list never shows a copy drawn from the whole thread.
174
+ // guuey#2549: a turn without words has no words to preview either, so it
175
+ // also leaves the prior preview standing rather than blanking it.
176
+ const preview = input.event !== undefined || input.inputKind !== undefined
177
+ ? null
178
+ : input.text
179
+ ? input.text.slice(0, PREVIEW_MAX_LEN)
180
+ : '';
181
+ // guuey#2031: the store re-judges a row's tap labels; it never trusts its caller's check.
182
+ const tapLabels = input.tapLabels !== undefined ? writeTapLabels(input.tapLabels) : undefined;
183
+ const draft = {
130
184
  threadId: input.threadId,
131
- seq,
185
+ seq: PROVISIONAL_SEQ,
132
186
  userId: input.userId,
133
187
  clientMessageId: input.clientMessageId,
134
188
  at: now,
@@ -139,10 +193,16 @@ export class ThreadStore {
139
193
  ...(input.untrustedOrigin === true ? { untrustedOrigin: true } : {}),
140
194
  ...(input.event !== undefined ? { event: input.event } : {}),
141
195
  ...(input.turnOrigin !== undefined ? { turnOrigin: input.turnOrigin } : {}),
196
+ ...(input.inputKind !== undefined ? { inputKind: input.inputKind } : {}),
142
197
  ...(input.answeredCardSessionId !== undefined ? { answeredCardSessionId: input.answeredCardSessionId } : {}),
143
198
  ...(input.clientClass !== undefined ? { clientClass: input.clientClass } : {}),
199
+ ...(tapLabels !== undefined ? { tapLabels } : {}),
144
200
  };
145
- await this.db.putMessage(row);
201
+ // guuey#1794: measured before the seq is allocated, so an oversized row
202
+ // spends no seq. The real seq is never longer than the provisional one.
203
+ assertRowFits(draft, this.db.rowBudget, 'append', 0);
204
+ const seq = await this.db.incrementSeq(input.threadId, preview, now);
205
+ await this.db.putMessage({ ...draft, seq });
146
206
  return { seq, deduped: false };
147
207
  }
148
208
  /** The thread's latest fold snapshot, or undefined when none persisted yet. */
@@ -166,45 +226,163 @@ export class ThreadStore {
166
226
  * Durable (non-thread) memory is logged-and-dropped in v1. `skipSnapshot`
167
227
  * suppresses the snapshot upsert so a degraded/parked fold never overwrites
168
228
  * a good snapshot.
229
+ *
230
+ * The snapshot is planned with the rows, before any row is written
231
+ * (guuey#2120): a working state its store cannot hold is refused (the
232
+ * snapshot keeps `priorWorkingState`), and a memory element that cannot be
233
+ * stored is left out, reported as `refusedSnapshot`. The snapshot write is
234
+ * never the write that fails after the turn's rows landed.
169
235
  */
170
236
  async appendFold(input) {
171
- const { threadId, userId, fold, clientMessageIdBase, skipSnapshot } = input;
237
+ const { threadId, clientMessageIdBase } = input;
172
238
  // Turn-level idempotency gate: a committed agent turn is never re-merged.
173
239
  const sentinelKey = `${clientMessageIdBase}#agentTurn`;
174
240
  const existing = await this.db.findByClientMessageId(threadId, sentinelKey);
175
241
  if (existing) {
176
- return { messageSeqs: [existing.seq], artifactSeqs: [], droppedDurableMemory: 0, deduped: true };
242
+ return { messageSeqs: [existing.seq], artifactSeqs: [], droppedDurableMemory: 0, deduped: true, elidedToolInputs: [] };
243
+ }
244
+ // guuey#1794 — plan every row of the turn, then write. Nothing is written
245
+ // (no seq spent, no sentinel claimed) until every row is known to fit the
246
+ // store's row budget: a row that failed mid-loop used to leave a partial
247
+ // turn whose sentinel short-circuited every retry. Planned rows carry a
248
+ // provisional seq and timestamp no smaller than the real ones, so the real
249
+ // values can only shrink a row that fit.
250
+ const provisionalAt = new Date().toISOString();
251
+ // guuey#2120 — the snapshot is planned with the rows: what its store
252
+ // cannot hold is left out here, before any row is written, so the
253
+ // snapshot write that follows the rows is never the one that fails.
254
+ const snapshot = this.planFoldSnapshot(input, provisionalAt);
255
+ const planned = [];
256
+ try {
257
+ this.planFoldRows(input, sentinelKey, provisionalAt, planned);
258
+ }
259
+ catch (err) {
260
+ if (!isStoredRowRefusal(err) || input.unstorableNotice === undefined)
261
+ throw err;
262
+ return this.writeUnstorableMarker(input, input.unstorableNotice, sentinelKey, err, snapshot);
177
263
  }
178
264
  const messageSeqs = [];
179
265
  const artifactSeqs = [];
180
- // Whichever row is written first claims the sentinel key.
181
- let sentinelClaimed = false;
182
- for (let i = 0; i < fold.messages.length; i++) {
183
- const msg = fold.messages[i];
184
- const clientMessageId = sentinelClaimed ? `${clientMessageIdBase}#agent#${i}` : sentinelKey;
185
- sentinelClaimed = true;
186
- // The preview is the ANSWER: interim narration (`phase: "interim"`) never becomes a thread's preview,
187
- // and neither does a framework notice (a non-conversational row).
188
- const text = msg.role === 'notice'
189
- ? ''
190
- : msg.content.reduce((acc, b) => (b.type === 'text' && b.phase !== 'interim' ? acc + b.text : acc), '');
266
+ const elidedToolInputs = [];
267
+ for (const p of planned) {
191
268
  const now = new Date().toISOString();
192
- // Text-less agent messages (tool-call + tool-result only — the canonical
193
- // card-producing turn) must not blank the preview: '' takes the SET
194
- // branch, null leaves the prior preview standing.
195
- const seq = await this.db.incrementSeq(threadId, text ? text.slice(0, PREVIEW_MAX_LEN) : null, now);
196
- const turnRecord = fold.turns.find((t) => t.turnId === msg.turnId);
197
- await this.db.putMessage(agMessageToRow(msg, {
198
- threadId,
199
- userId,
200
- seq,
201
- at: now,
202
- clientMessageId,
203
- ...(turnRecord ? { turnRecord } : {}),
204
- ...(input.untrustedOrigin === true ? { untrustedOrigin: true } : {}),
205
- }));
206
- messageSeqs.push(seq);
269
+ const seq = await this.db.incrementSeq(threadId, p.preview, now);
270
+ await this.db.putMessage({ ...p.row, seq, at: now });
271
+ (p.kind === 'message' ? messageSeqs : artifactSeqs).push(seq);
272
+ for (const e of p.elided) {
273
+ elidedToolInputs.push({ seq, toolCallId: e.toolCallId, name: e.name, field: e.field, reason: e.reason, bytes: e.bytes });
274
+ }
207
275
  }
276
+ await this.writeSnapshot(snapshot);
277
+ return {
278
+ messageSeqs,
279
+ artifactSeqs,
280
+ droppedDurableMemory: snapshot.droppedDurableMemory,
281
+ deduped: false,
282
+ elidedToolInputs,
283
+ ...(snapshot.refusal !== undefined ? { refusedSnapshot: snapshot.refusal } : {}),
284
+ };
285
+ }
286
+ /**
287
+ * The snapshot a fold persists (working state + thread memory + last turn),
288
+ * planned under the store's row budget (guuey#2120). Writes nothing.
289
+ * `row` is undefined when `skipSnapshot` is set or the snapshot cannot be
290
+ * stored at all.
291
+ */
292
+ planFoldSnapshot(input, provisionalAt) {
293
+ const { fold } = input;
294
+ const threadMemory = keepUnchangedThreadMemory(input.priorThreadMemory ?? [], fold.memory.filter((m) => m.scope === 'thread'));
295
+ const droppedDurableMemory = fold.memory.length - threadMemory.length;
296
+ if (input.skipSnapshot === true)
297
+ return { row: undefined, droppedDurableMemory };
298
+ const lastTurnId = fold.turns.length ? fold.turns[fold.turns.length - 1].turnId : undefined;
299
+ const planned = planSnapshot({
300
+ row: {
301
+ threadId: input.threadId,
302
+ userId: input.userId,
303
+ threadMemory,
304
+ ...(input.carriedThreadMemory !== undefined && input.carriedThreadMemory.length > 0
305
+ ? { carriedThreadMemory: [...input.carriedThreadMemory] }
306
+ : {}),
307
+ updatedAt: provisionalAt,
308
+ ...(fold.state !== undefined ? { workingState: fold.state } : {}),
309
+ ...(lastTurnId ? { lastTurnId } : {}),
310
+ },
311
+ ...(input.priorWorkingState !== undefined ? { priorWorkingState: input.priorWorkingState } : {}),
312
+ ...(input.priorThreadMemory !== undefined ? { priorThreadMemory: input.priorThreadMemory } : {}),
313
+ }, this.db.rowBudget);
314
+ return {
315
+ row: planned.row,
316
+ droppedDurableMemory,
317
+ ...(planned.refusal !== undefined ? { refusal: planned.refusal } : {}),
318
+ };
319
+ }
320
+ /** Write a planned snapshot, stamped now (the same length as the provisional stamp it was measured with). */
321
+ async writeSnapshot(plan) {
322
+ if (plan.row === undefined)
323
+ return;
324
+ await this.db.putSnapshot({ ...plan.row, updatedAt: new Date().toISOString() });
325
+ }
326
+ /**
327
+ * The turn's one row when a row of it cannot be stored: the caller's notice
328
+ * text, under the turn sentinel (so a retry short-circuits and the turn is
329
+ * final), carrying the failure code readers classify as a failed turn
330
+ * (`stored_turn_too_large` with the counts, or `stored_turn_unstorable_number`).
331
+ * No other row of the turn is written.
332
+ */
333
+ async writeUnstorableMarker(input, notice, sentinelKey, err, snapshot) {
334
+ const { text } = notice;
335
+ const error = err instanceof StoredRowTooLargeError
336
+ ? { code: STORED_TURN_TOO_LARGE_CODE, bytes: err.bytes, maxBytes: err.maxBytes }
337
+ : { code: STORED_TURN_UNSTORABLE_NUMBER_CODE };
338
+ const now = new Date().toISOString();
339
+ const seq = await this.db.incrementSeq(input.threadId, text.slice(0, PREVIEW_MAX_LEN), now);
340
+ await this.db.putMessage({
341
+ threadId: input.threadId,
342
+ seq,
343
+ userId: input.userId,
344
+ clientMessageId: sentinelKey,
345
+ at: now,
346
+ kind: 'text',
347
+ authorRole: 'agent',
348
+ text,
349
+ content: {
350
+ kind: 'text',
351
+ text,
352
+ error,
353
+ },
354
+ ...(input.untrustedOrigin === true ? { untrustedOrigin: true } : {}),
355
+ });
356
+ await this.writeSnapshot(snapshot);
357
+ return {
358
+ messageSeqs: [],
359
+ artifactSeqs: [],
360
+ droppedDurableMemory: snapshot.droppedDurableMemory,
361
+ deduped: false,
362
+ elidedToolInputs: [],
363
+ ...(snapshot.refusal !== undefined ? { refusedSnapshot: snapshot.refusal } : {}),
364
+ unstorable: err instanceof StoredRowTooLargeError
365
+ ? { seq, rowKind: err.rowKind, index: err.index, reason: 'size', bytes: err.bytes, maxBytes: err.maxBytes }
366
+ : { seq, rowKind: err.rowKind, index: err.index, reason: 'number' },
367
+ };
368
+ }
369
+ /**
370
+ * Build every row a fold writes, in write order, with a provisional seq and
371
+ * timestamp, bounded by the store's row budget (throws a
372
+ * {@link StoredRowRefusal} when one cannot be stored). Writes nothing.
373
+ */
374
+ planFoldRows(input, sentinelKey, provisionalAt, planned) {
375
+ const { threadId, userId, fold, clientMessageIdBase } = input;
376
+ const budget = this.db.rowBudget;
377
+ const seq = PROVISIONAL_SEQ;
378
+ // Whichever row is written first claims the sentinel key: the first
379
+ // message, or the first card when the fold has no message. The WRITE
380
+ // order below interleaves the cards, and a card only ever follows a
381
+ // message, so the first row written is still the first row keyed.
382
+ const messageRows = [];
383
+ /** Per message index, the card rows drawn with it (guuey#2396). */
384
+ const cardsAfterMessage = new Map();
385
+ const tailCards = [];
208
386
  // Cards can arrive two ways: first-class artifact events (fold.artifacts)
209
387
  // or UI-carrying tool-result blocks inside messages (the Claude facet's
210
388
  // only channel — see uiCardArtifactsFromMessages). Persist both through
@@ -220,38 +398,68 @@ export class ThreadStore {
220
398
  // beside the snapshot. Unresolvable (a first-class artifact event with
221
399
  // no tool-result part) persists no name; readers fall back.
222
400
  const toolNames = toolNamesByCallId(fold.messages);
223
- for (let i = 0; i < cardArtifacts.length; i++) {
224
- const art = cardArtifacts[i];
225
- const clientMessageId = sentinelClaimed ? `${clientMessageIdBase}#card#${i}` : sentinelKey;
226
- sentinelClaimed = true;
227
- const now = new Date().toISOString();
228
- const seq = await this.db.incrementSeq(threadId, art.name ?? null, now);
229
- await this.db.putMessage(agArtifactToCardRow(art, {
401
+ const cardRows = cardArtifacts.map((art, i) => agArtifactToCardRow(art, {
402
+ threadId,
403
+ userId,
404
+ seq,
405
+ at: provisionalAt,
406
+ clientMessageId: fold.messages.length > 0 || i > 0 ? `${clientMessageIdBase}#card#${i}` : sentinelKey,
407
+ ...(input.untrustedOrigin === true ? { untrustedOrigin: true } : {}),
408
+ }, producingToolName(art, toolNames)));
409
+ // The card rows are built before the message rows, so a message row over
410
+ // its budget can leave out a UI resource a card row of this turn carries
411
+ // whole (agMessageToBoundedRow).
412
+ const cards = cardUiDataByCallId(cardRows);
413
+ for (let i = 0; i < fold.messages.length; i++) {
414
+ const msg = fold.messages[i];
415
+ const clientMessageId = i > 0 ? `${clientMessageIdBase}#agent#${i}` : sentinelKey;
416
+ // The preview is the ANSWER: interim narration (`phase: "interim"`) never becomes a thread's preview,
417
+ // and neither does a framework notice (a non-conversational row).
418
+ const text = msg.role === 'notice'
419
+ ? ''
420
+ : msg.content.reduce((acc, b) => (b.type === 'text' && b.phase !== 'interim' ? acc + b.text : acc), '');
421
+ const turnRecord = fold.turns.find((t) => t.turnId === msg.turnId);
422
+ const { row, elided } = agMessageToBoundedRow(msg, {
230
423
  threadId,
231
424
  userId,
232
425
  seq,
233
- at: now,
426
+ at: provisionalAt,
234
427
  clientMessageId,
428
+ ...(turnRecord ? { turnRecord } : {}),
235
429
  ...(input.untrustedOrigin === true ? { untrustedOrigin: true } : {}),
236
- }, producingToolName(art, toolNames)));
237
- artifactSeqs.push(seq);
430
+ }, budget, i, cards);
431
+ // Text-less agent messages (tool-call + tool-result only — the canonical
432
+ // card-producing turn) must not blank the preview: '' takes the SET
433
+ // branch, null leaves the prior preview standing.
434
+ messageRows.push({ kind: 'message', row, preview: text ? text.slice(0, PREVIEW_MAX_LEN) : null, elided });
238
435
  }
239
- const threadMemory = keepUnchangedThreadMemory(input.priorThreadMemory ?? [], fold.memory.filter((m) => m.scope === 'thread'));
240
- const droppedDurableMemory = fold.memory.length - threadMemory.length;
241
- if (skipSnapshot !== true) {
242
- const lastTurnId = fold.turns.length ? fold.turns[fold.turns.length - 1].turnId : undefined;
243
- await this.db.putSnapshot({
244
- threadId,
245
- userId,
246
- threadMemory,
247
- ...(input.carriedThreadMemory !== undefined && input.carriedThreadMemory.length > 0
248
- ? { carriedThreadMemory: [...input.carriedThreadMemory] }
249
- : {}),
250
- updatedAt: new Date().toISOString(),
251
- ...(fold.state !== undefined ? { workingState: fold.state } : {}),
252
- ...(lastTurnId ? { lastTurnId } : {}),
253
- });
436
+ const resultIndices = toolResultMessageIndexByCallId(fold.messages);
437
+ for (let i = 0; i < cardArtifacts.length; i++) {
438
+ const art = cardArtifacts[i];
439
+ const row = cardRows[i];
440
+ assertRowFits(row, budget, 'card', i);
441
+ const card = { kind: 'card', row, preview: art.name ?? null, elided: [] };
442
+ const anchor = producingMessageIndex(art, resultIndices);
443
+ if (anchor === undefined) {
444
+ tailCards.push(card);
445
+ }
446
+ else {
447
+ const after = cardsAfterMessage.get(anchor);
448
+ if (after === undefined)
449
+ cardsAfterMessage.set(anchor, [card]);
450
+ else
451
+ after.push(card);
452
+ }
254
453
  }
255
- return { messageSeqs, artifactSeqs, droppedDurableMemory, deduped: false };
454
+ // guuey#2396: the stored order is the drawn order. A card is drawn when
455
+ // its tool-result arrives, so its row follows the message carrying that
456
+ // result, BEFORE any text the turn writes after it. Writing every message
457
+ // first and every card after it put a turn's closing text above the card
458
+ // it describes on every reload. A card with no producing result in the
459
+ // fold keeps the turn's tail, as before.
460
+ messageRows.forEach((message, index) => {
461
+ planned.push(message, ...(cardsAfterMessage.get(index) ?? []));
462
+ });
463
+ planned.push(...tailCards);
256
464
  }
257
465
  }
@@ -11,14 +11,19 @@
11
11
  * - a NOTICE row: a stored message with `role: "notice"` (a framework notice
12
12
  * the agent facet emits), a non-conversational, user-facing row;
13
13
  * - a BLANK row: an agent message whose every block shows the reader nothing
14
- * (an empty text part, or a provider carry with nothing in it).
14
+ * (an empty text part, or a provider carry with nothing in it). The
15
+ * message is read as every row reader reads it (`readStoredRowMessage`,
16
+ * fold-rows.ts): an answer block elided because the row's `text` carries
17
+ * it reads as that `text`, and on a row with no `text` it is left out
18
+ * (every other block still counts).
15
19
  *
16
20
  * The reader is lenient: it reads the stored `content` through the core's
17
21
  * stored-record reader, so a row written before any of this is classified the
18
22
  * same way, with no migration.
19
23
  */
20
- import { type AgBlock, type JsonValue } from "@silverprotocol/core";
24
+ import type { AgBlock, JsonValue } from "@silverprotocol/core";
21
25
  import type { ThreadMessageRow } from "./rows.js";
26
+ import { type StoredRowText } from "./fold-rows.js";
22
27
  /** The clientMessageId suffix the runtime gives a failed turn's reader-line row. */
23
28
  export declare const AGENT_ERROR_ROW_SUFFIX = "#agentError";
24
29
  /**
@@ -52,6 +57,15 @@ export type StoredRowClass = {
52
57
  } | {
53
58
  kind: "blank";
54
59
  };
60
+ /**
61
+ * What {@link classifyStoredRow} reads of a row: a whole stored row, or the
62
+ * fields it reads. In the second shape `text` is required (`undefined` or null
63
+ * when the row has none): an elided answer reads from it, so a caller that picks
64
+ * the fields out of a row passes the row's `text` on purpose.
65
+ */
66
+ export type ClassifiedRowInput = ThreadMessageRow | (Pick<ThreadMessageRow, "clientMessageId" | "authorRole" | "content"> & {
67
+ text: StoredRowText;
68
+ });
55
69
  /** Classify one stored row (see the module doc). */
56
- export declare function classifyStoredRow(row: Pick<ThreadMessageRow, "clientMessageId" | "authorRole" | "content">): StoredRowClass;
70
+ export declare function classifyStoredRow(row: ClassifiedRowInput): StoredRowClass;
57
71
  //# sourceMappingURL=stored-row.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"stored-row.d.ts","sourceRoot":"","sources":["../src/stored-row.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAuB,KAAK,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACzF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAElD,oFAAoF;AACpF,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,GAAG,OAAO,CAMvE;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIpD;AAED,mCAAmC;AACnC,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE;AAC1B,+FAA+F;GAC7F;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;AAC1C,kFAAkF;GAChF;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAUtB,oDAAoD;AACpD,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,IAAI,CAAC,gBAAgB,EAAE,iBAAiB,GAAG,YAAY,GAAG,SAAS,CAAC,GACxE,cAAc,CAWhB"}
1
+ {"version":3,"file":"stored-row.d.ts","sourceRoot":"","sources":["../src/stored-row.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAC/D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAE1E,oFAAoF;AACpF,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,GAAG,OAAO,CAMvE;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIpD;AAED,mCAAmC;AACnC,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE;AAC1B,+FAA+F;GAC7F;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE;AAC1C,kFAAkF;GAChF;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAUtB;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAC1B,gBAAgB,GAChB,CAAC,IAAI,CAAC,gBAAgB,EAAE,iBAAiB,GAAG,YAAY,GAAG,SAAS,CAAC,GAAG;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,CAAC,CAAC;AAErG,oDAAoD;AACpD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,kBAAkB,GAAG,cAAc,CAWzE"}
@@ -1,23 +1,4 @@
1
- /**
2
- * What a stored thread row IS, for every reader that turns rows back into
3
- * conversation. Some stored rows are not "the agent said this", and reading
4
- * them as such replays them to the model as the agent's own words and draws
5
- * them as answer bubbles:
6
- *
7
- * - a FAILURE row: the runtime's reader line for a failed turn, keyed
8
- * `…#agentError`. It carries `content.error.code` from the runtime release
9
- * that added codes; rows written before it carry only `{kind:'text', text}`,
10
- * so the key suffix alone must classify them too;
11
- * - a NOTICE row: a stored message with `role: "notice"` (a framework notice
12
- * the agent facet emits), a non-conversational, user-facing row;
13
- * - a BLANK row: an agent message whose every block shows the reader nothing
14
- * (an empty text part, or a provider carry with nothing in it).
15
- *
16
- * The reader is lenient: it reads the stored `content` through the core's
17
- * stored-record reader, so a row written before any of this is classified the
18
- * same way, with no migration.
19
- */
20
- import { readStoredAgMessage } from "@silverprotocol/core";
1
+ import { readStoredRowMessage } from "./fold-rows.js";
21
2
  /** The clientMessageId suffix the runtime gives a failed turn's reader-line row. */
22
3
  export const AGENT_ERROR_ROW_SUFFIX = "#agentError";
23
4
  /**
@@ -68,7 +49,7 @@ export function classifyStoredRow(row) {
68
49
  }
69
50
  if (row.authorRole === "user")
70
51
  return { kind: "conversation" };
71
- const stored = readStoredAgMessage(row.content).value;
52
+ const stored = readStoredRowMessage(row.content, row.text);
72
53
  if (stored === undefined)
73
54
  return { kind: "conversation" };
74
55
  if (stored.role === "notice")
@@ -3,9 +3,45 @@ export interface ThreadPersistenceHarness {
3
3
  port: ThreadPersistencePort;
4
4
  cleanup?: () => void | Promise<void>;
5
5
  }
6
+ /** What the binding under test claims beyond the required port. */
7
+ export interface ThreadPersistenceContractOptions {
8
+ /**
9
+ * The binding implements `closeThread`: the suite then requires it and
10
+ * holds it to the close contract (a closure is recorded once, and a closed
11
+ * thread allocates no seq).
12
+ */
13
+ canClose?: boolean;
14
+ }
6
15
  /**
7
16
  * Run the port contract against a binding. `make` must yield a FRESH,
8
17
  * empty store per call.
9
18
  */
10
- export declare function runThreadPersistenceContractSuite(name: string, make: () => Promise<ThreadPersistenceHarness>): void;
19
+ export declare function runThreadPersistenceContractSuite(name: string, make: () => Promise<ThreadPersistenceHarness>, options?: ThreadPersistenceContractOptions): void;
20
+ /**
21
+ * The error {@link withItemSizeLimit} rejects an oversized row with: the shape
22
+ * DynamoDB answers a too-large `PutItem` with.
23
+ */
24
+ export declare class ItemSizeExceededError extends Error {
25
+ readonly bytes: number;
26
+ readonly name = "ValidationException";
27
+ constructor(bytes: number);
28
+ }
29
+ /**
30
+ * The error {@link withItemSizeLimit} rejects a row carrying a number DynamoDB
31
+ * cannot hold with: like the DocumentClient's own, its message names the number.
32
+ */
33
+ export declare class UnstorableNumberError extends Error {
34
+ readonly value: number;
35
+ constructor(value: number);
36
+ }
37
+ /**
38
+ * Wrap a port so it behaves like a DynamoDB-backed binding on what a row may
39
+ * hold (guuey#1794): it declares the DynamoDB row budget, and its
40
+ * `putMessage` (and, guuey#2120, its `putSnapshot`) rejects a row over DynamoDB's 400 KB item limit (measured by
41
+ * `dynamoItemBytes`) with {@link ItemSizeExceededError}, and a row carrying a
42
+ * number DynamoDB cannot hold with {@link UnstorableNumberError}, instead of
43
+ * storing it. Run a `ThreadStore` over it to test what happens at those
44
+ * limits without a real table.
45
+ */
46
+ export declare function withItemSizeLimit(port: ThreadPersistencePort): ThreadPersistencePort;
11
47
  //# sourceMappingURL=contract-suite.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"contract-suite.d.ts","sourceRoot":"","sources":["../../src/testing/contract-suite.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,qBAAqB,EAAa,MAAM,YAAY,CAAC;AAEnE,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,qBAAqB,CAAC;IAC5B,OAAO,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACtC;AAiCD;;;GAGG;AACH,wBAAgB,iCAAiC,CAC/C,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,OAAO,CAAC,wBAAwB,CAAC,GAC5C,IAAI,CA0KN"}
1
+ {"version":3,"file":"contract-suite.d.ts","sourceRoot":"","sources":["../../src/testing/contract-suite.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,qBAAqB,EAAa,MAAM,YAAY,CAAC;AAInE,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,qBAAqB,CAAC;IAC5B,OAAO,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACtC;AAED,mEAAmE;AACnE,MAAM,WAAW,gCAAgC;IAC/C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAiCD;;;GAGG;AACH,wBAAgB,iCAAiC,CAC/C,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,OAAO,CAAC,wBAAwB,CAAC,EAC7C,OAAO,GAAE,gCAAqC,GAC7C,IAAI,CAmNN;AAED;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAGlC,QAAQ,CAAC,KAAK,EAAE,MAAM;IAFlC,SAAkB,IAAI,yBAAyB;gBAE1B,KAAK,EAAE,MAAM;CAGnC;AAED;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM;gBAAb,KAAK,EAAE,MAAM;CAGnC;AAYD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,qBAAqB,GAAG,qBAAqB,CA0BpF"}