Files
pi-gui/ui/src/workspace/reducer.test.ts
T

376 lines
10 KiB
TypeScript

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);
});
});