summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/api/src/agent-manager.ts37
-rw-r--r--packages/api/tests/agent-manager.test.ts156
-rw-r--r--packages/core/src/types/index.ts27
-rw-r--r--packages/frontend/src/App.svelte20
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts222
-rw-r--r--packages/frontend/src/lib/types.ts22
-rw-r--r--packages/frontend/tests/chat-store.test.ts382
7 files changed, 843 insertions, 23 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index efdd732..92caf81 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -37,6 +37,7 @@ import {
resolveApiKey,
type SkillDefinition,
type SystemChunkKind,
+ type TabStatusSnapshot,
TaskList,
updateMessage,
validateConfig,
@@ -711,10 +712,40 @@ export class AgentManager {
return this.tabAgents.get(tabId)?.status ?? "idle";
}
- getAllStatuses(): Record<string, AgentStatus> {
- const result: Record<string, AgentStatus> = {};
+ /**
+ * Snapshot of every tab the manager is currently tracking. Sent on WS
+ * connect and via GET /status so a freshly-loaded frontend can
+ * reconstruct any in-flight assistant turn without missing the chunks
+ * that arrived before its WS handshake completed.
+ *
+ * For each running tab, the snapshot includes:
+ * - status: "running"
+ * - currentChunks: a defensive shallow copy of `tabAgent.currentChunks`
+ * (the live chunk array the streaming loop appends to). The
+ * consumer owns this copy and may mutate it freely.
+ * - currentAssistantId: the DB id of the in-flight assistant message
+ * row. The frontend aligns its local assistant message id with
+ * this so the next `done` event lands on the right message.
+ *
+ * For idle/error tabs, only `status` is present. Tabs not in
+ * `this.tabAgents` (e.g. tabs in the DB that have never been touched
+ * since server start) are absent from the returned record — the
+ * caller infers their status from the DB row (always "idle" at rest).
+ */
+ getAllStatuses(): Record<string, TabStatusSnapshot> {
+ const result: Record<string, TabStatusSnapshot> = {};
for (const [tabId, tabAgent] of this.tabAgents.entries()) {
- result[tabId] = tabAgent.status;
+ const snap: TabStatusSnapshot = { status: tabAgent.status };
+ if (tabAgent.status === "running") {
+ if (tabAgent.currentChunks) {
+ // Defensive shallow copy: callers may serialize/mutate.
+ snap.currentChunks = [...tabAgent.currentChunks];
+ }
+ if (tabAgent.currentAssistantId) {
+ snap.currentAssistantId = tabAgent.currentAssistantId;
+ }
+ }
+ result[tabId] = snap;
}
return result;
}
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index 71d43d8..ba14cad 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -658,4 +658,160 @@ describe("AgentManager", () => {
expect(init[0]).toMatchObject({ role: "user", chunks: [{ type: "text", text: "q1" }] });
expect(init[1]).toMatchObject({ role: "assistant", chunks: [{ type: "text", text: "a1" }] });
});
+
+ // ─── getAllStatuses snapshot shape (for browser-reopen restore) ────
+ //
+ // The snapshot enriches the legacy `Record<string, AgentStatus>` shape
+ // with per-tab in-flight context so a fresh frontend can render the
+ // streaming assistant message correctly after a reload.
+
+ it("getAllStatuses returns an empty record when no tabs are tracked", () => {
+ const manager = new AgentManager();
+ expect(manager.getAllStatuses()).toEqual({});
+ });
+
+ it("getAllStatuses returns { status } for an idle tab (no currentChunks/currentAssistantId)", async () => {
+ const manager = new AgentManager();
+ // Drive a full turn so the tab gets registered; default mock run
+ // settles back to idle by the time `await` resolves.
+ await manager.processMessage("tab-idle", "hi");
+ const snap = manager.getAllStatuses();
+ expect(snap["tab-idle"]).toBeDefined();
+ expect(snap["tab-idle"]?.status).toBe("idle");
+ expect(snap["tab-idle"]).not.toHaveProperty("currentChunks");
+ expect(snap["tab-idle"]).not.toHaveProperty("currentAssistantId");
+ });
+
+ it("getAllStatuses includes currentChunks and currentAssistantId for a running tab", () => {
+ const manager = new AgentManager();
+ // Reach into the private map to set up a synthetic running state.
+ // Justification: there is no public API to enter a sustained
+ // "running" state without actually streaming, and we want to
+ // assert the snapshot shape — not the streaming pipeline.
+ const inner = manager as unknown as {
+ tabAgents: Map<
+ string,
+ {
+ agent: null;
+ status: "running" | "idle" | "error";
+ keyId: null;
+ modelId: null;
+ taskList: { onChange: (cb: unknown) => void };
+ messageQueue: unknown[];
+ queueListeners: unknown[];
+ shellStore: unknown;
+ transcriptStore: unknown;
+ currentChunks: Array<{ type: string; text?: string }> | null;
+ currentAssistantId: string | null;
+ }
+ >;
+ };
+ inner.tabAgents.set("tab-running", {
+ agent: null,
+ status: "running",
+ keyId: null,
+ modelId: null,
+ taskList: { onChange: () => {} },
+ messageQueue: [],
+ queueListeners: [],
+ shellStore: {},
+ transcriptStore: {},
+ currentChunks: [
+ { type: "thinking", text: "let me think" },
+ { type: "text", text: "partial answer" },
+ ],
+ currentAssistantId: "assistant-msg-id-7",
+ });
+
+ const snap = manager.getAllStatuses();
+ expect(snap["tab-running"]).toBeDefined();
+ expect(snap["tab-running"]?.status).toBe("running");
+ expect(snap["tab-running"]?.currentAssistantId).toBe("assistant-msg-id-7");
+ expect(snap["tab-running"]?.currentChunks).toEqual([
+ { type: "thinking", text: "let me think" },
+ { type: "text", text: "partial answer" },
+ ]);
+ });
+
+ it("getAllStatuses defensively copies currentChunks (mutating the snapshot doesn't affect the live array)", () => {
+ const manager = new AgentManager();
+ const inner = manager as unknown as {
+ tabAgents: Map<
+ string,
+ {
+ agent: null;
+ status: "running";
+ keyId: null;
+ modelId: null;
+ taskList: { onChange: (cb: unknown) => void };
+ messageQueue: unknown[];
+ queueListeners: unknown[];
+ shellStore: unknown;
+ transcriptStore: unknown;
+ currentChunks: Array<{ type: string; text?: string }>;
+ currentAssistantId: string;
+ }
+ >;
+ };
+ const liveChunks = [{ type: "text", text: "live" }];
+ inner.tabAgents.set("tab-copy", {
+ agent: null,
+ status: "running",
+ keyId: null,
+ modelId: null,
+ taskList: { onChange: () => {} },
+ messageQueue: [],
+ queueListeners: [],
+ shellStore: {},
+ transcriptStore: {},
+ currentChunks: liveChunks,
+ currentAssistantId: "msg-x",
+ });
+
+ const snap = manager.getAllStatuses();
+ // Mutate the snapshot's array
+ snap["tab-copy"]?.currentChunks?.push({ type: "text", text: "polluted" });
+ // Live array must be untouched
+ expect(liveChunks).toEqual([{ type: "text", text: "live" }]);
+ });
+
+ it("getAllStatuses omits currentChunks when a running tab has none yet", () => {
+ const manager = new AgentManager();
+ const inner = manager as unknown as {
+ tabAgents: Map<
+ string,
+ {
+ agent: null;
+ status: "running";
+ keyId: null;
+ modelId: null;
+ taskList: { onChange: (cb: unknown) => void };
+ messageQueue: unknown[];
+ queueListeners: unknown[];
+ shellStore: unknown;
+ transcriptStore: unknown;
+ currentChunks: null;
+ currentAssistantId: null;
+ }
+ >;
+ };
+ inner.tabAgents.set("tab-early", {
+ agent: null,
+ status: "running",
+ keyId: null,
+ modelId: null,
+ taskList: { onChange: () => {} },
+ messageQueue: [],
+ queueListeners: [],
+ shellStore: {},
+ transcriptStore: {},
+ currentChunks: null,
+ currentAssistantId: null,
+ });
+
+ const snap = manager.getAllStatuses();
+ expect(snap["tab-early"]?.status).toBe("running");
+ expect(snap["tab-early"]).not.toHaveProperty("currentChunks");
+ expect(snap["tab-early"]).not.toHaveProperty("currentAssistantId");
+ });
});
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index e59eb68..0df56ca 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -95,6 +95,33 @@ export interface ToolResult {
export type AgentStatus = "idle" | "running" | "error" | "waiting_for_key";
+/**
+ * Per-tab snapshot of live state, sent on WS connect and via
+ * `GET /status`. Carries enough information for a freshly-loaded
+ * frontend to reconstruct any in-flight assistant message.
+ *
+ * - `status` — always present; mirrors the in-memory `TabAgent.status`.
+ * - `currentChunks` — the live in-flight `Chunk[]` for the running
+ * assistant turn. Present iff `status === "running"` AND
+ * `TabAgent.currentChunks` is non-null. Defensively copied at
+ * snapshot time; the consumer owns the array.
+ * - `currentAssistantId` — DB id of the in-flight assistant message
+ * (the row that the eventual `flushAssistant` call will write/update).
+ * Present iff `status === "running"` AND `TabAgent.currentAssistantId`
+ * is set. The frontend uses this to align its local assistant
+ * message id with the persisted id so subsequent `done` and reload
+ * paths line up.
+ *
+ * Not part of `AgentEvent` itself: the `statuses` payload is a WS-
+ * connect-level snapshot, not an event the `Agent` emits. The frontend
+ * mirrors this type in `packages/frontend/src/lib/types.ts`.
+ */
+export interface TabStatusSnapshot {
+ status: AgentStatus;
+ currentChunks?: Chunk[];
+ currentAssistantId?: string;
+}
+
export type AgentEvent =
| { type: "status"; status: AgentStatus }
| { type: "text-delta"; delta: string }
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index 591266c..49db4ac 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -82,16 +82,24 @@ onMount(() => {
document.documentElement.setAttribute("data-theme", saved);
}
- // Connect WebSocket
+ // Connect WebSocket in parallel with hydration. The `statuses`
+ // snapshot delivered on WS open is idempotent against
+ // already-hydrated tabs (the handler reconciles per-tab).
wsClient.connect();
- // Initial models fetch
+ // Initial models fetch (fire-and-forget; UI tolerates models
+ // arriving later than tabs).
fetchModels();
- // Create initial tab
- if (tabStore.tabs.length === 0) {
- tabStore.createNewTab();
- }
+ // Restore tabs from the backend. The user's previous session is
+ // the source of truth; only fall back to a fresh tab if nothing
+ // was restored (first-ever load, or DB was wiped, or HTTP failed).
+ void (async () => {
+ const restored = await tabStore.hydrateFromBackend();
+ if (restored === 0 && tabStore.tabs.length === 0) {
+ await tabStore.createNewTab();
+ }
+ })();
return () => {
wsClient.disconnect();
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index 1d60e0b..326d288 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -18,6 +18,7 @@ import type {
LogEntry,
PermissionPrompt,
QueuedMessage,
+ TabStatusSnapshot,
TaskItem,
} from "./types.js";
import { wsClient } from "./ws.svelte.js";
@@ -399,6 +400,167 @@ export function createTabStore() {
}
}
+ /**
+ * Hydrate the tab store from the backend on app mount. Restores the
+ * full list of open tabs (every row with `is_open = 1` in the DB),
+ * loads each tab's persisted message history, and seeds the in-flight
+ * assistant message for any tab the backend is currently streaming.
+ *
+ * Wire calls:
+ * - GET /tabs → list of open tabs in `position` order
+ * - GET /tabs/:id/messages → persisted ChatMessage[] for each
+ * - GET /status → in-flight TabStatusSnapshot map
+ *
+ * Failure modes (all log + continue with whatever was successfully
+ * hydrated; callers fall back to creating a fresh tab if the final
+ * `tabs` array is empty):
+ * - /tabs request fails → no tabs restored
+ * - /tabs/:id/messages fails → that tab restored with empty messages
+ * - /status fails → tabs restored, in-flight streaming will be
+ * lost (will surface as a static "running" status until the next
+ * event arrives); harmless because the WS will broadcast `statuses`
+ * on reconnect anyway.
+ *
+ * Returns the number of tabs hydrated (0 on total failure, ≥1 on
+ * partial or full success). Caller uses this to decide whether to
+ * create a fresh tab.
+ *
+ * Idempotency: if `tabs.length > 0` when called, returns 0 without
+ * touching state — the caller already has tabs from elsewhere (e.g.
+ * a hot-reload that preserved Svelte state).
+ */
+ async function hydrateFromBackend(): Promise<number> {
+ if (tabs.length > 0) return 0;
+
+ // 1. Fetch the list of open tabs from the DB.
+ let tabRows: Array<{
+ id: string;
+ title: string;
+ keyId?: string | null;
+ modelId?: string | null;
+ parentTabId?: string | null;
+ }> = [];
+ try {
+ const res = await fetch(`${config.apiBase}/tabs`);
+ if (!res.ok) return 0;
+ const data = (await res.json()) as { tabs?: typeof tabRows };
+ tabRows = Array.isArray(data.tabs) ? data.tabs : [];
+ } catch {
+ return 0;
+ }
+
+ if (tabRows.length === 0) return 0;
+
+ // 2. Fetch the in-flight snapshot. Failure is non-fatal.
+ let statusMap: Record<string, TabStatusSnapshot> = {};
+ try {
+ const res = await fetch(`${config.apiBase}/status`);
+ if (res.ok) {
+ const data = (await res.json()) as { statuses?: Record<string, TabStatusSnapshot> };
+ if (data.statuses && typeof data.statuses === "object") {
+ statusMap = data.statuses;
+ }
+ }
+ } catch {
+ // Non-fatal: tabs still restore with idle status.
+ }
+
+ // 3. For each tab, fetch its persisted messages in parallel.
+ const messageFetches = tabRows.map(async (row) => {
+ try {
+ const res = await fetch(`${config.apiBase}/tabs/${row.id}/messages`);
+ if (!res.ok) return { id: row.id, messages: [] as ChatMessage[] };
+ const data = (await res.json()) as {
+ messages?: Array<{ id?: string; role: string; chunks?: Chunk[] }>;
+ };
+ const messages: ChatMessage[] = (data.messages ?? []).map((m) => ({
+ id: m.id ?? generateId(),
+ role: m.role as ChatMessage["role"],
+ chunks: Array.isArray(m.chunks) ? m.chunks : [],
+ isStreaming: false,
+ }));
+ return { id: row.id, messages };
+ } catch {
+ return { id: row.id, messages: [] as ChatMessage[] };
+ }
+ });
+
+ const messagesByTab = new Map<string, ChatMessage[]>();
+ for (const result of await Promise.all(messageFetches)) {
+ messagesByTab.set(result.id, result.messages);
+ }
+
+ // 4. Build the Tab objects, splicing in the in-flight snapshot for
+ // running tabs.
+ const restored: Tab[] = tabRows.map((row) => {
+ const snap = statusMap[row.id];
+ const messages = messagesByTab.get(row.id) ?? [];
+ const agentStatus: Tab["agentStatus"] = snap?.status ?? "idle";
+
+ let currentAssistantId: string | null = null;
+ let finalMessages = messages;
+
+ if (agentStatus === "running" && snap?.currentAssistantId) {
+ currentAssistantId = snap.currentAssistantId;
+ // Find or create the in-flight assistant message. If the DB
+ // already has a row with this id (the backend appended on
+ // first flush and we picked it up via /tabs/:id/messages),
+ // merge the snapshot chunks on top — the snapshot is the
+ // live source of truth and may have chunks the DB doesn't.
+ // If there's no matching row, append a new in-flight
+ // assistant message holding only the snapshot chunks.
+ const existingIdx = finalMessages.findIndex((m) => m.id === snap.currentAssistantId);
+ if (existingIdx >= 0) {
+ finalMessages = finalMessages.map((m, i) =>
+ i === existingIdx
+ ? {
+ ...m,
+ chunks: snap.currentChunks ? [...snap.currentChunks] : m.chunks,
+ isStreaming: true,
+ }
+ : m,
+ );
+ } else {
+ finalMessages = [
+ ...finalMessages,
+ {
+ id: snap.currentAssistantId,
+ role: "assistant",
+ chunks: snap.currentChunks ? [...snap.currentChunks] : [],
+ isStreaming: true,
+ },
+ ];
+ }
+ }
+
+ return {
+ id: row.id,
+ title: row.title,
+ messages: finalMessages,
+ agentStatus,
+ keyId: row.keyId ?? null,
+ modelId: row.modelId ?? null,
+ reasoningEffort: "max",
+ currentAssistantId,
+ tasks: [],
+ injectedSkills: [],
+ parentTabId: row.parentTabId ?? null,
+ persistent: true,
+ agentSlug: null,
+ agentScope: null,
+ agentModels: null,
+ workingDirectory: null,
+ queuedMessages: [],
+ };
+ });
+
+ tabs = restored;
+ // Activate the first restored tab (the list is already ordered by
+ // `position` from the backend).
+ activeTabId = restored[0]?.id ?? null;
+ return restored.length;
+ }
+
function handleEvent(event: AgentEvent & { tabId?: string }): void {
const tabId = event.tabId;
@@ -417,25 +579,62 @@ export function createTabStore() {
break;
}
case "statuses": {
- // WS (re)connect snapshot. For any tab where the frontend thought
- // we were running but the backend says otherwise, we missed the
- // finishing events while disconnected — pull the persisted
- // chunks from the API to recover. Also sync agentStatus and
- // clear in-flight pointers on every desyncing tab.
+ // WS (re)connect snapshot. The shape was widened to
+ // TabStatusSnapshot (status + optional currentChunks +
+ // optional currentAssistantId) so the frontend can seed
+ // in-flight assistant messages on browser reopen.
const backend = event.statuses;
for (const t of tabs) {
- const backendStatus = backend[t.id] ?? "idle";
+ const snap = backend[t.id];
+ const backendStatus = snap?.status ?? "idle";
+
+ // Desync case: frontend thought it was streaming, backend
+ // has already moved on. Pull the persisted chunks so the
+ // final answer shows up.
if (t.agentStatus === "running" && backendStatus !== "running") {
void reloadTabMessagesFromApi(t.id);
}
+
+ // Status alignment.
if (t.agentStatus !== backendStatus) {
updateTab(t.id, { agentStatus: backendStatus });
}
- if (backendStatus !== "running" && t.currentAssistantId) {
- // Mark any in-flight assistant message as no-longer-streaming;
- // `reloadTabMessagesFromApi` (if it ran) will replace the
- // whole messages array, but if no reload was triggered we
- // still want streaming flags cleared.
+
+ if (backendStatus === "running") {
+ // Seed the in-flight assistant message from the snapshot.
+ // This handles the "browser just reopened mid-stream"
+ // path: the DB only has chunks up to the last
+ // flushAssistant call, but the snapshot has the live
+ // in-memory currentChunks.
+ if (snap?.currentAssistantId) {
+ const targetId = snap.currentAssistantId;
+ updateTab(t.id, { currentAssistantId: targetId });
+ updateMessages(t.id, (msgs) => {
+ const idx = msgs.findIndex((m) => m.id === targetId);
+ if (idx >= 0) {
+ return msgs.map((m, i) =>
+ i === idx
+ ? {
+ ...m,
+ chunks: snap.currentChunks ? [...snap.currentChunks] : m.chunks,
+ isStreaming: true,
+ }
+ : m,
+ );
+ }
+ return [
+ ...msgs,
+ {
+ id: targetId,
+ role: "assistant",
+ chunks: snap.currentChunks ? [...snap.currentChunks] : [],
+ isStreaming: true,
+ },
+ ];
+ });
+ }
+ } else if (t.currentAssistantId) {
+ // Not running: clear streaming flags.
updateMessages(t.id, (msgs) =>
msgs.map((m) => (m.id === t.currentAssistantId ? { ...m, isStreaming: false } : m)),
);
@@ -1303,6 +1502,7 @@ export function createTabStore() {
// WS callback uses in production. Not intended for use in
// components — they should rely on the WS subscription instead.
handleEvent,
+ hydrateFromBackend,
};
}
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
index 6051810..22837a0 100644
--- a/packages/frontend/src/lib/types.ts
+++ b/packages/frontend/src/lib/types.ts
@@ -76,13 +76,33 @@ export interface ChatMessage {
export type ConnectionStatus = "connecting" | "connected" | "disconnected";
+/**
+ * Mirror of core's `TabStatusSnapshot` (see packages/core/src/types/index.ts).
+ *
+ * Sent on every WS (re)connect and via `GET /status`. The frontend uses
+ * this to:
+ * - reconcile its in-memory `agentStatus` with the backend's truth
+ * after a disconnect window;
+ * - reconstruct the in-flight assistant message for any tab the
+ * backend is currently streaming, so the user sees the partial
+ * thinking / text without waiting for the next delta.
+ *
+ * Wire-format symmetry MUST be kept with core. If you change one,
+ * change the other.
+ */
+export interface TabStatusSnapshot {
+ status: "idle" | "running" | "error";
+ currentChunks?: Chunk[];
+ currentAssistantId?: string;
+}
+
export type AgentEvent =
| { type: "status"; status: "idle" | "running" | "error" }
// Sent on every WS (re)connect: a snapshot of every tab the backend is
// currently tracking and its live status. The frontend uses this to
// detect desync after a reconnect (e.g. bun --watch restart killed the
// in-flight agent state, frontend missed `done` / `status:idle` events).
- | { type: "statuses"; statuses: Record<string, "idle" | "running" | "error"> }
+ | { type: "statuses"; statuses: Record<string, TabStatusSnapshot> }
| { type: "text-delta"; delta: string }
| { type: "reasoning-delta"; delta: string }
| { type: "reasoning-end"; metadata?: Record<string, unknown> }
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
index 47a6c97..41adb84 100644
--- a/packages/frontend/tests/chat-store.test.ts
+++ b/packages/frontend/tests/chat-store.test.ts
@@ -449,8 +449,8 @@ describe("tabStore — reactivity contract", () => {
// hiccup, etc.)
store.handleEvent({
type: "statuses",
- statuses: { [tabId]: "idle" },
- } as Parameters<typeof store.handleEvent>[0]);
+ statuses: { [tabId]: { status: "idle" } },
+ });
expect(store.tabs[0]?.agentStatus).toBe("idle");
expect(store.tabs[0]?.currentAssistantId).toBeNull();
@@ -672,3 +672,381 @@ describe("shell output parsing helper", () => {
expect(parseShellResult(JSON.stringify(42))).toBeNull();
});
});
+
+// ─── hydrateFromBackend ─────────────────────────────────────────
+//
+// Verifies the browser-reopen restore path: GET /tabs + GET /status +
+// GET /tabs/:id/messages combined into the in-memory tab store with
+// in-flight chunks seeded for any running tab.
+
+describe("hydrateFromBackend", () => {
+ it("restores tabs from /tabs with their persisted messages", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ tabs: [
+ { id: "t1", title: "First", keyId: null, modelId: null, parentTabId: null },
+ { id: "t2", title: "Second", keyId: "k", modelId: "m", parentTabId: null },
+ ],
+ }),
+ });
+ }
+ if (url.endsWith("/status")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ statuses: {} }),
+ });
+ }
+ if (url.endsWith("/tabs/t1/messages")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ messages: [
+ { id: "m1", role: "user", chunks: [{ type: "text", text: "hello" }] },
+ {
+ id: "m2",
+ role: "assistant",
+ chunks: [{ type: "text", text: "hi back" }],
+ },
+ ],
+ }),
+ });
+ }
+ if (url.endsWith("/tabs/t2/messages")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ messages: [] }),
+ });
+ }
+ return Promise.reject(new Error(`unexpected fetch ${url}`));
+ }),
+ );
+
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(2);
+ expect(store.tabs.length).toBe(2);
+ expect(store.tabs[0]?.id).toBe("t1");
+ expect(store.tabs[0]?.messages.length).toBe(2);
+ expect(store.tabs[1]?.id).toBe("t2");
+ expect(store.tabs[1]?.messages.length).toBe(0);
+ expect(store.activeTabId).toBe("t1");
+ });
+
+ it("seeds the in-flight assistant message from /status for a running tab", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ tabs: [
+ { id: "tr", title: "Running tab", keyId: null, modelId: null, parentTabId: null },
+ ],
+ }),
+ });
+ }
+ if (url.endsWith("/status")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ statuses: {
+ tr: {
+ status: "running",
+ currentAssistantId: "live-msg-id",
+ currentChunks: [
+ { type: "thinking", text: "still thinking" },
+ { type: "text", text: "partial " },
+ ],
+ },
+ },
+ }),
+ });
+ }
+ if (url.endsWith("/tabs/tr/messages")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ messages: [{ id: "u1", role: "user", chunks: [{ type: "text", text: "go" }] }],
+ }),
+ });
+ }
+ return Promise.reject(new Error(`unexpected fetch ${url}`));
+ }),
+ );
+
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(1);
+ const tab = store.tabs[0];
+ expect(tab?.agentStatus).toBe("running");
+ expect(tab?.currentAssistantId).toBe("live-msg-id");
+ // Two messages: the user message + the seeded in-flight assistant.
+ expect(tab?.messages.length).toBe(2);
+ const inflight = tab?.messages.find((m) => m.id === "live-msg-id");
+ expect(inflight).toBeDefined();
+ expect(inflight?.isStreaming).toBe(true);
+ expect(inflight?.chunks).toEqual([
+ { type: "thinking", text: "still thinking" },
+ { type: "text", text: "partial " },
+ ]);
+ });
+
+ it("returns 0 and leaves tabs empty when /tabs fails", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })),
+ );
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(0);
+ expect(store.tabs.length).toBe(0);
+ });
+
+ it("returns 0 and leaves tabs empty when /tabs returns an empty array", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ tabs: [] }) });
+ }
+ return Promise.reject(new Error(`unexpected fetch ${url}`));
+ }),
+ );
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(0);
+ expect(store.tabs.length).toBe(0);
+ });
+
+ it("is a no-op when the store already has tabs (idempotency)", async () => {
+ const store = createTabStore();
+ // Pretend the store already has a tab (e.g. from a hot-reload).
+ // We do this by reaching into the store via the public API.
+ // Use the create path with mocked fetch failure (existing
+ // `createNewTab` already tolerates fetch failure — adds locally).
+ // (beforeEach already stubs fetch to reject, so createNewTab will
+ // proceed past the failed POST and add the tab locally.)
+ await store.createNewTab();
+ expect(store.tabs.length).toBe(1);
+
+ // Now swap to a fetch that would lie about there being 3 tabs;
+ // hydrateFromBackend must NOT call it. We use a fresh mock that
+ // rejects to catch any stray background async calls too.
+ let hydrateCallCount = 0;
+ const sentinelFetch = vi.fn((url: string) => {
+ // Allow background auto-agent/skill fetches that fire from
+ // createNewTab's void async closure (autoSelectDefaultAgent,
+ // autoCheckDefaultSkills) — they use /agents and /skills paths,
+ // not /tabs. Reject them so they don't interfere.
+ if (url.includes("/agents") || url.includes("/skills")) {
+ return Promise.reject(new Error("test: background fetch ignored"));
+ }
+ // Any /tabs call would mean hydrateFromBackend ran — count it.
+ hydrateCallCount++;
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ tabs: [{ id: "x" }, { id: "y" }, { id: "z" }] }),
+ });
+ });
+ vi.stubGlobal("fetch", sentinelFetch);
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(0);
+ expect(store.tabs.length).toBe(1);
+ expect(hydrateCallCount).toBe(0);
+ });
+
+ it("restores a tab with an idle status when /status omits it", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ tabs: [{ id: "ti", title: "Idle", keyId: null, modelId: null, parentTabId: null }],
+ }),
+ });
+ }
+ if (url.endsWith("/status")) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
+ }
+ if (url.endsWith("/tabs/ti/messages")) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }) });
+ }
+ return Promise.reject(new Error(`unexpected fetch ${url}`));
+ }),
+ );
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(1);
+ expect(store.tabs[0]?.agentStatus).toBe("idle");
+ expect(store.tabs[0]?.currentAssistantId).toBeNull();
+ });
+
+ it("restores a tab with empty messages when /tabs/:id/messages fails (per-tab failure isolation)", async () => {
+ // The hydrateFromBackend implementation wraps each per-tab
+ // messages fetch in a try/catch so one tab's failure can't
+ // destroy the whole restore pass. This test covers BOTH failure
+ // modes the try/catch protects against:
+ // - response.ok === false (HTTP error like 500)
+ // - the fetch rejects (network error)
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url.endsWith("/tabs")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ tabs: [
+ {
+ id: "tA",
+ title: "Healthy",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ },
+ {
+ id: "tB",
+ title: "Broken (HTTP 500)",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ },
+ {
+ id: "tC",
+ title: "Broken (network)",
+ keyId: null,
+ modelId: null,
+ parentTabId: null,
+ },
+ ],
+ }),
+ });
+ }
+ if (url.endsWith("/status")) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
+ }
+ if (url.endsWith("/tabs/tA/messages")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ messages: [{ id: "msg-a", role: "user", chunks: [{ type: "text", text: "ok" }] }],
+ }),
+ });
+ }
+ if (url.endsWith("/tabs/tB/messages")) {
+ // HTTP error path: response is not ok.
+ return Promise.resolve({ ok: false, json: () => Promise.resolve({}) });
+ }
+ if (url.endsWith("/tabs/tC/messages")) {
+ // Network error path: the fetch itself rejects.
+ return Promise.reject(new Error("simulated network failure"));
+ }
+ return Promise.reject(new Error(`unexpected fetch ${url}`));
+ }),
+ );
+
+ const store = createTabStore();
+ const n = await store.hydrateFromBackend();
+ expect(n).toBe(3);
+
+ // Healthy tab restored with its message.
+ const tA = store.tabs.find((t) => t.id === "tA");
+ expect(tA?.messages.length).toBe(1);
+ expect(tA?.messages[0]?.chunks).toEqual([{ type: "text", text: "ok" }]);
+
+ // Both broken tabs restored with empty message lists — neither
+ // crashed the hydration nor leaked an error chunk into the UI.
+ const tB = store.tabs.find((t) => t.id === "tB");
+ expect(tB).toBeDefined();
+ expect(tB?.messages.length).toBe(0);
+ expect(tB?.agentStatus).toBe("idle");
+
+ const tC = store.tabs.find((t) => t.id === "tC");
+ expect(tC).toBeDefined();
+ expect(tC?.messages.length).toBe(0);
+ expect(tC?.agentStatus).toBe("idle");
+ });
+});
+
+// ─── statuses WS event with the wider TabStatusSnapshot shape ───
+//
+// The handler must reconcile snapshot.status against the local tab,
+// and (when running) seed currentChunks into the in-flight assistant
+// message.
+
+describe("handleEvent statuses with TabStatusSnapshot", () => {
+ it("seeds the in-flight assistant message when a running snapshot arrives", async () => {
+ const store = createTabStore();
+ // Manually add a tab to the store via the existing createNewTab path
+ // (fetch was mocked to reject in beforeEach; createNewTab tolerates).
+ // We then drive a statuses event.
+ await store.createNewTab();
+ const tabId = store.tabs[0]?.id;
+ if (!tabId) throw new Error("test fixture: tab id missing");
+
+ store.handleEvent({
+ type: "statuses",
+ statuses: {
+ [tabId]: {
+ status: "running",
+ currentAssistantId: "live-x",
+ currentChunks: [{ type: "text", text: "live data" }],
+ },
+ },
+ });
+
+ const tab = store.tabs.find((t) => t.id === tabId);
+ expect(tab?.agentStatus).toBe("running");
+ expect(tab?.currentAssistantId).toBe("live-x");
+ const inflight = tab?.messages.find((m) => m.id === "live-x");
+ expect(inflight).toBeDefined();
+ expect(inflight?.chunks).toEqual([{ type: "text", text: "live data" }]);
+ expect(inflight?.isStreaming).toBe(true);
+ });
+
+ it("clears in-flight pointers when snapshot says the tab is idle", async () => {
+ const store = createTabStore();
+ await store.createNewTab();
+ const tabId = store.tabs[0]?.id;
+ if (!tabId) throw new Error("test fixture: tab id missing");
+
+ // First put the tab into a running state with an in-flight message.
+ store.handleEvent({
+ type: "statuses",
+ statuses: {
+ [tabId]: {
+ status: "running",
+ currentAssistantId: "msg-a",
+ currentChunks: [{ type: "text", text: "x" }],
+ },
+ },
+ });
+ expect(store.tabs.find((t) => t.id === tabId)?.currentAssistantId).toBe("msg-a");
+
+ // Now snapshot says idle.
+ store.handleEvent({
+ type: "statuses",
+ statuses: { [tabId]: { status: "idle" } },
+ });
+ const tab = store.tabs.find((t) => t.id === tabId);
+ expect(tab?.agentStatus).toBe("idle");
+ expect(tab?.currentAssistantId).toBeNull();
+ const msgA = tab?.messages.find((m) => m.id === "msg-a");
+ expect(msgA?.isStreaming).toBe(false);
+ });
+});