* feat(sync): restore host directories before reconnect Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal. * fix(sync): preserve active turn identity during catch-up Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response. * test(e2e): inspect the real IndexedDB replica Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well. * fix warm reconnect cache paths * feat(sync): persist project icons and deepen directory sync * fix(sync): preserve project update order * fix(cache): preserve validated replica persistence * fix(sync): retain cached agents during catch-up
1175 lines
36 KiB
TypeScript
1175 lines
36 KiB
TypeScript
import { z } from "zod";
|
|
import { describe, expect, test } from "vitest";
|
|
import {
|
|
RecentProviderSessionDescriptorPayloadSchema,
|
|
SessionInboundMessageSchema,
|
|
SessionOutboundMessageSchema,
|
|
WorkspaceCreateRequestSchema,
|
|
WorkspaceDescriptorPayloadSchema,
|
|
WorkspaceScriptPayloadSchema,
|
|
} from "./messages.js";
|
|
|
|
describe("workspace message schemas", () => {
|
|
test("parses fetch_workspaces_request", () => {
|
|
const parsed = SessionInboundMessageSchema.parse({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-1",
|
|
filter: {
|
|
query: "repo",
|
|
projectId: "proj-12",
|
|
idPrefix: "/Users/me",
|
|
},
|
|
sort: [{ key: "activity_at", direction: "desc" }],
|
|
page: { limit: 50 },
|
|
subscribe: {},
|
|
});
|
|
|
|
expect(parsed.type).toBe("fetch_workspaces_request");
|
|
});
|
|
|
|
test("parses project.add request and response", () => {
|
|
expect(
|
|
SessionInboundMessageSchema.parse({
|
|
type: "project.add.request",
|
|
requestId: "req-add-project",
|
|
cwd: "/repo",
|
|
}),
|
|
).toEqual({
|
|
type: "project.add.request",
|
|
requestId: "req-add-project",
|
|
cwd: "/repo",
|
|
});
|
|
|
|
expect(
|
|
SessionOutboundMessageSchema.parse({
|
|
type: "project.add.response",
|
|
payload: {
|
|
requestId: "req-add-project",
|
|
project: {
|
|
projectId: "/repo",
|
|
projectDisplayName: "repo",
|
|
projectCustomName: null,
|
|
projectRootPath: "/repo",
|
|
projectKind: "git",
|
|
},
|
|
error: null,
|
|
},
|
|
}),
|
|
).toEqual({
|
|
type: "project.add.response",
|
|
payload: {
|
|
requestId: "req-add-project",
|
|
project: {
|
|
projectId: "/repo",
|
|
projectDisplayName: "repo",
|
|
projectCustomName: null,
|
|
projectRootPath: "/repo",
|
|
projectKind: "git",
|
|
},
|
|
error: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("parses active-scoped fetch_agents_request as an optional extension", () => {
|
|
const legacy = SessionInboundMessageSchema.parse({
|
|
type: "fetch_agents_request",
|
|
requestId: "req-agents-legacy",
|
|
page: { limit: 50 },
|
|
});
|
|
const activeScoped = SessionInboundMessageSchema.parse({
|
|
type: "fetch_agents_request",
|
|
requestId: "req-agents-active",
|
|
scope: "active",
|
|
page: { limit: 50 },
|
|
subscribe: {},
|
|
});
|
|
|
|
expect(legacy.type).toBe("fetch_agents_request");
|
|
expect(activeScoped.type).toBe("fetch_agents_request");
|
|
if (activeScoped.type !== "fetch_agents_request") {
|
|
throw new Error("Expected fetch_agents_request");
|
|
}
|
|
expect(activeScoped.scope).toBe("active");
|
|
});
|
|
|
|
test("parses optional sequenced directory requests and responses", () => {
|
|
expect(
|
|
SessionInboundMessageSchema.parse({
|
|
type: "project.list.request",
|
|
requestId: "projects-sync",
|
|
sync: { generation: "daemon-generation", afterSeq: 7 },
|
|
}),
|
|
).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 7 } });
|
|
expect(
|
|
SessionInboundMessageSchema.parse({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "workspaces-sync",
|
|
sync: { generation: "daemon-generation", afterSeq: 11 },
|
|
}),
|
|
).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 11 } });
|
|
expect(
|
|
SessionInboundMessageSchema.parse({
|
|
type: "fetch_agents_request",
|
|
requestId: "agents-sync",
|
|
scope: "active",
|
|
sync: { generation: "daemon-generation", afterSeq: 13 },
|
|
}),
|
|
).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 13 } });
|
|
|
|
const response = SessionOutboundMessageSchema.parse({
|
|
type: "project.list.response",
|
|
payload: {
|
|
requestId: "projects-sync",
|
|
projects: [
|
|
{
|
|
projectId: "project-1",
|
|
projectDisplayName: "Project",
|
|
projectRootPath: "/repo",
|
|
projectKind: "git",
|
|
syncSeq: 12,
|
|
},
|
|
],
|
|
sync: {
|
|
generation: "daemon-generation",
|
|
headSeq: 12,
|
|
mode: "changes",
|
|
removals: [{ id: "project-removed", seq: 10 }],
|
|
},
|
|
},
|
|
});
|
|
expect(response).toMatchObject({
|
|
payload: {
|
|
projects: [{ projectId: "project-1", syncSeq: 12 }],
|
|
sync: { headSeq: 12, removals: [{ id: "project-removed", seq: 10 }] },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("parses agent_update without project placement", () => {
|
|
const result = SessionOutboundMessageSchema.safeParse({
|
|
type: "agent_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
agent: {
|
|
id: "agent-1",
|
|
provider: "codex",
|
|
cwd: "/tmp/repo",
|
|
model: null,
|
|
features: [],
|
|
thinkingOptionId: null,
|
|
effectiveThinkingOptionId: null,
|
|
createdAt: "2026-04-04T00:00:00.000Z",
|
|
updatedAt: "2026-04-04T00:00:00.000Z",
|
|
lastUserMessageAt: null,
|
|
status: "running",
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
title: null,
|
|
labels: {},
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
test("parses paginated fetch_agent_history_request and response", () => {
|
|
const request = SessionInboundMessageSchema.parse({
|
|
type: "fetch_agent_history_request",
|
|
requestId: "req-history",
|
|
page: { limit: 25, cursor: "cursor-1" },
|
|
sort: [{ key: "updated_at", direction: "desc" }],
|
|
});
|
|
const response = SessionOutboundMessageSchema.parse({
|
|
type: "fetch_agent_history_response",
|
|
payload: {
|
|
requestId: "req-history",
|
|
entries: [],
|
|
pageInfo: {
|
|
nextCursor: "cursor-2",
|
|
prevCursor: "cursor-1",
|
|
hasMore: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(request.type).toBe("fetch_agent_history_request");
|
|
expect(response.type).toBe("fetch_agent_history_response");
|
|
});
|
|
|
|
test("parses recent provider session descriptors without legacy handle fields", () => {
|
|
const parsed = RecentProviderSessionDescriptorPayloadSchema.parse({
|
|
providerId: "custom-codex",
|
|
providerLabel: "Custom Codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
title: "Resume this",
|
|
firstPromptPreview: "first prompt",
|
|
lastPromptPreview: "last prompt",
|
|
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
|
});
|
|
|
|
expect(parsed).toEqual({
|
|
providerId: "custom-codex",
|
|
providerLabel: "Custom Codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
title: "Resume this",
|
|
firstPromptPreview: "first prompt",
|
|
lastPromptPreview: "last prompt",
|
|
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
|
});
|
|
});
|
|
|
|
test("parses fetch_recent_provider_sessions request and response", () => {
|
|
const request = SessionInboundMessageSchema.parse({
|
|
type: "fetch_recent_provider_sessions_request",
|
|
requestId: "req-recent-provider-sessions",
|
|
cwd: "/tmp/repo",
|
|
providers: ["my-claude"],
|
|
since: "2026-04-30T00:00:00.000Z",
|
|
limit: 25,
|
|
});
|
|
const response = SessionOutboundMessageSchema.parse({
|
|
type: "fetch_recent_provider_sessions_response",
|
|
payload: {
|
|
requestId: "req-recent-provider-sessions",
|
|
entries: [
|
|
{
|
|
providerId: "my-claude",
|
|
providerLabel: "My Claude",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
title: "Resume this",
|
|
firstPromptPreview: "first prompt",
|
|
lastPromptPreview: "last prompt",
|
|
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(request.type).toBe("fetch_recent_provider_sessions_request");
|
|
expect(request.providers).toEqual(["my-claude"]);
|
|
expect(response.payload).toEqual({
|
|
requestId: "req-recent-provider-sessions",
|
|
entries: [
|
|
{
|
|
providerId: "my-claude",
|
|
providerLabel: "My Claude",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
title: "Resume this",
|
|
firstPromptPreview: "first prompt",
|
|
lastPromptPreview: "last prompt",
|
|
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parses fetch_recent_provider_sessions response with filteredAlreadyImportedCount", () => {
|
|
const response = SessionOutboundMessageSchema.parse({
|
|
type: "fetch_recent_provider_sessions_response",
|
|
payload: {
|
|
requestId: "req-recent-provider-sessions",
|
|
entries: [],
|
|
filteredAlreadyImportedCount: 3,
|
|
},
|
|
});
|
|
|
|
if (response.type !== "fetch_recent_provider_sessions_response") {
|
|
throw new Error("expected fetch_recent_provider_sessions_response");
|
|
}
|
|
expect(response.payload.filteredAlreadyImportedCount).toBe(3);
|
|
});
|
|
|
|
test("parses new and legacy import agent requests", () => {
|
|
const newRequest = SessionInboundMessageSchema.parse({
|
|
type: "import_agent_request",
|
|
requestId: "req-import-new",
|
|
providerId: "custom-codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
});
|
|
const legacyRequest = SessionInboundMessageSchema.parse({
|
|
type: "import_agent_request",
|
|
requestId: "req-import-legacy",
|
|
provider: "custom-codex",
|
|
sessionId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
});
|
|
|
|
expect(newRequest).toEqual({
|
|
type: "import_agent_request",
|
|
requestId: "req-import-new",
|
|
providerId: "custom-codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
});
|
|
expect(legacyRequest).toEqual({
|
|
type: "import_agent_request",
|
|
requestId: "req-import-legacy",
|
|
provider: "custom-codex",
|
|
sessionId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
});
|
|
});
|
|
|
|
test("parses open_project_request", () => {
|
|
const parsed = SessionInboundMessageSchema.parse({
|
|
type: "open_project_request",
|
|
cwd: "/tmp/repo",
|
|
requestId: "req-open",
|
|
});
|
|
|
|
expect(parsed.type).toBe("open_project_request");
|
|
});
|
|
|
|
test("parses a GitHub clone response that registers a project without a workspace", () => {
|
|
const request = SessionInboundMessageSchema.parse({
|
|
type: "project.github.clone.request",
|
|
repo: "a/b",
|
|
cloneProtocol: "https",
|
|
targetDirectory: "~/workspace",
|
|
requestId: "req-clone",
|
|
});
|
|
const response = SessionOutboundMessageSchema.parse({
|
|
type: "project.github.clone.response",
|
|
payload: {
|
|
requestId: "req-clone",
|
|
repo: "a/b",
|
|
checkoutPath: "/tmp/b",
|
|
project: {
|
|
projectId: "project-b",
|
|
projectDisplayName: "b",
|
|
projectRootPath: "/tmp/b",
|
|
projectKind: "git",
|
|
},
|
|
error: null,
|
|
},
|
|
});
|
|
|
|
expect(request.type).toBe("project.github.clone.request");
|
|
if (request.type !== "project.github.clone.request") {
|
|
throw new Error("expected project.github.clone.request");
|
|
}
|
|
expect(request.cloneProtocol).toBe("https");
|
|
expect(response.type).toBe("project.github.clone.response");
|
|
if (response.type !== "project.github.clone.response") {
|
|
throw new Error("expected project.github.clone.response");
|
|
}
|
|
expect(response.payload.project?.projectId).toBe("project-b");
|
|
});
|
|
|
|
test("rejects invalid project GitHub clone protocols", () => {
|
|
const request = SessionInboundMessageSchema.safeParse({
|
|
type: "project.github.clone.request",
|
|
repo: "a/b",
|
|
cloneProtocol: "ftp",
|
|
targetDirectory: "~/workspace",
|
|
requestId: "req-clone",
|
|
});
|
|
|
|
expect(request.success).toBe(false);
|
|
});
|
|
|
|
test("rejects project GitHub clone repo paths shorter than owner slash repo", () => {
|
|
const request = SessionInboundMessageSchema.safeParse({
|
|
type: "project.github.clone.request",
|
|
repo: "ab",
|
|
targetDirectory: "~/workspace",
|
|
requestId: "req-clone",
|
|
});
|
|
const response = SessionOutboundMessageSchema.safeParse({
|
|
type: "project.github.clone.response",
|
|
payload: {
|
|
requestId: "req-clone",
|
|
repo: "ab",
|
|
checkoutPath: null,
|
|
project: null,
|
|
error: "failed",
|
|
},
|
|
});
|
|
|
|
expect(request.success).toBe(false);
|
|
expect(response.success).toBe(false);
|
|
});
|
|
|
|
test("parses legacy editor RPC messages for compatibility", () => {
|
|
const listRequest = SessionInboundMessageSchema.parse({
|
|
type: "list_available_editors_request",
|
|
requestId: "req-editors",
|
|
});
|
|
const openRequest = SessionInboundMessageSchema.parse({
|
|
type: "open_in_editor_request",
|
|
requestId: "req-open-editor",
|
|
editorId: "unknown-editor",
|
|
path: "/tmp/repo",
|
|
mode: "reveal",
|
|
cwd: "/tmp",
|
|
});
|
|
const listResponse = SessionOutboundMessageSchema.parse({
|
|
type: "list_available_editors_response",
|
|
payload: {
|
|
requestId: "req-editors",
|
|
editors: [{ id: "unknown-editor", label: "Unknown Editor" }],
|
|
error: null,
|
|
},
|
|
});
|
|
const openResponse = SessionOutboundMessageSchema.parse({
|
|
type: "open_in_editor_response",
|
|
payload: {
|
|
requestId: "req-open-editor",
|
|
error: "Editor opening moved to the desktop app",
|
|
},
|
|
});
|
|
|
|
expect(listRequest.type).toBe("list_available_editors_request");
|
|
expect(openRequest.type).toBe("open_in_editor_request");
|
|
expect(listResponse.type).toBe("list_available_editors_response");
|
|
expect(openResponse.type).toBe("open_in_editor_response");
|
|
});
|
|
|
|
test("rejects empty legacy editor ids", () => {
|
|
const result = SessionInboundMessageSchema.safeParse({
|
|
type: "open_in_editor_request",
|
|
requestId: "req-open-empty",
|
|
editorId: "",
|
|
path: "/tmp/repo",
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
test("rejects invalid workspace update payload", () => {
|
|
const result = SessionOutboundMessageSchema.safeParse({
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
workspace: {
|
|
id: "ws-invalid",
|
|
projectId: "proj-invalid",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: "/repo",
|
|
projectKind: "directory",
|
|
workspaceKind: "checkout",
|
|
name: "",
|
|
status: "not-a-bucket",
|
|
activityAt: null,
|
|
scripts: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
});
|
|
|
|
test("parses workspace descriptors with scripts", () => {
|
|
const parsed = SessionOutboundMessageSchema.parse({
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
workspace: {
|
|
id: "ws-1",
|
|
projectId: "proj-1",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: "/repo",
|
|
workspaceDirectory: "/repo",
|
|
projectKind: "directory",
|
|
workspaceKind: "checkout",
|
|
name: "repo",
|
|
status: "done",
|
|
activityAt: null,
|
|
scripts: [
|
|
{
|
|
scriptName: "web",
|
|
hostname: "web.paseo.localhost",
|
|
port: 3000,
|
|
proxyUrl: "http://web.paseo.localhost:6767",
|
|
lifecycle: "running",
|
|
health: "healthy",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(parsed.type).toBe("workspace_update");
|
|
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
|
|
throw new Error("Expected workspace_update upsert payload");
|
|
}
|
|
expect(parsed.payload.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,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parses legacy workspace descriptors without workspaceDirectory", () => {
|
|
const parsed = SessionOutboundMessageSchema.parse({
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
workspace: {
|
|
id: "legacy-workspace",
|
|
projectId: "legacy-project",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: "/repo",
|
|
projectKind: "git",
|
|
workspaceKind: "local_checkout",
|
|
name: "repo",
|
|
status: "done",
|
|
activityAt: null,
|
|
scripts: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(parsed.type).toBe("workspace_update");
|
|
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
|
|
throw new Error("Expected workspace_update upsert payload");
|
|
}
|
|
expect(parsed.payload.workspace.workspaceDirectory).toBe("/repo");
|
|
expect(parsed.payload.workspace.worktreeSlug).toBeUndefined();
|
|
});
|
|
|
|
test("preserves a Paseo-owned worktree slug", () => {
|
|
const parsed = WorkspaceDescriptorPayloadSchema.parse({
|
|
id: "owned-worktree",
|
|
projectId: "project",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: "/repo",
|
|
workspaceDirectory: "/paseo/worktrees/project/feature/packages/app",
|
|
worktreeSlug: "feature",
|
|
projectKind: "git",
|
|
workspaceKind: "worktree",
|
|
name: "feature",
|
|
status: "done",
|
|
activityAt: null,
|
|
scripts: [],
|
|
});
|
|
|
|
expect(parsed.worktreeSlug).toBe("feature");
|
|
});
|
|
|
|
test("defaults omitted workspace archiving state and preserves present timestamps", () => {
|
|
const baseWorkspace = {
|
|
id: "ws-archiving",
|
|
projectId: "proj-archiving",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: "/repo",
|
|
workspaceDirectory: "/repo",
|
|
projectKind: "git",
|
|
workspaceKind: "worktree",
|
|
name: "feature",
|
|
status: "done",
|
|
activityAt: null,
|
|
scripts: [],
|
|
} as const;
|
|
const archivingAt = "2026-04-30T20:45:00.000Z";
|
|
|
|
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).archivingAt).toBeNull();
|
|
expect(
|
|
WorkspaceDescriptorPayloadSchema.parse({
|
|
...baseWorkspace,
|
|
archivingAt,
|
|
}).archivingAt,
|
|
).toBe(archivingAt);
|
|
});
|
|
|
|
// The protocol `statusEnteredAt` field is optional and defaults to null.
|
|
// Old daemons omit it entirely and old clients must keep accepting that.
|
|
test("defaults statusEnteredAt to null for legacy descriptors", () => {
|
|
const baseWorkspace = {
|
|
id: "ws-status-entered",
|
|
projectId: "proj",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: "/repo",
|
|
workspaceDirectory: "/repo",
|
|
projectKind: "git",
|
|
workspaceKind: "worktree",
|
|
name: "feature",
|
|
status: "running",
|
|
activityAt: null,
|
|
scripts: [],
|
|
} as const;
|
|
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).statusEnteredAt).toBeNull();
|
|
});
|
|
|
|
test("preserves statusEnteredAt when present", () => {
|
|
const baseWorkspace = {
|
|
id: "ws-status-entered",
|
|
projectId: "proj",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: "/repo",
|
|
workspaceDirectory: "/repo",
|
|
projectKind: "git",
|
|
workspaceKind: "worktree",
|
|
name: "feature",
|
|
status: "running",
|
|
activityAt: null,
|
|
scripts: [],
|
|
} as const;
|
|
const statusEnteredAt = "2026-05-12T10:00:00.000Z";
|
|
expect(
|
|
WorkspaceDescriptorPayloadSchema.parse({
|
|
...baseWorkspace,
|
|
statusEnteredAt,
|
|
}).statusEnteredAt,
|
|
).toBe(statusEnteredAt);
|
|
});
|
|
|
|
test("preserves explicit statusEnteredAt: null for empty workspaces", () => {
|
|
// The server emits `statusEnteredAt: null` for workspaces with no
|
|
// contributing agents (the "done with no agents" case). The client must
|
|
// distinguish this from "field omitted" — both parse to null, but the
|
|
// round-trip must not lose the explicit null.
|
|
const baseWorkspace = {
|
|
id: "ws-status-entered",
|
|
projectId: "proj",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: "/repo",
|
|
workspaceDirectory: "/repo",
|
|
projectKind: "git",
|
|
workspaceKind: "worktree",
|
|
name: "feature",
|
|
status: "done",
|
|
activityAt: null,
|
|
scripts: [],
|
|
} as const;
|
|
const parsed = WorkspaceDescriptorPayloadSchema.parse({
|
|
...baseWorkspace,
|
|
statusEnteredAt: null,
|
|
});
|
|
expect(parsed.statusEnteredAt).toBeNull();
|
|
});
|
|
|
|
test("parses legacy workspace descriptor enum values", () => {
|
|
const parsed = SessionOutboundMessageSchema.parse({
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
workspace: {
|
|
id: "legacy-workspace",
|
|
projectId: "legacy-project",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: "/repo",
|
|
workspaceDirectory: "/repo",
|
|
projectKind: "non_git",
|
|
workspaceKind: "directory",
|
|
name: "repo",
|
|
status: "done",
|
|
activityAt: null,
|
|
scripts: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(parsed.type).toBe("workspace_update");
|
|
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
|
|
throw new Error("Expected workspace_update upsert payload");
|
|
}
|
|
expect(parsed.payload.workspace.projectKind).toBe("non_git");
|
|
expect(parsed.payload.workspace.workspaceKind).toBe("directory");
|
|
});
|
|
|
|
test("parses workspace script management request and response payloads", () => {
|
|
expect(
|
|
SessionInboundMessageSchema.parse({
|
|
type: "workspace.script.stop.request",
|
|
requestId: "req-script-stop",
|
|
workspaceId: "ws-repo",
|
|
scriptName: "web",
|
|
}),
|
|
).toMatchObject({ type: "workspace.script.stop.request", scriptName: "web" });
|
|
|
|
expect(
|
|
SessionOutboundMessageSchema.parse({
|
|
type: "workspace.script.start.response",
|
|
payload: {
|
|
requestId: "req-script-start",
|
|
workspaceId: "ws-repo",
|
|
scriptName: "web",
|
|
script: {
|
|
scriptName: "web",
|
|
type: "service",
|
|
hostname: "web--repo.localhost",
|
|
port: 3000,
|
|
proxyUrl: "http://web--repo.localhost:6767",
|
|
lifecycle: "running",
|
|
health: "healthy",
|
|
terminalId: "terminal-1",
|
|
},
|
|
error: null,
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
type: "workspace.script.start.response",
|
|
payload: { script: { terminalId: "terminal-1", lifecycle: "running" } },
|
|
});
|
|
});
|
|
|
|
test("parses script_status_update payload", () => {
|
|
const parsed = SessionOutboundMessageSchema.parse({
|
|
type: "script_status_update",
|
|
payload: {
|
|
workspaceId: "ws-repo",
|
|
scripts: [
|
|
{
|
|
scriptName: "web",
|
|
hostname: "web.paseo.localhost",
|
|
port: null,
|
|
proxyUrl: null,
|
|
lifecycle: "stopped",
|
|
health: null,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(parsed.type).toBe("script_status_update");
|
|
expect(parsed.payload.workspaceId).toBe("ws-repo");
|
|
expect(parsed.payload.scripts[0]).toMatchObject({
|
|
type: "service",
|
|
exitCode: null,
|
|
});
|
|
});
|
|
|
|
test("parses workspace service payloads from old daemons without split proxy URLs", () => {
|
|
const parsed = WorkspaceScriptPayloadSchema.parse({
|
|
scriptName: "web",
|
|
type: "service",
|
|
hostname: "web--repo.localhost",
|
|
port: 3000,
|
|
proxyUrl: "http://web--repo.localhost:6767",
|
|
lifecycle: "running",
|
|
health: "healthy",
|
|
});
|
|
|
|
expect(parsed.localProxyUrl).toBeUndefined();
|
|
expect(parsed.publicProxyUrl).toBeUndefined();
|
|
expect(parsed.proxyUrl).toBe("http://web--repo.localhost:6767");
|
|
});
|
|
|
|
test("parses workspace service payloads with split local and public proxy URLs", () => {
|
|
const parsed = WorkspaceScriptPayloadSchema.parse({
|
|
scriptName: "web",
|
|
type: "service",
|
|
hostname: "web--repo.localhost",
|
|
port: 3000,
|
|
localProxyUrl: "http://web--repo.localhost:6767",
|
|
publicProxyUrl: "https://web--repo.services.example.com",
|
|
proxyUrl: "https://web--repo.services.example.com",
|
|
lifecycle: "running",
|
|
health: "healthy",
|
|
});
|
|
|
|
expect(parsed.localProxyUrl).toBe("http://web--repo.localhost:6767");
|
|
expect(parsed.publicProxyUrl).toBe("https://web--repo.services.example.com");
|
|
expect(parsed.proxyUrl).toBe("https://web--repo.services.example.com");
|
|
});
|
|
|
|
test("defaults omitted workspace script proxyUrl to null", () => {
|
|
const parsed = WorkspaceScriptPayloadSchema.parse({
|
|
scriptName: "typecheck",
|
|
type: "script",
|
|
hostname: "typecheck",
|
|
port: null,
|
|
lifecycle: "stopped",
|
|
health: null,
|
|
});
|
|
|
|
expect(parsed.proxyUrl).toBeNull();
|
|
});
|
|
|
|
test("parses workspace_setup_progress payload", () => {
|
|
const parsed = SessionOutboundMessageSchema.parse({
|
|
type: "workspace_setup_progress",
|
|
payload: {
|
|
workspaceId: "ws-feature-a",
|
|
status: "completed",
|
|
detail: {
|
|
type: "worktree_setup",
|
|
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
|
branchName: "feature-a",
|
|
log: "done",
|
|
commands: [
|
|
{
|
|
index: 1,
|
|
command: "npm install",
|
|
cwd: "/repo/.paseo/worktrees/feature-a",
|
|
log: "done",
|
|
status: "completed",
|
|
exitCode: 0,
|
|
durationMs: 100,
|
|
},
|
|
],
|
|
},
|
|
error: null,
|
|
},
|
|
});
|
|
|
|
expect(parsed.type).toBe("workspace_setup_progress");
|
|
});
|
|
|
|
test("parses workspace_setup_status_request", () => {
|
|
const parsed = SessionInboundMessageSchema.parse({
|
|
type: "workspace_setup_status_request",
|
|
workspaceId: "ws-feature-a",
|
|
requestId: "req-status",
|
|
});
|
|
|
|
expect(parsed.type).toBe("workspace_setup_status_request");
|
|
});
|
|
|
|
test("parses workspace_setup_status_response payload", () => {
|
|
const parsed = SessionOutboundMessageSchema.parse({
|
|
type: "workspace_setup_status_response",
|
|
payload: {
|
|
requestId: "req-status",
|
|
workspaceId: "ws-feature-a",
|
|
snapshot: {
|
|
status: "completed",
|
|
detail: {
|
|
type: "worktree_setup",
|
|
worktreePath: "/repo/.paseo/worktrees/feature-a",
|
|
branchName: "feature-a",
|
|
log: "done",
|
|
commands: [],
|
|
},
|
|
error: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(parsed.type).toBe("workspace_setup_status_response");
|
|
});
|
|
|
|
test("parses fetch_workspaces_response with optional runtime fields", () => {
|
|
const parsed = SessionOutboundMessageSchema.parse({
|
|
type: "fetch_workspaces_response",
|
|
payload: {
|
|
requestId: "req-workspaces",
|
|
entries: [
|
|
{
|
|
id: "ws-main",
|
|
projectId: "remote:github.com/acme/repo",
|
|
projectDisplayName: "acme/repo",
|
|
projectRootPath: "/tmp/repo",
|
|
workspaceDirectory: "/tmp/repo",
|
|
projectKind: "git",
|
|
workspaceKind: "local_checkout",
|
|
name: "main",
|
|
status: "done",
|
|
activityAt: null,
|
|
diffStat: {
|
|
additions: 3,
|
|
deletions: 1,
|
|
},
|
|
gitRuntime: {
|
|
currentBranch: "main",
|
|
remoteUrl: "https://github.com/acme/repo.git",
|
|
isPaseoOwnedWorktree: false,
|
|
isDirty: true,
|
|
aheadBehind: {
|
|
ahead: 2,
|
|
behind: 1,
|
|
},
|
|
aheadOfOrigin: 2,
|
|
behindOfOrigin: 1,
|
|
},
|
|
githubRuntime: {
|
|
featuresEnabled: true,
|
|
pullRequest: {
|
|
url: "https://github.com/acme/repo/pull/123",
|
|
title: "Runtime payloads",
|
|
state: "open",
|
|
baseRefName: "main",
|
|
headRefName: "workspace-git-service",
|
|
isMerged: false,
|
|
},
|
|
error: null,
|
|
refreshedAt: "2026-04-12T00:00:00.000Z",
|
|
},
|
|
},
|
|
],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: null,
|
|
hasMore: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(parsed.type).toBe("fetch_workspaces_response");
|
|
expect(parsed.payload.entries[0]?.gitRuntime).toMatchObject({
|
|
currentBranch: "main",
|
|
isDirty: true,
|
|
aheadOfOrigin: 2,
|
|
});
|
|
expect(parsed.payload.entries[0]?.githubRuntime?.pullRequest?.title).toBe("Runtime payloads");
|
|
});
|
|
|
|
test("older workspace parsers ignore additive runtime fields", () => {
|
|
const message = {
|
|
type: "fetch_workspaces_response",
|
|
payload: {
|
|
requestId: "req-workspaces",
|
|
entries: [
|
|
{
|
|
id: "ws-main",
|
|
projectId: "remote:github.com/acme/repo",
|
|
projectDisplayName: "acme/repo",
|
|
projectRootPath: "/tmp/repo",
|
|
projectKind: "git",
|
|
workspaceKind: "local_checkout",
|
|
name: "main",
|
|
status: "done",
|
|
activityAt: null,
|
|
diffStat: null,
|
|
gitRuntime: {
|
|
currentBranch: "main",
|
|
remoteUrl: "https://github.com/acme/repo.git",
|
|
isPaseoOwnedWorktree: false,
|
|
isDirty: false,
|
|
aheadBehind: {
|
|
ahead: 0,
|
|
behind: 0,
|
|
},
|
|
aheadOfOrigin: 0,
|
|
behindOfOrigin: 0,
|
|
},
|
|
githubRuntime: {
|
|
featuresEnabled: true,
|
|
pullRequest: null,
|
|
error: null,
|
|
refreshedAt: "2026-04-12T00:00:00.000Z",
|
|
},
|
|
},
|
|
],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: null,
|
|
hasMore: false,
|
|
},
|
|
},
|
|
};
|
|
|
|
const legacyWorkspaceSchema = z.object({
|
|
id: z.string(),
|
|
projectId: z.string(),
|
|
projectDisplayName: z.string(),
|
|
projectRootPath: z.string(),
|
|
projectKind: z.enum(["git", "non_git"]),
|
|
workspaceKind: z.enum(["local_checkout", "worktree", "directory"]),
|
|
name: z.string(),
|
|
status: z.enum(["needs_input", "failed", "running", "attention", "done"]),
|
|
activityAt: z.string().nullable(),
|
|
diffStat: z
|
|
.object({
|
|
additions: z.number(),
|
|
deletions: z.number(),
|
|
})
|
|
.nullable()
|
|
.optional(),
|
|
});
|
|
const legacyMessageSchema = z.object({
|
|
type: z.literal("fetch_workspaces_response"),
|
|
payload: z.object({
|
|
requestId: z.string(),
|
|
entries: z.array(legacyWorkspaceSchema),
|
|
pageInfo: z.object({
|
|
nextCursor: z.string().nullable(),
|
|
prevCursor: z.string().nullable(),
|
|
hasMore: z.boolean(),
|
|
}),
|
|
}),
|
|
});
|
|
|
|
const parsed = legacyMessageSchema.parse(message);
|
|
|
|
expect(parsed.payload.entries[0]).toEqual({
|
|
id: "ws-main",
|
|
projectId: "remote:github.com/acme/repo",
|
|
projectDisplayName: "acme/repo",
|
|
projectRootPath: "/tmp/repo",
|
|
projectKind: "git",
|
|
workspaceKind: "local_checkout",
|
|
name: "main",
|
|
status: "done",
|
|
activityAt: null,
|
|
diffStat: null,
|
|
});
|
|
});
|
|
|
|
test("parses legacy fetch_agents_response checkout payloads without worktreeRoot", () => {
|
|
const result = SessionOutboundMessageSchema.safeParse({
|
|
type: "fetch_agents_response",
|
|
payload: {
|
|
requestId: "req-1",
|
|
entries: [
|
|
{
|
|
agent: {
|
|
id: "agent-1",
|
|
provider: "codex",
|
|
cwd: "C:\\repo",
|
|
model: null,
|
|
features: [],
|
|
thinkingOptionId: null,
|
|
effectiveThinkingOptionId: null,
|
|
createdAt: "2026-04-04T00:00:00.000Z",
|
|
updatedAt: "2026-04-04T00:00:00.000Z",
|
|
lastUserMessageAt: null,
|
|
status: "running",
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
title: "Agent 1",
|
|
labels: {},
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
},
|
|
project: {
|
|
projectKey: "remote:github.com/acme/repo",
|
|
projectName: "acme/repo",
|
|
checkout: {
|
|
cwd: "C:\\repo",
|
|
isGit: true,
|
|
currentBranch: "main",
|
|
remoteUrl: "https://github.com/acme/repo.git",
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: null,
|
|
hasMore: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
if (!result.success) {
|
|
return;
|
|
}
|
|
|
|
const checkout = result.data.payload.entries[0]?.project.checkout;
|
|
expect(checkout?.worktreeRoot).toBe("C:\\repo");
|
|
});
|
|
|
|
test("workspace summary parses without forge and round-trips forge when present", () => {
|
|
const baseWorkspace = {
|
|
id: "ws-forge",
|
|
projectId: "proj",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: "/repo",
|
|
workspaceDirectory: "/repo",
|
|
projectKind: "git",
|
|
workspaceKind: "worktree",
|
|
name: "feature",
|
|
status: "done",
|
|
activityAt: null,
|
|
scripts: [],
|
|
} as const;
|
|
|
|
// Old daemon: forge omitted -> parses, field absent (client falls back to github).
|
|
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).forge).toBeUndefined();
|
|
|
|
// New daemon: forge present -> round-trips (open string, like the PR-status forge).
|
|
expect(
|
|
WorkspaceDescriptorPayloadSchema.parse({ ...baseWorkspace, forge: "gitlab" }).forge,
|
|
).toBe("gitlab");
|
|
});
|
|
|
|
test("workspace.create.request rejects old flat backing shape and accepts new source envelope", () => {
|
|
// Old flat shape with backing enum must be rejected.
|
|
const oldFlat = WorkspaceCreateRequestSchema.safeParse({
|
|
type: "workspace.create.request",
|
|
requestId: "req-old",
|
|
backing: "worktree",
|
|
cwd: "/tmp/repo",
|
|
branch: "feat/my-feature",
|
|
});
|
|
expect(oldFlat.success).toBe(false);
|
|
|
|
// New envelope shape with source discriminated union must be accepted.
|
|
const newWorktree = WorkspaceCreateRequestSchema.parse({
|
|
type: "workspace.create.request",
|
|
requestId: "req-worktree",
|
|
source: {
|
|
kind: "worktree",
|
|
cwd: "/tmp/repo",
|
|
action: "checkout",
|
|
refName: "feat/my-feature",
|
|
},
|
|
});
|
|
expect(newWorktree.type).toBe("workspace.create.request");
|
|
expect(newWorktree.source.kind).toBe("worktree");
|
|
|
|
const branchOff = WorkspaceCreateRequestSchema.parse({
|
|
type: "workspace.create.request",
|
|
requestId: "req-branch-off",
|
|
source: {
|
|
kind: "worktree",
|
|
cwd: "/tmp/repo",
|
|
action: "branch-off",
|
|
branchName: "feature/auth",
|
|
worktreeSlug: "feature-auth",
|
|
},
|
|
});
|
|
expect(branchOff.source).toEqual({
|
|
kind: "worktree",
|
|
cwd: "/tmp/repo",
|
|
action: "branch-off",
|
|
branchName: "feature/auth",
|
|
worktreeSlug: "feature-auth",
|
|
});
|
|
|
|
// Directory source must also be accepted.
|
|
const newDirectory = WorkspaceCreateRequestSchema.parse({
|
|
type: "workspace.create.request",
|
|
requestId: "req-dir",
|
|
source: {
|
|
kind: "directory",
|
|
path: "/tmp/repo",
|
|
},
|
|
});
|
|
expect(newDirectory.type).toBe("workspace.create.request");
|
|
expect(newDirectory.source.kind).toBe("directory");
|
|
});
|
|
});
|