@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
@@ -3790,70 +3790,6 @@ function clearSession(key) {
3790
3790
  // src/utils/logger.ts
3791
3791
  var logger2 = createLogger("ai-assistant");
3792
3792
 
3793
- // src/state/interaction-context.ts
3794
- function createInteractionContext(interactionId) {
3795
- const state = {};
3796
- const onceCache = /* @__PURE__ */ new Map();
3797
- const listeners = /* @__PURE__ */ new Set();
3798
- const disposers = /* @__PURE__ */ new Set();
3799
- let disposed = false;
3800
- const context = {
3801
- interactionId,
3802
- state,
3803
- once(key, factory) {
3804
- if (onceCache.has(key)) return onceCache.get(key);
3805
- if (disposed) {
3806
- logger2.warn(
3807
- `InteractionContext.once("${key}") called after dispose; not creating a resource on an ended interaction.`
3808
- );
3809
- return null;
3810
- }
3811
- const value = factory();
3812
- onceCache.set(key, value);
3813
- return value;
3814
- },
3815
- notify() {
3816
- for (const listener of listeners) {
3817
- try {
3818
- listener();
3819
- } catch (e) {
3820
- logger2.error("InteractionContext subscriber threw during notify()", e);
3821
- }
3822
- }
3823
- },
3824
- subscribe(listener) {
3825
- listeners.add(listener);
3826
- return () => {
3827
- listeners.delete(listener);
3828
- };
3829
- },
3830
- onDispose(fn) {
3831
- if (disposed) {
3832
- fn();
3833
- return;
3834
- }
3835
- disposers.add(fn);
3836
- }
3837
- };
3838
- return {
3839
- context,
3840
- dispose() {
3841
- if (disposed) return;
3842
- disposed = true;
3843
- for (const fn of disposers) {
3844
- try {
3845
- fn();
3846
- } catch (e) {
3847
- logger2.error("InteractionContext onDispose callback threw", e);
3848
- }
3849
- }
3850
- disposers.clear();
3851
- listeners.clear();
3852
- onceCache.clear();
3853
- }
3854
- };
3855
- }
3856
-
3857
3793
  // src/suggestions/generate-suggestions.ts
3858
3794
  var HISTORY_WINDOW = 8;
3859
3795
  function capabilities(ctx, allAgentInfo) {
@@ -4826,6 +4762,250 @@ var FoldController = class {
4826
4762
  }
4827
4763
  };
4828
4764
 
4765
+ // src/state/interaction-context.ts
4766
+ function createInteractionContext(interactionId) {
4767
+ const state = {};
4768
+ const onceCache = /* @__PURE__ */ new Map();
4769
+ const listeners = /* @__PURE__ */ new Set();
4770
+ const disposers = /* @__PURE__ */ new Set();
4771
+ let disposed = false;
4772
+ const context = {
4773
+ interactionId,
4774
+ state,
4775
+ once(key, factory) {
4776
+ if (onceCache.has(key)) return onceCache.get(key);
4777
+ if (disposed) {
4778
+ logger2.warn(
4779
+ `InteractionContext.once("${key}") called after dispose; not creating a resource on an ended interaction.`
4780
+ );
4781
+ return null;
4782
+ }
4783
+ const value = factory();
4784
+ onceCache.set(key, value);
4785
+ return value;
4786
+ },
4787
+ notify() {
4788
+ for (const listener of listeners) {
4789
+ try {
4790
+ listener();
4791
+ } catch (e) {
4792
+ logger2.error("InteractionContext subscriber threw during notify()", e);
4793
+ }
4794
+ }
4795
+ },
4796
+ subscribe(listener) {
4797
+ listeners.add(listener);
4798
+ return () => {
4799
+ listeners.delete(listener);
4800
+ };
4801
+ },
4802
+ onDispose(fn) {
4803
+ if (disposed) {
4804
+ fn();
4805
+ return;
4806
+ }
4807
+ disposers.add(fn);
4808
+ }
4809
+ };
4810
+ return {
4811
+ context,
4812
+ dispose() {
4813
+ if (disposed) return;
4814
+ disposed = true;
4815
+ for (const fn of disposers) {
4816
+ try {
4817
+ fn();
4818
+ } catch (e) {
4819
+ logger2.error("InteractionContext onDispose callback threw", e);
4820
+ }
4821
+ }
4822
+ disposers.clear();
4823
+ listeners.clear();
4824
+ onceCache.clear();
4825
+ }
4826
+ };
4827
+ }
4828
+
4829
+ // src/components/chat-driver/interaction-controller.ts
4830
+ var InteractionController = class {
4831
+ constructor(deps) {
4832
+ this.deps = deps;
4833
+ this.pending = /* @__PURE__ */ new Map();
4834
+ /**
4835
+ * Live per-interaction contexts (GENC-1390), keyed by interactionId. Created in `request`
4836
+ * alongside the pending entry, disposed + removed in `resolve`. Non-serialisable (sockets,
4837
+ * timers, subscriber sets), so they live here — not on the DOM, not in the persisted store — and
4838
+ * therefore survive the element remounts (layout reflow/drag, transcript re-render) that this
4839
+ * feature exists to make harmless. At most one is live at a time (see the
4840
+ * one-interaction-at-a-time guard in `request`).
4841
+ */
4842
+ this.contexts = /* @__PURE__ */ new Map();
4843
+ }
4844
+ /** See `ChatDriver.setHostInteractionRequester`. */
4845
+ setHostRequester(fn) {
4846
+ this.hostRequester = fn;
4847
+ }
4848
+ /** See `ChatDriver.requestInteraction`. */
4849
+ request(componentName, data, options) {
4850
+ if (this.hostRequester) {
4851
+ return this.hostRequester(componentName, data, options);
4852
+ }
4853
+ if (this.pending.size > 0) {
4854
+ throw new Error(
4855
+ "requestInteraction: another user interaction is already in flight. Only one interaction may be active at a time \u2014 sequence them in a single tool handler rather than spawning widgets from parallel sub-agents or parallel tool calls."
4856
+ );
4857
+ }
4858
+ const interactionId = crypto.randomUUID();
4859
+ const chatInputDuringExecution = options?.chatInputDuringExecution;
4860
+ const timeoutMs = options?.timeoutMs;
4861
+ const presentation = options?.presentation;
4862
+ return new Promise((resolve, reject) => {
4863
+ this.pending.set(interactionId, {
4864
+ resolve,
4865
+ reject,
4866
+ overrideId: chatInputDuringExecution ? interactionId : void 0,
4867
+ // Resolve the interaction with a timeout result if the user does not
4868
+ // respond in time. `resolve` runs the same teardown a normal resolution
4869
+ // does (marks the widget read-only, reverts any input override) and
4870
+ // clears any still-pending handle.
4871
+ timeoutHandle: timeoutMs != null ? setTimeout(() => {
4872
+ this.resolve(interactionId, {
4873
+ status: "timeout",
4874
+ message: `Interaction timed out after ${timeoutMs}ms.`
4875
+ });
4876
+ }, timeoutMs) : void 0
4877
+ });
4878
+ recordMetaEvent(this.deps.sessionKey, "interaction.requested", {
4879
+ interactionId,
4880
+ component: componentName,
4881
+ agent: this.deps.activeAgentName()
4882
+ });
4883
+ this.deps.activityBus.publish("interaction-requested", void 0);
4884
+ if (chatInputDuringExecution) {
4885
+ this.deps.emit(
4886
+ new CustomEvent("interaction-start", {
4887
+ detail: { interactionId, chatInputDuringExecution }
4888
+ })
4889
+ );
4890
+ }
4891
+ this.contexts.set(interactionId, createInteractionContext(interactionId));
4892
+ this.deps.appendToHistory({
4893
+ role: "assistant",
4894
+ content: "",
4895
+ interaction: {
4896
+ interactionId,
4897
+ componentName,
4898
+ data,
4899
+ ...presentation ? { presentation } : {}
4900
+ }
4901
+ });
4902
+ });
4903
+ }
4904
+ /**
4905
+ * See `ChatDriver.resolveInteraction`. Warns and returns when the id is unknown — a widget can
4906
+ * report twice, and the second report has nothing left to settle.
4907
+ */
4908
+ resolve(interactionId, result) {
4909
+ const interaction = this.pending.get(interactionId);
4910
+ if (!interaction) {
4911
+ logger2.warn(`Interaction with ID ${interactionId} not found.`);
4912
+ return;
4913
+ }
4914
+ if (interaction.timeoutHandle) clearTimeout(interaction.timeoutHandle);
4915
+ this.deps.foldDiagnostics(
4916
+ interactionId,
4917
+ result?.diagnostics
4918
+ );
4919
+ const costUsd = typeof result?.costUsd === "number" && Number.isFinite(result.costUsd) && result.costUsd > 0 ? result.costUsd : void 0;
4920
+ const patched = this.deps.patchInteractionMessage(interactionId, (message) => ({
4921
+ ...message,
4922
+ interaction: { ...message.interaction, resolved: result },
4923
+ ...costUsd != null ? { externalCostUsd: (message.externalCostUsd ?? 0) + costUsd } : {}
4924
+ }));
4925
+ if (patched) this.deps.notifyHistoryUpdated();
4926
+ if (typeof result?.userMessage === "string" && result.userMessage.trim()) {
4927
+ this.deps.appendToHistory({ role: "synthetic-user", content: result.userMessage });
4928
+ }
4929
+ if (interaction.overrideId) {
4930
+ this.deps.emit(new CustomEvent("interaction-stop", { detail: { interactionId } }));
4931
+ }
4932
+ recordMetaEvent(this.deps.sessionKey, "interaction.resolved", { interactionId });
4933
+ this.deps.activityBus.publish("interaction-resolved", void 0);
4934
+ interaction.resolve(result);
4935
+ this.pending.delete(interactionId);
4936
+ this.contexts.get(interactionId)?.dispose();
4937
+ this.contexts.delete(interactionId);
4938
+ }
4939
+ /**
4940
+ * See `ChatDriver.recordExternalCost`.
4941
+ *
4942
+ * **Why this exists separately from `resolve`.** A widget's spend reaches the session total only
4943
+ * through `InteractionResult.costUsd`, which is read exactly once — `resolve` does its whole job
4944
+ * against a live pending entry and then deletes it. That is correct for the resolution itself,
4945
+ * but it assumes a widget stops costing money when it resolves, and several deliberately do not:
4946
+ * a widget that resolves EARLY so the assistant stops waiting (options shown, a preview
4947
+ * rendered) may stay interactive for minutes afterwards and do more paid work on the user's
4948
+ * behalf. Re-emitting `interaction-completed` does not help — with the interaction gone from the
4949
+ * map the call is a silent no-op — so that spend had nowhere to go and was simply missing from
4950
+ * the figure shown to the user.
4951
+ *
4952
+ * Touches only `externalCostUsd`: the recorded `interaction.resolved` payload is left exactly as
4953
+ * the resolution wrote it, because this is not a second resolution and must not read like one.
4954
+ */
4955
+ recordExternalCost(interactionId, costUsd) {
4956
+ const drop = (reason) => {
4957
+ recordMetaEvent(this.deps.sessionKey, "interaction.external-cost-dropped", {
4958
+ interactionId,
4959
+ costUsd,
4960
+ reason
4961
+ });
4962
+ return false;
4963
+ };
4964
+ if (!interactionId) return drop("no interactionId");
4965
+ if (typeof costUsd !== "number" || !Number.isFinite(costUsd) || costUsd <= 0) {
4966
+ return drop("not a positive finite amount");
4967
+ }
4968
+ const patched = this.deps.patchInteractionMessage(interactionId, (message) => ({
4969
+ ...message,
4970
+ externalCostUsd: (message.externalCostUsd ?? 0) + costUsd
4971
+ }));
4972
+ if (!patched) return drop("interaction message no longer in history");
4973
+ recordMetaEvent(this.deps.sessionKey, "interaction.external-cost", { interactionId, costUsd });
4974
+ this.deps.emit(
4975
+ new CustomEvent("external-cost-recorded", {
4976
+ detail: { interactionId, costUsd }
4977
+ })
4978
+ );
4979
+ return true;
4980
+ }
4981
+ /** See `ChatDriver.getInteractionContext`. */
4982
+ context(interactionId) {
4983
+ return this.contexts.get(interactionId)?.context;
4984
+ }
4985
+ /**
4986
+ * Resolve every in-flight interaction with a `'timeout'` result, closing its
4987
+ * widget read-only. Used when a sub-agent that owns the open widget times out:
4988
+ * the widget is parked in *this* driver's map (interactions resolve on the
4989
+ * host, not the child), so cancelling it here unblocks the sub-agent handler
4990
+ * awaiting it. The "one interaction at a time" invariant means there is at
4991
+ * most one.
4992
+ */
4993
+ cancelAll(message) {
4994
+ for (const interactionId of [...this.pending.keys()]) {
4995
+ this.resolve(interactionId, { status: "timeout", message });
4996
+ }
4997
+ }
4998
+ /**
4999
+ * Drop any live interaction contexts on driver teardown. Normally empty (a context is created
5000
+ * and destroyed with its pending interaction, and dispose is not reached mid-interaction);
5001
+ * disposed defensively so a stray context can never leak an open socket/timer.
5002
+ */
5003
+ disposeContexts() {
5004
+ for (const handle of this.contexts.values()) handle.dispose();
5005
+ this.contexts.clear();
5006
+ }
5007
+ };
5008
+
4829
5009
  // src/components/chat-driver/turn-failures.ts
4830
5010
  var TURN_FAILURE_EXITS = {
4831
5011
  contextGuard: {
@@ -4907,6 +5087,160 @@ function turnDone(failureReason, budget, providerRefused) {
4907
5087
  };
4908
5088
  }
4909
5089
 
5090
+ // src/components/chat-driver/turn-recorder.ts
5091
+ var TurnRecorder = class {
5092
+ constructor(sessionKey, maxSnapshots) {
5093
+ this.sessionKey = sessionKey;
5094
+ this.maxSnapshots = maxSnapshots;
5095
+ /**
5096
+ * Ring buffer of per-LLM-call snapshots, capped at `maxSnapshots`; older entries drop off as
5097
+ * new ones arrive.
5098
+ */
5099
+ this.buffer = [];
5100
+ /** Monotonic counter that survives agent swaps — useful for cross-referencing with history. */
5101
+ this.turnIndex = 0;
5102
+ /**
5103
+ * `turn` + `message` diagnostic entries harvested from an out-of-band source. `event` entries
5104
+ * are NOT kept here — they are folded into the session's meta-event registry so they inherit
5105
+ * fresh host indices, and reach the log through `getMetaEvents`. Append-only, and never cleared:
5106
+ * it lives with the recorder, so a new chat (fresh driver) starts empty while a compaction (same
5107
+ * instance) keeps it. (GENC-1461.)
5108
+ */
5109
+ this.externalEntries = [];
5110
+ /**
5111
+ * Monotonic count of external-diagnostics batches folded this driver-lifetime. Namespaces each
5112
+ * batch's re-keyed `turn` entries so distinct server generations in one chat session can't
5113
+ * collide on `turnIndex` in the forward-capture persister (which dedups turns by that key).
5114
+ */
5115
+ this.externalBatches = 0;
5116
+ }
5117
+ /** Point at the incoming agent's `getDebugSnapshot`, or clear it when the agent supplies none. */
5118
+ setSnapshotter(snapshotter) {
5119
+ this.snapshotter = snapshotter;
5120
+ }
5121
+ /**
5122
+ * The per-turn snapshots captured so far — what the LLM saw on each turn: system prompt, tool
5123
+ * surface, and agent-supplied state (e.g. a machine snapshot).
5124
+ */
5125
+ snapshots() {
5126
+ return this.buffer;
5127
+ }
5128
+ /**
5129
+ * The harvested `turn` + `message` diagnostic entries. Opaque, and chronologically sorted by the
5130
+ * host at assemble time.
5131
+ */
5132
+ externalDiagnostics() {
5133
+ return this.externalEntries;
5134
+ }
5135
+ /**
5136
+ * Push one snapshot to the ring buffer. Called inside `runToolLoop` just
5137
+ * before each LLM call — that's the latest point where the prompt, tool
5138
+ * surface, and agent state line up with what the model is about to see.
5139
+ *
5140
+ * Returns the pushed object so the caller can back-fill what only the response
5141
+ * knows (`usage`). Mutating it after the fact is safe whether or not the ring
5142
+ * buffer has since evicted it — an evicted snapshot is simply no longer exported.
5143
+ */
5144
+ record(turn) {
5145
+ let agentSnapshot;
5146
+ if (this.snapshotter) {
5147
+ try {
5148
+ agentSnapshot = this.snapshotter();
5149
+ } catch (e) {
5150
+ agentSnapshot = `<getDebugSnapshot threw: ${e instanceof Error ? e.message : String(e)}>`;
5151
+ }
5152
+ }
5153
+ const turnIndex = String(this.turnIndex);
5154
+ this.turnIndex += 1;
5155
+ const snapshot = {
5156
+ ...turn,
5157
+ turnIndex,
5158
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5159
+ agentSnapshot
5160
+ };
5161
+ this.buffer.push(snapshot);
5162
+ this.trim();
5163
+ return snapshot;
5164
+ }
5165
+ /**
5166
+ * Merge a sub-agent's turn snapshots into this driver's buffer so they surface
5167
+ * as `kind:'turn'` entries in the exported debug log. The child runs as a
5168
+ * separate, discarded driver, so its snapshots would otherwise be lost. Each is
5169
+ * re-labelled under the parent turn that activated the sub-agent: the child's
5170
+ * own (numeric) turns become `"<parentTurn>-1"`, `"-2"`, … (1-based, in order);
5171
+ * any already-forwarded grand-child labels (strings) have their leading segment
5172
+ * remapped the same way, so nesting composes (`"5-2"` → `"5-2-1"`).
5173
+ *
5174
+ * Note: two sub-agents invoked in the *same* parent turn share the prefix, so
5175
+ * their labels can repeat — `agentName` on each snapshot disambiguates them.
5176
+ */
5177
+ forwardSubAgent(childSnapshots) {
5178
+ if (childSnapshots.length === 0) return;
5179
+ const parentTurn = Math.max(0, this.turnIndex - 1);
5180
+ const ownTurnLabel = /* @__PURE__ */ new Map();
5181
+ let ownPos = 0;
5182
+ for (const snap of childSnapshots) {
5183
+ let turnIndex;
5184
+ if (!snap.turnIndex.includes("-")) {
5185
+ ownPos += 1;
5186
+ turnIndex = `${parentTurn}-${ownPos}`;
5187
+ ownTurnLabel.set(snap.turnIndex, turnIndex);
5188
+ } else {
5189
+ const [lead, ...rest] = snap.turnIndex.split("-");
5190
+ const leadLabel = ownTurnLabel.get(lead) ?? `${parentTurn}-${lead}`;
5191
+ turnIndex = [leadLabel, ...rest].join("-");
5192
+ }
5193
+ this.buffer.push({ ...snap, turnIndex });
5194
+ }
5195
+ this.trim();
5196
+ }
5197
+ /**
5198
+ * Fold a widget's out-of-band diagnostics into this session — typically a server-side
5199
+ * ChatDriver's collated log, arriving on `InteractionResult.diagnostics` — so the host's debug
5200
+ * log and persisted diagnostics stream include them. Independent of the interaction's own
5201
+ * message: these describe the widget's work, not that message. A non-array or empty harvest is
5202
+ * a no-op.
5203
+ */
5204
+ foldDiagnostics(interactionId, harvested) {
5205
+ if (!Array.isArray(harvested) || !harvested.length) return;
5206
+ const batch = this.externalBatches += 1;
5207
+ const externalEvents = [];
5208
+ let turnCount = 0;
5209
+ let messageCount = 0;
5210
+ for (const raw of harvested) {
5211
+ if (!raw || typeof raw !== "object" || typeof raw.kind !== "string") {
5212
+ continue;
5213
+ }
5214
+ const entry = raw;
5215
+ if (entry.kind === "event") {
5216
+ const { kind: _kind, ...event } = entry;
5217
+ externalEvents.push(event);
5218
+ } else if (entry.kind === "turn") {
5219
+ this.externalEntries.push({
5220
+ ...entry,
5221
+ turnIndex: `server-generation.${batch}:${turnCount}`
5222
+ });
5223
+ turnCount += 1;
5224
+ } else if (entry.kind === "message") {
5225
+ messageCount += 1;
5226
+ this.externalEntries.push(entry);
5227
+ }
5228
+ }
5229
+ if (externalEvents.length) mergeMetaEvents(this.sessionKey, externalEvents);
5230
+ recordMetaEvent(this.sessionKey, "external-diagnostics.folded", {
5231
+ interactionId,
5232
+ batch,
5233
+ counts: { turn: turnCount, event: externalEvents.length, message: messageCount }
5234
+ });
5235
+ }
5236
+ /** Hold the ring buffer at its cap, dropping oldest-first. */
5237
+ trim() {
5238
+ while (this.buffer.length > this.maxSnapshots) {
5239
+ this.buffer.shift();
5240
+ }
5241
+ }
5242
+ };
5243
+
4910
5244
  // src/components/chat-driver/chat-driver.ts
4911
5245
  var toolResultImages = (result) => {
4912
5246
  if (!result || typeof result !== "object") return void 0;
@@ -4944,17 +5278,6 @@ var ChatDriver = class _ChatDriver extends EventTarget {
4944
5278
  this.busy = false;
4945
5279
  /** Epoch ms when the current turn loop began — drives the `turn.end` duration. */
4946
5280
  this.turnStartedAt = 0;
4947
- this.pendingInteractions = /* @__PURE__ */ new Map();
4948
- /**
4949
- * Live per-interaction contexts (GENC-1390), keyed by interactionId. Created in
4950
- * `requestInteraction` alongside the pending entry, disposed + removed in
4951
- * `resolveInteraction`. Non-serialisable (sockets, timers, subscriber sets), so
4952
- * they live here on the driver — not on the DOM, not in the persisted store — and
4953
- * therefore survive the element remounts (layout reflow/drag, transcript
4954
- * re-render) that this feature exists to make harmless. At most one is live at a
4955
- * time (see the one-interaction-at-a-time guard in `requestInteraction`).
4956
- */
4957
- this.interactionContexts = /* @__PURE__ */ new Map();
4958
5281
  /**
4959
5282
  * Tool-declared condensation policies, keyed by tool-call id. Populated by
4960
5283
  * `condenseWhen` (first-wins per call); read by `applyCondensation` before each
@@ -5053,31 +5376,6 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5053
5376
  * doesn't leak forward.
5054
5377
  */
5055
5378
  this.agentReleaseRequested = false;
5056
- /**
5057
- * Ring buffer of per-LLM-call snapshots. Cap is configurable via
5058
- * `chatConfig.agent.maxTurnSnapshots`; older entries drop off as new ones
5059
- * arrive. See {@link TurnSnapshot} for the captured shape.
5060
- */
5061
- this.turnSnapshots = [];
5062
- /**
5063
- * `turn` + `message` diagnostic-log entries harvested from an out-of-band source (a resolved
5064
- * interaction whose widget returned `InteractionResult.diagnostics` — e.g. a server-side
5065
- * ChatDriver's debug log). `event` entries are NOT kept here — they're folded into the session's
5066
- * meta-event registry via `mergeMetaEvents` (see `resolveInteraction`) so they inherit fresh host
5067
- * indices, and reach the log through `getMetaEvents`. Append-only; the host folds these into its
5068
- * debug log alongside this driver's own timeline. Not cleared here — it lives with the driver
5069
- * instance, like `turnSnapshots`, so a new chat (fresh driver) starts empty while a compaction
5070
- * (same instance) keeps it. (GENC-1461.)
5071
- */
5072
- this.externalDiagnostics = [];
5073
- /**
5074
- * Monotonic count of external-diagnostics batches harvested this driver-lifetime. Namespaces each
5075
- * batch's re-keyed `turn` entries so distinct server generations in one chat session can't collide
5076
- * on `turnIndex` in the forward-capture persister (which dedups turns by that key). (GENC-1461.)
5077
- */
5078
- this.externalDiagnosticsBatches = 0;
5079
- /** Monotonic counter that survives agent swaps — useful for cross-referencing with history. */
5080
- this.globalTurnIndex = 0;
5081
5379
  /**
5082
5380
  * Aborted by `dispose()` on driver teardown (e.g. an agent-config swap).
5083
5381
  * Threaded into every provider call as `ChatRequestOptions.signal`, so a
@@ -5187,7 +5485,17 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5187
5485
  this.systemPrompt = systemPrompt;
5188
5486
  this.primerHistory = primerHistory;
5189
5487
  this.maxFoldOperations = maxFoldOperations;
5190
- this.maxTurnSnapshots = maxTurnSnapshots;
5488
+ this.turnRecorder = new TurnRecorder(sessionKey, maxTurnSnapshots);
5489
+ this.interactions = new InteractionController({
5490
+ sessionKey,
5491
+ activityBus,
5492
+ activeAgentName: () => this.activeAgentName,
5493
+ appendToHistory: (message) => this.appendToHistory(message),
5494
+ patchInteractionMessage: (interactionId, patch) => this.patchInteractionMessage(interactionId, patch),
5495
+ notifyHistoryUpdated: () => this.notifyHistoryUpdated(),
5496
+ emit: (event) => this.dispatchEvent(event),
5497
+ foldDiagnostics: (interactionId, harvested) => this.turnRecorder.foldDiagnostics(interactionId, harvested)
5498
+ });
5191
5499
  if (isObservableAIProviderRegistry(this.providerRegistry)) {
5192
5500
  this.unsubscribeRegistry = this.providerRegistry.subscribe(() => {
5193
5501
  this.resolvedProviderCache.clear();
@@ -5205,7 +5513,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5205
5513
  * on to completion or the transport timeout. Called by the host on driver
5206
5514
  * swap and by `OrchestratingDriver.dispose()`. Idempotent.
5207
5515
  *
5208
- * Deliberately does NOT settle `pendingInteractions` here: dispose is never
5516
+ * Deliberately does NOT settle pending interactions here: dispose is never
5209
5517
  * reached while one is pending. The only host-level dispose is the agent-swap
5210
5518
  * rebuild, which is guarded by `isBusy()` — a pending interaction keeps the
5211
5519
  * tool loop suspended (`busy === true`), so the swap is deferred until the
@@ -5216,8 +5524,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5216
5524
  */
5217
5525
  async dispose() {
5218
5526
  this.lifecycleController.abort(new DOMException("AI assistant driver disposed", "AbortError"));
5219
- for (const handle of this.interactionContexts.values()) handle.dispose();
5220
- this.interactionContexts.clear();
5527
+ this.interactions.disposeContexts();
5221
5528
  this.unsubscribeRegistry?.();
5222
5529
  this.unsubscribeRegistry = void 0;
5223
5530
  await this.agentSession.dispose();
@@ -5474,7 +5781,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5474
5781
  this.activeAgentName = config.name;
5475
5782
  this.displayName = config.displayName;
5476
5783
  this.activeAgentLabel = typeof config.displayName === "string" ? config.displayName : config.name;
5477
- this.debugSnapshotter = config.getDebugSnapshot;
5784
+ this.turnRecorder.setSnapshotter(config.getDebugSnapshot);
5478
5785
  this.subAgentsMap = new Map((config.subAgents ?? []).map((s) => [s.name, s]));
5479
5786
  this.activeProviderInput = config.provider;
5480
5787
  this.activeTemperatureInput = config.temperature;
@@ -5671,93 +5978,21 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5671
5978
  * log exporter to show what the LLM saw on each turn — system prompt, tool
5672
5979
  * surface, and agent-supplied state (e.g. a machine snapshot).
5673
5980
  *
5674
- * Ring-buffered at `MAX_TURN_SNAPSHOTS`; older entries are dropped.
5981
+ * Ring-buffered at `maxTurnSnapshots`; older entries are dropped.
5675
5982
  */
5676
5983
  getTurnSnapshots() {
5677
- return this.turnSnapshots;
5984
+ return this.turnRecorder.snapshots();
5678
5985
  }
5679
5986
  /**
5680
5987
  * The `turn` + `message` diagnostic-log entries harvested from resolved interactions'
5681
5988
  * `InteractionResult.diagnostics` (e.g. a server-side ChatDriver's collated debug log). The host
5682
5989
  * concatenates these into its own debug log so `downloadDebugLog`/the persisted diagnostics stream
5683
5990
  * include external-engine logs; `event` entries are excluded here because they're merged into the
5684
- * session's meta-event registry instead (see `resolveInteraction`) and reach the log via
5685
- * `getMetaEvents`. Opaque, chronologically sorted by the host at assemble time. (GENC-1461.)
5991
+ * session's meta-event registry instead and reach the log via `getMetaEvents`. Opaque,
5992
+ * chronologically sorted by the host at assemble time. (GENC-1461.)
5686
5993
  */
5687
5994
  getExternalDiagnostics() {
5688
- return this.externalDiagnostics;
5689
- }
5690
- /**
5691
- * Merge a sub-agent's turn snapshots into this driver's buffer so they surface
5692
- * as `kind:'turn'` entries in the exported debug log. The child runs as a
5693
- * separate, discarded driver, so its snapshots would otherwise be lost. Each is
5694
- * re-labelled under the parent turn that activated the sub-agent: the child's
5695
- * own (numeric) turns become `"<parentTurn>-1"`, `"-2"`, … (1-based, in order);
5696
- * any already-forwarded grand-child labels (strings) have their leading segment
5697
- * remapped the same way, so nesting composes (`"5-2"` → `"5-2-1"`).
5698
- *
5699
- * Note: two sub-agents invoked in the *same* parent turn share the prefix, so
5700
- * their labels can repeat — `agentName` on each snapshot disambiguates them.
5701
- */
5702
- forwardSubAgentSnapshots(childSnapshots) {
5703
- if (childSnapshots.length === 0) return;
5704
- const parentTurn = Math.max(0, this.globalTurnIndex - 1);
5705
- const ownTurnLabel = /* @__PURE__ */ new Map();
5706
- let ownPos = 0;
5707
- for (const snap of childSnapshots) {
5708
- let turnIndex;
5709
- if (!snap.turnIndex.includes("-")) {
5710
- ownPos += 1;
5711
- turnIndex = `${parentTurn}-${ownPos}`;
5712
- ownTurnLabel.set(snap.turnIndex, turnIndex);
5713
- } else {
5714
- const [lead, ...rest] = snap.turnIndex.split("-");
5715
- const leadLabel = ownTurnLabel.get(lead) ?? `${parentTurn}-${lead}`;
5716
- turnIndex = [leadLabel, ...rest].join("-");
5717
- }
5718
- this.turnSnapshots.push({ ...snap, turnIndex });
5719
- }
5720
- while (this.turnSnapshots.length > this.maxTurnSnapshots) {
5721
- this.turnSnapshots.shift();
5722
- }
5723
- }
5724
- /**
5725
- * Push one snapshot to the ring buffer. Called inside `runToolLoop` just
5726
- * before each LLM call — that's the latest point where the prompt, tool
5727
- * surface, and agent state line up with what the model is about to see.
5728
- *
5729
- * Returns the pushed object so the caller can back-fill what only the response
5730
- * knows (`usage`). Mutating it after the fact is safe whether or not the ring
5731
- * buffer has since evicted it — an evicted snapshot is simply no longer exported.
5732
- */
5733
- recordTurnSnapshot(resolvedSystemPrompt, temperature, toolChoice, tailContext) {
5734
- let agentSnapshot;
5735
- if (this.debugSnapshotter) {
5736
- try {
5737
- agentSnapshot = this.debugSnapshotter();
5738
- } catch (e) {
5739
- agentSnapshot = `<getDebugSnapshot threw: ${e instanceof Error ? e.message : String(e)}>`;
5740
- }
5741
- }
5742
- const turnIndex = String(this.globalTurnIndex);
5743
- this.globalTurnIndex += 1;
5744
- const snapshot = {
5745
- turnIndex,
5746
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5747
- agentName: this.activeAgentName,
5748
- agentLabel: this.activeAgentLabel,
5749
- systemPrompt: resolvedSystemPrompt,
5750
- tailContext,
5751
- toolNames: this.toolDefinitions.map((t) => t.name),
5752
- temperature,
5753
- toolChoice,
5754
- agentSnapshot
5755
- };
5756
- this.turnSnapshots.push(snapshot);
5757
- if (this.turnSnapshots.length > this.maxTurnSnapshots) {
5758
- this.turnSnapshots.shift();
5759
- }
5760
- return snapshot;
5995
+ return this.turnRecorder.externalDiagnostics();
5761
5996
  }
5762
5997
  /**
5763
5998
  * Set the mid-loop context guard. Its margin is deliberately far smaller than
@@ -5802,6 +6037,10 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5802
6037
  );
5803
6038
  return { estimated, threshold };
5804
6039
  }
6040
+ /**
6041
+ * Optional transform applied to conversation history immediately before each LLM request.
6042
+ * Cleared when `undefined`. Does not alter stored history.
6043
+ */
5805
6044
  setProviderHistoryTransform(transform) {
5806
6045
  this.providerHistoryTransform = transform;
5807
6046
  }
@@ -5955,7 +6194,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5955
6194
  * naturally: a grandchild → child → root.
5956
6195
  */
5957
6196
  setHostInteractionRequester(fn) {
5958
- this.hostInteractionRequester = fn;
6197
+ this.interactions.setHostRequester(fn);
5959
6198
  }
5960
6199
  /**
5961
6200
  * Request a custom UI interaction. Emits a new message with the interaction.
@@ -5977,76 +6216,15 @@ var ChatDriver = class _ChatDriver extends EventTarget {
5977
6216
  * bubble and/or shows the "Assistant" label.
5978
6217
  */
5979
6218
  async requestInteraction(componentName, data, options) {
5980
- if (this.hostInteractionRequester) {
5981
- return this.hostInteractionRequester(componentName, data, options);
5982
- }
5983
- if (this.pendingInteractions.size > 0) {
5984
- throw new Error(
5985
- "requestInteraction: another user interaction is already in flight. Only one interaction may be active at a time \u2014 sequence them in a single tool handler rather than spawning widgets from parallel sub-agents or parallel tool calls."
5986
- );
5987
- }
5988
- const interactionId = crypto.randomUUID();
5989
- const chatInputDuringExecution = options?.chatInputDuringExecution;
5990
- const timeoutMs = options?.timeoutMs;
5991
- const presentation = options?.presentation;
5992
- return new Promise((resolve, reject) => {
5993
- this.pendingInteractions.set(interactionId, {
5994
- resolve,
5995
- reject,
5996
- overrideId: chatInputDuringExecution ? interactionId : void 0,
5997
- // Resolve the interaction with a timeout result if the user does not
5998
- // respond in time. `resolveInteraction` runs the same teardown a normal
5999
- // resolution does (marks the widget read-only, reverts any input
6000
- // override) and clears any still-pending handle.
6001
- timeoutHandle: timeoutMs != null ? setTimeout(() => {
6002
- this.resolveInteraction(interactionId, {
6003
- status: "timeout",
6004
- message: `Interaction timed out after ${timeoutMs}ms.`
6005
- });
6006
- }, timeoutMs) : void 0
6007
- });
6008
- recordMetaEvent(this.sessionKey, "interaction.requested", {
6009
- interactionId,
6010
- component: componentName,
6011
- agent: this.activeAgentName
6012
- });
6013
- this.activityBus.publish("interaction-requested", void 0);
6014
- if (chatInputDuringExecution) {
6015
- this.dispatchEvent(
6016
- new CustomEvent("interaction-start", {
6017
- detail: { interactionId, chatInputDuringExecution }
6018
- })
6019
- );
6020
- }
6021
- this.interactionContexts.set(interactionId, createInteractionContext(interactionId));
6022
- this.appendToHistory({
6023
- role: "assistant",
6024
- content: "",
6025
- interaction: {
6026
- interactionId,
6027
- componentName,
6028
- data,
6029
- ...presentation ? { presentation } : {}
6030
- }
6031
- });
6032
- });
6219
+ return this.interactions.request(componentName, data, options);
6033
6220
  }
6034
6221
  /**
6035
- * Add external (non-LLM) spend to an interaction's message AFTER it has resolved.
6222
+ * Add external (non-LLM) spend to an interaction's message AFTER it has resolved — for a widget
6223
+ * that resolves early so the assistant stops waiting, then keeps doing paid work on the user's
6224
+ * behalf. `InteractionController.recordExternalCost` explains why the resolution path cannot
6225
+ * carry this.
6036
6226
  *
6037
- * **Why this exists separately from `resolveInteraction`.** A widget's spend reaches the session
6038
- * total only through `InteractionResult.costUsd`, which is read exactly once — `resolveInteraction`
6039
- * does its whole job inside `if (interaction)` and then deletes the entry from
6040
- * `pendingInteractions`. That is correct for the resolution itself, but it assumes a widget stops
6041
- * costing money when it resolves, and several deliberately do not: a widget that resolves EARLY so
6042
- * the assistant stops waiting (options shown, a preview rendered) may stay interactive for minutes
6043
- * afterwards and do more paid work on the user's behalf. Re-emitting `interaction-completed` does
6044
- * not help — with the interaction gone from the map the call is a silent no-op — so that spend had
6045
- * nowhere to go and was simply missing from the figure shown to the user.
6046
- *
6047
- * Additive by design: the caller reports a DELTA, not a running total, so repeated calls sum. It
6048
- * touches only `externalCostUsd`; the recorded `interaction.resolved` payload is left exactly as
6049
- * the resolution wrote it, because this is not a second resolution and must not read like one.
6227
+ * Additive by design: the caller reports a DELTA, not a running total, so repeated calls sum.
6050
6228
  *
6051
6229
  * Deliberately does NOT require the interaction to be pending, does not resurrect it, and has no
6052
6230
  * loading/turn side effects — this is bookkeeping about a turn that already happened.
@@ -6056,106 +6234,19 @@ var ChatDriver = class _ChatDriver extends EventTarget {
6056
6234
  * @returns `true` when the spend was recorded; `false` when it was not — either the value was
6057
6235
  * not a positive finite amount, or the message is no longer in history (trimmed or condensed
6058
6236
  * away). A real outcome a caller may want to log rather than a failure to throw on; every
6059
- * `false` also records an `interaction.external-cost-dropped` meta event here, so money can
6060
- * never disappear without a trace.
6237
+ * `false` also records an `interaction.external-cost-dropped` meta event, so money can never
6238
+ * disappear without a trace.
6061
6239
  * @beta
6062
6240
  */
6063
6241
  recordExternalCost(interactionId, costUsd) {
6064
- const drop = (reason) => {
6065
- recordMetaEvent(this.sessionKey, "interaction.external-cost-dropped", {
6066
- interactionId,
6067
- costUsd,
6068
- reason
6069
- });
6070
- return false;
6071
- };
6072
- if (!interactionId) return drop("no interactionId");
6073
- if (typeof costUsd !== "number" || !Number.isFinite(costUsd) || costUsd <= 0) {
6074
- return drop("not a positive finite amount");
6075
- }
6076
- const idx = this.history.findIndex((m) => m.interaction?.interactionId === interactionId);
6077
- if (idx === -1) return drop("interaction message no longer in history");
6078
- this.history[idx] = {
6079
- ...this.history[idx],
6080
- externalCostUsd: (this.history[idx].externalCostUsd ?? 0) + costUsd
6081
- };
6082
- recordMetaEvent(this.sessionKey, "interaction.external-cost", { interactionId, costUsd });
6083
- this.dispatchEvent(
6084
- new CustomEvent("external-cost-recorded", {
6085
- detail: { interactionId, costUsd }
6086
- })
6087
- );
6088
- return true;
6242
+ return this.interactions.recordExternalCost(interactionId, costUsd);
6089
6243
  }
6090
6244
  /**
6091
6245
  * Resolve a pending interaction. The wrapper component calls this on completion.
6092
6246
  * Marks the interaction message as resolved so it renders read-only on re-render.
6093
6247
  */
6094
6248
  resolveInteraction(interactionId, result) {
6095
- const interaction = this.pendingInteractions.get(interactionId);
6096
- if (interaction) {
6097
- if (interaction.timeoutHandle) clearTimeout(interaction.timeoutHandle);
6098
- const harvestedDiagnostics = result?.diagnostics;
6099
- if (Array.isArray(harvestedDiagnostics) && harvestedDiagnostics.length) {
6100
- const batch = this.externalDiagnosticsBatches += 1;
6101
- const externalEvents = [];
6102
- let turnCount = 0;
6103
- let messageCount = 0;
6104
- for (const raw of harvestedDiagnostics) {
6105
- if (!raw || typeof raw !== "object" || typeof raw.kind !== "string") {
6106
- continue;
6107
- }
6108
- const entry = raw;
6109
- if (entry.kind === "event") {
6110
- const { kind: _kind, ...event } = entry;
6111
- externalEvents.push(event);
6112
- } else if (entry.kind === "turn") {
6113
- this.externalDiagnostics.push({
6114
- ...entry,
6115
- turnIndex: `server-generation.${batch}:${turnCount}`
6116
- });
6117
- turnCount += 1;
6118
- } else if (entry.kind === "message") {
6119
- messageCount += 1;
6120
- this.externalDiagnostics.push(entry);
6121
- }
6122
- }
6123
- if (externalEvents.length) mergeMetaEvents(this.sessionKey, externalEvents);
6124
- recordMetaEvent(this.sessionKey, "external-diagnostics.folded", {
6125
- interactionId,
6126
- batch,
6127
- counts: { turn: turnCount, event: externalEvents.length, message: messageCount }
6128
- });
6129
- }
6130
- const idx = this.history.findIndex((m) => m.interaction?.interactionId === interactionId);
6131
- if (idx !== -1) {
6132
- const costUsd = typeof result?.costUsd === "number" && Number.isFinite(result.costUsd) && result.costUsd > 0 ? result.costUsd : void 0;
6133
- this.history[idx] = {
6134
- ...this.history[idx],
6135
- interaction: { ...this.history[idx].interaction, resolved: result },
6136
- ...costUsd != null ? { externalCostUsd: (this.history[idx].externalCostUsd ?? 0) + costUsd } : {}
6137
- };
6138
- this.dispatchEvent(
6139
- new CustomEvent("history-updated", {
6140
- detail: this.history
6141
- })
6142
- );
6143
- }
6144
- if (typeof result?.userMessage === "string" && result.userMessage.trim()) {
6145
- this.appendToHistory({ role: "synthetic-user", content: result.userMessage });
6146
- }
6147
- if (interaction.overrideId) {
6148
- this.dispatchEvent(new CustomEvent("interaction-stop", { detail: { interactionId } }));
6149
- }
6150
- recordMetaEvent(this.sessionKey, "interaction.resolved", { interactionId });
6151
- this.activityBus.publish("interaction-resolved", void 0);
6152
- interaction.resolve(result);
6153
- this.pendingInteractions.delete(interactionId);
6154
- this.interactionContexts.get(interactionId)?.dispose();
6155
- this.interactionContexts.delete(interactionId);
6156
- } else {
6157
- logger2.warn(`Interaction with ID ${interactionId} not found.`);
6158
- }
6249
+ this.interactions.resolve(interactionId, result);
6159
6250
  }
6160
6251
  /**
6161
6252
  * The live {@link InteractionContext} for a pending interaction (GENC-1390), or
@@ -6163,23 +6254,17 @@ var ChatDriver = class _ChatDriver extends EventTarget {
6163
6254
  * host) to build the widget's pending `interaction` descriptor.
6164
6255
  */
6165
6256
  getInteractionContext(interactionId) {
6166
- return this.interactionContexts.get(interactionId)?.context;
6257
+ return this.interactions.context(interactionId);
6167
6258
  }
6168
6259
  /**
6169
- * Resolve every in-flight interaction with a `'timeout'` result, closing its
6170
- * widget read-only. Used when a sub-agent that owns the open widget times out:
6171
- * the widget is parked in *this* driver's map (interactions resolve on the
6172
- * host, not the child), so cancelling it here unblocks the sub-agent handler
6173
- * awaiting it. The "one interaction at a time" invariant means there is at
6174
- * most one.
6260
+ * Replace the history message owning `interactionId`. `false` when it is no longer in history.
6261
+ * Does not announce the change — the interaction paths differ on whether they should.
6175
6262
  */
6176
- cancelPendingInteractions(message) {
6177
- for (const interactionId of [...this.pendingInteractions.keys()]) {
6178
- this.resolveInteraction(interactionId, {
6179
- status: "timeout",
6180
- message
6181
- });
6182
- }
6263
+ patchInteractionMessage(interactionId, patch) {
6264
+ const idx = this.history.findIndex((m) => m.interaction?.interactionId === interactionId);
6265
+ if (idx === -1) return false;
6266
+ this.history[idx] = patch(this.history[idx]);
6267
+ return true;
6183
6268
  }
6184
6269
  /**
6185
6270
  * Seeds the driver with a prior conversation history so a new component instance
@@ -6187,11 +6272,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
6187
6272
  */
6188
6273
  loadHistory(messages) {
6189
6274
  this.history = [...messages];
6190
- this.dispatchEvent(
6191
- new CustomEvent("history-updated", {
6192
- detail: this.history
6193
- })
6194
- );
6275
+ this.notifyHistoryUpdated();
6195
6276
  }
6196
6277
  async sendMessage(userInput, attachments) {
6197
6278
  if (this.busy || !userInput.trim() && !attachments?.length) return { reason: "done" };
@@ -6411,7 +6492,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
6411
6492
  if (winner === "timeout") {
6412
6493
  timedOut = true;
6413
6494
  child.dispose();
6414
- this.cancelPendingInteractions(`Sub-agent "${name}" timed out after ${timeoutMs}ms.`);
6495
+ this.interactions.cancelAll(`Sub-agent "${name}" timed out after ${timeoutMs}ms.`);
6415
6496
  await runPromise.catch(() => void 0);
6416
6497
  }
6417
6498
  } finally {
@@ -6424,7 +6505,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
6424
6505
  child.removeEventListener("provider-changed", forwardProviderChanged);
6425
6506
  child.dispose();
6426
6507
  this.dispatchEvent(new CustomEvent("sub-agent-stop", { detail: lifecycleDetail }));
6427
- this.forwardSubAgentSnapshots(child.getTurnSnapshots());
6508
+ this.turnRecorder.forwardSubAgent(child.getTurnSnapshots());
6428
6509
  const parentName = this.activeAgentName ?? "?";
6429
6510
  const harvested = getMetaEvents(childSessionKey).filter((e) => HARVESTED_SUBAGENT_EVENTS.has(e.type)).map((e) => {
6430
6511
  const existing = e.detail?.subAgent;
@@ -6512,7 +6593,10 @@ var ChatDriver = class _ChatDriver extends EventTarget {
6512
6593
  // ---------------------------------------------------------------------------
6513
6594
  // Tool loop
6514
6595
  // ---------------------------------------------------------------------------
6515
- // oxlint-disable-next-line complexity
6596
+ // Over both ceilings deliberately: the per-iteration phases share a dozen accumulators, so
6597
+ // splitting threads that state through signatures rather than removing it. `turn-setup.ts`
6598
+ // (docs/code-health.md §11.3) is the intended carve-up.
6599
+ // oxlint-disable-next-line complexity, max-lines-per-function
6516
6600
  async runToolLoop(userInput, attachments, transientPrimer) {
6517
6601
  if (!this.systemPrompt) {
6518
6602
  logger2.warn(
@@ -6668,12 +6752,15 @@ ${EMPTY_RESPONSE_NUDGE}` : "";
6668
6752
  ${tailBody}
6669
6753
  </system-reminder>` : void 0;
6670
6754
  const effectiveToolChoice = resolvedToolChoice ?? (this.isSubAgent ? "required" : void 0);
6671
- const turnSnapshot = this.recordTurnSnapshot(
6755
+ const turnSnapshot = this.turnRecorder.record({
6756
+ agentName: this.activeAgentName,
6757
+ agentLabel: this.activeAgentLabel,
6672
6758
  systemPrompt,
6673
- resolvedTemperature,
6674
- effectiveToolChoice,
6675
- tailContext
6676
- );
6759
+ tailContext,
6760
+ toolNames: this.toolDefinitions.map((t) => t.name),
6761
+ temperature: resolvedTemperature,
6762
+ toolChoice: effectiveToolChoice
6763
+ });
6677
6764
  const userInputForCall = currentInput;
6678
6765
  const attachmentsForCall = currentAttachments;
6679
6766
  currentInput = "";
@@ -7175,11 +7262,7 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
7175
7262
  };
7176
7263
  });
7177
7264
  this.history[tcMsgIdx] = { ...tcMsg, toolCalls: annotatedCalls };
7178
- this.dispatchEvent(
7179
- new CustomEvent("history-updated", {
7180
- detail: this.history
7181
- })
7182
- );
7265
+ this.notifyHistoryUpdated();
7183
7266
  }
7184
7267
  if (hitUnknownToolLimit) {
7185
7268
  logger2.error(
@@ -7262,6 +7345,10 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
7262
7345
  } : {}
7263
7346
  };
7264
7347
  this.history = [...this.history, tagged];
7348
+ this.notifyHistoryUpdated();
7349
+ }
7350
+ /** Announce the current history to the host. */
7351
+ notifyHistoryUpdated() {
7265
7352
  this.dispatchEvent(
7266
7353
  new CustomEvent("history-updated", {
7267
7354
  detail: this.history