@letta-ai/letta-agent-sdk 0.6.2 → 0.7.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.
Files changed (44) hide show
  1. package/AGENTS.md +42 -0
  2. package/README.md +101 -1
  3. package/dist/app-server-session.d.ts.map +1 -1
  4. package/dist/client-base.d.ts +7 -0
  5. package/dist/client-base.d.ts.map +1 -1
  6. package/dist/client-entry.d.ts +3 -0
  7. package/dist/client-entry.d.ts.map +1 -1
  8. package/dist/client-entry.js +816 -138
  9. package/dist/client-entry.js.map +16 -14
  10. package/dist/cloud-session.d.ts +8 -1
  11. package/dist/cloud-session.d.ts.map +1 -1
  12. package/dist/computers.d.ts +67 -0
  13. package/dist/computers.d.ts.map +1 -0
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +817 -139
  17. package/dist/index.js.map +17 -15
  18. package/dist/remote-client-session-core.d.ts +13 -16
  19. package/dist/remote-client-session-core.d.ts.map +1 -1
  20. package/dist/remote-session-protocol.d.ts +11 -0
  21. package/dist/remote-session-protocol.d.ts.map +1 -1
  22. package/dist/remote-turn-coordinator.d.ts +6 -1
  23. package/dist/remote-turn-coordinator.d.ts.map +1 -1
  24. package/dist/remote.d.ts +6 -1
  25. package/dist/remote.d.ts.map +1 -1
  26. package/dist/transcript-accumulator.d.ts +133 -0
  27. package/dist/transcript-accumulator.d.ts.map +1 -0
  28. package/dist/types.d.ts +41 -21
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/validation.d.ts.map +1 -1
  31. package/package.json +2 -2
  32. package/src/app-server-session.ts +9 -0
  33. package/src/client-base.ts +66 -19
  34. package/src/client-entry.ts +23 -0
  35. package/src/cloud-session.ts +112 -45
  36. package/src/computers.ts +132 -0
  37. package/src/index.ts +24 -0
  38. package/src/remote-client-session-core.ts +103 -27
  39. package/src/remote-session-protocol.ts +24 -0
  40. package/src/remote-turn-coordinator.ts +11 -2
  41. package/src/remote.ts +41 -12
  42. package/src/transcript-accumulator.ts +823 -0
  43. package/src/types.ts +43 -18
  44. package/src/validation.ts +16 -0
