@guuey/chat 0.4.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/dist/history-inputs.d.ts +24 -0
  4. package/dist/history-inputs.d.ts.map +1 -0
  5. package/dist/history-inputs.js +34 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +17 -0
  9. package/dist/plan.d.ts +5 -0
  10. package/dist/plan.d.ts.map +1 -0
  11. package/dist/plan.js +629 -0
  12. package/dist/policy.d.ts +110 -0
  13. package/dist/policy.d.ts.map +1 -0
  14. package/dist/policy.js +75 -0
  15. package/dist/react/components.d.ts +87 -0
  16. package/dist/react/components.d.ts.map +1 -0
  17. package/dist/react/components.js +270 -0
  18. package/dist/react/guuey-chat.d.ts +84 -0
  19. package/dist/react/guuey-chat.d.ts.map +1 -0
  20. package/dist/react/guuey-chat.js +103 -0
  21. package/dist/react/markdown.d.ts +32 -0
  22. package/dist/react/markdown.d.ts.map +1 -0
  23. package/dist/react/markdown.js +40 -0
  24. package/dist/react/theme-css.d.ts +16 -0
  25. package/dist/react/theme-css.d.ts.map +1 -0
  26. package/dist/react/theme-css.js +37 -0
  27. package/dist/react/transcript.d.ts +42 -0
  28. package/dist/react/transcript.d.ts.map +1 -0
  29. package/dist/react/transcript.js +88 -0
  30. package/dist/react/use-transcript.d.ts +39 -0
  31. package/dist/react/use-transcript.d.ts.map +1 -0
  32. package/dist/react/use-transcript.js +201 -0
  33. package/dist/react.d.ts +21 -0
  34. package/dist/react.d.ts.map +1 -0
  35. package/dist/react.js +20 -0
  36. package/dist/strings.d.ts +74 -0
  37. package/dist/strings.d.ts.map +1 -0
  38. package/dist/strings.js +45 -0
  39. package/dist/theme.d.ts +99 -0
  40. package/dist/theme.d.ts.map +1 -0
  41. package/dist/theme.js +182 -0
  42. package/dist/types.d.ts +283 -0
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/types.js +1 -0
  45. package/package.json +87 -0
  46. package/src/corpus/README.md +40 -0
  47. package/src/corpus/__snapshots__/corpus.test.ts.snap +1590 -0
  48. package/src/corpus/capture.ts +67 -0
  49. package/src/corpus/captures/issue2627-render-capture.coalesced.sse.txt +173 -0
  50. package/src/corpus/drive.ts +184 -0
  51. package/src/corpus/fixtures.ts +338 -0
  52. package/src/history-inputs.ts +48 -0
  53. package/src/index.ts +58 -0
  54. package/src/plan.ts +740 -0
  55. package/src/policy.ts +146 -0
  56. package/src/react/components.tsx +655 -0
  57. package/src/react/guuey-chat.tsx +227 -0
  58. package/src/react/markdown.tsx +114 -0
  59. package/src/react/theme-css.ts +50 -0
  60. package/src/react/transcript.tsx +187 -0
  61. package/src/react/use-transcript.ts +274 -0
  62. package/src/react.tsx +51 -0
  63. package/src/strings.ts +144 -0
  64. package/src/theme.ts +195 -0
  65. package/src/types.ts +320 -0
  66. package/styles.css +514 -0
