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