Files
paseo/packages/app/src/stores/session-store.test.ts
T
Mohamed BoudraandGitHub db6520bf36 Show live task progress while agents work (#3227)
* feat(agents): surface live task progress

Keep current task state above the composer and turn task snapshots into semantic timeline activity. Normalize provider task protocols while preserving genuine plan flows.

* fix(agents): admit tasks through stream sequencing

* test(agents): expect normalized task status
2026-08-11 18:23:30 +02:00

779 lines
24 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeWorkspaceDescriptor,
selectAgentTurnPresentation,
selectAgentTimelineState,
useSessionStore,
type WorkspaceDescriptor,
} from "./session-store";
import type { StreamItem } from "../types/stream";
import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectCustomName: input.projectCustomName ?? null,
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
afterEach(() => {
useSessionStore.getState().clearSession("test-server");
});
function initializeTestSession(): void {
useSessionStore.getState().initializeSession("test-server", null as unknown as DaemonClient);
}
function getTestSessionReferences() {
const state = useSessionStore.getState();
const session = state.sessions["test-server"];
if (!session) {
throw new Error("test session is not initialized");
}
return {
sessions: state.sessions,
session,
workspaces: session.workspaces,
};
}
function submittedMessage(clientMessageId: string): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id: clientMessageId,
clientMessageId,
text: clientMessageId,
timestamp: new Date("2026-07-31T10:00:00.000Z"),
};
}
function hasSubmittedMessage(items: readonly StreamItem[] | undefined, clientMessageId: string) {
return items?.some(
(item) => item.kind === "user_message" && item.clientMessageId === clientMessageId,
);
}
function permutations<T>(values: readonly T[]): T[][] {
if (values.length === 0) return [[]];
return values.flatMap((value, index) =>
permutations([...values.slice(0, index), ...values.slice(index + 1)]).map((suffix) =>
[value].concat(suffix),
),
);
}
function taskTexts(tasks: ReadonlyArray<{ text: string }>): string[] {
return tasks.map((task) => task.text);
}
describe("agent task state", () => {
it("notifies the task subscriber only when its task snapshot changes", () => {
initializeTestSession();
const snapshots: string[][] = [];
const unsubscribe = useSessionStore.subscribe(
(state) => state.sessions["test-server"]?.agentTasks.get("agent-1") ?? [],
(tasks) => snapshots.push(taskTexts(tasks)),
);
const tasks = [{ id: "1", text: "Inspect", status: "pending" as const, completed: false }];
useSessionStore
.getState()
.setAgentStreamState("test-server", "agent-1", { taskSnapshot: tasks });
useSessionStore.getState().setMessages("test-server", []);
useSessionStore
.getState()
.setAgentStreamState("test-server", "agent-1", { taskSnapshot: [...tasks] });
useSessionStore.getState().setAgentStreamState("test-server", "agent-1", {
taskSnapshot: [{ ...tasks[0], status: "completed", completed: true }],
});
unsubscribe();
expect(snapshots).toEqual([["Inspect"], ["Inspect"]]);
});
it("restores the latest task snapshot from an authoritative timeline", () => {
initializeTestSession();
const todo: StreamItem = {
kind: "todo_list",
id: "todo-1",
provider: "codex",
timestamp: new Date("2026-08-11T10:00:00.000Z"),
activity: { type: "started", task: "Verify" },
items: [{ text: "Verify", status: "in_progress", completed: false }],
};
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
items: [todo],
head: [],
range: null,
older: "none",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
expect(useSessionStore.getState().sessions["test-server"]?.agentTasks.get("agent-1")).toEqual(
todo.items,
);
});
});
describe("agent timeline state", () => {
it("commits canonical items, range, and older availability as one synced state", () => {
initializeTestSession();
const items: StreamItem[] = [
{
kind: "assistant_message",
id: "canonical-row",
text: "canonical",
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
];
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
items,
head: [],
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
older: "available",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
expect(
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
).toEqual({
status: "synced",
items,
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
older: "available",
newer: "none",
});
});
it("represents an empty authoritative timeline without inventing a range", () => {
initializeTestSession();
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
items: [],
head: [],
range: null,
older: "none",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
expect(
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" });
});
it("preserves older availability when an applied response leaves it unchanged", () => {
initializeTestSession();
const store = useSessionStore.getState();
store.applyAgentTimelineResponseState("test-server", "agent-1", {
items: [],
head: [],
range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 },
older: "available",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
store.applyAgentTimelineResponseState("test-server", "agent-1", {
items: [],
head: [],
range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 },
older: "unchanged",
newer: false,
synchronized: false,
acknowledgedClientMessageIds: [],
});
expect(
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
).toEqual({
status: "synced",
items: [],
range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 },
older: "available",
newer: "none",
});
});
it("stores turn liveness transitions without duplicating their policy", () => {
initializeTestSession();
const store = useSessionStore.getState();
store.applyAgentTurnLiveness("test-server", "agent-1", {
type: "snapshot",
activeTurn: { turnId: "turn-1", startedAt: null },
});
expect(store.getSession("test-server")?.agentTurnLiveness.get("agent-1")).toEqual({
phase: "open",
turnId: "turn-1",
startedAt: null,
cancellationRequestId: null,
});
store.applyAgentTurnLiveness("test-server", "agent-1", {
type: "snapshot",
activeTurn: null,
});
expect(store.getSession("test-server")?.agentTurnLiveness.has("agent-1")).toBe(false);
});
});
describe("message submission ordering", () => {
const agentId = "agent-1";
const clientMessageId = "client-1";
const startedAt = new Date("2026-07-31T10:00:01.000Z");
const steps = [
"accept",
"canonical-row",
"stream-open",
"snapshot-idle",
"snapshot-open",
"stream-close",
] as const;
function applyStep(step: (typeof steps)[number]): void {
const store = useSessionStore.getState();
if (step === "accept") {
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
return;
}
if (step === "canonical-row") {
store.setAgentStreamState("test-server", agentId, {
acknowledgedClientMessageIds: [clientMessageId],
});
return;
}
if (step === "stream-open") {
store.applyAgentTurnLiveness("test-server", agentId, {
type: "stream_open",
turn: { turnId: "turn-1", startedAt },
});
return;
}
if (step === "snapshot-open") {
store.applyAgentTurnLiveness("test-server", agentId, {
type: "snapshot",
activeTurn: { turnId: "turn-1", startedAt },
});
return;
}
if (step === "snapshot-idle") {
store.applyAgentTurnLiveness("test-server", agentId, {
type: "snapshot",
activeTurn: null,
});
return;
}
store.applyAgentTurnLiveness("test-server", agentId, {
type: "stream_close",
turnId: "turn-1",
});
}
it("keeps every ordering active until its canonical row and settles at quiescence", () => {
const continuityViolations: string[] = [];
const settlementViolations: string[] = [];
for (const ordering of permutations(steps)) {
useSessionStore.getState().clearSession("test-server");
initializeTestSession();
useSessionStore
.getState()
.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
let canonicalObserved = false;
for (const step of ordering) {
applyStep(step);
canonicalObserved ||= step === "canonical-row";
const presentation = selectAgentTurnPresentation(
useSessionStore.getState().sessions["test-server"],
agentId,
);
if (!canonicalObserved && !presentation.isActive) {
continuityViolations.push(`${ordering.join(" → ")}: inactive after ${step}`);
}
}
const store = useSessionStore.getState();
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
store.setAgentStreamState("test-server", agentId, {
acknowledgedClientMessageIds: [clientMessageId],
});
store.applyAgentTurnLiveness("test-server", agentId, [
{ type: "stream_close", turnId: "turn-1" },
{ type: "snapshot", activeTurn: null },
]);
const settled = selectAgentTurnPresentation(
useSessionStore.getState().sessions["test-server"],
agentId,
);
if (settled.isActive) settlementViolations.push(ordering.join(" → "));
}
expect({ continuityViolations, settlementViolations }).toEqual({
continuityViolations: [],
settlementViolations: [],
});
});
it("publishes the optimistic row and active presentation in one store notification", () => {
initializeTestSession();
const notifications: Array<{ hasOptimisticRow: boolean; isActive: boolean }> = [];
const unsubscribe = useSessionStore.subscribe((state) => {
const session = state.sessions["test-server"];
notifications.push({
hasOptimisticRow:
hasSubmittedMessage(session?.agentStreamTail.get(agentId), clientMessageId) === true,
isActive: selectAgentTurnPresentation(session, agentId).isActive,
});
});
useSessionStore
.getState()
.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
unsubscribe();
expect(notifications).toEqual([{ hasOptimisticRow: true, isActive: true }]);
});
it("keeps another unacknowledged submission active after one row is acknowledged", () => {
initializeTestSession();
const store = useSessionStore.getState();
store.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId));
store.beginAgentMessageSubmission("test-server", agentId, submittedMessage("client-2"));
store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId);
store.setAgentStreamState("test-server", agentId, {
acknowledgedClientMessageIds: [clientMessageId],
});
expect(
selectAgentTurnPresentation(useSessionStore.getState().sessions["test-server"], agentId)
.isActive,
).toBe(true);
});
it("reconciles a created-agent row without creating a submission transaction", () => {
initializeTestSession();
const message = submittedMessage(clientMessageId);
const handedOff = useSessionStore
.getState()
.handoffCreatedAgentUserMessage("test-server", agentId, message);
const session = useSessionStore.getState().sessions["test-server"];
expect({
handedOff,
tail: session?.agentStreamTail.get(agentId),
head: session?.agentStreamHead.get(agentId) ?? [],
submissions: session?.messageSubmissions.get(agentId) ?? [],
}).toEqual({
handedOff: true,
tail: [message],
head: [],
submissions: [],
});
});
});
describe("normalizeWorkspaceDescriptor", () => {
it("normalizes workspace scripts and invalid activity timestamps", () => {
const scripts = [
{
scriptName: "web",
type: "service" as const,
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running" as const,
health: "healthy" as const,
exitCode: null,
terminalId: null,
},
];
const workspace = normalizeWorkspaceDescriptor({
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "running",
statusEnteredAt: null,
activityAt: "not-a-date",
diffStat: null,
scripts,
});
expect(workspace.scripts).toEqual([
{
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
},
]);
expect(workspace.scripts).not.toBe(scripts);
});
it("canonicalizes the workspace directory and treats a blank one as empty", () => {
const canonical = normalizeWorkspaceDescriptor({
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo/app/",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
});
expect(canonical.workspaceDirectory).toBe("/repo/app");
const blank = normalizeWorkspaceDescriptor({
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: " ",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
});
expect(blank.workspaceDirectory).toBe("");
});
it("defaults missing scripts to an empty array", () => {
const payload = {
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
} as WorkspaceDescriptorPayload;
const workspace = normalizeWorkspaceDescriptor(payload);
expect(workspace.scripts).toEqual([]);
});
it("defaults missing archivingAt to null", () => {
const payload = {
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
scripts: [],
} as unknown as WorkspaceDescriptorPayload;
const workspace = normalizeWorkspaceDescriptor(payload);
expect(workspace.archivingAt).toBeNull();
});
it("normalizes statusEnteredAt strings to Date and missing or null values to null", () => {
const basePayload = {
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
status: "running",
activityAt: null,
diffStat: null,
scripts: [],
} satisfies Omit<WorkspaceDescriptorPayload, "statusEnteredAt" | "archivingAt">;
const withString = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
statusEnteredAt: "2026-05-12T09:30:00.000Z",
});
const withNull = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
statusEnteredAt: null,
});
const missing = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
} as unknown as WorkspaceDescriptorPayload);
expect(withString.statusEnteredAt).toEqual(new Date("2026-05-12T09:30:00.000Z"));
expect(withNull.statusEnteredAt).toBeNull();
expect(missing.statusEnteredAt).toBeNull();
});
it("preserves project placement from workspace descriptor payloads", () => {
const workspace = normalizeWorkspaceDescriptor({
id: "1",
projectId: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectRootPath: "/repo/app",
workspaceDirectory: "/repo/app",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
project: {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
});
expect(workspace.project).toEqual({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
checkout: {
cwd: "/repo/app",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/app.git",
worktreeRoot: "/repo/app",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
});
});
describe("mergeWorkspaces", () => {
it("preserves scripts on merged workspace entries", () => {
const store = useSessionStore.getState();
store.initializeSession("test-server", null as unknown as DaemonClient);
store.setWorkspaces(
"test-server",
new Map([["/repo/main", createWorkspace({ id: "/repo/main", scripts: [] })]]),
);
store.mergeWorkspaces("test-server", [
createWorkspace({
id: "/repo/main",
scripts: [
{
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
},
],
}),
]);
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.scripts).toEqual([
{
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
},
]);
});
it("preserves identity when merging content-equal workspace descriptors", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({ id: "/repo/main" });
store.mergeWorkspaces("test-server", [workspace]);
const first = getTestSessionReferences();
store.mergeWorkspaces("test-server", [{ ...workspace, scripts: [...workspace.scripts] }]);
const second = getTestSessionReferences();
expect(second.sessions).toBe(first.sessions);
expect(second.session).toBe(first.session);
expect(second.workspaces).toBe(first.workspaces);
expect(second.workspaces.get("/repo/main")).toBe(first.workspaces.get("/repo/main"));
});
it("preserves unaffected workspace entry identity when one workspace changes", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspaceA = createWorkspace({ id: "/repo/a", name: "main" });
const workspaceB = createWorkspace({ id: "/repo/b", name: "feature" });
store.mergeWorkspaces("test-server", [workspaceA, workspaceB]);
const before = getTestSessionReferences();
const beforeA = before.workspaces.get("/repo/a");
const beforeB = before.workspaces.get("/repo/b");
store.mergeWorkspaces("test-server", [{ ...workspaceA, status: "running" }]);
const after = getTestSessionReferences();
expect(after.sessions).not.toBe(before.sessions);
expect(after.session).not.toBe(before.session);
expect(after.workspaces).not.toBe(before.workspaces);
expect(after.workspaces.get("/repo/a")).not.toBe(beforeA);
expect(after.workspaces.get("/repo/b")).toBe(beforeB);
});
it("uses incoming null diff stat as authoritative", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({
id: "/repo/main",
diffStat: { additions: 2, deletions: 1 },
});
store.mergeWorkspaces("test-server", [workspace]);
const before = getTestSessionReferences();
store.mergeWorkspaces("test-server", [{ ...workspace, diffStat: null }]);
const after = getTestSessionReferences();
expect(after.sessions).not.toBe(before.sessions);
expect(after.session).not.toBe(before.session);
expect(after.workspaces).not.toBe(before.workspaces);
expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull();
});
});
describe("setWorkspaces", () => {
it("preserves identity when replacing workspaces with content-equal entries", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({ id: "/repo/main" });
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
const before = getTestSessionReferences();
store.setWorkspaces(
"test-server",
new Map([[workspace.id, { ...workspace, scripts: [...workspace.scripts] }]]),
);
const after = getTestSessionReferences();
expect(after.sessions).toBe(before.sessions);
expect(after.session).toBe(before.session);
expect(after.workspaces).toBe(before.workspaces);
expect(after.workspaces.get(workspace.id)).toBe(before.workspaces.get(workspace.id));
});
});
describe("removeWorkspace", () => {
it("preserves identity when removing a missing workspace", () => {
const store = useSessionStore.getState();
initializeTestSession();
const workspace = createWorkspace({ id: "/repo/main" });
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
const before = getTestSessionReferences();
store.removeWorkspace("test-server", "/repo/missing");
const after = getTestSessionReferences();
expect(after.sessions).toBe(before.sessions);
expect(after.session).toBe(before.session);
expect(after.workspaces).toBe(before.workspaces);
});
});
describe("patchWorkspaceScripts", () => {
it("preserves workspace entry identity when scripts are content-equal", () => {
const script = {
scriptName: "web",
type: "service" as const,
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running" as const,
health: "healthy" as const,
exitCode: null,
terminalId: null,
};
const workspace = createWorkspace({ id: "/repo/main", scripts: [script] });
const current = new Map([[workspace.id, workspace]]);
const next = patchWorkspaceScripts(current, {
workspaceId: workspace.id,
scripts: [{ ...script }],
});
expect(next).toBe(current);
expect(next.get(workspace.id)).toBe(workspace);
});
});