package/src/plan.ts ADDED
@@ -0,0 +1,740 @@
1
+ /**
2
+ * `planTranscript` — the headless view-model (wave-3a design §7, guuey#135).
3
+ *
4
+ * A PURE function: folded AgJSON + the flat status surface in, an ordered
5
+ * display list with stable keys out. No clocks (elapsed time is an input),
6
+ * no DOM, no React. Determinism contract: same inputs + policy + overrides
7
+ * ⇒ deeply equal plan — the fixture corpus asserts this literally.
8
+ *
9
+ * Source-ownership rules (spec §9 Change-1 — one unified plan owns both):
10
+ *
11
+ * - USER rows always come from the flat `inputs.messages`.
12
+ * - ASSISTANT content comes from the fold (`inputs.result`) when present;
13
+ * otherwise from the flat assistant entries plus the in-flight
14
+ * `assistantText`. Both paths normalize to the same block walk, so a
15
+ * silver stream carrying only text plans byte-identically to a bypass
16
+ * stream (fixture 5).
17
+ * - Interleaving is conversational alternation (user[i] then assistant[i]) —
18
+ * the transcript invariant of a request/reply chat. Finer-grained
19
+ * interleaving (true seq-ordering of history cards inside turns) needs
20
+ * read-plane sequence numbers the flat surface does not carry; the 3b
21
+ * assemblers own that refinement.
22
+ *
23
+ * Key scheme (stable across streaming updates): `u{slot}` user rows,
24
+ * `a{slot}.t{n}`/`.r{n}`/`.m{n}`/`.c{n}`/`.d{n}`/`.s{n}`/`.k{n}`/`.u{n}`
25
+ * per-kind ordinals inside an assistant slot, `tool.{toolCallId}` tool rows
26
+ * (the id survives `running → done` — spec §7), `view.{toolCallId}` mounts,
27
+ * `g.{firstToolKey}` derived groups, `card.{seq}` history cards, `p.{id}`
28
+ * prompts, `error`, `history` boundaries. Append-only streams only ever
29
+ * append ordinals, so every existing key survives each re-plan.
30
+ */
31
+ import type { AgBlock, AgReduceResult, JsonValue } from "@silverprotocol/core";
32
+ import { snapshotViewMount, toolResultViewMount, uiLocator, type ViewMount } from "@guuey/mcp-apps-host";
33
+ import type { TranscriptPolicy } from "./policy.js";
34
+ import type {
35
+ CitationsItem,
36
+ DataResultItem,
37
+ DisplayItem,
38
+ ItemKey,
39
+ StatusLineItem,
40
+ ToolGroupItem,
41
+ ToolItem,
42
+ TranscriptInputs,
43
+ TranscriptOverrides,
44
+ TranscriptPlan,
45
+ UnknownItem,
46
+ ViewMountItem,
47
+ } from "./types.js";
48
+
49
+ /** R5's giant threshold: above this byte count the state is `giant`. */
50
+ const GIANT_RESULT_BYTES = 16_384;
51
+
52
+ type ToolResultBlock = Extract<AgBlock, { type: "tool-result" }>;
53
+
54
+ /** Approximate byte size of a JSON value — deterministic, allocation-bounded. */
55
+ function jsonByteSize(value: unknown): number {
56
+ const text = JSON.stringify(value);
57
+ return text === undefined ? 0 : text.length;
58
+ }
59
+
60
+ /** Bounded (optionally pretty-printed) preview — never the full payload. */
61
+ function boundedPreview(value: unknown, previewChars: number, pretty = true): string | null {
62
+ const text = pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value);
63
+ if (text === undefined) return null;
64
+ return text.length > previewChars ? text.slice(0, previewChars) : text;
65
+ }
66
+
67
+ /**
68
+ * A JSON round-trip clone: any serializable value becomes a `JsonValue`
69
+ * without asserting a shape the source type does not promise. `JSON.parse`'s
70
+ * output IS JsonValue by construction — the assertion states that fact.
71
+ */
72
+ function jsonClone(value: unknown): JsonValue | null {
73
+ const text = JSON.stringify(value);
74
+ if (text === undefined) return null;
75
+ return JSON.parse(text) as JsonValue;
76
+ }
77
+
78
+ function resolveExpanded(
79
+ key: ItemKey,
80
+ policyDefault: boolean,
81
+ overrides: TranscriptOverrides,
82
+ ): boolean {
83
+ const override = overrides[key]?.expanded;
84
+ return override ?? policyDefault;
85
+ }
86
+
87
+ /** One assistant slot's renderable content, normalized across both sources. */
88
+ interface AssistantSource {
89
+ blocks: AgBlock[];
90
+ live: boolean;
91
+ stopped: boolean;
92
+ }
93
+
94
+ function foldAssistantSources(result: AgReduceResult, inFlight: boolean, aborted: boolean): AssistantSource[] {
95
+ // Real pod folds carry `tool-result` blocks in separate `role: "tool"`
96
+ // messages between assistant turns (the production ggui-render capture is
97
+ // the receipt — guuey#135 3b widget convergence found this): an
98
+ // assistant-only filter orphans every call and drops every mount. A tool
99
+ // message's blocks belong to the PRECEDING assistant slot's walk, exactly
100
+ // the whole-fold message order the retired first-party renderers used.
101
+ const sources: AssistantSource[] = [];
102
+ for (const m of result.messages) {
103
+ if (m.role === "assistant") {
104
+ sources.push({ blocks: [...m.content], live: false, stopped: false });
105
+ } else if (m.role === "tool" && sources.length > 0) {
106
+ sources[sources.length - 1]!.blocks.push(...m.content);
107
+ }
108
+ }
109
+ const last = sources[sources.length - 1];
110
+ if (last) {
111
+ last.live = inFlight;
112
+ last.stopped = aborted;
113
+ }
114
+ return sources;
115
+ }
116
+
117
+ function flatAssistantSources(inputs: TranscriptInputs, inFlight: boolean): AssistantSource[] {
118
+ const settled: AssistantSource[] = inputs.messages
119
+ .filter((m) => m.role === "assistant")
120
+ .map((m) => ({ blocks: [{ type: "text", text: m.text }], live: false, stopped: false }));
121
+ // The in-flight (or abort-kept) partial is its own trailing slot — settled
122
+ // turns live in `messages`; `assistantText` is ignored once `ready` again
123
+ // UNLESS the turn ended by abort (R1 aborted-partial keeps it).
124
+ if (inputs.assistantText !== "" && (inFlight || inputs.aborted === true)) {
125
+ settled.push({
126
+ blocks: [{ type: "text", text: inputs.assistantText }],
127
+ live: inFlight,
128
+ stopped: inputs.aborted === true,
129
+ });
130
+ }
131
+ return settled;
132
+ }
133
+
134
+ function dataResultFromToolResult(
135
+ block: ToolResultBlock,
136
+ key: ItemKey,
137
+ policy: TranscriptPolicy,
138
+ overrides: TranscriptOverrides,
139
+ ): DataResultItem {
140
+ const payload = block.structuredContent !== undefined ? block.structuredContent : undefined;
141
+ const textParts = block.content
142
+ .filter((b): b is Extract<AgBlock, { type: "text" }> => b.type === "text")
143
+ .map((b) => b.text)
144
+ .filter((t) => t !== "");
145
+ const mediaParts = block.content.filter(
146
+ (b) => b.type === "image" || b.type === "audio" || b.type === "file" || b.type === "document",
147
+ );
148
+
149
+ let state: DataResultItem["state"];
150
+ let preview: string | null;
151
+ let byteCount: number;
152
+ if (payload !== undefined) {
153
+ byteCount = jsonByteSize(payload);
154
+ preview = boundedPreview(payload, policy.dataResult.previewChars, policy.dataResult.prettyPrint);
155
+ state = byteCount > GIANT_RESULT_BYTES ? "giant" : "small";
156
+ } else if (textParts.length > 0) {
157
+ const joined = textParts.join("\n");
158
+ byteCount = joined.length;
159
+ preview =
160
+ joined.length > policy.dataResult.previewChars
161
+ ? joined.slice(0, policy.dataResult.previewChars)
162
+ : joined;
163
+ state = byteCount > GIANT_RESULT_BYTES ? "giant" : "small";
164
+ } else if (mediaParts.length > 0) {
165
+ byteCount = jsonByteSize(mediaParts);
166
+ preview = null;
167
+ state = "binary";
168
+ } else if (block.errorText !== undefined && block.errorText !== "") {
169
+ byteCount = block.errorText.length;
170
+ preview = block.errorText;
171
+ state = "small";
172
+ } else {
173
+ byteCount = 0;
174
+ preview = null;
175
+ state = "empty";
176
+ }
177
+
178
+ return {
179
+ kind: "data-result",
180
+ key,
181
+ expanded: resolveExpanded(key, true, overrides),
182
+ preview,
183
+ byteCount,
184
+ state,
185
+ showBytes: policy.dataResult.alwaysShowBytes || state === "giant",
186
+ };
187
+ }
188
+
189
+ function toolFailed(result: ToolResultBlock): boolean {
190
+ if (result.isError === true) return true;
191
+ const outcome = result.outcome;
192
+ // `input_required` is a pause (its ask surfaces through R10's hitl twin),
193
+ // not a failure — only error/denied read as ✕.
194
+ if (outcome === "error" || outcome === "denied") return true;
195
+ if (outcome === undefined) return result.errorText !== undefined && result.errorText !== "";
196
+ return false;
197
+ }
198
+
199
+ function viewLabel(
200
+ item: Pick<ViewMountItem, "phase" | "channel">,
201
+ policy: TranscriptPolicy,
202
+ ): string | null {
203
+ const s = policy.strings;
204
+ switch (item.phase) {
205
+ case "connected":
206
+ return null;
207
+ case "negotiating":
208
+ return s.viewNegotiating;
209
+ case "expired":
210
+ return s.viewExpired;
211
+ case "no-handshake":
212
+ // Channel-aware (R6): a ggui shell that never handshakes is a boot
213
+ // failure; inline tenant HTML may legitimately be a non-App document.
214
+ return item.channel === "ggui" ? s.viewBootFailure : s.viewInlineFallback;
215
+ }
216
+ }
217
+
218
+ function unknownFromValue(
219
+ key: ItemKey,
220
+ typeName: string,
221
+ value: unknown,
222
+ policy: TranscriptPolicy,
223
+ overrides: TranscriptOverrides,
224
+ ): UnknownItem {
225
+ return {
226
+ kind: "unknown",
227
+ key,
228
+ expanded: resolveExpanded(key, false, overrides),
229
+ label: policy.strings.unknownLabel,
230
+ typeName,
231
+ byteSize: jsonByteSize(value),
232
+ raw: policy.unknown.raw ? jsonClone(value) : null,
233
+ };
234
+ }
235
+
236
+ /** Walk one assistant slot's blocks into display items (matrix R1–R9, R14, R15). */
237
+ function planAssistantSource(
238
+ source: AssistantSource,
239
+ slot: number,
240
+ inputs: TranscriptInputs,
241
+ policy: TranscriptPolicy,
242
+ overrides: TranscriptOverrides,
243
+ ): DisplayItem[] {
244
+ const items: DisplayItem[] = [];
245
+ const prefix = `a${slot}`;
246
+ const ordinals = { t: 0, r: 0, m: 0, c: 0, d: 0, s: 0, k: 0, u: 0 };
247
+ const resultsById = new Map<string, ToolResultBlock>();
248
+ for (const block of source.blocks) {
249
+ if (block.type === "tool-result") resultsById.set(block.toolCallId, block);
250
+ }
251
+ const consumedResults = new Set<string>();
252
+
253
+ let citationRun: CitationsItem["sources"] = [];
254
+ const flushCitations = (): void => {
255
+ if (citationRun.length === 0) return;
256
+ const key = `${prefix}.s${ordinals.s++}`;
257
+ items.push({
258
+ kind: "citations",
259
+ key,
260
+ expanded: resolveExpanded(key, false, overrides),
261
+ label: policy.strings.citations(citationRun.length),
262
+ sources: citationRun,
263
+ style: policy.citations.style,
264
+ });
265
+ citationRun = [];
266
+ };
267
+
268
+ const streamingText = source.live && inputs.status === "responding";
269
+ const streamingReasoning = source.live && inputs.status === "thinking";
270
+ let lastTextKey: ItemKey | null = null;
271
+ let lastReasoningKey: ItemKey | null = null;
272
+
273
+ for (const block of source.blocks) {
274
+ if (block.type !== "search-result" && block.type !== "resource" && block.type !== "resource-link") {
275
+ flushCitations();
276
+ }
277
+ switch (block.type) {
278
+ case "text": {
279
+ if (block.text === "") break; // R1 empty-turn: no empty bubble.
280
+ const key = `${prefix}.t${ordinals.t++}`;
281
+ lastTextKey = key;
282
+ items.push({
283
+ kind: "text",
284
+ key,
285
+ expanded: resolveExpanded(key, true, overrides),
286
+ text: block.text,
287
+ markdown: policy.text.markdown,
288
+ streaming: false, // the LAST text item of a live slot flips below
289
+ stopped: false, // the abort marker lands on the last text item below
290
+ });
291
+ break;
292
+ }
293
+ case "reasoning": {
294
+ if (!policy.reasoning.show) break;
295
+ const text = block.text ?? "";
296
+ // Redacted/absent reasoning: no text and no opaque content → row omitted.
297
+ if (text === "" && block.opaque === undefined) break;
298
+ const key = `${prefix}.r${ordinals.r++}`;
299
+ lastReasoningKey = key;
300
+ items.push({
301
+ kind: "reasoning",
302
+ key,
303
+ expanded: resolveExpanded(key, policy.reasoning.expandedByDefault, overrides),
304
+ label: policy.strings.reasoningLabel,
305
+ text,
306
+ streaming: false, // the last reasoning item of a live slot flips below
307
+ });
308
+ break;
309
+ }
310
+ case "tool-call": {
311
+ const key = `tool.${block.toolCallId}`;
312
+ const result = resultsById.get(block.toolCallId);
313
+ if (result) consumedResults.add(block.toolCallId);
314
+ const mount: ViewMount | undefined = result ? toolResultViewMount(result) : undefined;
315
+ const failed = result !== undefined && toolFailed(result);
316
+ const state: ToolItem["state"] = result
317
+ ? failed
318
+ ? "failed"
319
+ : "done"
320
+ : source.live && inputs.aborted !== true
321
+ ? "running"
322
+ : "orphaned";
323
+ const tool: ToolItem = {
324
+ kind: "tool",
325
+ key,
326
+ expanded: resolveExpanded(key, policy.tool.expandByDefault, overrides),
327
+ toolCallId: block.toolCallId,
328
+ name: block.name,
329
+ title: policy.tool.humanizeTitle(block.title ?? block.name),
330
+ state,
331
+ argsPreview: policy.tool.argsVisible
332
+ ? boundedPreview(block.input, policy.dataResult.previewChars)
333
+ : null,
334
+ result:
335
+ result && mount === undefined
336
+ ? dataResultFromToolResult(result, `${key}.result`, policy, overrides)
337
+ : null,
338
+ // R4's display-bearing rule: in calm the call line folds into the
339
+ // view row's chrome as attribution; debug keeps the explicit line.
340
+ attribution: mount !== undefined && !policy.debugDetail,
341
+ };
342
+ items.push(tool);
343
+ if (mount !== undefined) {
344
+ const viewKey = `view.${block.toolCallId}`;
345
+ const view: ViewMountItem = {
346
+ kind: "view",
347
+ key: viewKey,
348
+ expanded: resolveExpanded(viewKey, true, overrides),
349
+ mount,
350
+ channel: mount.channel,
351
+ phase: inputs.viewPhases?.[viewKey] ?? "negotiating",
352
+ label: null,
353
+ attribution: policy.debugDetail ? null : policy.strings.viaTool(tool.title),
354
+ toolTitle: tool.title,
355
+ // `result` is narrowed by `mount !== undefined` above; the scope
356
+ // is the PERSISTED locator (`uiData.resourceUri`), never the
357
+ // mount payload's own uri (synthetic for a ggui shell).
358
+ actionScope: result ? (uiLocator(result.uiData) ?? null) : null,
359
+ };
360
+ view.label = viewLabel(view, policy);
361
+ items.push(view);
362
+ }
363
+ break;
364
+ }
365
+ case "tool-result": {
366
+ // Paired results were consumed by their call; an unpaired result is
367
+ // still rendered honestly as a standalone data row (R5's non-paired
368
+ // arm), never dropped.
369
+ if (consumedResults.has(block.toolCallId)) break;
370
+ if (resultsById.get(block.toolCallId) !== block) break; // duplicate id: first one owns
371
+ const hasCall = source.blocks.some(
372
+ (b) => b.type === "tool-call" && b.toolCallId === block.toolCallId,
373
+ );
374
+ if (hasCall) break; // its call renders it
375
+ const key = `${prefix}.d${ordinals.d++}`;
376
+ items.push(dataResultFromToolResult(block, key, policy, overrides));
377
+ break;
378
+ }
379
+ case "image":
380
+ case "audio":
381
+ case "file":
382
+ case "document": {
383
+ const key = `${prefix}.m${ordinals.m++}`;
384
+ const name =
385
+ block.type === "file"
386
+ ? (block.filename ?? null)
387
+ : block.type === "document"
388
+ ? (block.title ?? null)
389
+ : null;
390
+ items.push({
391
+ kind: "media",
392
+ key,
393
+ expanded: resolveExpanded(key, true, overrides),
394
+ media: block.type,
395
+ source: block.source,
396
+ name,
397
+ presentation:
398
+ policy.media.chipOnly || block.type === "file" || block.type === "document"
399
+ ? "chip"
400
+ : "inline",
401
+ });
402
+ break;
403
+ }
404
+ case "code": {
405
+ const key = `${prefix}.c${ordinals.c++}`;
406
+ items.push({
407
+ kind: "code",
408
+ key,
409
+ expanded: resolveExpanded(key, true, overrides),
410
+ language: block.language,
411
+ code: block.code,
412
+ wrap: policy.code.wrap,
413
+ });
414
+ break;
415
+ }
416
+ case "code-result":
417
+ case "data": {
418
+ // R5's standalone arms (no R3 pair to live inside).
419
+ const key = `${prefix}.d${ordinals.d++}`;
420
+ const payload = block.type === "data" ? block.data : block.output;
421
+ const byteCount = block.type === "data" ? jsonByteSize(payload) : block.output.length;
422
+ const preview =
423
+ block.type === "data"
424
+ ? boundedPreview(payload, policy.dataResult.previewChars)
425
+ : block.output.length > policy.dataResult.previewChars
426
+ ? block.output.slice(0, policy.dataResult.previewChars)
427
+ : block.output;
428
+ items.push({
429
+ kind: "data-result",
430
+ key,
431
+ expanded: resolveExpanded(key, true, overrides),
432
+ preview: byteCount === 0 ? null : preview,
433
+ byteCount,
434
+ state: byteCount === 0 ? "empty" : byteCount > GIANT_RESULT_BYTES ? "giant" : "small",
435
+ showBytes: policy.dataResult.alwaysShowBytes || byteCount > GIANT_RESULT_BYTES,
436
+ });
437
+ break;
438
+ }
439
+ case "search-result": {
440
+ citationRun.push({ title: block.title ?? null, url: block.url ?? null });
441
+ break;
442
+ }
443
+ case "resource": {
444
+ citationRun.push({ title: null, url: block.resource.uri ?? null });
445
+ break;
446
+ }
447
+ case "resource-link": {
448
+ citationRun.push({ title: null, url: block.uri });
449
+ break;
450
+ }
451
+ case "compaction": {
452
+ if (!policy.compaction.show) break;
453
+ const key = `${prefix}.k${ordinals.k++}`;
454
+ items.push({
455
+ kind: "compaction",
456
+ key,
457
+ expanded: resolveExpanded(key, true, overrides),
458
+ label: policy.strings.compaction,
459
+ });
460
+ break;
461
+ }
462
+ case "provider-raw": {
463
+ if (!policy.unknown.show) break;
464
+ items.push(
465
+ unknownFromValue(`${prefix}.u${ordinals.u++}`, `provider-raw:${block.vendor}`, block.raw, policy, overrides),
466
+ );
467
+ break;
468
+ }
469
+ default: {
470
+ // R15's trust invariant: a block type this version does not know (a
471
+ // future AgJSON addition reaching us through a lenient fold) renders
472
+ // as a LABELED row — never blank, never raw JSON in calm.
473
+ if (!policy.unknown.show) break;
474
+ const shape: { type: string } = block;
475
+ items.push(
476
+ unknownFromValue(`${prefix}.u${ordinals.u++}`, shape.type, shape, policy, overrides),
477
+ );
478
+ break;
479
+ }
480
+ }
481
+ }
482
+ flushCitations();
483
+
484
+ // Streaming + abort markers land on the slot's LAST text/reasoning item.
485
+ if (lastTextKey !== null) {
486
+ for (const item of items) {
487
+ if (item.key === lastTextKey && item.kind === "text") {
488
+ item.streaming = streamingText;
489
+ item.stopped = source.stopped;
490
+ }
491
+ }
492
+ }
493
+ if (lastReasoningKey !== null && streamingReasoning) {
494
+ for (const item of items) {
495
+ if (item.key === lastReasoningKey && item.kind === "reasoning") item.streaming = true;
496
+ }
497
+ }
498
+ return items;
499
+ }
500
+
501
+ /**
502
+ * R4's grouping pass — a VIEW-MODEL derivation, never wire: runs of
503
+ * adjacent SETTLED SILENT tool rows (done/failed, no display-bearing
504
+ * result) of at least the threshold collapse to one group row. A
505
+ * display-bearing result (its ViewMountItem sits between the tool rows)
506
+ * breaks adjacency at its sequence position by construction; the active
507
+ * tool is never absorbed (its state is `running`, not settled).
508
+ */
509
+ function groupTools(
510
+ items: DisplayItem[],
511
+ policy: TranscriptPolicy,
512
+ overrides: TranscriptOverrides,
513
+ ): DisplayItem[] {
514
+ const threshold = policy.toolGroup.threshold;
515
+ if (threshold === false) return items;
516
+ const out: DisplayItem[] = [];
517
+ let run: ToolItem[] = [];
518
+
519
+ const flush = (): void => {
520
+ if (run.length >= threshold) {
521
+ const key = `g.${run[0].key}`;
522
+ const failureCount = run.filter((t) => t.state === "failed").length;
523
+ const group: ToolGroupItem = {
524
+ kind: "tool-group",
525
+ key,
526
+ expanded: resolveExpanded(key, false, overrides),
527
+ label: policy.strings.toolGroup(run.length),
528
+ tools: run,
529
+ failureCount,
530
+ failureBadge: failureCount > 0 ? policy.strings.toolGroupFailures(failureCount) : null,
531
+ };
532
+ out.push(group);
533
+ } else {
534
+ out.push(...run);
535
+ }
536
+ run = [];
537
+ };
538
+
539
+ for (const item of items) {
540
+ // Silent = the row's entire output lives inside its own expansion (R5
541
+ // data, however large). Display-bearing calls carry `attribution` and
542
+ // their ViewMountItem already sits between tool rows, breaking the run.
543
+ if (item.kind === "tool" && (item.state === "done" || item.state === "failed") && !item.attribution) {
544
+ run.push(item);
545
+ } else {
546
+ flush();
547
+ out.push(item);
548
+ }
549
+ }
550
+ flush();
551
+ return out;
552
+ }
553
+
554
+ /** §4's status derivation — thresholds and copy from policy, elapsed as input. */
555
+ function deriveStatus(inputs: TranscriptInputs, policy: TranscriptPolicy): StatusLineItem | null {
556
+ const s = policy.strings;
557
+ const detail = policy.debugDetail ? `${inputs.status} · ${inputs.statusElapsedMs} ms` : null;
558
+ if (inputs.aborted === true) {
559
+ return { kind: "status", key: "status", state: "aborted", copy: s.stopped, detail };
560
+ }
561
+ switch (inputs.status) {
562
+ case "ready":
563
+ case "responding":
564
+ return null; // streaming text is its own indicator; idle copy is 3c's composer.
565
+ case "connecting": {
566
+ const state =
567
+ inputs.statusElapsedMs >= policy.status.longStartMs
568
+ ? "long-start"
569
+ : inputs.statusElapsedMs >= policy.status.wakingMs
570
+ ? "starting"
571
+ : "connecting";
572
+ const copy =
573
+ state === "long-start" ? s.longStart : state === "starting" ? s.starting : s.connecting;
574
+ return { kind: "status", key: "status", state, copy, detail };
575
+ }
576
+ case "thinking":
577
+ return { kind: "status", key: "status", state: "thinking", copy: s.thinking, detail };
578
+ case "using-tool": {
579
+ const title = policy.tool.humanizeTitle(inputs.activeTool ?? "");
580
+ return { kind: "status", key: "status", state: "using-tool", copy: s.usingTool(title), detail };
581
+ }
582
+ }
583
+ }
584
+
585
+ const ERROR_FAMILIES: Record<string, "auth" | "quota" | "invalid"> = {
586
+ UNAUTHORIZED: "auth",
587
+ AUTH_REQUIRED: "auth",
588
+ GUEST_ACCESS_DISABLED: "auth",
589
+ FORBIDDEN: "auth",
590
+ QUOTA_EXCEEDED: "quota",
591
+ MANAGED_SPEND_CAP: "quota",
592
+ INVALID_REQUEST: "invalid",
593
+ };
594
+
595
+ /** The one pure function (spec §7). */
596
+ export function planTranscript(
597
+ inputs: TranscriptInputs,
598
+ policy: TranscriptPolicy,
599
+ overrides: TranscriptOverrides = {},
600
+ ): TranscriptPlan {
601
+ const items: DisplayItem[] = [];
602
+
603
+ // R13 boundary states precede everything.
604
+ if (inputs.historyState === "loading") {
605
+ items.push({
606
+ kind: "history-boundary",
607
+ key: "history",
608
+ expanded: true,
609
+ state: "loading",
610
+ label: policy.strings.historyLoading,
611
+ });
612
+ } else if (inputs.historyState === "gone") {
613
+ items.push({
614
+ kind: "history-boundary",
615
+ key: "history",
616
+ expanded: true,
617
+ state: "gone",
618
+ label: policy.strings.threadGone,
619
+ });
620
+ }
621
+
622
+ const inFlight = inputs.status !== "ready";
623
+ const users = inputs.messages.filter((m) => m.role === "user");
624
+ const assistants = inputs.result
625
+ ? foldAssistantSources(inputs.result, inFlight, inputs.aborted === true)
626
+ : flatAssistantSources(inputs, inFlight);
627
+
628
+ const slots = Math.max(users.length, assistants.length);
629
+ const conversation: DisplayItem[] = [];
630
+ for (let slot = 0; slot < slots; slot++) {
631
+ const user = users[slot];
632
+ if (user) {
633
+ const key = `u${slot}`;
634
+ const sendState =
635
+ user.clientMessageId !== undefined
636
+ ? (inputs.sendStates?.[user.clientMessageId] ?? "sent")
637
+ : "sent";
638
+ conversation.push({
639
+ kind: "user",
640
+ key,
641
+ expanded: true,
642
+ text: user.text,
643
+ state: sendState,
644
+ retry: sendState === "failed" && policy.userMessage.retryAffordance,
645
+ });
646
+ }
647
+ const assistant = assistants[slot];
648
+ if (assistant) {
649
+ conversation.push(...planAssistantSource(assistant, slot, inputs, policy, overrides));
650
+ }
651
+ }
652
+ items.push(...groupTools(conversation, policy, overrides));
653
+
654
+ // R13 — persisted cards, seq order. (Position: after the settled
655
+ // conversation; true in-turn interleave needs read-plane seqs the flat
656
+ // surface lacks — the 3b assemblers own that refinement.)
657
+ const cards = [...(inputs.historyCards ?? [])].sort((a, b) => a.seq - b.seq);
658
+ for (const card of cards) {
659
+ const key = `card.${card.seq}`;
660
+ const mount = snapshotViewMount(card.cardSnapshot);
661
+ const view: ViewMountItem = {
662
+ kind: "view",
663
+ key,
664
+ expanded: resolveExpanded(key, true, overrides),
665
+ mount: mount ?? null,
666
+ channel: mount?.channel ?? null,
667
+ phase: mount === undefined ? "expired" : (inputs.viewPhases?.[key] ?? "negotiating"),
668
+ label: null,
669
+ attribution: null,
670
+ toolTitle: null,
671
+ actionScope:
672
+ mount === undefined
673
+ ? null
674
+ : mount.channel === "locator"
675
+ ? mount.resourceUri
676
+ : mount.resource.uri,
677
+ };
678
+ view.label = viewLabel(view, policy);
679
+ items.push(view);
680
+ }
681
+
682
+ // R10 — prompts, in input order.
683
+ for (const prompt of inputs.prompts) {
684
+ const key = `p.${prompt.id}`;
685
+ items.push({
686
+ kind: "prompt",
687
+ key,
688
+ promptId: prompt.id,
689
+ expanded: resolveExpanded(key, prompt.state === "pending", overrides),
690
+ promptKind: prompt.kind,
691
+ appId: prompt.appId,
692
+ requested: prompt.requested,
693
+ state: prompt.state,
694
+ raw: policy.prompt.rawPayload
695
+ ? { id: prompt.id, kind: prompt.kind, appId: prompt.appId, requested: prompt.requested, state: prompt.state }
696
+ : null,
697
+ });
698
+ }
699
+
700
+ // R11 — the coded error notice, always last.
701
+ if (inputs.error) {
702
+ const code = inputs.error.code;
703
+ const family = (code !== null ? ERROR_FAMILIES[code] : undefined) ?? "transient";
704
+ const familyCopy =
705
+ family === "auth"
706
+ ? policy.strings.errorAuth
707
+ : family === "quota"
708
+ ? policy.strings.errorQuota
709
+ : family === "invalid"
710
+ ? policy.strings.errorInvalid
711
+ : policy.strings.errorTransient;
712
+ // Voice resolution (R11's per-code knob): an exact per-code sentence
713
+ // wins; then a verbatim match renders the SOURCE message ("all" covers
714
+ // code-less client errors too — the widget's #162 posture); an empty
715
+ // source message falls back to family copy rather than a blank notice.
716
+ const { copyByCode, verbatimCodes } = policy.error;
717
+ const perCode = code !== null ? copyByCode[code] : undefined;
718
+ const verbatimVoice =
719
+ verbatimCodes === "all" || (code !== null && verbatimCodes.includes(code));
720
+ const copy =
721
+ perCode ?? (verbatimVoice && inputs.error.message !== "" ? inputs.error.message : familyCopy);
722
+ items.push({
723
+ kind: "error",
724
+ key: "error",
725
+ expanded: true,
726
+ family,
727
+ code,
728
+ copy,
729
+ message: inputs.error.message,
730
+ verbatim: policy.error.verbatim ? `${code ?? "uncoded"}: ${inputs.error.message}` : null,
731
+ });
732
+ }
733
+
734
+ return {
735
+ items,
736
+ status: deriveStatus(inputs, policy),
737
+ recovery:
738
+ inputs.adopted === true && policy.debugDetail ? policy.strings.recoveredFromHistory : null,
739
+ };
740
+ }