// Copyright 2026 OpenObserve Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { beforeEach, describe, expect, it, vi } from "vitest"; import { extractStreamText, reduce, type ReducerCtx, type StreamEffect, type StreamPhase, type StreamState, } from "@/components/O2AIChat.reducer"; import type { ChatMessage, NavigationAction } from "@/ts/interfaces/chat"; import { raw, type TranslateFn } from "@/types/i18n"; const t = ((key: string, named?: Record) => named ? `${key}(${JSON.stringify(named)})` : key) as unknown as TranslateFn; function makeState(over: Partial = {}): StreamState { return { messages: [], activeToolCall: null, textSegment: "", streamingMsg: "", title: undefined, lastTraceId: null, ownerUnavailable: false, pendingConfirmation: null, messageComplete: false, halted: false, ...over, }; } function makeCtx(over: Partial = {}): ReducerCtx { return { isActive: true, autoNavigationEnabled: false, phase: "stream", t, generateNavigation: () => null, ...over, }; } function assistant(over: Partial = {}): ChatMessage { return { role: "assistant", content: raw(""), contentBlocks: [], ...over }; } const kinds = (effects: StreamEffect[]) => effects.map((e) => e.kind); const bothPhases: StreamPhase[] = ["stream", "tailFlush"]; describe("extractStreamText", () => { it("returns null for non-objects", () => { expect(extractStreamText(null)).toBeNull(); expect(extractStreamText(undefined)).toBeNull(); expect(extractStreamText("hello")).toBeNull(); expect(extractStreamText(42)).toBeNull(); }); it("prefers the canonical string content field", () => { expect(extractStreamText({ content: "hi", response: "no" })).toBe("hi"); }); it("accepts an empty string content", () => { expect(extractStreamText({ content: "" })).toBe(""); }); it("ignores a non-string content and falls through", () => { expect(extractStreamText({ content: { parts: [] }, response: "fallback" })).toBe("fallback"); }); it("falls back to response", () => { expect(extractStreamText({ response: "r" })).toBe("r"); }); it("falls back to delta.content", () => { expect(extractStreamText({ delta: { content: "d" } })).toBe("d"); }); it("ignores a delta whose content is not a string", () => { expect(extractStreamText({ delta: { content: 1 }, text: "t" })).toBe("t"); }); it("falls back to choices[0].delta.content", () => { expect(extractStreamText({ choices: [{ delta: { content: "c" } }] })).toBe("c"); }); it("ignores choices that are not an array", () => { expect(extractStreamText({ choices: { delta: { content: "c" } }, text: "t" })).toBe("t"); }); it("ignores an empty choices array", () => { expect(extractStreamText({ choices: [] })).toBeNull(); }); it("ignores a choice with no delta", () => { expect(extractStreamText({ choices: [{}] })).toBeNull(); }); it("falls back to text last", () => { expect(extractStreamText({ text: "t" })).toBe("t"); }); it("returns null when the event carries no text", () => { expect(extractStreamText({ type: "complete", trace_id: "x" })).toBeNull(); }); }); describe("reduce: unknown events", () => { it("does nothing for a null payload", () => { const state = makeState(); expect(reduce(state, null, makeCtx())).toEqual([]); expect(state).toEqual(makeState()); }); it("does nothing for an event with no text and no known type", () => { const state = makeState(); expect(reduce(state, { type: "heartbeat" }, makeCtx())).toEqual([]); expect(state.messages).toEqual([]); }); }); describe("reduce: title", () => { it.each(bothPhases)("records the title and animates it when active (%s)", (phase) => { const state = makeState(); const effects = reduce(state, { type: "title", title: "My chat" }, makeCtx({ phase })); expect(state.title).toBe("My chat"); expect(effects).toEqual([{ kind: "animateTitle", title: "My chat" }]); }); it("records the title but does not animate when detached", () => { const state = makeState(); const effects = reduce( state, { type: "title", title: "Background" }, makeCtx({ isActive: false }), ); expect(state.title).toBe("Background"); expect(effects).toEqual([]); }); }); describe("reduce: tool_call", () => { it("sets the active tool call and scrolls during the stream phase", () => { const state = makeState(); const effects = reduce( state, { type: "tool_call", tool: "search", message: "Searching", call_id: "c1" }, makeCtx(), ); expect(state.activeToolCall).toEqual({ tool: "search", message: "Searching", context: {}, call_id: "c1", }); expect(kinds(effects)).toEqual(["finalizeText", "scroll"]); }); it("does not scroll during the tail flush", () => { const effects = reduce( makeState(), { type: "tool_call", tool: "search" }, makeCtx({ phase: "tailFlush" }), ); expect(kinds(effects)).toEqual(["finalizeText"]); }); it("does not adopt the new tool call when detached", () => { const state = makeState(); const effects = reduce( state, { type: "tool_call", tool: "search" }, makeCtx({ isActive: false }), ); expect(state.activeToolCall).toBeNull(); expect(kinds(effects)).toEqual(["scroll"]); }); it("completes a previous tool call into the last assistant message", () => { const msg = assistant(); const state = makeState({ messages: [msg], activeToolCall: { tool: "old", message: raw("Old"), context: {}, call_id: "c0" }, }); reduce(state, { type: "tool_call", tool: "new" }, makeCtx()); expect(msg.contentBlocks).toEqual([ { type: "tool_call", tool: "old", message: "Old", context: {}, call_id: "c0" }, ]); }); it("creates an assistant placeholder when the last message is a user message", () => { const state = makeState({ messages: [{ role: "user", content: raw("hi") }], activeToolCall: { tool: "old", message: raw("Old"), context: {} }, }); reduce(state, { type: "tool_call", tool: "new" }, makeCtx()); expect(state.messages).toHaveLength(2); expect(state.messages[1].role).toBe("assistant"); }); it("flushes the pending text segment into the last text block", () => { const msg = assistant({ contentBlocks: [{ type: "text", text: "par" }] }); const state = makeState({ messages: [msg], textSegment: "partial text" }); const effects = reduce(state, { type: "tool_call", tool: "x" }, makeCtx()); expect(msg.contentBlocks?.[0]).toEqual({ type: "text", text: "partial text" }); expect(state.textSegment).toBe(""); expect(kinds(effects)).toEqual(["finalizeText", "scroll"]); }); it("emits finalizeText even when there is no pending text segment", () => { const effects = reduce(makeState(), { type: "tool_call", tool: "x" }, makeCtx()); expect(kinds(effects)).toContain("finalizeText"); }); it("does not emit finalizeText when detached", () => { const state = makeState({ textSegment: "abc" }); const effects = reduce(state, { type: "tool_call", tool: "x" }, makeCtx({ isActive: false })); expect(kinds(effects)).toEqual(["scroll"]); expect(state.textSegment).toBe(""); }); }); describe("reduce: complete", () => { it.each(bothPhases)("captures the trace id when active (%s)", (phase) => { const state = makeState(); reduce(state, { type: "complete", trace_id: "tr-1" }, makeCtx({ phase })); expect(state.lastTraceId).toBe("tr-1"); }); it("ignores the trace id when detached", () => { const state = makeState(); reduce(state, { type: "complete", trace_id: "tr-1" }, makeCtx({ isActive: false })); expect(state.lastTraceId).toBeNull(); }); it("completes an active tool call and clears it", () => { const msg = assistant(); const state = makeState({ messages: [msg], activeToolCall: { tool: "search", message: raw("Searching"), context: {} }, }); reduce(state, { type: "complete" }, makeCtx()); expect(msg.contentBlocks).toHaveLength(1); expect(state.activeToolCall).toBeNull(); }); // An action-only turn produces no text, so `complete` is its only chance to persist. it("saves a turn that finished a tool call without producing any text", () => { const state = makeState({ messages: [assistant()], activeToolCall: { tool: "createDashboard", message: raw("Creating"), context: {} }, }); expect(reduce(state, { type: "complete" }, makeCtx())).toEqual([ { kind: "throttledSave", force: true }, ]); }); it("does not save a turn that already streamed text", () => { const state = makeState({ messages: [assistant()], messageComplete: true }); state.activeToolCall = { tool: "search", message: raw("Searching"), context: {} }; expect(reduce(state, { type: "complete" }, makeCtx())).toEqual([]); }); it("saves an action-only turn when detached, against the captured messages", () => { const state = makeState({ messages: [assistant()], activeToolCall: { tool: "search", message: raw("Searching"), context: {} }, }); expect(reduce(state, { type: "complete" }, makeCtx({ isActive: false }))).toEqual([ { kind: "throttledSave", force: true }, ]); }); it("does not save a detached turn with no assistant content blocks", () => { const state = makeState({ messages: [{ role: "user", content: raw("hi") }] }); expect(reduce(state, { type: "complete" }, makeCtx({ isActive: false }))).toEqual([]); }); it("keeps the active tool call set when detached", () => { const active = { tool: "search", message: raw("Searching"), context: {} }; const state = makeState({ messages: [assistant()], activeToolCall: active }); reduce(state, { type: "complete" }, makeCtx({ isActive: false })); expect(state.activeToolCall).toBe(active); }); }); describe("reduce: error", () => { it("flags an unavailable owner and does not halt", () => { const state = makeState(); const effects = reduce(state, { type: "error", code: "session_owner_unavailable" }, makeCtx()); expect(state.ownerUnavailable).toBe(true); expect(state.halted).toBe(false); expect(effects).toEqual([]); expect(state.messages).toEqual([]); }); it.each(bothPhases)("appends an error message, saves, scrolls and halts (%s)", (phase) => { const state = makeState({ textSegment: "leftover" }); const effects = reduce(state, { type: "error", error: "boom" }, makeCtx({ phase })); expect(state.messages).toHaveLength(1); expect(state.messages[0].contentBlocks?.[0].text).toBe( 'common.errorPrefix({"message":"boom"})', ); expect(state.textSegment).toBe(""); expect(state.halted).toBe(true); expect(kinds(effects)).toEqual(["syncSegments", "save", "scroll"]); }); it("defers the scroll gate to the shell rather than deciding it when detached", () => { const effects = reduce( makeState(), { type: "error", error: "boom" }, makeCtx({ isActive: false }), ); expect(effects).toEqual([ { kind: "syncSegments" }, { kind: "save" }, { kind: "scroll", whenActive: true }, ]); }); it("prefers error over message and falls back to a generic string", () => { const withMessage = makeState(); reduce(withMessage, { type: "error", message: "m" }, makeCtx()); expect(withMessage.messages[0].contentBlocks?.[0].text).toBe( 'common.errorPrefix({"message":"m"})', ); const bare = makeState(); reduce(bare, { type: "error" }, makeCtx()); expect(bare.messages[0].contentBlocks?.[0].text).toBe( 'common.errorPrefix({"message":"aiAssistant.aiChat.unexpectedError"})', ); }); it("stringifies a non-string error payload", () => { const state = makeState(); reduce(state, { type: "error", error: { detail: "x" } }, makeCtx()); expect(state.messages[0].contentBlocks?.[0].text).toContain('\\"detail\\": \\"x\\"'); }); it("appends the suggestion when present", () => { const state = makeState(); reduce(state, { type: "error", error: "boom", suggestion: "retry" }, makeCtx()); expect(state.messages[0].contentBlocks?.[0].text).toBe( 'common.errorPrefix({"message":"boom"})\n\nretry', ); }); it("replaces an auth error with the unauthorized copy and drops the suggestion", () => { const state = makeState(); reduce(state, { type: "error", error: "Unauthorized access", suggestion: "retry" }, makeCtx()); expect(state.messages[0].contentBlocks?.[0].text).toBe("common.unauthorizedAccess"); }); it("appends to an existing assistant message instead of pushing a new one", () => { const msg = assistant({ content: raw("earlier"), contentBlocks: [] }); const state = makeState({ messages: [msg] }); reduce(state, { type: "error", error: "boom" }, makeCtx()); expect(state.messages).toHaveLength(1); expect(msg.content).toBe('earlier\n\ncommon.errorPrefix({"message":"boom"})'); expect(msg.contentBlocks).toHaveLength(1); }); it("replaces empty content on an existing assistant message", () => { const msg = assistant(); const state = makeState({ messages: [msg] }); reduce(state, { type: "error", error: "boom" }, makeCtx()); expect(msg.content).toBe('common.errorPrefix({"message":"boom"})'); }); it("completes an active tool call before reporting the error", () => { const msg = assistant(); const state = makeState({ messages: [msg], activeToolCall: { tool: "search", message: raw("Searching"), context: {} }, }); reduce(state, { type: "error", error: "boom" }, makeCtx()); expect(msg.contentBlocks?.[0].type).toBe("tool_call"); expect(msg.contentBlocks?.[1].type).toBe("text"); expect(state.activeToolCall).toBeNull(); }); }); describe("reduce: tool_result", () => { const result = { type: "tool_result", tool: "search", call_id: "c1", message: "done", summary: { count: 2 }, }; it("completes the matching active tool call with the result data", () => { const msg = assistant(); const state = makeState({ messages: [msg], activeToolCall: { tool: "search", message: raw("Searching"), context: {}, call_id: "c1" }, }); const effects = reduce(state, result, makeCtx()); expect(msg.contentBlocks?.[0]).toMatchObject({ type: "tool_call", success: true, resultMessage: "done", summary: { count: 2 }, }); expect(state.activeToolCall).toBeNull(); expect(kinds(effects)).toEqual(["scroll"]); }); it("matches on tool name when the event carries no call_id", () => { const msg = assistant(); const state = makeState({ messages: [msg], activeToolCall: { tool: "search", message: raw("Searching"), context: {}, call_id: "c1" }, }); reduce(state, { type: "tool_result", tool: "search", success: false }, makeCtx()); expect(msg.contentBlocks?.[0]).toMatchObject({ success: false }); }); it("retroactively enriches an already completed block by call_id", () => { const msg = assistant({ contentBlocks: [{ type: "tool_call", tool: "search", call_id: "c1" }], }); const state = makeState({ messages: [msg] }); reduce(state, result, makeCtx()); expect(msg.contentBlocks?.[0]).toMatchObject({ resultMessage: "done", success: true }); }); it("retroactively enriches the last unresolved block matched by tool name", () => { const msg = assistant({ contentBlocks: [ { type: "tool_call", tool: "search", success: true }, { type: "tool_call", tool: "search" }, ], }); const state = makeState({ messages: [msg] }); reduce(state, { type: "tool_result", tool: "search", message: "second" }, makeCtx()); expect(msg.contentBlocks?.[1].resultMessage).toBe("second"); expect(msg.contentBlocks?.[0].resultMessage).toBeUndefined(); }); it("does nothing when no block matches", () => { const msg = assistant({ contentBlocks: [{ type: "text", text: "hi" }] }); const state = makeState({ messages: [msg] }); reduce(state, result, makeCtx()); expect(msg.contentBlocks).toEqual([{ type: "text", text: "hi" }]); }); it("attaches a derived navigation action during the stream phase", () => { const action: NavigationAction = { resource_type: "logs", action: "load_query", label: raw("View"), target: {}, }; const generateNavigation = vi.fn(() => action); const msg = assistant(); const state = makeState({ messages: [msg], activeToolCall: { tool: "search", message: raw("S"), context: {}, call_id: "c1" }, }); reduce(state, { ...result, call_args: { a: 1 } }, makeCtx({ generateNavigation })); expect(generateNavigation).toHaveBeenCalledWith("search", { a: 1 }, expect.anything()); expect(msg.contentBlocks?.[0].navigationAction).toBe(action); }); it("attaches a derived navigation action to a retroactively enriched block", () => { const action = { resource_type: "logs" } as unknown as NavigationAction; const msg = assistant({ contentBlocks: [{ type: "tool_call", tool: "search", call_id: "c1" }], }); const state = makeState({ messages: [msg] }); reduce( state, { ...result, call_args: { a: 1 } }, makeCtx({ generateNavigation: () => action }), ); expect(msg.contentBlocks?.[0].navigationAction).toBe(action); }); it("skips navigation for a failed tool result", () => { const generateNavigation = vi.fn(() => null); reduce( makeState({ messages: [assistant()] }), { ...result, success: false, call_args: { a: 1 } }, makeCtx({ generateNavigation }), ); expect(generateNavigation).not.toHaveBeenCalled(); }); it("emits a dashboard event when the tool mutates a dashboard", () => { const state = makeState({ messages: [assistant()] }); const effects = reduce( state, { type: "tool_result", tool: "createDashboard", call_args: { dashboard_id: "d1", folder: "f1" }, }, makeCtx(), ); expect(effects[0]).toEqual({ kind: "dashboardEvent", payload: { type: "dashboard_created", dashboardId: "d1", folderId: "f1" }, }); }); it("reads the dashboard id from nested call_args shapes", () => { const effects = reduce( makeState({ messages: [assistant()] }), { type: "tool_result", tool: "updatePanel", call_args: { request_body: { dashboard_id: "d2", folder: "f2" } }, }, makeCtx(), ); expect(effects[0]).toEqual({ kind: "dashboardEvent", payload: { type: "panel_updated", dashboardId: "d2", folderId: "f2" }, }); }); it("warns and skips the dashboard event when no dashboard id can be found", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const effects = reduce( makeState({ messages: [assistant()] }), { type: "tool_result", tool: "deleteDashboard", call_args: {} }, makeCtx(), ); expect(warn).toHaveBeenCalled(); expect(kinds(effects)).toEqual(["scroll"]); warn.mockRestore(); }); it("emits no dashboard event when detached", () => { const effects = reduce( makeState({ messages: [assistant()] }), { type: "tool_result", tool: "createDashboard", call_args: { dashboard_id: "d1" } }, makeCtx({ isActive: false }), ); expect(kinds(effects)).toEqual(["scroll"]); }); it("ignores the generic tools_call name for dashboard events", () => { const effects = reduce( makeState({ messages: [assistant()] }), { type: "tool_result", tool: "tools_call", call_args: { dashboard_id: "d1" } }, makeCtx(), ); expect(kinds(effects)).toEqual(["scroll"]); }); it("CURRENT BEHAVIOR (BUG): the tail flush derives no navigation, no dashboard event and no scroll", () => { const generateNavigation = vi.fn(() => ({}) as NavigationAction); const msg = assistant(); const state = makeState({ messages: [msg], activeToolCall: { tool: "createDashboard", message: raw("C"), context: {}, call_id: "c1" }, }); const effects = reduce( state, { type: "tool_result", tool: "createDashboard", call_id: "c1", call_args: { dashboard_id: "d1" }, }, makeCtx({ phase: "tailFlush", generateNavigation }), ); expect(generateNavigation).not.toHaveBeenCalled(); expect(msg.contentBlocks?.[0].navigationAction).toBeUndefined(); expect(effects).toEqual([]); }); }); describe("reduce: confirmation_required", () => { it("adds an inline confirmation block and scrolls", () => { const msg = assistant(); const state = makeState({ messages: [msg] }); const effects = reduce( state, { type: "confirmation_required", tool: "deleteAlert", message: "Confirm?", args: { id: 1 }, call_id: "c9", }, makeCtx(), ); expect(msg.contentBlocks?.[0]).toMatchObject({ type: "tool_call", tool: "deleteAlert", pendingConfirmation: true, confirmationMessage: "Confirm?", confirmationArgs: { id: 1 }, call_id: "c9", }); expect(state.pendingConfirmation).toEqual({ tool: "deleteAlert", args: { id: 1 }, message: "Confirm?", }); expect(kinds(effects)).toEqual(["finalizeText", "scroll"]); }); it("borrows the message, context and call_id from the active tool call", () => { const msg = assistant(); const state = makeState({ messages: [msg], activeToolCall: { tool: "deleteAlert", message: raw("Deleting"), context: { x: 1 }, call_id: "c0", }, }); reduce( state, { type: "confirmation_required", tool: "deleteAlert", message: "Confirm?" }, makeCtx(), ); expect(msg.contentBlocks?.[0]).toMatchObject({ message: "Deleting", context: { x: 1 }, call_id: "c0", }); expect(state.activeToolCall).toBeNull(); }); it("falls back to a translated confirmation prompt when the backend sends no message", () => { const state = makeState({ messages: [assistant()] }); reduce(state, { type: "confirmation_required", tool: "deleteAlert" }, makeCtx()); expect(state.pendingConfirmation?.message).toBe( 'aiAssistant.aiChat.confirmToolExecution({"tool":"deleteAlert"})', ); }); it("starts a new assistant message when the last one is from the user", () => { const state = makeState({ messages: [{ role: "user", content: raw("hi") }] }); reduce(state, { type: "confirmation_required", tool: "x", message: "m" }, makeCtx()); expect(state.messages).toHaveLength(2); expect(state.messages[1].contentBlocks?.[0].pendingConfirmation).toBe(true); }); it("auto-denies for a detached stream without touching the messages", () => { const state = makeState({ messages: [assistant()], textSegment: "x" }); const effects = reduce( state, { type: "confirmation_required", tool: "x", message: "m" }, makeCtx({ isActive: false }), ); expect(effects).toEqual([{ kind: "confirmPost", approved: false }]); expect(state.pendingConfirmation).toBeNull(); }); it("auto-approves a navigation confirmation when auto navigation is on", () => { const state = makeState({ messages: [assistant()] }); const effects = reduce( state, { type: "confirmation_required", tool: "navigation_action", message: "Go" }, makeCtx({ autoNavigationEnabled: true }), ); expect(effects).toEqual([{ kind: "finalizeText" }, { kind: "confirmPost", approved: true }]); expect(state.pendingConfirmation).toBeNull(); }); it("still asks for a navigation confirmation when auto navigation is off", () => { const state = makeState({ messages: [assistant()] }); const effects = reduce( state, { type: "confirmation_required", tool: "navigation_action", message: "Go" }, makeCtx({ autoNavigationEnabled: false }), ); expect(kinds(effects)).toEqual(["finalizeText", "scroll"]); expect(state.pendingConfirmation?.tool).toBe("navigation_action"); }); it("CURRENT BEHAVIOR (BUG): the tail flush has no confirmation handler at all", () => { const state = makeState({ messages: [assistant()], textSegment: "keep" }); const effects = reduce( state, { type: "confirmation_required", tool: "x", message: "m" }, makeCtx({ phase: "tailFlush" }), ); expect(effects).toEqual([]); expect(state.pendingConfirmation).toBeNull(); expect(state.textSegment).toBe("keep"); }); }); describe("reduce: navigation_action", () => { const event = { type: "navigation_action", resource_type: "logs", action: "load_query", label: "View in Logs", target: { query: "select 1" }, }; it("navigates immediately when auto navigation is on", () => { const state = makeState({ messages: [assistant()] }); const effects = reduce(state, event, makeCtx({ autoNavigationEnabled: true })); expect(effects).toEqual([ { kind: "finalizeText" }, { kind: "navigate", action: { resource_type: "logs", action: "load_query", label: "View in Logs", target: { query: "select 1" }, }, }, ]); expect(state.pendingConfirmation).toBeNull(); }); it("asks for confirmation when auto navigation is off", () => { const msg = assistant(); const state = makeState({ messages: [msg] }); const effects = reduce(state, event, makeCtx()); expect(msg.contentBlocks?.[0]).toMatchObject({ tool: "navigation_action", message: "View in Logs", pendingConfirmation: true, }); expect(state.pendingConfirmation?.navAction?.resource_type).toBe("logs"); expect(kinds(effects)).toEqual(["finalizeText", "scroll"]); }); it("falls back to a translated prompt when the event has no label", () => { const state = makeState({ messages: [assistant()] }); reduce(state, { ...event, label: undefined }, makeCtx()); expect(state.pendingConfirmation?.message).toBe("aiAssistant.aiChat.navigateConfirmQuestion"); }); it("flushes the text segment but skips navigation when detached", () => { const state = makeState({ messages: [assistant()], textSegment: "x" }); const effects = reduce(state, event, makeCtx({ isActive: false, autoNavigationEnabled: true })); expect(effects).toEqual([]); expect(state.textSegment).toBe(""); }); it("CURRENT BEHAVIOR (BUG): the tail flush has no navigation handler at all", () => { const state = makeState({ messages: [assistant()], textSegment: "keep" }); const effects = reduce( state, event, makeCtx({ phase: "tailFlush", autoNavigationEnabled: true }), ); expect(effects).toEqual([]); expect(state.textSegment).toBe("keep"); }); }); describe("reduce: text", () => { it("creates the assistant message and forces a save on the first delta", () => { const state = makeState(); const effects = reduce(state, { type: "message_delta", content: "Hel" }, makeCtx()); expect(state.messages[0]).toEqual({ role: "assistant", content: "Hel", contentBlocks: [{ type: "text", text: "Hel" }], }); expect(state.streamingMsg).toBe("Hel"); expect(state.messageComplete).toBe(true); expect(effects).toEqual([ { kind: "syncSegments" }, { kind: "animateText" }, { kind: "throttledSave", force: true }, { kind: "scroll", whenActive: true }, ]); }); it("appends the next delta and throttles the save", () => { const state = makeState(); const ctx = makeCtx(); reduce(state, { type: "message_delta", content: "Hel" }, ctx); const effects = reduce(state, { type: "message_delta", content: "lo" }, ctx); expect(state.messages).toHaveLength(1); expect(state.messages[0].content).toBe("Hello"); expect(state.messages[0].contentBlocks?.[0].text).toBe("Hello"); expect(effects).toEqual([ { kind: "syncSegments" }, { kind: "animateText" }, { kind: "throttledSave", force: false }, { kind: "scroll", whenActive: true }, ]); }); it("writes the full accumulated segment into the last text block, not just the delta", () => { const state = makeState(); const ctx = makeCtx(); reduce(state, { type: "message_delta", content: "abc" }, ctx); reduce(state, { type: "message_delta", content: "def" }, ctx); expect(state.messages[0].contentBlocks?.[0].text).toBe("abcdef"); expect(state.textSegment).toBe("abcdef"); }); it("starts a new text block after a tool call block", () => { const msg = assistant({ contentBlocks: [{ type: "tool_call", tool: "search" }] }); const state = makeState({ messages: [msg] }); reduce(state, { type: "message_delta", content: "hi" }, makeCtx()); expect(msg.contentBlocks).toHaveLength(2); expect(msg.contentBlocks?.[1]).toEqual({ type: "text", text: "hi" }); }); it("completes an active tool call before appending text", () => { const msg = assistant(); const state = makeState({ messages: [msg], activeToolCall: { tool: "search", message: raw("S"), context: {} }, }); reduce(state, { type: "message_delta", content: "hi" }, makeCtx()); expect(msg.contentBlocks?.[0].type).toBe("tool_call"); expect(msg.contentBlocks?.[1]).toEqual({ type: "text", text: "hi" }); expect(state.activeToolCall).toBeNull(); }); it("reformats code fences for full messages but not for deltas", () => { const full = makeState(); reduce(full, { type: "message", content: "```js let a = 1```" }, makeCtx()); expect(full.streamingMsg).toBe("```js\nlet a = 1\n```"); const delta = makeState(); reduce(delta, { type: "message_delta", content: "```js let a = 1```" }, makeCtx()); expect(delta.streamingMsg).toBe("```js let a = 1```"); }); it("separates a full message that starts a new segment after a tool call", () => { const state = makeState({ streamingMsg: "before", textSegment: "" }); reduce(state, { type: "message", content: "after" }, makeCtx()); expect(state.streamingMsg).toBe("before\n\nafter"); expect(state.textSegment).toBe("after"); }); it("separates consecutive full messages inside the same segment", () => { const state = makeState({ streamingMsg: "one", textSegment: "one" }); reduce(state, { type: "message", content: "two" }, makeCtx()); expect(state.streamingMsg).toBe("one\n\ntwo"); expect(state.textSegment).toBe("one\n\ntwo"); }); it("does not insert a separator when the previous text already ends in a newline", () => { const state = makeState({ streamingMsg: "one\n", textSegment: "one\n" }); reduce(state, { type: "message", content: "two" }, makeCtx()); expect(state.streamingMsg).toBe("one\ntwo"); }); it("does not insert a separator when the new content starts with a newline", () => { const state = makeState({ streamingMsg: "one", textSegment: "one" }); reduce(state, { type: "message", content: "\ntwo" }, makeCtx()); expect(state.streamingMsg).toBe("one\ntwo"); }); it("treats a fallback field as a delta even without the message_delta type", () => { const state = makeState({ streamingMsg: "one", textSegment: "one" }); reduce(state, { type: "message", text: "```js x```" }, makeCtx()); expect(state.streamingMsg).toBe("one```js x```"); }); it("does not animate when detached and leaves the scroll gate to the shell", () => { const state = makeState(); const effects = reduce( state, { type: "message_delta", content: "hi" }, makeCtx({ isActive: false }), ); expect(effects).toEqual([ { kind: "syncSegments" }, { kind: "throttledSave", force: true }, { kind: "scroll", whenActive: true }, ]); expect(state.messages).toHaveLength(1); }); it("CURRENT BEHAVIOR (BUG): the tail flush never scrolls", () => { const effects = reduce( makeState(), { type: "message_delta", content: "hi" }, makeCtx({ phase: "tailFlush" }), ); expect(effects).toEqual([ { kind: "syncSegments" }, { kind: "animateText" }, { kind: "throttledSave", force: true }, ]); }); }); describe("reduce: live-gated effects", () => { it("marks every isActive-gated scroll whenActive, so the shell re-reads isActive after its awaits", () => { const events: any[] = [ { type: "tool_call", tool: "x" }, { type: "error", error: "boom" }, { type: "tool_result", tool: "search" }, { type: "message_delta", content: "hi" }, ]; for (const event of events) { const effects = reduce(makeState({ messages: [assistant()] }), event, makeCtx()); const scroll = effects.find((e) => e.kind === "scroll"); expect(scroll).toEqual({ kind: "scroll", whenActive: true }); } }); it("leaves the two unconditional scrolls unconditional", () => { for (const event of [ { type: "confirmation_required", tool: "x", message: "m" }, { type: "navigation_action", label: "Go", target: {} }, ]) { const effects = reduce(makeState({ messages: [assistant()] }), event, makeCtx()); expect(effects.find((e) => e.kind === "scroll")).toEqual({ kind: "scroll" }); } }); }); describe("reduce: segment-ref syncing", () => { const nonTextEvents: any[] = [ { type: "title", title: "T" }, { type: "complete", trace_id: "tr" }, { type: "tool_result", tool: "search", message: "done" }, { type: "confirmation_required", tool: "x", message: "m" }, { type: "navigation_action", label: "Go", target: {} }, ]; it.each(nonTextEvents)("leaves the segment refs alone for %o", (event) => { const effects = reduce(makeState({ messages: [assistant()] }), event, makeCtx()); expect(kinds(effects)).not.toContain("syncSegments"); }); it("syncs only via finalizeText on tool_call, as localFinalizeTextBlock did", () => { const effects = reduce(makeState(), { type: "tool_call", tool: "x" }, makeCtx()); expect(kinds(effects)).toEqual(["finalizeText", "scroll"]); }); it("emits no sync at all on tool_call when detached", () => { const effects = reduce( makeState(), { type: "tool_call", tool: "x" }, makeCtx({ isActive: false }), ); expect(kinds(effects)).not.toContain("finalizeText"); expect(kinds(effects)).not.toContain("syncSegments"); }); it("syncs before the save and before the scroll on the error path", () => { const order = kinds(reduce(makeState(), { type: "error", error: "boom" }, makeCtx())); expect(order.indexOf("syncSegments")).toBeLessThan(order.indexOf("save")); expect(order.indexOf("syncSegments")).toBeLessThan(order.indexOf("scroll")); }); it("syncs first on the text path, before the animation and the save", () => { const order = kinds(reduce(makeState(), { type: "message_delta", content: "hi" }, makeCtx())); expect(order[0]).toBe("syncSegments"); expect(order.indexOf("syncSegments")).toBeLessThan(order.indexOf("animateText")); expect(order.indexOf("syncSegments")).toBeLessThan(order.indexOf("throttledSave")); }); it("re-attach window: a tool_result after a finalized segment must not re-sync", () => { const msg = assistant({ contentBlocks: [{ type: "text", text: "Found 3 errors" }] }); const state = makeState({ messages: [msg], textSegment: "Found 3 errors" }); const ctx = makeCtx(); reduce(state, { type: "tool_call", tool: "search", call_id: "c1" }, ctx); expect(state.textSegment).toBe(""); const effects = reduce( state, { type: "tool_result", tool: "search", call_id: "c1", message: "done" }, ctx, ); expect(kinds(effects)).not.toContain("syncSegments"); expect(msg.contentBlocks?.[0]).toEqual({ type: "text", text: "Found 3 errors" }); }); }); describe("reduce: state identity", () => { let state: StreamState; beforeEach(() => { state = makeState(); }); it("mutates the captured messages array in place so detached streams keep writing to it", () => { const messages: ChatMessage[] = []; state = makeState({ messages }); reduce(state, { type: "message_delta", content: "hi" }, makeCtx({ isActive: false })); expect(state.messages).toBe(messages); expect(messages).toHaveLength(1); }); });