feat: replace launcher panel with direct tab creation, use directory-based workspace IDs, and add backward-compatible schema fields

This commit is contained in:
Mohamed Boudra
2026-04-06 18:15:30 +07:00
parent 6e5cad812e
commit 7173342bcb
38 changed files with 1020 additions and 500 deletions
@@ -1,6 +1,7 @@
import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import pino from "pino";
@@ -205,11 +206,13 @@ describe("paseo daemon bootstrap", () => {
test("imports legacy project and workspace JSON into the DB on first bootstrap", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -221,7 +224,7 @@ describe("paseo daemon bootstrap", () => {
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -242,18 +245,16 @@ describe("paseo daemon bootstrap", () => {
const projectRows = await database.db.select().from(projects);
expect(projectRows).toHaveLength(1);
expect(projectRows[0]).toMatchObject({
directory: "/tmp/project-1",
directory: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
});
const workspaceRows = await database.db.select().from(workspaces);
expect(workspaceRows).toHaveLength(1);
expect(workspaceRows[0]).toMatchObject({
projectId: projectRows[0]!.id,
directory: "/tmp/project-1",
directory: projectDir,
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -264,17 +265,20 @@ describe("paseo daemon bootstrap", () => {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
test("does not duplicate imported legacy JSON across daemon restarts", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -286,7 +290,7 @@ describe("paseo daemon bootstrap", () => {
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -314,17 +318,20 @@ describe("paseo daemon bootstrap", () => {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
test("imports legacy project, workspace, and agent JSON into one SQLite bootstrap without duplicating records", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -336,7 +343,7 @@ describe("paseo daemon bootstrap", () => {
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -348,7 +355,7 @@ describe("paseo daemon bootstrap", () => {
writeLegacyAgentJson(config.paseoHome, "agents/agent-1.json", {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project-1",
cwd: projectDir,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
@@ -387,7 +394,7 @@ describe("paseo daemon bootstrap", () => {
expect(agentRows).toEqual([
expect.objectContaining({
agentId: "agent-1",
cwd: "/tmp/project-1",
cwd: projectDir,
workspaceId: workspaceRows[0]!.id,
title: "Imported Agent",
requiresAttention: false,
@@ -398,17 +405,20 @@ describe("paseo daemon bootstrap", () => {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
test("imports large legacy agent JSON batches during SQLite bootstrap", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -420,7 +430,7 @@ describe("paseo daemon bootstrap", () => {
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
@@ -434,7 +444,7 @@ describe("paseo daemon bootstrap", () => {
writeLegacyAgentJson(config.paseoHome, `agents/project-1/agent-${index}.json`, {
id: `agent-${index}`,
provider: "codex",
cwd: "/tmp/project-1",
cwd: projectDir,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
@@ -478,6 +488,7 @@ describe("paseo daemon bootstrap", () => {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
@@ -594,3 +605,15 @@ function writeLegacyAgentJson(paseoHome: string, relativePath: string, payload:
mkdirSync(path.dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, JSON.stringify(payload, null, 2), "utf8");
}
function initializeGitRepo(directory: string): void {
execFileSync("git", ["init", "-b", "main"], { cwd: directory, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.dev"], { cwd: directory, stdio: "pipe" });
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: directory, stdio: "pipe" });
writeFileSync(path.join(directory, "README.md"), "bootstrap fixture\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: directory, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "init"], {
cwd: directory,
stdio: "pipe",
});
}
@@ -121,7 +121,8 @@ describe("daemon E2E - persistence", () => {
(item): item is Extract<(typeof timelineItems)[number], { type: "assistant_message" }> =>
item.type === "assistant_message",
);
expect(assistantMessages).toEqual([{ type: "assistant_message", text: "timeline test" }]);
expect(assistantMessages.length).toBeGreaterThan(0);
expect(assistantMessages.map((item) => item.text).join("")).toBe("timeline test");
} finally {
await ctx.cleanup();
cleaned = true;
@@ -464,13 +464,11 @@ describe("daemon E2E terminal", () => {
test("propagates debounced terminal titles through list responses and snapshots", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "printf '\\033]0;Build Output\\007'\r",
const created = await ctx.client.createTerminal(cwd, undefined, undefined, {
command: "/bin/sh",
args: ["-lc", "printf '\\033]0;Build Output\\007'; sleep 2"],
});
const terminalId = created.terminal!.id;
let listedTitle: string | undefined;
const start = Date.now();
@@ -30,7 +30,7 @@ describe("daemon E2E - timeline window", () => {
modeId: "full-access",
});
const expected = "1234567890ABCDEFGHIJ";
const expected = "READY";
await ctx.client.sendMessage(agent.id, `Respond with exactly: ${expected}`);
const finalState = await ctx.client.waitForFinish(agent.id, 5_000);
expect(finalState.status).toBe("idle");
@@ -61,10 +61,10 @@ describe("daemon E2E - timeline window", () => {
modeId: "full-access",
});
await ctx.client.sendMessage(agent.id, "Respond with exactly: FIRST-RESPONSE");
await ctx.client.sendMessage(agent.id, "Respond with exactly: FIRST");
expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle");
const expected = "SECOND-RESPONSE";
const expected = "SECOND";
await ctx.client.sendMessage(agent.id, `Respond with exactly: ${expected}`);
expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle");
@@ -128,6 +128,10 @@ describe("waitForFinish edge cases", () => {
test("waitForFinish resolves first idle edge even if a new run starts immediately after", async () => {
const cwd = tmpCwd();
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
await secondary.connect();
await secondary.fetchAgents({ subscribe: { subscriptionId: "wait-for-idle-secondary" } });
const agent = await ctx.client.createAgent({
provider: "codex",
@@ -153,14 +157,9 @@ describe("waitForFinish edge cases", () => {
}
spawnedSecondRun = true;
const stream = ctx.daemon.daemon.agentManager.streamAgent(
agent.id,
"Use your shell tool to run sleep 1 and then reply done.",
);
secondRunDrain = (async () => {
for await (const _event of stream) {
// Drain second run so manager can settle.
}
await secondary.sendMessage(agent.id, "Reply with exactly: done.");
await secondary.waitForFinish(agent.id, 5_000);
})();
},
{ agentId: agent.id, replayState: false },
@@ -177,6 +176,7 @@ describe("waitForFinish edge cases", () => {
} finally {
unsubscribe();
await secondRunDrain;
await secondary.close();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}
@@ -227,7 +227,7 @@ describe("importLegacyProjectWorkspaceJson", () => {
expect(projectRows[0]).toEqual(
expect.objectContaining({
directory: "/tmp/project-1",
displayName: "Replacement Project",
displayName: "First Project",
}),
);
});
@@ -31,6 +31,9 @@ export function attachAgentStoragePersistence(
if (event.type !== "agent_state") {
return;
}
if (event.agent.lifecycle === "closed") {
return;
}
void storage.applySnapshot(event.agent).catch((error) => {
log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot");
});
@@ -104,9 +104,12 @@ describe("AgentLoadingService", () => {
expect(loaded.id).toBe(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([]);
expect(durableTimeline.rows.map((row) => row.item)).toEqual([
{ type: "assistant_message", text: "timeline test" },
]);
expect(durableTimeline.rows.every((row) => row.item.type === "assistant_message")).toBe(true);
expect(
durableTimeline.rows
.map((row) => (row.item.type === "assistant_message" ? row.item.text : ""))
.join(""),
).toBe("timeline test");
} finally {
await database.close();
rmSync(workspaceRoot, { recursive: true, force: true });
+30 -23
View File
@@ -1304,6 +1304,22 @@ export class Session {
return workspaces.find((workspace) => workspace.directory === normalizedCwd) ?? null;
}
/**
* Resolve a workspace ID that may be either a numeric ID (legacy) or a directory path
* (sent by clients that received the path-based descriptor format).
*/
private async resolveWorkspaceByIdOrDirectory(
workspaceId: string,
): Promise<PersistedWorkspaceRecord | null> {
const numericId = Number(workspaceId);
if (!Number.isNaN(numericId)) {
const record = await this.workspaceRegistry.get(numericId);
if (record) return record;
}
// Fallback: treat as directory path
return this.findWorkspaceByDirectory(workspaceId);
}
private async resolveWorkspaceDirectory(cwd: string): Promise<string> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
try {
@@ -1970,6 +1986,10 @@ export class Session {
);
}
// Drain queued persistence from the just-closed agent before removing its
// durable snapshot, otherwise an in-flight background write can recreate it.
await this.agentManager.flush();
try {
await this.agentStorage.remove(agentId);
await this.agentManager.deleteCommittedTimeline(agentId);
@@ -2031,20 +2051,7 @@ export class Session {
}
const archivedAt = new Date().toISOString();
const normalizedStatus =
existing.lastStatus === "running" || existing.lastStatus === "initializing"
? "idle"
: existing.lastStatus;
await this.agentStorage.upsert({
...existing,
archivedAt,
updatedAt: archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
});
await this.agentManager.archiveSnapshot(agentId, archivedAt);
return { agentId, archivedAt };
}
@@ -2743,7 +2750,7 @@ export class Session {
);
const resolvedWorkspace =
msg.workspaceId
? await this.workspaceRegistry.get(Number(msg.workspaceId))
? await this.resolveWorkspaceByIdOrDirectory(msg.workspaceId)
: (await this.findWorkspaceByDirectory(sessionConfig.cwd)) ??
(await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd));
if (!resolvedWorkspace) {
@@ -5202,8 +5209,8 @@ export class Session {
}
return {
id: String(workspace.id),
projectId: String(workspace.projectId),
id: workspace.directory,
projectId: resolvedProjectRecord?.directory ?? workspace.directory,
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
projectRootPath: resolvedProjectRecord?.directory ?? workspace.directory,
workspaceDirectory: workspace.directory,
@@ -5704,7 +5711,7 @@ export class Session {
const persistedWorkspace = await this.findWorkspaceByDirectory(normalizedCwd);
const all = await this.listWorkspaceDescriptorsSnapshot();
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
const workspaceIdsToEmit = persistedWorkspace ? [String(persistedWorkspace.id)] : [];
const workspaceIdsToEmit = persistedWorkspace ? [persistedWorkspace.directory] : [];
for (const nextWorkspaceId of workspaceIdsToEmit) {
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId);
@@ -5762,7 +5769,7 @@ export class Session {
for (const workspaceCwd of uniqueWorkspaceCwds) {
const persistedWorkspace = await this.findWorkspaceByDirectory(workspaceCwd);
const workspace = persistedWorkspace ? descriptorsByWorkspaceId.get(String(persistedWorkspace.id)) : null;
const workspace = persistedWorkspace ? descriptorsByWorkspaceId.get(persistedWorkspace.directory) : null;
const nextWorkspace =
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
? workspace
@@ -5773,7 +5780,7 @@ export class Session {
if (persistedWorkspace) {
this.bufferOrEmitWorkspaceUpdate(subscription, {
kind: "remove",
id: String(persistedWorkspace.id),
id: persistedWorkspace.directory,
});
}
continue;
@@ -5988,6 +5995,7 @@ export class Session {
{
emit: (message) => this.emit(message),
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
workspaceRegistry: this.workspaceRegistry,
},
request,
);
@@ -5997,8 +6005,7 @@ export class Session {
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
): Promise<void> {
try {
const numericWorkspaceId = Number(request.workspaceId);
const existing = await this.workspaceRegistry.get(numericWorkspaceId);
const existing = await this.resolveWorkspaceByIdOrDirectory(request.workspaceId);
if (!existing) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
@@ -6006,7 +6013,7 @@ export class Session {
throw new Error("Use worktree archive for Paseo worktrees");
}
const archivedAt = new Date().toISOString();
await this.archiveWorkspaceRecord(numericWorkspaceId, archivedAt);
await this.archiveWorkspaceRecord(existing.id, archivedAt);
await this.emitWorkspaceUpdateForCwd(existing.directory);
this.emit({
type: "archive_workspace_response",
@@ -612,6 +612,12 @@ describe("workspace aggregation", () => {
displayName: "repo",
});
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => {
storedRecord.archivedAt = archivedAt;
storedRecord.updatedAt = archivedAt;
return { ...storedRecord, archivedAt, updatedAt: archivedAt };
});
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as any),
@@ -631,6 +637,7 @@ describe("workspace aggregation", () => {
liveRecord.updatedAt = liveArchivedAt;
return { archivedAt: liveArchivedAt };
},
archiveSnapshot,
clearAgentAttention: async () => {},
} as any,
agentStorage: {
@@ -704,7 +711,8 @@ describe("workspace aggregation", () => {
requestId: "req-close-stored",
});
expect(upsertStoredRecord).toHaveBeenCalledTimes(1);
expect(archiveSnapshot).toHaveBeenCalledTimes(1);
expect(archiveSnapshot).toHaveBeenCalledWith(storedAgentId, expect.any(String));
expect(storedRecord.archivedAt).toEqual(expect.any(String));
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
agents: [
@@ -907,11 +915,11 @@ describe("workspace aggregation", () => {
expect(result.entries).toEqual([
expect.objectContaining({
id: 10,
projectId: 1,
id: "/tmp/repo",
projectId: "/tmp/repo",
name: "repo",
projectKind: "directory",
workspaceKind: "checkout",
projectKind: "non_git",
workspaceKind: "local_checkout",
status: "needs_input",
}),
]);
@@ -951,7 +959,7 @@ describe("workspace aggregation", () => {
});
expect(result.entries[0]).toMatchObject({
id: 20,
id: "/tmp/repo/.paseo/worktrees/feature-name",
name: "feature-name",
projectKind: "git",
workspaceKind: "worktree",
@@ -982,30 +990,34 @@ describe("workspace aggregation", () => {
};
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
{
id: 30,
projectId: 3,
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "directory",
workspaceKind: "checkout",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "running",
activityAt: "2026-03-01T12:00:00.000Z",
services: [],
},
];
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
{
id: 30,
projectId: 3,
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "directory",
workspaceKind: "checkout",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "done",
activityAt: null,
services: [],
},
];
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
@@ -1015,15 +1027,17 @@ describe("workspace aggregation", () => {
expect((workspaceUpdates[1] as any).payload).toEqual({
kind: "upsert",
workspace: {
id: 30,
projectId: 3,
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "directory",
workspaceKind: "checkout",
workspaceDirectory: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "local_checkout",
name: "repo",
status: "done",
activityAt: null,
services: [],
},
});
});
@@ -1081,13 +1095,16 @@ describe("workspace aggregation", () => {
name: "worktree-123",
status: "done",
});
expect(response?.payload.workspace?.id).toEqual(expect.any(Number));
const persistedWorkspace = workspaces.get(response!.payload.workspace.id);
expect(persistedWorkspace?.directory).toContain(path.join("worktree-123"));
expect(response?.payload.workspace?.id).toEqual(expect.any(String));
expect(response?.payload.workspace?.id).toContain("worktree-123");
// The worktree directory is created asynchronously in the background after
// the response is sent, so we only verify the DB record here.
expect(workspaces.has(response!.payload.workspace.id)).toBe(true);
expect(projects.has(response?.payload.workspace?.projectId)).toBe(true);
const persistedWorkspace = Array.from(workspaces.values()).find(
(ws) => ws.directory === response!.payload.workspace.id,
);
expect(persistedWorkspace).toBeTruthy();
expect(persistedWorkspace?.directory).toContain(path.join("worktree-123"));
expect(response?.payload.workspace?.projectId).toEqual(repoDir);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
@@ -1209,7 +1226,7 @@ describe("workspace aggregation", () => {
await (session as any).handleCreateAgentRequest({
type: "create_agent_request",
requestId: "req-create-agent-fail",
workspaceId: 999,
workspaceId: "999",
config: {
provider: "codex",
cwd: "/tmp/repo",
@@ -1264,7 +1281,7 @@ describe("workspace aggregation", () => {
projectDisplayName: "acme/repo",
projectKind: "git",
name: "feature/test-branch",
workspaceKind: "checkout",
workspaceKind: "local_checkout",
},
});
} finally {
@@ -1307,9 +1324,9 @@ describe("workspace aggregation", () => {
error: null,
workspace: {
projectDisplayName: "plain-dir",
projectKind: "directory",
projectKind: "non_git",
name: "plain-dir",
workspaceKind: "checkout",
workspaceKind: "local_checkout",
},
});
} finally {
@@ -1317,3 +1334,220 @@ describe("workspace aggregation", () => {
}
});
});
describe("backward compatibility", () => {
test("workspace descriptor uses directory path as id, not numeric database id", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/myproject",
displayName: "myproject",
kind: "git",
gitRemote: "https://github.com/acme/myproject.git",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/myproject",
displayName: "main",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-id" },
requestId: "compat-id",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
const entries = response.payload.entries;
expect(entries).toHaveLength(1);
expect(entries[0].id).toBe("/tmp/myproject");
expect(entries[0].id).not.toBe("10");
});
test("workspace descriptor maps projectKind 'directory' to 'non_git'", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/dirproject",
displayName: "dirproject",
kind: "directory",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/dirproject",
displayName: "dirproject",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-dir-kind" },
requestId: "compat-dir-kind",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
expect(response.payload.entries[0].projectKind).toBe("non_git");
});
test("workspace descriptor maps projectKind 'git' unchanged", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/gitproject",
displayName: "gitproject",
kind: "git",
gitRemote: "https://github.com/acme/gitproject.git",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/gitproject",
displayName: "main",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-git-kind" },
requestId: "compat-git-kind",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
expect(response.payload.entries[0].projectKind).toBe("git");
});
test("workspace descriptor maps workspaceKind 'checkout' to 'local_checkout'", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/checkout-project",
displayName: "checkout-project",
kind: "git",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/checkout-project",
displayName: "main",
kind: "checkout",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-checkout" },
requestId: "compat-checkout",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
expect(response.payload.entries[0].workspaceKind).toBe("local_checkout");
});
test("workspace descriptor maps workspaceKind 'worktree' unchanged", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/worktree-project",
displayName: "worktree-project",
kind: "git",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/worktree-project/.paseo/worktrees/feature",
displayName: "feature",
kind: "worktree",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-worktree" },
requestId: "compat-worktree",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
expect(response.payload.entries[0].workspaceKind).toBe("worktree");
});
test("workspace descriptor uses project directory as projectId", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 1,
directory: "/tmp/myproject",
displayName: "myproject",
kind: "git",
gitRemote: "https://github.com/acme/myproject.git",
});
seedWorkspace({
workspaces,
id: 10,
projectId: 1,
directory: "/tmp/myproject",
displayName: "main",
});
await (session as any).handleMessage({
type: "fetch_workspaces_request",
payload: { requestId: "compat-project-id" },
requestId: "compat-project-id",
});
const response = emitted.find((m) => m.type === "fetch_workspaces_response") as any;
expect(response).toBeTruthy();
expect(response.payload.entries[0].projectId).toBe("/tmp/myproject");
expect(response.payload.entries[0].projectId).not.toBe("1");
});
test("open_project_response returns backward-compatible descriptor", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
const { tempDir, repoDir } = createTempGitRepo({
remoteUrl: "https://github.com/acme/compat-repo.git",
branchName: "main",
});
try {
await (session as any).handleOpenProjectRequest({
type: "open_project_request",
cwd: repoDir,
requestId: "req-open-compat",
});
const response = emitted.find((m) => m.type === "open_project_response") as any;
expect(response).toBeTruthy();
const workspace = response.payload.workspace;
// id should be the directory path, not a numeric id
expect(workspace.id).toBe(repoDir);
// projectId should be the project directory path, not a numeric id
expect(workspace.projectId).toBe(repoDir);
// projectKind should map "git" to "git"
expect(workspace.projectKind).toBe("git");
// workspaceKind should map "checkout" to "local_checkout"
expect(workspace.workspaceKind).toBe("local_checkout");
// projectRootPath and workspaceDirectory should be the actual directory
expect(workspace.projectRootPath).toBe(repoDir);
expect(workspace.workspaceDirectory).toBe(repoDir);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
@@ -68,22 +68,31 @@ describe("snapshot mutation ownership boundary", () => {
test("session runtime flows delegate snapshot mutations to agent manager without direct storage writes", async () => {
const onMessage = vi.fn();
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => ({
const storedRecord = {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-24T00:00:00.000Z",
updatedAt: archivedAt,
updatedAt: "2026-03-24T00:00:00.000Z",
title: null,
labels: {},
lastStatus: "idle" as const,
config: null,
persistence: null,
archivedAt,
archivedAt: null as string | null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
}));
};
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => {
storedRecord.archivedAt = archivedAt;
storedRecord.updatedAt = archivedAt;
return {
...storedRecord,
archivedAt,
updatedAt: archivedAt,
};
});
const unarchiveSnapshot = vi.fn(async () => true);
const unarchiveSnapshotByHandle = vi.fn(async () => undefined);
const updateAgentMetadata = vi.fn(async () => undefined);
@@ -118,7 +127,7 @@ describe("snapshot mutation ownership boundary", () => {
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
get: async () => storedRecord,
applySnapshot: directStorageWrite,
upsert: directStorageWrite,
} as any,
@@ -148,7 +157,7 @@ describe("snapshot mutation ownership boundary", () => {
terminalManager: null,
}) as any;
const archiveResult = await session.archiveAgentState("agent-1");
const archiveResult = await session.archiveAgentForClose("agent-1");
expect(archiveSnapshot).toHaveBeenCalledTimes(1);
expect(archiveResult.archivedAt).toBeTruthy();
@@ -502,13 +502,16 @@ class FakeAgentSession implements AgentSession {
await this.appendHistoryEvent(assistantChunkA);
this.notifySubscribers(assistantChunkA);
const assistantChunkB: AgentStreamEvent = {
type: "timeline",
provider: this.providerName,
item: { type: "assistant_message", text: assistantText.slice(6) },
};
await this.appendHistoryEvent(assistantChunkB);
this.notifySubscribers(assistantChunkB);
const assistantChunkBText = assistantText.slice(6);
if (assistantChunkBText.length > 0) {
const assistantChunkB: AgentStreamEvent = {
type: "timeline",
provider: this.providerName,
item: { type: "assistant_message", text: assistantChunkBText },
};
await this.appendHistoryEvent(assistantChunkB);
this.notifySubscribers(assistantChunkB);
}
const completed: AgentStreamEvent = {
type: "turn_completed",
@@ -682,6 +682,7 @@ describe("createPaseoWorktreeInBackground", () => {
{
emit: (message) => emitted.push(message),
workspaceSetupSnapshots: snapshots,
workspaceRegistry: { list: async () => [] } as any,
},
{
type: "workspace_setup_status_request",
@@ -717,6 +718,7 @@ describe("createPaseoWorktreeInBackground", () => {
{
emit: (message) => emitted.push(message),
workspaceSetupSnapshots: new Map(),
workspaceRegistry: { list: async () => [] } as any,
},
{
type: "workspace_setup_status_request",
+13 -1
View File
@@ -117,6 +117,7 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
type HandleWorkspaceSetupStatusRequestDependencies = {
emit: EmitSessionMessage;
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
workspaceRegistry: WorkspaceRegistry;
};
type HandleCreatePaseoWorktreeRequestDependencies = {
@@ -645,12 +646,23 @@ export async function handleWorkspaceSetupStatusRequest(
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
): Promise<void> {
const workspaceId = request.workspaceId;
let snapshot = dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null;
// Fallback: if workspaceId is a directory path, resolve to numeric ID and retry lookup
if (!snapshot && Number.isNaN(Number(workspaceId))) {
const workspaces = await dependencies.workspaceRegistry.list();
const match = workspaces.find((w) => w.directory === workspaceId && !w.archivedAt);
if (match) {
snapshot = dependencies.workspaceSetupSnapshots.get(String(match.id)) ?? null;
}
}
dependencies.emit({
type: "workspace_setup_status_response",
payload: {
requestId: request.requestId,
workspaceId,
snapshot: dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null,
snapshot,
},
});
}
@@ -38,8 +38,8 @@ describe("shared messages stream parsing", () => {
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
});
it("rejects removed fetch timeline request baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineRequestMessageSchema.safeParse({
it("parses legacy fetch timeline request baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineRequestMessageSchema.parse({
type: "fetch_agent_timeline_request",
agentId: "agent_live",
requestId: "req-legacy",
@@ -51,36 +51,41 @@ describe("shared messages stream parsing", () => {
projection: "canonical",
});
expect(parsed.success).toBe(false);
expect(parsed.cursor?.epoch).toBe("legacy-epoch");
expect(parsed.projection).toBe("canonical");
});
it("rejects removed fetch timeline response baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineResponseMessageSchema.safeParse({
it("parses legacy fetch timeline response baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
type: "fetch_agent_timeline_response",
payload: {
requestId: "req-1",
agentId: "agent_live",
agent: null,
direction: "tail",
startSeq: 1,
endSeq: 2,
hasOlder: false,
hasNewer: false,
reset: false,
startCursor: { seq: 1 },
startCursor: { seq: 1, epoch: "legacy-epoch" },
endCursor: { seq: 2, epoch: "legacy-epoch" },
projection: "canonical",
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: "hello" },
timestamp: "2026-02-08T20:10:00.000Z",
seq: 2,
seqStart: 1,
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
collapsed: ["assistant_merge"],
},
],
error: null,
},
});
expect(parsed.success).toBe(false);
expect(parsed.payload.startSeq).toBe(1);
expect(parsed.payload.endSeq).toBe(2);
expect(parsed.payload.projection).toBe("canonical");
expect(parsed.payload.entries[0]?.seq).toBe(2);
});
it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => {
@@ -135,8 +140,8 @@ describe("shared messages stream parsing", () => {
}
});
it("rejects removed agent_stream baggage at the parser boundary", () => {
const parsed = AgentStreamMessageSchema.safeParse({
it("parses legacy agent_stream baggage at the parser boundary", () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
payload: {
agentId: "agent_live",
@@ -153,7 +158,7 @@ describe("shared messages stream parsing", () => {
},
});
expect(parsed.success).toBe(false);
expect(parsed.payload.epoch).toBe("legacy-epoch");
});
it("parses representative sub_agent tool_call event", () => {
+67 -21
View File
@@ -844,8 +844,10 @@ export const ShutdownServerRequestMessageSchema = z.object({
export const AgentTimelineCursorSchema = z
.object({
seq: z.number().int().nonnegative(),
// COMPAT(timeline): retain legacy cursor epoch for older clients.
epoch: z.string().optional(),
})
.strict();
;
export const FetchAgentTimelineRequestMessageSchema = z
.object({
@@ -856,8 +858,10 @@ export const FetchAgentTimelineRequestMessageSchema = z
cursor: AgentTimelineCursorSchema.optional(),
// 0 means "all matching rows for this query window".
limit: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain removed projection so older clients can still send it.
projection: z.enum(["canonical", "projected"]).optional(),
})
.strict();
;
export const SetAgentModeRequestMessageSchema = z.object({
type: z.literal("set_agent_mode_request"),
@@ -1739,7 +1743,8 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
projectRootPath: z.string(),
workspaceDirectory: z.string(),
projectKind: z.enum(["git", "non_git", "directory"]),
workspaceKind: z.enum(["local_checkout", "checkout", "worktree"]),
// COMPAT(workspaces): keep legacy directory workspace kind parseable.
workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]),
name: z.string(),
status: WorkspaceStateBucketSchema,
activityAt: z.string().nullable(),
@@ -1771,17 +1776,17 @@ export const AgentUpdateMessageSchema = z.object({
export const AgentStreamMessageSchema = z
.object({
type: z.literal("agent_stream"),
payload: z
.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
// Present only for committed timeline events.
seq: z.number().int().nonnegative().optional(),
})
.strict(),
payload: z.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
// Present only for committed timeline events.
seq: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain removed epoch for older clients.
epoch: z.string().optional(),
}),
})
.strict();
;
export const AgentStatusMessageSchema = z.object({
type: z.literal("agent_status"),
@@ -1908,14 +1913,40 @@ export const FetchAgentResponseMessageSchema = z.object({
}),
});
const TimelineProjectionKindSchema = z.enum(["assistant_merge", "tool_lifecycle"]);
const TimelineSeqRangeSchema = z.object({
startSeq: z.number().int().nonnegative(),
endSeq: z.number().int().nonnegative(),
});
export const AgentTimelineEntryPayloadSchema = z
.object({
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
timestamp: z.string(),
seq: z.number().int().nonnegative(),
seq: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain legacy projected timeline fields.
seqStart: z.number().int().nonnegative().optional(),
seqEnd: z.number().int().nonnegative().optional(),
sourceSeqRanges: z.array(TimelineSeqRangeSchema).optional(),
collapsed: z.array(TimelineProjectionKindSchema).optional(),
})
.strict();
.transform((entry, ctx) => {
const seq = entry.seq ?? entry.seqEnd ?? entry.seqStart;
if (typeof seq !== "number") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Agent timeline entry must include seq or legacy seqStart/seqEnd",
});
return z.NEVER;
}
return {
...entry,
seq,
};
});
export const FetchAgentTimelineResponseMessageSchema = z
.object({
@@ -1926,16 +1957,31 @@ export const FetchAgentTimelineResponseMessageSchema = z
agentId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
direction: z.enum(["tail", "before", "after"]),
startSeq: z.number().int().nonnegative().nullable(),
endSeq: z.number().int().nonnegative().nullable(),
hasOlder: z.boolean(),
hasNewer: z.boolean(),
startSeq: z.number().int().nonnegative().nullable().optional(),
endSeq: z.number().int().nonnegative().nullable().optional(),
hasOlder: z.boolean().optional(),
hasNewer: z.boolean().optional(),
entries: z.array(AgentTimelineEntryPayloadSchema),
error: z.string().nullable(),
// COMPAT(timeline): retain legacy response baggage for older clients.
epoch: z.string().optional(),
reset: z.boolean().optional(),
staleCursor: z.boolean().optional(),
gap: z.unknown().optional(),
window: z.unknown().optional(),
startCursor: AgentTimelineCursorSchema.nullable().optional(),
endCursor: AgentTimelineCursorSchema.nullable().optional(),
projection: z.enum(["canonical", "projected"]).optional(),
})
.strict(),
.transform((payload) => ({
...payload,
startSeq: payload.startSeq ?? payload.startCursor?.seq ?? null,
endSeq: payload.endSeq ?? payload.endCursor?.seq ?? null,
hasOlder: payload.hasOlder ?? false,
hasNewer: payload.hasNewer ?? false,
})),
})
.strict();
;
export const SendAgentMessageResponseMessageSchema = z.object({
type: z.literal("send_agent_message_response"),
@@ -96,6 +96,35 @@ describe("workspace message schemas", () => {
]);
});
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,
services: [],
},
},
});
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 service_status_update payload", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "service_status_update",