376 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|