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/components/Chatbox.jsx b/components/Chatbox.jsx index 17624fd..c798566 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(""); @@ -800,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, @@ -827,6 +846,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?} @@ -898,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 || ""); @@ -925,7 +1040,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 +1097,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 +1193,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, @@ -1116,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 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(); + }); +}); 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",