@genesislcap/ai-assistant 15.25.1 → 15.27.0-agent-log-submit.3

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 (52) hide show
  1. package/dist/ai-assistant.api.json +1014 -110
  2. package/dist/ai-assistant.d.ts +236 -99
  3. package/dist/chat-driver.cjs +460 -373
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +460 -373
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +1171 -248
  8. package/dist/dts/components/chat-driver/chat-driver.d.ts +28 -200
  9. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/interaction-controller.d.ts +99 -0
  11. package/dist/dts/components/chat-driver/interaction-controller.d.ts.map +1 -0
  12. package/dist/dts/components/chat-driver/turn-recorder.d.ts +191 -0
  13. package/dist/dts/components/chat-driver/turn-recorder.d.ts.map +1 -0
  14. package/dist/dts/components/settings-modal/settings-modal.d.ts.map +1 -1
  15. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  16. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +28 -0
  17. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  18. package/dist/dts/index.d.ts +1 -0
  19. package/dist/dts/index.d.ts.map +1 -1
  20. package/dist/dts/main/main.d.ts +180 -1
  21. package/dist/dts/main/main.d.ts.map +1 -1
  22. package/dist/dts/main/main.styles.d.ts.map +1 -1
  23. package/dist/dts/main/main.template.d.ts +19 -0
  24. package/dist/dts/main/main.template.d.ts.map +1 -1
  25. package/dist/dts/state/ai-assistant-slice.d.ts +7 -1
  26. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  27. package/dist/dts/state/persistence/session-persistence-provider.d.ts +5 -0
  28. package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
  29. package/dist/dts/state/session-store.d.ts +1 -0
  30. package/dist/dts/state/session-store.d.ts.map +1 -1
  31. package/dist/dts/utils/format-message-timestamp.d.ts +77 -0
  32. package/dist/dts/utils/format-message-timestamp.d.ts.map +1 -0
  33. package/dist/dts/utils/resolve-debug-log-note.d.ts +25 -0
  34. package/dist/dts/utils/resolve-debug-log-note.d.ts.map +1 -0
  35. package/dist/dts/utils/resolve-preference-baseline.d.ts +18 -1
  36. package/dist/dts/utils/resolve-preference-baseline.d.ts.map +1 -1
  37. package/dist/esm/components/chat-driver/chat-driver.js +68 -404
  38. package/dist/esm/components/chat-driver/interaction-controller.js +235 -0
  39. package/dist/esm/components/chat-driver/turn-recorder.js +206 -0
  40. package/dist/esm/components/settings-modal/settings-modal.js +1 -0
  41. package/dist/esm/components/settings-modal/settings-modal.styles.js +85 -5
  42. package/dist/esm/components/settings-modal/settings-modal.template.js +220 -25
  43. package/dist/esm/main/main.js +510 -23
  44. package/dist/esm/main/main.styles.js +75 -0
  45. package/dist/esm/main/main.template.js +43 -2
  46. package/dist/esm/state/ai-assistant-slice.js +5 -0
  47. package/dist/esm/utils/format-message-timestamp.js +126 -0
  48. package/dist/esm/utils/resolve-debug-log-note.js +37 -0
  49. package/dist/esm/utils/resolve-preference-baseline.js +54 -2
  50. package/package.json +16 -16
  51. package/sandbox/README.md +45 -2
  52. package/sandbox/sandbox.ts +95 -3
