summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 10:52:13 +0900
committerAdam Malczewski <[email protected]>2026-06-25 10:52:13 +0900
commit17ce47987e673b6618454d033885b17b2a01912e (patch)
tree2bd7259d51725eeadc20d410667d529f66834197 /src
parenteff289b2b4cc41db706f3c3274a089d46012be87 (diff)
downloaddispatch-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.ts98
-rw-r--r--src/adapters/history/index.ts93
-rw-r--r--src/adapters/ws/index.test.ts11
-rw-r--r--src/adapters/ws/logic.test.ts69
-rw-r--r--src/adapters/ws/logic.ts4
-rw-r--r--src/core/chunks/index.ts4
-rw-r--r--src/core/chunks/reducer.test.ts153
-rw-r--r--src/core/chunks/reducer.ts53
-rw-r--r--src/core/chunks/retry-banner.test.ts63
-rw-r--r--src/core/chunks/retry-banner.ts51
-rw-r--r--src/core/chunks/selectors.ts11
-rw-r--r--src/core/chunks/types.ts16
-rw-r--r--src/core/wire/conformance.test.ts17
-rw-r--r--src/core/wire/conformance.ts2
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;
}