@@ -0,0 +1,823 @@
1
+ /**
2
+ * Transcript accumulator
3
+ *
4
+ * Turns an `SDKMessage` stream into stable, render-ready rows so consumers stop
5
+ * hand-rolling stream reconciliation. It owns the four reconciliation rules the
6
+ * wire protocol requires:
7
+ *
8
+ * 1. Typed-by-family accumulation. Text slices are keyed on
9
+ * `message family + otid`, falling back to `uuid` *within the same family*.
10
+ * A bare `otid`/`uuid` key would collapse an assistant slice into a
11
+ * reasoning slice whenever a provider reuses an identifier across kinds.
12
+ * 2. Per-`runId` `seqId` replay suppression. Each run keeps its own high-water
13
+ * mark, so a resumed stream that replays positions is dropped while a new
14
+ * run starts from a clean threshold.
15
+ * 3. `toolCallId`-keyed merging. Tool argument fragments and the eventual tool
16
+ * result merge into one row keyed on the payload identity (`toolCallId`),
17
+ * while the envelope identities (the `uuid` of the `tool_call` message and
18
+ * of the `tool_result` message) stay separately visible.
19
+ * 4. `rebase()` for mid-run backfill. A history page is merged in place with
20
+ * replace semantics, reordered ahead of live-only rows, and raises the
21
+ * replay thresholds it proves.
22
+ *
23
+ * The accumulator is pure and portable: no I/O, no timers, no Node built-ins,
24
+ * so it is exported from both the package root and `/client`.
25
+ */
26
+
27
+ import type { Message as LettaMessage } from "@letta-ai/letta-client/resources/agents/messages";
28
+ import {
29
+ extractTextFromContent,
30
+ firstToolCall,
31
+ firstToolReturn,
32
+ } from "./remote-session-protocol.js";
33
+ import { extractStreamTextDelta } from "./stream-events.js";
34
+ import type { SDKMessage, SDKStreamEventMessage } from "./types.js";
35
+
36
+ // ═══════════════════════════════════════════════════════════════
37
+ // PUBLIC TYPES
38
+ // ═══════════════════════════════════════════════════════════════
39
+
40
+ /** Message families the accumulator projects into rows. */
41
+ export type TranscriptRowKind = "user" | "assistant" | "reasoning" | "tool_call";
42
+
43
+ /** Text families. Rows in different families never share a key. */
44
+ export type TranscriptTextKind = "user" | "assistant" | "reasoning";
45
+
46
+ export interface TranscriptRowIdentity {
47
+ /**
48
+ * Stable render key. Namespaced by message family, so a provider that reuses
49
+ * an `otid` or a message id across kinds still produces separate rows.
50
+ */
51
+ key: string;
52
+ /** Envelope id of the message that opened this row, when known. */
53
+ uuid?: string;
54
+ /** Lineage key for this typed slice, when the stream supplied one. */
55
+ otid?: string;
56
+ /** Run that most recently contributed to this row. */
57
+ runId?: string;
58
+ /** Highest replay cursor observed for this row. */
59
+ seqId?: number;
60
+ }
61
+
62
+ export interface TranscriptTextRow extends TranscriptRowIdentity {
63
+ kind: TranscriptTextKind;
64
+ /** Accumulated text for this slice. */
65
+ text: string;
66
+ }
67
+
68
+ export interface TranscriptToolResult {
69
+ content: string;
70
+ isError: boolean;
71
+ /**
72
+ * Envelope id of the `tool_result` message. Deliberately distinct from the
73
+ * row's `uuid`, which identifies the `tool_call` envelope.
74
+ */
75
+ uuid?: string;
76
+ }
77
+
78
+ /**
79
+ * Lifecycle of a tool row.
80
+ *
81
+ * - `streaming`: argument fragments are still arriving and have not parsed.
82
+ * - `ready`: arguments parsed; the result has not arrived.
83
+ * - `complete`: a tool result merged into the row.
84
+ */
85
+ export type TranscriptToolCallStatus = "streaming" | "ready" | "complete";
86
+
87
+ export interface TranscriptToolCallRow extends TranscriptRowIdentity {
88
+ kind: "tool_call";
89
+ /** Payload identity. This is what the row is keyed on. */
90
+ toolCallId: string;
91
+ toolName: string;
92
+ /**
93
+ * Best known parsed arguments. Never the transitional `{ raw }` wrapper the
94
+ * protocol layer emits for an argument fragment that does not parse.
95
+ */
96
+ toolInput: Record<string, unknown>;
97
+ /** Argument fragments concatenated in arrival order, when the wire sent any. */
98
+ rawArguments?: string;
99
+ /** Whether {@link toolInput} reflects fully parsed arguments. */
100
+ argumentsComplete: boolean;
101
+ result?: TranscriptToolResult;
102
+ status: TranscriptToolCallStatus;
103
+ }
104
+
105
+ export type TranscriptRow = TranscriptTextRow | TranscriptToolCallRow;
106
+
107
+ /**
108
+ * A history page accepted by {@link TranscriptAccumulator.rebase}. Covers
109
+ * `session.listMessages()`, `session.bootstrapState()`, and a bare array of
110
+ * Letta API messages.
111
+ */
112
+ export type TranscriptHistoryPage =
113
+ | { messages: readonly LettaMessage[] }
114
+ | readonly LettaMessage[];
115
+
116
+ export interface TranscriptRebaseOptions {
117
+ /**
118
+ * Order of the supplied page. Omitted means auto-detect from `seq_id`/`date`;
119
+ * `listMessages()` defaults to `"desc"` (newest first).
120
+ */
121
+ order?: "asc" | "desc";
122
+ }
123
+
124
+ export interface TranscriptAccumulator {
125
+ /**
126
+ * Fold one streamed message into the transcript and return the current rows.
127
+ *
128
+ * The returned array is referentially stable when the message changed
129
+ * nothing (a replayed position, or a message family the accumulator ignores),
130
+ * so it can be handed straight to a memoizing renderer.
131
+ */
132
+ apply(message: SDKMessage): readonly TranscriptRow[];
133
+ /** Merge a history page into the transcript. Safe to call mid-run. */
134
+ rebase(
135
+ page: TranscriptHistoryPage,
136
+ options?: TranscriptRebaseOptions,
137
+ ): readonly TranscriptRow[];
138
+ /** Current rows in transcript order. */
139
+ rows(): readonly TranscriptRow[];
140
+ /** Drop all rows and replay state. */
141
+ reset(): void;
142
+ }
143
+
144
+ // ═══════════════════════════════════════════════════════════════
145
+ // INTERNALS
146
+ // ═══════════════════════════════════════════════════════════════
147
+
148
+ /**
149
+ * Key segment separator. Every key carries a family segment and an identifier
150
+ * kind segment ("otid"/"uuid"), so no wire identifier can produce a key that
151
+ * collides with a key from another family or another identifier kind.
152
+ */
153
+ const SEP = ":";
154
+
155
+ /** Bound on tracked replay thresholds so a long session cannot grow forever. */
156
+ const MAX_TRACKED_RUNS = 64;
157
+
158
+ /** Bucket used for streams that do not carry a `runId`. */
159
+ const ANONYMOUS_RUN = "";
160
+
161
+ interface TextSlice {
162
+ kind: TranscriptTextKind;
163
+ text: string;
164
+ uuid?: string;
165
+ otid?: string;
166
+ runId?: string;
167
+ seqId?: number;
168
+ }
169
+
170
+ function familyOtidAlias(kind: TranscriptTextKind, otid: string): string {
171
+ return `${kind}${SEP}otid${SEP}${otid}`;
172
+ }
173
+
174
+ function familyUuidAlias(kind: TranscriptTextKind, uuid: string): string {
175
+ return `${kind}${SEP}uuid${SEP}${uuid}`;
176
+ }
177
+
178
+ function toolRowKey(toolCallId: string): string {
179
+ return `tool_call${SEP}id${SEP}${toolCallId}`;
180
+ }
181
+
182
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
183
+ return value && typeof value === "object" && !Array.isArray(value)
184
+ ? (value as Record<string, unknown>)
185
+ : undefined;
186
+ }
187
+
188
+ function readString(
189
+ record: Record<string, unknown>,
190
+ field: string,
191
+ ): string | undefined {
192
+ const value = record[field];
193
+ return typeof value === "string" && value.length > 0 ? value : undefined;
194
+ }
195
+
196
+ function readNumber(
197
+ record: Record<string, unknown>,
198
+ field: string,
199
+ ): number | undefined {
200
+ const value = record[field];
201
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
202
+ }
203
+
204
+ /** Parse a JSON object, returning undefined for partial or non-object JSON. */
205
+ function parseJsonObject(raw: string): Record<string, unknown> | undefined {
206
+ const trimmed = raw.trim();
207
+ if (trimmed.length === 0) return undefined;
208
+ try {
209
+ return asRecord(JSON.parse(trimmed) as unknown);
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Detect the protocol layer's transitional `{ raw }` wrapper.
217
+ *
218
+ * `toolInputFromArguments()` wraps an unparseable argument fragment as
219
+ * `{ raw: "<fragment>" }`. That wrapper is a parse failure, not arguments, and
220
+ * must never overwrite previously parsed input.
221
+ */
222
+ function isRawArgumentsWrapper(
223
+ input: Record<string, unknown> | undefined,
224
+ rawArguments: string | undefined,
225
+ ): boolean {
226
+ if (!input) return false;
227
+ const keys = Object.keys(input);
228
+ if (keys.length !== 1 || keys[0] !== "raw") return false;
229
+ const wrapped = input.raw;
230
+ if (typeof wrapped !== "string") return false;
231
+ return rawArguments === undefined || wrapped === rawArguments;
232
+ }
233
+
234
+ function toolStatus(row: {
235
+ argumentsComplete: boolean;
236
+ result?: TranscriptToolResult;
237
+ }): TranscriptToolCallStatus {
238
+ if (row.result) return "complete";
239
+ return row.argumentsComplete ? "ready" : "streaming";
240
+ }
241
+
242
+ interface ToolCallMerge {
243
+ toolCallId: string;
244
+ toolName?: string;
245
+ toolInput?: Record<string, unknown>;
246
+ rawArguments?: string;
247
+ uuid?: string;
248
+ runId?: string;
249
+ seqId?: number;
250
+ /**
251
+ * `fragment` appends streamed argument text; `whole` treats the arguments as
252
+ * an authoritative complete value (history backfill).
253
+ */
254
+ mode: "fragment" | "whole";
255
+ }
256
+
257
+ interface ToolResultMerge {
258
+ toolCallId: string;
259
+ content: string;
260
+ isError: boolean;
261
+ uuid?: string;
262
+ runId?: string;
263
+ seqId?: number;
264
+ }
265
+
266
+ interface ToolArguments {
267
+ rawArguments?: string;
268
+ toolInput: Record<string, unknown>;
269
+ argumentsComplete: boolean;
270
+ }
271
+
272
+ /**
273
+ * Fold one argument delivery into the arguments known so far.
274
+ *
275
+ * The wire can deliver arguments three ways for the same call: streamed JSON
276
+ * fragments, one complete JSON string, or an already-decoded object. Only a
277
+ * successful parse is allowed to change `toolInput`.
278
+ */
279
+ function mergeToolArguments(
280
+ previous: ToolArguments,
281
+ merge: ToolCallMerge,
282
+ ): ToolArguments {
283
+ let { rawArguments, toolInput, argumentsComplete } = previous;
284
+ const fragment = merge.rawArguments;
285
+ const wrapped = isRawArgumentsWrapper(merge.toolInput, fragment);
286
+
287
+ if (fragment !== undefined && fragment.length > 0) {
288
+ const whole = parseJsonObject(fragment);
289
+ if (whole) {
290
+ // A delivery that parses on its own is the complete argument value: a
291
+ // final non-chunked `tool_call_message`, or a backfilled history row.
292
+ // Replace rather than append so a repeated terminal message cannot
293
+ // corrupt the accumulation.
294
+ return { rawArguments: fragment, toolInput: whole, argumentsComplete: true };
295
+ }
296
+ if (argumentsComplete) {
297
+ // Arguments already parsed; a trailing partial (a replayed fragment after
298
+ // backfill) must not corrupt them.
299
+ return previous;
300
+ }
301
+ if (merge.mode === "whole") {
302
+ return { rawArguments: rawArguments ?? fragment, toolInput, argumentsComplete };
303
+ }
304
+ rawArguments = (rawArguments ?? "") + fragment;
305
+ const parsed = parseJsonObject(rawArguments);
306
+ if (parsed) {
307
+ return { rawArguments, toolInput: parsed, argumentsComplete: true };
308
+ }
309
+ // Keep the previous parse. Never promote the `{ raw }` wrapper.
310
+ return { rawArguments, toolInput, argumentsComplete: false };
311
+ }
312
+
313
+ if (!wrapped && merge.toolInput) {
314
+ const keys = Object.keys(merge.toolInput);
315
+ if (keys.length > 0) {
316
+ return { rawArguments, toolInput: merge.toolInput, argumentsComplete: true };
317
+ }
318
+ if (rawArguments === undefined) {
319
+ // Genuinely argument-free call: `{}` with no streamed fragments.
320
+ return { rawArguments, toolInput, argumentsComplete: true };
321
+ }
322
+ }
323
+
324
+ return previous;
325
+ }
326
+
327
+ class TranscriptAccumulatorImpl implements TranscriptAccumulator {
328
+ /** Row key -> row. Map insertion order is the transcript order. */
329
+ private byKey = new Map<string, TranscriptRow>();
330
+
331
+ /** `family + otid` -> row key. */
332
+ private aliasByOtid = new Map<string, string>();
333
+
334
+ /** `family + uuid` -> row key. */
335
+ private aliasByUuid = new Map<string, string>();
336
+
337
+ /** `runId` -> highest accepted `seqId` for that run. */
338
+ private seqThresholds = new Map<string, number>();
339
+
340
+ private snapshot: readonly TranscriptRow[] | null = null;
341
+
342
+ private anonymousCounter = 0;
343
+
344
+ apply(message: SDKMessage): readonly TranscriptRow[] {
345
+ switch (message.type) {
346
+ case "assistant":
347
+ case "reasoning":
348
+ this.applyText({
349
+ kind: message.type,
350
+ text: message.content,
351
+ uuid: message.uuid,
352
+ otid: typeof message.otid === "string" ? message.otid : undefined,
353
+ runId: message.runId,
354
+ seqId: message.seqId,
355
+ });
356
+ break;
357
+ case "tool_call":
358
+ this.mergeToolCall({
359
+ toolCallId: message.toolCallId,
360
+ toolName: message.toolName,
361
+ toolInput: message.toolInput,
362
+ rawArguments: message.rawArguments,
363
+ uuid: message.uuid,
364
+ runId: message.runId,
365
+ mode: "fragment",
366
+ });
367
+ break;
368
+ case "tool_result":
369
+ this.mergeToolResult({
370
+ toolCallId: message.toolCallId,
371
+ content: message.content,
372
+ isError: message.isError,
373
+ uuid: message.uuid,
374
+ runId: message.runId,
375
+ });
376
+ break;
377
+ case "stream_event":
378
+ this.applyStreamEvent(message);
379
+ break;
380
+ default:
381
+ // init/result/error/retry/queue_update/loop_status are turn-level
382
+ // signals rather than transcript content; consumers handle them
383
+ // directly.
384
+ break;
385
+ }
386
+ return this.rows();
387
+ }
388
+
389
+ rebase(
390
+ page: TranscriptHistoryPage,
391
+ options?: TranscriptRebaseOptions,
392
+ ): readonly TranscriptRow[] {
393
+ const messages = normalizeHistoryPage(page, options?.order);
394
+ if (messages.length === 0) return this.rows();
395
+
396
+ const historyKeys: string[] = [];
397
+ const seen = new Set<string>();
398
+ for (const message of messages) {
399
+ const key = this.applyHistoryMessage(message);
400
+ if (!key || seen.has(key)) continue;
401
+ seen.add(key);
402
+ historyKeys.push(key);
403
+ }
404
+
405
+ this.reorder(historyKeys);
406
+ return this.rows();
407
+ }
408
+
409
+ rows(): readonly TranscriptRow[] {
410
+ if (!this.snapshot) {
411
+ this.snapshot = Array.from(this.byKey.values());
412
+ }
413
+ return this.snapshot;
414
+ }
415
+
416
+ reset(): void {
417
+ this.byKey.clear();
418
+ this.aliasByOtid.clear();
419
+ this.aliasByUuid.clear();
420
+ this.seqThresholds.clear();
421
+ this.anonymousCounter = 0;
422
+ this.snapshot = null;
423
+ }
424
+
425
+ // ── replay suppression ────────────────────────────────────────
426
+
427
+ /**
428
+ * Per-run replay guard.
429
+ *
430
+ * Thresholds are bucketed by `runId`, so a brand new run starts with no
431
+ * threshold (a natural reset) while a resumed run keeps suppressing the
432
+ * positions it already delivered. Messages without a `seqId` are never
433
+ * suppressed here: their families are deduplicated by identity instead.
434
+ */
435
+ private isReplay(
436
+ runId: string | undefined,
437
+ seqId: number | undefined,
438
+ ): boolean {
439
+ if (seqId === undefined) return false;
440
+ const bucket = runId ?? ANONYMOUS_RUN;
441
+ const threshold = this.seqThresholds.get(bucket);
442
+ if (threshold !== undefined && seqId <= threshold) return true;
443
+ this.rememberSeq(bucket, seqId);
444
+ return false;
445
+ }
446
+
447
+ private rememberSeq(bucket: string, seqId: number): void {
448
+ const threshold = this.seqThresholds.get(bucket);
449
+ if (threshold !== undefined && threshold >= seqId) return;
450
+ this.seqThresholds.set(bucket, seqId);
451
+ while (this.seqThresholds.size > MAX_TRACKED_RUNS) {
452
+ const oldest = this.seqThresholds.keys().next();
453
+ if (oldest.done) break;
454
+ this.seqThresholds.delete(oldest.value);
455
+ }
456
+ }
457
+
458
+ // ── text families ─────────────────────────────────────────────
459
+
460
+ private applyText(slice: TextSlice): void {
461
+ if (this.isReplay(slice.runId, slice.seqId)) return;
462
+ this.writeText(slice, "append");
463
+ }
464
+
465
+ private writeText(slice: TextSlice, write: "append" | "replace"): string {
466
+ const key = this.resolveTextKey(slice.kind, slice.uuid, slice.otid);
467
+ const existing = this.byKey.get(key);
468
+ const previous =
469
+ existing && existing.kind === slice.kind ? existing : undefined;
470
+ this.setRow(key, {
471
+ kind: slice.kind,
472
+ key,
473
+ text: write === "append" ? (previous?.text ?? "") + slice.text : slice.text,
474
+ uuid: previous?.uuid ?? slice.uuid,
475
+ otid: slice.otid ?? previous?.otid,
476
+ runId: slice.runId ?? previous?.runId,
477
+ seqId: maxDefined(slice.seqId, previous?.seqId),
478
+ });
479
+ return key;
480
+ }
481
+
482
+ /**
483
+ * Resolve the row key for a text slice.
484
+ *
485
+ * `otid` is the lineage key when present, but a stream can transition: early
486
+ * fragments may carry only the message id and later fragments add an `otid`.
487
+ * Both identifiers are aliased to one row so the transition does not split
488
+ * it. When a message id is reused by a *second* slice carrying a different
489
+ * `otid` (a provider emitting `[text, thinking, text]` under one message id),
490
+ * the new `otid` opens its own row instead of appending to the previous one.
491
+ */
492
+ private resolveTextKey(
493
+ kind: TranscriptTextKind,
494
+ uuid: string | undefined,
495
+ otid: string | undefined,
496
+ ): string {
497
+ const otidAlias = otid ? familyOtidAlias(kind, otid) : undefined;
498
+ const uuidAlias = uuid ? familyUuidAlias(kind, uuid) : undefined;
499
+ const fromOtid = otidAlias ? this.aliasByOtid.get(otidAlias) : undefined;
500
+ const fromUuid = uuidAlias ? this.aliasByUuid.get(uuidAlias) : undefined;
501
+
502
+ let key: string | undefined = fromOtid ?? fromUuid;
503
+
504
+ if (key && !fromOtid && otid) {
505
+ const existing = this.byKey.get(key);
506
+ if (existing && existing.otid !== undefined && existing.otid !== otid) {
507
+ // The envelope already committed to a different lineage: this is a new
508
+ // slice sharing a message id, not a continuation of the old one.
509
+ key = undefined;
510
+ }
511
+ }
512
+
513
+ if (!key) {
514
+ key =
515
+ otidAlias ??
516
+ uuidAlias ??
517
+ `${kind}${SEP}auto${SEP}${++this.anonymousCounter}`;
518
+ }
519
+
520
+ if (otidAlias) this.aliasByOtid.set(otidAlias, key);
521
+ // The newest slice owns the envelope, so later id-only fragments continue
522
+ // it rather than the slice that closed before it.
523
+ if (uuidAlias) this.aliasByUuid.set(uuidAlias, key);
524
+
525
+ return key;
526
+ }
527
+
528
+ // ── tool families ─────────────────────────────────────────────
529
+
530
+ private mergeToolCall(merge: ToolCallMerge): string {
531
+ const key = toolRowKey(merge.toolCallId);
532
+ const existing = this.byKey.get(key);
533
+ const previous =
534
+ existing && existing.kind === "tool_call" ? existing : undefined;
535
+
536
+ const args = mergeToolArguments(
537
+ {
538
+ rawArguments: previous?.rawArguments,
539
+ toolInput: previous?.toolInput ?? {},
540
+ argumentsComplete: previous?.argumentsComplete ?? false,
541
+ },
542
+ merge,
543
+ );
544
+
545
+ const toolName =
546
+ merge.toolName && merge.toolName !== "?"
547
+ ? merge.toolName
548
+ : (previous?.toolName ?? merge.toolName ?? "?");
549
+
550
+ const next: TranscriptToolCallRow = {
551
+ kind: "tool_call",
552
+ key,
553
+ toolCallId: merge.toolCallId,
554
+ toolName,
555
+ toolInput: args.toolInput,
556
+ ...(args.rawArguments !== undefined
557
+ ? { rawArguments: args.rawArguments }
558
+ : {}),
559
+ argumentsComplete: args.argumentsComplete,
560
+ ...(previous?.result ? { result: previous.result } : {}),
561
+ status: "streaming",
562
+ // Envelope identity stays pinned to the `tool_call` message that opened
563
+ // the row; the payload identity is `toolCallId`.
564
+ uuid: previous?.uuid ?? merge.uuid,
565
+ runId: merge.runId ?? previous?.runId,
566
+ seqId: maxDefined(merge.seqId, previous?.seqId),
567
+ };
568
+ next.status = toolStatus(next);
569
+ this.setRow(key, next);
570
+ return key;
571
+ }
572
+
573
+ private mergeToolResult(merge: ToolResultMerge): string {
574
+ const key = toolRowKey(merge.toolCallId);
575
+ const existing = this.byKey.get(key);
576
+ const previous =
577
+ existing && existing.kind === "tool_call" ? existing : undefined;
578
+
579
+ this.setRow(key, {
580
+ kind: "tool_call",
581
+ key,
582
+ toolCallId: merge.toolCallId,
583
+ toolName: previous?.toolName ?? "?",
584
+ toolInput: previous?.toolInput ?? {},
585
+ ...(previous?.rawArguments !== undefined
586
+ ? { rawArguments: previous.rawArguments }
587
+ : {}),
588
+ argumentsComplete: previous?.argumentsComplete ?? false,
589
+ result: {
590
+ content: merge.content,
591
+ isError: merge.isError,
592
+ // The result envelope is a different message than the call envelope,
593
+ // so it is recorded beside the row's `uuid`, not over it.
594
+ ...(merge.uuid ? { uuid: merge.uuid } : {}),
595
+ },
596
+ status: "complete",
597
+ uuid: previous?.uuid,
598
+ runId: merge.runId ?? previous?.runId,
599
+ seqId: maxDefined(merge.seqId, previous?.seqId),
600
+ });
601
+ return key;
602
+ }
603
+
604
+ // ── raw stream events ─────────────────────────────────────────
605
+
606
+ /**
607
+ * Compose with {@link extractStreamTextDelta} for payloads the session layer
608
+ * passes through uncooked.
609
+ *
610
+ * Identity comes from the payload when it has any (`id`, `otid`, `seq_id`,
611
+ * `run_id`). Content-block deltas carry none, so they fold into a single live
612
+ * row per family — the most an anonymous delta stream can support.
613
+ */
614
+ private applyStreamEvent(message: SDKStreamEventMessage): void {
615
+ const delta = extractStreamTextDelta(message.event);
616
+ if (!delta) return;
617
+ const payload = asRecord(message.event);
618
+ const otid = payload ? readString(payload, "otid") : undefined;
619
+ const payloadId = payload ? readString(payload, "id") : undefined;
620
+ const identified = otid !== undefined || payloadId !== undefined;
621
+
622
+ this.applyText({
623
+ kind: delta.kind,
624
+ text: delta.text,
625
+ uuid: identified ? payloadId : `${delta.kind}${SEP}live`,
626
+ otid,
627
+ runId: payload ? readString(payload, "run_id") : undefined,
628
+ seqId: payload ? readNumber(payload, "seq_id") : undefined,
629
+ });
630
+ }
631
+
632
+ // ── history backfill ──────────────────────────────────────────
633
+
634
+ private applyHistoryMessage(message: LettaMessage): string | undefined {
635
+ const record = asRecord(message);
636
+ if (!record) return undefined;
637
+ const messageType = readString(record, "message_type");
638
+ if (!messageType) return undefined;
639
+
640
+ const uuid = readString(record, "id");
641
+ const otid = readString(record, "otid");
642
+ const runId = readString(record, "run_id");
643
+ const seqId = readNumber(record, "seq_id");
644
+
645
+ // A history page proves every position up to its own cursor for that run,
646
+ // so replayed deltas at or below it are suppressed after the merge.
647
+ if (seqId !== undefined) {
648
+ this.rememberSeq(runId ?? ANONYMOUS_RUN, seqId);
649
+ }
650
+
651
+ switch (messageType) {
652
+ case "user_message":
653
+ case "assistant_message": {
654
+ const text = extractTextFromContent(record.content);
655
+ if (text === null) return undefined;
656
+ return this.writeText(
657
+ {
658
+ kind: messageType === "user_message" ? "user" : "assistant",
659
+ text,
660
+ uuid,
661
+ otid,
662
+ runId,
663
+ seqId,
664
+ },
665
+ "replace",
666
+ );
667
+ }
668
+ case "reasoning_message": {
669
+ const text =
670
+ typeof record.reasoning === "string"
671
+ ? record.reasoning
672
+ : extractTextFromContent(record.content);
673
+ if (text === null || text === undefined) return undefined;
674
+ return this.writeText(
675
+ { kind: "reasoning", text, uuid, otid, runId, seqId },
676
+ "replace",
677
+ );
678
+ }
679
+ case "tool_call_message":
680
+ case "approval_request_message": {
681
+ const toolCall = firstToolCall(record);
682
+ if (!toolCall) return undefined;
683
+ const fn = asRecord(toolCall.function);
684
+ const toolCallId =
685
+ (typeof toolCall.tool_call_id === "string"
686
+ ? toolCall.tool_call_id
687
+ : undefined) ??
688
+ (typeof toolCall.id === "string" ? toolCall.id : undefined);
689
+ if (!toolCallId) return undefined;
690
+ const args = toolCall.arguments ?? fn?.arguments;
691
+ return this.mergeToolCall({
692
+ toolCallId,
693
+ toolName:
694
+ (typeof toolCall.name === "string" ? toolCall.name : undefined) ??
695
+ (typeof fn?.name === "string" ? fn.name : undefined),
696
+ toolInput: asRecord(args),
697
+ rawArguments: typeof args === "string" ? args : undefined,
698
+ uuid,
699
+ runId,
700
+ seqId,
701
+ mode: "whole",
702
+ });
703
+ }
704
+ case "tool_return_message": {
705
+ const toolReturn = firstToolReturn(record) ?? record;
706
+ const toolCallId =
707
+ readString(record, "tool_call_id") ??
708
+ (typeof toolReturn.tool_call_id === "string"
709
+ ? toolReturn.tool_call_id
710
+ : undefined);
711
+ if (!toolCallId) return undefined;
712
+ const content =
713
+ extractTextFromContent(
714
+ record.tool_return ?? toolReturn.tool_return ?? toolReturn.content,
715
+ ) ?? "";
716
+ const status =
717
+ readString(record, "status") ??
718
+ (typeof toolReturn.status === "string"
719
+ ? toolReturn.status
720
+ : undefined);
721
+ return this.mergeToolResult({
722
+ toolCallId,
723
+ content,
724
+ isError: status === "error",
725
+ uuid,
726
+ runId,
727
+ seqId,
728
+ });
729
+ }
730
+ default:
731
+ // system/summary/event/hidden-reasoning/approval-response messages are
732
+ // not transcript content this accumulator claims to own.
733
+ return undefined;
734
+ }
735
+ }
736
+
737
+ // ── row bookkeeping ───────────────────────────────────────────
738
+
739
+ private setRow(key: string, row: TranscriptRow): void {
740
+ this.byKey.set(key, row);
741
+ this.snapshot = null;
742
+ }
743
+
744
+ /** Move backfilled rows ahead of rows that only exist in the live stream. */
745
+ private reorder(historyKeys: readonly string[]): void {
746
+ if (historyKeys.length === 0) return;
747
+ const historySet = new Set(historyKeys);
748
+ const reordered = new Map<string, TranscriptRow>();
749
+ for (const key of historyKeys) {
750
+ const row = this.byKey.get(key);
751
+ if (row) reordered.set(key, row);
752
+ }
753
+ for (const [key, row] of this.byKey) {
754
+ if (historySet.has(key)) continue;
755
+ reordered.set(key, row);
756
+ }
757
+ this.byKey = reordered;
758
+ this.snapshot = null;
759
+ }
760
+ }
761
+
762
+ function maxDefined(
763
+ next: number | undefined,
764
+ previous: number | undefined,
765
+ ): number | undefined {
766
+ if (next === undefined) return previous;
767
+ if (previous === undefined) return next;
768
+ return Math.max(next, previous);
769
+ }
770
+
771
+ function normalizeHistoryPage(
772
+ page: TranscriptHistoryPage,
773
+ order: "asc" | "desc" | undefined,
774
+ ): readonly LettaMessage[] {
775
+ const messages = Array.isArray(page)
776
+ ? (page as readonly LettaMessage[])
777
+ : ((page as { messages?: readonly LettaMessage[] }).messages ?? []);
778
+ if (messages.length < 2) return messages;
779
+ const descending = order ? order === "desc" : detectDescending(messages);
780
+ return descending ? [...messages].reverse() : messages;
781
+ }
782
+
783
+ /**
784
+ * `listMessages()` defaults to newest-first. Detect that from the page itself
785
+ * so callers do not have to restate the order they requested.
786
+ */
787
+ function detectDescending(messages: readonly LettaMessage[]): boolean {
788
+ const seqIds: number[] = [];
789
+ const dates: number[] = [];
790
+ for (const message of messages) {
791
+ const record = asRecord(message);
792
+ if (!record) continue;
793
+ const seqId = readNumber(record, "seq_id");
794
+ if (seqId !== undefined) seqIds.push(seqId);
795
+ const date = readString(record, "date");
796
+ if (!date) continue;
797
+ const parsed = Date.parse(date);
798
+ if (!Number.isNaN(parsed)) dates.push(parsed);
799
+ }
800
+ const ordered = seqIds.length >= 2 ? seqIds : dates;
801
+ if (ordered.length < 2) return false;
802
+ const first = ordered[0] as number;
803
+ const last = ordered[ordered.length - 1] as number;
804
+ return last < first;
805
+ }
806
+
807
+ /**
808
+ * Create a transcript accumulator.
809
+ *
810
+ * @example
811
+ * ```typescript
812
+ * const acc = createTranscriptAccumulator();
813
+ * for await (const message of session.stream()) {
814
+ * render(acc.apply(message));
815
+ * }
816
+ *
817
+ * // Safe mid-run: merges older history without duplicating live rows.
818
+ * acc.rebase(await session.listMessages({ limit: 50 }));
819
+ * ```
820
+ */
821
+ export function createTranscriptAccumulator(): TranscriptAccumulator {
822
+ return new TranscriptAccumulatorImpl();
823
+ }