@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.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/history-inputs.d.ts +24 -0
- package/dist/history-inputs.d.ts.map +1 -0
- package/dist/history-inputs.js +34 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/plan.d.ts +5 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +629 -0
- package/dist/policy.d.ts +110 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +75 -0
- package/dist/react/components.d.ts +87 -0
- package/dist/react/components.d.ts.map +1 -0
- package/dist/react/components.js +270 -0
- package/dist/react/guuey-chat.d.ts +84 -0
- package/dist/react/guuey-chat.d.ts.map +1 -0
- package/dist/react/guuey-chat.js +103 -0
- package/dist/react/markdown.d.ts +32 -0
- package/dist/react/markdown.d.ts.map +1 -0
- package/dist/react/markdown.js +40 -0
- package/dist/react/theme-css.d.ts +16 -0
- package/dist/react/theme-css.d.ts.map +1 -0
- package/dist/react/theme-css.js +37 -0
- package/dist/react/transcript.d.ts +42 -0
- package/dist/react/transcript.d.ts.map +1 -0
- package/dist/react/transcript.js +88 -0
- package/dist/react/use-transcript.d.ts +39 -0
- package/dist/react/use-transcript.d.ts.map +1 -0
- package/dist/react/use-transcript.js +201 -0
- package/dist/react.d.ts +21 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +20 -0
- package/dist/strings.d.ts +74 -0
- package/dist/strings.d.ts.map +1 -0
- package/dist/strings.js +45 -0
- package/dist/theme.d.ts +99 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +182 -0
- package/dist/types.d.ts +283 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +87 -0
- package/src/corpus/README.md +40 -0
- package/src/corpus/__snapshots__/corpus.test.ts.snap +1590 -0
- package/src/corpus/capture.ts +67 -0
- package/src/corpus/captures/issue2627-render-capture.coalesced.sse.txt +173 -0
- package/src/corpus/drive.ts +184 -0
- package/src/corpus/fixtures.ts +338 -0
- package/src/history-inputs.ts +48 -0
- package/src/index.ts +58 -0
- package/src/plan.ts +740 -0
- package/src/policy.ts +146 -0
- package/src/react/components.tsx +655 -0
- package/src/react/guuey-chat.tsx +227 -0
- package/src/react/markdown.tsx +114 -0
- package/src/react/theme-css.ts +50 -0
- package/src/react/transcript.tsx +187 -0
- package/src/react/use-transcript.ts +274 -0
- package/src/react.tsx +51 -0
- package/src/strings.ts +144 -0
- package/src/theme.ts +195 -0
- package/src/types.ts +320 -0
- 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
|
+
}
|