diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 22:21:55 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 22:23:39 +0900 |
| commit | c333fcec32b1f90bf0da6bb14d2609c20e38a74f (patch) | |
| tree | 0db3ec77a6838c4f800c362df0de3c6cd8544431 /src/features/chat | |
| parent | 1285564f12238b22f6b39b9f3fbcecaca8456911 (diff) | |
| download | dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.tar.gz dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.zip | |
style: switch from tabs to 2-space indentation (incl. svelte)
Diffstat (limited to 'src/features/chat')
| -rw-r--r-- | src/features/chat/index.ts | 28 | ||||
| -rw-r--r-- | src/features/chat/model-select.test.ts | 88 | ||||
| -rw-r--r-- | src/features/chat/model-select.ts | 44 | ||||
| -rw-r--r-- | src/features/chat/ports.ts | 24 | ||||
| -rw-r--r-- | src/features/chat/reasoning-effort.test.ts | 68 | ||||
| -rw-r--r-- | src/features/chat/reasoning-effort.ts | 32 | ||||
| -rw-r--r-- | src/features/chat/store.svelte.ts | 724 | ||||
| -rw-r--r-- | src/features/chat/store.test.ts | 3060 | ||||
| -rw-r--r-- | src/features/chat/test-helpers.ts | 228 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 1526 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 611 | ||||
| -rw-r--r-- | src/features/chat/ui/CompactionView.svelte | 278 | ||||
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 358 | ||||
| -rw-r--r-- | src/features/chat/ui/ModelSelector.svelte | 82 | ||||
| -rw-r--r-- | src/features/chat/ui/ReasoningEffortSelector.svelte | 130 |
15 files changed, 3667 insertions, 3614 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index c120916..ddb094d 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -1,23 +1,23 @@ export type { - ProviderRetryView, - RenderedChunk, - RenderGroup, - ToolBatchEntry, + ProviderRetryView, + RenderedChunk, + RenderGroup, + ToolBatchEntry, } from "../../core/chunks"; export { groupRenderedChunks, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export type { - EffortOption, - ReasoningEffortSaveResult, - SaveReasoningEffort, + EffortOption, + ReasoningEffortSaveResult, + SaveReasoningEffort, } from "./reasoning-effort"; export { - DEFAULT_REASONING_EFFORT, - effectiveEffort, - effortOptions, - isReasoningEffort, - REASONING_EFFORT_LEVELS, + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, + isReasoningEffort, + REASONING_EFFORT_LEVELS, } from "./reasoning-effort"; export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; @@ -30,6 +30,6 @@ export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "chat", - description: "Conversation turns, composer, model selector, and metrics", + name: "chat", + description: "Conversation turns, composer, model selector, and metrics", } as const; diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts index 109cae1..deb673d 100644 --- a/src/features/chat/model-select.test.ts +++ b/src/features/chat/model-select.test.ts @@ -2,57 +2,57 @@ import { describe, expect, it } from "vitest"; import { joinModelName, modelKeys, modelsForKey, splitModelName } from "./model-select"; describe("splitModelName", () => { - it("splits on the first slash", () => { - expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" }); - }); - - it("keeps slashes in the model part (splits only the first)", () => { - expect(splitModelName("openrouter/anthropic/claude")).toEqual({ - key: "openrouter", - model: "anthropic/claude", - }); - }); - - it("treats a slashless name as all key", () => { - expect(splitModelName("local")).toEqual({ key: "local", model: "" }); - }); + it("splits on the first slash", () => { + expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" }); + }); + + it("keeps slashes in the model part (splits only the first)", () => { + expect(splitModelName("openrouter/anthropic/claude")).toEqual({ + key: "openrouter", + model: "anthropic/claude", + }); + }); + + it("treats a slashless name as all key", () => { + expect(splitModelName("local")).toEqual({ key: "local", model: "" }); + }); }); describe("joinModelName", () => { - it("recombines key + model", () => { - expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4"); - }); - - it("returns just the key when the model is empty", () => { - expect(joinModelName("local", "")).toBe("local"); - }); - - it("round-trips with splitModelName", () => { - const full = "openrouter/anthropic/claude"; - const { key, model } = splitModelName(full); - expect(joinModelName(key, model)).toBe(full); - }); + it("recombines key + model", () => { + expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4"); + }); + + it("returns just the key when the model is empty", () => { + expect(joinModelName("local", "")).toBe("local"); + }); + + it("round-trips with splitModelName", () => { + const full = "openrouter/anthropic/claude"; + const { key, model } = splitModelName(full); + expect(joinModelName(key, model)).toBe(full); + }); }); describe("modelKeys", () => { - it("returns distinct keys in first-seen order", () => { - expect( - modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]), - ).toEqual(["openai", "anthropic", "google"]); - }); - - it("is empty for no models", () => { - expect(modelKeys([])).toEqual([]); - }); + it("returns distinct keys in first-seen order", () => { + expect( + modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]), + ).toEqual(["openai", "anthropic", "google"]); + }); + + it("is empty for no models", () => { + expect(modelKeys([])).toEqual([]); + }); }); describe("modelsForKey", () => { - it("returns the model suffixes under a key, in order", () => { - const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; - expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]); - }); - - it("returns empty for an unknown key", () => { - expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]); - }); + it("returns the model suffixes under a key, in order", () => { + const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; + expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]); + }); + + it("returns empty for an unknown key", () => { + expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]); + }); }); diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts index b1d70b9..db41772 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -8,42 +8,42 @@ */ export interface SplitModel { - readonly key: string; - readonly model: string; + readonly key: string; + readonly model: string; } /** Split `<key>/<model>` on the first slash. A slashless name is all-key. */ export function splitModelName(full: string): SplitModel { - const i = full.indexOf("/"); - if (i === -1) return { key: full, model: "" }; - return { key: full.slice(0, i), model: full.slice(i + 1) }; + const i = full.indexOf("/"); + if (i === -1) return { key: full, model: "" }; + return { key: full.slice(0, i), model: full.slice(i + 1) }; } /** Recombine a key + model into a `<key>/<model>` name (key-only if no model). */ export function joinModelName(key: string, model: string): string { - return model === "" ? key : `${key}/${model}`; + return model === "" ? key : `${key}/${model}`; } /** Distinct keys across all models, in first-seen order. */ export function modelKeys(models: readonly string[]): string[] { - const seen = new Set<string>(); - const out: string[] = []; - for (const full of models) { - const { key } = splitModelName(full); - if (!seen.has(key)) { - seen.add(key); - out.push(key); - } - } - return out; + const seen = new Set<string>(); + const out: string[] = []; + for (const full of models) { + const { key } = splitModelName(full); + if (!seen.has(key)) { + seen.add(key); + out.push(key); + } + } + return out; } /** The model suffixes available under a given key, in order. */ export function modelsForKey(models: readonly string[], key: string): string[] { - const out: string[] = []; - for (const full of models) { - const split = splitModelName(full); - if (split.key === key) out.push(split.model); - } - return out; + const out: string[] = []; + for (const full of models) { + const split = splitModelName(full); + if (split.key === key) out.push(split.model); + } + return out; } diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts index ffe2c94..2fe10dc 100644 --- a/src/features/chat/ports.ts +++ b/src/features/chat/ports.ts @@ -1,8 +1,8 @@ import type { - ChatQueueMessage, - ChatSendMessage, - ConversationHistoryResponse, - ConversationMetricsResponse, + ChatQueueMessage, + ChatSendMessage, + ConversationHistoryResponse, + ConversationMetricsResponse, } from "@dispatch/transport-contract"; /** @@ -11,7 +11,7 @@ import type { * auto-starts a turn if idle). */ export interface ChatTransport { - send(msg: ChatSendMessage | ChatQueueMessage): void; + send(msg: ChatSendMessage | ChatQueueMessage): void; } /** @@ -19,10 +19,10 @@ export interface ChatTransport { * Both must be POSITIVE integers when present (the server 400s otherwise). */ export interface HistoryWindow { - /** Return only the NEWEST `limit` chunks of the selection (still ascending). */ - readonly limit?: number; - /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */ - readonly beforeSeq?: number; + /** Return only the NEWEST `limit` chunks of the selection (still ascending). */ + readonly limit?: number; + /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */ + readonly beforeSeq?: number; } /** @@ -34,9 +34,9 @@ export interface HistoryWindow { * satisfies this naturally). */ export type HistorySync = ( - conversationId: string, - sinceSeq: number, - window?: HistoryWindow, + conversationId: string, + sinceSeq: number, + window?: HistoryWindow, ) => Promise<ConversationHistoryResponse>; /** Injected metrics-sync port — fetches persisted per-turn metrics from the server. */ diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts index 8f76dea..6d409e9 100644 --- a/src/features/chat/reasoning-effort.test.ts +++ b/src/features/chat/reasoning-effort.test.ts @@ -1,45 +1,45 @@ import { describe, expect, it } from "vitest"; import { - DEFAULT_REASONING_EFFORT, - effectiveEffort, - effortOptions, - isReasoningEffort, - REASONING_EFFORT_LEVELS, + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, + isReasoningEffort, + REASONING_EFFORT_LEVELS, } from "./reasoning-effort"; describe("reasoning-effort helpers", () => { - it("ladder matches the wire contract, in ascending depth order", () => { - expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]); - }); + it("ladder matches the wire contract, in ascending depth order", () => { + expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]); + }); - it("the server default is high", () => { - expect(DEFAULT_REASONING_EFFORT).toBe("high"); - }); + it("the server default is high", () => { + expect(DEFAULT_REASONING_EFFORT).toBe("high"); + }); - it("isReasoningEffort narrows ladder strings and rejects everything else", () => { - for (const level of REASONING_EFFORT_LEVELS) { - expect(isReasoningEffort(level)).toBe(true); - } - expect(isReasoningEffort("banana")).toBe(false); - expect(isReasoningEffort("")).toBe(false); - expect(isReasoningEffort("HIGH")).toBe(false); - }); + it("isReasoningEffort narrows ladder strings and rejects everything else", () => { + for (const level of REASONING_EFFORT_LEVELS) { + expect(isReasoningEffort(level)).toBe(true); + } + expect(isReasoningEffort("banana")).toBe(false); + expect(isReasoningEffort("")).toBe(false); + expect(isReasoningEffort("HIGH")).toBe(false); + }); - it("effectiveEffort maps null (never set) to the default, not 'off'", () => { - expect(effectiveEffort(null)).toBe("high"); - }); + it("effectiveEffort maps null (never set) to the default, not 'off'", () => { + expect(effectiveEffort(null)).toBe("high"); + }); - it("effectiveEffort passes a persisted value through", () => { - expect(effectiveEffort("xhigh")).toBe("xhigh"); - expect(effectiveEffort("low")).toBe("low"); - }); + it("effectiveEffort passes a persisted value through", () => { + expect(effectiveEffort("xhigh")).toBe("xhigh"); + expect(effectiveEffort("low")).toBe("low"); + }); - it("effortOptions lists every level once and marks only the default", () => { - const options = effortOptions(); - expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); - expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); - for (const option of options) { - if (option.value !== "high") expect(option.label).toBe(option.value); - } - }); + it("effortOptions lists every level once and marks only the default", () => { + const options = effortOptions(); + expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); + expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); + for (const option of options) { + if (option.value !== "high") expect(option.label).toBe(option.value); + } + }); }); diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts index 2a55089..1eb77b6 100644 --- a/src/features/chat/reasoning-effort.ts +++ b/src/features/chat/reasoning-effort.ts @@ -13,11 +13,11 @@ import type { ReasoningEffort } from "@dispatch/transport-contract"; /** The canonical ladder, in ascending thinking-depth order (`[email protected]`). */ export const REASONING_EFFORT_LEVELS: readonly ReasoningEffort[] = [ - "low", - "medium", - "high", - "xhigh", - "max", + "low", + "medium", + "high", + "xhigh", + "max", ]; /** The server's fallback when nothing is set (the resolution chain's tail). */ @@ -25,7 +25,7 @@ export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high"; /** Narrow an untrusted string (e.g. a `<select>` value) to the ladder. */ export function isReasoningEffort(value: string): value is ReasoningEffort { - return (REASONING_EFFORT_LEVELS as readonly string[]).includes(value); + return (REASONING_EFFORT_LEVELS as readonly string[]).includes(value); } /** @@ -33,13 +33,13 @@ export function isReasoningEffort(value: string): value is ReasoningEffort { * server default when never set (`null` = "default applies", not "off"). */ export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEffort { - return persisted ?? DEFAULT_REASONING_EFFORT; + return persisted ?? DEFAULT_REASONING_EFFORT; } /** One `<option>` of the selector. */ export interface EffortOption { - readonly value: ReasoningEffort; - readonly label: string; + readonly value: ReasoningEffort; + readonly label: string; } /** @@ -47,10 +47,10 @@ export interface EffortOption { * `(default)` so a never-set conversation reads "high (default)". */ export function effortOptions(): readonly EffortOption[] { - return REASONING_EFFORT_LEVELS.map((level) => ({ - value: level, - label: level === DEFAULT_REASONING_EFFORT ? `${level} (default)` : level, - })); + return REASONING_EFFORT_LEVELS.map((level) => ({ + value: level, + label: level === DEFAULT_REASONING_EFFORT ? `${level} (default)` : level, + })); } // ── Injected port (consumer-defines-port; the composition root adapts the @@ -58,9 +58,9 @@ export function effortOptions(): readonly EffortOption[] { /** Outcome of `PUT /conversations/:id/reasoning-effort`. */ export type ReasoningEffortSaveResult = - | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } + | { readonly ok: false; readonly error: string }; export type SaveReasoningEffort = ( - level: ReasoningEffort, + level: ReasoningEffort, ) => Promise<ReasoningEffortSaveResult | null>; diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index a31fe55..3588da1 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -1,398 +1,398 @@ import type { - ChatDeltaMessage, - ChatErrorMessage, - ChatQueueMessage, - ChatSendMessage, + ChatDeltaMessage, + ChatErrorMessage, + ChatQueueMessage, + ChatSendMessage, } from "@dispatch/transport-contract"; import type { ChatMessage, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire"; import type { RenderedChunk, TranscriptState } from "../../core/chunks"; import { - appendUserMessage, - applyHistory, - clearGenerating, - foldEvent, - initialState, - initialWindowSize, - normalizeChatLimit, - restoreEarlier, - selectChunks, - selectGenerating, - selectHasEarlier, - selectMessages, - selectProviderRetry, - trimTranscript, - unloadCount, - windowTranscript, + appendUserMessage, + applyHistory, + clearGenerating, + foldEvent, + initialState, + initialWindowSize, + normalizeChatLimit, + restoreEarlier, + selectChunks, + selectGenerating, + selectHasEarlier, + selectMessages, + selectProviderRetry, + trimTranscript, + unloadCount, + windowTranscript, } from "../../core/chunks"; import type { MetricsState, TurnMetricsEntry } from "../../core/metrics"; import { - applyDurableMetrics, - foldMetricsEvent, - initialMetricsState, - selectCurrentContextSize, - selectOrderedTurnMetrics, + applyDurableMetrics, + foldMetricsEvent, + initialMetricsState, + selectCurrentContextSize, + selectOrderedTurnMetrics, } from "../../core/metrics"; import type { ConversationCache } from "../conversation-cache"; import type { ChatTransport, HistorySync, MetricsSync } from "./ports"; export interface ChatStoreDependencies { - readonly conversationId: string; - readonly model?: string; - readonly transport: ChatTransport; - readonly historySync: HistorySync; - readonly metricsSync: MetricsSync; - readonly cache: ConversationCache; - /** - * The workspace this conversation belongs to (its URL slug). Sent on - * `chat.send`/`chat.queue` so the backend stamps the conversation's workspace - * at creation (default "default"). Absent → omitted (legacy behavior). - */ - readonly workspaceId?: string; - /** - * The chat limit: max loaded chunks before the oldest quarter is unloaded - * (see `core/chunks/trim.ts`). Normalized via `normalizeChatLimit`; absent → - * `DEFAULT_CHAT_LIMIT`. - */ - readonly chatLimit?: number; - /** - * Whether unloading may run RIGHT NOW. The composition root wires this to the - * smart-scroll "stuck to bottom" state: while the reader is scrolled up, a - * trim would yank the content under them, so it is DEFERRED until they return - * to the bottom (the next fold retries). Absent → always allowed. - */ - readonly canUnload?: () => boolean; - /** - * Called when a swallowed error should be surfaced to the user (e.g. a - * metrics sync failure). Wired by the composition root to the app store's - * `reportError` → full-screen error modal. Absent → errors are logged only. - */ - readonly onError?: (context: string, err: unknown) => void; + readonly conversationId: string; + readonly model?: string; + readonly transport: ChatTransport; + readonly historySync: HistorySync; + readonly metricsSync: MetricsSync; + readonly cache: ConversationCache; + /** + * The workspace this conversation belongs to (its URL slug). Sent on + * `chat.send`/`chat.queue` so the backend stamps the conversation's workspace + * at creation (default "default"). Absent → omitted (legacy behavior). + */ + readonly workspaceId?: string; + /** + * The chat limit: max loaded chunks before the oldest quarter is unloaded + * (see `core/chunks/trim.ts`). Normalized via `normalizeChatLimit`; absent → + * `DEFAULT_CHAT_LIMIT`. + */ + readonly chatLimit?: number; + /** + * Whether unloading may run RIGHT NOW. The composition root wires this to the + * smart-scroll "stuck to bottom" state: while the reader is scrolled up, a + * trim would yank the content under them, so it is DEFERRED until they return + * to the bottom (the next fold retries). Absent → always allowed. + */ + readonly canUnload?: () => boolean; + /** + * Called when a swallowed error should be surfaced to the user (e.g. a + * metrics sync failure). Wired by the composition root to the app store's + * `reportError` → full-screen error modal. Absent → errors are logged only. + */ + readonly onError?: (context: string, err: unknown) => void; } export interface ChatStore { - readonly messages: readonly ChatMessage[]; - readonly chunks: readonly RenderedChunk[]; - readonly turnMetrics: readonly TurnMetricsEntry[]; - /** - * The conversation's current context size (tokens occupied) — the latest - * finalized turn's `contextSize`, or `undefined` ("unknown") when none is - * known yet. Never `0` for the unknown case. - */ - readonly currentContextSize: number | undefined; - /** - * Whether a turn is currently generating server-side — derived from the event - * stream (`turn-start`…no-`done`/`turn-sealed`-yet). True for ANY watching - * client: the sender, a second device, or a reconnected client whose in-flight - * turn was replayed. Drives the composer's "generating…" indicator. - */ - readonly generating: boolean; - /** - * The latest `provider-retry` event for the current turn, or `null` when no - * retry is pending. Drives the transient yellow "retrying…" warning banner — - * never persisted (never sent to the model or replayed on reload). Coalesces - * to the newest attempt + delay; cleared when content resumes or the turn ends. - */ - readonly providerRetry: TurnProviderRetryEvent | null; - readonly pendingSync: boolean; - readonly error: string | null; - readonly model: string | undefined; - /** - * Whether earlier history was unloaded by the chat limit (or never loaded by - * the fresh-load window) and can be paged back in — drives the - * "Show earlier messages" affordance. - */ - readonly hasEarlier: boolean; - /** - * Render-key base for thinking collapses: how many thinking chunks are - * unloaded below the watermark, so the UI's ordinal keys stay stable across - * a trim (see `TranscriptState.hiddenThinkingCount`). - */ - readonly thinkingKeyBase: number; - handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void; - send(text: string): void; - /** - * Enqueue a steering message onto the conversation's queue (`chat.queue` - * WS op). While a turn is generating, the message is delivered mid-turn at - * the next tool-result boundary (a `steering` `AgentEvent` fires + the - * message-queue surface updates). When no turn is active, the server - * auto-starts a turn with the message as its opening prompt (equivalent to - * `chat.send`). No optimistic transcript echo — the queue SURFACE carries the - * pending message until drain; the `steering` event places it in the - * transcript. `text` must be non-empty (the server 400/errors otherwise). - */ - queueMessage(text: string): void; - setModel(model: string): void; - /** - * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window. - * Lowering it unloads older committed chunks (deferred via the gate while the - * reader is scrolled up, catching up on the next mutation). Raising it - * REFILLS older history (cache first, then CR-5 `?beforeSeq=`) up to the - * fresh-load window (`initialWindowSize` = 75% of the limit) — the same - * window a fresh `load()` would show — so upping the limit reveals more - * history instead of leaving a partial view. New deltas + loads use the new - * limit. The refill awaits, so a caller can preserve scroll over the prepend. - */ - setChatLimit(limit: number): Promise<void>; - load(): Promise<void>; - /** - * Page one unload-unit (`ceil(limit/4)`) of earlier history back in — the - * "Show earlier messages" action. Local cache first; when the cache doesn't - * reach far enough back (a server-windowed fresh load), the missing older - * run is fetched via CR-5 `?beforeSeq=&limit=` and persisted to the cache. - */ - showEarlier(): Promise<void>; - /** - * Re-sync after a WS (re)connect. Clears any stale `generating` (a turn may - * have sealed while disconnected — the live `turn-sealed` was missed), then - * pulls newly-sealed turns from history (+ metrics). If the turn is still - * running, the server's post-subscribe replay re-asserts `generating`. The - * app store pairs this with a `chat.subscribe` for the conversation. - */ - resync(): void; - dispose(): void; + readonly messages: readonly ChatMessage[]; + readonly chunks: readonly RenderedChunk[]; + readonly turnMetrics: readonly TurnMetricsEntry[]; + /** + * The conversation's current context size (tokens occupied) — the latest + * finalized turn's `contextSize`, or `undefined` ("unknown") when none is + * known yet. Never `0` for the unknown case. + */ + readonly currentContextSize: number | undefined; + /** + * Whether a turn is currently generating server-side — derived from the event + * stream (`turn-start`…no-`done`/`turn-sealed`-yet). True for ANY watching + * client: the sender, a second device, or a reconnected client whose in-flight + * turn was replayed. Drives the composer's "generating…" indicator. + */ + readonly generating: boolean; + /** + * The latest `provider-retry` event for the current turn, or `null` when no + * retry is pending. Drives the transient yellow "retrying…" warning banner — + * never persisted (never sent to the model or replayed on reload). Coalesces + * to the newest attempt + delay; cleared when content resumes or the turn ends. + */ + readonly providerRetry: TurnProviderRetryEvent | null; + readonly pendingSync: boolean; + readonly error: string | null; + readonly model: string | undefined; + /** + * Whether earlier history was unloaded by the chat limit (or never loaded by + * the fresh-load window) and can be paged back in — drives the + * "Show earlier messages" affordance. + */ + readonly hasEarlier: boolean; + /** + * Render-key base for thinking collapses: how many thinking chunks are + * unloaded below the watermark, so the UI's ordinal keys stay stable across + * a trim (see `TranscriptState.hiddenThinkingCount`). + */ + readonly thinkingKeyBase: number; + handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void; + send(text: string): void; + /** + * Enqueue a steering message onto the conversation's queue (`chat.queue` + * WS op). While a turn is generating, the message is delivered mid-turn at + * the next tool-result boundary (a `steering` `AgentEvent` fires + the + * message-queue surface updates). When no turn is active, the server + * auto-starts a turn with the message as its opening prompt (equivalent to + * `chat.send`). No optimistic transcript echo — the queue SURFACE carries the + * pending message until drain; the `steering` event places it in the + * transcript. `text` must be non-empty (the server 400/errors otherwise). + */ + queueMessage(text: string): void; + setModel(model: string): void; + /** + * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window. + * Lowering it unloads older committed chunks (deferred via the gate while the + * reader is scrolled up, catching up on the next mutation). Raising it + * REFILLS older history (cache first, then CR-5 `?beforeSeq=`) up to the + * fresh-load window (`initialWindowSize` = 75% of the limit) — the same + * window a fresh `load()` would show — so upping the limit reveals more + * history instead of leaving a partial view. New deltas + loads use the new + * limit. The refill awaits, so a caller can preserve scroll over the prepend. + */ + setChatLimit(limit: number): Promise<void>; + load(): Promise<void>; + /** + * Page one unload-unit (`ceil(limit/4)`) of earlier history back in — the + * "Show earlier messages" action. Local cache first; when the cache doesn't + * reach far enough back (a server-windowed fresh load), the missing older + * run is fetched via CR-5 `?beforeSeq=&limit=` and persisted to the cache. + */ + showEarlier(): Promise<void>; + /** + * Re-sync after a WS (re)connect. Clears any stale `generating` (a turn may + * have sealed while disconnected — the live `turn-sealed` was missed), then + * pulls newly-sealed turns from history (+ metrics). If the turn is still + * running, the server's post-subscribe replay re-asserts `generating`. The + * app store pairs this with a `chat.subscribe` for the conversation. + */ + resync(): void; + dispose(): void; } export function createChatStore(deps: ChatStoreDependencies): ChatStore { - let transcript = $state<TranscriptState>(initialState()); - let metrics = $state<MetricsState>(initialMetricsState()); - let _pendingSync = $state(false); - let _error = $state<string | null>(null); - let _model = $state<string | undefined>(deps.model); - let disposed = false; + let transcript = $state<TranscriptState>(initialState()); + let metrics = $state<MetricsState>(initialMetricsState()); + let _pendingSync = $state(false); + let _error = $state<string | null>(null); + let _model = $state<string | undefined>(deps.model); + let disposed = false; - let chatLimit = normalizeChatLimit(deps.chatLimit); + let chatLimit = normalizeChatLimit(deps.chatLimit); - /** - * Enforce the chat limit after a transcript mutation — unless the injected - * gate says the reader is scrolled up (then defer; the next mutation retries - * and `trimTranscript` unloads whole quarters to catch up). - */ - function maybeTrim(): void { - if (deps.canUnload !== undefined && !deps.canUnload()) return; - transcript = trimTranscript(transcript, chatLimit); - } + /** + * Enforce the chat limit after a transcript mutation — unless the injected + * gate says the reader is scrolled up (then defer; the next mutation retries + * and `trimTranscript` unloads whole quarters to catch up). + */ + function maybeTrim(): void { + if (deps.canUnload !== undefined && !deps.canUnload()) return; + transcript = trimTranscript(transcript, chatLimit); + } - /** - * Pull `seq > cache-cursor` from the server and fold it in. `coldLimit`, when - * given AND the cache is empty (a truly fresh browser), windows the fetch to - * the newest N chunks (CR-5 `?limit=`) so a huge conversation doesn't ship - * whole. It is deliberately NOT applied to a warm-cache tail: windowing a - * tail that grew past N while we were away would leave a silent seq GAP - * between the cache and the fetched window. - */ - async function syncTail(coldLimit?: number): Promise<void> { - if (disposed || _pendingSync) return; - _pendingSync = true; - try { - const since = await deps.cache.sinceSeq(deps.conversationId); - const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined; - const res = await deps.historySync(deps.conversationId, since, window); - const merged = await deps.cache.commit(deps.conversationId, res.chunks); - transcript = applyHistory(transcript, merged); - maybeTrim(); - _error = null; - } catch (err) { - _error = err instanceof Error ? err.message : String(err); - } finally { - _pendingSync = false; - } - } + /** + * Pull `seq > cache-cursor` from the server and fold it in. `coldLimit`, when + * given AND the cache is empty (a truly fresh browser), windows the fetch to + * the newest N chunks (CR-5 `?limit=`) so a huge conversation doesn't ship + * whole. It is deliberately NOT applied to a warm-cache tail: windowing a + * tail that grew past N while we were away would leave a silent seq GAP + * between the cache and the fetched window. + */ + async function syncTail(coldLimit?: number): Promise<void> { + if (disposed || _pendingSync) return; + _pendingSync = true; + try { + const since = await deps.cache.sinceSeq(deps.conversationId); + const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined; + const res = await deps.historySync(deps.conversationId, since, window); + const merged = await deps.cache.commit(deps.conversationId, res.chunks); + transcript = applyHistory(transcript, merged); + maybeTrim(); + _error = null; + } catch (err) { + _error = err instanceof Error ? err.message : String(err); + } finally { + _pendingSync = false; + } + } - async function syncMetrics(): Promise<void> { - if (disposed) return; - try { - const res = await deps.metricsSync(deps.conversationId); - metrics = applyDurableMetrics(metrics, res.turns); - } catch (err) { - // Metrics fetch failure must not block history sync or throw; - // live-folded metrics remain intact. Surface via onError (modal). - console.error("[syncMetrics] failed:", err); - deps.onError?.("Failed to sync conversation metrics", err); - } - } + async function syncMetrics(): Promise<void> { + if (disposed) return; + try { + const res = await deps.metricsSync(deps.conversationId); + metrics = applyDurableMetrics(metrics, res.turns); + } catch (err) { + // Metrics fetch failure must not block history sync or throw; + // live-folded metrics remain intact. Surface via onError (modal). + console.error("[syncMetrics] failed:", err); + deps.onError?.("Failed to sync conversation metrics", err); + } + } - /** - * Fetch up to `want` older chunks (seq < `oldest`) — cache first, then a - * CR-5 `?beforeSeq=&limit=` server backfill when the cache is too shallow, - * persisting it so the next read is local. Returns every locally-known - * chunk older than `oldest` (the caller — `restoreEarlier` — takes the - * newest `count` of them). Shared by `showEarlier` and the raise-refill. - */ - async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> { - let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest); - const oldestKnown = earlier[0]?.seq ?? oldest; - if (earlier.length < want && oldestKnown > 1) { - const res = await deps.historySync(deps.conversationId, 0, { - beforeSeq: oldestKnown, - limit: want - earlier.length, - }); - const merged = await deps.cache.commit(deps.conversationId, res.chunks); - earlier = merged.filter((c) => c.seq < oldest); - } - return earlier; - } + /** + * Fetch up to `want` older chunks (seq < `oldest`) — cache first, then a + * CR-5 `?beforeSeq=&limit=` server backfill when the cache is too shallow, + * persisting it so the next read is local. Returns every locally-known + * chunk older than `oldest` (the caller — `restoreEarlier` — takes the + * newest `count` of them). Shared by `showEarlier` and the raise-refill. + */ + async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> { + let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest); + const oldestKnown = earlier[0]?.seq ?? oldest; + if (earlier.length < want && oldestKnown > 1) { + const res = await deps.historySync(deps.conversationId, 0, { + beforeSeq: oldestKnown, + limit: want - earlier.length, + }); + const merged = await deps.cache.commit(deps.conversationId, res.chunks); + earlier = merged.filter((c) => c.seq < oldest); + } + return earlier; + } - /** - * Refill toward the fresh-load window after a limit RAISE: pull older - * history (cache first, then server) so the loaded set grows to match what a - * fresh `load()` would show at the new limit. No-op when already at the - * origin (seq 1) or already within the window. `restoreEarlier` re-derives - * the window start at apply time, so a delta landing during the await can't - * corrupt the merge. NOT gated (refilling prepends above the viewport; the - * caller preserves scroll position). - */ - async function refill(): Promise<void> { - if (disposed) return; - const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq; - if (oldest <= 1) return; - const want = initialWindowSize(chatLimit) - transcript.committed.length; - if (want <= 0) return; - try { - const earlier = await backfillOlder(oldest, want); - if (earlier.length === 0) return; - transcript = restoreEarlier(transcript, earlier, want); - _error = null; - } catch (err) { - _error = err instanceof Error ? err.message : String(err); - } - } + /** + * Refill toward the fresh-load window after a limit RAISE: pull older + * history (cache first, then server) so the loaded set grows to match what a + * fresh `load()` would show at the new limit. No-op when already at the + * origin (seq 1) or already within the window. `restoreEarlier` re-derives + * the window start at apply time, so a delta landing during the await can't + * corrupt the merge. NOT gated (refilling prepends above the viewport; the + * caller preserves scroll position). + */ + async function refill(): Promise<void> { + if (disposed) return; + const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq; + if (oldest <= 1) return; + const want = initialWindowSize(chatLimit) - transcript.committed.length; + if (want <= 0) return; + try { + const earlier = await backfillOlder(oldest, want); + if (earlier.length === 0) return; + transcript = restoreEarlier(transcript, earlier, want); + _error = null; + } catch (err) { + _error = err instanceof Error ? err.message : String(err); + } + } - return { - get messages(): readonly ChatMessage[] { - return selectMessages(transcript); - }, - get chunks(): readonly RenderedChunk[] { - return selectChunks(transcript); - }, - get turnMetrics(): readonly TurnMetricsEntry[] { - return selectOrderedTurnMetrics(metrics); - }, - get currentContextSize(): number | undefined { - return selectCurrentContextSize(metrics); - }, - get generating(): boolean { - return selectGenerating(transcript); - }, - get providerRetry(): TurnProviderRetryEvent | null { - return selectProviderRetry(transcript); - }, - get pendingSync(): boolean { - return _pendingSync; - }, - get error(): string | null { - return _error; - }, - get model(): string | undefined { - return _model; - }, - get hasEarlier(): boolean { - return selectHasEarlier(transcript); - }, - get thinkingKeyBase(): number { - return transcript.hiddenThinkingCount; - }, + return { + get messages(): readonly ChatMessage[] { + return selectMessages(transcript); + }, + get chunks(): readonly RenderedChunk[] { + return selectChunks(transcript); + }, + get turnMetrics(): readonly TurnMetricsEntry[] { + return selectOrderedTurnMetrics(metrics); + }, + get currentContextSize(): number | undefined { + return selectCurrentContextSize(metrics); + }, + get generating(): boolean { + return selectGenerating(transcript); + }, + get providerRetry(): TurnProviderRetryEvent | null { + return selectProviderRetry(transcript); + }, + get pendingSync(): boolean { + return _pendingSync; + }, + get error(): string | null { + return _error; + }, + get model(): string | undefined { + return _model; + }, + get hasEarlier(): boolean { + return selectHasEarlier(transcript); + }, + get thinkingKeyBase(): number { + return transcript.hiddenThinkingCount; + }, - handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void { - if (msg.type === "chat.error") { - if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) { - return; - } - _error = msg.message; - return; - } - if (msg.event.conversationId !== deps.conversationId) { - return; - } - transcript = foldEvent(transcript, msg.event); - metrics = foldMetricsEvent(metrics, msg.event); - maybeTrim(); - if (transcript.sealedTurnId !== null) { - void syncTail(); - void syncMetrics(); - } - }, + handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void { + if (msg.type === "chat.error") { + if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) { + return; + } + _error = msg.message; + return; + } + if (msg.event.conversationId !== deps.conversationId) { + return; + } + transcript = foldEvent(transcript, msg.event); + metrics = foldMetricsEvent(metrics, msg.event); + maybeTrim(); + if (transcript.sealedTurnId !== null) { + void syncTail(); + void syncMetrics(); + } + }, - send(text: string): void { - transcript = appendUserMessage(transcript, text); - maybeTrim(); - const msg: ChatSendMessage = { - type: "chat.send", - conversationId: deps.conversationId, - message: text, - ...(_model !== undefined ? { model: _model } : {}), - ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}), - }; - deps.transport.send(msg); - }, + send(text: string): void { + transcript = appendUserMessage(transcript, text); + maybeTrim(); + const msg: ChatSendMessage = { + type: "chat.send", + conversationId: deps.conversationId, + message: text, + ...(_model !== undefined ? { model: _model } : {}), + ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}), + }; + deps.transport.send(msg); + }, - queueMessage(text: string): void { - const trimmed = text.trim(); - if (trimmed.length === 0) return; - const msg: ChatQueueMessage = { - type: "chat.queue", - conversationId: deps.conversationId, - text: trimmed, - ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}), - }; - deps.transport.send(msg); - }, + queueMessage(text: string): void { + const trimmed = text.trim(); + if (trimmed.length === 0) return; + const msg: ChatQueueMessage = { + type: "chat.queue", + conversationId: deps.conversationId, + text: trimmed, + ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}), + }; + deps.transport.send(msg); + }, - setModel(model: string): void { - _model = model; - }, + setModel(model: string): void { + _model = model; + }, - async setChatLimit(limit: number): Promise<void> { - const prev = chatLimit; - chatLimit = normalizeChatLimit(limit); - if (chatLimit < prev) { - maybeTrim(); - } else if (chatLimit > prev) { - await refill(); - } - }, + async setChatLimit(limit: number): Promise<void> { + const prev = chatLimit; + chatLimit = normalizeChatLimit(limit); + if (chatLimit < prev) { + maybeTrim(); + } else if (chatLimit > prev) { + await refill(); + } + }, - async load(): Promise<void> { - // Fresh load shows only the newest 75% of the limit — headroom before the - // first trim. A warm cache is windowed locally (synchronously with its - // apply — no render in between); a COLD cache passes the window to the - // server instead (CR-5 `?limit=`), so a huge conversation never ships - // whole. The post-sync window re-asserts the cap either way. - const windowSize = initialWindowSize(chatLimit); - const cached = await deps.cache.load(deps.conversationId); - if (cached.length > 0) { - transcript = windowTranscript(applyHistory(transcript, cached), windowSize); - } - await syncTail(windowSize); - transcript = windowTranscript(transcript, windowSize); - await syncMetrics(); - }, + async load(): Promise<void> { + // Fresh load shows only the newest 75% of the limit — headroom before the + // first trim. A warm cache is windowed locally (synchronously with its + // apply — no render in between); a COLD cache passes the window to the + // server instead (CR-5 `?limit=`), so a huge conversation never ships + // whole. The post-sync window re-asserts the cap either way. + const windowSize = initialWindowSize(chatLimit); + const cached = await deps.cache.load(deps.conversationId); + if (cached.length > 0) { + transcript = windowTranscript(applyHistory(transcript, cached), windowSize); + } + await syncTail(windowSize); + transcript = windowTranscript(transcript, windowSize); + await syncMetrics(); + }, - async showEarlier(): Promise<void> { - if (disposed) return; - const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq; - if (oldest <= 1) return; - const want = unloadCount(chatLimit); - try { - const earlier = await backfillOlder(oldest, want); - transcript = restoreEarlier(transcript, earlier, want); - _error = null; - } catch (err) { - _error = err instanceof Error ? err.message : String(err); - } - }, + async showEarlier(): Promise<void> { + if (disposed) return; + const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq; + if (oldest <= 1) return; + const want = unloadCount(chatLimit); + try { + const earlier = await backfillOlder(oldest, want); + transcript = restoreEarlier(transcript, earlier, want); + _error = null; + } catch (err) { + _error = err instanceof Error ? err.message : String(err); + } + }, - resync(): void { - if (disposed) return; - // A turn may have sealed while we were disconnected (missed `turn-sealed`): - // clear the now-stale spinner BEFORE re-subscribing, so a finished turn - // doesn't spin forever. A still-running turn's replay re-asserts it. - transcript = clearGenerating(transcript); - void syncTail(); - void syncMetrics(); - }, + resync(): void { + if (disposed) return; + // A turn may have sealed while we were disconnected (missed `turn-sealed`): + // clear the now-stale spinner BEFORE re-subscribing, so a finished turn + // doesn't spin forever. A still-running turn's replay re-asserts it. + transcript = clearGenerating(transcript); + void syncTail(); + void syncMetrics(); + }, - dispose(): void { - disposed = true; - }, - }; + dispose(): void { + disposed = true; + }, + }; } diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts index c1d62a6..c052d1b 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -2,1548 +2,1548 @@ import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire"; import { describe, expect, it, vi } from "vitest"; import { createChatStore } from "./store.svelte"; import { - createFakeCache, - createFakeHistorySync, - createFakeMetricsSync, - createFakeTransport, + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, } from "./test-helpers"; const CONV_ID = "test-conv-1"; function makeStoredChunk(seq: number, role: "user" | "assistant" = "assistant"): StoredChunk { - return { seq, role, chunk: { type: "text", text: `chunk-${seq}` } }; + return { seq, role, chunk: { type: "text", text: `chunk-${seq}` } }; } function deltaEvent(event: AgentEvent): import("@dispatch/transport-contract").ChatDeltaMessage { - return { type: "chat.delta", event }; + return { type: "chat.delta", event }; } function errorMessage(message: string): import("@dispatch/transport-contract").ChatErrorMessage { - return { type: "chat.error", message }; + return { type: "chat.error", message }; } describe("createChatStore", () => { - it("folding a chat.delta updates messages", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hello" }), - ); - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: " world" }), - ); - - expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("assistant"); - expect(store.messages[0]?.chunks).toHaveLength(1); - expect(store.messages[0]?.chunks[0]?.type).toBe("text"); - expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe( - "Hello world", - ); - - store.dispose(); - }); - - it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // Set up what the history sync will return - historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)]; - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hi" }), - ); - store.handleDelta( - deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), - ); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - // Wait for the async sync to complete - await vi.waitFor(() => { - expect(historySync.calls).toHaveLength(1); - }); - - expect(historySync.calls[0]?.conversationId).toBe(CONV_ID); - expect(historySync.calls[0]?.sinceSeq).toBe(0); - - // Cache should have the committed chunks - const cached = await cache.impl.load(CONV_ID); - expect(cached).toHaveLength(2); - - // Messages should include both provisional and committed - expect(store.messages.length).toBeGreaterThanOrEqual(1); - - store.dispose(); - }); - - it("send posts a chat.send with conversationId", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.send("Hello server"); - - expect(transport.sent).toHaveLength(1); - expect(transport.sent[0]?.type).toBe("chat.send"); - expect(transport.sent[0]?.conversationId).toBe(CONV_ID); - expect(transport.sent[0]?.message).toBe("Hello server"); - expect(transport.sent[0]).not.toHaveProperty("model"); - - store.dispose(); - }); - - it("send posts a chat.send with model when set", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - model: "openai/gpt-4", - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.send("Hello"); - - expect(transport.sent).toHaveLength(1); - expect(transport.sent[0]?.model).toBe("openai/gpt-4"); - - store.dispose(); - }); - - describe("queueMessage (chat.queue — steering)", () => { - it("posts a chat.queue with conversationId + text", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.queueMessage("steer left"); - - expect(transport.sent).toHaveLength(0); // chat.send stays empty - expect(transport.sentQueue).toHaveLength(1); - expect(transport.sentQueue[0]?.type).toBe("chat.queue"); - expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID); - expect(transport.sentQueue[0]?.text).toBe("steer left"); - - store.dispose(); - }); - - it("trims whitespace before sending", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.queueMessage(" padded "); - - expect(transport.sentQueue[0]?.text).toBe("padded"); - - store.dispose(); - }); - - it("does not send for empty/whitespace-only text", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.queueMessage(" "); - store.queueMessage(""); - - expect(transport.sentQueue).toHaveLength(0); - - store.dispose(); - }); - - it("does NOT optimistically echo into the transcript (the surface carries the queue)", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.queueMessage("queued steering message"); - - expect(store.chunks).toHaveLength(0); // no transcript echo - - store.dispose(); - }); - }); - - it("chat.error sets error", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - expect(store.error).toBeNull(); - - store.handleDelta(errorMessage("Something broke")); - - expect(store.error).toBe("Something broke"); - - store.dispose(); - }); - - it("load hydrates from cache then syncs the tail", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - - // Pre-populate cache - await cache.impl.commit(CONV_ID, [makeStoredChunk(1, "user"), makeStoredChunk(2, "assistant")]); - - // History sync returns new chunks - historySync.returnChunks = [makeStoredChunk(3, "assistant")]; - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - await store.load(); - - // Should have synced - expect(historySync.calls).toHaveLength(1); - expect(historySync.calls[0]?.sinceSeq).toBe(2); - - // Messages should include all chunks - expect(store.messages.length).toBeGreaterThanOrEqual(2); - - store.dispose(); - }); - - it("load with empty cache still syncs", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - - historySync.returnChunks = [makeStoredChunk(1, "assistant")]; - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - await store.load(); - - expect(historySync.calls).toHaveLength(1); - expect(historySync.calls[0]?.sinceSeq).toBe(0); - - store.dispose(); - }); - - it("error is cleared on successful sync", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // First, set an error - store.handleDelta(errorMessage("fail")); - expect(store.error).toBe("fail"); - - // Now trigger a successful sync via turn-sealed - historySync.returnChunks = [makeStoredChunk(1)]; - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), - ); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - await vi.waitFor(() => { - expect(store.error).toBeNull(); - }); - - store.dispose(); - }); - - it("dispose prevents further syncs", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.dispose(); - - // Trigger a turn-sealed after dispose - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - // Wait a tick to let any async work settle - await new Promise((r) => setTimeout(r, 10)); - - // No sync should have happened - expect(historySync.calls).toHaveLength(0); - - store.dispose(); - }); - - it("overlapping syncs are guarded", async () => { - const transport = createFakeTransport(); - const _historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - - // Make the first sync slow - let resolveFirstSync: (() => void) | undefined; - const firstSyncPromise = new Promise<void>((resolve) => { - resolveFirstSync = resolve; - }); - - let callCount = 0; - const slowHistorySync: import("./ports").HistorySync = async (_conversationId, sinceSeq) => { - callCount++; - if (callCount === 1) { - await firstSyncPromise; - } - return { chunks: [], latestSeq: sinceSeq }; - }; - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: slowHistorySync, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // Trigger first sync - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - // Wait a tick so the first sync starts - await new Promise((r) => setTimeout(r, 0)); - - // Trigger second sync while first is pending - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t2" })); - - // Only one call should have been made (second was guarded) - expect(callCount).toBe(1); - - // Release the first sync - resolveFirstSync?.(); - await new Promise((r) => setTimeout(r, 10)); - - store.dispose(); - }); - - it("handles tool-call and tool-result chunks", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ - type: "tool-call", - conversationId: CONV_ID, - turnId: "t1", - toolCallId: "tc1", - toolName: "read_file", - input: { path: "/tmp/test.txt" }, - stepId: "t1#0" as StepId, - }), - ); - store.handleDelta( - deltaEvent({ - type: "tool-result", - conversationId: CONV_ID, - turnId: "t1", - toolCallId: "tc1", - toolName: "read_file", - content: "file contents", - isError: false, - stepId: "t1#0" as StepId, - }), - ); - - expect(store.chunks).toHaveLength(2); - expect(store.chunks[0]?.chunk.type).toBe("tool-call"); - expect(store.chunks[1]?.chunk.type).toBe("tool-result"); - - store.dispose(); - }); - - it("setModel changes the model used by the next send", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - model: "openai/gpt-4", - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.send("First"); - expect(transport.sent[0]?.model).toBe("openai/gpt-4"); - - store.setModel("anthropic/claude-3"); - store.send("Second"); - expect(transport.sent[1]?.model).toBe("anthropic/claude-3"); - - store.dispose(); - }); - - it("setModel from undefined to a model", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.send("First"); - expect(transport.sent[0]).not.toHaveProperty("model"); - - store.setModel("openai/gpt-4o"); - store.send("Second"); - expect(transport.sent[1]?.model).toBe("openai/gpt-4o"); - - store.dispose(); - }); - - it("handleDelta ignores a chat.delta for a different conversationId", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.handleDelta( - deltaEvent({ type: "turn-start", conversationId: "other-conv", turnId: "t1" }), - ); - store.handleDelta( - deltaEvent({ - type: "text-delta", - conversationId: "other-conv", - turnId: "t1", - delta: "Should be ignored", - }), - ); - - expect(store.messages).toHaveLength(0); - - store.dispose(); - }); - - it("handleDelta ignores a chat.error for a different conversationId", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.handleDelta({ type: "chat.error", conversationId: "other-conv", message: "Wrong conv" }); - - expect(store.error).toBeNull(); - - store.dispose(); - }); - - it("send optimistically shows the user message immediately", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.send("hi"); - - expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("user"); - expect(store.messages[0]?.chunks).toHaveLength(1); - expect(store.messages[0]?.chunks[0]?.type).toBe("text"); - expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe("hi"); - - store.dispose(); - }); - - it("the optimistic user message is replaced after turn-sealed + history sync", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - historySync.returnChunks = [ - { seq: 1, role: "user", chunk: { type: "text", text: "hi" } }, - { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } }, - ]; - - store.send("hi"); - expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("user"); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hello!" }), - ); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - await vi.waitFor(() => { - expect(store.messages.length).toBe(2); - }); - - expect(store.messages[0]?.role).toBe("user"); - expect(store.messages[1]?.role).toBe("assistant"); - - store.dispose(); - }); - - it("folding usage/step-complete/done deltas exposes turnMetrics", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - expect(store.turnMetrics).toHaveLength(0); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ - type: "usage", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - store.handleDelta( - deltaEvent({ - type: "step-complete", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - ttftMs: 200, - genTotalMs: 800, - }), - ); - store.handleDelta( - deltaEvent({ - type: "done", - conversationId: CONV_ID, - turnId: "t1", - reason: "end-turn", - durationMs: 1200, - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - - expect(store.turnMetrics).toHaveLength(1); - const entry = store.turnMetrics[0]; - expect(entry?.turnId).toBe("t1"); - expect(entry?.steps).toHaveLength(1); - expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId); - expect(entry?.steps[0]?.usage.inputTokens).toBe(100); - expect(entry?.steps[0]?.genTotalMs).toBe(800); - expect(entry?.total).not.toBeNull(); - expect(entry?.total?.usage.inputTokens).toBe(100); - expect(entry?.total?.usage.outputTokens).toBe(50); - expect(entry?.total?.durationMs).toBe(1200); - - store.dispose(); - }); - - it("turnMetrics entry has total: null before done (progressive turn)", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ - type: "usage", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - store.handleDelta( - deltaEvent({ - type: "step-complete", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - ttftMs: 200, - genTotalMs: 800, - }), - ); - - expect(store.turnMetrics).toHaveLength(1); - const entry = store.turnMetrics[0]; - expect(entry?.turnId).toBe("t1"); - expect(entry?.steps).toHaveLength(1); - expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId); - expect(entry?.total).toBeNull(); - - store.dispose(); - }); - - it("metricsSync durable result overrides live by turnId", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // Live fold gives some metrics - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ - type: "usage", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - store.handleDelta( - deltaEvent({ - type: "done", - conversationId: CONV_ID, - turnId: "t1", - reason: "end-turn", - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - - expect(store.turnMetrics).toHaveLength(1); - expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50); - - // Durable sync returns different numbers for the same turnId - metricsSync.returnTurns = [ - { - turnId: "t1", - usage: { inputTokens: 200, outputTokens: 80 }, - durationMs: 500, - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 200, outputTokens: 80 }, - genTotalMs: 400, - }, - ], - }, - ]; - - // Trigger metrics sync via turn-sealed - historySync.returnChunks = []; - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - await vi.waitFor(() => { - expect(metricsSync.calls).toHaveLength(1); - }); - - // Durable should now override live (syncMetrics is async, wait for it) - await vi.waitFor(() => { - expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80); - }); - - expect(store.turnMetrics).toHaveLength(1); - expect(store.turnMetrics[0]?.total?.durationMs).toBe(500); - - store.dispose(); - }); - - it("rejected metricsSync leaves live metrics intact and does not throw", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // Live fold some metrics - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ - type: "usage", - conversationId: CONV_ID, - turnId: "t1", - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - store.handleDelta( - deltaEvent({ - type: "done", - conversationId: CONV_ID, - turnId: "t1", - reason: "end-turn", - usage: { inputTokens: 100, outputTokens: 50 }, - }), - ); - - expect(store.turnMetrics).toHaveLength(1); - - // Make the metrics sync reject - metricsSync.nextError = "metrics endpoint unavailable"; - - historySync.returnChunks = []; - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - - await vi.waitFor(() => { - expect(metricsSync.calls).toHaveLength(1); - }); - - // Live metrics should still be intact - expect(store.turnMetrics).toHaveLength(1); - expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50); - - // No error should have been thrown to the store - expect(store.error).toBeNull(); - - store.dispose(); - }); - - it("load calls metricsSync after history sync", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - - metricsSync.returnTurns = [ - { - turnId: "t1", - usage: { inputTokens: 300, outputTokens: 100 }, - durationMs: 900, - steps: [], - }, - ]; - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - await store.load(); - - expect(historySync.calls).toHaveLength(1); - expect(metricsSync.calls).toHaveLength(1); - expect(metricsSync.calls[0]).toBe(CONV_ID); - expect(store.turnMetrics).toHaveLength(1); - expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300); - - store.dispose(); - }); - - it("generating reflects the turn lifecycle (idle → running → idle)", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - expect(store.generating).toBe(false); - - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - expect(store.generating).toBe(true); - - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hi" }), - ); - expect(store.generating).toBe(true); - - store.handleDelta( - deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), - ); - expect(store.generating).toBe(false); - - store.dispose(); - }); - - it("generating lights up for a watcher whose turn was replayed (no send first)", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // A late-joiner receives the in-flight turn replayed from turn-start. - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta( - deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "partial" }), - ); - expect(store.generating).toBe(true); - expect(transport.sent).toHaveLength(0); // it never sent — it's just watching - - store.dispose(); - }); - - it("resync clears a stale generating flag and re-syncs history + metrics", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - // Disconnected mid-turn: turn-start seen, but the live done/turn-sealed was - // missed, so generating is stuck true. - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - expect(store.generating).toBe(true); - - // The turn actually sealed while we were gone — history now has the chunks. - historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)]; - - store.resync(); - - // Generating is cleared synchronously (a finished turn must not spin forever). - expect(store.generating).toBe(false); - - await vi.waitFor(() => { - expect(historySync.calls).toHaveLength(1); - expect(metricsSync.calls).toHaveLength(1); - }); - - store.dispose(); - }); - - it("chat limit: crossing the limit unloads the oldest quarter in one bulk pass", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - // Commit exactly 100 chunks via a sealed turn (at the limit — no trim). - const hundred = Array.from({ length: 100 }, (_, i) => makeStoredChunk(i + 1)); - historySync.returnChunks = hundred; - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - await vi.waitFor(() => { - expect(store.chunks).toHaveLength(100); - }); - expect(store.hasEarlier).toBe(false); - - // The 101st chunk (a live tool-call) crosses the limit → 25 unload → 76 remain. - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); - store.handleDelta( - deltaEvent({ - type: "tool-call", - conversationId: CONV_ID, - turnId: "t2", - toolCallId: "tc1", - toolName: "probe", - input: {}, - stepId: "t2#0" as StepId, - }), - ); - - expect(store.chunks).toHaveLength(76); - expect(store.chunks[0]?.seq).toBe(26); - expect(store.hasEarlier).toBe(true); - - store.dispose(); - }); - - it("chat limit: unloading is deferred while the gate is closed, then catches up", () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - let atBottom = false; // reader scrolled up - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 10, - canUnload: () => atBottom, - }); - - // 15 live tool-calls: over the limit, but the gate defers every trim. - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - for (let i = 0; i < 15; i++) { - store.handleDelta( - deltaEvent({ - type: "tool-call", - conversationId: CONV_ID, - turnId: "t1", - toolCallId: `tc${i}`, - toolName: "probe", - input: {}, - stepId: `t1#${i}` as StepId, - }), - ); - } - expect(store.chunks).toHaveLength(15); - - // Reader returns to the bottom — the deferred trim now catches up. - // With no committed chunks, it drops the oldest provisional chunks - // (the in-flight turn) to stay within the limit. - atBottom = true; - store.handleDelta( - deltaEvent({ - type: "tool-call", - conversationId: CONV_ID, - turnId: "t1", - toolCallId: "tc15", - toolName: "probe", - input: {}, - stepId: "t1#15" as StepId, - }), - ); - // 16 provisional, limit 10, quarter 3 → drop 6 oldest → 10 remain. - expect(store.chunks).toHaveLength(10); - - store.dispose(); - }); - - it("chat limit: a deferred trim catches up across committed history once the gate opens", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - let atBottom = false; - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - canUnload: () => atBottom, - }); - - // Seal a turn committing 130 chunks while the reader is scrolled up: no trim. - historySync.returnChunks = Array.from({ length: 130 }, (_, i) => makeStoredChunk(i + 1)); - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - await vi.waitFor(() => { - expect(store.chunks).toHaveLength(130); - }); - - // Back at the bottom: the next fold trims whole quarters down to ≤ 100. - atBottom = true; - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); - // 130 → 2 quarters of 25 → 80 committed (turn-start adds no chunk). - expect(store.chunks).toHaveLength(80); - expect(store.chunks[0]?.seq).toBe(51); - - store.dispose(); - }); - - it("chat limit: load windows a long cached conversation to 75% of the limit", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit( - CONV_ID, - Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), - ); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); - - // floor(100 × 0.75) = 75 newest chunks: seqs 426..500. - expect(store.chunks).toHaveLength(75); - expect(store.chunks[0]?.seq).toBe(426); - expect(store.hasEarlier).toBe(true); - // The tail sync still used the cache's real cursor (not the window's edge). - expect(historySync.calls[0]?.sinceSeq).toBe(500); - - store.dispose(); - }); - - it("chat limit: a cold cache (fresh browser) asks the SERVER for the 75% window (CR-5 ?limit=)", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - // The server holds 500 chunks; the windowed fetch returns the newest 75. - historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); - - // The cold-cache initial sync carried the window (`?sinceSeq=0&limit=75`). - expect(historySync.calls[0]?.sinceSeq).toBe(0); - expect(historySync.calls[0]?.window).toEqual({ limit: 75 }); - - expect(store.chunks).toHaveLength(75); - expect(store.chunks[0]?.seq).toBe(426); - // hasEarlier derives from the 1-based gap-free seq contract (426 > 1) — - // no local watermark was ever set. - expect(store.hasEarlier).toBe(true); - // Only the window was shipped + cached (the point of CR-5). - const cached = await cache.impl.load(CONV_ID); - expect(cached).toHaveLength(75); - - store.dispose(); - }); - - it("chat limit: a warm cache syncs the tail UNWINDOWED (no seq gap behind the cache)", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit(CONV_ID, [makeStoredChunk(1), makeStoredChunk(2)]); - historySync.returnChunks = [makeStoredChunk(3)]; - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); - - expect(historySync.calls[0]?.sinceSeq).toBe(2); - expect(historySync.calls[0]?.window).toBeUndefined(); - - store.dispose(); - }); - - it("chat limit: showEarlier backfills from the server when the cache is too shallow (CR-5 ?beforeSeq=)", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); // server-windowed: loaded + cached = 426..500 - expect(store.chunks[0]?.seq).toBe(426); - - await store.showEarlier(); - - // Nothing below 426 was cached → fetched the missing run from the server. - const backfill = historySync.calls[1]; - expect(backfill?.window).toEqual({ beforeSeq: 426, limit: 25 }); - expect(store.chunks).toHaveLength(100); - expect(store.chunks[0]?.seq).toBe(401); - expect(store.hasEarlier).toBe(true); - // The backfilled run is persisted: the NEXT page-in is cache-local. - const cached = await cache.impl.load(CONV_ID); - expect(cached).toHaveLength(100); - - store.dispose(); - }); - - it("chat limit: showEarlier pages a quarter back in from the cache", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit( - CONV_ID, - Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), - ); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); - expect(store.chunks[0]?.seq).toBe(426); - - await store.showEarlier(); // +ceil(100/4) = 25 older chunks - expect(store.chunks).toHaveLength(100); - expect(store.chunks[0]?.seq).toBe(401); - expect(store.hasEarlier).toBe(true); - // The cache reached deep enough — no server backfill was needed. - expect(historySync.calls).toHaveLength(1); - - store.dispose(); - }); - - it("chat limit: showEarlier clears hasEarlier when the cache is exhausted", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit( - CONV_ID, - Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)), - ); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); // window 75: hidden 1..5 - expect(store.chunks).toHaveLength(75); - expect(store.hasEarlier).toBe(true); - - await store.showEarlier(); // restores all 5 → nothing left below - expect(store.chunks).toHaveLength(80); - expect(store.chunks[0]?.seq).toBe(1); - expect(store.hasEarlier).toBe(false); - - store.dispose(); - }); - - it("chat limit: a post-trim history sync does not resurrect unloaded chunks", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit( - CONV_ID, - Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), - ); - - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - await store.load(); - expect(store.chunks[0]?.seq).toBe(426); - - // A sealed turn triggers syncTail, whose cache.commit returns the FULL - // merged cache (seqs 1..501) — the watermark must keep 1..425 out. - historySync.returnChunks = [makeStoredChunk(501)]; - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t9" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t9" })); - - await vi.waitFor(() => { - expect(store.chunks[store.chunks.length - 1]?.seq).toBe(501); - }); - expect(store.chunks[0]?.seq).toBe(426); - expect(store.chunks).toHaveLength(76); - - store.dispose(); - }); - - it("setChatLimit: lowering the limit trims older committed chunks live", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - // Load 80 committed chunks (under the limit — no trim yet). - historySync.returnChunks = Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)); - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - await vi.waitFor(() => { - expect(store.chunks).toHaveLength(80); - }); - - // Lower the limit to 10: 80 → unload ceil(10/4)=3 per quarter, needs - // ceil((80-10)/3)=24 quarters → drop min(72, 80)=72 → 8 remain. - await store.setChatLimit(10); - expect(store.chunks).toHaveLength(8); - expect(store.chunks[0]?.seq).toBe(73); - expect(store.hasEarlier).toBe(true); - - store.dispose(); - }); - - it("setChatLimit: raising the limit refills older history up to the fresh-load window", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - // Cache holds 200 chunks; load at limit 100 → window 75 → seqs 126..200. - await cache.impl.commit( - CONV_ID, - Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)), - ); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - await store.load(); - expect(store.chunks).toHaveLength(75); - expect(store.chunks[0]?.seq).toBe(126); - expect(store.hasEarlier).toBe(true); - - // Raise to 200 → window floor(0.75×200)=150 → refill 75 older chunks - // (seqs 51..125) from the cache. No server backfill (cache is deep enough). - await store.setChatLimit(200); - expect(historySync.calls).toHaveLength(1); // the load-time tail sync only - expect(store.chunks).toHaveLength(150); - expect(store.chunks[0]?.seq).toBe(51); - expect(store.hasEarlier).toBe(true); // 51 > 1 - - store.dispose(); - }); - - it("setChatLimit: raising backfills from the server when the cache is too shallow", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200. - historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - await store.load(); - expect(store.chunks[0]?.seq).toBe(126); - - // Raise to 200 → want 75 older. Cache only holds 126..200 → backfill - // seqs 51..125 from the server (CR-5 ?beforeSeq=126&limit=75). - await store.setChatLimit(200); - const backfill = historySync.calls[1]; - expect(backfill?.window).toEqual({ beforeSeq: 126, limit: 75 }); - expect(store.chunks).toHaveLength(150); - expect(store.chunks[0]?.seq).toBe(51); - - store.dispose(); - }); - - it("setChatLimit: raising refills all available older history (down to the origin)", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - // 101 chunks → one trim pass drops 25 → 76 remain (seqs 26..101). - historySync.returnChunks = Array.from({ length: 101 }, (_, i) => makeStoredChunk(i + 1)); - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - await vi.waitFor(() => { - expect(store.chunks).toHaveLength(76); - }); - expect(store.chunks[0]?.seq).toBe(26); - expect(store.hasEarlier).toBe(true); - - // Raise to 500 → window 375 → want 299 older. The cache holds only - // seqs 1..25 below the window (no more server-side) → restore all 25 → - // 101 loaded, reaching the origin. - await store.setChatLimit(500); - expect(store.chunks).toHaveLength(101); - expect(store.chunks[0]?.seq).toBe(1); - expect(store.hasEarlier).toBe(false); - - store.dispose(); - }); - - it("setChatLimit: raising is a no-op when the window already starts at the origin", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - await cache.impl.commit( - CONV_ID, - Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)), - ); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - await store.load(); // only 50 chunks → all loaded, window starts at seq 1 - expect(store.chunks).toHaveLength(50); - expect(store.hasEarlier).toBe(false); - const callsAfterLoad = historySync.calls.length; - - await store.setChatLimit(500); // raise → refill no-ops (oldest = 1) - expect(store.chunks).toHaveLength(50); - expect(store.chunks[0]?.seq).toBe(1); - expect(historySync.calls).toHaveLength(callsAfterLoad); // no backfill - - store.dispose(); - }); - - it("setChatLimit: a nonsensical value is normalized (no crash, no trim)", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - chatLimit: 100, - }); - - historySync.returnChunks = Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)); - store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); - store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); - await vi.waitFor(() => { - expect(store.chunks).toHaveLength(50); - }); - - // NaN normalizes to the default (256). prev was 100 → raise → refill, - // but the loaded window already starts at seq 1 (origin) → no-op. - await store.setChatLimit(Number.NaN); - expect(store.chunks).toHaveLength(50); - - store.dispose(); - }); - - it("resync is a no-op after dispose", async () => { - const transport = createFakeTransport(); - const historySync = createFakeHistorySync(); - const metricsSync = createFakeMetricsSync(); - const cache = createFakeCache(); - const store = createChatStore({ - conversationId: CONV_ID, - transport: transport.impl, - historySync: historySync.impl, - metricsSync: metricsSync.impl, - cache: cache.impl, - }); - - store.dispose(); - store.resync(); - - await new Promise((r) => setTimeout(r, 10)); - expect(historySync.calls).toHaveLength(0); - expect(metricsSync.calls).toHaveLength(0); - }); + it("folding a chat.delta updates messages", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hello" }), + ); + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: " world" }), + ); + + expect(store.messages).toHaveLength(1); + expect(store.messages[0]?.role).toBe("assistant"); + expect(store.messages[0]?.chunks).toHaveLength(1); + expect(store.messages[0]?.chunks[0]?.type).toBe("text"); + expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe( + "Hello world", + ); + + store.dispose(); + }); + + it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Set up what the history sync will return + historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)]; + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hi" }), + ); + store.handleDelta( + deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), + ); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + // Wait for the async sync to complete + await vi.waitFor(() => { + expect(historySync.calls).toHaveLength(1); + }); + + expect(historySync.calls[0]?.conversationId).toBe(CONV_ID); + expect(historySync.calls[0]?.sinceSeq).toBe(0); + + // Cache should have the committed chunks + const cached = await cache.impl.load(CONV_ID); + expect(cached).toHaveLength(2); + + // Messages should include both provisional and committed + expect(store.messages.length).toBeGreaterThanOrEqual(1); + + store.dispose(); + }); + + it("send posts a chat.send with conversationId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.send("Hello server"); + + expect(transport.sent).toHaveLength(1); + expect(transport.sent[0]?.type).toBe("chat.send"); + expect(transport.sent[0]?.conversationId).toBe(CONV_ID); + expect(transport.sent[0]?.message).toBe("Hello server"); + expect(transport.sent[0]).not.toHaveProperty("model"); + + store.dispose(); + }); + + it("send posts a chat.send with model when set", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + model: "openai/gpt-4", + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.send("Hello"); + + expect(transport.sent).toHaveLength(1); + expect(transport.sent[0]?.model).toBe("openai/gpt-4"); + + store.dispose(); + }); + + describe("queueMessage (chat.queue — steering)", () => { + it("posts a chat.queue with conversationId + text", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.queueMessage("steer left"); + + expect(transport.sent).toHaveLength(0); // chat.send stays empty + expect(transport.sentQueue).toHaveLength(1); + expect(transport.sentQueue[0]?.type).toBe("chat.queue"); + expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID); + expect(transport.sentQueue[0]?.text).toBe("steer left"); + + store.dispose(); + }); + + it("trims whitespace before sending", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.queueMessage(" padded "); + + expect(transport.sentQueue[0]?.text).toBe("padded"); + + store.dispose(); + }); + + it("does not send for empty/whitespace-only text", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.queueMessage(" "); + store.queueMessage(""); + + expect(transport.sentQueue).toHaveLength(0); + + store.dispose(); + }); + + it("does NOT optimistically echo into the transcript (the surface carries the queue)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.queueMessage("queued steering message"); + + expect(store.chunks).toHaveLength(0); // no transcript echo + + store.dispose(); + }); + }); + + it("chat.error sets error", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + expect(store.error).toBeNull(); + + store.handleDelta(errorMessage("Something broke")); + + expect(store.error).toBe("Something broke"); + + store.dispose(); + }); + + it("load hydrates from cache then syncs the tail", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + + // Pre-populate cache + await cache.impl.commit(CONV_ID, [makeStoredChunk(1, "user"), makeStoredChunk(2, "assistant")]); + + // History sync returns new chunks + historySync.returnChunks = [makeStoredChunk(3, "assistant")]; + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + await store.load(); + + // Should have synced + expect(historySync.calls).toHaveLength(1); + expect(historySync.calls[0]?.sinceSeq).toBe(2); + + // Messages should include all chunks + expect(store.messages.length).toBeGreaterThanOrEqual(2); + + store.dispose(); + }); + + it("load with empty cache still syncs", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + + historySync.returnChunks = [makeStoredChunk(1, "assistant")]; + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + await store.load(); + + expect(historySync.calls).toHaveLength(1); + expect(historySync.calls[0]?.sinceSeq).toBe(0); + + store.dispose(); + }); + + it("error is cleared on successful sync", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // First, set an error + store.handleDelta(errorMessage("fail")); + expect(store.error).toBe("fail"); + + // Now trigger a successful sync via turn-sealed + historySync.returnChunks = [makeStoredChunk(1)]; + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), + ); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + await vi.waitFor(() => { + expect(store.error).toBeNull(); + }); + + store.dispose(); + }); + + it("dispose prevents further syncs", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.dispose(); + + // Trigger a turn-sealed after dispose + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + // Wait a tick to let any async work settle + await new Promise((r) => setTimeout(r, 10)); + + // No sync should have happened + expect(historySync.calls).toHaveLength(0); + + store.dispose(); + }); + + it("overlapping syncs are guarded", async () => { + const transport = createFakeTransport(); + const _historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + + // Make the first sync slow + let resolveFirstSync: (() => void) | undefined; + const firstSyncPromise = new Promise<void>((resolve) => { + resolveFirstSync = resolve; + }); + + let callCount = 0; + const slowHistorySync: import("./ports").HistorySync = async (_conversationId, sinceSeq) => { + callCount++; + if (callCount === 1) { + await firstSyncPromise; + } + return { chunks: [], latestSeq: sinceSeq }; + }; + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: slowHistorySync, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Trigger first sync + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + // Wait a tick so the first sync starts + await new Promise((r) => setTimeout(r, 0)); + + // Trigger second sync while first is pending + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t2" })); + + // Only one call should have been made (second was guarded) + expect(callCount).toBe(1); + + // Release the first sync + resolveFirstSync?.(); + await new Promise((r) => setTimeout(r, 10)); + + store.dispose(); + }); + + it("handles tool-call and tool-result chunks", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "tool-call", + conversationId: CONV_ID, + turnId: "t1", + toolCallId: "tc1", + toolName: "read_file", + input: { path: "/tmp/test.txt" }, + stepId: "t1#0" as StepId, + }), + ); + store.handleDelta( + deltaEvent({ + type: "tool-result", + conversationId: CONV_ID, + turnId: "t1", + toolCallId: "tc1", + toolName: "read_file", + content: "file contents", + isError: false, + stepId: "t1#0" as StepId, + }), + ); + + expect(store.chunks).toHaveLength(2); + expect(store.chunks[0]?.chunk.type).toBe("tool-call"); + expect(store.chunks[1]?.chunk.type).toBe("tool-result"); + + store.dispose(); + }); + + it("setModel changes the model used by the next send", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + model: "openai/gpt-4", + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.send("First"); + expect(transport.sent[0]?.model).toBe("openai/gpt-4"); + + store.setModel("anthropic/claude-3"); + store.send("Second"); + expect(transport.sent[1]?.model).toBe("anthropic/claude-3"); + + store.dispose(); + }); + + it("setModel from undefined to a model", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.send("First"); + expect(transport.sent[0]).not.toHaveProperty("model"); + + store.setModel("openai/gpt-4o"); + store.send("Second"); + expect(transport.sent[1]?.model).toBe("openai/gpt-4o"); + + store.dispose(); + }); + + it("handleDelta ignores a chat.delta for a different conversationId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.handleDelta( + deltaEvent({ type: "turn-start", conversationId: "other-conv", turnId: "t1" }), + ); + store.handleDelta( + deltaEvent({ + type: "text-delta", + conversationId: "other-conv", + turnId: "t1", + delta: "Should be ignored", + }), + ); + + expect(store.messages).toHaveLength(0); + + store.dispose(); + }); + + it("handleDelta ignores a chat.error for a different conversationId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.handleDelta({ type: "chat.error", conversationId: "other-conv", message: "Wrong conv" }); + + expect(store.error).toBeNull(); + + store.dispose(); + }); + + it("send optimistically shows the user message immediately", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.send("hi"); + + expect(store.messages).toHaveLength(1); + expect(store.messages[0]?.role).toBe("user"); + expect(store.messages[0]?.chunks).toHaveLength(1); + expect(store.messages[0]?.chunks[0]?.type).toBe("text"); + expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe("hi"); + + store.dispose(); + }); + + it("the optimistic user message is replaced after turn-sealed + history sync", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + historySync.returnChunks = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } }, + ]; + + store.send("hi"); + expect(store.messages).toHaveLength(1); + expect(store.messages[0]?.role).toBe("user"); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hello!" }), + ); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + await vi.waitFor(() => { + expect(store.messages.length).toBe(2); + }); + + expect(store.messages[0]?.role).toBe("user"); + expect(store.messages[1]?.role).toBe("assistant"); + + store.dispose(); + }); + + it("folding usage/step-complete/done deltas exposes turnMetrics", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + expect(store.turnMetrics).toHaveLength(0); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "step-complete", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 200, + genTotalMs: 800, + }), + ); + store.handleDelta( + deltaEvent({ + type: "done", + conversationId: CONV_ID, + turnId: "t1", + reason: "end-turn", + durationMs: 1200, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + const entry = store.turnMetrics[0]; + expect(entry?.turnId).toBe("t1"); + expect(entry?.steps).toHaveLength(1); + expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId); + expect(entry?.steps[0]?.usage.inputTokens).toBe(100); + expect(entry?.steps[0]?.genTotalMs).toBe(800); + expect(entry?.total).not.toBeNull(); + expect(entry?.total?.usage.inputTokens).toBe(100); + expect(entry?.total?.usage.outputTokens).toBe(50); + expect(entry?.total?.durationMs).toBe(1200); + + store.dispose(); + }); + + it("turnMetrics entry has total: null before done (progressive turn)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "step-complete", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 200, + genTotalMs: 800, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + const entry = store.turnMetrics[0]; + expect(entry?.turnId).toBe("t1"); + expect(entry?.steps).toHaveLength(1); + expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId); + expect(entry?.total).toBeNull(); + + store.dispose(); + }); + + it("metricsSync durable result overrides live by turnId", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Live fold gives some metrics + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "done", + conversationId: CONV_ID, + turnId: "t1", + reason: "end-turn", + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50); + + // Durable sync returns different numbers for the same turnId + metricsSync.returnTurns = [ + { + turnId: "t1", + usage: { inputTokens: 200, outputTokens: 80 }, + durationMs: 500, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 200, outputTokens: 80 }, + genTotalMs: 400, + }, + ], + }, + ]; + + // Trigger metrics sync via turn-sealed + historySync.returnChunks = []; + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + await vi.waitFor(() => { + expect(metricsSync.calls).toHaveLength(1); + }); + + // Durable should now override live (syncMetrics is async, wait for it) + await vi.waitFor(() => { + expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80); + }); + + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.durationMs).toBe(500); + + store.dispose(); + }); + + it("rejected metricsSync leaves live metrics intact and does not throw", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Live fold some metrics + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ + type: "usage", + conversationId: CONV_ID, + turnId: "t1", + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + store.handleDelta( + deltaEvent({ + type: "done", + conversationId: CONV_ID, + turnId: "t1", + reason: "end-turn", + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ); + + expect(store.turnMetrics).toHaveLength(1); + + // Make the metrics sync reject + metricsSync.nextError = "metrics endpoint unavailable"; + + historySync.returnChunks = []; + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + + await vi.waitFor(() => { + expect(metricsSync.calls).toHaveLength(1); + }); + + // Live metrics should still be intact + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50); + + // No error should have been thrown to the store + expect(store.error).toBeNull(); + + store.dispose(); + }); + + it("load calls metricsSync after history sync", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + + metricsSync.returnTurns = [ + { + turnId: "t1", + usage: { inputTokens: 300, outputTokens: 100 }, + durationMs: 900, + steps: [], + }, + ]; + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + await store.load(); + + expect(historySync.calls).toHaveLength(1); + expect(metricsSync.calls).toHaveLength(1); + expect(metricsSync.calls[0]).toBe(CONV_ID); + expect(store.turnMetrics).toHaveLength(1); + expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300); + + store.dispose(); + }); + + it("generating reflects the turn lifecycle (idle → running → idle)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + expect(store.generating).toBe(false); + + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + expect(store.generating).toBe(true); + + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hi" }), + ); + expect(store.generating).toBe(true); + + store.handleDelta( + deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }), + ); + expect(store.generating).toBe(false); + + store.dispose(); + }); + + it("generating lights up for a watcher whose turn was replayed (no send first)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // A late-joiner receives the in-flight turn replayed from turn-start. + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta( + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "partial" }), + ); + expect(store.generating).toBe(true); + expect(transport.sent).toHaveLength(0); // it never sent — it's just watching + + store.dispose(); + }); + + it("resync clears a stale generating flag and re-syncs history + metrics", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // Disconnected mid-turn: turn-start seen, but the live done/turn-sealed was + // missed, so generating is stuck true. + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + expect(store.generating).toBe(true); + + // The turn actually sealed while we were gone — history now has the chunks. + historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)]; + + store.resync(); + + // Generating is cleared synchronously (a finished turn must not spin forever). + expect(store.generating).toBe(false); + + await vi.waitFor(() => { + expect(historySync.calls).toHaveLength(1); + expect(metricsSync.calls).toHaveLength(1); + }); + + store.dispose(); + }); + + it("chat limit: crossing the limit unloads the oldest quarter in one bulk pass", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + // Commit exactly 100 chunks via a sealed turn (at the limit — no trim). + const hundred = Array.from({ length: 100 }, (_, i) => makeStoredChunk(i + 1)); + historySync.returnChunks = hundred; + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + await vi.waitFor(() => { + expect(store.chunks).toHaveLength(100); + }); + expect(store.hasEarlier).toBe(false); + + // The 101st chunk (a live tool-call) crosses the limit → 25 unload → 76 remain. + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); + store.handleDelta( + deltaEvent({ + type: "tool-call", + conversationId: CONV_ID, + turnId: "t2", + toolCallId: "tc1", + toolName: "probe", + input: {}, + stepId: "t2#0" as StepId, + }), + ); + + expect(store.chunks).toHaveLength(76); + expect(store.chunks[0]?.seq).toBe(26); + expect(store.hasEarlier).toBe(true); + + store.dispose(); + }); + + it("chat limit: unloading is deferred while the gate is closed, then catches up", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + let atBottom = false; // reader scrolled up + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 10, + canUnload: () => atBottom, + }); + + // 15 live tool-calls: over the limit, but the gate defers every trim. + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + for (let i = 0; i < 15; i++) { + store.handleDelta( + deltaEvent({ + type: "tool-call", + conversationId: CONV_ID, + turnId: "t1", + toolCallId: `tc${i}`, + toolName: "probe", + input: {}, + stepId: `t1#${i}` as StepId, + }), + ); + } + expect(store.chunks).toHaveLength(15); + + // Reader returns to the bottom — the deferred trim now catches up. + // With no committed chunks, it drops the oldest provisional chunks + // (the in-flight turn) to stay within the limit. + atBottom = true; + store.handleDelta( + deltaEvent({ + type: "tool-call", + conversationId: CONV_ID, + turnId: "t1", + toolCallId: "tc15", + toolName: "probe", + input: {}, + stepId: "t1#15" as StepId, + }), + ); + // 16 provisional, limit 10, quarter 3 → drop 6 oldest → 10 remain. + expect(store.chunks).toHaveLength(10); + + store.dispose(); + }); + + it("chat limit: a deferred trim catches up across committed history once the gate opens", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + let atBottom = false; + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + canUnload: () => atBottom, + }); + + // Seal a turn committing 130 chunks while the reader is scrolled up: no trim. + historySync.returnChunks = Array.from({ length: 130 }, (_, i) => makeStoredChunk(i + 1)); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + await vi.waitFor(() => { + expect(store.chunks).toHaveLength(130); + }); + + // Back at the bottom: the next fold trims whole quarters down to ≤ 100. + atBottom = true; + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); + // 130 → 2 quarters of 25 → 80 committed (turn-start adds no chunk). + expect(store.chunks).toHaveLength(80); + expect(store.chunks[0]?.seq).toBe(51); + + store.dispose(); + }); + + it("chat limit: load windows a long cached conversation to 75% of the limit", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit( + CONV_ID, + Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), + ); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); + + // floor(100 × 0.75) = 75 newest chunks: seqs 426..500. + expect(store.chunks).toHaveLength(75); + expect(store.chunks[0]?.seq).toBe(426); + expect(store.hasEarlier).toBe(true); + // The tail sync still used the cache's real cursor (not the window's edge). + expect(historySync.calls[0]?.sinceSeq).toBe(500); + + store.dispose(); + }); + + it("chat limit: a cold cache (fresh browser) asks the SERVER for the 75% window (CR-5 ?limit=)", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + // The server holds 500 chunks; the windowed fetch returns the newest 75. + historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); + + // The cold-cache initial sync carried the window (`?sinceSeq=0&limit=75`). + expect(historySync.calls[0]?.sinceSeq).toBe(0); + expect(historySync.calls[0]?.window).toEqual({ limit: 75 }); + + expect(store.chunks).toHaveLength(75); + expect(store.chunks[0]?.seq).toBe(426); + // hasEarlier derives from the 1-based gap-free seq contract (426 > 1) — + // no local watermark was ever set. + expect(store.hasEarlier).toBe(true); + // Only the window was shipped + cached (the point of CR-5). + const cached = await cache.impl.load(CONV_ID); + expect(cached).toHaveLength(75); + + store.dispose(); + }); + + it("chat limit: a warm cache syncs the tail UNWINDOWED (no seq gap behind the cache)", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit(CONV_ID, [makeStoredChunk(1), makeStoredChunk(2)]); + historySync.returnChunks = [makeStoredChunk(3)]; + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); + + expect(historySync.calls[0]?.sinceSeq).toBe(2); + expect(historySync.calls[0]?.window).toBeUndefined(); + + store.dispose(); + }); + + it("chat limit: showEarlier backfills from the server when the cache is too shallow (CR-5 ?beforeSeq=)", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); // server-windowed: loaded + cached = 426..500 + expect(store.chunks[0]?.seq).toBe(426); + + await store.showEarlier(); + + // Nothing below 426 was cached → fetched the missing run from the server. + const backfill = historySync.calls[1]; + expect(backfill?.window).toEqual({ beforeSeq: 426, limit: 25 }); + expect(store.chunks).toHaveLength(100); + expect(store.chunks[0]?.seq).toBe(401); + expect(store.hasEarlier).toBe(true); + // The backfilled run is persisted: the NEXT page-in is cache-local. + const cached = await cache.impl.load(CONV_ID); + expect(cached).toHaveLength(100); + + store.dispose(); + }); + + it("chat limit: showEarlier pages a quarter back in from the cache", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit( + CONV_ID, + Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), + ); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); + expect(store.chunks[0]?.seq).toBe(426); + + await store.showEarlier(); // +ceil(100/4) = 25 older chunks + expect(store.chunks).toHaveLength(100); + expect(store.chunks[0]?.seq).toBe(401); + expect(store.hasEarlier).toBe(true); + // The cache reached deep enough — no server backfill was needed. + expect(historySync.calls).toHaveLength(1); + + store.dispose(); + }); + + it("chat limit: showEarlier clears hasEarlier when the cache is exhausted", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit( + CONV_ID, + Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)), + ); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); // window 75: hidden 1..5 + expect(store.chunks).toHaveLength(75); + expect(store.hasEarlier).toBe(true); + + await store.showEarlier(); // restores all 5 → nothing left below + expect(store.chunks).toHaveLength(80); + expect(store.chunks[0]?.seq).toBe(1); + expect(store.hasEarlier).toBe(false); + + store.dispose(); + }); + + it("chat limit: a post-trim history sync does not resurrect unloaded chunks", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit( + CONV_ID, + Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)), + ); + + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + await store.load(); + expect(store.chunks[0]?.seq).toBe(426); + + // A sealed turn triggers syncTail, whose cache.commit returns the FULL + // merged cache (seqs 1..501) — the watermark must keep 1..425 out. + historySync.returnChunks = [makeStoredChunk(501)]; + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t9" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t9" })); + + await vi.waitFor(() => { + expect(store.chunks[store.chunks.length - 1]?.seq).toBe(501); + }); + expect(store.chunks[0]?.seq).toBe(426); + expect(store.chunks).toHaveLength(76); + + store.dispose(); + }); + + it("setChatLimit: lowering the limit trims older committed chunks live", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + // Load 80 committed chunks (under the limit — no trim yet). + historySync.returnChunks = Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + await vi.waitFor(() => { + expect(store.chunks).toHaveLength(80); + }); + + // Lower the limit to 10: 80 → unload ceil(10/4)=3 per quarter, needs + // ceil((80-10)/3)=24 quarters → drop min(72, 80)=72 → 8 remain. + await store.setChatLimit(10); + expect(store.chunks).toHaveLength(8); + expect(store.chunks[0]?.seq).toBe(73); + expect(store.hasEarlier).toBe(true); + + store.dispose(); + }); + + it("setChatLimit: raising the limit refills older history up to the fresh-load window", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + // Cache holds 200 chunks; load at limit 100 → window 75 → seqs 126..200. + await cache.impl.commit( + CONV_ID, + Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)), + ); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + await store.load(); + expect(store.chunks).toHaveLength(75); + expect(store.chunks[0]?.seq).toBe(126); + expect(store.hasEarlier).toBe(true); + + // Raise to 200 → window floor(0.75×200)=150 → refill 75 older chunks + // (seqs 51..125) from the cache. No server backfill (cache is deep enough). + await store.setChatLimit(200); + expect(historySync.calls).toHaveLength(1); // the load-time tail sync only + expect(store.chunks).toHaveLength(150); + expect(store.chunks[0]?.seq).toBe(51); + expect(store.hasEarlier).toBe(true); // 51 > 1 + + store.dispose(); + }); + + it("setChatLimit: raising backfills from the server when the cache is too shallow", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200. + historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + await store.load(); + expect(store.chunks[0]?.seq).toBe(126); + + // Raise to 200 → want 75 older. Cache only holds 126..200 → backfill + // seqs 51..125 from the server (CR-5 ?beforeSeq=126&limit=75). + await store.setChatLimit(200); + const backfill = historySync.calls[1]; + expect(backfill?.window).toEqual({ beforeSeq: 126, limit: 75 }); + expect(store.chunks).toHaveLength(150); + expect(store.chunks[0]?.seq).toBe(51); + + store.dispose(); + }); + + it("setChatLimit: raising refills all available older history (down to the origin)", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + // 101 chunks → one trim pass drops 25 → 76 remain (seqs 26..101). + historySync.returnChunks = Array.from({ length: 101 }, (_, i) => makeStoredChunk(i + 1)); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + await vi.waitFor(() => { + expect(store.chunks).toHaveLength(76); + }); + expect(store.chunks[0]?.seq).toBe(26); + expect(store.hasEarlier).toBe(true); + + // Raise to 500 → window 375 → want 299 older. The cache holds only + // seqs 1..25 below the window (no more server-side) → restore all 25 → + // 101 loaded, reaching the origin. + await store.setChatLimit(500); + expect(store.chunks).toHaveLength(101); + expect(store.chunks[0]?.seq).toBe(1); + expect(store.hasEarlier).toBe(false); + + store.dispose(); + }); + + it("setChatLimit: raising is a no-op when the window already starts at the origin", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + await cache.impl.commit( + CONV_ID, + Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)), + ); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + await store.load(); // only 50 chunks → all loaded, window starts at seq 1 + expect(store.chunks).toHaveLength(50); + expect(store.hasEarlier).toBe(false); + const callsAfterLoad = historySync.calls.length; + + await store.setChatLimit(500); // raise → refill no-ops (oldest = 1) + expect(store.chunks).toHaveLength(50); + expect(store.chunks[0]?.seq).toBe(1); + expect(historySync.calls).toHaveLength(callsAfterLoad); // no backfill + + store.dispose(); + }); + + it("setChatLimit: a nonsensical value is normalized (no crash, no trim)", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + chatLimit: 100, + }); + + historySync.returnChunks = Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + await vi.waitFor(() => { + expect(store.chunks).toHaveLength(50); + }); + + // NaN normalizes to the default (256). prev was 100 → raise → refill, + // but the loaded window already starts at seq 1 (origin) → no-op. + await store.setChatLimit(Number.NaN); + expect(store.chunks).toHaveLength(50); + + store.dispose(); + }); + + it("resync is a no-op after dispose", async () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.dispose(); + store.resync(); + + await new Promise((r) => setTimeout(r, 10)); + expect(historySync.calls).toHaveLength(0); + expect(metricsSync.calls).toHaveLength(0); + }); }); diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts index 100449f..26c5590 100644 --- a/src/features/chat/test-helpers.ts +++ b/src/features/chat/test-helpers.ts @@ -4,139 +4,139 @@ import type { ConversationCache } from "../conversation-cache"; import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export interface FakeTransport { - /** All `chat.send` messages sent through the fake transport. */ - readonly sent: ChatSendMessage[]; - /** All `chat.queue` messages sent through the fake transport. */ - readonly sentQueue: ChatQueueMessage[]; - readonly impl: ChatTransport; + /** All `chat.send` messages sent through the fake transport. */ + readonly sent: ChatSendMessage[]; + /** All `chat.queue` messages sent through the fake transport. */ + readonly sentQueue: ChatQueueMessage[]; + readonly impl: ChatTransport; } export function createFakeTransport(): FakeTransport { - const sent: ChatSendMessage[] = []; - const sentQueue: ChatQueueMessage[] = []; - return { - sent, - sentQueue, - impl: { - send(msg) { - if (msg.type === "chat.queue") { - sentQueue.push(msg); - } else { - sent.push(msg); - } - }, - }, - }; + const sent: ChatSendMessage[] = []; + const sentQueue: ChatQueueMessage[] = []; + return { + sent, + sentQueue, + impl: { + send(msg) { + if (msg.type === "chat.queue") { + sentQueue.push(msg); + } else { + sent.push(msg); + } + }, + }, + }; } export interface FakeHistorySync { - readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>; - /** Set the chunks to return on the next call. */ - returnChunks: readonly StoredChunk[]; - readonly impl: HistorySync; + readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>; + /** Set the chunks to return on the next call. */ + returnChunks: readonly StoredChunk[]; + readonly impl: HistorySync; } export function createFakeHistorySync(): FakeHistorySync { - const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = []; - let returnChunks: readonly StoredChunk[] = []; - return { - calls, - get returnChunks() { - return returnChunks; - }, - set returnChunks(v: readonly StoredChunk[]) { - returnChunks = v; - }, - impl: async (conversationId, sinceSeq, window) => { - calls.push({ conversationId, sinceSeq, ...(window !== undefined ? { window } : {}) }); - // Apply the CR-5 WINDOW semantics (`beforeSeq` bound, then newest-`limit`) - // so store tests exercise the real windowed flows. `sinceSeq` filtering is - // deliberately NOT applied — tests set `returnChunks` to the slice they - // mean the server to hold past the cursor. - let chunks = returnChunks; - const before = window?.beforeSeq; - if (before !== undefined) { - chunks = chunks.filter((c) => c.seq < before); - } - if (window?.limit !== undefined && chunks.length > window.limit) { - chunks = chunks.slice(-window.limit); - } - const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq; - return { chunks, latestSeq }; - }, - }; + const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = []; + let returnChunks: readonly StoredChunk[] = []; + return { + calls, + get returnChunks() { + return returnChunks; + }, + set returnChunks(v: readonly StoredChunk[]) { + returnChunks = v; + }, + impl: async (conversationId, sinceSeq, window) => { + calls.push({ conversationId, sinceSeq, ...(window !== undefined ? { window } : {}) }); + // Apply the CR-5 WINDOW semantics (`beforeSeq` bound, then newest-`limit`) + // so store tests exercise the real windowed flows. `sinceSeq` filtering is + // deliberately NOT applied — tests set `returnChunks` to the slice they + // mean the server to hold past the cursor. + let chunks = returnChunks; + const before = window?.beforeSeq; + if (before !== undefined) { + chunks = chunks.filter((c) => c.seq < before); + } + if (window?.limit !== undefined && chunks.length > window.limit) { + chunks = chunks.slice(-window.limit); + } + const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq; + return { chunks, latestSeq }; + }, + }; } export interface FakeMetricsSync { - readonly calls: string[]; - returnTurns: import("@dispatch/wire").TurnMetrics[]; - /** If set, the next call will reject with this error. */ - nextError: string | undefined; - readonly impl: MetricsSync; + readonly calls: string[]; + returnTurns: import("@dispatch/wire").TurnMetrics[]; + /** If set, the next call will reject with this error. */ + nextError: string | undefined; + readonly impl: MetricsSync; } export function createFakeMetricsSync(): FakeMetricsSync { - const calls: string[] = []; - let returnTurns: import("@dispatch/wire").TurnMetrics[] = []; - let nextError: string | undefined; - return { - calls, - get returnTurns() { - return returnTurns; - }, - set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) { - returnTurns = v; - }, - get nextError() { - return nextError; - }, - set nextError(v: string | undefined) { - nextError = v; - }, - impl: async (conversationId) => { - calls.push(conversationId); - if (nextError !== undefined) { - const err = nextError; - nextError = undefined; - throw new Error(err); - } - return { turns: returnTurns }; - }, - }; + const calls: string[] = []; + let returnTurns: import("@dispatch/wire").TurnMetrics[] = []; + let nextError: string | undefined; + return { + calls, + get returnTurns() { + return returnTurns; + }, + set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) { + returnTurns = v; + }, + get nextError() { + return nextError; + }, + set nextError(v: string | undefined) { + nextError = v; + }, + impl: async (conversationId) => { + calls.push(conversationId); + if (nextError !== undefined) { + const err = nextError; + nextError = undefined; + throw new Error(err); + } + return { turns: returnTurns }; + }, + }; } export interface FakeCache { - readonly store: Map<string, StoredChunk[]>; - readonly impl: ConversationCache; + readonly store: Map<string, StoredChunk[]>; + readonly impl: ConversationCache; } export function createFakeCache(): FakeCache { - const store = new Map<string, StoredChunk[]>(); - return { - store, - impl: { - async load(conversationId) { - return store.get(conversationId) ?? []; - }, - async commit(conversationId, incoming) { - const existing = store.get(conversationId) ?? []; - const seen = new Set(existing.map((c) => c.seq)); - const toAppend = incoming.filter((c) => !seen.has(c.seq)); - const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq); - store.set(conversationId, merged); - return merged; - }, - async sinceSeq(conversationId) { - const chunks = store.get(conversationId) ?? []; - if (chunks.length === 0) return 0; - return Math.max(...chunks.map((c) => c.seq)); - }, - async evictIfOverBudget() { - return []; - }, - async delete(conversationId) { - store.delete(conversationId); - }, - }, - }; + const store = new Map<string, StoredChunk[]>(); + return { + store, + impl: { + async load(conversationId) { + return store.get(conversationId) ?? []; + }, + async commit(conversationId, incoming) { + const existing = store.get(conversationId) ?? []; + const seen = new Set(existing.map((c) => c.seq)); + const toAppend = incoming.filter((c) => !seen.has(c.seq)); + const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq); + store.set(conversationId, merged); + return merged; + }, + async sinceSeq(conversationId) { + const chunks = store.get(conversationId) ?? []; + if (chunks.length === 0) return 0; + return Math.max(...chunks.map((c) => c.seq)); + }, + async evictIfOverBudget() { + return []; + }, + async delete(conversationId) { + store.delete(conversationId); + }, + }, + }; } diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index b8b3193..a2fd944 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -10,797 +10,797 @@ import ModelSelector from "./ui/ModelSelector.svelte"; import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte"; describe("ChatView", () => { - it("renders a message's text chunk", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { type: "text", text: "Hello world" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("Hello world")).toBeInTheDocument(); - }); - - it("renders multiple chunks", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("Hi there")).toBeInTheDocument(); - expect(screen.getByText("Hello!")).toBeInTheDocument(); - }); - - it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => { - const chunks: RenderedChunk[] = [ - { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false }, - ]; - - let resolveEarlier: (() => void) | undefined; - const onShowEarlier = vi.fn( - () => - new Promise<void>((resolve) => { - resolveEarlier = resolve; - }), - ); - - render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } }); - - const button = screen.getByRole("button", { name: /show earlier messages/i }); - const user = userEvent.setup(); - await user.click(button); - - expect(onShowEarlier).toHaveBeenCalledTimes(1); - // While the page-in is awaited the button is disabled (no double-fire). - expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled(); - - resolveEarlier?.(); - await vi.waitFor(() => { - expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled(); - }); - }); - - it("hides the show-earlier button when nothing is unloaded", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false }, - ]; - - render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } }); - - expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); - }); - - it("renders tool-call chunks", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "read_file", - input: { path: "/tmp/test.txt" }, - }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("read_file")).toBeInTheDocument(); - const pre = screen.getByText((content, element) => { - return element?.tagName === "PRE" && content.includes("/tmp/test.txt"); - }); - expect(pre).toBeInTheDocument(); - }); - - it("renders tool-result chunks", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "tool", - chunk: { - type: "tool-result", - toolCallId: "tc1", - toolName: "read_file", - content: "file contents here", - isError: false, - }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("read_file")).toBeInTheDocument(); - expect(screen.getByText("file contents here")).toBeInTheDocument(); - }); - - it("renders error chunks with alert role", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { type: "error", message: "Something failed" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent("Something failed"); - }); - - it("renders error chunks with code", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("Rate limited")).toBeInTheDocument(); - expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument(); - }); - - it("renders system chunks", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "system", - chunk: { type: "system", text: "System context loaded" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks } }); - - expect(screen.getByText("System context loaded")).toBeInTheDocument(); - }); - - it("renders provisional (in-flight) chunks without any dimming", () => { - const chunks: RenderedChunk[] = [ - { - seq: null, - role: "assistant", - chunk: { type: "text", text: "Streaming..." }, - provisional: true, - }, - ]; - - render(ChatView, { props: { chunks } }); - - // In-flight chunks render at full opacity (no faded "disabled" look). - const wrapper = screen.getByText("Streaming...").closest("div"); - expect(wrapper).not.toHaveClass("opacity-50"); - }); - - it("renders empty transcript", () => { - render(ChatView, { props: { chunks: [] } }); - - const log = screen.getByRole("log"); - expect(log).toBeInTheDocument(); - expect(log.children).toHaveLength(0); - }); - - it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { - const chunks: RenderedChunk[] = [ - { - seq: 1, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "a", - toolName: "read_file", - input: { path: "/a" }, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 2, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "b", - toolName: "list_dir", - input: { path: "/b" }, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 3, - role: "tool", - chunk: { - type: "tool-result", - toolCallId: "a", - toolName: "read_file", - content: "contents-of-a", - isError: false, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const { container } = render(ChatView, { props: { chunks } }); - - // Batched calls render as collapsible cards (one per call), not a list. - const collapses = container.querySelectorAll(".collapse"); - expect(collapses).toHaveLength(2); - - // Both call names + the available result are shown; the result is absorbed - // (no standalone tool-result card). - expect(screen.getByText("read_file")).toBeInTheDocument(); - expect(screen.getByText("list_dir")).toBeInTheDocument(); - expect(screen.getByText("contents-of-a")).toBeInTheDocument(); - }); - - it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => { - const chunks: RenderedChunk[] = [ - { - seq: null, - role: "assistant", - chunk: { type: "thinking", text: "Let me think..." }, - provisional: true, - streaming: true, - }, - ]; - - const { container } = render(ChatView, { props: { chunks } }); - - const collapse = container.querySelector(".collapse"); - expect(collapse).not.toBeNull(); - expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon - expect(collapse).not.toHaveClass("collapse-plus"); - // Visible bubble, like tool cards. - expect(collapse).toHaveClass("bg-base-200"); - expect(collapse).toHaveClass("rounded-box"); - expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument(); - }); - - it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => { - const streaming: RenderedChunk[] = [ - { - seq: null, - role: "assistant", - chunk: { type: "thinking", text: "hmm" }, - provisional: true, - streaming: true, - }, - ]; - - const { container, rerender } = render(ChatView, { props: { chunks: streaming } }); - - // Streaming: "Thinking" + loading dots. - expect(screen.getByText("Thinking")).toBeInTheDocument(); - expect(screen.queryByText("Thoughts")).toBeNull(); - expect(container.querySelector(".loading")).not.toBeNull(); - - // Open it. - const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" }); - await userEvent.click(checkbox); - expect(checkbox).toBeChecked(); - - // Transition generating → completed/committed (seq assigned, no longer streaming). - await rerender({ - chunks: [ - { - seq: 1, - role: "assistant", - chunk: { type: "thinking", text: "hmm, all done" }, - provisional: false, - }, - ], - }); - - // Completed: "Thoughts", no dots — and the open state survived the transition. - expect(screen.getByText("Thoughts")).toBeInTheDocument(); - expect(screen.queryByText("Thinking")).toBeNull(); - expect(container.querySelector(".loading")).toBeNull(); - expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked(); - expect(container).toHaveTextContent("hmm, all done"); - }); - - it("renders step and turn metrics as separate rows", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - { - seq: 3, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }, - ], - total: { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - durationMs: 1200, - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }, - ], - }, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - expect(screen.getByText("Hi")).toBeInTheDocument(); - expect(screen.getByText("Hello!")).toBeInTheDocument(); - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/150 tok/)).toHaveLength(2); - expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument(); - expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); - }); - - it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - ]; - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [], - total: { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 }, - steps: [], - }, - }, - ]; - - const { container } = render(ChatView, { props: { chunks, turnMetrics } }); - - expect(screen.getByText("Last turn:")).toBeInTheDocument(); - expect(screen.getByText("Chat Total:")).toBeInTheDocument(); - // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge - const badges = container.querySelectorAll(".badge"); - expect(badges).toHaveLength(2); - for (const b of badges) { - expect(b.textContent).toBe("93%"); - expect(b.classList.contains("badge-success")).toBe(true); - } - }); - - it("renders step-metrics inline after tool group", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "bash", - input: { command: "ls" }, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 3, - role: "tool", - chunk: { - type: "tool-result", - toolCallId: "tc1", - toolName: "bash", - content: "file.txt", - isError: false, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - { - seq: 4, - role: "assistant", - chunk: { type: "text", text: "Done!" }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 80, outputTokens: 20 }, - genTotalMs: 300, - }, - ], - total: { - turnId: "t1", - usage: { inputTokens: 80, outputTokens: 20 }, - durationMs: 500, - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 80, outputTokens: 20 }, - genTotalMs: 300, - }, - ], - }, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - // Both step-metrics and turn-metrics render - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument(); - - // They are in separate elements (different rows) - const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div"); - const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div"); - expect(stepEl).not.toBe(turnEl); - }); - - it("renders no metrics bubble when turnMetrics is empty", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics: [] } }); - - expect(screen.getByText("Hi")).toBeInTheDocument(); - expect(screen.getByText("Hello!")).toBeInTheDocument(); - expect(screen.queryByText(/step 1/)).toBeNull(); - expect(screen.queryByText(/^turn/)).toBeNull(); - }); - - it("omits null view values from metrics bubbles", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Response" }, - provisional: false, - }, - { - seq: 3, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - }, - ], - total: { - turnId: "t1", - usage: { inputTokens: 10, outputTokens: 5 }, - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - }, - ], - }, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - // Step metrics rendered - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/15 tok/)).toHaveLength(2); - // Turn metrics rendered - expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument(); - // No "null" or "undefined" in the DOM - expect(screen.queryByText("null")).toBeNull(); - expect(screen.queryByText("undefined")).toBeNull(); - }); - - it("renders step text but no turn total for a progressive turn (total: null)", () => { - const chunks: RenderedChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, - { - seq: 2, - role: "assistant", - chunk: { type: "text", text: "Hello!" }, - provisional: false, - }, - { - seq: 3, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: "tc1", - toolName: "test", - input: {}, - stepId: "t1#0" as StepId, - }, - provisional: false, - }, - ]; - - const turnMetrics: TurnMetricsEntry[] = [ - { - turnId: "t1", - steps: [ - { - stepId: "t1#0" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }, - ], - total: null, - }, - ]; - - render(ChatView, { props: { chunks, turnMetrics } }); - - // Step metrics should render - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/150 tok/)).toBeInTheDocument(); - - // Turn total should NOT render (total is null — turn still in progress) - expect(screen.queryByText(/^turn/)).toBeNull(); - }); + it("renders a message's text chunk", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "text", text: "Hello world" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Hello world")).toBeInTheDocument(); + }); + + it("renders multiple chunks", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Hi there")).toBeInTheDocument(); + expect(screen.getByText("Hello!")).toBeInTheDocument(); + }); + + it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => { + const chunks: RenderedChunk[] = [ + { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false }, + ]; + + let resolveEarlier: (() => void) | undefined; + const onShowEarlier = vi.fn( + () => + new Promise<void>((resolve) => { + resolveEarlier = resolve; + }), + ); + + render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } }); + + const button = screen.getByRole("button", { name: /show earlier messages/i }); + const user = userEvent.setup(); + await user.click(button); + + expect(onShowEarlier).toHaveBeenCalledTimes(1); + // While the page-in is awaited the button is disabled (no double-fire). + expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled(); + + resolveEarlier?.(); + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled(); + }); + }); + + it("hides the show-earlier button when nothing is unloaded", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false }, + ]; + + render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } }); + + expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); + }); + + it("renders tool-call chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "read_file", + input: { path: "/tmp/test.txt" }, + }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("read_file")).toBeInTheDocument(); + const pre = screen.getByText((content, element) => { + return element?.tagName === "PRE" && content.includes("/tmp/test.txt"); + }); + expect(pre).toBeInTheDocument(); + }); + + it("renders tool-result chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "tc1", + toolName: "read_file", + content: "file contents here", + isError: false, + }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("file contents here")).toBeInTheDocument(); + }); + + it("renders error chunks with alert role", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "error", message: "Something failed" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Something failed"); + }); + + it("renders error chunks with code", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("Rate limited")).toBeInTheDocument(); + expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument(); + }); + + it("renders system chunks", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "system", + chunk: { type: "system", text: "System context loaded" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks } }); + + expect(screen.getByText("System context loaded")).toBeInTheDocument(); + }); + + it("renders provisional (in-flight) chunks without any dimming", () => { + const chunks: RenderedChunk[] = [ + { + seq: null, + role: "assistant", + chunk: { type: "text", text: "Streaming..." }, + provisional: true, + }, + ]; + + render(ChatView, { props: { chunks } }); + + // In-flight chunks render at full opacity (no faded "disabled" look). + const wrapper = screen.getByText("Streaming...").closest("div"); + expect(wrapper).not.toHaveClass("opacity-50"); + }); + + it("renders empty transcript", () => { + render(ChatView, { props: { chunks: [] } }); + + const log = screen.getByRole("log"); + expect(log).toBeInTheDocument(); + expect(log.children).toHaveLength(0); + }); + + it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "read_file", + input: { path: "/a" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "b", + toolName: "list_dir", + input: { path: "/b" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 3, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "read_file", + content: "contents-of-a", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { props: { chunks } }); + + // Batched calls render as collapsible cards (one per call), not a list. + const collapses = container.querySelectorAll(".collapse"); + expect(collapses).toHaveLength(2); + + // Both call names + the available result are shown; the result is absorbed + // (no standalone tool-result card). + expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("list_dir")).toBeInTheDocument(); + expect(screen.getByText("contents-of-a")).toBeInTheDocument(); + }); + + it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => { + const chunks: RenderedChunk[] = [ + { + seq: null, + role: "assistant", + chunk: { type: "thinking", text: "Let me think..." }, + provisional: true, + streaming: true, + }, + ]; + + const { container } = render(ChatView, { props: { chunks } }); + + const collapse = container.querySelector(".collapse"); + expect(collapse).not.toBeNull(); + expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon + expect(collapse).not.toHaveClass("collapse-plus"); + // Visible bubble, like tool cards. + expect(collapse).toHaveClass("bg-base-200"); + expect(collapse).toHaveClass("rounded-box"); + expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument(); + }); + + it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => { + const streaming: RenderedChunk[] = [ + { + seq: null, + role: "assistant", + chunk: { type: "thinking", text: "hmm" }, + provisional: true, + streaming: true, + }, + ]; + + const { container, rerender } = render(ChatView, { props: { chunks: streaming } }); + + // Streaming: "Thinking" + loading dots. + expect(screen.getByText("Thinking")).toBeInTheDocument(); + expect(screen.queryByText("Thoughts")).toBeNull(); + expect(container.querySelector(".loading")).not.toBeNull(); + + // Open it. + const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" }); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + + // Transition generating → completed/committed (seq assigned, no longer streaming). + await rerender({ + chunks: [ + { + seq: 1, + role: "assistant", + chunk: { type: "thinking", text: "hmm, all done" }, + provisional: false, + }, + ], + }); + + // Completed: "Thoughts", no dots — and the open state survived the transition. + expect(screen.getByText("Thoughts")).toBeInTheDocument(); + expect(screen.queryByText("Thinking")).toBeNull(); + expect(container.querySelector(".loading")).toBeNull(); + expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked(); + expect(container).toHaveTextContent("hmm, all done"); + }); + + it("renders step and turn metrics as separate rows", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }, + ], + total: { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + durationMs: 1200, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }, + ], + }, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + expect(screen.getByText("Hi")).toBeInTheDocument(); + expect(screen.getByText("Hello!")).toBeInTheDocument(); + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getAllByText(/150 tok/)).toHaveLength(2); + expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument(); + expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); + }); + + it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + ]; + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [], + total: { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 }, + steps: [], + }, + }, + ]; + + const { container } = render(ChatView, { props: { chunks, turnMetrics } }); + + expect(screen.getByText("Last turn:")).toBeInTheDocument(); + expect(screen.getByText("Chat Total:")).toBeInTheDocument(); + // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge + const badges = container.querySelectorAll(".badge"); + expect(badges).toHaveLength(2); + for (const b of badges) { + expect(b.textContent).toBe("93%"); + expect(b.classList.contains("badge-success")).toBe(true); + } + }); + + it("renders step-metrics inline after tool group", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "bash", + input: { command: "ls" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 3, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "tc1", + toolName: "bash", + content: "file.txt", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 4, + role: "assistant", + chunk: { type: "text", text: "Done!" }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 80, outputTokens: 20 }, + genTotalMs: 300, + }, + ], + total: { + turnId: "t1", + usage: { inputTokens: 80, outputTokens: 20 }, + durationMs: 500, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 80, outputTokens: 20 }, + genTotalMs: 300, + }, + ], + }, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + // Both step-metrics and turn-metrics render + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument(); + + // They are in separate elements (different rows) + const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div"); + const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div"); + expect(stepEl).not.toBe(turnEl); + }); + + it("renders no metrics bubble when turnMetrics is empty", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics: [] } }); + + expect(screen.getByText("Hi")).toBeInTheDocument(); + expect(screen.getByText("Hello!")).toBeInTheDocument(); + expect(screen.queryByText(/step 1/)).toBeNull(); + expect(screen.queryByText(/^turn/)).toBeNull(); + }); + + it("omits null view values from metrics bubbles", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Response" }, + provisional: false, + }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + }, + ], + total: { + turnId: "t1", + usage: { inputTokens: 10, outputTokens: 5 }, + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + }, + ], + }, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + // Step metrics rendered + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getAllByText(/15 tok/)).toHaveLength(2); + // Turn metrics rendered + expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument(); + // No "null" or "undefined" in the DOM + expect(screen.queryByText("null")).toBeNull(); + expect(screen.queryByText("undefined")).toBeNull(); + }); + + it("renders step text but no turn total for a progressive turn (total: null)", () => { + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { type: "text", text: "Hello!" }, + provisional: false, + }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "test", + input: {}, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const turnMetrics: TurnMetricsEntry[] = [ + { + turnId: "t1", + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }, + ], + total: null, + }, + ]; + + render(ChatView, { props: { chunks, turnMetrics } }); + + // Step metrics should render + expect(screen.getByText(/step 1/)).toBeInTheDocument(); + expect(screen.getByText(/150 tok/)).toBeInTheDocument(); + + // Turn total should NOT render (total is null — turn still in progress) + expect(screen.queryByText(/^turn/)).toBeNull(); + }); }); describe("Composer", () => { - it("calls onSend with the typed text and clears", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); + it("calls onSend with the typed text and clears", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, "Hello world"); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "Hello world"); - const sendButton = screen.getByRole("button", { name: "Send" }); - await user.click(sendButton); + const sendButton = screen.getByRole("button", { name: "Send" }); + await user.click(sendButton); - expect(onSend).toHaveBeenCalledTimes(1); - expect(onSend).toHaveBeenCalledWith("Hello world"); - expect(textarea).toHaveValue(""); - }); + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("Hello world"); + expect(textarea).toHaveValue(""); + }); - it("does not call onSend with empty text", async () => { - const onSend = vi.fn(); - const _user = userEvent.setup(); + it("does not call onSend with empty text", async () => { + const onSend = vi.fn(); + const _user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const sendButton = screen.getByRole("button", { name: "Send" }); - expect(sendButton).toBeDisabled(); + const sendButton = screen.getByRole("button", { name: "Send" }); + expect(sendButton).toBeDisabled(); - expect(onSend).not.toHaveBeenCalled(); - }); + expect(onSend).not.toHaveBeenCalled(); + }); - it("trims whitespace before sending", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); + it("trims whitespace before sending", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, " hello "); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, " hello "); - const sendButton = screen.getByRole("button", { name: "Send" }); - await user.click(sendButton); + const sendButton = screen.getByRole("button", { name: "Send" }); + await user.click(sendButton); - expect(onSend).toHaveBeenCalledWith("hello"); - }); + expect(onSend).toHaveBeenCalledWith("hello"); + }); - it("sends on Enter key (without Shift)", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); + it("sends on Enter key (without Shift)", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, "Test message{Enter}"); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "Test message{Enter}"); - expect(onSend).toHaveBeenCalledWith("Test message"); - }); + expect(onSend).toHaveBeenCalledWith("Test message"); + }); - it("does not send on Shift+Enter", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); + it("does not send on Shift+Enter", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); - render(Composer, { props: { onSend } }); + render(Composer, { props: { onSend } }); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2"); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2"); - expect(onSend).not.toHaveBeenCalled(); - }); + expect(onSend).not.toHaveBeenCalled(); + }); }); describe("ModelSelector", () => { - const optionValues = (el: HTMLElement): string[] => - within(el) - .getAllByRole("option") - .map((o) => (o as HTMLOptionElement).value); - - it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => { - const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]; - render(ModelSelector, { - props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() }, - }); - - const keySelect = screen.getByRole("combobox", { name: "Key selector" }); - const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); - expect(keySelect).toHaveValue("anthropic"); - expect(modelSelect).toHaveValue("claude-3"); - - expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); - // only the models under the selected key - expect(optionValues(modelSelect)).toEqual(["claude-3"]); - }); - - it("selecting a key switches to the first model under it", async () => { - const onSelect = vi.fn(); - const user = userEvent.setup(); - const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"]; - - render(ModelSelector, { - props: { models, selected: "openai/gpt-4o", onSelect }, - }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic"); - - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3"); - }); - - it("selecting a model keeps the current key", async () => { - const onSelect = vi.fn(); - const user = userEvent.setup(); - const models = ["openai/gpt-4", "openai/gpt-4o"]; - - render(ModelSelector, { - props: { models, selected: "openai/gpt-4", onSelect }, - }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o"); - - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); - }); + const optionValues = (el: HTMLElement): string[] => + within(el) + .getAllByRole("option") + .map((o) => (o as HTMLOptionElement).value); + + it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => { + const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]; + render(ModelSelector, { + props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() }, + }); + + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + expect(keySelect).toHaveValue("anthropic"); + expect(modelSelect).toHaveValue("claude-3"); + + expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); + // only the models under the selected key + expect(optionValues(modelSelect)).toEqual(["claude-3"]); + }); + + it("selecting a key switches to the first model under it", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"]; + + render(ModelSelector, { + props: { models, selected: "openai/gpt-4o", onSelect }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic"); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3"); + }); + + it("selecting a model keeps the current key", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4", "openai/gpt-4o"]; + + render(ModelSelector, { + props: { models, selected: "openai/gpt-4", onSelect }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o"); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); + }); }); describe("ReasoningEffortSelector", () => { - it("renders null (never set) as the default level, marked '(default)'", () => { - render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } }); - - const select = screen.getByRole("combobox", { name: "Reasoning effort" }); - expect(select).toHaveValue("high"); - expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument(); - // All five ladder levels are offered. - expect(within(select).getAllByRole("option")).toHaveLength(5); - }); - - it("renders a persisted level as selected", () => { - render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } }); - - expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh"); - }); - - it("selecting a level saves it via the injected port and confirms", async () => { - const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({ - ok: true as const, - reasoningEffort: level, - })); - const user = userEvent.setup(); - - render(ReasoningEffortSelector, { props: { persisted: null, save } }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); - - expect(save).toHaveBeenCalledTimes(1); - expect(save).toHaveBeenCalledWith("max"); - await vi.waitFor(() => { - expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument(); - }); - expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max"); - }); - - it("a failed save shows the error and reverts to the persisted value", async () => { - const save = vi.fn(async () => ({ ok: false as const, error: "nope" })); - const user = userEvent.setup(); - - render(ReasoningEffortSelector, { props: { persisted: "low", save } }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); - - await vi.waitFor(() => { - expect(screen.getByText("nope")).toBeInTheDocument(); - }); - expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low"); - }); - - it("disables the select while a save is in flight (no double-fire)", async () => { - let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined; - const save = vi.fn( - () => - new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => { - resolveSave = resolve; - }), - ); - const user = userEvent.setup(); - - render(ReasoningEffortSelector, { props: { persisted: null, save } }); - - await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); - - expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled(); - - resolveSave?.({ ok: true, reasoningEffort: "max" }); - await vi.waitFor(() => { - expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled(); - }); - }); + it("renders null (never set) as the default level, marked '(default)'", () => { + render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } }); + + const select = screen.getByRole("combobox", { name: "Reasoning effort" }); + expect(select).toHaveValue("high"); + expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument(); + // All five ladder levels are offered. + expect(within(select).getAllByRole("option")).toHaveLength(5); + }); + + it("renders a persisted level as selected", () => { + render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } }); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh"); + }); + + it("selecting a level saves it via the injected port and confirms", async () => { + const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({ + ok: true as const, + reasoningEffort: level, + })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { props: { persisted: null, save } }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); + + expect(save).toHaveBeenCalledTimes(1); + expect(save).toHaveBeenCalledWith("max"); + await vi.waitFor(() => { + expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument(); + }); + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max"); + }); + + it("a failed save shows the error and reverts to the persisted value", async () => { + const save = vi.fn(async () => ({ ok: false as const, error: "nope" })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { props: { persisted: "low", save } }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); + + await vi.waitFor(() => { + expect(screen.getByText("nope")).toBeInTheDocument(); + }); + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low"); + }); + + it("disables the select while a save is in flight (no double-fire)", async () => { + let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined; + const save = vi.fn( + () => + new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => { + resolveSave = resolve; + }), + ); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { props: { persisted: null, save } }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled(); + + resolveSave?.({ ok: true, reasoningEffort: "max" }); + await vi.waitFor(() => { + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled(); + }); + }); }); diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index f2899c7..e72f639 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -1,294 +1,347 @@ <script lang="ts"> - import type { TurnProviderRetryEvent } from "@dispatch/wire"; - import { groupRenderedChunks, type RenderedChunk, viewProviderRetry } from "../index"; - import { - interleaveTurnMetrics, - viewCacheRate, - viewExpectedCache, - viewStepMetrics, - viewTurnMetrics, - type TurnMetricsEntry, - } from "../../../core/metrics"; - import { Markdown } from "../../markdown"; + import type { TurnProviderRetryEvent } from "@dispatch/wire"; + import { groupRenderedChunks, type RenderedChunk, viewProviderRetry } from "../index"; + import { + interleaveTurnMetrics, + viewCacheRate, + viewExpectedCache, + viewStepMetrics, + viewTurnMetrics, + type TurnMetricsEntry, + } from "../../../core/metrics"; + import { Markdown } from "../../markdown"; - const badgeClass = { - success: "badge-success", - warning: "badge-warning", - error: "badge-error", - } as const; + const badgeClass = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + } as const; - let { - chunks, - turnMetrics = [], - hasEarlier = false, - onShowEarlier, - thinkingKeyBase = 0, - providerRetry = null, - }: { - chunks: readonly RenderedChunk[]; - turnMetrics?: readonly TurnMetricsEntry[]; - /** Earlier history is unloaded (chat limit) and can be paged back in. */ - hasEarlier?: boolean; - /** Page earlier history back in; the caller owns scroll-position preservation. */ - onShowEarlier?: () => Promise<void>; - /** - * Ordinal base for thinking-collapse keys: the count of thinking chunks - * unloaded by the chat limit, so the remaining ordinals don't shift (and - * swap collapse state) when a trim removes older thinking blocks. - */ - thinkingKeyBase?: number; - /** - * The latest `provider-retry` event for the current turn, or `null` when - * no retry is pending → renders the transient yellow "retrying…" banner. - * Never persisted (never part of the message history); coalesces to the - * newest attempt + delay, and is cleared when content resumes / turn ends. - */ - providerRetry?: TurnProviderRetryEvent | null; - } = $props(); + let { + chunks, + turnMetrics = [], + hasEarlier = false, + onShowEarlier, + thinkingKeyBase = 0, + providerRetry = null, + }: { + chunks: readonly RenderedChunk[]; + turnMetrics?: readonly TurnMetricsEntry[]; + /** Earlier history is unloaded (chat limit) and can be paged back in. */ + hasEarlier?: boolean; + /** Page earlier history back in; the caller owns scroll-position preservation. */ + onShowEarlier?: () => Promise<void>; + /** + * Ordinal base for thinking-collapse keys: the count of thinking chunks + * unloaded by the chat limit, so the remaining ordinals don't shift (and + * swap collapse state) when a trim removes older thinking blocks. + */ + thinkingKeyBase?: number; + /** + * The latest `provider-retry` event for the current turn, or `null` when + * no retry is pending → renders the transient yellow "retrying…" banner. + * Never persisted (never part of the message history); coalesces to the + * newest attempt + delay, and is cleared when content resumes / turn ends. + */ + providerRetry?: TurnProviderRetryEvent | null; + } = $props(); - // True while a show-earlier page-in is awaited (disables the button). - let loadingEarlier = $state(false); + // True while a show-earlier page-in is awaited (disables the button). + let loadingEarlier = $state(false); - async function showEarlier() { - if (!onShowEarlier || loadingEarlier) return; - loadingEarlier = true; - try { - await onShowEarlier(); - } finally { - loadingEarlier = false; - } - } + async function showEarlier() { + if (!onShowEarlier || loadingEarlier) return; + loadingEarlier = true; + try { + await onShowEarlier(); + } finally { + loadingEarlier = false; + } + } - const groups = $derived(groupRenderedChunks(chunks)); + const groups = $derived(groupRenderedChunks(chunks)); - const rows = $derived(interleaveTurnMetrics(groups, turnMetrics)); + const rows = $derived(interleaveTurnMetrics(groups, turnMetrics)); - // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that - // survives the provisional→committed (seq null → seq N) transition, so the - // collapse's open/close state is NOT lost when a turn seals. The ordinal - // starts at `thinkingKeyBase` so keys also survive a chat-limit trim removing - // older thinking blocks. (App isolates these keys per conversation via {#key}.) - const keyedRows = $derived.by(() => { - let thinking = thinkingKeyBase; - return rows.map((row, i) => { - if (row.kind === "step-metrics") { - return { row, key: `s${row.step.stepId}` }; - } - if (row.kind === "turn-metrics") { - return { row, key: `m${row.turn.turnId}` }; - } - const group = row.group; - let key: string; - if (group.kind === "tool-batch") { - key = `b${group.stepId}`; - } else if (group.chunk.chunk.type === "thinking") { - key = `think${thinking++}`; - } else if (group.chunk.seq != null) { - key = `c${group.chunk.seq}`; - } else { - key = `p${i}`; - } - return { row, key }; - }); - }); + // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that + // survives the provisional→committed (seq null → seq N) transition, so the + // collapse's open/close state is NOT lost when a turn seals. The ordinal + // starts at `thinkingKeyBase` so keys also survive a chat-limit trim removing + // older thinking blocks. (App isolates these keys per conversation via {#key}.) + const keyedRows = $derived.by(() => { + let thinking = thinkingKeyBase; + return rows.map((row, i) => { + if (row.kind === "step-metrics") { + return { row, key: `s${row.step.stepId}` }; + } + if (row.kind === "turn-metrics") { + return { row, key: `m${row.turn.turnId}` }; + } + const group = row.group; + let key: string; + if (group.kind === "tool-batch") { + key = `b${group.stepId}`; + } else if (group.chunk.chunk.type === "thinking") { + key = `think${thinking++}`; + } else if (group.chunk.seq != null) { + key = `c${group.chunk.seq}`; + } else { + key = `p${i}`; + } + return { row, key }; + }); + }); </script> {#snippet chunkRow(rendered: RenderedChunk)} - {#if rendered.role === "user"} - <!-- User: a speech bubble, left-aligned --> - <div class="chat chat-start"> - <div class="chat-bubble chat-bubble-primary"> - {#if rendered.chunk.type === "text"} - <p>{rendered.chunk.text}</p> - {/if} - </div> - </div> - {:else if rendered.chunk.type === "thinking"} - <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse - (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots - while generating, then "Thoughts" with no dots once complete. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle thoughts" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span> - {#if rendered.streaming} - <span class="loading loading-dots loading-sm" aria-label="Generating"></span> - {/if} - </div> - <div class="collapse-content"> - <p class="whitespace-pre-wrap">{rendered.chunk.text}</p> - </div> - </div> - </div> - </div> - {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} - <!-- Single tool call/result: a collapsible card (collapsed by default, - like thinking). Title shows the tool name; content shows the - input/output. Same chat-start grid shim as the thinking block. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - {#if rendered.chunk.type === "tool-call"} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool call" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> - <span>{rendered.chunk.toolName}</span> - </div> - <div class="collapse-content"> - <pre class="overflow-x-auto text-xs">{JSON.stringify(rendered.chunk.input, null, 2)}</pre> - </div> - </div> - {:else} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool result" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium" class:text-error={rendered.chunk.isError}> - <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> - <span>{rendered.chunk.toolName}</span> - {#if rendered.chunk.isError} - <span class="badge badge-error badge-xs">error</span> - {/if} - </div> - <div class="collapse-content"> - <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre> - </div> - </div> - {/if} - </div> - </div> - {:else} - <!-- Assistant text / system / error: an INVISIBLE speech bubble — same - chat-start grid as the user bubble, so it inherits identical left spacing. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> - <div class="chat-bubble w-full bg-transparent"> - {#if rendered.chunk.type === "text"} - <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} /> - {:else if rendered.chunk.type === "error"} - <div class="text-error" role="alert"> - {rendered.chunk.message} - {#if rendered.chunk.code} - <span class="text-xs opacity-70">[{rendered.chunk.code}]</span> - {/if} - </div> - {:else if rendered.chunk.type === "system"} - <div class="text-sm opacity-70">{rendered.chunk.text}</div> - {/if} - </div> - </div> - {/if} + {#if rendered.role === "user"} + <!-- User: a speech bubble, left-aligned --> + <div class="chat chat-start"> + <div class="chat-bubble chat-bubble-primary"> + {#if rendered.chunk.type === "text"} + <p>{rendered.chunk.text}</p> + {/if} + </div> + </div> + {:else if rendered.chunk.type === "thinking"} + <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse + (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots + while generating, then "Thoughts" with no dots once complete. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> + <div class="chat-bubble w-full bg-transparent"> + <div class="collapse w-full rounded-box bg-base-200 text-sm"> + <input type="checkbox" aria-label="Toggle thoughts" /> + <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> + <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span> + {#if rendered.streaming} + <span class="loading loading-dots loading-sm" aria-label="Generating"></span> + {/if} + </div> + <div class="collapse-content"> + <p class="whitespace-pre-wrap">{rendered.chunk.text}</p> + </div> + </div> + </div> + </div> + {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} + <!-- Single tool call/result: a collapsible card (collapsed by default, + like thinking). Title shows the tool name; content shows the + input/output. Same chat-start grid shim as the thinking block. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> + <div class="chat-bubble w-full bg-transparent"> + {#if rendered.chunk.type === "tool-call"} + <div class="collapse w-full rounded-box bg-base-200 text-sm"> + <input type="checkbox" aria-label="Toggle tool call" /> + <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> + <svg + class="h-4 w-4 opacity-60" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + ><path + d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" + /></svg + > + <span>{rendered.chunk.toolName}</span> + </div> + <div class="collapse-content"> + <pre class="overflow-x-auto text-xs">{JSON.stringify( + rendered.chunk.input, + null, + 2, + )}</pre> + </div> + </div> + {:else} + <div class="collapse w-full rounded-box bg-base-200 text-sm"> + <input type="checkbox" aria-label="Toggle tool result" /> + <div + class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium" + class:text-error={rendered.chunk.isError} + > + <svg + class="h-4 w-4 opacity-60" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + ><path + d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" + /></svg + > + <span>{rendered.chunk.toolName}</span> + {#if rendered.chunk.isError} + <span class="badge badge-error badge-xs">error</span> + {/if} + </div> + <div class="collapse-content"> + <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre> + </div> + </div> + {/if} + </div> + </div> + {:else} + <!-- Assistant text / system / error: an INVISIBLE speech bubble — same + chat-start grid as the user bubble, so it inherits identical left spacing. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> + <div class="chat-bubble w-full bg-transparent"> + {#if rendered.chunk.type === "text"} + <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} /> + {:else if rendered.chunk.type === "error"} + <div class="text-error" role="alert"> + {rendered.chunk.message} + {#if rendered.chunk.code} + <span class="text-xs opacity-70">[{rendered.chunk.code}]</span> + {/if} + </div> + {:else if rendered.chunk.type === "system"} + <div class="text-sm opacity-70">{rendered.chunk.text}</div> + {/if} + </div> + </div> + {/if} {/snippet} <div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite"> - {#if hasEarlier && onShowEarlier} - <!-- Chat limit: older chunks are unloaded; offer to page them back in. --> - <div class="flex justify-center"> - <button class="btn btn-ghost btn-xs" disabled={loadingEarlier} onclick={showEarlier}> - {#if loadingEarlier} - <span class="loading loading-spinner loading-xs" aria-hidden="true"></span> - Loading earlier messages… - {:else} - Show earlier messages - {/if} - </button> - </div> - {/if} - {#each keyedRows as { row, key } (key)} - {#if row.kind === "step-metrics"} - {@const sv = viewStepMetrics(row.step, row.index)} - <div class="chat chat-start"> - <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> - <div class="text-xs opacity-70"> - {sv.label} · {sv.tokensLabel} - {#if sv.tps} · {sv.tps}{/if} - {#if sv.genTotal} · {sv.genTotal}{/if} - </div> - </div> - </div> - {:else if row.kind === "turn-metrics"} - {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} - {@const lastCache = viewCacheRate(row.turn.usage)} - {@const chatCache = viewCacheRate(row.cumulativeUsage)} - {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} - <div class="chat chat-start"> - <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> - <div class="flex flex-col gap-1 text-xs"> - <div class="opacity-70"> - {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) - {#if turnView.tps} · {turnView.tps}{/if} - {#if turnView.duration} · {turnView.duration}{/if} - </div> - <div class="flex flex-wrap items-center gap-x-3 gap-y-1"> - <span class="flex items-center gap-1"> - <span class="opacity-70">Last turn:</span> - <span class="badge badge-sm {badgeClass[lastCache.level]}">{lastCache.pct}%</span> - </span> - <span class="flex items-center gap-1"> - <span class="opacity-70">Chat Total:</span> - <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span> - </span> - {#if retention} - <span class="flex items-center gap-1"> - <span class="opacity-70">Retention:</span> - <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span> - </span> - {/if} - </div> - </div> - </div> - </div> - {:else if row.group.kind === "single"} - {@render chunkRow(row.group.chunk)} - {:else} - <!-- Batched tool calls (one step): each entry is a collapsible card. - Click to expand and see the input/output. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - <div class="flex flex-col gap-1"> - {#each row.group.entries as entry (entry.call.toolCallId)} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool call" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> - <span>{entry.call.toolName}</span> - {#if entry.result?.isError} - <span class="badge badge-error badge-xs">error</span> - {:else if entry.result === null} - <span class="loading loading-spinner loading-xs" aria-label="Running"></span> - {/if} - </div> - <div class="collapse-content"> - <pre class="overflow-x-auto text-xs">{JSON.stringify(entry.call.input, null, 2)}</pre> - {#if entry.result} - <pre class="mt-1 max-h-96 overflow-auto text-xs" class:text-error={entry.result.isError}>{entry.result.content}</pre> - {/if} - </div> - </div> - {/each} - </div> - </div> - </div> - {/if} - {/each} - {#if providerRetry} - {@const rv = viewProviderRetry(providerRetry)} - <!-- Transient yellow warning: a provider error is being retried with backoff. - NOT a message chunk (never persisted/replayed) — a live UI notification only, - shown where the reply would appear. Coalesces to the newest attempt + delay, - and is cleared (foldEvent) when content resumes or the turn ends. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> - <div class="chat-bubble w-full bg-transparent"> - <div class="alert alert-warning flex-wrap items-start gap-2 py-2 text-sm" role="status"> - <div class="flex flex-wrap items-center gap-2 font-medium"> - <span aria-hidden="true">⚠</span> - <span>{rv.attemptLabel} — retrying in {rv.delayLabel}…</span> - {#if rv.code} - <span class="badge badge-warning badge-sm font-mono">{rv.code}</span> - {/if} - </div> - <div class="w-full font-mono text-xs opacity-70">{rv.message}</div> - </div> - </div> - </div> - {/if} + {#if hasEarlier && onShowEarlier} + <!-- Chat limit: older chunks are unloaded; offer to page them back in. --> + <div class="flex justify-center"> + <button class="btn btn-ghost btn-xs" disabled={loadingEarlier} onclick={showEarlier}> + {#if loadingEarlier} + <span class="loading loading-spinner loading-xs" aria-hidden="true"></span> + Loading earlier messages… + {:else} + Show earlier messages + {/if} + </button> + </div> + {/if} + {#each keyedRows as { row, key } (key)} + {#if row.kind === "step-metrics"} + {@const sv = viewStepMetrics(row.step, row.index)} + <div class="chat chat-start"> + <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> + <div class="text-xs opacity-70"> + {sv.label} · {sv.tokensLabel} + {#if sv.tps} + · {sv.tps}{/if} + {#if sv.genTotal} + · {sv.genTotal}{/if} + </div> + </div> + </div> + {:else if row.kind === "turn-metrics"} + {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} + {@const lastCache = viewCacheRate(row.turn.usage)} + {@const chatCache = viewCacheRate(row.cumulativeUsage)} + {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} + <div class="chat chat-start"> + <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> + <div class="flex flex-col gap-1 text-xs"> + <div class="opacity-70"> + {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) + {#if turnView.tps} + · {turnView.tps}{/if} + {#if turnView.duration} + · {turnView.duration}{/if} + </div> + <div class="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span class="flex items-center gap-1"> + <span class="opacity-70">Last turn:</span> + <span class="badge badge-sm {badgeClass[lastCache.level]}">{lastCache.pct}%</span> + </span> + <span class="flex items-center gap-1"> + <span class="opacity-70">Chat Total:</span> + <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span> + </span> + {#if retention} + <span class="flex items-center gap-1"> + <span class="opacity-70">Retention:</span> + <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span> + </span> + {/if} + </div> + </div> + </div> + </div> + {:else if row.group.kind === "single"} + {@render chunkRow(row.group.chunk)} + {:else} + <!-- Batched tool calls (one step): each entry is a collapsible card. + Click to expand and see the input/output. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> + <div class="chat-bubble w-full bg-transparent"> + <div class="flex flex-col gap-1"> + {#each row.group.entries as entry (entry.call.toolCallId)} + <div class="collapse w-full rounded-box bg-base-200 text-sm"> + <input type="checkbox" aria-label="Toggle tool call" /> + <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> + <svg + class="h-4 w-4 opacity-60" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + ><path + d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" + /></svg + > + <span>{entry.call.toolName}</span> + {#if entry.result?.isError} + <span class="badge badge-error badge-xs">error</span> + {:else if entry.result === null} + <span class="loading loading-spinner loading-xs" aria-label="Running"></span> + {/if} + </div> + <div class="collapse-content"> + <pre class="overflow-x-auto text-xs">{JSON.stringify( + entry.call.input, + null, + 2, + )}</pre> + {#if entry.result} + <pre + class="mt-1 max-h-96 overflow-auto text-xs" + class:text-error={entry.result.isError}>{entry.result.content}</pre> + {/if} + </div> + </div> + {/each} + </div> + </div> + </div> + {/if} + {/each} + {#if providerRetry} + {@const rv = viewProviderRetry(providerRetry)} + <!-- Transient yellow warning: a provider error is being retried with backoff. + NOT a message chunk (never persisted/replayed) — a live UI notification only, + shown where the reply would appear. Coalesces to the newest attempt + delay, + and is cleared (foldEvent) when content resumes or the turn ends. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> + <div class="chat-bubble w-full bg-transparent"> + <div class="alert alert-warning flex-wrap items-start gap-2 py-2 text-sm" role="status"> + <div class="flex flex-wrap items-center gap-2 font-medium"> + <span aria-hidden="true">⚠</span> + <span>{rv.attemptLabel} — retrying in {rv.delayLabel}…</span> + {#if rv.code} + <span class="badge badge-warning badge-sm font-mono">{rv.code}</span> + {/if} + </div> + <div class="w-full font-mono text-xs opacity-70">{rv.message}</div> + </div> + </div> + </div> + {/if} </div> diff --git a/src/features/chat/ui/CompactionView.svelte b/src/features/chat/ui/CompactionView.svelte index 7bec984..5014e5c 100644 --- a/src/features/chat/ui/CompactionView.svelte +++ b/src/features/chat/ui/CompactionView.svelte @@ -1,154 +1,154 @@ <script lang="ts"> - export type CompactNowResult = - | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number } - | { readonly ok: false; readonly error: string }; + export type CompactNowResult = + | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number } + | { readonly ok: false; readonly error: string }; - export type SaveCompactPercentResult = - | { readonly ok: true; readonly percent: number } - | { readonly ok: false; readonly error: string }; + export type SaveCompactPercentResult = + | { readonly ok: true; readonly percent: number } + | { readonly ok: false; readonly error: string }; - let { - percent, - canCompact, - compactNow, - savePercent, - }: { - /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */ - percent: number | null; - /** Whether a real conversation is focused (a draft has nothing to compact). */ - canCompact: boolean; - compactNow: () => Promise<CompactNowResult | null>; - savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>; - } = $props(); + let { + percent, + canCompact, + compactNow, + savePercent, + }: { + /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */ + percent: number | null; + /** Whether a real conversation is focused (a draft has nothing to compact). */ + canCompact: boolean; + compactNow: () => Promise<CompactNowResult | null>; + savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>; + } = $props(); - const DEFAULT_PERCENT = 85; + const DEFAULT_PERCENT = 85; - let compacting = $state(false); - let compactError = $state<string | null>(null); - let compactResult = $state<{ summarized: number; kept: number } | null>(null); + let compacting = $state(false); + let compactError = $state<string | null>(null); + let compactResult = $state<{ summarized: number; kept: number } | null>(null); - let percentInput = $state(""); - let savingPercent = $state(false); - let percentError = $state<string | null>(null); - let percentSaved = $state(false); + let percentInput = $state(""); + let savingPercent = $state(false); + let percentError = $state<string | null>(null); + let percentSaved = $state(false); - // Sync the input from the prop when it changes (focus switch / initial load). - let lastPercent = $state<number | null>(null); - $effect(() => { - if (percent !== lastPercent) { - lastPercent = percent; - percentInput = percent !== null ? String(percent) : ""; - percentError = null; - percentSaved = false; - } - }); + // Sync the input from the prop when it changes (focus switch / initial load). + let lastPercent = $state<number | null>(null); + $effect(() => { + if (percent !== lastPercent) { + lastPercent = percent; + percentInput = percent !== null ? String(percent) : ""; + percentError = null; + percentSaved = false; + } + }); - const percentLabel = $derived( - percent == null - ? "Loading…" - : percent === 0 - ? "Disabled (manual only)" - : percent === DEFAULT_PERCENT - ? `${percent}% (default)` - : `${percent}%`, - ); + const percentLabel = $derived( + percent == null + ? "Loading…" + : percent === 0 + ? "Disabled (manual only)" + : percent === DEFAULT_PERCENT + ? `${percent}% (default)` + : `${percent}%`, + ); - async function handleCompact() { - if (compacting || !canCompact) return; - compacting = true; - compactError = null; - compactResult = null; - const result = await compactNow(); - compacting = false; - if (result === null) return; - if (result.ok) { - compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept }; - } else { - compactError = result.error; - } - } + async function handleCompact() { + if (compacting || !canCompact) return; + compacting = true; + compactError = null; + compactResult = null; + const result = await compactNow(); + compacting = false; + if (result === null) return; + if (result.ok) { + compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept }; + } else { + compactError = result.error; + } + } - async function handleSavePercent() { - const value = Number.parseInt(percentInput, 10); - if (Number.isNaN(value) || value < 0 || value > 100) { - percentError = "Must be 0-100"; - return; - } - savingPercent = true; - percentError = null; - percentSaved = false; - const result = await savePercent(value); - savingPercent = false; - if (result === null) return; - if (result.ok) { - percentSaved = true; - } else { - percentError = result.error; - } - } + async function handleSavePercent() { + const value = Number.parseInt(percentInput, 10); + if (Number.isNaN(value) || value < 0 || value > 100) { + percentError = "Must be 0-100"; + return; + } + savingPercent = true; + percentError = null; + percentSaved = false; + const result = await savePercent(value); + savingPercent = false; + if (result === null) return; + if (result.ok) { + percentSaved = true; + } else { + percentError = result.error; + } + } </script> <div class="flex flex-col gap-3"> - <!-- Manual compaction --> - <section class="flex flex-col gap-1"> - <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span> - <button - type="button" - class="btn btn-sm btn-outline" - disabled={!canCompact || compacting} - onclick={handleCompact} - > - {#if compacting} - <span class="loading loading-spinner loading-xs"></span> - Compacting… - {:else} - Compact now - {/if} - </button> - {#if !canCompact} - <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p> - {:else if compactError} - <p class="text-xs text-error">{compactError}</p> - {:else if compactResult} - <p class="text-xs text-success"> - Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept. - </p> - {:else} - <p class="text-xs opacity-50"> - Summarizes old messages into a system summary + retains the most recent messages. - </p> - {/if} - </section> + <!-- Manual compaction --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span> + <button + type="button" + class="btn btn-sm btn-outline" + disabled={!canCompact || compacting} + onclick={handleCompact} + > + {#if compacting} + <span class="loading loading-spinner loading-xs"></span> + Compacting… + {:else} + Compact now + {/if} + </button> + {#if !canCompact} + <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p> + {:else if compactError} + <p class="text-xs text-error">{compactError}</p> + {:else if compactResult} + <p class="text-xs text-success"> + Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept. + </p> + {:else} + <p class="text-xs opacity-50"> + Summarizes old messages into a system summary + retains the most recent messages. + </p> + {/if} + </section> - <!-- Auto-compact percent --> - <section class="flex flex-col gap-1"> - <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span> - <div class="flex items-center gap-2"> - <input - type="number" - class="input input-bordered input-sm w-24" - min="0" - max="100" - placeholder={String(DEFAULT_PERCENT)} - value={percentInput} - disabled={savingPercent} - onchange={handleSavePercent} - aria-label="Compact percent (0-100)" - /> - <span class="text-xs opacity-60">%</span> - {#if savingPercent} - <span class="loading loading-spinner loading-xs"></span> - {/if} - </div> - <p class="text-xs opacity-50"> - Current: {percentLabel} - <br /> - 0 disables auto-compact. Default is {DEFAULT_PERCENT}%. - </p> - {#if percentError} - <p class="text-xs text-error">{percentError}</p> - {:else if percentSaved} - <p class="text-xs text-success">Saved.</p> - {/if} - </section> + <!-- Auto-compact percent --> + <section class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span> + <div class="flex items-center gap-2"> + <input + type="number" + class="input input-bordered input-sm w-24" + min="0" + max="100" + placeholder={String(DEFAULT_PERCENT)} + value={percentInput} + disabled={savingPercent} + onchange={handleSavePercent} + aria-label="Compact percent (0-100)" + /> + <span class="text-xs opacity-60">%</span> + {#if savingPercent} + <span class="loading loading-spinner loading-xs"></span> + {/if} + </div> + <p class="text-xs opacity-50"> + Current: {percentLabel} + <br /> + 0 disables auto-compact. Default is {DEFAULT_PERCENT}%. + </p> + {#if percentError} + <p class="text-xs text-error">{percentError}</p> + {:else if percentSaved} + <p class="text-xs text-success">Saved.</p> + {/if} + </section> </div> diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index fe9ea94..96b4b3a 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -1,198 +1,198 @@ <script lang="ts"> - import { computeContextUsage, formatCompactTokens } from "../../../core/metrics"; + import { computeContextUsage, formatCompactTokens } from "../../../core/metrics"; - const FALLBACK_CONTEXT_WINDOW = 1_000_000; - const MAX_LINES = 7; + const FALLBACK_CONTEXT_WINDOW = 1_000_000; + const MAX_LINES = 7; - let { - onSend, - onQueue, - onStop, - contextSize = undefined, - contextWindow = undefined, - status = "idle", - }: { - onSend: (text: string) => void; - /** - * Enqueue a steering message (`chat.queue`). When provided AND the status - * is `running`, the send button becomes a "Queue" button that steers the - * in-flight turn instead of starting a new one. When absent, `onSend` is - * used regardless (tests / non-steering contexts). - */ - onQueue?: (text: string) => void; - /** Stop the in-flight generation (`POST /conversations/:id/stop`). */ - onStop?: () => void; - // Current context occupancy (latest turn's contextSize), or `undefined` - // when unknown — the status bar then shows "— tokens", never 0%. - contextSize?: number | undefined; - /** Per-model context window (max tokens) from `GET /models` modelInfo. */ - contextWindow?: number | undefined; - // Coarse agent status for the status-bar icon. - status?: "idle" | "running" | "error"; - } = $props(); + let { + onSend, + onQueue, + onStop, + contextSize = undefined, + contextWindow = undefined, + status = "idle", + }: { + onSend: (text: string) => void; + /** + * Enqueue a steering message (`chat.queue`). When provided AND the status + * is `running`, the send button becomes a "Queue" button that steers the + * in-flight turn instead of starting a new one. When absent, `onSend` is + * used regardless (tests / non-steering contexts). + */ + onQueue?: (text: string) => void; + /** Stop the in-flight generation (`POST /conversations/:id/stop`). */ + onStop?: () => void; + // Current context occupancy (latest turn's contextSize), or `undefined` + // when unknown — the status bar then shows "— tokens", never 0%. + contextSize?: number | undefined; + /** Per-model context window (max tokens) from `GET /models` modelInfo. */ + contextWindow?: number | undefined; + // Coarse agent status for the status-bar icon. + status?: "idle" | "running" | "error"; + } = $props(); - let text = $state(""); - let inputEl: HTMLTextAreaElement | undefined; + let text = $state(""); + let inputEl: HTMLTextAreaElement | undefined; - const hasText = $derived(text.trim().length > 0); - const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW); - const usage = $derived(computeContextUsage(contextSize, effectiveMax)); - const hasUsage = $derived(contextSize !== undefined); + const hasText = $derived(text.trim().length > 0); + const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW); + const usage = $derived(computeContextUsage(contextSize, effectiveMax)); + const hasUsage = $derived(contextSize !== undefined); - // One button, three modes: - // - idle → "Send" (starts a turn via chat.send) - // - running + text → "Queue" (steers via chat.queue) - // - running + empty → "Stop" (aborts via POST /stop) - const buttonMode = $derived.by<"send" | "queue" | "stop">(() => { - if (status === "running" && !hasText && onStop !== undefined) return "stop"; - if (status === "running" && hasText && onQueue !== undefined) return "queue"; - return "send"; - }); - const placeholder = $derived(status === "running" ? "Steer the conversation..." : "Type a message..."); + // One button, three modes: + // - idle → "Send" (starts a turn via chat.send) + // - running + text → "Queue" (steers via chat.queue) + // - running + empty → "Stop" (aborts via POST /stop) + const buttonMode = $derived.by<"send" | "queue" | "stop">(() => { + if (status === "running" && !hasText && onStop !== undefined) return "stop"; + if (status === "running" && hasText && onQueue !== undefined) return "queue"; + return "send"; + }); + const placeholder = $derived( + status === "running" ? "Steer the conversation..." : "Type a message...", + ); - // As the window fills, escalate color: calm → warning → danger. - function fillClass(pct: number): string { - if (pct >= 90) return "progress-error"; - if (pct >= 70) return "progress-warning"; - return "progress-success"; - } + // As the window fills, escalate color: calm → warning → danger. + function fillClass(pct: number): string { + if (pct >= 90) return "progress-error"; + if (pct >= 70) return "progress-warning"; + return "progress-success"; + } - function resize(): void { - const el = inputEl; - if (!el) return; - el.style.height = "auto"; - const style = getComputedStyle(el); - const lineHeight = Number.parseFloat(style.lineHeight) || 20; - const paddingY = - Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom); - const borderY = - Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth); - const maxHeight = lineHeight * MAX_LINES + paddingY + borderY; - const next = Math.min(el.scrollHeight, maxHeight); - el.style.height = `${next}px`; - el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden"; - } + function resize(): void { + const el = inputEl; + if (!el) return; + el.style.height = "auto"; + const style = getComputedStyle(el); + const lineHeight = Number.parseFloat(style.lineHeight) || 20; + const paddingY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom); + const borderY = + Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth); + const maxHeight = lineHeight * MAX_LINES + paddingY + borderY; + const next = Math.min(el.scrollHeight, maxHeight); + el.style.height = `${next}px`; + el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden"; + } - // Re-run resize whenever the value changes (covers programmatic clears too). - $effect(() => { - void text; - resize(); - }); + // Re-run resize whenever the value changes (covers programmatic clears too). + $effect(() => { + void text; + resize(); + }); - function handleSubmit(): void { - const trimmed = text.trim(); - if (trimmed.length === 0) return; - if (buttonMode === "queue") { - onQueue?.(trimmed); - } else { - onSend(trimmed); - } - text = ""; - } + function handleSubmit(): void { + const trimmed = text.trim(); + if (trimmed.length === 0) return; + if (buttonMode === "queue") { + onQueue?.(trimmed); + } else { + onSend(trimmed); + } + text = ""; + } - function handleKeydown(e: KeyboardEvent): void { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - } + function handleKeydown(e: KeyboardEvent): void { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + } </script> <form - class="flex flex-col" - onsubmit={(e) => { - e.preventDefault(); - handleSubmit(); - }} + class="flex flex-col" + onsubmit={(e) => { + e.preventDefault(); + handleSubmit(); + }} > - <!-- Top bar: expanding textarea + single context-aware button --> - <div class="flex items-end gap-2 px-4 pt-3 pb-2"> - <textarea - bind:this={inputEl} - class="textarea textarea-bordered flex-1 resize-none leading-normal !min-h-0 h-auto" - bind:value={text} - onkeydown={handleKeydown} - placeholder={placeholder} - rows="1" - aria-label="Message input" - ></textarea> - {#if buttonMode === "stop"} - <button - class="btn btn-error w-20 shrink-0" - type="button" - aria-label="Stop generation" - onclick={() => onStop?.()} - > - Stop - </button> - {:else} - <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}> - {buttonMode === "queue" ? "Queue" : "Send"} - </button> - {/if} - </div> + <!-- Top bar: expanding textarea + single context-aware button --> + <div class="flex items-end gap-2 px-4 pt-3 pb-2"> + <textarea + bind:this={inputEl} + class="textarea textarea-bordered flex-1 resize-none leading-normal !min-h-0 h-auto" + bind:value={text} + onkeydown={handleKeydown} + {placeholder} + rows="1" + aria-label="Message input"></textarea> + {#if buttonMode === "stop"} + <button + class="btn btn-error w-20 shrink-0" + type="button" + aria-label="Stop generation" + onclick={() => onStop?.()} + > + Stop + </button> + {:else} + <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}> + {buttonMode === "queue" ? "Queue" : "Send"} + </button> + {/if} + </div> - <!-- Bottom status bar: status icon · context-window fill · token count --> - <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50"> - <span class="shrink-0"> - {#if status === "running"} - <span class="loading loading-spinner loading-xs text-primary"></span> - {:else if status === "error"} - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - class="h-4 w-4 text-error" - aria-label="Error" - > - <circle cx="12" cy="12" r="10"></circle> - <line x1="12" y1="8" x2="12" y2="12"></line> - <line x1="12" y1="16" x2="12.01" y2="16"></line> - </svg> - {:else} - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2.5" - stroke-linecap="round" - stroke-linejoin="round" - class="h-4 w-4 text-success" - aria-label="Idle" - > - <polyline points="20 6 9 17 4 12"></polyline> - </svg> - {/if} - </span> + <!-- Bottom status bar: status icon · context-window fill · token count --> + <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50"> + <span class="shrink-0"> + {#if status === "running"} + <span class="loading loading-spinner loading-xs text-primary"></span> + {:else if status === "error"} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + class="h-4 w-4 text-error" + aria-label="Error" + > + <circle cx="12" cy="12" r="10"></circle> + <line x1="12" y1="8" x2="12" y2="12"></line> + <line x1="12" y1="16" x2="12.01" y2="16"></line> + </svg> + {:else} + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2.5" + stroke-linecap="round" + stroke-linejoin="round" + class="h-4 w-4 text-success" + aria-label="Idle" + > + <polyline points="20 6 9 17 4 12"></polyline> + </svg> + {/if} + </span> - {#if usage.percent !== null} - <progress - class="progress h-2 flex-1 {fillClass(usage.percent)}" - value={usage.percent} - max="100" - ></progress> - {:else} - <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress> - {/if} + {#if usage.percent !== null} + <progress + class="progress h-2 flex-1 {fillClass(usage.percent)}" + value={usage.percent} + max="100" + ></progress> + {:else} + <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress> + {/if} - <span class="shrink-0 whitespace-nowrap font-mono"> - {#if hasUsage} - {formatCompactTokens(usage.current)}{#if usage.max !== null}<span - class="text-base-content/40" - > - / {formatCompactTokens(usage.max)}</span - >{/if} - {#if usage.percent !== null} - <span class="ml-1">· {usage.percent.toFixed(1)}%</span> - {/if} - {:else} - <span class="text-base-content/40">— tokens</span> - {/if} - </span> - </div> + <span class="shrink-0 whitespace-nowrap font-mono"> + {#if hasUsage} + {formatCompactTokens(usage.current)}{#if usage.max !== null}<span + class="text-base-content/40" + > + / {formatCompactTokens(usage.max)}</span + >{/if} + {#if usage.percent !== null} + <span class="ml-1">· {usage.percent.toFixed(1)}%</span> + {/if} + {:else} + <span class="text-base-content/40">— tokens</span> + {/if} + </span> + </div> </form> diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte index a288cb8..03acb79 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,50 +1,50 @@ <script lang="ts"> - import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select"; + import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select"; - let { - models, - selected, - onSelect, - }: { - models: readonly string[]; - selected: string; - onSelect: (model: string) => void; - } = $props(); + let { + models, + selected, + onSelect, + }: { + models: readonly string[]; + selected: string; + onSelect: (model: string) => void; + } = $props(); - const keys = $derived(modelKeys(models)); - const current = $derived(splitModelName(selected)); - const keyModels = $derived(modelsForKey(models, current.key)); + const keys = $derived(modelKeys(models)); + const current = $derived(splitModelName(selected)); + const keyModels = $derived(modelsForKey(models, current.key)); - // Switching key jumps to the first model available under it. - function selectKey(key: string): void { - const first = modelsForKey(models, key)[0] ?? ""; - onSelect(joinModelName(key, first)); - } + // Switching key jumps to the first model available under it. + function selectKey(key: string): void { + const first = modelsForKey(models, key)[0] ?? ""; + onSelect(joinModelName(key, first)); + } - function selectModel(model: string): void { - onSelect(joinModelName(current.key, model)); - } + function selectModel(model: string): void { + onSelect(joinModelName(current.key, model)); + } </script> <div class="flex flex-col gap-2"> - <select - class="select w-full" - value={current.key} - onchange={(e) => selectKey(e.currentTarget.value)} - aria-label="Key selector" - > - {#each keys as key (key)} - <option value={key}>{key}</option> - {/each} - </select> - <select - class="select w-full" - value={current.model} - onchange={(e) => selectModel(e.currentTarget.value)} - aria-label="Model selector" - > - {#each keyModels as model (model)} - <option value={model}>{model}</option> - {/each} - </select> + <select + class="select w-full" + value={current.key} + onchange={(e) => selectKey(e.currentTarget.value)} + aria-label="Key selector" + > + {#each keys as key (key)} + <option value={key}>{key}</option> + {/each} + </select> + <select + class="select w-full" + value={current.model} + onchange={(e) => selectModel(e.currentTarget.value)} + aria-label="Model selector" + > + {#each keyModels as model (model)} + <option value={model}>{model}</option> + {/each} + </select> </div> diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte index 8c7b193..d982905 100644 --- a/src/features/chat/ui/ReasoningEffortSelector.svelte +++ b/src/features/chat/ui/ReasoningEffortSelector.svelte @@ -1,75 +1,75 @@ <script lang="ts"> - import type { ReasoningEffort } from "@dispatch/transport-contract"; - import { - effectiveEffort, - effortOptions, - isReasoningEffort, - type SaveReasoningEffort, - } from "../reasoning-effort"; + import type { ReasoningEffort } from "@dispatch/transport-contract"; + import { + effectiveEffort, + effortOptions, + isReasoningEffort, + type SaveReasoningEffort, + } from "../reasoning-effort"; - let { - persisted, - save, - }: { - /** The conversation's persisted level, or null when never set (default applies). */ - persisted: ReasoningEffort | null; - save: SaveReasoningEffort; - } = $props(); + let { + persisted, + save, + }: { + /** The conversation's persisted level, or null when never set (default applies). */ + persisted: ReasoningEffort | null; + save: SaveReasoningEffort; + } = $props(); - const options = effortOptions(); + const options = effortOptions(); - // The user's in-flight choice; null = mirror the (async-loaded) persisted prop. - // Re-mounted per conversation, so there is no cross-tab bleed. - let chosen = $state<ReasoningEffort | null>(null); - let saving = $state(false); - let error = $state<string | null>(null); - let justSaved = $state(false); + // The user's in-flight choice; null = mirror the (async-loaded) persisted prop. + // Re-mounted per conversation, so there is no cross-tab bleed. + let chosen = $state<ReasoningEffort | null>(null); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); - const selected = $derived(chosen ?? effectiveEffort(persisted)); + const selected = $derived(chosen ?? effectiveEffort(persisted)); - async function handleChange(value: string) { - if (!isReasoningEffort(value) || saving) return; - chosen = value; - saving = true; - error = null; - justSaved = false; - const result = await save(value); - saving = false; - if (result === null) return; - if (result.ok) { - justSaved = true; - } else { - error = result.error; - chosen = null; // revert to the persisted value - } - } + async function handleChange(value: string) { + if (!isReasoningEffort(value) || saving) return; + chosen = value; + saving = true; + error = null; + justSaved = false; + const result = await save(value); + saving = false; + if (result === null) return; + if (result.ok) { + justSaved = true; + } else { + error = result.error; + chosen = null; // revert to the persisted value + } + } </script> <div class="flex flex-col gap-1"> - <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span> - <div class="flex items-center gap-2"> - <select - class="select select-sm w-full" - value={selected} - disabled={saving} - onchange={(e) => handleChange(e.currentTarget.value)} - aria-label="Reasoning effort" - > - {#each options as option (option.value)} - <option value={option.value}>{option.label}</option> - {/each} - </select> - {#if saving} - <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span> - {/if} - </div> - {#if error} - <p class="text-xs text-error">{error}</p> - {:else if justSaved} - <p class="text-xs text-success">Saved — applies from the next turn.</p> - {:else} - <p class="text-xs opacity-50"> - How long the model thinks before answering. Changing it can re-prefill the prompt cache once. - </p> - {/if} + <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span> + <div class="flex items-center gap-2"> + <select + class="select select-sm w-full" + value={selected} + disabled={saving} + onchange={(e) => handleChange(e.currentTarget.value)} + aria-label="Reasoning effort" + > + {#each options as option (option.value)} + <option value={option.value}>{option.label}</option> + {/each} + </select> + {#if saving} + <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span> + {/if} + </div> + {#if error} + <p class="text-xs text-error">{error}</p> + {:else if justSaved} + <p class="text-xs text-success">Saved — applies from the next turn.</p> + {:else} + <p class="text-xs opacity-50"> + How long the model thinks before answering. Changing it can re-prefill the prompt cache once. + </p> + {/if} </div> |