@@ -0,0 +1,235 @@
1
+ import { recordMetaEvent } from '../../state/debug-event-log';
2
+ import { createInteractionContext, } from '../../state/interaction-context';
3
+ import { logger } from '../../utils/logger';
4
+ /**
5
+ * Custom UI interactions: parking a tool handler on a widget, and settling it when the user (or a
6
+ * timeout) answers.
7
+ *
8
+ * Owns the pending call and its live context, which are created and destroyed together. Everything
9
+ * else it needs is the driver's history and event target, reached through `InteractionDeps`.
10
+ */
11
+ export class InteractionController {
12
+ constructor(deps) {
13
+ this.deps = deps;
14
+ this.pending = new Map();
15
+ /**
16
+ * Live per-interaction contexts (GENC-1390), keyed by interactionId. Created in `request`
17
+ * alongside the pending entry, disposed + removed in `resolve`. Non-serialisable (sockets,
18
+ * timers, subscriber sets), so they live here — not on the DOM, not in the persisted store — and
19
+ * therefore survive the element remounts (layout reflow/drag, transcript re-render) that this
20
+ * feature exists to make harmless. At most one is live at a time (see the
21
+ * one-interaction-at-a-time guard in `request`).
22
+ */
23
+ this.contexts = new Map();
24
+ }
25
+ /** See `ChatDriver.setHostInteractionRequester`. */
26
+ setHostRequester(fn) {
27
+ this.hostRequester = fn;
28
+ }
29
+ /** See `ChatDriver.requestInteraction`. */
30
+ request(componentName, data, options) {
31
+ if (this.hostRequester) {
32
+ return this.hostRequester(componentName, data, options);
33
+ }
34
+ if (this.pending.size > 0) {
35
+ throw new Error('requestInteraction: another user interaction is already in flight. ' +
36
+ 'Only one interaction may be active at a time — sequence them in a single tool handler ' +
37
+ 'rather than spawning widgets from parallel sub-agents or parallel tool calls.');
38
+ }
39
+ const interactionId = crypto.randomUUID();
40
+ const chatInputDuringExecution = options === null || options === void 0 ? void 0 : options.chatInputDuringExecution;
41
+ const timeoutMs = options === null || options === void 0 ? void 0 : options.timeoutMs;
42
+ const presentation = options === null || options === void 0 ? void 0 : options.presentation;
43
+ return new Promise((resolve, reject) => {
44
+ this.pending.set(interactionId, {
45
+ resolve,
46
+ reject,
47
+ overrideId: chatInputDuringExecution ? interactionId : undefined,
48
+ // Resolve the interaction with a timeout result if the user does not
49
+ // respond in time. `resolve` runs the same teardown a normal resolution
50
+ // does (marks the widget read-only, reverts any input override) and
51
+ // clears any still-pending handle.
52
+ timeoutHandle: timeoutMs != null
53
+ ? setTimeout(() => {
54
+ this.resolve(interactionId, {
55
+ status: 'timeout',
56
+ message: `Interaction timed out after ${timeoutMs}ms.`,
57
+ });
58
+ }, timeoutMs)
59
+ : undefined,
60
+ });
61
+ recordMetaEvent(this.deps.sessionKey, 'interaction.requested', {
62
+ interactionId,
63
+ component: componentName,
64
+ agent: this.deps.activeAgentName(),
65
+ });
66
+ // Signal the park boundary on the activity bus so turn-aware UI can tell
67
+ // "actively computing" from "parked awaiting the user" — the latter is a
68
+ // safe window for actions disallowed mid-request (e.g. switching provider
69
+ // during a long journey step). Paired with `interaction-resolved`.
70
+ this.deps.activityBus.publish('interaction-requested', undefined);
71
+ if (chatInputDuringExecution) {
72
+ this.deps.emit(new CustomEvent('interaction-start', {
73
+ detail: { interactionId, chatInputDuringExecution },
74
+ }));
75
+ }
76
+ // Create the live context (GENC-1390) BEFORE the widget can mount, so the
77
+ // wrapper finds it when it builds the pending `interaction` descriptor.
78
+ this.contexts.set(interactionId, createInteractionContext(interactionId));
79
+ this.deps.appendToHistory({
80
+ role: 'assistant',
81
+ content: '',
82
+ interaction: Object.assign({ interactionId,
83
+ componentName,
84
+ data }, (presentation ? { presentation } : {})),
85
+ });
86
+ });
87
+ }
88
+ /**
89
+ * See `ChatDriver.resolveInteraction`. Warns and returns when the id is unknown — a widget can
90
+ * report twice, and the second report has nothing left to settle.
91
+ */
92
+ resolve(interactionId, result) {
93
+ var _a;
94
+ const interaction = this.pending.get(interactionId);
95
+ if (!interaction) {
96
+ logger.warn(`Interaction with ID ${interactionId} not found.`);
97
+ return;
98
+ }
99
+ // Clear the timeout so a user resolution doesn't later fire a stale
100
+ // timeout (and a timeout firing here clears its own already-fired handle).
101
+ if (interaction.timeoutHandle)
102
+ clearTimeout(interaction.timeoutHandle);
103
+ // Harvest any out-of-band diagnostic entries the widget collated (e.g. from a server-side
104
+ // ChatDriver) so the host's debug log + persisted diagnostics include them. Independent of the
105
+ // message patched below — they describe the widget's own work, not this message.
106
+ this.deps.foldDiagnostics(interactionId, result === null || result === void 0 ? void 0 : result.diagnostics);
107
+ // Fold any widget-reported external (non-LLM) cost onto the message, so
108
+ // the host's `sumCosts` recompute includes it in the session total.
109
+ // Accumulates defensively in case a widget reports more than once.
110
+ const costUsd = typeof (result === null || result === void 0 ? void 0 : result.costUsd) === 'number' && Number.isFinite(result.costUsd) && result.costUsd > 0
111
+ ? result.costUsd
112
+ : undefined;
113
+ const patched = this.deps.patchInteractionMessage(interactionId, (message) => {
114
+ var _a;
115
+ return (Object.assign(Object.assign(Object.assign({}, message), { interaction: Object.assign(Object.assign({}, message.interaction), { resolved: result }) }), (costUsd != null ? { externalCostUsd: ((_a = message.externalCostUsd) !== null && _a !== void 0 ? _a : 0) + costUsd } : {})));
116
+ });
117
+ if (patched)
118
+ this.deps.notifyHistoryUpdated();
119
+ // A widget may opt into echoing its outcome on the user's side of the
120
+ // chat by returning `userMessage`. Appended as a display-only
121
+ // `'synthetic-user'` message: it is tracked in history (and the debug log)
122
+ // and rendered like a user message, but transports never serialize it —
123
+ // the model already learns the outcome from this handler's return value.
124
+ // Appended before `interaction.resolve` so it precedes the tool result and
125
+ // any follow-up turn the resumed handler produces.
126
+ if (typeof (result === null || result === void 0 ? void 0 : result.userMessage) === 'string' && result.userMessage.trim()) {
127
+ this.deps.appendToHistory({ role: 'synthetic-user', content: result.userMessage });
128
+ }
129
+ if (interaction.overrideId) {
130
+ this.deps.emit(new CustomEvent('interaction-stop', { detail: { interactionId } }));
131
+ }
132
+ recordMetaEvent(this.deps.sessionKey, 'interaction.resolved', { interactionId });
133
+ // The park is ending and the loop is about to resume computing — paired
134
+ // with `interaction-requested`. Fires for every resolution path (user
135
+ // completion, timeout, cancellation), since all route through here.
136
+ this.deps.activityBus.publish('interaction-resolved', undefined);
137
+ interaction.resolve(result);
138
+ this.pending.delete(interactionId);
139
+ // Tear down the live context on RESOLVE (not on element unmount) — this closes
140
+ // any sockets/timers the widget parked on it via `onDispose`. (GENC-1390)
141
+ (_a = this.contexts.get(interactionId)) === null || _a === void 0 ? void 0 : _a.dispose();
142
+ this.contexts.delete(interactionId);
143
+ }
144
+ /**
145
+ * See `ChatDriver.recordExternalCost`.
146
+ *
147
+ * **Why this exists separately from `resolve`.** A widget's spend reaches the session total only
148
+ * through `InteractionResult.costUsd`, which is read exactly once — `resolve` does its whole job
149
+ * against a live pending entry and then deletes it. That is correct for the resolution itself,
150
+ * but it assumes a widget stops costing money when it resolves, and several deliberately do not:
151
+ * a widget that resolves EARLY so the assistant stops waiting (options shown, a preview
152
+ * rendered) may stay interactive for minutes afterwards and do more paid work on the user's
153
+ * behalf. Re-emitting `interaction-completed` does not help — with the interaction gone from the
154
+ * map the call is a silent no-op — so that spend had nowhere to go and was simply missing from
155
+ * the figure shown to the user.
156
+ *
157
+ * Touches only `externalCostUsd`: the recorded `interaction.resolved` payload is left exactly as
158
+ * the resolution wrote it, because this is not a second resolution and must not read like one.
159
+ */
160
+ recordExternalCost(interactionId, costUsd) {
161
+ const drop = (reason) => {
162
+ recordMetaEvent(this.deps.sessionKey, 'interaction.external-cost-dropped', {
163
+ interactionId,
164
+ costUsd,
165
+ reason,
166
+ });
167
+ return false;
168
+ };
169
+ if (!interactionId)
170
+ return drop('no interactionId');
171
+ if (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd <= 0) {
172
+ return drop('not a positive finite amount');
173
+ }
174
+ const patched = this.deps.patchInteractionMessage(interactionId, (message) => {
175
+ var _a;
176
+ return (Object.assign(Object.assign({}, message), { externalCostUsd: ((_a = message.externalCostUsd) !== null && _a !== void 0 ? _a : 0) + costUsd }));
177
+ });
178
+ // The message is gone (trimmed or condensed): the spend is real and already billed, so it is
179
+ // recorded as dropped rather than silently discarded — an invisible gap in the session total
180
+ // is exactly what this whole channel exists to close.
181
+ if (!patched)
182
+ return drop('interaction message no longer in history');
183
+ recordMetaEvent(this.deps.sessionKey, 'interaction.external-cost', { interactionId, costUsd });
184
+ // A NARROW event, deliberately not `history-updated`.
185
+ //
186
+ // The host answers `history-updated` by reassigning `messages`, which re-evaluates
187
+ // `activeInteractionRow` — and the trailing interaction slot renders through a
188
+ // `repeat(..., { recycle: false })`, so every view in it is disposed and rebuilt regardless of
189
+ // item identity. That is correct for a PENDING interaction (GENC-1429: a recycled wrapper
190
+ // carries the previous interaction's truthy `resolved` and swallows the next completion), and
191
+ // survivable because `buildInteraction` hands a remounted pending widget the same driver-owned
192
+ // `interactionContext`.
193
+ //
194
+ // A RESOLVED one has no such context — `resolve` disposed and deleted it — so announcing this
195
+ // on `history-updated` would tear down and blank the very widget that just reported, in
196
+ // exactly the flow this channel exists for (a user tweaking an option after the interaction
197
+ // resolved). Bookkeeping about a finished turn must stay invisible, so the host recomputes its
198
+ // totals from this driver's history and saves, without touching what is rendered. Reviewer
199
+ // catch on #2474 — confirmed against the layout widget, whose tweak result went off screen
200
+ // because the rebuild raced the session write that would have restored it.
201
+ this.deps.emit(new CustomEvent('external-cost-recorded', {
202
+ detail: { interactionId, costUsd },
203
+ }));
204
+ return true;
205
+ }
206
+ /** See `ChatDriver.getInteractionContext`. */
207
+ context(interactionId) {
208
+ var _a;
209
+ return (_a = this.contexts.get(interactionId)) === null || _a === void 0 ? void 0 : _a.context;
210
+ }
211
+ /**
212
+ * Resolve every in-flight interaction with a `'timeout'` result, closing its
213
+ * widget read-only. Used when a sub-agent that owns the open widget times out:
214
+ * the widget is parked in *this* driver's map (interactions resolve on the
215
+ * host, not the child), so cancelling it here unblocks the sub-agent handler
216
+ * awaiting it. The "one interaction at a time" invariant means there is at
217
+ * most one.
218
+ */
219
+ cancelAll(message) {
220
+ // Snapshot the keys: `resolve` deletes from the map it would otherwise be iterating live.
221
+ for (const interactionId of [...this.pending.keys()]) {
222
+ this.resolve(interactionId, { status: 'timeout', message });
223
+ }
224
+ }
225
+ /**
226
+ * Drop any live interaction contexts on driver teardown. Normally empty (a context is created
227
+ * and destroyed with its pending interaction, and dispose is not reached mid-interaction);
228
+ * disposed defensively so a stray context can never leak an open socket/timer.
229
+ */
230
+ disposeContexts() {
231
+ for (const handle of this.contexts.values())
232
+ handle.dispose();
233
+ this.contexts.clear();
234
+ }
235
+ }
@@ -0,0 +1,206 @@
1
+ import { __rest } from "tslib";
2
+ import { mergeMetaEvents, recordMetaEvent } from '../../state/debug-event-log';
3
+ /**
4
+ * Everything the exported debug log draws from one driver: its own per-LLM-call snapshots, the
5
+ * ones forwarded up from sub-agents, and the diagnostics harvested from resolved interactions.
6
+ *
7
+ * All three feed the same timeline and all three are keyed in this driver's turn space, which is
8
+ * why they share an owner — a forwarded child turn and a harvested server turn are both re-labelled
9
+ * against the counter kept here.
10
+ */
11
+ export class TurnRecorder {
12
+ constructor(sessionKey, maxSnapshots) {
13
+ this.sessionKey = sessionKey;
14
+ this.maxSnapshots = maxSnapshots;
15
+ /**
16
+ * Ring buffer of per-LLM-call snapshots, capped at `maxSnapshots`; older entries drop off as
17
+ * new ones arrive.
18
+ */
19
+ this.buffer = [];
20
+ /** Monotonic counter that survives agent swaps — useful for cross-referencing with history. */
21
+ this.turnIndex = 0;
22
+ /**
23
+ * `turn` + `message` diagnostic entries harvested from an out-of-band source. `event` entries
24
+ * are NOT kept here — they are folded into the session's meta-event registry so they inherit
25
+ * fresh host indices, and reach the log through `getMetaEvents`. Append-only, and never cleared:
26
+ * it lives with the recorder, so a new chat (fresh driver) starts empty while a compaction (same
27
+ * instance) keeps it. (GENC-1461.)
28
+ */
29
+ this.externalEntries = [];
30
+ /**
31
+ * Monotonic count of external-diagnostics batches folded this driver-lifetime. Namespaces each
32
+ * batch's re-keyed `turn` entries so distinct server generations in one chat session can't
33
+ * collide on `turnIndex` in the forward-capture persister (which dedups turns by that key).
34
+ */
35
+ this.externalBatches = 0;
36
+ }
37
+ /** Point at the incoming agent's `getDebugSnapshot`, or clear it when the agent supplies none. */
38
+ setSnapshotter(snapshotter) {
39
+ this.snapshotter = snapshotter;
40
+ }
41
+ /**
42
+ * The per-turn snapshots captured so far — what the LLM saw on each turn: system prompt, tool
43
+ * surface, and agent-supplied state (e.g. a machine snapshot).
44
+ */
45
+ snapshots() {
46
+ return this.buffer;
47
+ }
48
+ /**
49
+ * The harvested `turn` + `message` diagnostic entries. Opaque, and chronologically sorted by the
50
+ * host at assemble time.
51
+ */
52
+ externalDiagnostics() {
53
+ return this.externalEntries;
54
+ }
55
+ /**
56
+ * Push one snapshot to the ring buffer. Called inside `runToolLoop` just
57
+ * before each LLM call — that's the latest point where the prompt, tool
58
+ * surface, and agent state line up with what the model is about to see.
59
+ *
60
+ * Returns the pushed object so the caller can back-fill what only the response
61
+ * knows (`usage`). Mutating it after the fact is safe whether or not the ring
62
+ * buffer has since evicted it — an evicted snapshot is simply no longer exported.
63
+ */
64
+ record(turn) {
65
+ let agentSnapshot;
66
+ if (this.snapshotter) {
67
+ try {
68
+ agentSnapshot = this.snapshotter();
69
+ }
70
+ catch (e) {
71
+ // A snapshotter throwing must not derail the LLM call — capture the
72
+ // error string in place of the snapshot so the export still shows
73
+ // *something* happened.
74
+ agentSnapshot = `<getDebugSnapshot threw: ${e instanceof Error ? e.message : String(e)}>`;
75
+ }
76
+ }
77
+ const turnIndex = String(this.turnIndex);
78
+ this.turnIndex += 1;
79
+ const snapshot = Object.assign(Object.assign({}, turn), { turnIndex, timestamp: new Date().toISOString(), agentSnapshot });
80
+ this.buffer.push(snapshot);
81
+ this.trim();
82
+ return snapshot;
83
+ }
84
+ /**
85
+ * Merge a sub-agent's turn snapshots into this driver's buffer so they surface
86
+ * as `kind:'turn'` entries in the exported debug log. The child runs as a
87
+ * separate, discarded driver, so its snapshots would otherwise be lost. Each is
88
+ * re-labelled under the parent turn that activated the sub-agent: the child's
89
+ * own (numeric) turns become `"<parentTurn>-1"`, `"-2"`, … (1-based, in order);
90
+ * any already-forwarded grand-child labels (strings) have their leading segment
91
+ * remapped the same way, so nesting composes (`"5-2"` → `"5-2-1"`).
92
+ *
93
+ * Note: two sub-agents invoked in the *same* parent turn share the prefix, so
94
+ * their labels can repeat — `agentName` on each snapshot disambiguates them.
95
+ */
96
+ forwardSubAgent(childSnapshots) {
97
+ var _a;
98
+ if (childSnapshots.length === 0)
99
+ return;
100
+ // The activating parent turn = the most recent snapshot this driver recorded
101
+ // before entering the tool handler that invoked the sub-agent.
102
+ const parentTurn = Math.max(0, this.turnIndex - 1);
103
+ const ownTurnLabel = new Map();
104
+ let ownPos = 0;
105
+ for (const snap of childSnapshots) {
106
+ let turnIndex;
107
+ if (!snap.turnIndex.includes('-')) {
108
+ // The child's own turn (a bare counter) → number it under the parent turn.
109
+ ownPos += 1;
110
+ turnIndex = `${parentTurn}-${ownPos}`;
111
+ ownTurnLabel.set(snap.turnIndex, turnIndex);
112
+ }
113
+ else {
114
+ // An already-forwarded grand-child label — remap its leading segment.
115
+ const [lead, ...rest] = snap.turnIndex.split('-');
116
+ const leadLabel = (_a = ownTurnLabel.get(lead)) !== null && _a !== void 0 ? _a : `${parentTurn}-${lead}`;
117
+ turnIndex = [leadLabel, ...rest].join('-');
118
+ }
119
+ this.buffer.push(Object.assign(Object.assign({}, snap), { turnIndex }));
120
+ }
121
+ this.trim();
122
+ }
123
+ /**
124
+ * Fold a widget's out-of-band diagnostics into this session — typically a server-side
125
+ * ChatDriver's collated log, arriving on `InteractionResult.diagnostics` — so the host's debug
126
+ * log and persisted diagnostics stream include them. Independent of the interaction's own
127
+ * message: these describe the widget's work, not that message. A non-array or empty harvest is
128
+ * a no-op.
129
+ */
130
+ foldDiagnostics(interactionId, harvested) {
131
+ // The harvested entries carry the SOURCE engine's own index space (0-based turns/events), which
132
+ // would collide with this host's cursors in the forward-capture persister — turns dedup by
133
+ // `turnIndex`, events by an `index` high-water mark — so foreign entries get silently dropped
134
+ // from the persisted stream. Route each kind through the host's own collision-safe machinery,
135
+ // mirroring how a sub-agent's log is folded in (see `invokeSubAgent`):
136
+ // • `event` → `mergeMetaEvents`, which re-indexes them onto this session's monotonic event
137
+ // counter (clearing the high-water) while preserving their timestamps; they then
138
+ // reach the log via `getMetaEvents` in `buildTimelineEntries`, not this buffer.
139
+ // • `turn` → re-key `turnIndex` to the turn's local position within a per-batch namespace,
140
+ // so turns stay unique even when one interaction folds in several independent
141
+ // server runs that each number their own turns from 0 (a consolidator carries a
142
+ // consolidator-code run AND a table-code run), and never collide with the host's
143
+ // own bare-integer turn keys.
144
+ // • `message` → kept as-is; the persister already keys messages by content, so they survive.
145
+ // TODO(GENC-1461, option 2 / streaming): expose an imperative `appendDiagnostics(entries)` on
146
+ // the handler context (see buildHandlerContext) that runs this same decomposition as SSE ticks
147
+ // arrive, so a streaming widget can surface its external engine's log live rather than only at
148
+ // resolve time. Batch-at-resolve (here) is the current, non-streaming path.
149
+ if (!Array.isArray(harvested) || !harvested.length)
150
+ return;
151
+ const batch = (this.externalBatches += 1);
152
+ const externalEvents = [];
153
+ let turnCount = 0;
154
+ let messageCount = 0;
155
+ for (const raw of harvested) {
156
+ // The harvest is external and typed `unknown[]`, so an element could be a primitive, null,
157
+ // or an object without a string `kind`. Guard before reading `.kind` so a malformed entry
158
+ // can't throw here (and gets skipped rather than derailing the whole fold).
159
+ if (!raw || typeof raw !== 'object' || typeof raw.kind !== 'string') {
160
+ continue;
161
+ }
162
+ const entry = raw;
163
+ if (entry.kind === 'event') {
164
+ const { kind: _kind } = entry, event = __rest(entry, ["kind"]);
165
+ externalEvents.push(event);
166
+ }
167
+ else if (entry.kind === 'turn') {
168
+ // Local position within THIS harvest, not the source engine's own `turnIndex`: one
169
+ // interaction can carry several independent server runs (a consolidator folds in its
170
+ // consolidator-code run and its table-code run), each numbering its turns from 0, so
171
+ // keying by the source index would collide them. The local position keeps every turn key
172
+ // distinct within the batch, so the persister's per-`turnIndex` dedup keeps them all.
173
+ this.externalEntries.push(Object.assign(Object.assign({}, entry), { turnIndex: `server-generation.${batch}:${turnCount}` }));
174
+ turnCount += 1;
175
+ }
176
+ else if (entry.kind === 'message') {
177
+ messageCount += 1;
178
+ this.externalEntries.push(entry);
179
+ }
180
+ // Any other kind is intentionally dropped, not folded. A sibling's `buildTimelineEntries`
181
+ // only ever emits message/turn/event; `meta-snapshot` is host/DOM-owned (latest-wins on
182
+ // reassembly, plus its own persister dedup), so folding a foreign one would pollute this
183
+ // host's timeline and meta cursor. Restricting to the three real kinds also keeps the
184
+ // manifest counts accurate (nothing miscounted as a `message`).
185
+ }
186
+ if (externalEvents.length)
187
+ mergeMetaEvents(this.sessionKey, externalEvents);
188
+ // Manifest: one provenance marker for the fold itself — records that an out-of-band engine's
189
+ // log crossed into this session and how much of each kind, so a regression that silently
190
+ // drops a generation's diagnostics (e.g. a new path that forgets to thread them across the
191
+ // boundary) surfaces as a zero/absent manifest rather than only by diffing a download. Also
192
+ // the natural seed for the streaming PR's open/close bracket. Stamped at resolve, so it
193
+ // closes the block on the sorted timeline. (GENC-1461.)
194
+ recordMetaEvent(this.sessionKey, 'external-diagnostics.folded', {
195
+ interactionId,
196
+ batch,
197
+ counts: { turn: turnCount, event: externalEvents.length, message: messageCount },
198
+ });
199
+ }
200
+ /** Hold the ring buffer at its cap, dropping oldest-first. */
201
+ trim() {
202
+ while (this.buffer.length > this.maxSnapshots) {
203
+ this.buffer.shift();
204
+ }
205
+ }
206
+ }
@@ -225,6 +225,7 @@ export class SettingsModalController {
225
225
  (ui === null || ui === void 0 ? void 0 : ui.showNarration) != null ||
226
226
  (ui === null || ui === void 0 ? void 0 : ui.showAgentSwitchIndicator) != null ||
227
227
  (ui === null || ui === void 0 ? void 0 : ui.allowDebugDownload) === true ||
228
+ (ui === null || ui === void 0 ? void 0 : ui.debugLogSubmit) != null ||
228
229
  (ui === null || ui === void 0 ? void 0 : ui.animations) != null ||
229
230
  cost.showCost ||
230
231
  cost.showTokens ||
@@ -826,13 +826,93 @@ export const settingsModalStyles = css `
826
826
  line-height: 1.4;
827
827
  }
828
828
 
829
+ /* The agent-log row's two actions (Download / Send) side by side. */
830
+ .agent-log-actions {
831
+ display: flex;
832
+ align-items: center;
833
+ gap: calc(var(--design-unit) * 2px);
834
+ }
835
+
836
+ /*
837
+ * Row + compose box. The row is normally the section's last child (no divider); inside
838
+ * the wrapper it is only the last child while the box is closed, so drop the divider
839
+ * explicitly and let the box supply its own spacing beneath the row.
840
+ */
841
+ .agent-log-block {
842
+ display: flex;
843
+ flex-direction: column;
844
+ }
845
+
846
+ .agent-log-block .settings-toggle-row {
847
+ border-bottom: none;
848
+ padding-bottom: calc(var(--design-unit) * 1px);
849
+ }
850
+
851
+ .agent-log-compose {
852
+ display: flex;
853
+ flex-direction: column;
854
+ gap: calc(var(--design-unit) * 2px);
855
+ margin-top: calc(var(--design-unit) * 2px);
856
+ }
857
+
858
+ .agent-log-note {
859
+ width: 100%;
860
+ }
861
+
862
+ .agent-log-note::part(control) {
863
+ width: 100%;
864
+ box-sizing: border-box;
865
+ font-size: 13px;
866
+ line-height: 1.5;
867
+ }
868
+
869
+ .agent-log-compose-footer {
870
+ display: flex;
871
+ align-items: center;
872
+ justify-content: space-between;
873
+ gap: calc(var(--design-unit) * 3px);
874
+ }
875
+
876
+ /* Counter / validation / failure line. Hint-coloured until it carries an error. */
877
+ .agent-log-compose-hint {
878
+ font-size: 12px;
879
+ color: var(--neutral-foreground-hint);
880
+ min-width: 0;
881
+ }
882
+
883
+ /*
884
+ * Failure state without leaning on --error-color: rapid's fixed brand red is 3.02:1 on a
885
+ * light surface, under AA for 12px text, and the failure line is the one that most needs
886
+ * reading. The contrast-adaptive foreground plus weight carries it; the text itself also
887
+ * changes, so colour was never the only channel.
888
+ */
889
+ .agent-log-compose-hint.is-error {
890
+ color: var(--neutral-foreground-rest);
891
+ font-weight: 600;
892
+ }
893
+
894
+ /* Screen-reader-only live region for submission outcomes (same recipe as main.styles). */
895
+ .visually-hidden {
896
+ position: absolute;
897
+ width: 1px;
898
+ height: 1px;
899
+ margin: -1px;
900
+ padding: 0;
901
+ overflow: hidden;
902
+ clip-path: inset(50%);
903
+ white-space: nowrap;
904
+ border: 0;
905
+ }
906
+
829
907
  /*
830
- * Inline spinner shown in the "Download agent log" button while the log builds
831
- * (GENC-1351 §5.8) — the lifetime-stitch path is an async provider round-trip.
832
- * Sized to the button label and tinted with currentColor so it tracks the
833
- * button's text colour in every appearance / theme.
908
+ * Inline spinner shown in the agent-log buttons (Download / Send) while the log
909
+ * builds (GENC-1351 §5.8) — the lifetime-stitch path is an async provider
910
+ * round-trip. Sized to the button label and tinted with currentColor so it tracks
911
+ * the button's text colour in every appearance / theme.
834
912
  */
835
- [part='download-button'] .download-spinner {
913
+ [part='download-button'] .download-spinner,
914
+ [part='submit-log-button'] .download-spinner,
915
+ [part='agent-log-send-button'] .download-spinner {
836
916
  display: inline-block;
837
917
  width: 12px;
838
918
  height: 12px;