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 | |
| 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')
| -rw-r--r-- | src/adapters/history/index.test.ts | 98 | ||||
| -rw-r--r-- | src/adapters/history/index.ts | 93 | ||||
| -rw-r--r-- | src/adapters/ws/index.test.ts | 11 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 69 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 4 | ||||
| -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 |
14 files changed, 631 insertions, 14 deletions
diff --git a/src/adapters/history/index.test.ts b/src/adapters/history/index.test.ts new file mode 100644 index 0000000..dec2323 --- /dev/null +++ b/src/adapters/history/index.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { createHistoryAdapter, type HistoryWindow } from "./index"; + +/** + * A minimal in-memory `HistoryWindow` fake for deterministic tests. `pathname` + * is a closure variable reflected through the `location` getter; `back(url)` + * simulates an external navigation (back/forward): it sets the path + fires the + * popstate listeners. + */ +function fakeWindow(initial = "/"): HistoryWindow & { back(url: string): void } { + let pathname = initial; + const popstateListeners = new Set<() => void>(); + return { + get location() { + return { + get pathname() { + return pathname; + }, + }; + }, + history: { + pushState(_data, _unused, url) { + if (typeof url === "string") pathname = url; + }, + replaceState(_data, _unused, url) { + if (typeof url === "string") pathname = url; + }, + }, + addEventListener(type, listener) { + if (type === "popstate") popstateListeners.add(listener); + }, + removeEventListener(type, listener) { + if (type === "popstate") popstateListeners.delete(listener); + }, + back(url: string) { + pathname = url; + for (const l of popstateListeners) l(); + }, + }; +} + +describe("createHistoryAdapter", () => { + it("reads the current path", () => { + const w = fakeWindow("/my-ws"); + const h = createHistoryAdapter({ window: w }); + expect(h.path).toBe("/my-ws"); + }); + + it("navigate updates the path + notifies subscribers", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.navigate("/default"); + expect(h.path).toBe("/default"); + expect(seen).toEqual(["/default"]); + }); + + it("replace updates the path WITHOUT notifying", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.replace("/default"); + expect(h.path).toBe("/default"); + expect(seen).toEqual([]); + }); + + it("fires subscribers on popstate (back/forward)", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + w.back("/my-ws"); + expect(seen).toEqual(["/my-ws"]); + }); + + it("unsubscribe stops notifications (navigate + popstate)", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + const unsub = h.subscribe((p) => seen.push(p)); + unsub(); + h.navigate("/default"); + w.back("/other"); + expect(seen).toEqual([]); + }); + + it("degrades to a no-op adapter when there is no location (SSR)", () => { + const w = { location: undefined } as unknown as HistoryWindow; + const h = createHistoryAdapter({ window: w }); + expect(h.path).toBe("/"); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.navigate("/default"); + expect(seen).toEqual([]); + }); +}); diff --git a/src/adapters/history/index.ts b/src/adapters/history/index.ts new file mode 100644 index 0000000..87deafb --- /dev/null +++ b/src/adapters/history/index.ts @@ -0,0 +1,93 @@ +/** + * History adapter — the injected browser effect for client-side routing. + * + * A thin wrapper over `window.history` + the `popstate` event that exposes the + * current pathname, a `navigate` (pushState) that updates the URL without a + * reload, and a `subscribe` for path changes (back/forward buttons + programmatic + * navigation). The route SEMANTICS (path → workspace/home) live in the + * `workspaces` feature's pure `parsePath`; this adapter deals only in path + * strings, so it stays generic + reusable. + * + * The browser edge (`window`/`history`) is INJECTED (defaults to the global) so + * it is testable without the DOM and degrades to a no-op when absent (SSR / no + * `window`). + */ + +/** The minimal browser-history surface this adapter needs. */ +export interface HistoryWindow { + readonly location: { readonly pathname: string }; + readonly history: { + pushState(data: unknown, unused: string, url?: string | URL | null): void; + replaceState(data: unknown, unused: string, url?: string | URL | null): void; + }; + addEventListener(type: "popstate", listener: () => void): void; + removeEventListener(type: "popstate", listener: () => void): void; +} + +export interface HistoryAdapter { + /** The current pathname. */ + readonly path: string; + /** Push a new path (updates the URL without a reload) + notifies subscribers. */ + navigate(path: string): void; + /** Replace the current path without adding a history entry (no notify). */ + replace(path: string): void; + /** Subscribe to path changes (back/forward + navigate). Returns unsubscribe. */ + subscribe(cb: (path: string) => void): () => void; +} + +export interface CreateHistoryOptions { + /** The browser window; defaults to `globalThis`. Inject for tests. */ + readonly window?: HistoryWindow; +} + +function noop(): void {} + +/** A no-op adapter for when there is no `window` (SSR). */ +function createNoopHistory(): HistoryAdapter { + return { + get path() { + return "/"; + }, + navigate: noop, + replace: noop, + subscribe() { + return noop; + }, + }; +} + +export function createHistoryAdapter(opts?: CreateHistoryOptions): HistoryAdapter { + const w = opts?.window ?? (globalThis as unknown as HistoryWindow); + if (w === undefined || w === null || w.location === undefined) { + return createNoopHistory(); + } + + const listeners = new Set<(path: string) => void>(); + const current = (): string => w.location.pathname; + const emit = (): void => { + const p = current(); + for (const cb of listeners) cb(p); + }; + + return { + get path() { + return current(); + }, + navigate(path: string): void { + w.history.pushState(null, "", path); + emit(); + }, + replace(path: string): void { + w.history.replaceState(null, "", path); + }, + subscribe(cb: (path: string) => void): () => void { + listeners.add(cb); + const onPop = (): void => cb(current()); + w.addEventListener("popstate", onPop); + return () => { + listeners.delete(cb); + w.removeEventListener("popstate", onPop); + }; + }, + }; +} diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts index 92d57a8..e8e5434 100644 --- a/src/adapters/ws/index.test.ts +++ b/src/adapters/ws/index.test.ts @@ -283,11 +283,18 @@ describe("createSurfaceSocket", () => { }); ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" })); + ws.invokeMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }), + ); expect(onConversationOpen).toHaveBeenCalledOnce(); expect(onConversationOpen).toHaveBeenCalledWith({ type: "conversation.open", conversationId: "c1", + workspaceId: "w1", }); expect(onMessage).not.toHaveBeenCalled(); expect(onChat).not.toHaveBeenCalled(); @@ -310,6 +317,7 @@ describe("createSurfaceSocket", () => { type: "conversation.statusChanged", conversationId: "c1", status: "active", + workspaceId: "w1", }), ); expect(onConversationStatusChanged).toHaveBeenCalledOnce(); @@ -317,6 +325,7 @@ describe("createSurfaceSocket", () => { type: "conversation.statusChanged", conversationId: "c1", status: "active", + workspaceId: "w1", }); expect(onMessage).not.toHaveBeenCalled(); }); diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index 2463519..062d47b 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -219,18 +219,52 @@ describe("parseServerMessage", () => { }); it("parses a conversation.open message", () => { - const data = JSON.stringify({ type: "conversation.open", conversationId: "c1" }); + const data = JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); const result = parseServerMessage(data); - expect(result).toEqual({ type: "conversation.open", conversationId: "c1" }); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); }); it("returns null for conversation.open with missing conversationId", () => { - expect(parseServerMessage(JSON.stringify({ type: "conversation.open" }))).toBeNull(); + expect( + parseServerMessage(JSON.stringify({ type: "conversation.open", workspaceId: "w1" })), + ).toBeNull(); }); it("returns null for conversation.open with non-string conversationId", () => { expect( - parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: 42 })), + parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: 42, + workspaceId: "w1", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.open with missing workspaceId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" })), + ).toBeNull(); + }); + + it("returns null for conversation.open with non-string workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: 42, + }), + ), ).toBeNull(); }); @@ -239,14 +273,40 @@ describe("parseServerMessage", () => { type: "conversation.statusChanged", conversationId: "c1", status: "active", + workspaceId: "w1", }); expect(parseServerMessage(data)).toEqual({ type: "conversation.statusChanged", conversationId: "c1", status: "active", + workspaceId: "w1", }); }); + it("returns null for conversation.statusChanged with missing workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.statusChanged with non-string workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: 42, + }), + ), + ).toBeNull(); + }); it("returns null for conversation.statusChanged with invalid status", () => { expect( parseServerMessage( @@ -254,6 +314,7 @@ describe("parseServerMessage", () => { type: "conversation.statusChanged", conversationId: "c1", status: "done", + workspaceId: "w1", }), ), ).toBeNull(); diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index b11c5c4..d1a5b5f 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -115,9 +115,11 @@ export function parseServerMessage(data: string): WsServerMessage | null { } case "conversation.open": { if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.workspaceId !== "string") return null; const msg: ConversationOpenMessage = { type: "conversation.open", conversationId: parsed.conversationId, + workspaceId: parsed.workspaceId, }; return msg; } @@ -127,10 +129,12 @@ export function parseServerMessage(data: string): WsServerMessage | null { if (parsed.status !== "active" && parsed.status !== "idle" && parsed.status !== "closed") { return null; } + if (typeof parsed.workspaceId !== "string") return null; const msg: ConversationStatusChangedMessage = { type: "conversation.statusChanged", conversationId: parsed.conversationId, status: parsed.status, + workspaceId: parsed.workspaceId, }; return msg; } 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; } |
