diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 10:52:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 10:52:13 +0900 |
| commit | 17ce47987e673b6618454d033885b17b2a01912e (patch) | |
| tree | 2bd7259d51725eeadc20d410667d529f66834197 /src/core | |
| parent | eff289b2b4cc41db706f3c3274a089d46012be87 (diff) | |
| download | dispatch-web-17ce47987e673b6618454d033885b17b2a01912e.tar.gz dispatch-web-17ce47987e673b6618454d033885b17b2a01912e.zip | |
feat(core): chunks retry-banner, wire conformance, ws logic, history adapter
- core/chunks: add retry-banner view-model (+test), update reducer/selectors/types
- core/wire: update conformance checks (+test)
- adapters/ws: update reconnect logic (+tests)
- adapters/history: new client-side routing adapter (history + popstate wrapper)
Diffstat (limited to 'src/core')
| -rw-r--r-- | src/core/chunks/index.ts | 4 | ||||
| -rw-r--r-- | src/core/chunks/reducer.test.ts | 153 | ||||
| -rw-r--r-- | src/core/chunks/reducer.ts | 53 | ||||
| -rw-r--r-- | src/core/chunks/retry-banner.test.ts | 63 | ||||
| -rw-r--r-- | src/core/chunks/retry-banner.ts | 51 | ||||
| -rw-r--r-- | src/core/chunks/selectors.ts | 11 | ||||
| -rw-r--r-- | src/core/chunks/types.ts | 16 | ||||
| -rw-r--r-- | src/core/wire/conformance.test.ts | 17 | ||||
| -rw-r--r-- | src/core/wire/conformance.ts | 2 |
9 files changed, 361 insertions, 9 deletions
diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts index 6ab0f35..162531d 100644 --- a/src/core/chunks/index.ts +++ b/src/core/chunks/index.ts @@ -7,7 +7,9 @@ export { foldEvent, initialState, } from "./reducer"; -export { selectChunks, selectGenerating, selectMessages } from "./selectors"; +export type { ProviderRetryView } from "./retry-banner"; +export { formatRetryDelay, viewProviderRetry } from "./retry-banner"; +export { selectChunks, selectGenerating, selectMessages, selectProviderRetry } from "./selectors"; export { DEFAULT_CHAT_LIMIT, initialWindowSize, diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index 883461e..ac9b895 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -4,6 +4,7 @@ import type { TurnDoneEvent, TurnErrorEvent, TurnInputEvent, + TurnProviderRetryEvent, TurnReasoningDeltaEvent, TurnSealedEvent, TurnStartEvent, @@ -21,7 +22,7 @@ import { foldEvent, initialState, } from "./reducer"; -import { selectChunks, selectGenerating, selectMessages } from "./selectors"; +import { selectChunks, selectGenerating, selectMessages, selectProviderRetry } from "./selectors"; const turnStart = (turnId: string): TurnStartEvent => ({ type: "turn-start", @@ -101,6 +102,17 @@ const turnSealed = (turnId: string): TurnSealedEvent => ({ turnId, }); +const providerRetry = ( + turnId: string, + attempt: number, + delayMs: number, + message = "HTTP 429: overloaded", + code?: string, +): TurnProviderRetryEvent => + code !== undefined + ? { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message, code } + : { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message }; + const storedChunk = ( seq: number, role: "user" | "assistant" | "tool" | "system", @@ -384,6 +396,145 @@ describe("foldEvent — status and tool-output", () => { }); }); +describe("foldEvent — provider-retry (transient retry banner)", () => { + it("sets the provider-retry banner on a provider-retry event", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000, "HTTP 429: overloaded", "429")); + const retry = selectProviderRetry(s); + expect(retry).not.toBeNull(); + expect(retry?.attempt).toBe(0); + expect(retry?.delayMs).toBe(5000); + expect(retry?.code).toBe("429"); + }); + + it("does NOT add a chunk (never persisted — never pollutes the prompt)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(selectChunks(s)).toHaveLength(0); + expect(s.provisional).toHaveLength(0); + expect(s.accumulating).toBeNull(); + }); + + it("coalesces: the latest attempt + delay replaces the previous", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000, "first", "429")); + s = foldEvent(s, providerRetry("t1", 1, 10000, "second", "429")); + const retry = selectProviderRetry(s); + expect(retry?.attempt).toBe(1); + expect(retry?.delayMs).toBe(10000); + expect(retry?.message).toBe("second"); + }); + + it("keeps generating true (the turn is still in flight, just retrying)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(s.generating).toBe(true); + }); + + it("clears when content resumes (text-delta)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, textDelta("t1", "here is the reply")); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("clears when content resumes (reasoning-delta / tool-call / tool-result)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, reasoningDelta("t1", "thinking")); + expect(selectProviderRetry(s)).toBeNull(); + + s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("clears when the turn ends (done / turn-sealed / error)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, errorEvent("t1", "exhausted")); + expect(selectProviderRetry(s)).toBeNull(); + + s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, doneEvent("t1")); + expect(selectProviderRetry(s)).toBeNull(); + + s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, turnSealed("t1")); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("clears on a new turn (turn-start)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, turnStart("t2")); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("leaves the banner untouched across metadata events (usage / step-complete / status)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, usageEvent("t1", 10, 20)); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, { + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 100, + decodeMs: 200, + genTotalMs: 300, + }); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, { type: "status", conversationId: "c1", status: "running" }); + expect(selectProviderRetry(s)).not.toBeNull(); + }); + + it("is null in the initial state", () => { + expect(selectProviderRetry(initialState())).toBeNull(); + }); +}); + +describe("clearGenerating also clears a stale provider-retry banner (reconnect)", () => { + it("clears the retry banner alongside generating on reconnect", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(s.generating).toBe(true); + expect(selectProviderRetry(s)).not.toBeNull(); + const cleared = clearGenerating(s); + expect(cleared.generating).toBe(false); + expect(selectProviderRetry(cleared)).toBeNull(); + }); + + it("preserves transcript content while clearing the banner", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + const cleared = clearGenerating(s); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(selectProviderRetry(cleared)).toBeNull(); + }); +}); + describe("foldEvent — user-message (the turn's user prompt; backend CR-3)", () => { const userMessage = (text: string): TurnInputEvent => ({ type: "user-message", diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts index 0783c22..1e9c8f5 100644 --- a/src/core/chunks/reducer.ts +++ b/src/core/chunks/reducer.ts @@ -13,6 +13,7 @@ export function initialState(): TranscriptState { hiddenBeforeSeq: 0, hiddenThinkingCount: 0, generating: false, + providerRetry: null, }; } @@ -25,7 +26,10 @@ export function initialState(): TranscriptState { */ export function clearGenerating(state: TranscriptState): TranscriptState { if (!state.generating) return state; - return { ...state, generating: false }; + // Also drop a stale `provider-retry` banner — a retry pending at disconnect + // is stale once we re-subscribe (provider-retry events are not replayed), so + // a finished turn must not keep showing a "retrying…" banner forever. + return { ...state, generating: false, providerRetry: null }; } function flushAccumulating( @@ -116,8 +120,12 @@ export function applyHistory( * it true; `done` / `turn-sealed` / `error` clear it. This is what a watching * (or reconnected) client renders as "generating…", with no dependence on the * free-form `status` event string. + * + * NOTE: this is the inner reducer. The transient `provider-retry` banner is SET + * here (on a `provider-retry` event) but CLEARED by the `foldEvent` wrapper + * below, so the clearing logic stays centralized in one place. */ -export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState { +function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState { switch (event.type) { case "status": case "tool-output": @@ -280,7 +288,48 @@ export function foldEvent(state: TranscriptState, event: AgentEvent): Transcript generating: true, }; } + + case "provider-retry": { + // TRANSIENT: a retryable provider error is being retried with backoff. + // Coalesce — the latest attempt + delay replaces any previous, so a + // single updating "retrying…" banner shows the newest. NOT a chunk: it + // never enters provisional/committed, so it can never pollute the prompt + // or be replayed on a reload. The turn is still in flight, so `generating` + // (already true from `turn-start`) is left untouched. + return { ...state, providerRetry: event }; + } + } +} + +/** + * Fold one live AgentEvent into the transcript state. Wraps `reduceEvent` to + * centralize the TRANSIENT `provider-retry` banner's clearing: the banner is + * SET by `reduceEvent` on a `provider-retry` event (coalescing), and CLEARED + * here when the model's content resumes (the retry succeeded) or the turn ends + * (done/sealed/error) or a new turn starts. Metadata/no-op events + * (`status`/`tool-output`/`usage`/`step-complete`) leave a showing banner + * untouched. The `state.providerRetry !== null` guard keeps the common path + * (no banner pending) identity-stable — no needless new object. + */ +// Events that clear a showing provider-retry banner (content resumed or turn ended). +const RETRY_CLEARING_EVENTS: ReadonlySet<AgentEvent["type"]> = new Set([ + "turn-start", + "text-delta", + "reasoning-delta", + "tool-call", + "tool-result", + "error", + "done", + "turn-sealed", +]); + +export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState { + const next = reduceEvent(state, event); + if (event.type === "provider-retry") return next; // set by reduceEvent; not a clearing event + if (RETRY_CLEARING_EVENTS.has(event.type) && state.providerRetry !== null) { + return { ...next, providerRetry: null }; } + return next; } /** diff --git a/src/core/chunks/retry-banner.test.ts b/src/core/chunks/retry-banner.test.ts new file mode 100644 index 0000000..ecc2232 --- /dev/null +++ b/src/core/chunks/retry-banner.test.ts @@ -0,0 +1,63 @@ +import type { TurnProviderRetryEvent } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { formatRetryDelay, viewProviderRetry } from "./retry-banner"; + +const retry = ( + attempt: number, + delayMs: number, + message = "HTTP 429: overloaded", + code?: string, +): TurnProviderRetryEvent => + code !== undefined + ? { + type: "provider-retry", + conversationId: "c1", + turnId: "t1", + attempt, + delayMs, + message, + code, + } + : { type: "provider-retry", conversationId: "c1", turnId: "t1", attempt, delayMs, message }; + +describe("formatRetryDelay", () => { + it("formats sub-minute delays as seconds", () => { + expect(formatRetryDelay(5000)).toBe("5s"); + expect(formatRetryDelay(10000)).toBe("10s"); + expect(formatRetryDelay(30000)).toBe("30s"); + }); + + it("formats minute+ delays as minutes", () => { + expect(formatRetryDelay(60000)).toBe("1m"); + expect(formatRetryDelay(300000)).toBe("5m"); + expect(formatRetryDelay(1800000)).toBe("30m"); + }); + + it("rounds to the nearest whole unit", () => { + expect(formatRetryDelay(5500)).toBe("6s"); // 5.5s -> 6s + expect(formatRetryDelay(90000)).toBe("2m"); // 1.5m -> 2m + }); +}); + +describe("viewProviderRetry", () => { + it("labels the attempt 1-based (attempt 0 = Retry #1)", () => { + expect(viewProviderRetry(retry(0, 5000)).attemptLabel).toBe("Retry #1"); + expect(viewProviderRetry(retry(1, 10000)).attemptLabel).toBe("Retry #2"); + expect(viewProviderRetry(retry(7, 1800000)).attemptLabel).toBe("Retry #8"); + }); + + it("derives the delay label from delayMs", () => { + expect(viewProviderRetry(retry(0, 5000)).delayLabel).toBe("5s"); + expect(viewProviderRetry(retry(4, 300000)).delayLabel).toBe("5m"); + }); + + it("passes the endpoint error verbatim", () => { + const msg = 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}'; + expect(viewProviderRetry(retry(0, 5000, msg)).message).toBe(msg); + }); + + it("surfaces the code when present, null when absent", () => { + expect(viewProviderRetry(retry(0, 5000, "msg", "429")).code).toBe("429"); + expect(viewProviderRetry(retry(0, 5000, "msg")).code).toBeNull(); + }); +}); diff --git a/src/core/chunks/retry-banner.ts b/src/core/chunks/retry-banner.ts new file mode 100644 index 0000000..a00748a --- /dev/null +++ b/src/core/chunks/retry-banner.ts @@ -0,0 +1,51 @@ +import type { TurnProviderRetryEvent } from "@dispatch/wire"; + +/** + * Pure view-model for the transient `provider-retry` warning banner. Zero DOM, + * zero effects, zero Svelte — mirrors the `core/metrics` view-models the chat UI + * already imports. The banner state itself lives in `TranscriptState.providerRetry` + * (set/cleared by `foldEvent`); this module only formats an event into render data. + * + * The "countdown" is a STATIC label derived from `delayMs` (e.g. "retrying in + * 5s…"), matching the backend's examples — NOT a live ticking timer (which would + * be a component effect and re-render churn). Each new `provider-retry` event + * coalesces over the previous, so the banner always shows the newest attempt + delay. + */ + +/** The display shape for a provider-retry banner. */ +export interface ProviderRetryView { + /** "Retry #N" — `attempt` is 0-based, so +1 (attempt 0 = "Retry #1"). */ + readonly attemptLabel: string; + /** The scheduled sleep as a short duration: "5s" / "30s" / "1m" / "5m" / "30m". */ + readonly delayLabel: string; + /** The endpoint's error verbatim, e.g. "HTTP 429: {…overloaded_error…}". */ + readonly message: string; + /** The HTTP code when known (e.g. "429"), else null. */ + readonly code: string | null; +} + +/** + * Format a millisecond delay as a short, human-friendly duration. Matches the + * backend's backoff schedule (5s→10s→30s→60s→5m→10m→15m→30m): under a minute + * shows seconds, under an hour shows minutes, else hours. + */ +export function formatRetryDelay(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const totalMinutes = Math.round(totalSeconds / 60); + if (totalMinutes < 60) return `${totalMinutes}m`; + return `${Math.round(totalMinutes / 60)}h`; +} + +/** + * Map a `provider-retry` event to its banner view. `attempt` is 0-based (the Nth + * retry about to happen), so the label is 1-based for the user. + */ +export function viewProviderRetry(event: TurnProviderRetryEvent): ProviderRetryView { + return { + attemptLabel: `Retry #${event.attempt + 1}`, + delayLabel: formatRetryDelay(event.delayMs), + message: event.message, + code: event.code ?? null, + }; +} diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts index 6929de2..3d91559 100644 --- a/src/core/chunks/selectors.ts +++ b/src/core/chunks/selectors.ts @@ -1,4 +1,4 @@ -import type { ChatMessage, Chunk } from "@dispatch/wire"; +import type { ChatMessage, Chunk, TurnProviderRetryEvent } from "@dispatch/wire"; import type { RenderedChunk, TranscriptState } from "./types"; /** @@ -33,6 +33,15 @@ export function selectGenerating(state: TranscriptState): boolean { } /** + * The latest `provider-retry` event for the current turn, or `null` when no retry + * is pending. Drives the transient yellow "retrying…" warning banner (rendered + * by ChatView). Never persisted — see `TranscriptState.providerRetry`. + */ +export function selectProviderRetry(state: TranscriptState): TurnProviderRetryEvent | null { + return state.providerRetry; +} + +/** * Group consecutive same-role rendered chunks into ChatMessages. */ export function selectMessages(state: TranscriptState): readonly ChatMessage[] { diff --git a/src/core/chunks/types.ts b/src/core/chunks/types.ts index 14619bd..32b9984 100644 --- a/src/core/chunks/types.ts +++ b/src/core/chunks/types.ts @@ -1,4 +1,4 @@ -import type { Chunk, Role, StoredChunk, Usage } from "@dispatch/wire"; +import type { Chunk, Role, StoredChunk, TurnProviderRetryEvent, Usage } from "@dispatch/wire"; /** A chunk being accumulated from streaming deltas (text or thinking). */ export interface AccumulatingChunk { @@ -45,6 +45,20 @@ export interface TranscriptState { * watching client. NOT inferred from the free-form `status` event string. */ readonly generating: boolean; + /** + * The latest `provider-retry` event for the current turn, or `null` when no + * retry is pending. TRANSIENT UI state (never a Chunk — never committed or + * provisional, so it can NEVER pollute the model's prompt or be replayed on a + * reload/replay of past turns: only committed seq'd chunks are history). Set + * by `foldEvent` on each `provider-retry` (the latest coalesces over previous + * so a single updating "retrying…" banner shows the newest attempt + delay); + * cleared when the model's content resumes (`text-delta`/`reasoning-delta`/ + * `tool-call`/`tool-result`), the turn ends (`done`/`turn-sealed`/`error`), + * or a new turn starts (`turn-start`). Also cleared on a WS reconnect + * (`clearGenerating`) — a retry pending at disconnect is stale once we + * re-subscribe (provider-retry events are not replayed). + */ + readonly providerRetry: TurnProviderRetryEvent | null; } /** A chunk ready for rendering: either committed (with seq) or provisional. */ diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts index c50cbf4..b5dd8e1 100644 --- a/src/core/wire/conformance.test.ts +++ b/src/core/wire/conformance.test.ts @@ -76,6 +76,15 @@ describe("classifies every AgentEvent type", () => { { type: "done", conversationId: "c1", turnId: "t1", reason: "complete" }, { type: "turn-sealed", conversationId: "c1", turnId: "t1" }, { type: "steering", conversationId: "c1", turnId: "t1", text: "steer mid-turn" }, + { + type: "provider-retry", + conversationId: "c1", + turnId: "t1", + attempt: 0, + delayMs: 5000, + message: "HTTP 429: overloaded", + code: "429", + }, ]; it("returns a stable label for every AgentEvent.type variant", () => { @@ -95,11 +104,12 @@ describe("classifies every AgentEvent type", () => { "done", "turn-sealed", "steering", + "provider-retry", ]); }); - it("covers all 14 AgentEvent variants", () => { - expect(samples).toHaveLength(14); + it("covers all 15 AgentEvent variants", () => { + expect(samples).toHaveLength(15); }); }); @@ -139,11 +149,12 @@ describe("classifies every WsServerMessage type", () => { event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" }, }, { type: "chat.error" as const, message: "e" }, - { type: "conversation.open" as const, conversationId: "c1" }, + { type: "conversation.open" as const, conversationId: "c1", workspaceId: "w1" }, { type: "conversation.statusChanged" as const, conversationId: "c1", status: "active" as const, + workspaceId: "w1", }, { type: "conversation.compacted" as const, diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts index 07808fc..7da4794 100644 --- a/src/core/wire/conformance.ts +++ b/src/core/wire/conformance.ts @@ -36,6 +36,8 @@ export function assertAgentEventExhaustive(event: AgentEvent): string { return "step-complete"; case "steering": return "steering"; + case "provider-retry": + return "provider-retry"; default: return event satisfies never; } |
