From a82949e4683880c4e2f72f2e08f196a1a6aecf41 Mon Sep 17 00:00:00 2001 From: romer8 Date: Thu, 28 May 2026 15:35:15 -0600 Subject: [PATCH 1/4] feat(engine): add onToolEnvelope callback for per-tool dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine grows an optional `onToolEnvelope({kind, envelope, dispatchedUuids})` callback in the processToolCalls + runChatSession options bags. Fires once per `visualization` / `layer_update` / `patch_update` push with the per-call delta (just-pushed UUID, NOT cumulative). Wrapped in a fireToolEnvelope helper that mirrors the existing fireStatus pattern: try/catch + console.warn, never aborts the engine loop on host bugs. Also threads `signal` into the processToolCalls options bag (previously only forwarded to executeTool). The fireToolEnvelope wrapper gates on `signal?.aborted` so a user-initiated Stop between two tool dispatches prevents further tile envelopes from reaching the host — preserves tiles already dispatched while halting downstream side effects. Undefined onToolEnvelope is a no-op: legacy consumers and tests inherit end-of-turn-only dispatch behavior. 10 new tests in engine/onToolEnvelope.test.js covering: - per-shape happy paths (visualization/layer_update/patch_update) - multi-shape single result (visualization + layer_update fires twice) - data-only tool (no envelope → no fire) - multi-tool turn ordering + per-call delta isolation - host throw containment via console.warn - subsequent tools process after host throw - signal.aborted gate suppresses post-abort fires - undefined callback is a clean no-op Phase 1 of docs/plans/2026-05-28-002-feat-chatbox-core-incremental-tile-streaming-plan.md Unit 1. --- engine/index.js | 60 ++++++ engine/onToolEnvelope.test.js | 343 ++++++++++++++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 engine/onToolEnvelope.test.js diff --git a/engine/index.js b/engine/index.js index c0bc504..06d367a 100644 --- a/engine/index.js +++ b/engine/index.js @@ -835,6 +835,18 @@ export async function processToolCalls( // preserves transient-connect behavior for callers without a cache. connectionCache = null, servers = null, + // Per-tool envelope dispatch callback (plan 2026-05-28-002 Unit 1). + // Fires once per `visualization` / `layer_update` / `patch_update` + // push with the per-call delta of `dispatchedUuids`. Host (Chatbox.jsx) + // translates each invocation into a window.dispatchEvent so tiles can + // appear incrementally instead of in one end-of-turn batch. + // Undefined preserves end-of-turn-only behavior for legacy consumers. + onToolEnvelope = null, + // AbortController signal forwarded from runChatSession. When aborted, + // the engine skips invoking onToolEnvelope for any envelopes that + // arrive after the abort — preserves tiles already dispatched but + // prevents further tile-render side effects (R7 success criterion). + signal = null, }, ) { let hadError = false; @@ -855,6 +867,23 @@ export async function processToolCalls( } }; + // Plan 2026-05-28-002 Unit 1 — per-envelope dispatch callback for + // incremental tile streaming. Same wrapper discipline as fireStatus + // (try/catch + console.warn). Gated on signal?.aborted so user-initiated + // Stop between two tool dispatches drops further envelopes from reaching + // the host (engine still runs to LLM "done" per the no-early-return rule). + const fireToolEnvelope = (payload) => { + if (!onToolEnvelope) return; + if (signal?.aborted) return; + try { + onToolEnvelope(payload); + } catch (err) { + // Host bug — log and continue. + // eslint-disable-next-line no-console + console.warn("[chatbox-core] onToolEnvelope callback threw:", err); + } + }; + for (const toolCall of toolCalls) { let toolName = toolCall?.function?.name; let args = toolCall?.function?.arguments ?? {}; @@ -1022,16 +1051,39 @@ export async function processToolCalls( if (!state.lastReturnedUuids) state.lastReturnedUuids = {}; state.lastReturnedUuids[typeKey] = viz.uuid; } + // Plan 2026-05-28-002 Unit 1 — fire per-envelope dispatch with the + // per-call delta (just-pushed UUID, NOT cumulative across the turn). + fireToolEnvelope({ + kind: "visualization", + envelope: toolResult.visualization, + dispatchedUuids: typeof viz?.uuid === "string" && viz.uuid ? [viz.uuid] : [], + }); } // Collect layer updates (from add_map_service_layer) before truncation if (toolResult && typeof toolResult === "object" && toolResult.layer_update) { state.pendingLayerUpdates.push(toolResult.layer_update); + // Layer updates carry `map_uuid`, NOT `uuid` — the existing + // dispatchedUuids slice at line ~1055 filters out non-string uuids + // and yields []; the host callback gets the same empty delta here. + // The map_uuid is reachable on envelope.map_uuid for host routing. + const lu = toolResult.layer_update; + fireToolEnvelope({ + kind: "layer_update", + envelope: lu, + dispatchedUuids: typeof lu?.uuid === "string" && lu.uuid ? [lu.uuid] : [], + }); } // Collect patch envelopes (from patch_visualization) before truncation if (toolResult && typeof toolResult === "object" && toolResult.patch_update) { state.pendingPatches.push(toolResult.patch_update); + const pu = toolResult.patch_update; + fireToolEnvelope({ + kind: "patch_update", + envelope: pu, + dispatchedUuids: typeof pu?.uuid === "string" && pu.uuid ? [pu.uuid] : [], + }); } // R16 — record patch_visualization rejections so the host chatbox can @@ -1295,6 +1347,12 @@ export async function runChatSession({ // opening fresh and closing at end-of-turn. Default null preserves // existing transient-connect behavior for callers without a cache. connectionCache = null, + // Per-tool envelope callback (plan 2026-05-28-002 Unit 1). Forwarded + // into processToolCalls; fires once per visualization/layer_update/ + // patch_update push so the host can dispatch incremental DOM events. + // Undefined keeps end-of-turn-only dispatch behavior for legacy + // consumers and tests. + onToolEnvelope = null, }) { const cacheOptions = { enabled: !!enableResultCache, conversationId }; @@ -1501,6 +1559,8 @@ export async function runChatSession({ cacheOptions, connectionCache, servers, + onToolEnvelope, + signal, }, ); diff --git a/engine/onToolEnvelope.test.js b/engine/onToolEnvelope.test.js new file mode 100644 index 0000000..02621f2 --- /dev/null +++ b/engine/onToolEnvelope.test.js @@ -0,0 +1,343 @@ +/** + * engine/onToolEnvelope.test.js — coverage for the per-tool envelope + * callback (Plan 2026-05-28-002 Unit 1). + * + * Contract: when the host supplies `onToolEnvelope` in the processToolCalls + * options bag, the engine fires the callback once per envelope push + * (`visualization`, `layer_update`, `patch_update`) with the *per-call* delta + * of dispatchedUuids (NOT cumulative). The callback never aborts the engine + * loop: host throws are swallowed via console.warn. signal?.aborted gates + * the invocation — if the user clicks Stop between two tool dispatches, the + * second envelope does not fire to the host. + * + * The callback is informational only; mirrors the existing fireStatus + * wrapper pattern from plan 2026-05-08-003 Unit 1 (onToolStatus). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { processToolCalls } from "./index.js"; +import { makeFakeClient } from "../test-helpers/fakeConn.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeFreshState() { + return { + lastChartResult: null, + lastQueryResult: null, + lastQuerySQL: null, + lastListResult: null, + lastMapResult: null, + lastHydrofabricResult: null, + pendingVisualizations: [], + pendingLayerUpdates: [], + pendingPatches: [], + rejectedPatches: [], + toolCallsThisTurn: [], + }; +} + +function makeToolCall(name, args = {}, id = `call-${name}`) { + return { id, function: { name, arguments: args } }; +} + +function makeConnections(toolResultsByName) { + const callTool = vi.fn(async ({ name }) => { + const result = toolResultsByName[name]; + if (result === undefined) { + throw new Error(`Test fixture missing result for tool: ${name}`); + } + return { data: result }; + }); + const client = makeFakeClient({ callToolImpl: callTool }); + const connections = [{ client, transport: null, protocolUsed: "http" }]; + const toolServerMap = new Map( + Object.keys(toolResultsByName).map((name) => [name, 0]), + ); + return { connections, toolServerMap }; +} + +let warnSpy; + +beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("onToolEnvelope — per-envelope dispatch", () => { + it("fires once with kind:'visualization' on a visualization push", async () => { + const { connections, toolServerMap } = makeConnections({ + create_plotly_chart: { + visualization: { uuid: "viz-1", source: "Plotly", args: {} }, + }, + }); + const calls = []; + const onToolEnvelope = vi.fn((payload) => calls.push(payload)); + + await processToolCalls( + [makeToolCall("create_plotly_chart")], + [], + connections, + toolServerMap, + makeFreshState(), + "", + { onToolEnvelope }, + ); + + expect(onToolEnvelope).toHaveBeenCalledTimes(1); + expect(calls[0].kind).toBe("visualization"); + expect(calls[0].envelope).toEqual({ + uuid: "viz-1", + source: "Plotly", + args: {}, + }); + expect(calls[0].dispatchedUuids).toEqual(["viz-1"]); + }); + + it("fires once with kind:'layer_update' on a layer_update push (uses map_uuid)", async () => { + const { connections, toolServerMap } = makeConnections({ + add_wms_layer: { + layer_update: { map_uuid: "map-1", layer: { type: "wms", url: "x" } }, + }, + }); + const calls = []; + const onToolEnvelope = vi.fn((payload) => calls.push(payload)); + + await processToolCalls( + [makeToolCall("add_wms_layer")], + [], + connections, + toolServerMap, + makeFreshState(), + "", + { onToolEnvelope }, + ); + + expect(onToolEnvelope).toHaveBeenCalledTimes(1); + expect(calls[0].kind).toBe("layer_update"); + expect(calls[0].envelope.map_uuid).toBe("map-1"); + // The dispatchedUuids slice maps `uuid` from each envelope; layer_update + // envelopes carry no `uuid` field (only `map_uuid`), so the per-call + // delta is empty. This is consistent with the existing slice logic at + // engine/index.js:1060-1063 which filters out non-string uuids. + expect(calls[0].dispatchedUuids).toEqual([]); + }); + + it("fires once with kind:'patch_update' on a patch_update push", async () => { + const { connections, toolServerMap } = makeConnections({ + patch_visualization: { + patch_update: { uuid: "viz-1", ops: [{ op: "replace", path: "/x", value: 1 }] }, + }, + }); + const calls = []; + const onToolEnvelope = vi.fn((payload) => calls.push(payload)); + + await processToolCalls( + [makeToolCall("patch_visualization")], + [], + connections, + toolServerMap, + makeFreshState(), + "", + { onToolEnvelope }, + ); + + expect(onToolEnvelope).toHaveBeenCalledTimes(1); + expect(calls[0].kind).toBe("patch_update"); + expect(calls[0].envelope.uuid).toBe("viz-1"); + expect(calls[0].dispatchedUuids).toEqual(["viz-1"]); + }); + + it("fires twice when one tool result carries both visualization and layer_update", async () => { + const { connections, toolServerMap } = makeConnections({ + compound_tool: { + visualization: { uuid: "viz-1", source: "Map", args: {} }, + layer_update: { map_uuid: "viz-1", layer: { type: "wms" } }, + }, + }); + const calls = []; + const onToolEnvelope = vi.fn((payload) => calls.push(payload)); + + await processToolCalls( + [makeToolCall("compound_tool")], + [], + connections, + toolServerMap, + makeFreshState(), + "", + { onToolEnvelope }, + ); + + expect(onToolEnvelope).toHaveBeenCalledTimes(2); + expect(calls.map((c) => c.kind)).toEqual(["visualization", "layer_update"]); + // Per-call delta: each callback's dispatchedUuids reflects ONLY what was + // pushed at that moment, not cumulative across the two pushes. + expect(calls[0].dispatchedUuids).toEqual(["viz-1"]); + expect(calls[1].dispatchedUuids).toEqual([]); // layer_update has no uuid field + }); + + it("does NOT fire when a tool returns no envelope", async () => { + const { connections, toolServerMap } = makeConnections({ + list_intake_plugins: { plugins: ["a", "b"] }, + }); + const onToolEnvelope = vi.fn(); + + await processToolCalls( + [makeToolCall("list_intake_plugins")], + [], + connections, + toolServerMap, + makeFreshState(), + "", + { onToolEnvelope }, + ); + + expect(onToolEnvelope).not.toHaveBeenCalled(); + }); + + it("fires in tool-execution order across a multi-tool turn", async () => { + const { connections, toolServerMap } = makeConnections({ + t1: { visualization: { uuid: "uuid-1", source: "Plotly", args: {} } }, + t2: { visualization: { uuid: "uuid-2", source: "Plotly", args: {} } }, + t3: { visualization: { uuid: "uuid-3", source: "Plotly", args: {} } }, + }); + const calls = []; + const onToolEnvelope = vi.fn((payload) => calls.push(payload)); + + await processToolCalls( + [makeToolCall("t1"), makeToolCall("t2"), makeToolCall("t3")], + [], + connections, + toolServerMap, + makeFreshState(), + "", + { onToolEnvelope }, + ); + + expect(onToolEnvelope).toHaveBeenCalledTimes(3); + expect(calls.map((c) => c.envelope.uuid)).toEqual(["uuid-1", "uuid-2", "uuid-3"]); + // Each call's dispatchedUuids is per-call (just the one freshly pushed), + // NOT cumulative across the turn. + expect(calls[0].dispatchedUuids).toEqual(["uuid-1"]); + expect(calls[1].dispatchedUuids).toEqual(["uuid-2"]); + expect(calls[2].dispatchedUuids).toEqual(["uuid-3"]); + }); +}); + +describe("onToolEnvelope — host bug containment", () => { + it("swallows host callback exceptions and logs via console.warn", async () => { + const { connections, toolServerMap } = makeConnections({ + create_plotly_chart: { + visualization: { uuid: "viz-1", source: "Plotly", args: {} }, + }, + }); + const onToolEnvelope = vi.fn(() => { + throw new Error("host bug"); + }); + + // Engine completes normally even though host throws. + await expect( + processToolCalls( + [makeToolCall("create_plotly_chart")], + [], + connections, + toolServerMap, + makeFreshState(), + "", + { onToolEnvelope }, + ), + ).resolves.toBeDefined(); + + expect(onToolEnvelope).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("[chatbox-core] onToolEnvelope callback threw"), + expect.any(Error), + ); + }); + + it("subsequent tools still process after a host throw", async () => { + const { connections, toolServerMap } = makeConnections({ + t1: { visualization: { uuid: "uuid-1", source: "Plotly", args: {} } }, + t2: { visualization: { uuid: "uuid-2", source: "Plotly", args: {} } }, + }); + let callCount = 0; + const onToolEnvelope = vi.fn(() => { + callCount += 1; + if (callCount === 1) throw new Error("host bug"); + }); + + await processToolCalls( + [makeToolCall("t1"), makeToolCall("t2")], + [], + connections, + toolServerMap, + makeFreshState(), + "", + { onToolEnvelope }, + ); + + expect(onToolEnvelope).toHaveBeenCalledTimes(2); + }); +}); + +describe("onToolEnvelope — abort gate", () => { + it("does NOT fire when signal.aborted is true at invocation time", async () => { + const { connections, toolServerMap } = makeConnections({ + t1: { visualization: { uuid: "uuid-1", source: "Plotly", args: {} } }, + t2: { visualization: { uuid: "uuid-2", source: "Plotly", args: {} } }, + }); + const calls = []; + const controller = new AbortController(); + + // Abort BEFORE the engine fires onToolEnvelope for t2. + const onToolEnvelope = vi.fn((payload) => { + calls.push(payload); + if (calls.length === 1) controller.abort(); + }); + + await processToolCalls( + [makeToolCall("t1"), makeToolCall("t2")], + [], + connections, + toolServerMap, + makeFreshState(), + "", + { onToolEnvelope, signal: controller.signal }, + ); + + // t1 fires; t2 does NOT (aborted between). + expect(onToolEnvelope).toHaveBeenCalledTimes(1); + expect(calls[0].envelope.uuid).toBe("uuid-1"); + }); +}); + +describe("onToolEnvelope — backward compatibility", () => { + it("undefined onToolEnvelope is a no-op and never throws", async () => { + const { connections, toolServerMap } = makeConnections({ + t1: { visualization: { uuid: "uuid-1", source: "Plotly", args: {} } }, + }); + + await expect( + processToolCalls( + [makeToolCall("t1")], + [], + connections, + toolServerMap, + makeFreshState(), + "", + {}, + ), + ).resolves.toBeDefined(); + // No assertions on warnSpy — undefined callback should never log. + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); From e7acb0dccdf061070e4d6f938f0d80731364176d Mon Sep 17 00:00:00 2001 From: romer8 Date: Thu, 28 May 2026 15:40:42 -0600 Subject: [PATCH 2/4] feat(chatbox): per-tool dispatch translation + end-of-turn no-op sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chatbox.jsx supplies onToolEnvelope to runChatSession. The callback translates each per-tool envelope into the existing window.dispatchEvent shapes — same event names, same payload contracts — so tiles render as each MCP tool returns instead of in one end-of-turn batch. Per-envelope payload shapes (mirrors existing handlers, no DashboardLayout changes): - visualization → tethysdash:add-visualization {batch:true, panels:[panel]} with the per-panel construction inlined from the end-of-turn block at lines 928-955 (resolveVisualizationUrl, client_custom_remote initialData, inlineData branch). Single-item batch preserves the stale-ref-doc boundary. - layer_update → tethysdash:update-visualization FLAT shape {uuid: map_uuid, operation: 'append_layers', layers: [layer]}. Matches the existing single-event dispatch at lines 988-994 and the handler's append_layers branch in DashboardLayout.js which reads detail.uuid and detail.layers directly. NOT batched (handler does not look at detail.updates). - patch_update → tethysdash:update-visualization {batch:true, operation:'apply_patch', patches:[{uuid, ops}]}. Mirrors the existing end-of-turn patch dispatch at lines 1066-1088. Defense-in-depth gates inside the callback: - controller.signal.aborted check (engine also gates, but a stale awaited result could land here mid-abort). - capturedTurnId !== turnIdRef.current check (user submitted a new turn while this one's tools were in flight). - No requestAnimationFrame — synchronous dispatch only per raf-timing-race-layer-dispatch-stale-ref solution doc. End-of-turn no-op sentinel (streamedDispatchFiredRef): - Reset to false at turn start (alongside setLoading(true)). - Set to true by the callback on first invocation. - Read by the three end-of-turn dispatch sites at lines ~928, ~983, ~1072 to skip dispatch when streaming already fired (R10). pendingVisualizations[], pendingLayerUpdates[], pendingPatches[] still flow through for dispatchBanner / whitelistWarning / onResult — only the dispatch is suppressed. Backward compatibility: legacy hosts that don't supply onToolEnvelope (undefined) see streamedDispatchFiredRef stay false and the existing end-of-turn dispatch fires unchanged. Phase 1 of docs/plans/2026-05-28-002-feat-chatbox-core-incremental-tile-streaming-plan.md Unit 2 (Units 2+4 merged per scope-guardian review pass 2). Note: a Chatbox.streaming.test.jsx file was drafted to drive the send flow and assert window.dispatchEvent shapes, but the existing component-test harness doesn't support the send-flow path (per the comment at the top of Chatbox.test.jsx). The contract is covered at the engine layer by Unit 1's onToolEnvelope.test.js (10 tests) and by manual smoke at PR time. A follow-up could extract the per-tool dispatch logic to a helper that's unit-testable in isolation. --- components/Chatbox.jsx | 118 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 115 insertions(+), 3 deletions(-) diff --git a/components/Chatbox.jsx b/components/Chatbox.jsx index 17624fd..25d1b54 100644 --- a/components/Chatbox.jsx +++ b/components/Chatbox.jsx @@ -648,6 +648,11 @@ export default function Chatbox({ // update-visualization dispatch sites below. Bumped on each user send; // captured at schedule time, compared at fire time. See helpers/scheduleDispatch.js. const turnIdRef = useRef(0); + // Plan 2026-05-28-002 Unit 2 — set by the per-tool onToolEnvelope + // callback on its first invocation in a turn. End-of-turn dispatch sites + // check this ref; when true, they skip dispatch because tiles already + // rendered incrementally. Resets to false at the start of each turn. + const streamedDispatchFiredRef = useRef(false); const stopGeneration = useCallback(() => { abortRef.current?.abort(); }, []); @@ -787,6 +792,11 @@ export default function Chatbox({ // taken below and threaded into the rAF freshness check. turnIdRef.current += 1; const capturedTurnId = turnIdRef.current; + // Plan 2026-05-28-002 Unit 2 — reset the streaming-dispatch sentinel + // for this turn. Set by the per-tool onToolEnvelope callback below on + // its first invocation; read by the end-of-turn dispatch sites to + // skip when streaming already fired (R10). + streamedDispatchFiredRef.current = false; setError(""); setThinkingBuffer(""); @@ -827,6 +837,93 @@ export default function Chatbox({ connectionCache: getCache(), // Inject domain-specific extensions (empty for generic sidebar) ...engineExtensions, + // Plan 2026-05-28-002 Unit 2 — per-tool envelope dispatch. + // Engine fires this once per visualization/layer_update/patch_update + // push. Translate into the existing DOM events so tiles render as + // each MCP tool returns instead of in one end-of-turn batch. Set the + // sentinel so the end-of-turn dispatch sites below skip themselves. + onToolEnvelope: ({ kind, envelope }) => { + // Defense-in-depth: engine already gates on signal?.aborted, but + // a stale awaited result could land here mid-abort. Skip dispatch + // if the abort signaled. No tiles flash in after Stop. + if (controller.signal.aborted) return; + // Freshness guard mirrors the rAF capturedTurnId pattern: drop + // dispatch if the user has already started a new turn. + if (capturedTurnId !== turnIdRef.current) return; + + streamedDispatchFiredRef.current = true; + + if (kind === "visualization") { + // Per-panel construction inlined from the end-of-turn block + // at lines 928-955. Single panel only; batch wrapper preserves + // the stale-ref-doc dispatch boundary. + const viz = envelope; + if (viz.vizType === "custom" && viz.scope && !viz.url && resolveVisualizationUrl) { + viz.url = resolveVisualizationUrl(viz); + } + let args; + if (viz.inlineData) { + args = { vizType: viz.vizType, inlineData: viz.inlineData }; + } else if (viz.vizType === "custom" && viz.scope) { + const initialData = { data: viz.args || {} }; + if (viz.dataKey) initialData[viz.dataKey] = viz.args || {}; + args = { + url: viz.url, + scope: viz.scope, + module: viz.module, + remoteType: viz.remoteType || "vite-esm", + initialData, + }; + } else { + args = viz.args; + } + const panel = { source: viz.source, args, w: viz.w, h: viz.h, uuid: viz.uuid }; + window.dispatchEvent( + new CustomEvent(ADD_VISUALIZATION_EVENT, { + detail: { batch: true, panels: [panel] }, + }), + ); + return; + } + + if (kind === "layer_update") { + // Flat shape — matches the existing single-event dispatch at + // lines 988-994 and the handler's append_layers branch in + // DashboardLayout.js which reads detail.uuid and detail.layers + // directly. NOT batched (handler does not look at detail.updates). + const lu = envelope; + if (!lu?.map_uuid || !lu?.layer) return; + window.dispatchEvent( + new CustomEvent(UPDATE_VISUALIZATION_EVENT, { + detail: { + uuid: lu.map_uuid, + operation: "append_layers", + layers: [lu.layer], + }, + }), + ); + return; + } + + if (kind === "patch_update") { + // Batched shape — matches the existing end-of-turn patch dispatch + // at lines 1066-1088. Single-entry batch keeps the host handler + // signature untouched. + const pu = envelope; + if (!pu?.uuid || !Array.isArray(pu?.ops)) return; + const entry = { uuid: pu.uuid, ops: pu.ops }; + if (pu.source) entry.source = pu.source; + window.dispatchEvent( + new CustomEvent(UPDATE_VISUALIZATION_EVENT, { + detail: { + batch: true, + operation: "apply_patch", + patches: [entry], + }, + }), + ); + } + }, onToolStatus: (status) => { // Plan 2026-05-08-003: payload is now per-tool — // {type: "tool_start" | "tool_complete", toolName, success?} @@ -925,7 +1022,12 @@ export default function Chatbox({ // event. Individual events in a loop cause duplicate grid item keys // and lost items because handleAddVisualization reads a stale ref // between dispatches (no re-render between synchronous events). - if (result.visualizations?.length > 0) { + // + // Plan 2026-05-28-002 Unit 2 — when per-tool dispatch already fired + // during the turn, skip the end-of-turn batch dispatch (R10). The + // pendingVisualizations array still flows through result.visualizations + // for downstream consumers (dispatchBanner, onResult, host hooks). + if (result.visualizations?.length > 0 && !streamedDispatchFiredRef.current) { const panels = result.visualizations.map((viz) => { // Resolve MFE URL for client_custom_remote plugins if (viz.vizType === "custom" && viz.scope && !viz.url && resolveVisualizationUrl) { @@ -977,10 +1079,14 @@ export default function Chatbox({ // visualization in the current batch (pre-existing maps from previous // sessions). These grid items already exist in React state, so the // requestAnimationFrame timing is not a concern. + // + // Plan 2026-05-28-002 Unit 2 — when streaming fired during the turn, + // skip the end-of-turn layer dispatch (R10). The per-tool callback + // already fired the flat update-visualization event for each layer. const unmatchedUpdates = Object.entries(layerUpdatesByUuid).filter( ([uuid]) => !matchedLayerUuids.has(uuid), ); - if (unmatchedUpdates.length > 0) { + if (unmatchedUpdates.length > 0 && !streamedDispatchFiredRef.current) { scheduleDispatchIfFresh({ getCurrentTurnId: () => turnIdRef.current, capturedTurnId, @@ -1069,7 +1175,13 @@ export default function Chatbox({ // never dispatch N events in a loop when a batch shape exists. // Wrapped in scheduleDispatchIfFresh so a stale Turn-N rAF callback // is skipped if Turn N+1 has started before it fires (Plan 20 #16). - if (survivingEntries.length > 0) { + // + // Plan 2026-05-28-002 Unit 2 — when streaming fired during the turn, + // skip the end-of-turn patch dispatch (R10). The per-tool callback + // already fired apply_patch events for each envelope. dispatchBanner + // / collisionWarning / whitelistWarning still compute above and reach + // the assistant-message append below. + if (survivingEntries.length > 0 && !streamedDispatchFiredRef.current) { scheduleDispatchIfFresh({ getCurrentTurnId: () => turnIdRef.current, capturedTurnId, From b14216ac332a5f1747e45f0490d46e1d709fc89d Mon Sep 17 00:00:00 2001 From: romer8 Date: Thu, 28 May 2026 15:42:58 -0600 Subject: [PATCH 3/4] feat(chatbox): emit turn-start/turn-end events; document Stop UX contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit 3: Dispatches two new window CustomEvents around the chatbox turn lifecycle so a host (DashboardLoader in tethysdash) can flip an isStreaming flag and gate per-tile edit/delete affordances during streaming: - tethysdash:turn-start fires immediately AFTER abortRef.current = controller is assigned. Inline INVARIANT comment names this sequencing so a future refactor doesn't silently move the dispatch above the assignment — a Stop click landing in the dispatch tick must reach a live controller, not null. - tethysdash:turn-end fires from the shared finally block after setLoading(false). Single dispatch site covers success, thrown error, user-initiated abort, and /clear paths uniformly because they all converge in the same finally. Events carry no payload; listeners toggle a boolean. Additive observable surface — no existing event behavior changes, no chatbox-core public component-prop changes. Unit 5: Documents the existing Stop UX contract (R8a). The current `result.aborted ? (accumulatedContent || "(Stopped)") : (result.assistantText ...)` shape at the message-append site already implements the brainstorm's R8a because the engine returns `{aborted: true, ...}` rather than throwing on abort, so the stop path routes through the success branch and naturally appends via setMessages. setError stays inert → ChatErrorPanel does NOT render on Stop. Real errors land in the catch branch which calls setError and surfaces via ChatErrorPanel — the two surfaces are visually distinct as required by R8a. Added a comment block at the content-shaping site naming the contract so the existing behavior is recognized as intentional rather than incidental and survives future refactors. Phase 1 of docs/plans/2026-05-28-002-feat-chatbox-core-incremental-tile-streaming-plan.md Units 3 + 5. 679/679 tests pass. Dist rebuilt clean. --- components/Chatbox.jsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/components/Chatbox.jsx b/components/Chatbox.jsx index 25d1b54..c798566 100644 --- a/components/Chatbox.jsx +++ b/components/Chatbox.jsx @@ -810,6 +810,15 @@ export default function Chatbox({ const controller = new AbortController(); abortRef.current = controller; + // Plan 2026-05-28-002 Unit 3 — fire turn-start window event so the + // host (DashboardLoader) can flip its isStreaming flag and lock the + // per-tile edit/delete affordances. + // + // INVARIANT: turn-start MUST fire AFTER abortRef.current is assigned + // (line 811) so a Stop click landing in this dispatch tick resolves + // to the live controller, not null. Do not reorder. + window.dispatchEvent(new CustomEvent("tethysdash:turn-start")); + try { const result = await runChatSession({ prompt: userText, @@ -995,6 +1004,15 @@ export default function Chatbox({ setMessages((prev) => [...prev, ...systemMessages]); } + // Plan 2026-05-28-002 Unit 5 — R8a: user-initiated stop renders as a + // normal assistant message via setMessages below (NOT via ChatErrorPanel). + // result.aborted=true routes through the success branch (engine returns + // {aborted: true, ...} rather than throwing), so setError is never called + // on the stop path — ChatErrorPanel stays inert. accumulatedContent is + // preserved when non-empty so a partial-streaming response survives the + // stop; otherwise we append a bare "(Stopped)" marker. + // Real errors (thrown by runChatSession) land in the catch branch below + // (line ~1230) which calls setError and surfaces via ChatErrorPanel. const content = result.aborted ? (accumulatedContent || "(Stopped)") : (result.assistantText || ""); @@ -1228,6 +1246,12 @@ export default function Chatbox({ abortRef.current = null; setToolStatus(null); setLoading(false); + // Plan 2026-05-28-002 Unit 3 — fire turn-end window event so the host + // (DashboardLoader) can flip its isStreaming flag back to false and + // re-enable per-tile edit/delete affordances. Single fire site covers + // success, thrown error, abort, and /clear paths uniformly — they all + // converge here. + window.dispatchEvent(new CustomEvent("tethysdash:turn-end")); // Clear streaming buffers regardless of how we got here. The success // path also clears them, but on abort or thrown error the partial // buffers would otherwise survive in state and flash on the next From 8bf75414359aab41f7b9fed85e9587e537a88b36 Mon Sep 17 00:00:00 2001 From: romer8 Date: Thu, 28 May 2026 15:44:36 -0600 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=200.16.0-beta.0=20=E2=80=94?= =?UTF-8?q?=20incremental=20tile=20streaming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG entry documents the two new observable surfaces (onToolEnvelope engine callback option + tethysdash:turn-start/turn-end window events) plus the streamedDispatchFiredRef sentinel that prevents double-dispatch. Bumps to 0.16.0-beta.0 on the beta dist-tag — minor bump appropriate for additive observable API surface with no breaking changes. Adjust before publishing if a different version is preferred. --- CHANGELOG.md | 20 ++++++++++++++++++++ package.json | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8586754..776c1c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.0-beta.0] — 2026-05-28 + +### Added + +- **Incremental tile streaming during chatbox turns** (plan `docs/plans/2026-05-28-002-feat-chatbox-core-incremental-tile-streaming-plan.md`, Phase 1). Per-tool envelope dispatch lets hosts render visualizations as each MCP tool returns instead of after the LLM signals end-of-turn. Two pieces of additive observable surface: + + 1. **`onToolEnvelope({kind, envelope, dispatchedUuids})` callback option** on `runChatSession` and `processToolCalls`. Fires once per `visualization` / `layer_update` / `patch_update` push with the *per-call* delta of dispatched UUIDs (NOT cumulative across the turn). Mirrors the existing `onToolStatus` callback contract: host throws are swallowed via `console.warn`, never abort the engine loop. Gated on `signal?.aborted` so a user-initiated Stop between two tool dispatches prevents further tile envelopes from reaching the host. Undefined preserves end-of-turn-only behavior for legacy consumers. + + 2. **`tethysdash:turn-start` and `tethysdash:turn-end` window events**, dispatched from `Chatbox.jsx`. `turn-start` fires immediately after `abortRef.current` is assigned (so a Stop click landing in the dispatch tick reaches a live controller); `turn-end` fires from the shared `finally` block covering success / error / abort / `/clear` uniformly. Events carry no payload; listeners toggle a boolean. Additive — no existing event behavior changes. + +- **End-of-turn dispatch sites no-op when streaming fired** (`streamedDispatchFiredRef` sentinel in `Chatbox.jsx`). Backward-compatible: when `onToolEnvelope` is not supplied or never fires, the existing end-of-turn batch dispatch runs as today. When it does fire, the three end-of-turn dispatch sites (visualization batch, unmatched-layer-updates, patch batch) skip themselves to prevent double-dispatch. The `pendingVisualizations[]` / `pendingLayerUpdates[]` / `pendingPatches[]` arrays still flow through the engine return for `dispatchBanner` and host result hooks. + +### Documented + +- **Stop UX contract (R8a)** at the message-append site in `Chatbox.jsx`. User-initiated stop routes through the success branch (engine returns `{aborted: true, ...}` rather than throwing) and naturally appends the partial accumulator content (or bare `(Stopped)` when empty) via `setMessages` — never via `ChatErrorPanel`. Real errors land in the catch branch which calls `setError` and surfaces via `ChatErrorPanel`. Comment block names the contract so the existing behavior survives future refactors. + +### Notes + +- This release is `0.16.0-beta.0` on the `beta` dist-tag. Existing `latest` consumers see no behavior change until they upgrade. The two new window events and the new engine callback option are additive surface; passive consumers (hosts that don't add listeners or supply the callback) are unaffected. + ## [0.14.0] — 2026-05-20 ### Changed diff --git a/package.json b/package.json index b3f0e3e..56946fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aquaveo/chatbox-core", - "version": "0.15.3", + "version": "0.16.0-beta.0", "description": "Generic chatbox engine, UI components, and helpers. Self-contained build — consumers only need react + styled-components.", "license": "MIT", "author": "Aquaveo LLC",