import { describe, expect, it } from "vitest"; import { applyWorkspaceEvent, initialWorkspaceState, reconcileWorkspace, workspaceReducer, } from "./reducer"; import type { RuntimeSnapshot, Workspace } from "./types"; const runtime = (id: string, path = "/repo") => ({ runtimeId: id, worktreePath: path, state: "idle", label: id, attention: false, queueCount: 0, lastActivity: id, openedAt: id, }); const workspace: Workspace = { bridgeInstanceId: "epoch", latestSeq: 0, directories: [ { worktreePath: "/repo", isHome: false, openCount: 2, workingCount: 0, attentionCount: 0, recoveringCount: 0, errorCount: 0, runtimes: [runtime("a"), runtime("b")], }, ], }; describe("workspace reducer", () => { it("isolates drafts, snapshots and background unread by runtime", () => { let state = reconcileWorkspace(initialWorkspaceState, workspace); state = workspaceReducer(state, { type: "draftChanged", runtimeId: "a", draft: "draft a", }); state = workspaceReducer(state, { type: "snapshotRequested", runtimeId: "b", generation: 1, }); const snapshot: RuntimeSnapshot = { bridgeInstanceId: "epoch", latestSeq: 0, runtime: runtime("b"), transcript: { data: { messages: [{ role: "user", content: "b" }] } }, extensions: [], }; state = workspaceReducer(state, { type: "snapshotLoaded", runtimeId: "b", generation: 1, snapshot, }); state = applyWorkspaceEvent(state, { bridgeInstanceId: "epoch", seq: 1, type: "runtime_event", data: { runtimeId: "b", eventType: "transcript", eventData: { event: { type: "message_end" } }, }, }); expect(state.runtimesById.a.draft).toBe("draft a"); expect(state.runtimesById.a.messages).toEqual([]); expect(state.runtimesById.b.messages[0].content).toBe("b"); expect(state.runtimesById.b.unread).toBe(true); }); it("removes the optimistic submission when Pi returns its structured user message", () => { let state = reconcileWorkspace(initialWorkspaceState, workspace); state = workspaceReducer(state, { type: "submissionAdded", runtimeId: "a", submission: { id: 1, text: "Hello Pi", userMessageCount: 0, phase: "sent", }, }); state = workspaceReducer(state, { type: "snapshotRequested", runtimeId: "a", generation: 1, }); const snapshot: RuntimeSnapshot = { bridgeInstanceId: "epoch", latestSeq: 1, runtime: runtime("a"), transcript: { data: { messages: [ { role: "user", content: [{ type: "text", text: "Hello Pi" }] }, ], }, }, extensions: [], }; state = workspaceReducer(state, { type: "snapshotLoaded", runtimeId: "a", generation: 1, snapshot, }); expect(state.runtimesById.a.pendingSubmissions).toEqual([]); }); it("reconciles duplicate pending submissions one message at a time and honors Pi queue totals", () => { let state = reconcileWorkspace(initialWorkspaceState, workspace); for (const id of [1, 2]) state = workspaceReducer(state, { type: "submissionAdded", runtimeId: "a", submission: { id, text: "Continue", userMessageCount: 0, phase: "sent", }, }); state = workspaceReducer(state, { type: "snapshotRequested", runtimeId: "a", generation: 1, }); state = workspaceReducer(state, { type: "snapshotLoaded", runtimeId: "a", generation: 1, snapshot: { bridgeInstanceId: "epoch", latestSeq: 1, runtime: runtime("a"), transcript: { data: { messages: [{ role: "user", content: "Continue" }] }, }, extensions: [], }, }); expect(state.runtimesById.a.pendingSubmissions).toHaveLength(1); state = applyWorkspaceEvent(state, { bridgeInstanceId: "epoch", seq: 1, type: "runtime_event", data: { runtimeId: "a", eventType: "queue", eventData: { event: { pendingMessageCount: 3, followUp: ["one"], steering: ["two"], }, }, }, }); expect(state.runtimesById.a.summary.queueCount).toBe(3); }); it("keeps a hotkey-triggered new-session startup visible until completion", () => { let state = reconcileWorkspace(initialWorkspaceState, workspace); state = workspaceReducer(state, { type: "newSessionLaunch", launch: { phase: "starting", detail: "Starting a new Pi session…" }, }); expect(state.newSessionLaunch?.phase).toBe("starting"); state = workspaceReducer(state, { type: "newSessionLaunch", launch: undefined, }); expect(state.newSessionLaunch).toBeUndefined(); }); it("rejects out-of-order snapshots and detects duplicate/gap/epoch events", () => { let state = reconcileWorkspace(initialWorkspaceState, workspace); state = workspaceReducer(state, { type: "snapshotRequested", runtimeId: "a", generation: 2, }); const snap: RuntimeSnapshot = { bridgeInstanceId: "epoch", latestSeq: 0, runtime: runtime("a"), extensions: [], }; expect( workspaceReducer(state, { type: "snapshotLoaded", runtimeId: "a", generation: 1, snapshot: snap, }).runtimesById.a.loading, ).toBe(true); const first = applyWorkspaceEvent(state, { bridgeInstanceId: "epoch", seq: 1, data: { runtimeId: "a", runtime: runtime("a") }, }); expect( applyWorkspaceEvent(first, { bridgeInstanceId: "epoch", seq: 1, data: { runtimeId: "a" }, }), ).toBe(first); expect( applyWorkspaceEvent(first, { bridgeInstanceId: "epoch", seq: 3, data: { runtimeId: "a" }, }).bridge.status, ).toBe("resyncing"); expect( applyWorkspaceEvent(first, { bridgeInstanceId: "new", seq: 2, data: { runtimeId: "a" }, }).bridge.status, ).toBe("resyncing"); }); it("selects an adjacent tab on close, preserves drafts, and keeps failed dormant runtimes", () => { let state = reconcileWorkspace(initialWorkspaceState, workspace, { version: 1, directoryOrder: ["/repo"], runtimeOrderByDirectory: { "/repo": ["b", "a"] }, selectedRuntimeId: "b", drafts: { a: "saved" }, scroll: {}, lastSeen: {}, }); expect(state.runtimesById.a.draft).toBe("saved"); state = applyWorkspaceEvent(state, { bridgeInstanceId: "epoch", seq: 1, type: "runtime_closed", data: { runtimeId: "b" }, }); expect(state.selectedRuntimeId).toBe("a"); const failed: Workspace = { ...workspace, directories: [ { ...workspace.directories[0], runtimes: [ { ...runtime("failed"), state: "failed", attention: true, error: { message: "missing" }, }, ], }, ], }; expect( reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed .summary.agentId, ).toBeUndefined(); }); it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => { let state = reconcileWorkspace(initialWorkspaceState, workspace); state = workspaceReducer(state, { type: "draftChanged", runtimeId: "a", draft: "keep me", }); state = workspaceReducer(state, { type: "scrollChanged", runtimeId: "a", atBottom: false, scrollTop: 42, }); state.runtimesById.a.loadedThroughSeq = 100; state = workspaceReducer(state, { type: "hostEvent", host: { kind: "resetRequired", bridgeInstanceId: "new-epoch", latestSeq: 2, reason: "epochChanged", }, }); const restored = reconcileWorkspace(state, { ...workspace, bridgeInstanceId: "new-epoch", latestSeq: 2, }); expect(restored.runtimesById.a.loadedThroughSeq).toBe(0); expect(restored.runtimesById.a.draft).toBe("keep me"); expect(restored.runtimesById.a.scroll.scrollTop).toBe(42); const requested = workspaceReducer(restored, { type: "snapshotRequested", runtimeId: "a", generation: 1, }); const snapshot: RuntimeSnapshot = { bridgeInstanceId: "new-epoch", latestSeq: 2, runtime: runtime("a"), extensions: [], }; const accepted = workspaceReducer(requested, { type: "snapshotLoaded", runtimeId: "a", generation: 1, snapshot, }); expect(accepted.runtimesById.a.loadedThroughSeq).toBe(2); expect(accepted.runtimesById.a.extensions).toEqual([]); expect(accepted.runtimesById.a.messages).toEqual([]); }); it("clears stale extension and transcript state for every runtime after an epoch change", () => { const state = reconcileWorkspace(initialWorkspaceState, workspace); state.runtimesById.b.messages = [{ role: "assistant", content: "old" }]; state.runtimesById.b.extensions = [ { id: "dead-request", method: "confirm" }, ]; state.runtimesById.b.unread = true; const reset = workspaceReducer(state, { type: "hostEvent", host: { kind: "resetRequired", bridgeInstanceId: "new-epoch", latestSeq: 1, reason: "epochChanged", }, }); const restored = reconcileWorkspace(reset, { ...workspace, bridgeInstanceId: "new-epoch", latestSeq: 1, }); expect(restored.runtimesById.b.messages).toEqual([]); expect(restored.runtimesById.b.extensions).toEqual([]); expect(restored.runtimesById.b.unread).toBe(false); }); it("requests reconciliation when a runtime lifecycle event changes the open tabs", () => { const state = reconcileWorkspace(initialWorkspaceState, workspace); const next = applyWorkspaceEvent(state, { bridgeInstanceId: "epoch", seq: 1, type: "runtime_opened", data: { runtimeId: "external", runtime: runtime("external") }, }); expect(next.bridge.status).toBe("resyncing"); }); it("clears unread only when selected, active, and at latest", () => { let state = reconcileWorkspace(initialWorkspaceState, workspace); state.runtimesById.a.unread = true; state = workspaceReducer(state, { type: "setWindowActive", active: false }); state = workspaceReducer(state, { type: "markRead", runtimeId: "a" }); expect(state.runtimesById.a.unread).toBe(true); state = workspaceReducer(state, { type: "setWindowActive", active: true }); state = workspaceReducer(state, { type: "scrollChanged", runtimeId: "a", atBottom: false, }); state = workspaceReducer(state, { type: "markRead", runtimeId: "a" }); expect(state.runtimesById.a.unread).toBe(true); state = workspaceReducer(state, { type: "scrollChanged", runtimeId: "a", atBottom: true, }); state = workspaceReducer(state, { type: "markRead", runtimeId: "a" }); expect(state.runtimesById.a.unread).toBe(false); }); });