mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Preserve keyed modern agent worktree creation while retiring legacy placement that selected an agent-owned setup lifecycle. Keep output in per-command workspace snapshots and retain empty strings required by existing wire schemas.
9685 lines
322 KiB
TypeScript
9685 lines
322 KiB
TypeScript
import {
|
|
createMessageReceiptsStub,
|
|
createTestCreationService,
|
|
} from "./test-utils/session-stubs.js";
|
|
import { execFileSync } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
realpathSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
|
import { afterEach, expect, test, vi } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
|
import { createTestLogger } from "../test-utils/test-logger.js";
|
|
import { Session } from "./session.js";
|
|
import type { SessionOptions } from "./session.js";
|
|
import { OWNER_PERMISSIONS } from "./authorization/index.js";
|
|
import type { AgentUpdatesService } from "./session/agent-updates/agent-updates-service.js";
|
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
|
import { createTerminalManager } from "../terminal/terminal-manager.js";
|
|
import { AgentManager, type AgentManagerEvent, type ManagedAgent } from "./agent/agent-manager.js";
|
|
import type { ProviderSubagentDescriptor } from "./agent/provider-subagents/store.js";
|
|
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
|
import type {
|
|
AgentClient,
|
|
AgentCreateSessionOptions,
|
|
AgentLaunchContext,
|
|
AgentPersistenceHandle,
|
|
AgentRunResult,
|
|
AgentSession,
|
|
AgentSessionConfig,
|
|
AgentStreamEvent,
|
|
} from "./agent/agent-sdk-types.js";
|
|
import { createWorktree } from "../utils/worktree.js";
|
|
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
|
import {
|
|
readPaseoWorktreeMetadata,
|
|
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
|
writePaseoWorktreeMetadata,
|
|
} from "../utils/worktree-metadata.js";
|
|
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
|
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
|
|
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
|
import type { ForgeService } from "../services/forge-service.js";
|
|
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
|
|
import { deriveProjectKey } from "./project-key.js";
|
|
import {
|
|
asSessionLogger,
|
|
asAgentManager,
|
|
asAgentStorage,
|
|
asDownloadTokenStore,
|
|
asPushNotifications,
|
|
asScheduleService,
|
|
asCheckoutDiffManager,
|
|
asDaemonConfigStore,
|
|
asTerminalManager,
|
|
asSessionInternals,
|
|
createProviderSnapshotManagerStub,
|
|
isSessionOutboundMessage,
|
|
filterByType,
|
|
findByType,
|
|
} from "./test-utils/session-stubs.js";
|
|
import {
|
|
FileBackedProjectRegistry,
|
|
FileBackedWorkspaceRegistry,
|
|
createPersistedProjectRecord,
|
|
createPersistedWorkspaceRecord,
|
|
type PersistedProjectRecord,
|
|
type PersistedWorkspaceRecord,
|
|
type WorkspaceMutation,
|
|
} from "./workspace-registry.js";
|
|
|
|
const REPO_CWD = path.resolve("/tmp/repo");
|
|
const UNREGISTERED_CWD = path.resolve("/tmp/unregistered");
|
|
|
|
const terminalManagers: TerminalManager[] = [];
|
|
|
|
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((resolvePromise) => {
|
|
resolve = resolvePromise;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
async function flushWorkspaceUpdateBackgroundWork(): Promise<void> {
|
|
await waitForImmediate();
|
|
}
|
|
|
|
afterEach(async () => {
|
|
while (terminalManagers.length > 0) {
|
|
const manager = terminalManagers.pop();
|
|
if (manager) {
|
|
manager.killAll();
|
|
}
|
|
}
|
|
await flushTerminalContributionWork();
|
|
});
|
|
|
|
interface SessionTestAccess {
|
|
projectRegistry: {
|
|
list(...args: unknown[]): Promise<unknown[]>;
|
|
archive(projectId: string, archivedAt: string): Promise<void>;
|
|
get(id: string): Promise<unknown>;
|
|
upsert(record: unknown): Promise<unknown>;
|
|
remove(projectId: string): Promise<void>;
|
|
};
|
|
agentStorage: {
|
|
list(...args: unknown[]): Promise<unknown[]>;
|
|
get(agentId: string): Promise<unknown>;
|
|
upsert(record: unknown): Promise<void>;
|
|
};
|
|
agentManager: {
|
|
listAgents(): unknown[];
|
|
getAgent(agentId: string): unknown;
|
|
reloadAgentSession(agentId: string, overrides?: unknown, options?: unknown): Promise<unknown>;
|
|
listImportableSessions(options?: unknown): Promise<{
|
|
sessions: unknown[];
|
|
providerErrors: Array<{ provider: string; message: string }>;
|
|
}>;
|
|
importProviderSession(input: unknown): Promise<unknown>;
|
|
resumeAgentFromPersistence(
|
|
handle: unknown,
|
|
overrides?: unknown,
|
|
preferredId?: string,
|
|
extras?: unknown,
|
|
): Promise<unknown>;
|
|
hydrateTimelineFromProvider(agentId: string): Promise<unknown>;
|
|
getTimeline(agentId: string): readonly unknown[];
|
|
setTitle(agentId: string, title: string): Promise<unknown>;
|
|
};
|
|
workspaceRegistry: {
|
|
list(...args: unknown[]): Promise<unknown[]>;
|
|
archive(workspaceId: string, archivedAt: string): Promise<void>;
|
|
get(workspaceId: string): Promise<unknown>;
|
|
update(
|
|
workspaceId: string,
|
|
updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord,
|
|
): Promise<unknown>;
|
|
upsert(record: unknown): Promise<unknown>;
|
|
};
|
|
agentUpdates: AgentUpdatesService;
|
|
interruptAgentIfRunning(agentId: string): unknown;
|
|
reconcileWorkspaceRecord(workspaceId: string): Promise<{
|
|
changed: boolean;
|
|
workspace?: Record<string, unknown> | null;
|
|
removedWorkspaceId?: string | null;
|
|
[key: string]: unknown;
|
|
}>;
|
|
handleArchiveAgentRequest(agentId: string, requestId: string): Promise<unknown>;
|
|
handleMessage(message: unknown, source?: object): Promise<unknown>;
|
|
handleCreatePaseoWorktreeRequest(params: unknown): Promise<unknown>;
|
|
listAgentPayloads(...args: unknown[]): Promise<unknown[]>;
|
|
listFetchWorkspacesEntries(params: unknown): Promise<ListFetchResult>;
|
|
listFetchAgentsEntries(params: unknown): Promise<ListFetchResult>;
|
|
resolveAgentIdentifier(identifier: string): Promise<unknown>;
|
|
getAgentPayloadById(agentId: string): Promise<unknown>;
|
|
buildProjectPlacementForWorkspaceId(workspaceId: string): Promise<unknown>;
|
|
buildProjectPlacement(cwd: string): Promise<unknown>;
|
|
buildWorkspaceDescriptorMap(...args: unknown[]): Promise<Map<string, unknown>>;
|
|
describeWorkspaceRecord(...args: unknown[]): Promise<unknown>;
|
|
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<unknown>;
|
|
markWorkspaceArchiving(workspaceIds: Iterable<string>, archivingAt: string): void;
|
|
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void;
|
|
emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>;
|
|
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
|
|
emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void>;
|
|
updateClientCapabilities(capabilities: Record<string, unknown> | null, source?: object): void;
|
|
publish: Session["publish"];
|
|
emit(message: unknown): void;
|
|
onMessage(message: unknown): void;
|
|
paseoHome: string;
|
|
terminalManager: {
|
|
killTerminal(id: string): unknown;
|
|
clearTerminalAttention?(id: string): Promise<boolean>;
|
|
} | null;
|
|
workspaceGitService: {
|
|
getCheckout: (cwd: string) => Promise<unknown>;
|
|
getSnapshot: (cwd: string, options?: unknown) => Promise<WorkspaceGitRuntimeSnapshot>;
|
|
peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null;
|
|
registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void };
|
|
};
|
|
filesystem: {
|
|
isDirectory(cwd: string): Promise<boolean>;
|
|
};
|
|
}
|
|
|
|
interface ListFetchResult {
|
|
entries: Array<Record<string, unknown>>;
|
|
pageInfo: Record<string, unknown>;
|
|
nextCursor?: string | null;
|
|
total?: number;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
type TestSession = SessionTestAccess;
|
|
|
|
function asTestSession(session: Session | TestSession): TestSession {
|
|
return asSessionInternals<TestSession>(session);
|
|
}
|
|
|
|
type AgentUpdatesSubscriptionFilter = Parameters<
|
|
AgentUpdatesService["beginSubscription"]
|
|
>[0]["filter"];
|
|
|
|
async function activateAgentUpdatesSubscription(
|
|
session: TestSession,
|
|
subscriptionId: string,
|
|
filter?: AgentUpdatesSubscriptionFilter,
|
|
): Promise<void> {
|
|
await session.handleMessage({
|
|
type: "fetch_agents_request",
|
|
requestId: subscriptionId,
|
|
filter,
|
|
subscribe: { subscriptionId },
|
|
});
|
|
}
|
|
|
|
const AgentIdEntrySchema = z.object({ agent: z.object({ id: z.string() }) });
|
|
|
|
function makeAgent(input: {
|
|
id: string;
|
|
cwd: string;
|
|
workspaceId?: string;
|
|
status: AgentSnapshotPayload["status"];
|
|
updatedAt: string;
|
|
pendingPermissions?: number;
|
|
requiresAttention?: boolean;
|
|
attentionReason?: AgentSnapshotPayload["attentionReason"];
|
|
attentionTimestamp?: string | null;
|
|
labels?: Record<string, string>;
|
|
}): AgentSnapshotPayload {
|
|
const pendingPermissionCount = input.pendingPermissions ?? 0;
|
|
return {
|
|
id: input.id,
|
|
provider: "codex",
|
|
cwd: input.cwd,
|
|
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
|
model: null,
|
|
thinkingOptionId: null,
|
|
effectiveThinkingOptionId: null,
|
|
createdAt: input.updatedAt,
|
|
updatedAt: input.updatedAt,
|
|
lastUserMessageAt: null,
|
|
status: input.status,
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: Array.from({ length: pendingPermissionCount }, (_, index) => ({
|
|
id: `perm-${input.id}-${index}`,
|
|
provider: "codex",
|
|
name: "tool",
|
|
kind: "tool",
|
|
})),
|
|
persistence: null,
|
|
runtimeInfo: {
|
|
provider: "codex",
|
|
sessionId: null,
|
|
},
|
|
title: null,
|
|
labels: input.labels ?? {},
|
|
requiresAttention: input.requiresAttention ?? false,
|
|
attentionReason: input.attentionReason ?? null,
|
|
attentionTimestamp: input.attentionTimestamp ?? null,
|
|
archivedAt: null,
|
|
};
|
|
}
|
|
|
|
function makeStoredAgent(input: {
|
|
id: string;
|
|
cwd: string;
|
|
updatedAt: string;
|
|
requiresAttention?: boolean;
|
|
attentionReason?: StoredAgentRecord["attentionReason"];
|
|
workspaceId?: string;
|
|
labels?: Record<string, string>;
|
|
}): StoredAgentRecord {
|
|
return {
|
|
id: input.id,
|
|
provider: "codex",
|
|
cwd: input.cwd,
|
|
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
|
createdAt: input.updatedAt,
|
|
updatedAt: input.updatedAt,
|
|
lastActivityAt: input.updatedAt,
|
|
lastUserMessageAt: null,
|
|
title: null,
|
|
labels: input.labels ?? {},
|
|
lastStatus: "closed",
|
|
lastModeId: null,
|
|
config: { provider: "codex", cwd: input.cwd },
|
|
runtimeInfo: { provider: "codex", sessionId: null },
|
|
features: [],
|
|
persistence: null,
|
|
lastError: null,
|
|
requiresAttention: input.requiresAttention ?? false,
|
|
attentionReason: input.attentionReason ?? null,
|
|
attentionTimestamp: input.requiresAttention ? input.updatedAt : null,
|
|
internal: false,
|
|
archivedAt: null,
|
|
};
|
|
}
|
|
|
|
function makeManagedAgent(input: {
|
|
id: string;
|
|
cwd: string;
|
|
workspaceId?: string;
|
|
lifecycle: AgentSnapshotPayload["status"];
|
|
updatedAt: string;
|
|
activeTurn?: { turnId: string; startedAt: string };
|
|
}) {
|
|
const now = new Date(input.updatedAt);
|
|
const snapshot = makeAgent({
|
|
id: input.id,
|
|
cwd: input.cwd,
|
|
workspaceId: input.workspaceId,
|
|
status: input.lifecycle,
|
|
updatedAt: input.updatedAt,
|
|
});
|
|
|
|
return {
|
|
...snapshot,
|
|
lifecycle: snapshot.status,
|
|
config: {
|
|
provider: snapshot.provider,
|
|
cwd: snapshot.cwd,
|
|
},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
pendingPermissions: new Map(),
|
|
bufferedPermissionResolutions: new Map(),
|
|
inFlightPermissionResponses: new Set(),
|
|
pendingReplacement: false,
|
|
persistence: null,
|
|
historyPrimed: true,
|
|
lastUserMessageAt: null,
|
|
attention: {
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
attentionTimestamp: now,
|
|
},
|
|
foregroundTurnWaiters: new Set(),
|
|
unsubscribeSession: null,
|
|
session: null,
|
|
activeForegroundTurnId: input.lifecycle === "running" ? "turn-1" : null,
|
|
activeTurnId: input.activeTurn?.turnId ?? null,
|
|
activeTurnStartedAt: input.activeTurn ? new Date(input.activeTurn.startedAt) : null,
|
|
};
|
|
}
|
|
|
|
function makeImportableProviderSession(input: {
|
|
provider: string;
|
|
sessionId: string;
|
|
nativeHandle?: string;
|
|
cwd: string;
|
|
title?: string | null;
|
|
lastActivityAt: string;
|
|
firstPrompt?: string;
|
|
}): {
|
|
provider: string;
|
|
providerHandleId: string;
|
|
cwd: string;
|
|
title: string | null;
|
|
firstPromptPreview: string | null;
|
|
lastPromptPreview: string | null;
|
|
lastActivityAt: Date;
|
|
} {
|
|
return {
|
|
provider: input.provider,
|
|
providerHandleId: input.nativeHandle ?? input.sessionId,
|
|
cwd: input.cwd,
|
|
title: input.title ?? null,
|
|
firstPromptPreview: input.firstPrompt ?? null,
|
|
lastPromptPreview: input.firstPrompt ?? null,
|
|
lastActivityAt: new Date(input.lastActivityAt),
|
|
};
|
|
}
|
|
|
|
function agentIdsFromEntries(entries: Array<Record<string, unknown>>) {
|
|
return entries.map((entry) => AgentIdEntrySchema.parse(entry).agent.id);
|
|
}
|
|
|
|
function createWorkspaceRuntimeSnapshot(
|
|
cwd: string,
|
|
overrides?: {
|
|
git?: Partial<WorkspaceGitRuntimeSnapshot["git"]>;
|
|
forge?: Partial<WorkspaceGitRuntimeSnapshot["forge"]>;
|
|
},
|
|
): WorkspaceGitRuntimeSnapshot {
|
|
const base: WorkspaceGitRuntimeSnapshot = {
|
|
cwd,
|
|
git: {
|
|
isGit: true,
|
|
repoRoot: cwd,
|
|
mainRepoRoot: null,
|
|
currentBranch: "main",
|
|
remoteUrl: "https://github.com/acme/repo.git",
|
|
isPaseoOwnedWorktree: false,
|
|
isDirty: false,
|
|
baseRef: "main",
|
|
aheadBehind: { ahead: 0, behind: 0 },
|
|
aheadOfOrigin: 0,
|
|
behindOfOrigin: 0,
|
|
hasRemote: true,
|
|
diffStat: { additions: 1, deletions: 0 },
|
|
},
|
|
forge: {
|
|
featuresEnabled: true,
|
|
pullRequest: {
|
|
url: "https://github.com/acme/repo/pull/123",
|
|
title: "Runtime payloads",
|
|
state: "open",
|
|
baseRefName: "main",
|
|
headRefName: "feature/runtime-payloads",
|
|
isMerged: false,
|
|
},
|
|
error: null,
|
|
},
|
|
};
|
|
|
|
return {
|
|
cwd,
|
|
git: {
|
|
...base.git,
|
|
...overrides?.git,
|
|
},
|
|
forge: {
|
|
...base.forge,
|
|
...overrides?.forge,
|
|
pullRequest:
|
|
overrides?.forge && "pullRequest" in overrides.forge
|
|
? (overrides.forge.pullRequest ?? null)
|
|
: base.forge.pullRequest,
|
|
error:
|
|
overrides?.forge && "error" in overrides.forge
|
|
? (overrides.forge.error ?? null)
|
|
: base.forge.error,
|
|
},
|
|
};
|
|
}
|
|
|
|
const CREATE_AGENT_TEST_CAPABILITIES = {
|
|
supportsStreaming: false,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: false,
|
|
supportsMcpServers: false,
|
|
supportsReasoningStream: false,
|
|
supportsToolInvocations: false,
|
|
} as const;
|
|
|
|
class CreateAgentTestSession implements AgentSession {
|
|
readonly provider = "codex";
|
|
readonly id = "create-agent-test-session";
|
|
readonly capabilities = CREATE_AGENT_TEST_CAPABILITIES;
|
|
|
|
constructor(private readonly config: AgentSessionConfig) {}
|
|
|
|
async run(): Promise<AgentRunResult> {
|
|
return { sessionId: this.id, finalText: "", timeline: [] };
|
|
}
|
|
|
|
async startTurn(): Promise<{ turnId: string }> {
|
|
return { turnId: "turn-1" };
|
|
}
|
|
|
|
subscribe(): () => void {
|
|
return () => {};
|
|
}
|
|
|
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
|
|
|
async getRuntimeInfo() {
|
|
return {
|
|
provider: this.provider,
|
|
sessionId: this.id,
|
|
model: this.config.model ?? null,
|
|
modeId: this.config.modeId ?? null,
|
|
};
|
|
}
|
|
|
|
async getAvailableModes() {
|
|
return [];
|
|
}
|
|
|
|
async getCurrentMode() {
|
|
return null;
|
|
}
|
|
|
|
async setMode(): Promise<void> {}
|
|
|
|
getPendingPermissions() {
|
|
return [];
|
|
}
|
|
|
|
async respondToPermission(): Promise<void> {}
|
|
|
|
describePersistence(): AgentPersistenceHandle {
|
|
return { provider: this.provider, sessionId: this.id };
|
|
}
|
|
|
|
async interrupt(): Promise<void> {}
|
|
|
|
async close(): Promise<void> {}
|
|
}
|
|
|
|
class CreateAgentTestClient implements AgentClient {
|
|
readonly provider = "codex";
|
|
readonly capabilities = CREATE_AGENT_TEST_CAPABILITIES;
|
|
|
|
async createSession(
|
|
config: AgentSessionConfig,
|
|
_launchContext?: AgentLaunchContext,
|
|
_options?: AgentCreateSessionOptions,
|
|
): Promise<AgentSession> {
|
|
return new CreateAgentTestSession(config);
|
|
}
|
|
|
|
async resumeSession(
|
|
_handle: AgentPersistenceHandle,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
): Promise<AgentSession> {
|
|
return new CreateAgentTestSession({
|
|
provider: this.provider,
|
|
cwd: overrides?.cwd ?? process.cwd(),
|
|
});
|
|
}
|
|
|
|
async fetchCatalog() {
|
|
return {
|
|
models: [{ provider: this.provider, id: "gpt-test", label: "GPT Test", isDefault: true }],
|
|
modes: [],
|
|
};
|
|
}
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function createSessionForWorkspaceTests(
|
|
options: {
|
|
appVersion?: string | null;
|
|
onMessage?: (message: SessionOutboundMessage) => void;
|
|
onWorkspaceRecovered?: SessionOptions["onWorkspaceRecovered"];
|
|
workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>;
|
|
terminalManager?: TerminalManager | null;
|
|
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
|
|
agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown };
|
|
projectRegistry?: SessionOptions["projectRegistry"];
|
|
workspaceRegistry?: SessionOptions["workspaceRegistry"];
|
|
github?: ForgeService;
|
|
paseoHome?: string;
|
|
worktreesRoot?: string;
|
|
renameCurrentBranch?: (
|
|
cwd: string,
|
|
newName: string,
|
|
) => Promise<{ previousBranch: string | null; currentBranch: string | null }>;
|
|
generateWorkspaceName?: () => Promise<GeneratedWorkspaceName | null>;
|
|
} = {},
|
|
): TestSession {
|
|
const logger = {
|
|
child: () => logger,
|
|
trace: vi.fn(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
const agentManager = asAgentManager({
|
|
subscribe: () => () => {},
|
|
listAgents: () => [],
|
|
listProviderSubagentActivity: () => [],
|
|
getAgent: () => null,
|
|
archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
|
|
archiveSnapshot: async () => ({}),
|
|
unarchiveSnapshot: async () => true,
|
|
clearAgentAttention: async () => {},
|
|
markAgentUnread: async () => {},
|
|
notifyAgentState: () => {},
|
|
...options.agentManager,
|
|
});
|
|
const workspaceRegistry: SessionOptions["workspaceRegistry"] = options.workspaceRegistry ?? {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-running",
|
|
projectId: "proj-repo-running",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
],
|
|
get: async (workspaceId: string) =>
|
|
workspaceId === "ws-repo-running"
|
|
? createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-running",
|
|
projectId: "proj-repo-running",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
})
|
|
: null,
|
|
update: async (workspaceId, updater) => {
|
|
if (workspaceId !== "ws-repo-running") {
|
|
return null;
|
|
}
|
|
return updater(
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-running",
|
|
projectId: "proj-repo-running",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
);
|
|
},
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
const workspaceGitService = options.workspaceGitService ?? createNoopWorkspaceGitService();
|
|
const providerSnapshotManager = createProviderSnapshotManagerStub().manager;
|
|
|
|
const session = asTestSession(
|
|
new Session({
|
|
messageReceipts: createMessageReceiptsStub(),
|
|
creationService: createTestCreationService(),
|
|
clientId: "test-client",
|
|
permissions: OWNER_PERMISSIONS,
|
|
appVersion: options.appVersion ?? null,
|
|
onMessage: options.onMessage ?? vi.fn(),
|
|
onWorkspaceRecovered: options.onWorkspaceRecovered,
|
|
logger: asSessionLogger(logger),
|
|
downloadTokenStore: asDownloadTokenStore(),
|
|
pushNotifications: asPushNotifications(),
|
|
paseoHome: options.paseoHome ?? "/tmp/paseo-test",
|
|
worktreesRoot: options.worktreesRoot,
|
|
agentManager,
|
|
agentStorage: asAgentStorage({
|
|
list: async () => [
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-running",
|
|
projectId: "proj-repo-running",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
],
|
|
get: async (workspaceId: string) =>
|
|
workspaceId === "ws-repo-running"
|
|
? createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-running",
|
|
projectId: "proj-repo-running",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
})
|
|
: null,
|
|
upsert: async () => {},
|
|
...options.agentStorage,
|
|
}),
|
|
projectRegistry: options.projectRegistry ?? {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [],
|
|
get: async () => null,
|
|
getOrCreateActiveByRoot: async (input) =>
|
|
createPersistedProjectRecord({
|
|
projectId: "prj_0000000000000000",
|
|
rootPath: input.rootPath,
|
|
kind: input.kind,
|
|
displayName: input.displayName,
|
|
createdAt: input.timestamp,
|
|
updatedAt: input.timestamp,
|
|
}),
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
},
|
|
workspaceRegistry,
|
|
filesystem: { isDirectory: async () => true },
|
|
scheduleService: asScheduleService(),
|
|
checkoutDiffManager: asCheckoutDiffManager({
|
|
subscribe: async () => ({
|
|
initial: { cwd: "/tmp", files: [], error: null },
|
|
unsubscribe: () => {},
|
|
}),
|
|
scheduleRefreshForCwd: () => {},
|
|
onWorkspaceStateMayHaveChanged: () => {},
|
|
invalidateForge: () => {},
|
|
getMetrics: () => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
}),
|
|
dispose: () => {},
|
|
}),
|
|
github: options.github,
|
|
workspaceGitService,
|
|
workspaceAutoName: new WorkspaceAutoName({
|
|
agentManager,
|
|
workspaceRegistry,
|
|
workspaceGitService,
|
|
providerSnapshotManager,
|
|
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
|
|
gitMutation: { notifyGitMutation: async () => {} },
|
|
emitWorkspaceUpdateForCwd: async () => {},
|
|
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
|
logger: asSessionLogger(logger),
|
|
generateWorkspaceName: options.generateWorkspaceName,
|
|
}),
|
|
renameCurrentBranch: options.renameCurrentBranch,
|
|
daemonConfigStore: asDaemonConfigStore({
|
|
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
|
onChange: () => () => {},
|
|
}),
|
|
mcpBaseUrl: null,
|
|
stt: null,
|
|
tts: null,
|
|
providerSnapshotManager,
|
|
terminalManager: options.terminalManager ?? null,
|
|
}),
|
|
);
|
|
return session;
|
|
}
|
|
|
|
test("project.list.request catches up by sequence on the existing RPC", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
let project = createPersistedProjectRecord({
|
|
projectId: "project-sequenced",
|
|
rootPath: "/repo/sequenced",
|
|
kind: "git",
|
|
displayName: "Before",
|
|
createdAt: "2026-08-12T08:00:00.000Z",
|
|
updatedAt: "2026-08-12T08:00:00.000Z",
|
|
});
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
projectRegistry: {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [project],
|
|
get: async () => project,
|
|
getOrCreateActiveByRoot: async () => project,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "project.list.request",
|
|
requestId: "initial",
|
|
sync: {},
|
|
});
|
|
const initial = findByType(emitted, "project.list.response")?.payload;
|
|
expect(initial?.sync).toMatchObject({ mode: "snapshot", headSeq: 1 });
|
|
|
|
project = { ...project, displayName: "After", updatedAt: "2026-08-12T08:01:00.000Z" };
|
|
await session.handleMessage({
|
|
type: "project.list.request",
|
|
requestId: "catch-up",
|
|
sync: {
|
|
generation: initial?.sync?.generation,
|
|
afterSeq: initial?.sync?.headSeq,
|
|
},
|
|
});
|
|
const responses = filterByType(emitted, "project.list.response");
|
|
expect(responses.at(-1)?.payload).toMatchObject({
|
|
requestId: "catch-up",
|
|
projects: [{ projectId: "project-sequenced", projectDisplayName: "After", syncSeq: 2 }],
|
|
sync: { mode: "changes", headSeq: 2, removals: [] },
|
|
});
|
|
});
|
|
|
|
test("agent updates preserve queued live transitions across stored metadata reads", async () => {
|
|
const running = makeManagedAgent({
|
|
id: "agent-coherent",
|
|
cwd: REPO_CWD,
|
|
workspaceId: "ws-repo-running",
|
|
lifecycle: "running",
|
|
updatedAt: "2026-07-31T10:00:01.000Z",
|
|
activeTurn: {
|
|
turnId: "turn-running",
|
|
startedAt: "2026-07-31T10:00:00.500Z",
|
|
},
|
|
}) as unknown as ManagedAgent;
|
|
const idle = makeManagedAgent({
|
|
id: "agent-coherent",
|
|
cwd: REPO_CWD,
|
|
workspaceId: "ws-repo-running",
|
|
lifecycle: "idle",
|
|
updatedAt: "2026-07-31T10:00:02.000Z",
|
|
}) as unknown as ManagedAgent;
|
|
running.config.model = "running-model";
|
|
idle.config = running.config;
|
|
const stored = makeStoredAgent({
|
|
id: "agent-coherent",
|
|
cwd: REPO_CWD,
|
|
updatedAt: "2026-07-31T10:00:03.000Z",
|
|
});
|
|
stored.title = "Stored title";
|
|
const storageReadStarted = deferred<void>();
|
|
const firstStorageRead = deferred<StoredAgentRecord | null>();
|
|
const emittedAgentUpdates: Extract<
|
|
SessionOutboundMessage,
|
|
{ type: "agent_update" }
|
|
>["payload"][] = [];
|
|
const twoUpdatesEmitted = deferred<void>();
|
|
let storageReadCount = 0;
|
|
let forwardAgentEvent: ((event: AgentManagerEvent) => void) | null = null;
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => {
|
|
if (message.type !== "agent_update") return;
|
|
emittedAgentUpdates.push(message.payload);
|
|
if (emittedAgentUpdates.length === 2) twoUpdatesEmitted.resolve();
|
|
},
|
|
agentManager: {
|
|
subscribe: (listener: (event: AgentManagerEvent) => void) => {
|
|
forwardAgentEvent = listener;
|
|
return () => {};
|
|
},
|
|
getAgent: () => idle,
|
|
},
|
|
agentStorage: {
|
|
get: async () => {
|
|
storageReadCount += 1;
|
|
if (storageReadCount === 1) {
|
|
storageReadStarted.resolve();
|
|
return firstStorageRead.promise;
|
|
}
|
|
return stored;
|
|
},
|
|
},
|
|
});
|
|
session.projectRegistry.get = async () =>
|
|
createPersistedProjectRecord({
|
|
projectId: "proj-repo-running",
|
|
rootPath: REPO_CWD,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-07-31T10:00:00.000Z",
|
|
updatedAt: "2026-07-31T10:00:00.000Z",
|
|
});
|
|
await activateAgentUpdatesSubscription(session, "sub-coherent");
|
|
if (!forwardAgentEvent) throw new Error("Agent event listener was not installed");
|
|
|
|
forwardAgentEvent({ type: "agent_state", agent: running });
|
|
await storageReadStarted.promise;
|
|
idle.config.model = "idle-model";
|
|
forwardAgentEvent({ type: "agent_state", agent: idle });
|
|
idle.config.model = "mutated-after-queue";
|
|
firstStorageRead.resolve(stored);
|
|
await twoUpdatesEmitted.promise;
|
|
|
|
expect(emittedAgentUpdates).toEqual([
|
|
expect.objectContaining({
|
|
kind: "upsert",
|
|
agent: expect.objectContaining({
|
|
status: "running",
|
|
activeTurn: {
|
|
turnId: "turn-running",
|
|
startedAt: "2026-07-31T10:00:00.500Z",
|
|
},
|
|
updatedAt: "2026-07-31T10:00:01.000Z",
|
|
model: "running-model",
|
|
title: "Stored title",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "upsert",
|
|
agent: expect.objectContaining({
|
|
status: "idle",
|
|
activeTurn: null,
|
|
updatedAt: "2026-07-31T10:00:02.000Z",
|
|
model: "idle-model",
|
|
title: "Stored title",
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
test("client heartbeat clears attention for the focused terminal", async () => {
|
|
const clearedTerminalIds: string[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
terminalManager: asTerminalManager({
|
|
subscribeTerminalsChanged: () => () => {},
|
|
clearTerminalAttention: async (terminalId: string) => {
|
|
clearedTerminalIds.push(terminalId);
|
|
return true;
|
|
},
|
|
}),
|
|
});
|
|
|
|
const source = {};
|
|
await session.handleMessage(
|
|
{
|
|
type: "client_heartbeat",
|
|
deviceType: "web",
|
|
focusedAgentId: null,
|
|
focusedTerminalId: "terminal-1",
|
|
lastActivityAt: "2026-06-13T12:00:00.000Z",
|
|
appVisible: true,
|
|
},
|
|
source,
|
|
);
|
|
|
|
expect(clearedTerminalIds).toEqual(["terminal-1"]);
|
|
expect(session.getClientActivity(source)).toMatchObject({
|
|
focusedAgentId: null,
|
|
focusedTerminalId: "terminal-1",
|
|
appVisible: true,
|
|
});
|
|
});
|
|
|
|
test("create_agent_request keeps requested child cwd when grouped under an existing parent workspace", async () => {
|
|
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-"));
|
|
try {
|
|
const parent = path.join(workdir, "parent");
|
|
const child = path.join(parent, "child");
|
|
mkdirSync(child, { recursive: true });
|
|
|
|
const logger = {
|
|
child: () => logger,
|
|
trace: vi.fn(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger));
|
|
const agentManager = new AgentManager({
|
|
clients: { codex: new CreateAgentTestClient() },
|
|
registry: agentStorage,
|
|
logger: asSessionLogger(logger),
|
|
idFactory: () => "00000000-0000-4000-8000-000000000551",
|
|
});
|
|
const projectRegistry = new FileBackedProjectRegistry(
|
|
path.join(workdir, "projects.json"),
|
|
asSessionLogger(logger),
|
|
);
|
|
const workspaceRegistry = new FileBackedWorkspaceRegistry(
|
|
path.join(workdir, "workspaces.json"),
|
|
asSessionLogger(logger),
|
|
);
|
|
const workspaceGitService = createNoopWorkspaceGitService({
|
|
getCheckout: async (cwd: string) => ({
|
|
cwd,
|
|
isGit: true,
|
|
currentBranch: "main",
|
|
remoteUrl: null,
|
|
worktreeRoot: parent,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
}),
|
|
});
|
|
|
|
await projectRegistry.upsert(
|
|
createPersistedProjectRecord({
|
|
projectId: "proj-parent",
|
|
rootPath: parent,
|
|
kind: "git",
|
|
displayName: "parent",
|
|
createdAt: "2026-05-07T00:00:00.000Z",
|
|
updatedAt: "2026-05-07T00:00:00.000Z",
|
|
}),
|
|
);
|
|
await workspaceRegistry.upsert(
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-parent",
|
|
projectId: "proj-parent",
|
|
cwd: parent,
|
|
kind: "local_checkout",
|
|
displayName: "parent",
|
|
createdAt: "2026-05-07T00:00:00.000Z",
|
|
updatedAt: "2026-05-07T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = asTestSession(
|
|
new Session({
|
|
messageReceipts: createMessageReceiptsStub(),
|
|
creationService: createTestCreationService(),
|
|
clientId: "test-client",
|
|
serverId: "test-server",
|
|
permissions: OWNER_PERMISSIONS,
|
|
appVersion: null,
|
|
onMessage: (message) => emitted.push(message),
|
|
logger: asSessionLogger(logger),
|
|
downloadTokenStore: asDownloadTokenStore(),
|
|
pushNotifications: asPushNotifications(),
|
|
paseoHome: path.join(workdir, "paseo-home"),
|
|
agentManager,
|
|
agentStorage,
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
scheduleService: asScheduleService(),
|
|
checkoutDiffManager: asCheckoutDiffManager({
|
|
subscribe: async () => ({
|
|
initial: { cwd: child, files: [], error: null },
|
|
unsubscribe: () => {},
|
|
}),
|
|
scheduleRefreshForCwd: () => {},
|
|
onWorkspaceStateMayHaveChanged: () => {},
|
|
invalidateForge: () => {},
|
|
getMetrics: () => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
}),
|
|
dispose: () => {},
|
|
}),
|
|
workspaceGitService,
|
|
daemonConfigStore: asDaemonConfigStore({
|
|
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
|
onChange: () => () => {},
|
|
}),
|
|
mcpBaseUrl: null,
|
|
stt: null,
|
|
tts: null,
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
terminalManager: null,
|
|
}),
|
|
);
|
|
await session.handleMessage({
|
|
type: "create_agent_request",
|
|
requestId: "req-create-child",
|
|
config: { provider: "codex", cwd: child },
|
|
attachments: [],
|
|
});
|
|
|
|
const [createdAgent] = agentManager.listAgents();
|
|
expect(createdAgent?.cwd).toBe(child);
|
|
const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!);
|
|
expect(createdWorkspace).not.toBeNull();
|
|
await expect(projectRegistry.get(createdWorkspace!.projectId)).resolves.toMatchObject({
|
|
projectKey: deriveProjectKey({
|
|
rootPath: child,
|
|
remoteUrl: null,
|
|
worktreeRoot: null,
|
|
mainRepoRoot: null,
|
|
serverId: "test-server",
|
|
}),
|
|
});
|
|
await expect(
|
|
session.buildProjectPlacementForWorkspaceId(createdAgent!.workspaceId!),
|
|
).resolves.toMatchObject({
|
|
projectKey: createdWorkspace!.projectId,
|
|
checkout: { cwd: child },
|
|
});
|
|
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
|
status: "agent_created",
|
|
agent: { cwd: child },
|
|
});
|
|
} finally {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("create_agent_request launches from an exact subdirectory in a created worktree", async () => {
|
|
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-worktree-cwd-"));
|
|
try {
|
|
const parent = path.join(workdir, "parent");
|
|
const child = path.join(parent, "packages", "app");
|
|
mkdirSync(child, { recursive: true });
|
|
execFileSync("git", ["init", "-b", "main"], { cwd: parent, stdio: "pipe" });
|
|
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
|
|
cwd: parent,
|
|
stdio: "pipe",
|
|
});
|
|
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: parent, stdio: "pipe" });
|
|
writeFileSync(path.join(child, "README.md"), "app\n");
|
|
execFileSync("git", ["add", "."], { cwd: parent, stdio: "pipe" });
|
|
execFileSync("git", ["commit", "-m", "initial"], { cwd: parent, stdio: "pipe" });
|
|
|
|
const logger = {
|
|
child: () => logger,
|
|
trace: vi.fn(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger));
|
|
const agentManager = new AgentManager({
|
|
clients: { codex: new CreateAgentTestClient() },
|
|
registry: agentStorage,
|
|
logger: asSessionLogger(logger),
|
|
idFactory: () => "00000000-0000-4000-8000-000000000552",
|
|
});
|
|
const projectRegistry = new FileBackedProjectRegistry(
|
|
path.join(workdir, "projects.json"),
|
|
asSessionLogger(logger),
|
|
);
|
|
const workspaceRegistry = new FileBackedWorkspaceRegistry(
|
|
path.join(workdir, "workspaces.json"),
|
|
asSessionLogger(logger),
|
|
);
|
|
const workspaceGitService = createNoopWorkspaceGitService({
|
|
getCheckout: async (cwd: string) => ({
|
|
cwd,
|
|
isGit: true,
|
|
currentBranch: "main",
|
|
remoteUrl: null,
|
|
worktreeRoot: parent,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
}),
|
|
resolveRepoRoot: async () => parent,
|
|
resolveDefaultBranch: async () => "main",
|
|
});
|
|
await projectRegistry.upsert(
|
|
createPersistedProjectRecord({
|
|
projectId: "proj-parent",
|
|
rootPath: parent,
|
|
kind: "git",
|
|
displayName: "parent",
|
|
createdAt: "2026-05-07T00:00:00.000Z",
|
|
updatedAt: "2026-05-07T00:00:00.000Z",
|
|
}),
|
|
);
|
|
await workspaceRegistry.upsert(
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-parent",
|
|
projectId: "proj-parent",
|
|
cwd: parent,
|
|
kind: "local_checkout",
|
|
displayName: "parent",
|
|
createdAt: "2026-05-07T00:00:00.000Z",
|
|
updatedAt: "2026-05-07T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = new Session({
|
|
messageReceipts: createMessageReceiptsStub(),
|
|
creationService: createTestCreationService(),
|
|
clientId: "test-client",
|
|
permissions: OWNER_PERMISSIONS,
|
|
appVersion: null,
|
|
onMessage: (message) => emitted.push(message),
|
|
logger: asSessionLogger(logger),
|
|
downloadTokenStore: asDownloadTokenStore(),
|
|
pushNotifications: asPushNotifications(),
|
|
paseoHome: path.join(workdir, "paseo-home"),
|
|
agentManager,
|
|
agentStorage,
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
scheduleService: asScheduleService(),
|
|
checkoutDiffManager: asCheckoutDiffManager({
|
|
subscribe: async () => ({
|
|
initial: { cwd: child, files: [], error: null },
|
|
unsubscribe: () => {},
|
|
}),
|
|
scheduleRefreshForCwd: () => {},
|
|
onWorkspaceStateMayHaveChanged: () => {},
|
|
getMetrics: () => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
}),
|
|
dispose: () => {},
|
|
}),
|
|
workspaceGitService,
|
|
workspaceAutoName: new WorkspaceAutoName({
|
|
agentManager,
|
|
workspaceRegistry,
|
|
workspaceGitService,
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
|
|
gitMutation: { notifyGitMutation: async () => {} },
|
|
emitWorkspaceUpdateForCwd: async () => {},
|
|
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
|
logger: asSessionLogger(logger),
|
|
}),
|
|
daemonConfigStore: asDaemonConfigStore({
|
|
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
|
onChange: () => () => {},
|
|
}),
|
|
mcpBaseUrl: null,
|
|
stt: null,
|
|
tts: null,
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
terminalManager: null,
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "create_agent_request",
|
|
requestId: "req-create-worktree-child",
|
|
config: { provider: "codex", cwd: child },
|
|
attachments: [],
|
|
worktree: { mode: "branch-off", newBranch: "feature/created-worktree" },
|
|
});
|
|
|
|
const [createdAgent] = agentManager.listAgents();
|
|
const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
cwd: createdAgent!.cwd,
|
|
stdio: "pipe",
|
|
})
|
|
.toString()
|
|
.trim();
|
|
expect(
|
|
createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))(
|
|
createdAgent?.cwd ?? "",
|
|
),
|
|
).toBe(true);
|
|
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
|
status: "agent_created",
|
|
agent: { cwd: createdAgent?.cwd },
|
|
});
|
|
} finally {
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("create_agent_request does not title an existing workspace from the agent prompt", async () => {
|
|
vi.useFakeTimers();
|
|
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-existing-title-"));
|
|
try {
|
|
const cwd = path.join(workdir, "repo");
|
|
mkdirSync(cwd, { recursive: true });
|
|
|
|
const logger = {
|
|
child: () => logger,
|
|
trace: vi.fn(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger));
|
|
const agentManager = new AgentManager({
|
|
clients: { codex: new CreateAgentTestClient() },
|
|
registry: agentStorage,
|
|
logger: asSessionLogger(logger),
|
|
idFactory: () => "00000000-0000-4000-8000-000000000552",
|
|
});
|
|
const projectRegistry = new FileBackedProjectRegistry(
|
|
path.join(workdir, "projects.json"),
|
|
asSessionLogger(logger),
|
|
);
|
|
const workspaceRegistry = new FileBackedWorkspaceRegistry(
|
|
path.join(workdir, "workspaces.json"),
|
|
asSessionLogger(logger),
|
|
);
|
|
|
|
await projectRegistry.upsert(
|
|
createPersistedProjectRecord({
|
|
projectId: "proj-existing",
|
|
rootPath: cwd,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-05-07T00:00:00.000Z",
|
|
updatedAt: "2026-05-07T00:00:00.000Z",
|
|
}),
|
|
);
|
|
await workspaceRegistry.upsert(
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-existing",
|
|
projectId: "proj-existing",
|
|
cwd,
|
|
kind: "local_checkout",
|
|
displayName: "repo",
|
|
title: null,
|
|
createdAt: "2026-05-07T00:00:00.000Z",
|
|
updatedAt: "2026-05-07T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
let generateCalls = 0;
|
|
const session = asTestSession(
|
|
new Session({
|
|
messageReceipts: createMessageReceiptsStub(),
|
|
creationService: createTestCreationService(),
|
|
clientId: "test-client",
|
|
permissions: OWNER_PERMISSIONS,
|
|
appVersion: null,
|
|
onMessage: vi.fn(),
|
|
logger: asSessionLogger(logger),
|
|
downloadTokenStore: asDownloadTokenStore(),
|
|
pushNotifications: asPushNotifications(),
|
|
paseoHome: path.join(workdir, "paseo-home"),
|
|
agentManager,
|
|
agentStorage,
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
scheduleService: asScheduleService(),
|
|
checkoutDiffManager: asCheckoutDiffManager({
|
|
subscribe: async () => ({
|
|
initial: { cwd, files: [], error: null },
|
|
unsubscribe: () => {},
|
|
}),
|
|
scheduleRefreshForCwd: () => {},
|
|
onWorkspaceStateMayHaveChanged: () => {},
|
|
invalidateForge: () => {},
|
|
getMetrics: () => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
}),
|
|
dispose: () => {},
|
|
}),
|
|
workspaceGitService: createNoopWorkspaceGitService(),
|
|
daemonConfigStore: asDaemonConfigStore({
|
|
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
|
onChange: () => () => {},
|
|
}),
|
|
mcpBaseUrl: null,
|
|
stt: null,
|
|
tts: null,
|
|
generateWorkspaceName: async () => {
|
|
generateCalls += 1;
|
|
return { title: "Generated title that must not be written", branch: null };
|
|
},
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
terminalManager: null,
|
|
}),
|
|
);
|
|
|
|
await session.handleMessage({
|
|
type: "create_agent_request",
|
|
requestId: "req-create-existing-title",
|
|
workspaceId: "ws-existing",
|
|
config: { provider: "codex", cwd },
|
|
initialPrompt: "Fix login bug\nwith better validation",
|
|
attachments: [],
|
|
});
|
|
await vi.runAllTimersAsync();
|
|
|
|
const [createdAgent] = agentManager.listAgents();
|
|
expect(createdAgent?.workspaceId).toBe("ws-existing");
|
|
expect(generateCalls).toBe(0);
|
|
await expect(workspaceRegistry.get("ws-existing")).resolves.toMatchObject({
|
|
title: null,
|
|
updatedAt: "2026-05-07T00:00:00.000Z",
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
rmSync(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => {
|
|
const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" });
|
|
const storedRecord = {
|
|
id: "agent-unsupported",
|
|
provider: "gemini",
|
|
cwd: path.resolve("/tmp/history"),
|
|
createdAt: "2026-04-13T10:13:11.457Z",
|
|
updatedAt: "2026-04-13T10:16:06.556Z",
|
|
lastActivityAt: "2026-04-13T10:16:06.556Z",
|
|
lastUserMessageAt: "2026-04-13T10:13:11.911Z",
|
|
title: "Interactive Session",
|
|
labels: {},
|
|
lastStatus: "closed",
|
|
lastModeId: "default",
|
|
config: {
|
|
title: "hello",
|
|
modeId: "default",
|
|
model: "gemini-2.5-flash",
|
|
},
|
|
runtimeInfo: {
|
|
provider: "gemini",
|
|
sessionId: "61c738df-7ba4-49c2-a8fd-07c1395ad1c7",
|
|
model: "gemini-2.5-flash",
|
|
modeId: "default",
|
|
},
|
|
persistence: {
|
|
provider: "gemini",
|
|
sessionId: "61c738df-7ba4-49c2-a8fd-07c1395ad1c7",
|
|
},
|
|
archivedAt: "2026-04-13T10:16:06.514Z",
|
|
};
|
|
|
|
session.agentStorage.list = async () => [storedRecord];
|
|
session.agentStorage.get = async (agentId: string) =>
|
|
agentId === storedRecord.id ? storedRecord : null;
|
|
|
|
await expect(session.listAgentPayloads()).resolves.toEqual([]);
|
|
|
|
await expect(session.listAgentPayloads({ includeUnavailablePersisted: true })).resolves.toEqual(
|
|
[],
|
|
);
|
|
|
|
await expect(
|
|
session.listAgentPayloads({ includeArchived: true, includeUnavailablePersisted: true }),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
id: "agent-unsupported",
|
|
provider: "gemini",
|
|
providerUnavailable: true,
|
|
persistence: null,
|
|
}),
|
|
]);
|
|
|
|
await expect(session.getAgentPayloadById("agent-unsupported")).resolves.toEqual(
|
|
expect.objectContaining({
|
|
id: "agent-unsupported",
|
|
provider: "gemini",
|
|
providerUnavailable: true,
|
|
persistence: null,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("agent_update placement does not refresh git snapshots", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const getSnapshot = vi.fn(async () => {
|
|
throw new Error("getSnapshot should not be called for agent_update placement");
|
|
});
|
|
const workspaceGitService = {
|
|
...createNoopWorkspaceGitService(),
|
|
getSnapshot,
|
|
peekSnapshot: vi.fn(() => null),
|
|
};
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
workspaceGitService,
|
|
}),
|
|
);
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-1",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-1",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null);
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.workspaceRegistry.get = async (id: string) =>
|
|
id === workspace.workspaceId ? workspace : null;
|
|
await activateAgentUpdatesSubscription(session, "sub-agents", {});
|
|
|
|
await session.agentUpdates.forwardLiveAgent(
|
|
makeManagedAgent({
|
|
id: "agent-1",
|
|
cwd: REPO_CWD,
|
|
workspaceId: workspace.workspaceId,
|
|
lifecycle: "running",
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
expect(getSnapshot).not.toHaveBeenCalled();
|
|
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
|
|
kind: "upsert",
|
|
agent: {
|
|
id: "agent-1",
|
|
status: "running",
|
|
},
|
|
project: {
|
|
projectKey: "proj-1",
|
|
projectName: "repo",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("agent_update emits remove when the agent has no workspaceId", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const getSnapshot = vi.fn(async () => {
|
|
throw new Error("getSnapshot should not be called for unregistered agent_update placement");
|
|
});
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
workspaceGitService: {
|
|
...createNoopWorkspaceGitService(),
|
|
getSnapshot,
|
|
peekSnapshot: vi.fn(() => null),
|
|
},
|
|
}),
|
|
);
|
|
|
|
await activateAgentUpdatesSubscription(session, "sub-agents", {});
|
|
|
|
await session.agentUpdates.forwardLiveAgent(
|
|
makeManagedAgent({
|
|
id: "agent-1",
|
|
cwd: UNREGISTERED_CWD,
|
|
lifecycle: "running",
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
expect(getSnapshot).not.toHaveBeenCalled();
|
|
const update = emitted.find((message) => message.type === "agent_update");
|
|
expect(update?.payload).toMatchObject({
|
|
kind: "remove",
|
|
agentId: "agent-1",
|
|
});
|
|
});
|
|
|
|
test("archive emits an authoritative agent_update upsert for subscribed clients", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const archivedRecord = {
|
|
id: "agent-1",
|
|
provider: "codex",
|
|
cwd: REPO_CWD,
|
|
workspaceId: "ws-1",
|
|
createdAt: "2026-03-30T15:00:00.000Z",
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
lastActivityAt: "2026-03-30T15:00:00.000Z",
|
|
lastUserMessageAt: null,
|
|
lastStatus: "idle",
|
|
lastModeId: null,
|
|
runtimeInfo: null,
|
|
config: {
|
|
provider: "codex",
|
|
cwd: REPO_CWD,
|
|
},
|
|
persistence: null,
|
|
title: "Archive me",
|
|
labels: {},
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
attentionTimestamp: null,
|
|
archivedAt: null,
|
|
};
|
|
|
|
const logger = {
|
|
child: () => logger,
|
|
trace: vi.fn(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
|
|
const session = asTestSession(
|
|
new Session({
|
|
messageReceipts: createMessageReceiptsStub(),
|
|
creationService: createTestCreationService(),
|
|
clientId: "test-client",
|
|
permissions: OWNER_PERMISSIONS,
|
|
onMessage: (message) => emitted.push(message),
|
|
logger: asSessionLogger(logger),
|
|
downloadTokenStore: asDownloadTokenStore(),
|
|
pushNotifications: asPushNotifications(),
|
|
paseoHome: "/tmp/paseo-test",
|
|
agentManager: asAgentManager({
|
|
subscribe: () => () => {},
|
|
listAgents: () => [],
|
|
getAgent: () => null,
|
|
archiveAgent: async () => {
|
|
const archivedAt = new Date().toISOString();
|
|
Object.assign(archivedRecord, {
|
|
archivedAt,
|
|
updatedAt: archivedAt,
|
|
});
|
|
return { archivedAt };
|
|
},
|
|
archiveSnapshot: async (_agentId: string, archivedAt: string) => {
|
|
Object.assign(archivedRecord, { archivedAt, updatedAt: archivedAt });
|
|
return archivedRecord;
|
|
},
|
|
clearAgentAttention: async () => {},
|
|
notifyAgentState: () => {},
|
|
}),
|
|
agentStorage: asAgentStorage({
|
|
list: async () => [archivedRecord],
|
|
get: async (agentId: string) => (agentId === archivedRecord.id ? archivedRecord : null),
|
|
upsert: async (record: typeof archivedRecord) => {
|
|
Object.assign(archivedRecord, record);
|
|
},
|
|
}),
|
|
projectRegistry: (() => {
|
|
const proj = createPersistedProjectRecord({
|
|
projectId: "proj-1",
|
|
rootPath: REPO_CWD,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
return {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [proj],
|
|
get: async (id: string) => (id === "proj-1" ? proj : null),
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
})(),
|
|
workspaceRegistry: (() => {
|
|
const ws = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-1",
|
|
projectId: "proj-1",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
return {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [ws],
|
|
get: async (id: string) => (id === "ws-1" ? ws : null),
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
})(),
|
|
scheduleService: asScheduleService(),
|
|
checkoutDiffManager: asCheckoutDiffManager({
|
|
subscribe: async () => ({
|
|
initial: { cwd: REPO_CWD, files: [], error: null },
|
|
unsubscribe: () => {},
|
|
}),
|
|
scheduleRefreshForCwd: () => {},
|
|
onWorkspaceStateMayHaveChanged: () => {},
|
|
invalidateForge: () => {},
|
|
getMetrics: () => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
}),
|
|
dispose: () => {},
|
|
}),
|
|
workspaceGitService: createNoopWorkspaceGitService(),
|
|
daemonConfigStore: asDaemonConfigStore({
|
|
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
|
onChange: () => () => {},
|
|
}),
|
|
mcpBaseUrl: null,
|
|
stt: null,
|
|
tts: null,
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
terminalManager: null,
|
|
}),
|
|
);
|
|
|
|
await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true });
|
|
|
|
await session.handleArchiveAgentRequest("agent-1", "req-archive");
|
|
|
|
const update = emitted.find((message) => message.type === "agent_update");
|
|
expect(update?.payload).toMatchObject({
|
|
kind: "upsert",
|
|
agent: {
|
|
id: "agent-1",
|
|
archivedAt: expect.any(String),
|
|
},
|
|
});
|
|
expect(emitted.find((message) => message.type === "agent_archived")?.payload).toMatchObject({
|
|
agentId: "agent-1",
|
|
archivedAt: expect.any(String),
|
|
requestId: "req-archive",
|
|
});
|
|
});
|
|
|
|
test("workspace clear attention clears stored-only agents and responds", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: REPO_CWD,
|
|
projectId: REPO_CWD,
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-30T15:00:00.000Z",
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId: REPO_CWD,
|
|
rootPath: REPO_CWD,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-30T15:00:00.000Z",
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
});
|
|
let storedRecord = makeStoredAgent({
|
|
id: "stored-agent-1",
|
|
cwd: REPO_CWD,
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
});
|
|
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
|
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.workspaceRegistry.get = async (id: string) =>
|
|
id === workspace.workspaceId ? workspace : null;
|
|
session.projectRegistry.list = async () => [project];
|
|
session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null);
|
|
session.agentStorage.get = async (agentId: string) =>
|
|
agentId === storedRecord.id ? storedRecord : null;
|
|
session.agentStorage.upsert = async (record: unknown) => {
|
|
storedRecord = record as StoredAgentRecord;
|
|
};
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: storedRecord.id,
|
|
cwd: storedRecord.cwd,
|
|
workspaceId: workspace.workspaceId,
|
|
status: "closed",
|
|
updatedAt: storedRecord.updatedAt,
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
}),
|
|
];
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.clear_attention.request",
|
|
workspaceId: workspace.workspaceId,
|
|
requestId: "req-1",
|
|
});
|
|
|
|
expect(storedRecord.requiresAttention).toBe(false);
|
|
expect(storedRecord.attentionReason).toBeNull();
|
|
expect(storedRecord.attentionTimestamp).toBeNull();
|
|
expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({
|
|
requestId: "req-1",
|
|
workspaceId: workspace.workspaceId,
|
|
clearedAgentIds: [storedRecord.id],
|
|
success: true,
|
|
error: null,
|
|
});
|
|
const agentUpdate = findByType(emitted, "agent_update");
|
|
expect(agentUpdate.payload.kind).toBe("upsert");
|
|
if (agentUpdate.payload.kind === "upsert") {
|
|
expect(agentUpdate.payload.agent.requiresAttention).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("workspace clear attention responds with an error instead of timing out", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
|
session.workspaceRegistry.get = async () => null;
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.clear_attention.request",
|
|
workspaceId: "missing-workspace",
|
|
requestId: "req-1",
|
|
});
|
|
|
|
expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({
|
|
requestId: "req-1",
|
|
workspaceId: "missing-workspace",
|
|
clearedAgentIds: [],
|
|
success: false,
|
|
error: "Workspace not found: missing-workspace",
|
|
});
|
|
});
|
|
|
|
test("workspace mark unread selects the newest finished workspace root", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: REPO_CWD,
|
|
projectId: REPO_CWD,
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-30T15:00:00.000Z",
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId: REPO_CWD,
|
|
rootPath: REPO_CWD,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-30T15:00:00.000Z",
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
});
|
|
const storedRecords = new Map<string, StoredAgentRecord>([
|
|
[
|
|
"root-agent",
|
|
makeStoredAgent({
|
|
id: "root-agent",
|
|
cwd: REPO_CWD,
|
|
workspaceId: workspace.workspaceId,
|
|
updatedAt: "2026-03-30T16:00:00.000Z",
|
|
}),
|
|
],
|
|
[
|
|
"newer-child",
|
|
makeStoredAgent({
|
|
id: "newer-child",
|
|
cwd: REPO_CWD,
|
|
workspaceId: workspace.workspaceId,
|
|
updatedAt: "2026-03-30T17:00:00.000Z",
|
|
labels: { "paseo.parent-agent-id": "root-agent" },
|
|
}),
|
|
],
|
|
]);
|
|
const markedAgentIds: string[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
agentManager: {
|
|
markAgentUnread: async (agentId: string) => {
|
|
markedAgentIds.push(agentId);
|
|
},
|
|
},
|
|
});
|
|
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.workspaceRegistry.get = async (id: string) =>
|
|
id === workspace.workspaceId ? workspace : null;
|
|
session.projectRegistry.list = async () => [project];
|
|
session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null);
|
|
session.listAgentPayloads = async () =>
|
|
Array.from(storedRecords.values()).map((record) =>
|
|
makeAgent({
|
|
id: record.id,
|
|
cwd: record.cwd,
|
|
workspaceId: record.workspaceId,
|
|
status: record.lastStatus,
|
|
updatedAt: record.updatedAt,
|
|
requiresAttention: record.requiresAttention,
|
|
attentionReason: record.attentionReason,
|
|
attentionTimestamp: record.attentionTimestamp,
|
|
labels: record.labels,
|
|
}),
|
|
);
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.mark_unread.request",
|
|
workspaceId: workspace.workspaceId,
|
|
requestId: "req-mark-unread",
|
|
});
|
|
|
|
expect(markedAgentIds).toEqual(["root-agent"]);
|
|
expect(findByType(emitted, "workspace.mark_unread.response").payload).toEqual({
|
|
requestId: "req-mark-unread",
|
|
workspaceId: workspace.workspaceId,
|
|
markedAgentId: "root-agent",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("workspace mark unread rejects workspaces without a finished root agent", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: REPO_CWD,
|
|
projectId: REPO_CWD,
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-30T15:00:00.000Z",
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
});
|
|
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
|
session.workspaceRegistry.get = async (id: string) =>
|
|
id === workspace.workspaceId ? workspace : null;
|
|
session.listAgentPayloads = async () => [];
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.mark_unread.request",
|
|
workspaceId: workspace.workspaceId,
|
|
requestId: "req-mark-unread",
|
|
});
|
|
|
|
expect(findByType(emitted, "workspace.mark_unread.response").payload).toEqual({
|
|
requestId: "req-mark-unread",
|
|
workspaceId: workspace.workspaceId,
|
|
markedAgentId: null,
|
|
success: false,
|
|
error: `Workspace has no finished agent to mark unread: ${workspace.workspaceId}`,
|
|
});
|
|
});
|
|
|
|
test("workspace clear attention can clear multiple workspaces in one request", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const workspaces = [
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-a",
|
|
projectId: "/tmp/repo-a",
|
|
cwd: "/tmp/repo-a",
|
|
kind: "directory",
|
|
displayName: "repo-a",
|
|
createdAt: "2026-03-30T15:00:00.000Z",
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
}),
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-b",
|
|
projectId: "/tmp/repo-b",
|
|
cwd: "/tmp/repo-b",
|
|
kind: "directory",
|
|
displayName: "repo-b",
|
|
createdAt: "2026-03-30T15:00:00.000Z",
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
}),
|
|
];
|
|
const projects = workspaces.map((workspace) =>
|
|
createPersistedProjectRecord({
|
|
projectId: workspace.projectId,
|
|
rootPath: workspace.cwd,
|
|
kind: "non_git",
|
|
displayName: workspace.displayName,
|
|
createdAt: "2026-03-30T15:00:00.000Z",
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
}),
|
|
);
|
|
const storedRecords = new Map(
|
|
workspaces.map((workspace, index) => [
|
|
`stored-agent-${index + 1}`,
|
|
makeStoredAgent({
|
|
id: `stored-agent-${index + 1}`,
|
|
cwd: workspace.cwd,
|
|
updatedAt: "2026-03-30T15:00:00.000Z",
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
}),
|
|
]),
|
|
);
|
|
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
|
|
|
session.workspaceRegistry.list = async () => workspaces;
|
|
session.workspaceRegistry.get = async (id: string) =>
|
|
workspaces.find((workspace) => workspace.workspaceId === id) ?? null;
|
|
session.projectRegistry.list = async () => projects;
|
|
session.projectRegistry.get = async (id: string) =>
|
|
projects.find((project) => project.projectId === id) ?? null;
|
|
session.agentStorage.get = async (agentId: string) => storedRecords.get(agentId) ?? null;
|
|
session.agentStorage.upsert = async (record: unknown) => {
|
|
const storedRecord = record as StoredAgentRecord;
|
|
storedRecords.set(storedRecord.id, storedRecord);
|
|
};
|
|
session.listAgentPayloads = async () =>
|
|
Array.from(storedRecords.values()).map((record) => {
|
|
const owner = workspaces.find((workspace) => workspace.cwd === record.cwd);
|
|
return makeAgent({
|
|
id: record.id,
|
|
cwd: record.cwd,
|
|
...(owner ? { workspaceId: owner.workspaceId } : {}),
|
|
status: "closed",
|
|
updatedAt: record.updatedAt,
|
|
requiresAttention: record.requiresAttention,
|
|
attentionReason: record.attentionReason,
|
|
});
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.clear_attention.request",
|
|
workspaceId: workspaces.map((workspace) => workspace.workspaceId),
|
|
requestId: "req-1",
|
|
});
|
|
|
|
expect(Array.from(storedRecords.values()).map((record) => record.requiresAttention)).toEqual([
|
|
false,
|
|
false,
|
|
]);
|
|
expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({
|
|
requestId: "req-1",
|
|
workspaceId: workspaces.map((workspace) => workspace.workspaceId),
|
|
clearedAgentIds: ["stored-agent-1", "stored-agent-2"],
|
|
results: [
|
|
{
|
|
workspaceId: workspaces[0].workspaceId,
|
|
clearedAgentIds: ["stored-agent-1"],
|
|
success: true,
|
|
error: null,
|
|
},
|
|
{
|
|
workspaceId: workspaces[1].workspaceId,
|
|
clearedAgentIds: ["stored-agent-2"],
|
|
success: true,
|
|
error: null,
|
|
},
|
|
],
|
|
success: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("close_items_request archives agents and kills terminals in one batch", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const archivedAt = "2026-04-01T00:00:00.000Z";
|
|
const sessionLogger = {
|
|
child: () => sessionLogger,
|
|
trace: vi.fn(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
const archivedRecord = {
|
|
id: "agent-1",
|
|
provider: "codex",
|
|
cwd: REPO_CWD,
|
|
workspaceId: "ws-close",
|
|
model: null,
|
|
thinkingOptionId: null,
|
|
effectiveThinkingOptionId: null,
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
lastUserMessageAt: null,
|
|
status: "idle",
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
runtimeInfo: { provider: "codex", sessionId: null },
|
|
title: null,
|
|
labels: {},
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
attentionTimestamp: null,
|
|
archivedAt: null,
|
|
};
|
|
const killTerminal = vi.fn();
|
|
const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const }));
|
|
const session = asTestSession(
|
|
new Session({
|
|
messageReceipts: createMessageReceiptsStub(),
|
|
creationService: createTestCreationService(),
|
|
clientId: "test-client",
|
|
permissions: OWNER_PERMISSIONS,
|
|
onMessage: (message) => emitted.push(message),
|
|
logger: asSessionLogger(sessionLogger),
|
|
downloadTokenStore: asDownloadTokenStore(),
|
|
pushNotifications: asPushNotifications(),
|
|
paseoHome: "/tmp/paseo-test",
|
|
agentManager: asAgentManager({
|
|
subscribe: () => () => {},
|
|
listAgents: () => [],
|
|
getAgent: (agentId: string) => (agentId === "agent-1" ? { id: agentId } : null),
|
|
hasInFlightRun: (agentId: string) => agentId === "agent-1",
|
|
cancelAgentRun,
|
|
archiveAgent: async () => ({ archivedAt }),
|
|
clearAgentAttention: async () => {},
|
|
notifyAgentState: () => {},
|
|
}),
|
|
agentStorage: asAgentStorage({
|
|
list: async () => [],
|
|
get: async (agentId: string) => {
|
|
if (agentId !== "agent-1") {
|
|
return null;
|
|
}
|
|
archivedRecord.archivedAt = archivedAt;
|
|
archivedRecord.updatedAt = archivedAt;
|
|
return archivedRecord;
|
|
},
|
|
}),
|
|
projectRegistry: (() => {
|
|
const proj = createPersistedProjectRecord({
|
|
projectId: "proj-close",
|
|
rootPath: REPO_CWD,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
return {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [proj],
|
|
get: async (id: string) => (id === "proj-close" ? proj : null),
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
})(),
|
|
workspaceRegistry: (() => {
|
|
const ws = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-close",
|
|
projectId: "proj-close",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
return {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [ws],
|
|
get: async (id: string) => (id === "ws-close" ? ws : null),
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
})(),
|
|
scheduleService: asScheduleService(),
|
|
checkoutDiffManager: asCheckoutDiffManager({
|
|
subscribe: async () => ({
|
|
initial: { cwd: "/tmp", files: [], error: null },
|
|
unsubscribe: () => {},
|
|
}),
|
|
scheduleRefreshForCwd: () => {},
|
|
onWorkspaceStateMayHaveChanged: () => {},
|
|
invalidateForge: () => {},
|
|
getMetrics: () => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
}),
|
|
dispose: () => {},
|
|
}),
|
|
workspaceGitService: createNoopWorkspaceGitService(),
|
|
daemonConfigStore: asDaemonConfigStore({
|
|
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
|
onChange: () => () => {},
|
|
}),
|
|
mcpBaseUrl: null,
|
|
stt: null,
|
|
tts: null,
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
terminalManager: asTerminalManager({
|
|
killTerminal,
|
|
subscribeTerminalsChanged: () => () => {},
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true });
|
|
|
|
await session.handleMessage({
|
|
type: "close_items_request",
|
|
agentIds: ["agent-1"],
|
|
terminalIds: ["term-1"],
|
|
requestId: "req-close-items",
|
|
});
|
|
|
|
expect(cancelAgentRun).toHaveBeenCalledWith("agent-1");
|
|
expect(killTerminal).toHaveBeenCalledWith("term-1");
|
|
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
|
|
agents: [{ agentId: "agent-1", archivedAt }],
|
|
terminals: [{ terminalId: "term-1", success: true }],
|
|
requestId: "req-close-items",
|
|
});
|
|
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
|
|
kind: "upsert",
|
|
agent: {
|
|
id: "agent-1",
|
|
archivedAt,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("close_items_request archives stored agents that are not currently loaded", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const sessionLogger = {
|
|
child: () => sessionLogger,
|
|
trace: vi.fn(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
const liveArchivedAt = "2026-04-01T00:00:00.000Z";
|
|
const storedAgentId = "agent-stored";
|
|
const liveRecord = {
|
|
...makeAgent({
|
|
id: "agent-live",
|
|
cwd: REPO_CWD,
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
archivedAt: null as string | null,
|
|
};
|
|
const storedRecord = {
|
|
...makeAgent({
|
|
id: storedAgentId,
|
|
cwd: REPO_CWD,
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:05:00.000Z",
|
|
}),
|
|
archivedAt: null as string | null,
|
|
};
|
|
const upsertStoredRecord = vi.fn(async (record: typeof storedRecord) => {
|
|
if (record.id === storedAgentId) {
|
|
storedRecord.archivedAt = record.archivedAt;
|
|
storedRecord.updatedAt = record.updatedAt;
|
|
storedRecord.status = record.status;
|
|
storedRecord.requiresAttention = record.requiresAttention;
|
|
storedRecord.attentionReason = record.attentionReason;
|
|
storedRecord.attentionTimestamp = record.attentionTimestamp;
|
|
}
|
|
});
|
|
|
|
const session = asTestSession(
|
|
new Session({
|
|
messageReceipts: createMessageReceiptsStub(),
|
|
creationService: createTestCreationService(),
|
|
clientId: "test-client",
|
|
permissions: OWNER_PERMISSIONS,
|
|
onMessage: (message) => emitted.push(message),
|
|
logger: asSessionLogger(sessionLogger),
|
|
downloadTokenStore: asDownloadTokenStore(),
|
|
pushNotifications: asPushNotifications(),
|
|
paseoHome: "/tmp/paseo-test",
|
|
agentManager: asAgentManager({
|
|
subscribe: () => () => {},
|
|
listAgents: () => [],
|
|
getAgent: (agentId: string) => (agentId === "agent-live" ? { id: agentId } : null),
|
|
hasInFlightRun: () => false,
|
|
archiveAgent: async (agentId: string) => {
|
|
if (agentId !== "agent-live") {
|
|
throw new Error(`Unexpected live archive: ${agentId}`);
|
|
}
|
|
liveRecord.archivedAt = liveArchivedAt;
|
|
liveRecord.updatedAt = liveArchivedAt;
|
|
return { archivedAt: liveArchivedAt };
|
|
},
|
|
archiveSnapshot: async (_agentId: string, archivedAt: string) => {
|
|
storedRecord.archivedAt = archivedAt;
|
|
storedRecord.updatedAt = archivedAt;
|
|
storedRecord.status = "completed";
|
|
storedRecord.requiresAttention = false;
|
|
storedRecord.attentionReason = null;
|
|
storedRecord.attentionTimestamp = null;
|
|
return storedRecord;
|
|
},
|
|
clearAgentAttention: async () => {},
|
|
notifyAgentState: () => {},
|
|
}),
|
|
agentStorage: asAgentStorage({
|
|
list: async () => [],
|
|
get: async (agentId: string) => {
|
|
if (agentId === "agent-live") {
|
|
return liveRecord;
|
|
}
|
|
if (agentId === storedAgentId) {
|
|
return storedRecord;
|
|
}
|
|
return null;
|
|
},
|
|
upsert: upsertStoredRecord,
|
|
}),
|
|
projectRegistry: (() => {
|
|
const proj = createPersistedProjectRecord({
|
|
projectId: "proj-stored",
|
|
rootPath: REPO_CWD,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
return {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [proj],
|
|
get: async (id: string) => (id === "proj-stored" ? proj : null),
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
})(),
|
|
workspaceRegistry: (() => {
|
|
const ws = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-stored",
|
|
projectId: "proj-stored",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
return {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [ws],
|
|
get: async (id: string) => (id === "ws-stored" ? ws : null),
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
})(),
|
|
scheduleService: asScheduleService(),
|
|
checkoutDiffManager: asCheckoutDiffManager({
|
|
subscribe: async () => ({
|
|
initial: { cwd: "/tmp", files: [], error: null },
|
|
unsubscribe: () => {},
|
|
}),
|
|
scheduleRefreshForCwd: () => {},
|
|
onWorkspaceStateMayHaveChanged: () => {},
|
|
invalidateForge: () => {},
|
|
getMetrics: () => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
}),
|
|
dispose: () => {},
|
|
}),
|
|
workspaceGitService: createNoopWorkspaceGitService(),
|
|
daemonConfigStore: asDaemonConfigStore({
|
|
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
|
onChange: () => () => {},
|
|
}),
|
|
mcpBaseUrl: null,
|
|
stt: null,
|
|
tts: null,
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
terminalManager: null,
|
|
}),
|
|
);
|
|
|
|
await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true });
|
|
|
|
await session.handleMessage({
|
|
type: "close_items_request",
|
|
agentIds: ["agent-live", storedAgentId],
|
|
terminalIds: [],
|
|
requestId: "req-close-stored",
|
|
});
|
|
|
|
expect(storedRecord.archivedAt).toEqual(expect.any(String));
|
|
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
|
|
agents: [
|
|
{ agentId: "agent-live", archivedAt: liveArchivedAt },
|
|
{ agentId: storedAgentId, archivedAt: storedRecord.archivedAt },
|
|
],
|
|
terminals: [],
|
|
requestId: "req-close-stored",
|
|
});
|
|
expect(sessionLogger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("close_items_request continues after an archive failure", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const sessionLogger = {
|
|
child: () => sessionLogger,
|
|
trace: vi.fn(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
const archivedAt = "2026-04-01T00:00:00.000Z";
|
|
const goodRecord = {
|
|
...makeAgent({
|
|
id: "agent-good",
|
|
cwd: REPO_CWD,
|
|
workspaceId: "ws-err",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
archivedAt: null as string | null,
|
|
};
|
|
const killTerminalBestEffort = vi.fn();
|
|
const session = asTestSession(
|
|
new Session({
|
|
messageReceipts: createMessageReceiptsStub(),
|
|
creationService: createTestCreationService(),
|
|
clientId: "test-client",
|
|
permissions: OWNER_PERMISSIONS,
|
|
onMessage: (message) => emitted.push(message),
|
|
logger: asSessionLogger(sessionLogger),
|
|
downloadTokenStore: asDownloadTokenStore(),
|
|
pushNotifications: asPushNotifications(),
|
|
paseoHome: "/tmp/paseo-test",
|
|
agentManager: asAgentManager({
|
|
subscribe: () => () => {},
|
|
listAgents: () => [],
|
|
getAgent: (agentId: string) =>
|
|
agentId === "agent-bad" || agentId === "agent-good" ? { id: agentId } : null,
|
|
hasInFlightRun: () => false,
|
|
archiveAgent: async (agentId: string) => {
|
|
if (agentId === "agent-bad") {
|
|
throw new Error("archive failed");
|
|
}
|
|
return { archivedAt };
|
|
},
|
|
clearAgentAttention: async () => {},
|
|
notifyAgentState: () => {},
|
|
}),
|
|
agentStorage: asAgentStorage({
|
|
list: async () => [],
|
|
get: async (agentId: string) => {
|
|
if (agentId !== "agent-good") {
|
|
return null;
|
|
}
|
|
goodRecord.archivedAt = archivedAt;
|
|
goodRecord.updatedAt = archivedAt;
|
|
return goodRecord;
|
|
},
|
|
}),
|
|
projectRegistry: (() => {
|
|
const proj = createPersistedProjectRecord({
|
|
projectId: "proj-err",
|
|
rootPath: REPO_CWD,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
return {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [proj],
|
|
get: async (id: string) => (id === "proj-err" ? proj : null),
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
})(),
|
|
workspaceRegistry: (() => {
|
|
const ws = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-err",
|
|
projectId: "proj-err",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
return {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [ws],
|
|
get: async (id: string) => (id === "ws-err" ? ws : null),
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
})(),
|
|
scheduleService: asScheduleService(),
|
|
checkoutDiffManager: asCheckoutDiffManager({
|
|
subscribe: async () => ({
|
|
initial: { cwd: "/tmp", files: [], error: null },
|
|
unsubscribe: () => {},
|
|
}),
|
|
scheduleRefreshForCwd: () => {},
|
|
onWorkspaceStateMayHaveChanged: () => {},
|
|
invalidateForge: () => {},
|
|
getMetrics: () => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
}),
|
|
dispose: () => {},
|
|
}),
|
|
workspaceGitService: createNoopWorkspaceGitService(),
|
|
daemonConfigStore: asDaemonConfigStore({
|
|
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
|
onChange: () => () => {},
|
|
}),
|
|
mcpBaseUrl: null,
|
|
stt: null,
|
|
tts: null,
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
terminalManager: asTerminalManager({
|
|
killTerminal: killTerminalBestEffort,
|
|
subscribeTerminalsChanged: () => () => {},
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true });
|
|
|
|
await session.handleMessage({
|
|
type: "close_items_request",
|
|
agentIds: ["agent-bad", "agent-good"],
|
|
terminalIds: ["term-1"],
|
|
requestId: "req-close-best-effort",
|
|
});
|
|
|
|
expect(killTerminalBestEffort).toHaveBeenCalledWith("term-1");
|
|
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
|
|
agents: [{ agentId: "agent-good", archivedAt }],
|
|
terminals: [{ terminalId: "term-1", success: true }],
|
|
requestId: "req-close-best-effort",
|
|
});
|
|
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
|
|
kind: "upsert",
|
|
agent: {
|
|
id: "agent-good",
|
|
archivedAt,
|
|
},
|
|
});
|
|
expect(sessionLogger.warn).toHaveBeenCalled();
|
|
});
|
|
|
|
test("non-git workspace uses deterministic directory name and no unknown branch fallback", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
session.workspaceRegistry.list = async () => [
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-non-git",
|
|
projectId: "proj-non-git",
|
|
cwd: "/tmp/non-git",
|
|
kind: "directory",
|
|
displayName: "non-git",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
];
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: "a1",
|
|
cwd: "/tmp/non-git",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
];
|
|
const result = await session.listFetchWorkspacesEntries({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-1",
|
|
});
|
|
|
|
expect(result.entries).toHaveLength(1);
|
|
expect(result.entries[0]?.name).toBe("non-git");
|
|
expect(result.entries[0]?.name).not.toBe("Unknown branch");
|
|
});
|
|
|
|
test("workspace placements preserve checkout facts independently from the project", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const manualWorktree = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-manual-worktree",
|
|
projectId: "proj-manual-worktree",
|
|
cwd: "/tmp/manual-worktree",
|
|
kind: "worktree",
|
|
displayName: "manual",
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: "/tmp/main-repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const explicitDirectory = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-explicit-directory",
|
|
projectId: "proj-manual-worktree",
|
|
cwd: "/tmp/plain-directory",
|
|
kind: "directory",
|
|
displayName: "plain",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const paseoSubdirectory = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-paseo-subdirectory",
|
|
projectId: "proj-manual-worktree",
|
|
cwd: "/tmp/paseo-worktree/packages/app",
|
|
kind: "worktree",
|
|
displayName: "app",
|
|
isPaseoOwnedWorktree: true,
|
|
mainRepoRoot: "/tmp/main-repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-manual-worktree",
|
|
rootPath: "/tmp/main-repo",
|
|
kind: "git",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
|
[manualWorktree, explicitDirectory, paseoSubdirectory].find(
|
|
(workspace) => workspace.workspaceId === workspaceId,
|
|
) ?? null;
|
|
session.projectRegistry.get = async () => project;
|
|
session.workspaceGitService.peekSnapshot = (cwd: string) =>
|
|
cwd === paseoSubdirectory.cwd
|
|
? createWorkspaceRuntimeSnapshot(cwd, {
|
|
git: { repoRoot: "/tmp/paseo-worktree" },
|
|
})
|
|
: null;
|
|
|
|
await expect(
|
|
session.buildProjectPlacementForWorkspaceId(manualWorktree.workspaceId),
|
|
).resolves.toEqual(
|
|
expect.objectContaining({
|
|
checkout: expect.objectContaining({
|
|
isGit: true,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: "/tmp/main-repo",
|
|
}),
|
|
}),
|
|
);
|
|
await expect(
|
|
session.buildProjectPlacementForWorkspaceId(explicitDirectory.workspaceId),
|
|
).resolves.toEqual(
|
|
expect.objectContaining({
|
|
checkout: expect.objectContaining({
|
|
isGit: false,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
}),
|
|
}),
|
|
);
|
|
await expect(
|
|
session.buildProjectPlacementForWorkspaceId(paseoSubdirectory.workspaceId),
|
|
).resolves.toEqual(
|
|
expect.objectContaining({
|
|
checkout: expect.objectContaining({
|
|
cwd: paseoSubdirectory.cwd,
|
|
worktreeRoot: "/tmp/paseo-worktree",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("active-scoped fetch_agents includes only unarchived agents in active workspaces", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const archivedAt = "2026-03-02T12:00:00.000Z";
|
|
const activeProject = createPersistedProjectRecord({
|
|
projectId: "proj-active",
|
|
rootPath: "/tmp/active",
|
|
kind: "non_git",
|
|
displayName: "active",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const archivedProject = createPersistedProjectRecord({
|
|
projectId: "proj-archived",
|
|
rootPath: "/tmp/archived-project",
|
|
kind: "non_git",
|
|
displayName: "archived project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
archivedAt,
|
|
});
|
|
const activeWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-active",
|
|
projectId: activeProject.projectId,
|
|
cwd: "/tmp/active",
|
|
kind: "directory",
|
|
displayName: "active",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const archivedWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-archived",
|
|
projectId: activeProject.projectId,
|
|
cwd: "/tmp/archived-workspace",
|
|
kind: "directory",
|
|
displayName: "archived workspace",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
archivedAt,
|
|
});
|
|
const workspaceInArchivedProject = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-archived-project",
|
|
projectId: archivedProject.projectId,
|
|
cwd: "/tmp/archived-project",
|
|
kind: "directory",
|
|
displayName: "archived project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
session.projectRegistry.list = async () => [activeProject, archivedProject];
|
|
session.projectRegistry.get = async (projectId: string) =>
|
|
[activeProject, archivedProject].find((project) => project.projectId === projectId) ?? null;
|
|
session.workspaceRegistry.list = async () => [
|
|
activeWorkspace,
|
|
archivedWorkspace,
|
|
workspaceInArchivedProject,
|
|
];
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: "agent-active",
|
|
cwd: "/tmp/active",
|
|
workspaceId: "ws-active",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:04:00.000Z",
|
|
}),
|
|
makeAgent({
|
|
id: "agent-subdir",
|
|
cwd: "/tmp/active/packages/app",
|
|
workspaceId: "ws-active",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:03:00.000Z",
|
|
}),
|
|
makeAgent({
|
|
id: "agent-archived-workspace",
|
|
cwd: "/tmp/archived-workspace",
|
|
workspaceId: "ws-archived",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:02:00.000Z",
|
|
}),
|
|
makeAgent({
|
|
id: "agent-archived-project",
|
|
cwd: "/tmp/archived-project",
|
|
workspaceId: "ws-archived-project",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:01:00.000Z",
|
|
}),
|
|
{
|
|
...makeAgent({
|
|
id: "agent-archived",
|
|
cwd: "/tmp/active",
|
|
workspaceId: "ws-active",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
archivedAt,
|
|
},
|
|
];
|
|
|
|
const result = await session.listFetchAgentsEntries({
|
|
type: "fetch_agents_request",
|
|
requestId: "req-active-agents",
|
|
scope: "active",
|
|
filter: { includeArchived: true },
|
|
});
|
|
|
|
expect(agentIdsFromEntries(result.entries)).toEqual(["agent-active", "agent-subdir"]);
|
|
expect(result.pageInfo.hasMore).toBe(false);
|
|
});
|
|
|
|
test("active-scoped fetch_agents pages within active scope instead of global history", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-active-pages",
|
|
rootPath: "/tmp/pages",
|
|
kind: "non_git",
|
|
displayName: "pages",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const activeOne = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-active-one",
|
|
projectId: project.projectId,
|
|
cwd: "/tmp/pages/one",
|
|
kind: "directory",
|
|
displayName: "one",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const activeTwo = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-active-two",
|
|
projectId: project.projectId,
|
|
cwd: "/tmp/pages/two",
|
|
kind: "directory",
|
|
displayName: "two",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const archivedWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-stale",
|
|
projectId: project.projectId,
|
|
cwd: "/tmp/pages/stale",
|
|
kind: "directory",
|
|
displayName: "stale",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
archivedAt: "2026-03-02T12:00:00.000Z",
|
|
});
|
|
|
|
session.projectRegistry.list = async () => [project];
|
|
session.projectRegistry.get = async () => project;
|
|
session.workspaceRegistry.list = async () => [activeOne, activeTwo, archivedWorkspace];
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: "active-one",
|
|
cwd: "/tmp/pages/one",
|
|
workspaceId: "ws-active-one",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:03:00.000Z",
|
|
}),
|
|
makeAgent({
|
|
id: "stale-between",
|
|
cwd: "/tmp/pages/stale",
|
|
workspaceId: "ws-stale",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:02:00.000Z",
|
|
}),
|
|
makeAgent({
|
|
id: "active-two",
|
|
cwd: "/tmp/pages/two",
|
|
workspaceId: "ws-active-two",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:01:00.000Z",
|
|
}),
|
|
];
|
|
|
|
const firstPage = await session.listFetchAgentsEntries({
|
|
type: "fetch_agents_request",
|
|
requestId: "req-active-page-1",
|
|
scope: "active",
|
|
page: { limit: 1 },
|
|
});
|
|
const secondPage = await session.listFetchAgentsEntries({
|
|
type: "fetch_agents_request",
|
|
requestId: "req-active-page-2",
|
|
scope: "active",
|
|
page: { limit: 1, cursor: firstPage.pageInfo.nextCursor },
|
|
});
|
|
|
|
expect(agentIdsFromEntries(firstPage.entries)).toEqual(["active-one"]);
|
|
expect(firstPage.pageInfo.hasMore).toBe(true);
|
|
expect(agentIdsFromEntries(secondPage.entries)).toEqual(["active-two"]);
|
|
expect(secondPage.pageInfo.hasMore).toBe(false);
|
|
});
|
|
|
|
test("legacy unscoped fetch_agents keeps global workspace behavior", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const legacyRoot = path.resolve("/tmp/legacy");
|
|
const activeCwd = path.join(legacyRoot, "active");
|
|
const archivedCwd = path.join(legacyRoot, "archived");
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-legacy-global",
|
|
rootPath: legacyRoot,
|
|
kind: "non_git",
|
|
displayName: "legacy",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const activeWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-legacy-active",
|
|
projectId: project.projectId,
|
|
cwd: activeCwd,
|
|
kind: "directory",
|
|
displayName: "active",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const archivedWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-legacy-archived",
|
|
projectId: project.projectId,
|
|
cwd: archivedCwd,
|
|
kind: "directory",
|
|
displayName: "archived",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
archivedAt: "2026-03-02T12:00:00.000Z",
|
|
});
|
|
|
|
session.projectRegistry.get = async () => project;
|
|
session.workspaceRegistry.list = async () => [activeWorkspace, archivedWorkspace];
|
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
|
[activeWorkspace, archivedWorkspace].find(
|
|
(workspace) => workspace.workspaceId === workspaceId,
|
|
) ?? null;
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: "legacy-active",
|
|
cwd: activeCwd,
|
|
workspaceId: "ws-legacy-active",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:01:00.000Z",
|
|
}),
|
|
makeAgent({
|
|
id: "legacy-archived-workspace",
|
|
cwd: archivedCwd,
|
|
workspaceId: "ws-legacy-archived",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
];
|
|
|
|
const result = await session.listFetchAgentsEntries({
|
|
type: "fetch_agents_request",
|
|
requestId: "req-legacy-global",
|
|
});
|
|
|
|
expect(agentIdsFromEntries(result.entries)).toEqual([
|
|
"legacy-active",
|
|
"legacy-archived-workspace",
|
|
]);
|
|
});
|
|
|
|
test("fetch_agent_history_request pages archived historical rows separately", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const historyCwd = path.resolve("/tmp/history");
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-history",
|
|
rootPath: historyCwd,
|
|
kind: "non_git",
|
|
displayName: "history",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-history",
|
|
projectId: project.projectId,
|
|
cwd: historyCwd,
|
|
kind: "directory",
|
|
displayName: "history",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
archivedAt: "2026-03-02T12:00:00.000Z",
|
|
});
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async () => project;
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.workspaceRegistry.get = async () => workspace;
|
|
session.listAgentPayloads = async () => [
|
|
{
|
|
...makeAgent({
|
|
id: "history-archived",
|
|
cwd: historyCwd,
|
|
workspaceId: "ws-history",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
archivedAt: "2026-03-02T12:00:00.000Z",
|
|
},
|
|
];
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_agent_history_request",
|
|
requestId: "req-history",
|
|
page: { limit: 25 },
|
|
});
|
|
|
|
expect(emitted).toEqual([
|
|
{
|
|
type: "fetch_agent_history_response",
|
|
payload: expect.objectContaining({
|
|
requestId: "req-history",
|
|
entries: [
|
|
expect.objectContaining({
|
|
agent: expect.objectContaining({ id: "history-archived" }),
|
|
}),
|
|
],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: null,
|
|
hasMore: false,
|
|
},
|
|
}),
|
|
},
|
|
]);
|
|
expect(session.agentUpdates.hasSubscription()).toBe(false);
|
|
});
|
|
|
|
test("fetch_agent_history_request filters across history and paginates chronologically", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const historyCwd = path.resolve("/tmp/history-search");
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-search",
|
|
rootPath: historyCwd,
|
|
kind: "non_git",
|
|
displayName: "history-search",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-search",
|
|
projectId: project.projectId,
|
|
cwd: historyCwd,
|
|
kind: "directory",
|
|
displayName: "history-search",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async () => project;
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.workspaceRegistry.get = async () => workspace;
|
|
session.listAgentPayloads = async () => [
|
|
// Search keeps chronological order, skipping unrelated rows between pages.
|
|
{
|
|
...makeAgent({
|
|
id: "weak",
|
|
cwd: historyCwd,
|
|
workspaceId: "ws-search",
|
|
status: "idle",
|
|
updatedAt: "2026-03-03T12:00:00.000Z",
|
|
}),
|
|
title: "Unbilled usage report",
|
|
},
|
|
{
|
|
...makeAgent({
|
|
id: "unrelated",
|
|
cwd: historyCwd,
|
|
workspaceId: "ws-search",
|
|
status: "idle",
|
|
updatedAt: "2026-03-02T12:00:00.000Z",
|
|
}),
|
|
title: "Terminal resize fix",
|
|
},
|
|
{
|
|
...makeAgent({
|
|
id: "strong",
|
|
cwd: historyCwd,
|
|
workspaceId: "ws-search",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
title: "Add Stripe billing",
|
|
},
|
|
];
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_agent_history_request",
|
|
requestId: "req-search-truncated",
|
|
search: "bill",
|
|
page: { limit: 1 },
|
|
});
|
|
|
|
expect(emitted).toHaveLength(1);
|
|
const first = filterByType(emitted, "fetch_agent_history_response")[0];
|
|
expect(first.payload.entries.map((entry) => entry.agent.id)).toEqual(["weak"]);
|
|
expect(first.payload.pageInfo.hasMore).toBe(true);
|
|
expect(first.payload.entries[0].searchScore).toBeUndefined();
|
|
await session.handleMessage({
|
|
type: "fetch_agent_history_request",
|
|
requestId: "req-search-next",
|
|
search: "bill",
|
|
page: { limit: 1, cursor: first.payload.pageInfo.nextCursor! },
|
|
});
|
|
expect(emitted).toHaveLength(2);
|
|
const second = filterByType(emitted, "fetch_agent_history_response")[1];
|
|
expect(second.payload.entries.map((entry) => entry.agent.id)).toEqual(["strong"]);
|
|
expect(second.payload.pageInfo.hasMore).toBe(false);
|
|
});
|
|
|
|
test("fetch_agent_history_request rejects a malformed search cursor", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const historyCwd = path.resolve("/tmp/history-cursor");
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-cursor",
|
|
rootPath: historyCwd,
|
|
kind: "non_git",
|
|
displayName: "history-cursor",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-cursor",
|
|
projectId: project.projectId,
|
|
cwd: historyCwd,
|
|
kind: "directory",
|
|
displayName: "history-cursor",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async () => project;
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.workspaceRegistry.get = async () => workspace;
|
|
session.listAgentPayloads = async () => [
|
|
{
|
|
...makeAgent({
|
|
id: "match",
|
|
cwd: historyCwd,
|
|
workspaceId: "ws-cursor",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
title: "Add Stripe billing",
|
|
},
|
|
];
|
|
|
|
// Search uses the same validated chronological cursor as unfiltered history.
|
|
await session.handleMessage({
|
|
type: "fetch_agent_history_request",
|
|
requestId: "req-cursor",
|
|
search: "billing",
|
|
page: { limit: 25, cursor: "eyJpZCI6ImFnZW50In0=" },
|
|
});
|
|
|
|
expect(emitted).toEqual([
|
|
{
|
|
type: "rpc_error",
|
|
payload: expect.objectContaining({
|
|
requestId: "req-cursor",
|
|
requestType: "fetch_agent_history_request",
|
|
code: "invalid_cursor",
|
|
}),
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("fetch_agent_history_request skips rows whose workspace project record is missing", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const stableCwd = path.resolve("/tmp/stable-history");
|
|
const orphanCwd = path.resolve("/tmp/orphan-history");
|
|
const stableProject = createPersistedProjectRecord({
|
|
projectId: "proj-stable-history",
|
|
rootPath: stableCwd,
|
|
kind: "non_git",
|
|
displayName: "stable history",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const stableWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-stable-history",
|
|
projectId: stableProject.projectId,
|
|
cwd: stableCwd,
|
|
kind: "directory",
|
|
displayName: "stable history",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const orphanWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-orphan-history",
|
|
projectId: orphanCwd,
|
|
cwd: orphanCwd,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) =>
|
|
projectId === stableProject.projectId ? stableProject : null;
|
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
|
[stableWorkspace, orphanWorkspace].find((workspace) => workspace.workspaceId === workspaceId) ??
|
|
null;
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: "history-orphan",
|
|
cwd: orphanCwd,
|
|
workspaceId: orphanWorkspace.workspaceId,
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:01:00.000Z",
|
|
}),
|
|
makeAgent({
|
|
id: "history-stable",
|
|
cwd: stableCwd,
|
|
workspaceId: stableWorkspace.workspaceId,
|
|
status: "closed",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
];
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_agent_history_request",
|
|
requestId: "req-history-orphan",
|
|
page: { limit: 25 },
|
|
});
|
|
|
|
expect(emitted).toEqual([
|
|
{
|
|
type: "fetch_agent_history_response",
|
|
payload: expect.objectContaining({
|
|
requestId: "req-history-orphan",
|
|
entries: [
|
|
expect.objectContaining({
|
|
agent: expect.objectContaining({ id: "history-stable" }),
|
|
project: expect.objectContaining({
|
|
projectKey: stableProject.projectId,
|
|
projectName: "stable history",
|
|
workspaceName: "stable history",
|
|
}),
|
|
}),
|
|
],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: null,
|
|
hasMore: false,
|
|
},
|
|
}),
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("fetch_recent_provider_sessions_request lists importable provider sessions by handle", async () => {
|
|
const emitted: Array<{ type: string; payload: unknown }> = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
|
|
session.emit = (message) => emitted.push(message as { type: string; payload: unknown });
|
|
session.agentManager.listAgents = () => [
|
|
{
|
|
provider: "codex",
|
|
persistence: {
|
|
provider: "codex",
|
|
sessionId: "live-session",
|
|
nativeHandle: "live-handle",
|
|
},
|
|
},
|
|
];
|
|
const importableSessions = [
|
|
makeImportableProviderSession({
|
|
provider: "codex",
|
|
sessionId: "outside-filter",
|
|
nativeHandle: "outside-filter-handle",
|
|
cwd: "/tmp/elsewhere",
|
|
title: "Outside filter",
|
|
lastActivityAt: "2026-04-30T12:05:00.000Z",
|
|
}),
|
|
makeImportableProviderSession({
|
|
provider: "codex",
|
|
sessionId: "stored-session",
|
|
nativeHandle: "stored-handle",
|
|
cwd: "/tmp/recent",
|
|
title: "Already stored",
|
|
lastActivityAt: "2026-04-30T12:04:00.000Z",
|
|
firstPrompt: "stored prompt",
|
|
}),
|
|
makeImportableProviderSession({
|
|
provider: "claude",
|
|
sessionId: "wrong-provider",
|
|
cwd: "/tmp/recent",
|
|
title: "Wrong provider",
|
|
lastActivityAt: "2026-04-30T12:03:00.000Z",
|
|
}),
|
|
makeImportableProviderSession({
|
|
provider: "codex",
|
|
sessionId: "older-session",
|
|
nativeHandle: "older-handle",
|
|
cwd: "/tmp/recent",
|
|
title: "Older than since",
|
|
lastActivityAt: "2026-04-29T23:59:59.000Z",
|
|
}),
|
|
makeImportableProviderSession({
|
|
provider: "codex",
|
|
sessionId: "newer-session",
|
|
nativeHandle: "newer-handle",
|
|
cwd: "/tmp/recent",
|
|
title: "Newer import",
|
|
lastActivityAt: "2026-04-30T12:02:00.000Z",
|
|
firstPrompt: "newer prompt",
|
|
}),
|
|
makeImportableProviderSession({
|
|
provider: "codex",
|
|
sessionId: "second-session",
|
|
nativeHandle: "second-handle",
|
|
cwd: "/tmp/recent",
|
|
title: "Second import",
|
|
lastActivityAt: "2026-04-30T12:00:00.000Z",
|
|
firstPrompt: "second prompt",
|
|
}),
|
|
makeImportableProviderSession({
|
|
provider: "codex",
|
|
sessionId: "third-session",
|
|
nativeHandle: "third-handle",
|
|
cwd: "/tmp/recent",
|
|
title: "Third import",
|
|
lastActivityAt: "2026-04-30T11:59:00.000Z",
|
|
firstPrompt: "third prompt",
|
|
}),
|
|
makeImportableProviderSession({
|
|
provider: "codex",
|
|
sessionId: "live-session",
|
|
nativeHandle: "live-handle",
|
|
cwd: "/tmp/recent",
|
|
title: "Already live",
|
|
lastActivityAt: "2026-04-30T12:01:00.000Z",
|
|
firstPrompt: "live prompt",
|
|
}),
|
|
];
|
|
// The real AgentManager filters by providerFilter at the fan-out level
|
|
// (Phase 1). Mirror that here so the mock matches the contract.
|
|
session.agentManager.listImportableSessions = async (options?: unknown) => {
|
|
const providerFilter = (options as { providerFilter?: Set<string> } | undefined)
|
|
?.providerFilter;
|
|
return {
|
|
sessions: providerFilter
|
|
? importableSessions.filter((entry) => providerFilter.has(entry.provider))
|
|
: importableSessions,
|
|
providerErrors: [],
|
|
};
|
|
};
|
|
session.agentStorage.list = async () => [
|
|
{
|
|
id: "stored-agent",
|
|
provider: "codex",
|
|
cwd: "/tmp/recent",
|
|
createdAt: "2026-04-30T00:00:00.000Z",
|
|
updatedAt: "2026-04-30T00:00:00.000Z",
|
|
title: "Stored",
|
|
labels: {},
|
|
lastStatus: "closed",
|
|
persistence: {
|
|
provider: "codex",
|
|
sessionId: "stored-session",
|
|
nativeHandle: "stored-handle",
|
|
},
|
|
},
|
|
];
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_recent_provider_sessions_request",
|
|
requestId: "req-recent-provider-sessions",
|
|
cwd: "/tmp/recent",
|
|
providers: ["codex"],
|
|
since: "2026-04-30T00:00:00.000Z",
|
|
limit: 2,
|
|
});
|
|
|
|
expect(emitted).toEqual([
|
|
{
|
|
type: "fetch_recent_provider_sessions_response",
|
|
payload: {
|
|
requestId: "req-recent-provider-sessions",
|
|
entries: [
|
|
{
|
|
providerId: "codex",
|
|
providerLabel: "Codex",
|
|
providerHandleId: "newer-handle",
|
|
cwd: "/tmp/recent",
|
|
title: "Newer import",
|
|
firstPromptPreview: "newer prompt",
|
|
lastPromptPreview: "newer prompt",
|
|
lastActivityAt: "2026-04-30T12:02:00.000Z",
|
|
},
|
|
{
|
|
providerId: "codex",
|
|
providerLabel: "Codex",
|
|
providerHandleId: "second-handle",
|
|
cwd: "/tmp/recent",
|
|
title: "Second import",
|
|
firstPromptPreview: "second prompt",
|
|
lastPromptPreview: "second prompt",
|
|
lastActivityAt: "2026-04-30T12:00:00.000Z",
|
|
},
|
|
],
|
|
filteredAlreadyImportedCount: 2,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("fetch_recent_provider_sessions_request forwards providerFilter to agent manager", async () => {
|
|
const emitted: Array<{ type: string; payload: unknown }> = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
let capturedOptions: { providerFilter?: Set<string>; limit?: number } | undefined;
|
|
|
|
session.emit = (message) => emitted.push(message as { type: string; payload: unknown });
|
|
session.agentManager.listAgents = () => [];
|
|
session.agentStorage.list = async () => [];
|
|
session.agentManager.listImportableSessions = async (options?: unknown) => {
|
|
capturedOptions = options as { providerFilter?: Set<string>; limit?: number };
|
|
return {
|
|
sessions: [],
|
|
providerErrors: [{ provider: "claude", message: "Claude listing failed" }],
|
|
};
|
|
};
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_recent_provider_sessions_request",
|
|
requestId: "req-provider-filter",
|
|
cwd: "/tmp/recent",
|
|
providers: ["claude"],
|
|
});
|
|
|
|
expect(capturedOptions?.providerFilter).toBeInstanceOf(Set);
|
|
expect(Array.from(capturedOptions?.providerFilter ?? [])).toEqual(["claude"]);
|
|
expect(emitted).toEqual([
|
|
{
|
|
type: "fetch_recent_provider_sessions_response",
|
|
payload: {
|
|
requestId: "req-provider-filter",
|
|
entries: [],
|
|
providerErrors: [{ provider: "claude", message: "Claude listing failed" }],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("fetch_recent_provider_sessions_request reports filteredAlreadyImportedCount when all candidates are already imported", async () => {
|
|
const emitted: Array<{ type: string; payload: unknown }> = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
|
|
session.emit = (message) => emitted.push(message as { type: string; payload: unknown });
|
|
session.agentManager.listAgents = () => [
|
|
{
|
|
provider: "codex",
|
|
persistence: {
|
|
provider: "codex",
|
|
sessionId: "live-session",
|
|
nativeHandle: "live-handle",
|
|
},
|
|
},
|
|
];
|
|
session.agentStorage.list = async () => [];
|
|
session.agentManager.listImportableSessions = async () => ({
|
|
sessions: [
|
|
{
|
|
provider: "codex",
|
|
providerHandleId: "live-handle",
|
|
cwd: "/tmp/recent",
|
|
title: "Already live",
|
|
firstPromptPreview: "live prompt",
|
|
lastPromptPreview: "live prompt",
|
|
lastActivityAt: new Date("2026-04-30T12:01:00.000Z"),
|
|
},
|
|
],
|
|
providerErrors: [],
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_recent_provider_sessions_request",
|
|
requestId: "req-all-imported",
|
|
cwd: "/tmp/recent",
|
|
providers: ["codex"],
|
|
});
|
|
|
|
expect(emitted).toEqual([
|
|
{
|
|
type: "fetch_recent_provider_sessions_response",
|
|
payload: {
|
|
requestId: "req-all-imported",
|
|
entries: [],
|
|
filteredAlreadyImportedCount: 1,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("fetch_agent_request still resolves archived historical agents", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const agent = {
|
|
...makeAgent({
|
|
id: "archived-history-agent",
|
|
cwd: path.resolve("/tmp/history-detail"),
|
|
workspaceId: "ws-history-detail",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
archivedAt: "2026-03-02T12:00:00.000Z",
|
|
title: "Archived History Agent",
|
|
};
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.resolveAgentIdentifier = async (identifier: string) =>
|
|
identifier === "Archived History Agent"
|
|
? { ok: true, agentId: agent.id }
|
|
: { ok: false, notFound: true, error: `Agent not found: ${identifier}` };
|
|
session.getAgentPayloadById = async (agentId: string) => (agentId === agent.id ? agent : null);
|
|
session.buildProjectPlacementForWorkspaceId = async () => ({
|
|
projectKey: "proj-history-detail",
|
|
projectName: "history detail",
|
|
checkout: {
|
|
cwd: agent.cwd,
|
|
isGit: false,
|
|
currentBranch: null,
|
|
remoteUrl: null,
|
|
worktreeRoot: null,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_agent_request",
|
|
requestId: "req-agent-detail",
|
|
agentId: "Archived History Agent",
|
|
});
|
|
|
|
expect(emitted).toEqual([
|
|
{
|
|
type: "fetch_agent_response",
|
|
payload: {
|
|
requestId: "req-agent-detail",
|
|
agent,
|
|
project: expect.objectContaining({
|
|
projectKey: "proj-history-detail",
|
|
}),
|
|
error: null,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("git branch workspace uses branch as canonical name", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
session.workspaceRegistry.list = async () => [
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-branch",
|
|
projectId: "proj-repo-branch",
|
|
cwd: "/tmp/repo-branch",
|
|
kind: "local_checkout",
|
|
displayName: "feature/name-from-server",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
];
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: "a1",
|
|
cwd: "/tmp/repo-branch",
|
|
status: "running",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
];
|
|
session.buildProjectPlacement = async (cwd: string) => ({
|
|
projectKey: cwd,
|
|
projectName: "repo-branch",
|
|
checkout: {
|
|
cwd,
|
|
isGit: true,
|
|
currentBranch: "feature/name-from-server",
|
|
remoteUrl: "https://github.com/acme/repo-branch.git",
|
|
worktreeRoot: cwd,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
});
|
|
const result = await session.listFetchWorkspacesEntries({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-branch",
|
|
});
|
|
|
|
expect(result.entries).toHaveLength(1);
|
|
expect(result.entries[0]?.name).toBe("feature/name-from-server");
|
|
});
|
|
|
|
test("branch/detached policies and dominant status bucket are deterministic", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
session.workspaceRegistry.list = async () => [
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-status",
|
|
projectId: "proj-repo-status",
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
];
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: "a1",
|
|
cwd: REPO_CWD,
|
|
workspaceId: "ws-repo-status",
|
|
status: "running",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
makeAgent({
|
|
id: "a2",
|
|
cwd: REPO_CWD,
|
|
workspaceId: "ws-repo-status",
|
|
status: "error",
|
|
updatedAt: "2026-03-01T12:01:00.000Z",
|
|
}),
|
|
makeAgent({
|
|
id: "a3",
|
|
cwd: REPO_CWD,
|
|
workspaceId: "ws-repo-status",
|
|
status: "idle",
|
|
updatedAt: "2026-03-01T12:02:00.000Z",
|
|
pendingPermissions: 1,
|
|
}),
|
|
];
|
|
const result = await session.listFetchWorkspacesEntries({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-2",
|
|
});
|
|
|
|
expect(result.entries).toHaveLength(1);
|
|
expect(result.entries[0]?.name).toBe("repo");
|
|
expect(result.entries[0]?.status).toBe("needs_input");
|
|
});
|
|
|
|
test("subdirectory agents contribute to their owning workspace descriptor", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
session.workspaceRegistry.list = async () => [
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-subdir",
|
|
projectId: "proj-repo-subdir",
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
];
|
|
// The agent runs in a subdirectory but carries its owning workspaceId; the
|
|
// subdir cwd is cosmetic and never drives attribution.
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: "a1",
|
|
cwd: "/tmp/repo/packages/app",
|
|
workspaceId: "ws-repo-subdir",
|
|
status: "running",
|
|
updatedAt: "2026-03-01T12:03:00.000Z",
|
|
}),
|
|
];
|
|
|
|
const result = await session.listFetchWorkspacesEntries({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-subdir-agent",
|
|
});
|
|
|
|
expect(result.entries).toHaveLength(1);
|
|
expect(result.entries[0]).toMatchObject({
|
|
id: "ws-repo-subdir",
|
|
status: "running",
|
|
activityAt: null,
|
|
});
|
|
});
|
|
|
|
test("workspace update stream keeps persisted workspace visible after agents stop", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const logger = {
|
|
child: () => logger,
|
|
trace: vi.fn(),
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
|
|
const session = asTestSession(
|
|
new Session({
|
|
messageReceipts: createMessageReceiptsStub(),
|
|
creationService: createTestCreationService(),
|
|
clientId: "test-client",
|
|
permissions: OWNER_PERMISSIONS,
|
|
onMessage: (message) => emitted.push(message),
|
|
logger: asSessionLogger(logger),
|
|
downloadTokenStore: asDownloadTokenStore(),
|
|
pushNotifications: asPushNotifications(),
|
|
paseoHome: "/tmp/paseo-test",
|
|
agentManager: asAgentManager({
|
|
subscribe: () => () => {},
|
|
listAgents: () => [],
|
|
getAgent: () => null,
|
|
}),
|
|
agentStorage: asAgentStorage({
|
|
list: async () => [],
|
|
get: async () => null,
|
|
}),
|
|
projectRegistry: {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [],
|
|
get: async () => null,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
},
|
|
workspaceRegistry: {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-running",
|
|
projectId: "proj-repo-running",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
],
|
|
get: async (workspaceId: string) =>
|
|
workspaceId === "ws-repo-running"
|
|
? createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-running",
|
|
projectId: "proj-repo-running",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
})
|
|
: null,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
},
|
|
scheduleService: asScheduleService(),
|
|
checkoutDiffManager: asCheckoutDiffManager({
|
|
subscribe: async () => ({
|
|
initial: { cwd: "/tmp", files: [], error: null },
|
|
unsubscribe: () => {},
|
|
}),
|
|
scheduleRefreshForCwd: () => {},
|
|
onWorkspaceStateMayHaveChanged: () => {},
|
|
invalidateForge: () => {},
|
|
getMetrics: () => ({
|
|
checkoutDiffTargetCount: 0,
|
|
checkoutDiffSubscriptionCount: 0,
|
|
checkoutDiffWatcherCount: 0,
|
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
|
}),
|
|
dispose: () => {},
|
|
}),
|
|
workspaceGitService: createNoopWorkspaceGitService(),
|
|
daemonConfigStore: asDaemonConfigStore({
|
|
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
|
onChange: () => () => {},
|
|
}),
|
|
mcpBaseUrl: null,
|
|
stt: null,
|
|
tts: null,
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
terminalManager: null,
|
|
}),
|
|
);
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-1",
|
|
filter: undefined,
|
|
subscribe: { subscriptionId: "sub-1" },
|
|
});
|
|
expect(filterByType(emitted, "rpc_error")).toEqual([]);
|
|
session.buildWorkspaceDescriptorMap = async () =>
|
|
new Map([
|
|
[
|
|
"ws-repo-running",
|
|
{
|
|
id: "ws-repo-running",
|
|
projectId: "proj-repo-running",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: REPO_CWD,
|
|
projectKind: "non_git",
|
|
workspaceKind: "directory",
|
|
name: "repo",
|
|
status: "running",
|
|
activityAt: "2026-03-01T12:00:00.000Z",
|
|
},
|
|
],
|
|
]);
|
|
await session.emitWorkspaceUpdateForCwd(REPO_CWD);
|
|
|
|
session.buildWorkspaceDescriptorMap = async () =>
|
|
new Map([
|
|
[
|
|
"ws-repo-running",
|
|
{
|
|
id: "ws-repo-running",
|
|
projectId: "proj-repo-running",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: REPO_CWD,
|
|
projectKind: "non_git",
|
|
workspaceKind: "directory",
|
|
name: "repo",
|
|
status: "done",
|
|
activityAt: null,
|
|
},
|
|
],
|
|
]);
|
|
await session.emitWorkspaceUpdateForCwd(REPO_CWD);
|
|
|
|
const workspaceUpdates = filterByType(emitted, "workspace_update");
|
|
expect(workspaceUpdates).toHaveLength(2);
|
|
expect(workspaceUpdates[0]?.payload.kind).toBe("upsert");
|
|
expect(workspaceUpdates[1]?.payload).toEqual({
|
|
kind: "upsert",
|
|
workspace: {
|
|
id: "ws-repo-running",
|
|
projectId: "proj-repo-running",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: REPO_CWD,
|
|
projectKind: "non_git",
|
|
workspaceKind: "directory",
|
|
name: "repo",
|
|
status: "done",
|
|
activityAt: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("archiving the last workspace emits a remove carrying the now-empty project", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-empty-after-archive",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const archivedWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-last",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
archivedAt: "2026-03-02T12:00:00.000Z",
|
|
});
|
|
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => [archivedWorkspace];
|
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
|
workspaceId === archivedWorkspace.workspaceId ? archivedWorkspace : null;
|
|
|
|
archivedWorkspace.archivedAt = null;
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-1",
|
|
subscribe: { subscriptionId: "sub-1" },
|
|
});
|
|
archivedWorkspace.archivedAt = "2026-03-02T12:00:00.000Z";
|
|
// The archived workspace no longer resolves to an active descriptor.
|
|
session.buildWorkspaceDescriptorMap = async () => new Map();
|
|
|
|
await session.emitWorkspaceUpdatesForWorkspaceIds([archivedWorkspace.workspaceId]);
|
|
|
|
const removeUpdate = filterByType(emitted, "workspace_update").find(
|
|
(message) => message.payload.kind === "remove",
|
|
);
|
|
expect(removeUpdate?.payload).toEqual({
|
|
kind: "remove",
|
|
id: archivedWorkspace.workspaceId,
|
|
emptyProject: {
|
|
projectId: project.projectId,
|
|
projectDisplayName: "repo",
|
|
projectCustomName: null,
|
|
projectCustomIconRevision: null,
|
|
projectRootPath: REPO_CWD,
|
|
projectKind: "git",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("project.remove.request archives active workspaces and removes the project record", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-remove-with-workspace",
|
|
projectKey: "remote:github.com/acme/remove-with-workspace",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-project-remove",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const projects = new Map<string, PersistedProjectRecord>([[project.projectId, project]]);
|
|
const workspaces = new Map<string, PersistedWorkspaceRecord>([
|
|
[workspace.workspaceId, workspace],
|
|
]);
|
|
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.projectRegistry.remove = async (projectId: string) => {
|
|
projects.delete(projectId);
|
|
};
|
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
|
workspaces.get(workspaceId) ?? null;
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => {
|
|
const existing = workspaces.get(workspaceId);
|
|
if (!existing) return;
|
|
workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt });
|
|
};
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-project-remove",
|
|
filter: undefined,
|
|
subscribe: { subscriptionId: "sub-project-remove" },
|
|
});
|
|
session.listAgentPayloads = async () => [];
|
|
session.buildWorkspaceDescriptorMap = async (options: { workspaceIds?: Iterable<string> }) => {
|
|
const workspaceIds = Array.from(options.workspaceIds ?? workspaces.keys());
|
|
const descriptors = new Map<string, unknown>();
|
|
for (const workspaceId of workspaceIds) {
|
|
const record = workspaces.get(workspaceId);
|
|
if (!record || record.archivedAt) continue;
|
|
descriptors.set(workspaceId, {
|
|
id: record.workspaceId,
|
|
projectId: record.projectId,
|
|
projectDisplayName: "repo",
|
|
projectRootPath: REPO_CWD,
|
|
projectKind: "git",
|
|
workspaceKind: record.kind,
|
|
name: record.displayName,
|
|
status: "idle",
|
|
activityAt: null,
|
|
});
|
|
}
|
|
return descriptors;
|
|
};
|
|
|
|
await session.handleMessage({
|
|
type: "project.remove.request",
|
|
projectId: project.projectId,
|
|
requestId: "req-remove-project",
|
|
});
|
|
|
|
expect(projects.has(project.projectId)).toBe(false);
|
|
expect(workspaces.get(workspace.workspaceId)).toEqual({
|
|
...workspace,
|
|
updatedAt: expect.any(String),
|
|
archivedAt: expect.any(String),
|
|
});
|
|
expect(findByType(emitted, "project.remove.response")?.payload).toEqual({
|
|
requestId: "req-remove-project",
|
|
projectId: project.projectId,
|
|
accepted: true,
|
|
removedWorkspaceIds: [workspace.workspaceId],
|
|
error: null,
|
|
});
|
|
const workspaceUpdates = filterByType(emitted, "workspace_update");
|
|
expect(workspaceUpdates.at(-1)?.payload).toEqual({
|
|
kind: "remove",
|
|
id: workspace.workspaceId,
|
|
removedProjectId: project.projectId,
|
|
});
|
|
});
|
|
|
|
test("project.remove.request removes an already-empty project", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-remove-empty",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const archivedWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-project-remove-empty",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
archivedAt: "2026-03-02T12:00:00.000Z",
|
|
});
|
|
const projects = new Map<string, PersistedProjectRecord>([[project.projectId, project]]);
|
|
const workspaces = new Map<string, PersistedWorkspaceRecord>([
|
|
[archivedWorkspace.workspaceId, archivedWorkspace],
|
|
]);
|
|
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.projectRegistry.remove = async (projectId: string) => {
|
|
projects.delete(projectId);
|
|
};
|
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
|
workspaces.get(workspaceId) ?? null;
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => {
|
|
const existing = workspaces.get(workspaceId);
|
|
if (!existing) return;
|
|
workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt });
|
|
};
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-empty-project-remove",
|
|
subscribe: { subscriptionId: "sub-empty-project-remove" },
|
|
});
|
|
session.listAgentPayloads = async () => [];
|
|
session.buildWorkspaceDescriptorMap = async () => new Map();
|
|
|
|
await session.handleMessage({
|
|
type: "project.remove.request",
|
|
projectId: project.projectId,
|
|
requestId: "req-remove-empty-project",
|
|
});
|
|
|
|
expect(projects.has(project.projectId)).toBe(false);
|
|
expect(workspaces.get(archivedWorkspace.workspaceId)).toEqual(archivedWorkspace);
|
|
expect(findByType(emitted, "project.remove.response")?.payload).toEqual({
|
|
requestId: "req-remove-empty-project",
|
|
projectId: project.projectId,
|
|
accepted: true,
|
|
removedWorkspaceIds: [],
|
|
error: null,
|
|
});
|
|
expect(filterByType(emitted, "workspace_update")).toEqual([
|
|
{
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "remove",
|
|
id: archivedWorkspace.workspaceId,
|
|
removedProjectId: project.projectId,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("create paseo worktree response preserves an explicit non-Git project", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const createdAt = "2026-05-12T12:00:00.000Z";
|
|
vi.setSystemTime(new Date(createdAt));
|
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-")));
|
|
const repoDir = path.join(tempDir, "repo");
|
|
const paseoHome = path.join(tempDir, "paseo-home");
|
|
mkdirSync(repoDir, { recursive: true });
|
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["config", "user.email", "test@test.com"], {
|
|
cwd: repoDir,
|
|
stdio: "pipe",
|
|
});
|
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
|
|
writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
|
|
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
|
cwd: repoDir,
|
|
stdio: "pipe",
|
|
});
|
|
const workspaceGitService = createNoopWorkspaceGitService();
|
|
workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => {
|
|
if (cwd === repoDir) {
|
|
return createWorkspaceRuntimeSnapshot(cwd, {
|
|
git: {
|
|
repoRoot: repoDir,
|
|
currentBranch: "main",
|
|
remoteUrl: null,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (cwd.includes("worktree-123")) {
|
|
return createWorkspaceRuntimeSnapshot(cwd, {
|
|
git: {
|
|
repoRoot: cwd,
|
|
currentBranch: "worktree-123",
|
|
remoteUrl: null,
|
|
isPaseoOwnedWorktree: true,
|
|
mainRepoRoot: repoDir,
|
|
},
|
|
});
|
|
}
|
|
|
|
return createWorkspaceRuntimeSnapshot(cwd, {
|
|
git: {
|
|
repoRoot: cwd,
|
|
currentBranch: "main",
|
|
remoteUrl: null,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
});
|
|
});
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({
|
|
workspaceGitService,
|
|
}),
|
|
);
|
|
|
|
const workspaces = new Map();
|
|
const explicitProject = createPersistedProjectRecord({
|
|
projectId: "prj_explicit_non_git",
|
|
rootPath: path.join(tempDir, "selected-project"),
|
|
kind: "non_git",
|
|
displayName: "Selected project",
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
});
|
|
const projects = new Map([[explicitProject.projectId, explicitProject]]);
|
|
session.paseoHome = paseoHome;
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.projectRegistry.getOrCreateActiveByRoot = async (input) => {
|
|
const existing = Array.from(projects.values()).find(
|
|
(project) => !project.archivedAt && project.rootPath === input.rootPath,
|
|
);
|
|
if (existing) return existing;
|
|
const project = createPersistedProjectRecord({
|
|
projectId: `prj_${projects.size.toString().padStart(16, "0")}`,
|
|
rootPath: input.rootPath,
|
|
kind: input.kind,
|
|
displayName: input.displayName,
|
|
createdAt: input.timestamp,
|
|
updatedAt: input.timestamp,
|
|
});
|
|
projects.set(project.projectId, project);
|
|
return project;
|
|
};
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.emit = (message: unknown) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
try {
|
|
await session.handleCreatePaseoWorktreeRequest({
|
|
type: "create_paseo_worktree_request",
|
|
cwd: repoDir,
|
|
projectId: explicitProject.projectId,
|
|
worktreeSlug: "worktree-123",
|
|
requestId: "req-worktree",
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
|
|
const response = findByType(emitted, "create_paseo_worktree_response");
|
|
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace).toMatchObject({
|
|
projectId: explicitProject.projectId,
|
|
projectDisplayName: explicitProject.displayName,
|
|
projectRootPath: explicitProject.rootPath,
|
|
projectKind: "non_git",
|
|
workspaceKind: "worktree",
|
|
name: "worktree-123",
|
|
status: "done",
|
|
statusEnteredAt: createdAt,
|
|
});
|
|
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
|
|
expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123"));
|
|
expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true);
|
|
expect(projects.get(explicitProject.projectId)).toEqual({
|
|
...explicitProject,
|
|
projectKey: deriveProjectKey({
|
|
rootPath: explicitProject.rootPath,
|
|
remoteUrl: null,
|
|
worktreeRoot: null,
|
|
mainRepoRoot: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
test("workspace updates stay scoped to the matching cwd", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const archivedWorkspaceIds: string[] = [];
|
|
const missingRoot = path.join(tmpdir(), `paseo-scoped-workspace-${Date.now()}`);
|
|
rmSync(missingRoot, { recursive: true, force: true });
|
|
const mainCwd = path.join(missingRoot, "main");
|
|
const featureCwd = path.join(missingRoot, "feature");
|
|
const session = createSessionForWorkspaceTests();
|
|
session.workspaceRegistry.list = async () => [
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-main",
|
|
projectId: "proj-repo-main",
|
|
cwd: mainCwd,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-feature",
|
|
projectId: "proj-repo-main",
|
|
cwd: featureCwd,
|
|
kind: "worktree",
|
|
displayName: "feature",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
];
|
|
session.workspaceRegistry.archive = async (workspaceId) => {
|
|
archivedWorkspaceIds.push(workspaceId);
|
|
};
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-dedup",
|
|
filter: undefined,
|
|
subscribe: { subscriptionId: "sub-dedup" },
|
|
});
|
|
session.buildWorkspaceDescriptorMap = async () =>
|
|
new Map([
|
|
[
|
|
"ws-repo-feature",
|
|
{
|
|
id: "ws-repo-feature",
|
|
projectId: "proj-repo-main",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: mainCwd,
|
|
projectKind: "git",
|
|
workspaceKind: "worktree",
|
|
name: "feature",
|
|
status: "running",
|
|
activityAt: "2026-03-01T12:00:00.000Z",
|
|
},
|
|
],
|
|
]);
|
|
session.onMessage = (message: unknown) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
|
|
await session.emitWorkspaceUpdateForCwd(featureCwd);
|
|
await flushWorkspaceUpdateBackgroundWork();
|
|
|
|
const workspaceUpdates = filterByType(emitted, "workspace_update");
|
|
expect(workspaceUpdates).toEqual([
|
|
{
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
workspace: expect.objectContaining({ id: "ws-repo-feature" }),
|
|
},
|
|
},
|
|
]);
|
|
expect(archivedWorkspaceIds).toEqual([]);
|
|
});
|
|
|
|
test("open_project_request registers a workspace before any agent exists", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => {
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "prj_githubruntime",
|
|
rootPath: allocation.rootPath,
|
|
kind: allocation.kind,
|
|
displayName: allocation.displayName,
|
|
createdAt: allocation.timestamp,
|
|
updatedAt: allocation.timestamp,
|
|
});
|
|
projects.set(project.projectId, project);
|
|
return project;
|
|
};
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.buildProjectPlacement = async (cwd: string) => ({
|
|
projectKey: cwd,
|
|
projectName: "repo",
|
|
checkout: {
|
|
cwd,
|
|
isGit: false,
|
|
currentBranch: null,
|
|
remoteUrl: null,
|
|
worktreeRoot: null,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd: REPO_CWD,
|
|
requestId: "req-open",
|
|
});
|
|
|
|
const registeredWorkspace = Array.from(workspaces.values()).find(
|
|
(workspace) => workspace.cwd === REPO_CWD,
|
|
);
|
|
expect(registeredWorkspace).toBeTruthy();
|
|
const response = findByType(emitted, "open_project_response");
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace?.id).toBe(registeredWorkspace?.workspaceId);
|
|
});
|
|
|
|
test("import_agent_request registers a workspace for a never-seen cwd", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
},
|
|
});
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const importedCwd = path.resolve("/tmp/imported-project");
|
|
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.buildProjectPlacement = async (cwd: string) => ({
|
|
projectKey: cwd,
|
|
projectName: "imported",
|
|
checkout: {
|
|
cwd,
|
|
isGit: false,
|
|
currentBranch: null,
|
|
remoteUrl: null,
|
|
worktreeRoot: null,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
});
|
|
|
|
const managed = makeManagedAgent({
|
|
id: "imported-agent",
|
|
cwd: importedCwd,
|
|
lifecycle: "idle",
|
|
updatedAt: "2026-05-21T00:00:00.000Z",
|
|
});
|
|
session.agentManager.listAgents = () => [managed];
|
|
session.agentManager.importProviderSession = async () => managed;
|
|
session.agentManager.getTimeline = () => [];
|
|
session.agentManager.setTitle = async () => undefined;
|
|
session.agentStorage.list = async () => [];
|
|
session.agentStorage.get = async () => null;
|
|
session.agentUpdates.forwardLiveAgent = async () => undefined;
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-import",
|
|
filter: undefined,
|
|
subscribe: { subscriptionId: "sub-import" },
|
|
});
|
|
session.buildWorkspaceDescriptorMap = async () => {
|
|
const workspace = Array.from(workspaces.values()).find(
|
|
(candidate) => candidate.cwd === importedCwd,
|
|
);
|
|
if (!workspace) {
|
|
return new Map();
|
|
}
|
|
return new Map([
|
|
[
|
|
workspace.workspaceId,
|
|
{
|
|
id: workspace.workspaceId,
|
|
projectId: workspace.projectId,
|
|
projectDisplayName: "imported-project",
|
|
projectRootPath: importedCwd,
|
|
workspaceDirectory: importedCwd,
|
|
projectKind: "non_git",
|
|
workspaceKind: "directory",
|
|
name: "imported-project",
|
|
status: "done",
|
|
activityAt: null,
|
|
},
|
|
],
|
|
]);
|
|
};
|
|
|
|
await session.handleMessage({
|
|
type: "import_agent_request",
|
|
requestId: "req-import",
|
|
providerId: "codex",
|
|
providerHandleId: "session-xyz",
|
|
cwd: importedCwd,
|
|
});
|
|
|
|
const importedWorkspace = Array.from(workspaces.values()).find(
|
|
(workspace) => workspace.cwd === importedCwd,
|
|
);
|
|
expect(importedWorkspace).toBeTruthy();
|
|
const workspaceUpdates = filterByType(emitted, "workspace_update");
|
|
expect(workspaceUpdates.length).toBeGreaterThan(0);
|
|
expect(
|
|
workspaceUpdates.some(
|
|
(update) =>
|
|
update.payload.kind === "upsert" &&
|
|
update.payload.workspace.workspaceDirectory === importedCwd,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("import_agent_request imports into the workspace that opened the import sheet", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const workspaceId = "ws-repo-running";
|
|
let importedWorkspaceId: string | undefined;
|
|
let workspaceCreated = false;
|
|
|
|
session.projectRegistry.get = async () =>
|
|
createPersistedProjectRecord({
|
|
projectId: "proj-repo-running",
|
|
rootPath: REPO_CWD,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
session.workspaceRegistry.upsert = async () => {
|
|
workspaceCreated = true;
|
|
};
|
|
|
|
session.agentManager.importProviderSession = async (input: unknown) => {
|
|
importedWorkspaceId = (input as { workspaceId: string }).workspaceId;
|
|
return makeManagedAgent({
|
|
id: "imported-agent",
|
|
cwd: REPO_CWD,
|
|
workspaceId: importedWorkspaceId,
|
|
lifecycle: "idle",
|
|
updatedAt: "2026-05-21T00:00:00.000Z",
|
|
});
|
|
};
|
|
session.agentManager.getTimeline = () => [];
|
|
session.agentStorage.list = async () => [];
|
|
session.agentStorage.get = async () => null;
|
|
session.agentUpdates.forwardLiveAgent = async () => undefined;
|
|
|
|
await session.handleMessage({
|
|
type: "import_agent_request",
|
|
requestId: "req-import-current-workspace",
|
|
providerId: "codex",
|
|
providerHandleId: "session-xyz",
|
|
cwd: REPO_CWD,
|
|
workspaceId,
|
|
});
|
|
|
|
expect(importedWorkspaceId).toBe(workspaceId);
|
|
expect(workspaceCreated).toBe(false);
|
|
});
|
|
|
|
test("import_agent_request maps an import failure to agent_create_failed", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
session.projectRegistry.get = async () =>
|
|
createPersistedProjectRecord({
|
|
projectId: "proj-repo-running",
|
|
rootPath: REPO_CWD,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
session.agentStorage.list = async () => [];
|
|
session.agentManager.importProviderSession = async () => {
|
|
throw new Error("provider session is unavailable");
|
|
};
|
|
|
|
await session.handleMessage({
|
|
type: "import_agent_request",
|
|
requestId: "req-failed-import",
|
|
providerId: "codex",
|
|
providerHandleId: "stale-session",
|
|
cwd: REPO_CWD,
|
|
workspaceId: "ws-repo-running",
|
|
});
|
|
|
|
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
|
status: "agent_create_failed",
|
|
requestId: "req-failed-import",
|
|
error: "provider session is unavailable",
|
|
});
|
|
});
|
|
|
|
test("open_project_response returns immediately even when the GitHub fetch is slow", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const cwd = path.resolve("/tmp/slow-github-repo");
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceGitService.getCheckout = async (requestedCwd: string) => ({
|
|
cwd: requestedCwd,
|
|
isGit: true,
|
|
currentBranch: "main",
|
|
remoteUrl: "https://github.com/acme/slow.git",
|
|
worktreeRoot: requestedCwd,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
});
|
|
let resolveSnapshot: (snapshot: WorkspaceGitRuntimeSnapshot) => void = () => {};
|
|
const snapshotPromise = new Promise<WorkspaceGitRuntimeSnapshot>((resolve) => {
|
|
resolveSnapshot = resolve;
|
|
});
|
|
session.workspaceGitService.getSnapshot = (requestedCwd: string) => {
|
|
void requestedCwd;
|
|
return snapshotPromise;
|
|
};
|
|
|
|
const start = Date.now();
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd,
|
|
requestId: "req-slow-github",
|
|
});
|
|
const elapsedMs = Date.now() - start;
|
|
|
|
expect(elapsedMs).toBeLessThan(500);
|
|
|
|
const response = findByType(emitted, "open_project_response");
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
|
|
expect(response?.payload.workspace?.workspaceDirectory).toBe(cwd);
|
|
expect(response?.payload.workspace?.gitRuntime).toBeUndefined();
|
|
expect(response?.payload.workspace?.githubRuntime).toBeUndefined();
|
|
|
|
resolveSnapshot(createWorkspaceRuntimeSnapshot(cwd));
|
|
});
|
|
|
|
test("open_project_request emits a workspace_update with githubRuntime once the snapshot resolves", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const cwd = path.resolve("/tmp/github-runtime-repo");
|
|
const snapshot = createWorkspaceRuntimeSnapshot(cwd);
|
|
|
|
let listener: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
|
|
const peeked = { value: null as WorkspaceGitRuntimeSnapshot | null };
|
|
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => {
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "prj_githubruntime",
|
|
rootPath: allocation.rootPath,
|
|
kind: allocation.kind,
|
|
displayName: allocation.displayName,
|
|
createdAt: allocation.timestamp,
|
|
updatedAt: allocation.timestamp,
|
|
});
|
|
projects.set(project.projectId, project);
|
|
return project;
|
|
};
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceGitService.getCheckout = async (requestedCwd: string) => ({
|
|
cwd: requestedCwd,
|
|
isGit: true,
|
|
currentBranch: "main",
|
|
remoteUrl: "https://github.com/acme/repo.git",
|
|
worktreeRoot: requestedCwd,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
});
|
|
session.workspaceGitService.peekSnapshot = () => peeked.value;
|
|
session.workspaceGitService.registerWorkspace = (
|
|
_params,
|
|
incomingListener: (snapshot: WorkspaceGitRuntimeSnapshot) => void,
|
|
) => {
|
|
listener = incomingListener;
|
|
return { unsubscribe: () => {} };
|
|
};
|
|
session.workspaceGitService.getSnapshot = async () => {
|
|
peeked.value = snapshot;
|
|
listener?.(snapshot);
|
|
return snapshot;
|
|
};
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-open-project",
|
|
filter: undefined,
|
|
subscribe: { subscriptionId: "sub-open-project" },
|
|
});
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd,
|
|
requestId: "req-runtime-update",
|
|
});
|
|
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
const updates = filterByType(emitted, "workspace_update");
|
|
const upsertedWithGitHub = updates
|
|
.map((update) => update.payload)
|
|
.filter(
|
|
(payload): payload is WorkspaceUpsertPayload =>
|
|
payload.kind === "upsert" && payload.workspace.workspaceDirectory === cwd,
|
|
)
|
|
.find((payload) => payload.workspace.githubRuntime?.pullRequest);
|
|
expect(upsertedWithGitHub?.workspace.githubRuntime?.pullRequest).toEqual(
|
|
expect.objectContaining({ url: "https://github.com/acme/repo/pull/123" }),
|
|
);
|
|
});
|
|
|
|
interface WorkspaceUpsertPayload {
|
|
kind: "upsert";
|
|
workspace: {
|
|
id: string;
|
|
workspaceDirectory?: string;
|
|
githubRuntime?: {
|
|
pullRequest?: { url?: string } | null;
|
|
} | null;
|
|
};
|
|
}
|
|
|
|
test("open_project_request does not match a new child directory to an existing parent workspace", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const home = path.resolve("/home/developer");
|
|
const worktree = path.join(home, ".paseo", "worktrees", "project-config-lifecycle-textarea");
|
|
|
|
projects.set(
|
|
home,
|
|
createPersistedProjectRecord({
|
|
projectId: home,
|
|
rootPath: home,
|
|
kind: "non_git",
|
|
displayName: "developer",
|
|
createdAt: "2026-04-24T09:00:00.000Z",
|
|
updatedAt: "2026-04-24T09:00:00.000Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
home,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: home,
|
|
projectId: home,
|
|
cwd: home,
|
|
kind: "directory",
|
|
displayName: "developer",
|
|
createdAt: "2026-04-24T09:00:00.000Z",
|
|
updatedAt: "2026-04-24T09:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd: worktree,
|
|
requestId: "req-open-worktree-under-home",
|
|
});
|
|
|
|
const response = findByType(emitted, "open_project_response");
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
|
|
expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree);
|
|
expect(Array.from(workspaces.values()).some((workspace) => workspace.cwd === worktree)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("open_project_request does not unarchive an archived parent workspace for a new child directory", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const home = path.resolve("/home/developer");
|
|
const worktree = path.join(home, ".paseo", "worktrees", "project-config-lifecycle-textarea");
|
|
const archivedAt = "2026-04-24T08:00:00.000Z";
|
|
|
|
projects.set(
|
|
home,
|
|
createPersistedProjectRecord({
|
|
projectId: home,
|
|
rootPath: home,
|
|
kind: "non_git",
|
|
displayName: "moboudra",
|
|
createdAt: "2026-04-24T07:00:00.000Z",
|
|
updatedAt: archivedAt,
|
|
archivedAt,
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
home,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: home,
|
|
projectId: home,
|
|
cwd: home,
|
|
kind: "directory",
|
|
displayName: "moboudra",
|
|
createdAt: "2026-04-24T07:00:00.000Z",
|
|
updatedAt: archivedAt,
|
|
archivedAt,
|
|
}),
|
|
);
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd: worktree,
|
|
requestId: "req-open-worktree-under-archived-home",
|
|
});
|
|
|
|
const response = findByType(emitted, "open_project_response");
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
|
|
expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree);
|
|
expect(workspaces.get(home)?.archivedAt).toBe(archivedAt);
|
|
expect(projects.get(home)?.archivedAt).toBe(archivedAt);
|
|
});
|
|
|
|
test("open_project_request reclassifies an archived directory workspace when git metadata becomes available", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const repoRoot = path.resolve("/home/developer/dev/paseo");
|
|
const cwd = path.join(
|
|
path.resolve("/home/developer"),
|
|
".paseo",
|
|
"worktrees",
|
|
"orchestrate",
|
|
"desktop-daemon-settings",
|
|
);
|
|
const archivedAt = "2026-04-24T09:48:36.168Z";
|
|
const workspaceId = "ws-desktop-daemon-settings";
|
|
|
|
projects.set(
|
|
cwd,
|
|
createPersistedProjectRecord({
|
|
projectId: cwd,
|
|
rootPath: cwd,
|
|
kind: "non_git",
|
|
displayName: "desktop-daemon-settings",
|
|
createdAt: "2026-04-24T09:46:43.146Z",
|
|
updatedAt: archivedAt,
|
|
archivedAt,
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
workspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId,
|
|
projectId: cwd,
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "desktop-daemon-settings",
|
|
createdAt: "2026-04-24T09:46:43.146Z",
|
|
updatedAt: archivedAt,
|
|
archivedAt,
|
|
}),
|
|
);
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceGitService.getCheckout = async () => ({
|
|
cwd,
|
|
isGit: true,
|
|
currentBranch: "feature/desktop-daemon-settings",
|
|
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
|
worktreeRoot: cwd,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: repoRoot,
|
|
});
|
|
session.workspaceGitService.getSnapshot = async () =>
|
|
createWorkspaceRuntimeSnapshot(cwd, {
|
|
git: {
|
|
isGit: true,
|
|
repoRoot: cwd,
|
|
currentBranch: "feature/desktop-daemon-settings",
|
|
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: repoRoot,
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd,
|
|
requestId: "req-open-archived-directory-now-git",
|
|
});
|
|
|
|
const response = findByType(emitted, "open_project_response");
|
|
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
|
expect(projects.get(cwd)?.archivedAt).toBe(archivedAt);
|
|
expect(workspaces.get(workspaceId)?.archivedAt).toBe(archivedAt);
|
|
});
|
|
|
|
test("open_project_request reclassifies an active directory workspace when git metadata becomes available", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const repoRoot = path.resolve("/home/developer/dev/paseo");
|
|
const cwd = path.join(
|
|
path.resolve("/home/developer"),
|
|
".paseo",
|
|
"worktrees",
|
|
"orchestrate",
|
|
"desktop-daemon-settings",
|
|
);
|
|
|
|
projects.set(
|
|
cwd,
|
|
createPersistedProjectRecord({
|
|
projectId: cwd,
|
|
rootPath: cwd,
|
|
kind: "non_git",
|
|
displayName: "desktop-daemon-settings",
|
|
createdAt: "2026-04-24T09:46:43.146Z",
|
|
updatedAt: "2026-04-24T09:46:43.146Z",
|
|
}),
|
|
);
|
|
projects.set(
|
|
repoRoot,
|
|
createPersistedProjectRecord({
|
|
projectId: repoRoot,
|
|
rootPath: repoRoot,
|
|
kind: "git",
|
|
displayName: "paseo",
|
|
createdAt: "2026-04-24T09:40:00.000Z",
|
|
updatedAt: "2026-04-24T09:40:00.000Z",
|
|
}),
|
|
);
|
|
const workspaceId = "ws-desktop-daemon-settings-active";
|
|
const repoWorkspaceId = "ws-paseo-main";
|
|
workspaces.set(
|
|
workspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId,
|
|
projectId: cwd,
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "desktop-daemon-settings",
|
|
createdAt: "2026-04-24T09:46:43.146Z",
|
|
updatedAt: "2026-04-24T09:46:43.146Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
repoWorkspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: repoWorkspaceId,
|
|
projectId: repoRoot,
|
|
cwd: repoRoot,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-04-24T09:40:00.000Z",
|
|
updatedAt: "2026-04-24T09:40:00.000Z",
|
|
}),
|
|
);
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceGitService.getCheckout = async (requestedCwd: string) => ({
|
|
cwd: requestedCwd,
|
|
isGit: true,
|
|
currentBranch: requestedCwd === repoRoot ? "main" : "feature/desktop-daemon-settings",
|
|
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
|
worktreeRoot: requestedCwd,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: requestedCwd === repoRoot ? null : repoRoot,
|
|
});
|
|
session.workspaceGitService.getSnapshot = async (requestedCwd: string) =>
|
|
createWorkspaceRuntimeSnapshot(requestedCwd, {
|
|
git: {
|
|
isGit: true,
|
|
repoRoot: requestedCwd,
|
|
currentBranch: requestedCwd === repoRoot ? "main" : "feature/desktop-daemon-settings",
|
|
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: requestedCwd === repoRoot ? null : repoRoot,
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd,
|
|
requestId: "req-open-active-directory-now-git",
|
|
});
|
|
|
|
const response = findByType(emitted, "open_project_response");
|
|
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace?.projectId).toBe(cwd);
|
|
expect(workspaces.get(workspaceId)?.projectId).toBe(cwd);
|
|
});
|
|
|
|
test("open_project_request gives a plain git worktree its own exact-root project", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const repoRoot = path.resolve("/home/developer/dev/paseo");
|
|
const cwd = path.join(
|
|
path.resolve("/home/developer"),
|
|
".paseo",
|
|
"worktrees",
|
|
"orchestrate",
|
|
"desktop-daemon-settings",
|
|
);
|
|
|
|
projects.set(
|
|
repoRoot,
|
|
createPersistedProjectRecord({
|
|
projectId: repoRoot,
|
|
rootPath: repoRoot,
|
|
kind: "git",
|
|
displayName: "paseo",
|
|
createdAt: "2026-04-24T09:46:43.146Z",
|
|
updatedAt: "2026-04-24T09:46:43.146Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
repoRoot,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: repoRoot,
|
|
projectId: repoRoot,
|
|
cwd: repoRoot,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-04-24T09:46:43.146Z",
|
|
updatedAt: "2026-04-24T09:46:43.146Z",
|
|
}),
|
|
);
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceGitService.getCheckout = async (requestedCwd: string) => ({
|
|
cwd: requestedCwd,
|
|
isGit: true,
|
|
currentBranch: requestedCwd === repoRoot ? "main" : "feature/desktop-daemon-settings",
|
|
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
|
worktreeRoot: requestedCwd,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: requestedCwd === repoRoot ? null : repoRoot,
|
|
});
|
|
session.workspaceGitService.getSnapshot = async (requestedCwd: string) =>
|
|
createWorkspaceRuntimeSnapshot(requestedCwd, {
|
|
git: {
|
|
isGit: true,
|
|
repoRoot: requestedCwd,
|
|
currentBranch: requestedCwd === repoRoot ? "main" : "feature/desktop-daemon-settings",
|
|
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: requestedCwd === repoRoot ? null : repoRoot,
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd,
|
|
requestId: "req-open-plain-git-worktree",
|
|
});
|
|
|
|
const response = findByType(emitted, "open_project_response");
|
|
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
|
const worktreeWorkspace = Array.from(workspaces.values()).find(
|
|
(workspace) => workspace.cwd === cwd,
|
|
);
|
|
expect(worktreeWorkspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
|
});
|
|
|
|
test("open_project_request keeps archived records and allocates a fresh workspace", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
|
|
const cwd = REPO_CWD;
|
|
const workspaceId = "ws-repo-archived";
|
|
projects.set(
|
|
cwd,
|
|
createPersistedProjectRecord({
|
|
projectId: cwd,
|
|
rootPath: cwd,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
workspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId,
|
|
projectId: cwd,
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd,
|
|
requestId: "req-open-unarchive",
|
|
});
|
|
|
|
expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull();
|
|
expect(projects.get(cwd)?.archivedAt).not.toBeNull();
|
|
const response = findByType(emitted, "open_project_response");
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace?.id).not.toBe(workspaceId);
|
|
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
|
});
|
|
|
|
test("open_project_request does not repurpose an orphaned archived workspace", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
|
|
const cwd = REPO_CWD;
|
|
const workspaceId = "ws-repo-project-removed";
|
|
workspaces.set(
|
|
workspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId,
|
|
projectId: cwd,
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd,
|
|
requestId: "req-open-removed-project",
|
|
});
|
|
|
|
expect(projects.get(cwd)).toBeUndefined();
|
|
expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull();
|
|
const response = findByType(emitted, "open_project_response");
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace?.id).not.toBe(workspaceId);
|
|
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
|
});
|
|
|
|
test("workspace recovery stays accepted when git observer warming fails", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const workspaceGitService = createNoopWorkspaceGitService({
|
|
registerWorkspace: () => {
|
|
throw new Error("git watcher unavailable");
|
|
},
|
|
});
|
|
const session = createSessionForWorkspaceTests({
|
|
appVersion: "0.1.105",
|
|
workspaceGitService,
|
|
onMessage: (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
},
|
|
});
|
|
const archivedAt = "2026-03-10T00:00:00.000Z";
|
|
let project = createPersistedProjectRecord({
|
|
projectId: REPO_CWD,
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: archivedAt,
|
|
archivedAt,
|
|
});
|
|
let workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-recovery-warm-failure",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: archivedAt,
|
|
archivedAt,
|
|
});
|
|
|
|
session.filesystem.isDirectory = async () => true;
|
|
session.projectRegistry.get = async (projectId: string) =>
|
|
projectId === project.projectId ? project : null;
|
|
session.projectRegistry.list = async () => [project];
|
|
session.projectRegistry.upsert = async (record: PersistedProjectRecord) => {
|
|
project = record;
|
|
};
|
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
|
workspaceId === workspace.workspaceId ? workspace : null;
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.workspaceRegistry.upsert = async (record: PersistedWorkspaceRecord) => {
|
|
workspace = record;
|
|
};
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-recovery-warm-failure",
|
|
filter: undefined,
|
|
subscribe: { subscriptionId: "sub-recovery-warm-failure" },
|
|
});
|
|
session.listAgentPayloads = async () => [];
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.recovery.restore.request",
|
|
requestId: "req-recovery-warm-failure",
|
|
workspaceId: workspace.workspaceId,
|
|
});
|
|
|
|
expect(workspace.archivedAt).toBeNull();
|
|
expect(findByType(emitted, "workspace.recovery.restore.response")?.payload).toEqual({
|
|
requestId: "req-recovery-warm-failure",
|
|
workspaceId: workspace.workspaceId,
|
|
accepted: true,
|
|
error: null,
|
|
});
|
|
expect(findByType(emitted, "workspace_update")?.payload).toMatchObject({
|
|
kind: "upsert",
|
|
workspace: { id: workspace.workspaceId },
|
|
});
|
|
});
|
|
|
|
test("refresh_agent_request leaves workspace archival independent when its directory exists", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
},
|
|
});
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
|
|
const cwd = path.resolve("/tmp/paseo-unit2-existing-dir");
|
|
session.filesystem.isDirectory = async () => true;
|
|
const workspaceId = "ws-repo-archived";
|
|
const agentId = "agent-archived";
|
|
projects.set(
|
|
cwd,
|
|
createPersistedProjectRecord({
|
|
projectId: cwd,
|
|
rootPath: cwd,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
workspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId,
|
|
projectId: cwd,
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const storedAgent: StoredAgentRecord = {
|
|
...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
|
|
workspaceId,
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
};
|
|
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
|
|
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
|
|
session.agentStorage.upsert = async () => {};
|
|
|
|
const managed = makeManagedAgent({
|
|
id: agentId,
|
|
cwd,
|
|
workspaceId,
|
|
lifecycle: "idle",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
});
|
|
session.agentManager.getAgent = () => managed;
|
|
session.interruptAgentIfRunning = async () => undefined;
|
|
session.agentManager.reloadAgentSession = async () => managed;
|
|
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
|
|
session.agentManager.getTimeline = () => [];
|
|
session.agentUpdates.forwardLiveAgent = async () => undefined;
|
|
|
|
const unarchivedWorkspaceIds: string[][] = [];
|
|
const realEmit = session.emitWorkspaceUpdatesForWorkspaceIds.bind(session);
|
|
session.emitWorkspaceUpdatesForWorkspaceIds = async (ids: string[], ...rest: unknown[]) => {
|
|
unarchivedWorkspaceIds.push(ids);
|
|
return realEmit(ids, ...rest);
|
|
};
|
|
|
|
await session.handleMessage({
|
|
type: "refresh_agent_request",
|
|
agentId,
|
|
requestId: "req-refresh-unarchive",
|
|
});
|
|
|
|
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
|
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
|
expect(unarchivedWorkspaceIds).toEqual([]);
|
|
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
|
});
|
|
|
|
test("refresh_agent_request leaves workspace archival independent when its directory is missing", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
},
|
|
});
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
|
|
const cwd = path.resolve("/tmp/paseo-missing-workspace-dir");
|
|
session.filesystem.isDirectory = async () => false;
|
|
const workspaceId = "ws-missing-dir";
|
|
const agentId = "agent-missing-dir";
|
|
projects.set(
|
|
cwd,
|
|
createPersistedProjectRecord({
|
|
projectId: cwd,
|
|
rootPath: cwd,
|
|
kind: "non_git",
|
|
displayName: "missing",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
workspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId,
|
|
projectId: cwd,
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "missing",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const storedAgent: StoredAgentRecord = {
|
|
...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
|
|
workspaceId,
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
};
|
|
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
|
|
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
|
|
session.agentStorage.upsert = async () => {};
|
|
|
|
const managed = makeManagedAgent({
|
|
id: agentId,
|
|
cwd,
|
|
workspaceId,
|
|
lifecycle: "idle",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
});
|
|
session.agentManager.getAgent = () => managed;
|
|
session.interruptAgentIfRunning = async () => undefined;
|
|
session.agentManager.reloadAgentSession = async () => managed;
|
|
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
|
|
session.agentManager.getTimeline = () => [];
|
|
session.agentUpdates.forwardLiveAgent = async () => undefined;
|
|
|
|
await session.handleMessage({
|
|
type: "refresh_agent_request",
|
|
agentId,
|
|
requestId: "req-refresh-missing-dir",
|
|
});
|
|
|
|
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
|
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
|
});
|
|
|
|
test("refresh_agent_request does not recreate or unarchive a deleted worktree", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
appVersion: "0.1.105",
|
|
onMessage: (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
},
|
|
});
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
|
|
const cwd = path.resolve("/tmp/paseo-deleted-worktree-dir");
|
|
session.filesystem.isDirectory = async () => false;
|
|
const workspaceId = "ws-deleted-worktree";
|
|
const agentId = "agent-deleted-worktree";
|
|
projects.set(
|
|
cwd,
|
|
createPersistedProjectRecord({
|
|
projectId: cwd,
|
|
rootPath: cwd,
|
|
kind: "git",
|
|
displayName: "worktree-project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
workspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId,
|
|
projectId: cwd,
|
|
cwd,
|
|
kind: "worktree",
|
|
branch: "feature/keep",
|
|
displayName: "feature/keep",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const storedAgent: StoredAgentRecord = {
|
|
...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
|
|
workspaceId,
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
};
|
|
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
|
|
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
|
|
session.agentStorage.upsert = async () => {};
|
|
|
|
const managed = makeManagedAgent({
|
|
id: agentId,
|
|
cwd,
|
|
workspaceId,
|
|
lifecycle: "idle",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
});
|
|
session.agentManager.getAgent = () => managed;
|
|
session.interruptAgentIfRunning = async () => undefined;
|
|
session.agentManager.reloadAgentSession = async () => managed;
|
|
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
|
|
session.agentManager.getTimeline = () => [];
|
|
session.agentUpdates.forwardLiveAgent = async () => undefined;
|
|
|
|
const unarchivedWorkspaceIds: string[][] = [];
|
|
const realEmit = session.emitWorkspaceUpdatesForWorkspaceIds.bind(session);
|
|
session.emitWorkspaceUpdatesForWorkspaceIds = async (ids: string[], ...rest: unknown[]) => {
|
|
unarchivedWorkspaceIds.push(ids);
|
|
return realEmit(ids, ...rest);
|
|
};
|
|
|
|
await session.handleMessage({
|
|
type: "refresh_agent_request",
|
|
agentId,
|
|
requestId: "req-refresh-recreate-worktree",
|
|
});
|
|
|
|
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
|
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
|
|
expect(unarchivedWorkspaceIds).toEqual([]);
|
|
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
|
});
|
|
|
|
test("refresh_agent_request does not inspect an archived worktree branch", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
},
|
|
});
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
|
|
const cwd = path.resolve("/tmp/paseo-deleted-worktree-fail");
|
|
session.filesystem.isDirectory = async () => false;
|
|
const workspaceId = "ws-deleted-worktree-fail";
|
|
const agentId = "agent-deleted-worktree-fail";
|
|
projects.set(
|
|
cwd,
|
|
createPersistedProjectRecord({
|
|
projectId: cwd,
|
|
rootPath: cwd,
|
|
kind: "git",
|
|
displayName: "worktree-project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
workspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId,
|
|
projectId: cwd,
|
|
cwd,
|
|
kind: "worktree",
|
|
branch: "feature/gone",
|
|
displayName: "feature/gone",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const storedAgent: StoredAgentRecord = {
|
|
...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
|
|
workspaceId,
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
};
|
|
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
|
|
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
|
|
session.agentStorage.upsert = async () => {};
|
|
|
|
const managed = makeManagedAgent({
|
|
id: agentId,
|
|
cwd,
|
|
workspaceId,
|
|
lifecycle: "idle",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
});
|
|
session.agentManager.getAgent = () => managed;
|
|
session.interruptAgentIfRunning = async () => undefined;
|
|
session.agentManager.reloadAgentSession = async () => managed;
|
|
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
|
|
session.agentManager.getTimeline = () => [];
|
|
session.agentUpdates.forwardLiveAgent = async () => undefined;
|
|
|
|
await session.handleMessage({
|
|
type: "refresh_agent_request",
|
|
agentId,
|
|
requestId: "req-refresh-recreate-fail",
|
|
});
|
|
|
|
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
|
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
|
});
|
|
|
|
function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } {
|
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-recreate-worktree-")));
|
|
const repoDir = path.join(tempDir, "repo");
|
|
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
|
|
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
|
|
cwd: repoDir,
|
|
stdio: "pipe",
|
|
});
|
|
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir, stdio: "pipe" });
|
|
writeFileSync(path.join(repoDir, "README.md"), "main\n");
|
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
|
|
return { tempDir, repoDir };
|
|
}
|
|
|
|
test("legacy refresh_agent_request restores a real deleted worktree", async () => {
|
|
const { tempDir, repoDir } = createRecreateWorktreeRepo();
|
|
const branch = "feature/keep";
|
|
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
|
|
|
const worktreesRoot = path.join(tempDir, "worktrees");
|
|
const paseoHome = path.join(tempDir, "paseo-home");
|
|
const created = await createWorktree({
|
|
cwd: repoDir,
|
|
worktreeSlug: "keep",
|
|
source: { kind: "checkout-branch", branchName: branch },
|
|
|
|
paseoHome,
|
|
worktreesRoot,
|
|
});
|
|
const worktreePath = realpathSync(created.worktreePath);
|
|
// Simulate archive: drop the worktree dir but keep the branch.
|
|
rmSync(worktreePath, { recursive: true, force: true });
|
|
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
|
|
expect(existsSync(worktreePath)).toBe(false);
|
|
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
appVersion: "0.1.104",
|
|
paseoHome,
|
|
worktreesRoot,
|
|
onMessage: (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
},
|
|
});
|
|
// Real directory probe so the missing worktree reads as gone and the repo root as present.
|
|
session.filesystem.isDirectory = async (target: string) =>
|
|
existsSync(target) && statSync(target).isDirectory();
|
|
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const workspaceId = "ws-real-recreate";
|
|
const agentId = "agent-real-recreate";
|
|
const projectId = repoDir;
|
|
projects.set(
|
|
projectId,
|
|
createPersistedProjectRecord({
|
|
projectId,
|
|
rootPath: repoDir,
|
|
kind: "git",
|
|
displayName: "worktree-project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
workspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId,
|
|
projectId,
|
|
cwd: worktreePath,
|
|
kind: "worktree",
|
|
branch,
|
|
displayName: branch,
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const storedAgent: StoredAgentRecord = {
|
|
...makeStoredAgent({ id: agentId, cwd: worktreePath, updatedAt: "2026-03-10T00:00:00.000Z" }),
|
|
workspaceId,
|
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
|
};
|
|
|
|
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
|
|
session.agentStorage.upsert = async () => {};
|
|
|
|
const managed = makeManagedAgent({
|
|
id: agentId,
|
|
cwd: worktreePath,
|
|
workspaceId,
|
|
lifecycle: "idle",
|
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
|
});
|
|
session.agentManager.getAgent = () => managed;
|
|
session.interruptAgentIfRunning = async () => undefined;
|
|
session.agentManager.reloadAgentSession = async () => managed;
|
|
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
|
|
session.agentManager.getTimeline = () => [];
|
|
session.agentUpdates.forwardLiveAgent = async () => undefined;
|
|
|
|
await session.handleMessage({
|
|
type: "refresh_agent_request",
|
|
agentId,
|
|
requestId: "req-refresh-real-recreate",
|
|
});
|
|
|
|
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
|
expect(existsSync(worktreePath)).toBe(true);
|
|
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
|
|
expect(workspaces.get(workspaceId)?.cwd).toBe(worktreePath);
|
|
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
|
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const repoRoot = REPO_CWD;
|
|
const subdir = "/tmp/repo/packages/app";
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.buildProjectPlacement = async (cwd: string) => ({
|
|
projectKey: repoRoot,
|
|
projectName: "repo",
|
|
checkout: {
|
|
cwd,
|
|
isGit: true,
|
|
currentBranch: "main",
|
|
remoteUrl: null,
|
|
worktreeRoot: repoRoot,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd: subdir,
|
|
requestId: "req-open-subdir",
|
|
});
|
|
|
|
expect(workspaces.get(repoRoot)).toBeTruthy();
|
|
expect(workspaces.has(subdir)).toBe(false);
|
|
const response = findByType(emitted, "open_project_response");
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace?.id).toBe(repoRoot);
|
|
});
|
|
|
|
test("legacy editor RPC requests return daemon unsupported errors", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "list_available_editors_request",
|
|
requestId: "req-editors",
|
|
});
|
|
await session.handleMessage({
|
|
type: "open_in_editor_request",
|
|
requestId: "req-open-editor",
|
|
editorId: "vscode",
|
|
path: REPO_CWD,
|
|
});
|
|
|
|
const listResponse = findByType(emitted, "list_available_editors_response");
|
|
const openResponse = findByType(emitted, "open_in_editor_response");
|
|
expect(listResponse?.payload.editors).toEqual([]);
|
|
expect(listResponse?.payload.error).toBe(
|
|
"Editor opening moved to the desktop app and is no longer supported by the daemon",
|
|
);
|
|
expect(openResponse?.payload.error).toBe(
|
|
"Editor opening moved to the desktop app and is no longer supported by the daemon",
|
|
);
|
|
});
|
|
|
|
test("archive_workspace_request hides non-destructive workspace records", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-repo-archive",
|
|
projectId: "proj-repo-archive",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.workspaceRegistry.get = async () => workspace;
|
|
session.workspaceRegistry.archive = async (_workspaceId: string, archivedAt: string) => {
|
|
workspace.archivedAt = archivedAt;
|
|
};
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.projectRegistry.archive = async () => {};
|
|
|
|
await session.handleMessage({
|
|
type: "archive_workspace_request",
|
|
workspaceId: "ws-repo-archive",
|
|
requestId: "req-archive",
|
|
});
|
|
|
|
expect(workspace.archivedAt).toBeTruthy();
|
|
const response = emitted.find((message) => message.type === "archive_workspace_response") as
|
|
| { payload: Record<string, unknown> }
|
|
| undefined;
|
|
expect(response?.payload.error).toBeNull();
|
|
});
|
|
|
|
test("archive_workspace_request archives a worktree-kind workspace and removes the directory on last reference", async () => {
|
|
const tempDir = mkdtempSync(path.join(tmpdir(), "session-worktree-kind-archive-"));
|
|
const repoDir = path.join(tempDir, "repo");
|
|
mkdirSync(repoDir, { recursive: true });
|
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
|
|
cwd: repoDir,
|
|
stdio: "pipe",
|
|
});
|
|
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial"], {
|
|
cwd: repoDir,
|
|
stdio: "pipe",
|
|
});
|
|
|
|
const paseoHome = path.join(tempDir, ".paseo");
|
|
const worktree = await createWorktree({
|
|
cwd: repoDir,
|
|
worktreeSlug: "worktree-kind-archive",
|
|
source: {
|
|
kind: "branch-off",
|
|
baseBranch: "main",
|
|
branchName: "worktree-kind-archive",
|
|
},
|
|
|
|
paseoHome,
|
|
});
|
|
|
|
const workspaceId = "ws-worktree-kind-archive";
|
|
const projectId = "proj-worktree-kind-archive";
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId,
|
|
projectId,
|
|
cwd: worktree.worktreePath,
|
|
kind: "worktree",
|
|
displayName: "worktree-kind-archive",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId,
|
|
rootPath: repoDir,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
workspaceGitService: createNoopWorkspaceGitService({
|
|
getSnapshot: async (): Promise<WorkspaceGitRuntimeSnapshot> => ({
|
|
cwd: worktree.worktreePath,
|
|
git: {
|
|
isGit: true,
|
|
repoRoot: repoDir,
|
|
mainRepoRoot: repoDir,
|
|
currentBranch: "worktree-kind-archive",
|
|
remoteUrl: null,
|
|
isPaseoOwnedWorktree: true,
|
|
isDirty: false,
|
|
baseRef: null,
|
|
aheadBehind: null,
|
|
aheadOfOrigin: null,
|
|
behindOfOrigin: null,
|
|
hasRemote: false,
|
|
diffStat: null,
|
|
},
|
|
forge: {
|
|
featuresEnabled: false,
|
|
pullRequest: null,
|
|
error: null,
|
|
},
|
|
}),
|
|
}),
|
|
});
|
|
session.paseoHome = paseoHome;
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.workspaceRegistry.get = async () => workspace;
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.workspaceRegistry.archive = async (_id: string, archivedAt: string) => {
|
|
workspace.archivedAt = archivedAt;
|
|
};
|
|
session.projectRegistry.list = async () => [project];
|
|
|
|
try {
|
|
await session.handleMessage({
|
|
type: "archive_workspace_request",
|
|
workspaceId,
|
|
requestId: "req-worktree-kind-archive",
|
|
});
|
|
|
|
expect(workspace.archivedAt).toBeTruthy();
|
|
expect(existsSync(worktree.worktreePath)).toBe(false);
|
|
const response = emitted.find((message) => message.type === "archive_workspace_response") as
|
|
| { payload: Record<string, unknown> }
|
|
| undefined;
|
|
expect(response?.payload.error).toBeNull();
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test.skip("opening a new worktree reconciles older local workspaces into the remote project", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
|
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-reconcile-")));
|
|
const mainWorkspaceId = path.join(tempDir, "inkwell");
|
|
const worktreeWorkspaceId = path.join(mainWorkspaceId, ".paseo", "worktrees", "feature-a");
|
|
const localProjectId = mainWorkspaceId;
|
|
const remoteProjectId = "remote:github.com/zimakki/inkwell";
|
|
|
|
mkdirSync(worktreeWorkspaceId, { recursive: true });
|
|
|
|
projects.set(
|
|
localProjectId,
|
|
createPersistedProjectRecord({
|
|
projectId: localProjectId,
|
|
rootPath: mainWorkspaceId,
|
|
kind: "git",
|
|
displayName: "inkwell",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
mainWorkspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: mainWorkspaceId,
|
|
projectId: localProjectId,
|
|
cwd: mainWorkspaceId,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-reconcile",
|
|
filter: undefined,
|
|
subscribe: { subscriptionId: "sub-reconcile" },
|
|
});
|
|
session.listAgentPayloads = async () => [];
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.projectRegistry.archive = async (projectId: string, archivedAt: string) => {
|
|
const existing = projects.get(projectId);
|
|
if (!existing) return;
|
|
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt });
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.buildProjectPlacement = async (cwd: string) => ({
|
|
projectKey: remoteProjectId,
|
|
projectName: "zimakki/inkwell",
|
|
checkout: {
|
|
cwd,
|
|
isGit: true,
|
|
currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a",
|
|
remoteUrl: "https://github.com/zimakki/inkwell.git",
|
|
worktreeRoot: cwd,
|
|
isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
|
|
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await session.handleMessage({
|
|
type: "open_project_request",
|
|
cwd: worktreeWorkspaceId,
|
|
requestId: "req-open-worktree",
|
|
});
|
|
|
|
const mainWorkspaceProjectId = workspaces.get(mainWorkspaceId)?.projectId;
|
|
expect([localProjectId, remoteProjectId]).toContain(mainWorkspaceProjectId);
|
|
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId);
|
|
expect(Boolean(projects.get(localProjectId)?.archivedAt)).toBe(
|
|
mainWorkspaceProjectId === remoteProjectId,
|
|
);
|
|
|
|
const workspaceUpdates = filterByType(emitted, "workspace_update");
|
|
expect(workspaceUpdates).toHaveLength(1);
|
|
const firstUpdate = workspaceUpdates[0];
|
|
expect(firstUpdate?.payload.kind === "upsert" ? firstUpdate.payload.workspace.id : null).toBe(
|
|
worktreeWorkspaceId,
|
|
);
|
|
expect(
|
|
firstUpdate?.payload.kind === "upsert" ? firstUpdate.payload.workspace.projectId : null,
|
|
).toBe(remoteProjectId);
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test.skip("fetch_workspaces_request reconciles remote URL changes for existing workspaces", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
|
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-fetch-")));
|
|
const mainWorkspaceId = path.join(tempDir, "inkwell");
|
|
const worktreeWorkspaceId = path.join(mainWorkspaceId, ".paseo", "worktrees", "feature-a");
|
|
const oldProjectId = "remote:github.com/old-owner/inkwell";
|
|
const newProjectId = "remote:github.com/new-owner/inkwell";
|
|
|
|
mkdirSync(worktreeWorkspaceId, { recursive: true });
|
|
|
|
projects.set(
|
|
oldProjectId,
|
|
createPersistedProjectRecord({
|
|
projectId: oldProjectId,
|
|
rootPath: mainWorkspaceId,
|
|
kind: "git",
|
|
displayName: "old-owner/inkwell",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
for (const [workspaceId, displayName] of [
|
|
[mainWorkspaceId, "main"],
|
|
[worktreeWorkspaceId, "feature-a"],
|
|
] as const) {
|
|
workspaces.set(
|
|
workspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId,
|
|
projectId: oldProjectId,
|
|
cwd: workspaceId,
|
|
kind: workspaceId === mainWorkspaceId ? "local_checkout" : "worktree",
|
|
displayName,
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
);
|
|
}
|
|
|
|
session.listAgentPayloads = async () => [];
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.projectRegistry.archive = async (projectId: string, archivedAt: string) => {
|
|
const existing = projects.get(projectId);
|
|
if (!existing) return;
|
|
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt });
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.buildProjectPlacement = async (cwd: string) => ({
|
|
projectKey: newProjectId,
|
|
projectName: "new-owner/inkwell",
|
|
checkout: {
|
|
cwd,
|
|
isGit: true,
|
|
currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a",
|
|
remoteUrl: "https://github.com/new-owner/inkwell.git",
|
|
worktreeRoot: cwd,
|
|
isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
|
|
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await session.reconcileWorkspaceRecord(mainWorkspaceId);
|
|
await session.reconcileWorkspaceRecord(worktreeWorkspaceId);
|
|
|
|
const result = await session.listFetchWorkspacesEntries({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-fetch-reconcile",
|
|
});
|
|
|
|
expect(result.entries.map((entry) => entry["projectId"])).toEqual([newProjectId, newProjectId]);
|
|
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(newProjectId);
|
|
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(newProjectId);
|
|
expect(projects.get(oldProjectId)?.archivedAt).toBeTruthy();
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test.skip("reconcile archives stale subdirectory workspace records when collapsing to the repo root", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
|
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-collapse-")));
|
|
const repoRoot = path.join(tempDir, "repo");
|
|
const subdirWorkspaceId = path.join(repoRoot, "packages", "app");
|
|
const projectId = "remote:github.com/acme/repo";
|
|
|
|
mkdirSync(subdirWorkspaceId, { recursive: true });
|
|
|
|
projects.set(
|
|
projectId,
|
|
createPersistedProjectRecord({
|
|
projectId,
|
|
rootPath: repoRoot,
|
|
kind: "git",
|
|
displayName: "acme/repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
repoRoot,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: repoRoot,
|
|
projectId,
|
|
cwd: repoRoot,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
);
|
|
workspaces.set(
|
|
subdirWorkspaceId,
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: subdirWorkspaceId,
|
|
projectId,
|
|
cwd: subdirWorkspaceId,
|
|
kind: "directory",
|
|
displayName: "app",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
session.projectRegistry.get = async (nextProjectId: string) =>
|
|
projects.get(nextProjectId) ?? null;
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.projectRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
|
) => {
|
|
projects.set(record.projectId, record);
|
|
};
|
|
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
|
workspaces.get(lookupWorkspaceId) ?? null;
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceRegistry.upsert = async (
|
|
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
|
) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
};
|
|
session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => {
|
|
const existing = workspaces.get(workspaceId);
|
|
if (!existing) return;
|
|
workspaces.set(workspaceId, { ...existing, archivedAt, updatedAt: archivedAt });
|
|
};
|
|
session.buildProjectPlacement = async (cwd: string) => ({
|
|
projectKey: projectId,
|
|
projectName: "acme/repo",
|
|
checkout: {
|
|
cwd,
|
|
isGit: true,
|
|
currentBranch: "main",
|
|
remoteUrl: "https://github.com/acme/repo.git",
|
|
worktreeRoot: repoRoot,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
});
|
|
|
|
try {
|
|
const result = await session.reconcileWorkspaceRecord(subdirWorkspaceId);
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(result.workspace?.["workspaceId"]).toBe(repoRoot);
|
|
expect(result.removedWorkspaceId).toBe(subdirWorkspaceId);
|
|
expect(workspaces.get(repoRoot)?.archivedAt).toBeNull();
|
|
expect(workspaces.get(subdirWorkspaceId)?.archivedAt).toBeTruthy();
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline descriptor path", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-baseline",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-baseline",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
session.listAgentPayloads = async () => [];
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
|
|
const baselineDescriptor = {
|
|
id: workspace.workspaceId,
|
|
projectId: project.projectId,
|
|
projectDisplayName: project.displayName,
|
|
projectRootPath: project.rootPath,
|
|
projectKind: project.kind,
|
|
workspaceKind: workspace.kind,
|
|
name: "main",
|
|
archivingAt: null,
|
|
status: "done",
|
|
statusEnteredAt: workspace.createdAt,
|
|
activityAt: null,
|
|
diffStat: null,
|
|
} as const;
|
|
const gitDescriptor = {
|
|
...baselineDescriptor,
|
|
diffStat: { additions: 3, deletions: 1 },
|
|
} as const;
|
|
|
|
const describeWorkspaceRecord = vi.fn(async () => baselineDescriptor);
|
|
const describeWorkspaceRecordWithGitData = vi.fn(async () => gitDescriptor);
|
|
session.describeWorkspaceRecord = describeWorkspaceRecord;
|
|
session.describeWorkspaceRecordWithGitData = describeWorkspaceRecordWithGitData;
|
|
|
|
const descriptors = Array.from(
|
|
(
|
|
await session.buildWorkspaceDescriptorMap({
|
|
includeGitData: false,
|
|
})
|
|
).values(),
|
|
);
|
|
|
|
expect(describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project);
|
|
expect(describeWorkspaceRecordWithGitData).not.toHaveBeenCalled();
|
|
expect(descriptors).toEqual([baselineDescriptor]);
|
|
});
|
|
|
|
test("lists Git runtime for a checkout explicitly owned by a non-Git project", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-explicit-directory",
|
|
rootPath: "/tmp/explicit-directory",
|
|
kind: "non_git",
|
|
displayName: "directory project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-explicit-checkout",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
session.listAgentPayloads = async () => [];
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.workspaceGitService.peekSnapshot = () => createWorkspaceRuntimeSnapshot(REPO_CWD);
|
|
|
|
const descriptors = Array.from(
|
|
(await session.buildWorkspaceDescriptorMap({ includeGitData: true })).values(),
|
|
) as Array<{ gitRuntime?: { currentBranch: string | null }; githubRuntime?: unknown }>;
|
|
|
|
expect(descriptors[0]).toMatchObject({
|
|
gitRuntime: { currentBranch: "main" },
|
|
githubRuntime: expect.any(Object),
|
|
});
|
|
});
|
|
|
|
test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => {
|
|
const setupSession = () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-status-entered",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-status-entered",
|
|
projectId: project.projectId,
|
|
cwd: "/tmp/repo",
|
|
kind: "worktree",
|
|
displayName: "feature",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
return { session, workspace };
|
|
};
|
|
|
|
const buildDescriptor = (session: TestSession, workspaceId: string) =>
|
|
session.buildWorkspaceDescriptorMap({ includeGitData: false }).then((map) => {
|
|
const descriptor = map.get(workspaceId);
|
|
expect(descriptor).toBeDefined();
|
|
return descriptor!;
|
|
});
|
|
|
|
// 1. Empty workspace — no agents contribute. The workspace entered its
|
|
// initial "done" bucket when it was created.
|
|
{
|
|
const { session, workspace } = setupSession();
|
|
session.listAgentPayloads = async () => [];
|
|
const descriptor = await buildDescriptor(session, workspace.workspaceId);
|
|
expect(descriptor.status).toBe("done");
|
|
expect(descriptor.statusEnteredAt).toBe(workspace.createdAt);
|
|
}
|
|
|
|
// Agents own the workspace by id; cwd is incidental.
|
|
const owned = (input: Parameters<typeof makeAgent>[0]) =>
|
|
makeAgent({ ...input, workspaceId: "ws-status-entered" });
|
|
|
|
// 2. Single idle agent (derives to "done") — statusEnteredAt uses the
|
|
// agent's updatedAt as a best-effort timestamp.
|
|
{
|
|
const { session, workspace } = setupSession();
|
|
const updatedAt = "2026-05-12T09:30:00.000Z";
|
|
session.listAgentPayloads = async () => [
|
|
owned({
|
|
id: "agent-done",
|
|
cwd: workspace.cwd,
|
|
status: "idle",
|
|
updatedAt,
|
|
}),
|
|
];
|
|
const descriptor = await buildDescriptor(session, workspace.workspaceId);
|
|
expect(descriptor.status).toBe("done");
|
|
expect(descriptor.statusEnteredAt).toBe(updatedAt);
|
|
}
|
|
|
|
// 3. A root agent that is still initializing does not make the workspace
|
|
// look like it is actively working.
|
|
{
|
|
const { session, workspace } = setupSession();
|
|
const updatedAt = "2026-05-12T09:45:00.000Z";
|
|
session.listAgentPayloads = async () => [
|
|
owned({
|
|
id: "agent-initializing",
|
|
cwd: workspace.cwd,
|
|
status: "initializing",
|
|
updatedAt,
|
|
}),
|
|
];
|
|
const descriptor = await buildDescriptor(session, workspace.workspaceId);
|
|
expect(descriptor.status).toBe("done");
|
|
expect(descriptor.statusEnteredAt).toBe(updatedAt);
|
|
}
|
|
|
|
// 4. Highest-priority across all buckets: a "needs_input" agent beats
|
|
// a "running" agent beats a "done" agent. statusEnteredAt is the winning
|
|
// bucket's newest agent timestamp.
|
|
{
|
|
const { session, workspace } = setupSession();
|
|
const doneUpdatedAt = "2026-05-12T09:30:00.000Z";
|
|
const runningUpdatedAt = "2026-05-12T10:00:00.000Z";
|
|
const needsInputUpdatedAt = "2026-05-12T10:15:00.000Z";
|
|
session.listAgentPayloads = async () => [
|
|
owned({
|
|
id: "agent-done",
|
|
cwd: workspace.cwd,
|
|
status: "idle",
|
|
updatedAt: doneUpdatedAt,
|
|
}),
|
|
owned({
|
|
id: "agent-running",
|
|
cwd: workspace.cwd,
|
|
status: "running",
|
|
updatedAt: runningUpdatedAt,
|
|
}),
|
|
owned({
|
|
id: "agent-needs-input",
|
|
cwd: workspace.cwd,
|
|
status: "idle",
|
|
updatedAt: needsInputUpdatedAt,
|
|
pendingPermissions: 1,
|
|
}),
|
|
];
|
|
const descriptor = await buildDescriptor(session, workspace.workspaceId);
|
|
expect(descriptor.status).toBe("needs_input");
|
|
expect(descriptor.statusEnteredAt).toBe(needsInputUpdatedAt);
|
|
}
|
|
|
|
// 5. Same-bucket: keep the previous bucket entry time even when newer
|
|
// agents contribute to the same winning bucket.
|
|
{
|
|
const { session, workspace } = setupSession();
|
|
const earlyUpdatedAt = "2026-05-12T08:00:00.000Z";
|
|
const lateUpdatedAt = "2026-05-12T08:30:00.000Z";
|
|
session.listAgentPayloads = async () => [
|
|
owned({
|
|
id: "agent-done-early",
|
|
cwd: workspace.cwd,
|
|
status: "idle",
|
|
updatedAt: earlyUpdatedAt,
|
|
}),
|
|
];
|
|
const first = await buildDescriptor(session, workspace.workspaceId);
|
|
expect(first.status).toBe("done");
|
|
expect(first.statusEnteredAt).toBe(earlyUpdatedAt);
|
|
|
|
// Second call: same winning bucket, newer agent updatedAt must not move
|
|
// the workspace bucket entry time forward.
|
|
session.listAgentPayloads = async () => [
|
|
owned({
|
|
id: "agent-done-early",
|
|
cwd: workspace.cwd,
|
|
status: "idle",
|
|
updatedAt: earlyUpdatedAt,
|
|
}),
|
|
owned({
|
|
id: "agent-done-late",
|
|
cwd: workspace.cwd,
|
|
status: "idle",
|
|
updatedAt: lateUpdatedAt,
|
|
}),
|
|
];
|
|
const second = await buildDescriptor(session, workspace.workspaceId);
|
|
expect(second.status).toBe("done");
|
|
expect(second.statusEnteredAt).toBe(earlyUpdatedAt);
|
|
}
|
|
|
|
// 5. Priority unmasking: a higher-priority bucket clears, revealing a
|
|
// lower-priority one. The unmask time must be "now".
|
|
{
|
|
const { session, workspace } = setupSession();
|
|
const unmaskTime = "2026-05-12T12:00:00.000Z";
|
|
vi.setSystemTime(new Date(unmaskTime));
|
|
const doneUpdatedAt = "2026-05-12T08:00:00.000Z";
|
|
const needsInputUpdatedAt = "2026-05-12T07:00:00.000Z";
|
|
session.listAgentPayloads = async () => [
|
|
owned({
|
|
id: "agent-done",
|
|
cwd: workspace.cwd,
|
|
status: "idle",
|
|
updatedAt: doneUpdatedAt,
|
|
}),
|
|
owned({
|
|
id: "agent-needs-input",
|
|
cwd: workspace.cwd,
|
|
status: "idle",
|
|
updatedAt: needsInputUpdatedAt,
|
|
pendingPermissions: 1,
|
|
}),
|
|
];
|
|
const first = await buildDescriptor(session, workspace.workspaceId);
|
|
expect(first.status).toBe("needs_input");
|
|
|
|
// Drop the needs_input agent. The unmask time is "now", not doneUpdatedAt.
|
|
session.listAgentPayloads = async () => [
|
|
owned({
|
|
id: "agent-done",
|
|
cwd: workspace.cwd,
|
|
status: "idle",
|
|
updatedAt: doneUpdatedAt,
|
|
}),
|
|
];
|
|
const second = await buildDescriptor(session, workspace.workspaceId);
|
|
expect(second.status).toBe("done");
|
|
expect(second.statusEnteredAt).toBe(unmaskTime);
|
|
vi.useRealTimers();
|
|
}
|
|
|
|
// 6. Attention agent uses attentionTimestamp as the entered-at signal.
|
|
{
|
|
const { session, workspace } = setupSession();
|
|
const attentionTs = "2026-05-12T11:00:00.000Z";
|
|
const updatedAt = "2026-05-12T10:00:00.000Z";
|
|
session.listAgentPayloads = async () => [
|
|
owned({
|
|
id: "agent-attention",
|
|
cwd: workspace.cwd,
|
|
status: "idle",
|
|
updatedAt,
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
attentionTimestamp: attentionTs,
|
|
}),
|
|
];
|
|
const descriptor = await buildDescriptor(session, workspace.workspaceId);
|
|
expect(descriptor.status).toBe("attention");
|
|
// attentionTimestamp takes priority over updatedAt
|
|
expect(descriptor.statusEnteredAt).toBe(attentionTs);
|
|
}
|
|
});
|
|
|
|
test("same-cwd workspace descriptors compute agent status per workspaceId", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-same-cwd-status",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspaceA = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-same-cwd-a",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspaceB = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-same-cwd-b",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "second view",
|
|
createdAt: "2026-03-01T12:00:01.000Z",
|
|
updatedAt: "2026-03-01T12:00:01.000Z",
|
|
});
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => [workspaceA, workspaceB];
|
|
|
|
// A running agent owned by A leaves the sibling B done — status is per id.
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: "agent-running-a",
|
|
cwd: REPO_CWD,
|
|
workspaceId: workspaceA.workspaceId,
|
|
status: "running",
|
|
updatedAt: "2026-05-12T10:00:00.000Z",
|
|
}),
|
|
];
|
|
const runningDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
|
|
expect(runningDescriptors.get(workspaceA.workspaceId)?.status).toBe("running");
|
|
expect(runningDescriptors.get(workspaceB.workspaceId)?.status).toBe("done");
|
|
|
|
// An attention agent owned by B leaves the sibling A done.
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: "agent-attention-b",
|
|
cwd: REPO_CWD,
|
|
workspaceId: workspaceB.workspaceId,
|
|
status: "idle",
|
|
updatedAt: "2026-05-12T11:00:00.000Z",
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
attentionTimestamp: "2026-05-12T11:00:00.000Z",
|
|
}),
|
|
];
|
|
const attentionDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
|
|
expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("done");
|
|
expect(attentionDescriptors.get(workspaceB.workspaceId)?.status).toBe("attention");
|
|
});
|
|
|
|
test("buildWorkspaceDescriptorMap keeps a done workspace recent after its agents are archived", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-archive-status-entered",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-archive-status-entered",
|
|
projectId: project.projectId,
|
|
cwd: "/tmp/repo/archive-status-entered",
|
|
kind: "worktree",
|
|
displayName: "feature",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const doneEnteredAt = "2026-05-12T09:30:00.000Z";
|
|
const archivedAt = "2026-05-12T09:45:00.000Z";
|
|
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.listAgentPayloads = async () => [
|
|
makeAgent({
|
|
id: "agent-done",
|
|
cwd: workspace.cwd,
|
|
workspaceId: workspace.workspaceId,
|
|
status: "idle",
|
|
updatedAt: doneEnteredAt,
|
|
}),
|
|
];
|
|
|
|
const first = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
|
|
expect(first.get(workspace.workspaceId)?.status).toBe("done");
|
|
expect(first.get(workspace.workspaceId)?.statusEnteredAt).toBe(doneEnteredAt);
|
|
|
|
session.listAgentPayloads = async () => [
|
|
{
|
|
...makeAgent({
|
|
id: "agent-done",
|
|
cwd: workspace.cwd,
|
|
workspaceId: workspace.workspaceId,
|
|
status: "idle",
|
|
updatedAt: doneEnteredAt,
|
|
}),
|
|
archivedAt,
|
|
},
|
|
];
|
|
|
|
const second = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
|
|
expect(second.get(workspace.workspaceId)).toMatchObject({
|
|
status: "done",
|
|
statusEnteredAt: doneEnteredAt,
|
|
});
|
|
});
|
|
|
|
test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => {
|
|
const session = createSessionForWorkspaceTests();
|
|
const archivingAt = "2026-04-30T20:45:00.000Z";
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-archiving-map",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-archiving-map",
|
|
projectId: project.projectId,
|
|
cwd: "/tmp/repo/worktree",
|
|
kind: "worktree",
|
|
displayName: "feature",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
session.listAgentPayloads = async () => [];
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
|
|
const readArchivingAt = async () =>
|
|
(
|
|
await session.buildWorkspaceDescriptorMap({
|
|
includeGitData: false,
|
|
})
|
|
).get(workspace.workspaceId)?.archivingAt;
|
|
|
|
await expect(readArchivingAt()).resolves.toBeNull();
|
|
|
|
session.markWorkspaceArchiving([workspace.workspaceId], archivingAt);
|
|
await expect(readArchivingAt()).resolves.toBe(archivingAt);
|
|
|
|
session.clearWorkspaceArchiving([workspace.workspaceId]);
|
|
await expect(readArchivingAt()).resolves.toBeNull();
|
|
});
|
|
|
|
test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes unchanged emits", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
|
const archivingAt = "2026-04-30T20:45:00.000Z";
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-archiving-emit",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-archiving-emit",
|
|
projectId: project.projectId,
|
|
cwd: "/tmp/repo/worktree",
|
|
kind: "worktree",
|
|
displayName: "feature",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-archiving",
|
|
filter: undefined,
|
|
subscribe: { subscriptionId: "sub-archiving" },
|
|
});
|
|
session.listAgentPayloads = async () => [];
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
|
|
emitted.length = 0;
|
|
session.markWorkspaceArchiving([workspace.workspaceId], archivingAt);
|
|
|
|
await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
|
|
await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
|
|
|
|
expect(emitted).toEqual([
|
|
{
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
workspace: expect.objectContaining({
|
|
id: workspace.workspaceId,
|
|
archivingAt,
|
|
}),
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("external workspace updates emit one deduplicated batch", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-observer-batch",
|
|
rootPath: "/tmp/observer-batch",
|
|
kind: "non_git",
|
|
displayName: "observer-batch",
|
|
createdAt: "2026-07-15T00:00:00.000Z",
|
|
updatedAt: "2026-07-15T00:00:00.000Z",
|
|
});
|
|
const main = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-observer-main",
|
|
projectId: project.projectId,
|
|
cwd: "/tmp/observer-batch/main",
|
|
kind: "directory",
|
|
displayName: "main",
|
|
createdAt: "2026-07-15T00:00:00.000Z",
|
|
updatedAt: "2026-07-15T00:00:00.000Z",
|
|
});
|
|
const feature = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-observer-feature",
|
|
projectId: project.projectId,
|
|
cwd: "/tmp/observer-batch/feature",
|
|
kind: "directory",
|
|
displayName: "feature",
|
|
createdAt: "2026-07-15T00:00:00.000Z",
|
|
updatedAt: "2026-07-15T00:00:00.000Z",
|
|
});
|
|
const snapshotReads = { projects: 0, workspaces: 0 };
|
|
session.projectRegistry.list = async () => {
|
|
snapshotReads.projects += 1;
|
|
return [project];
|
|
};
|
|
session.workspaceRegistry.list = async () => {
|
|
snapshotReads.workspaces += 1;
|
|
return [main, feature];
|
|
};
|
|
session.listAgentPayloads = async () => [];
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-observer-batch",
|
|
filter: undefined,
|
|
subscribe: { subscriptionId: "sub-observer-batch" },
|
|
});
|
|
main.displayName = "Updated main";
|
|
feature.displayName = "Updated feature";
|
|
snapshotReads.projects = 0;
|
|
snapshotReads.workspaces = 0;
|
|
session.onMessage = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
|
|
await session.emitWorkspaceUpdatesForExternalWorkspaceIds([
|
|
main.workspaceId,
|
|
feature.workspaceId,
|
|
main.workspaceId,
|
|
]);
|
|
|
|
expect(filterByType(emitted, "workspace_update")).toEqual([
|
|
{
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
workspace: expect.objectContaining({
|
|
id: main.workspaceId,
|
|
projectId: project.projectId,
|
|
workspaceDirectory: main.cwd,
|
|
name: main.displayName,
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
workspace: expect.objectContaining({
|
|
id: feature.workspaceId,
|
|
projectId: project.projectId,
|
|
workspaceDirectory: feature.cwd,
|
|
name: feature.displayName,
|
|
}),
|
|
},
|
|
},
|
|
]);
|
|
expect(snapshotReads).toEqual({ projects: 1, workspaces: 1 });
|
|
});
|
|
|
|
test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
|
git: {
|
|
currentBranch: "runtime-branch",
|
|
isDirty: true,
|
|
aheadBehind: { ahead: 3, behind: 1 },
|
|
aheadOfOrigin: 3,
|
|
behindOfOrigin: 1,
|
|
},
|
|
forge: {
|
|
pullRequest: {
|
|
url: "https://github.com/acme/repo/pull/456",
|
|
title: "Ship runtime payloads",
|
|
state: "open",
|
|
baseRefName: "main",
|
|
headRefName: "runtime-branch",
|
|
isMerged: false,
|
|
},
|
|
},
|
|
});
|
|
const peekSnapshotRuntimeFetch = vi.fn(() => runtimeSnapshot);
|
|
const workspaceGitService = createNoopWorkspaceGitService();
|
|
workspaceGitService.peekSnapshot = peekSnapshotRuntimeFetch;
|
|
workspaceGitService.registerWorkspace = vi.fn(() => ({
|
|
unsubscribe: () => {},
|
|
}));
|
|
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({
|
|
workspaceGitService,
|
|
}),
|
|
);
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-runtime-fetch",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-runtime-fetch",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.listAgentPayloads = async () => [];
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.buildProjectPlacement = async (cwd: string) => ({
|
|
projectKey: cwd,
|
|
projectName: "repo",
|
|
checkout: {
|
|
cwd,
|
|
isGit: true,
|
|
currentBranch: runtimeSnapshot.git.currentBranch,
|
|
remoteUrl: runtimeSnapshot.git.remoteUrl,
|
|
worktreeRoot: cwd,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-fetch-workspaces-runtime",
|
|
});
|
|
|
|
const response = emitted.find((message) => message.type === "fetch_workspaces_response") as
|
|
| { type: "fetch_workspaces_response"; payload: Record<string, unknown> }
|
|
| undefined;
|
|
|
|
expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith(REPO_CWD);
|
|
expect(response?.payload.entries).toEqual([
|
|
expect.objectContaining({
|
|
id: "ws-runtime-fetch",
|
|
gitRuntime: {
|
|
currentBranch: "runtime-branch",
|
|
remoteUrl: "https://github.com/acme/repo.git",
|
|
isPaseoOwnedWorktree: false,
|
|
isDirty: true,
|
|
aheadBehind: { ahead: 3, behind: 1 },
|
|
aheadOfOrigin: 3,
|
|
behindOfOrigin: 1,
|
|
},
|
|
githubRuntime: {
|
|
featuresEnabled: true,
|
|
pullRequest: {
|
|
url: "https://github.com/acme/repo/pull/456",
|
|
title: "Ship runtime payloads",
|
|
state: "open",
|
|
baseRefName: "main",
|
|
headRefName: "runtime-branch",
|
|
isMerged: false,
|
|
},
|
|
error: null,
|
|
},
|
|
}),
|
|
]);
|
|
});
|
|
|
|
test("fetch_workspaces_response emits before cold registration-triggered git work starts", async () => {
|
|
const events: string[] = [];
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const workspaceGitService = createNoopWorkspaceGitService();
|
|
const getSnapshot = vi.fn(async (cwd: string) => {
|
|
events.push(`git:${cwd}`);
|
|
return createWorkspaceRuntimeSnapshot(cwd);
|
|
});
|
|
workspaceGitService.getSnapshot = getSnapshot;
|
|
workspaceGitService.registerWorkspace = vi.fn((params: { cwd: string }) => {
|
|
queueMicrotask(() => {
|
|
void getSnapshot(params.cwd);
|
|
});
|
|
return {
|
|
unsubscribe: () => {},
|
|
};
|
|
});
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({
|
|
workspaceGitService,
|
|
onMessage: (message) => {
|
|
if (message.type === "fetch_workspaces_response") events.push("response");
|
|
emitted.push(message);
|
|
},
|
|
}),
|
|
);
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-fetch-boundary",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-fetch-boundary",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
session.listAgentPayloads = async () => [];
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-fetch-workspaces-boundary",
|
|
subscribe: {},
|
|
});
|
|
|
|
expect(emitted.find((message) => message.type === "fetch_workspaces_response")).toBeDefined();
|
|
expect(events[0]).toBe("response");
|
|
});
|
|
|
|
test("workspace_update includes updated runtime fields", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
|
|
git: {
|
|
currentBranch: "feature/runtime-payloads",
|
|
isDirty: true,
|
|
},
|
|
forge: {
|
|
pullRequest: {
|
|
url: "https://github.com/acme/repo/pull/789",
|
|
title: "Updated runtime payloads",
|
|
state: "merged",
|
|
baseRefName: "main",
|
|
headRefName: "feature/runtime-payloads",
|
|
isMerged: true,
|
|
},
|
|
},
|
|
});
|
|
const peekSnapshotRuntimeUpdate = vi.fn(() => runtimeSnapshot);
|
|
const workspaceGitService = createNoopWorkspaceGitService();
|
|
workspaceGitService.peekSnapshot = peekSnapshotRuntimeUpdate;
|
|
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({
|
|
workspaceGitService,
|
|
onMessage: (message) => emitted.push(message),
|
|
}),
|
|
);
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-runtime-update",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-runtime-update",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-runtime",
|
|
filter: undefined,
|
|
subscribe: { subscriptionId: "sub-runtime" },
|
|
});
|
|
session.listAgentPayloads = async () => [];
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.buildProjectPlacement = async (cwd: string) => ({
|
|
projectKey: cwd,
|
|
projectName: "repo",
|
|
checkout: {
|
|
cwd,
|
|
isGit: true,
|
|
currentBranch: runtimeSnapshot.git.currentBranch,
|
|
remoteUrl: runtimeSnapshot.git.remoteUrl,
|
|
worktreeRoot: cwd,
|
|
isPaseoOwnedWorktree: false,
|
|
mainRepoRoot: null,
|
|
},
|
|
});
|
|
|
|
await session.emitWorkspaceUpdateForCwd(REPO_CWD);
|
|
|
|
expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith(REPO_CWD);
|
|
expect(emitted).toContainEqual({
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
workspace: expect.objectContaining({
|
|
id: "ws-runtime-update",
|
|
gitRuntime: expect.objectContaining({
|
|
currentBranch: "feature/runtime-payloads",
|
|
isDirty: true,
|
|
}),
|
|
githubRuntime: expect.objectContaining({
|
|
featuresEnabled: true,
|
|
pullRequest: expect.objectContaining({
|
|
title: "Updated runtime payloads",
|
|
isMerged: true,
|
|
}),
|
|
}),
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
|
|
test("subscribed fetch_workspaces includes git enrichment in the initial snapshot", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests();
|
|
const gitProject = createPersistedProjectRecord({
|
|
projectId: "proj-git-subscribe",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const directoryProject = createPersistedProjectRecord({
|
|
projectId: "proj-docs-subscribe",
|
|
rootPath: "/tmp/docs",
|
|
kind: "non_git",
|
|
displayName: "docs",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const gitWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-git-subscribe",
|
|
projectId: gitProject.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const directoryWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-docs-subscribe",
|
|
projectId: directoryProject.projectId,
|
|
cwd: "/tmp/docs",
|
|
kind: "directory",
|
|
displayName: "docs",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const baselineGitDescriptor = {
|
|
id: gitWorkspace.workspaceId,
|
|
projectId: gitProject.projectId,
|
|
projectDisplayName: gitProject.displayName,
|
|
projectRootPath: gitProject.rootPath,
|
|
workspaceDirectory: gitWorkspace.cwd,
|
|
projectKind: gitProject.kind,
|
|
workspaceKind: gitWorkspace.kind,
|
|
name: "main",
|
|
status: "done",
|
|
activityAt: null,
|
|
diffStat: null,
|
|
} as const;
|
|
const enrichedGitDescriptor = {
|
|
...baselineGitDescriptor,
|
|
diffStat: { additions: 3, deletions: 1 },
|
|
} as const;
|
|
const directoryDescriptor = {
|
|
id: directoryWorkspace.workspaceId,
|
|
projectId: directoryProject.projectId,
|
|
projectDisplayName: directoryProject.displayName,
|
|
projectRootPath: directoryProject.rootPath,
|
|
workspaceDirectory: directoryWorkspace.cwd,
|
|
projectKind: directoryProject.kind,
|
|
workspaceKind: directoryWorkspace.kind,
|
|
name: "docs",
|
|
status: "done",
|
|
activityAt: null,
|
|
diffStat: null,
|
|
} as const;
|
|
|
|
session.emit = (message) => {
|
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
|
};
|
|
session.listAgentPayloads = async () => [];
|
|
session.projectRegistry.list = async () => [gitProject, directoryProject];
|
|
session.workspaceRegistry.list = async () => [gitWorkspace, directoryWorkspace];
|
|
const describeWorkspaceRecordSubscribed = vi.fn(
|
|
async (workspace: typeof gitWorkspace, project: unknown) => {
|
|
if (workspace.workspaceId === gitWorkspace.workspaceId) {
|
|
expect(project).toEqual(gitProject);
|
|
return baselineGitDescriptor;
|
|
}
|
|
expect(project).toEqual(directoryProject);
|
|
return directoryDescriptor;
|
|
},
|
|
);
|
|
const describeWorkspaceRecordWithGitDataSubscribed = vi.fn(async () => enrichedGitDescriptor);
|
|
session.describeWorkspaceRecord = describeWorkspaceRecordSubscribed;
|
|
session.describeWorkspaceRecordWithGitData = describeWorkspaceRecordWithGitDataSubscribed;
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-fetch-workspaces",
|
|
subscribe: {},
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
const response = findByType(emitted, "fetch_workspaces_response");
|
|
expect(response?.payload.entries.map((entry) => [entry.id, entry.diffStat])).toEqual([
|
|
[directoryDescriptor.id, directoryDescriptor.diffStat],
|
|
[enrichedGitDescriptor.id, enrichedGitDescriptor.diffStat],
|
|
]);
|
|
|
|
const workspaceUpdates = filterByType(emitted, "workspace_update");
|
|
expect(workspaceUpdates).toEqual([]);
|
|
expect(describeWorkspaceRecordWithGitDataSubscribed).toHaveBeenCalledWith(
|
|
gitWorkspace,
|
|
gitProject,
|
|
);
|
|
});
|
|
|
|
test("workspace mutation handling does not let a delayed upsert recreate an archived observer", async () => {
|
|
let mutationListener: ((mutation: WorkspaceMutation) => void | Promise<void>) | null = null;
|
|
const registerCalls: string[] = [];
|
|
const unsubscribeCalls: string[] = [];
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-race",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-race",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
let listedWorkspaces: PersistedWorkspaceRecord[] = [];
|
|
const workspaceRegistry: SessionOptions["workspaceRegistry"] = {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => listedWorkspaces,
|
|
get: async (workspaceId: string) => (workspaceId === workspace.workspaceId ? workspace : null),
|
|
update: async () => null,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
subscribeToMutations: (listener) => {
|
|
mutationListener = listener;
|
|
return () => {
|
|
mutationListener = null;
|
|
};
|
|
},
|
|
};
|
|
const session = createSessionForWorkspaceTests({
|
|
projectRegistry: {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [project],
|
|
get: async (projectId: string) => (projectId === project.projectId ? project : null),
|
|
getOrCreateActiveByRoot: async () => project,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
},
|
|
workspaceRegistry,
|
|
workspaceGitService: createNoopWorkspaceGitService({
|
|
registerWorkspace: ({ cwd }) => {
|
|
registerCalls.push(path.resolve(cwd));
|
|
return {
|
|
unsubscribe: () => {
|
|
unsubscribeCalls.push(path.resolve(cwd));
|
|
},
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
session.emitWorkspaceUpdatesForWorkspaceIds = async () => {};
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-fetch-workspaces-subscribe",
|
|
subscribe: {},
|
|
});
|
|
listedWorkspaces = [workspace];
|
|
|
|
let resumeDescribe!: () => void;
|
|
const describeStarted = new Promise<void>((resolveStarted) => {
|
|
workspaceRegistry.list = async () => {
|
|
resolveStarted();
|
|
await new Promise<void>((resolveResume) => {
|
|
resumeDescribe = resolveResume;
|
|
});
|
|
return listedWorkspaces;
|
|
};
|
|
});
|
|
|
|
const upsertMutation = mutationListener?.({
|
|
kind: "upsert",
|
|
workspaceId: workspace.workspaceId,
|
|
workspace,
|
|
});
|
|
expect(upsertMutation).toBeDefined();
|
|
const upsertPromise = Promise.resolve(upsertMutation);
|
|
await describeStarted;
|
|
|
|
const archivedWorkspace = { ...workspace, archivedAt: "2026-03-02T12:00:00.000Z" };
|
|
const archivePromise = Promise.resolve(
|
|
mutationListener?.({
|
|
kind: "archive",
|
|
workspaceId: workspace.workspaceId,
|
|
workspace: archivedWorkspace,
|
|
}),
|
|
);
|
|
|
|
await Promise.resolve();
|
|
expect(unsubscribeCalls).toEqual([]);
|
|
|
|
resumeDescribe();
|
|
await upsertPromise;
|
|
await archivePromise;
|
|
|
|
expect(registerCalls).toEqual([REPO_CWD]);
|
|
expect(unsubscribeCalls).toEqual([REPO_CWD]);
|
|
});
|
|
|
|
test("workspace mutations outside a filtered subscription neither watch nor emit", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
let mutationListener: ((mutation: WorkspaceMutation) => void | Promise<void>) | null = null;
|
|
const registerCalls: string[] = [];
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-filtered-mutation",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-filtered-mutation",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const descriptor = {
|
|
id: workspace.workspaceId,
|
|
projectId: project.projectId,
|
|
projectDisplayName: project.displayName,
|
|
projectRootPath: project.rootPath,
|
|
workspaceDirectory: workspace.cwd,
|
|
projectKind: project.kind,
|
|
workspaceKind: workspace.kind,
|
|
name: workspace.displayName,
|
|
status: "done",
|
|
activityAt: null,
|
|
diffStat: null,
|
|
} as WorkspaceDescriptorPayload;
|
|
const workspaceRegistry: SessionOptions["workspaceRegistry"] = {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [workspace],
|
|
get: async (workspaceId: string) => (workspaceId === workspace.workspaceId ? workspace : null),
|
|
update: async () => null,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
subscribeToMutations: (listener) => {
|
|
mutationListener = listener;
|
|
return () => {
|
|
mutationListener = null;
|
|
};
|
|
},
|
|
};
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
projectRegistry: {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [project],
|
|
get: async (projectId: string) => (projectId === project.projectId ? project : null),
|
|
getOrCreateActiveByRoot: async () => project,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
},
|
|
workspaceRegistry,
|
|
workspaceGitService: createNoopWorkspaceGitService({
|
|
registerWorkspace: ({ cwd }) => {
|
|
registerCalls.push(path.resolve(cwd));
|
|
return { unsubscribe: () => {} };
|
|
},
|
|
}),
|
|
});
|
|
session.listFetchWorkspacesEntries = async () => ({
|
|
entries: [],
|
|
emptyProjects: [],
|
|
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
|
});
|
|
session.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
|
|
|
|
const source = {};
|
|
session.updateClientCapabilities({ [CLIENT_CAPS.ownedSubscriptions]: true }, source);
|
|
await session.handleMessage(
|
|
{
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-filtered-mutation",
|
|
filter: { projectId: "some-other-project" },
|
|
subscribe: {},
|
|
},
|
|
source,
|
|
);
|
|
emitted.length = 0;
|
|
|
|
await mutationListener?.({
|
|
kind: "upsert",
|
|
workspaceId: workspace.workspaceId,
|
|
workspace,
|
|
});
|
|
session.buildWorkspaceDescriptorMap = async () => new Map();
|
|
await mutationListener?.({
|
|
kind: "archive",
|
|
workspaceId: workspace.workspaceId,
|
|
workspace: { ...workspace, archivedAt: "2026-03-02T12:00:00.000Z" },
|
|
});
|
|
|
|
expect(registerCalls).toEqual([]);
|
|
expect(filterByType(emitted, "workspace_update")).toEqual([]);
|
|
});
|
|
|
|
test("project removal mutation broadcasts the final delta to another subscribed session", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-global-remove",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-global-remove",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-02T12:00:00.000Z",
|
|
archivedAt: "2026-03-02T12:00:00.000Z",
|
|
});
|
|
let projectMutationListener:
|
|
| Parameters<NonNullable<SessionOptions["projectRegistry"]["subscribeToMutations"]>>[0]
|
|
| null = null;
|
|
const projectRegistry: SessionOptions["projectRegistry"] = {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [],
|
|
get: async () => null,
|
|
getOrCreateActiveByRoot: async () => project,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async (projectId) => {
|
|
await projectMutationListener?.({ kind: "remove", projectId, project: null });
|
|
},
|
|
subscribeToMutations: (listener) => {
|
|
projectMutationListener = listener;
|
|
return () => {
|
|
projectMutationListener = null;
|
|
};
|
|
},
|
|
};
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
projectRegistry,
|
|
workspaceRegistry: {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [workspace],
|
|
get: async (workspaceId) => (workspaceId === workspace.workspaceId ? workspace : null),
|
|
update: async () => null,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
},
|
|
});
|
|
workspace.archivedAt = null;
|
|
projectRegistry.list = async () => [project];
|
|
projectRegistry.get = async () => project;
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-global-remove",
|
|
subscribe: { subscriptionId: "sub-global-remove" },
|
|
});
|
|
workspace.archivedAt = "2026-03-02T12:00:00.000Z";
|
|
await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
|
|
emitted.length = 0;
|
|
projectRegistry.list = async () => [];
|
|
projectRegistry.get = async () => null;
|
|
|
|
await projectRegistry.remove(project.projectId);
|
|
|
|
expect(filterByType(emitted, "workspace_update")).toEqual([
|
|
{
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "remove",
|
|
id: workspace.workspaceId,
|
|
removedProjectId: project.projectId,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("workspace mutation handling drops queued observer sync after session cleanup", async () => {
|
|
let mutationListener: ((mutation: WorkspaceMutation) => void | Promise<void>) | null = null;
|
|
const registerCalls: string[] = [];
|
|
const unsubscribeCalls: string[] = [];
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-cleanup-race",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const firstWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-cleanup-first",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const queuedWorkspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-cleanup-queued",
|
|
projectId: project.projectId,
|
|
cwd: "/tmp/repo/queued",
|
|
kind: "local_checkout",
|
|
displayName: "queued",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspaces = new Map([
|
|
[firstWorkspace.workspaceId, firstWorkspace],
|
|
[queuedWorkspace.workspaceId, queuedWorkspace],
|
|
]);
|
|
let listedWorkspaces: PersistedWorkspaceRecord[] = [];
|
|
const workspaceRegistry: SessionOptions["workspaceRegistry"] = {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => listedWorkspaces,
|
|
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
|
update: async () => null,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
subscribeToMutations: (listener) => {
|
|
mutationListener = listener;
|
|
return () => {
|
|
mutationListener = null;
|
|
};
|
|
},
|
|
};
|
|
const session = createSessionForWorkspaceTests({
|
|
projectRegistry: {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => [project],
|
|
get: async (projectId: string) => (projectId === project.projectId ? project : null),
|
|
getOrCreateActiveByRoot: async () => project,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
},
|
|
workspaceRegistry,
|
|
workspaceGitService: createNoopWorkspaceGitService({
|
|
registerWorkspace: ({ cwd }) => {
|
|
registerCalls.push(path.resolve(cwd));
|
|
return {
|
|
unsubscribe: () => {
|
|
unsubscribeCalls.push(path.resolve(cwd));
|
|
},
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-fetch-workspaces-cleanup-subscribe",
|
|
subscribe: {},
|
|
});
|
|
listedWorkspaces = Array.from(workspaces.values());
|
|
|
|
session.describeWorkspaceRecordWithGitData = async (workspace) =>
|
|
({
|
|
id: workspace.workspaceId,
|
|
projectId: workspace.projectId,
|
|
projectDisplayName: project.displayName,
|
|
projectRootPath: project.rootPath,
|
|
workspaceDirectory: workspace.cwd,
|
|
projectKind: project.kind,
|
|
workspaceKind: workspace.kind,
|
|
name: workspace.displayName,
|
|
status: "done",
|
|
activityAt: null,
|
|
diffStat: null,
|
|
}) as WorkspaceDescriptorPayload;
|
|
|
|
let resumeFirstEmit!: () => void;
|
|
const firstEmitStarted = new Promise<void>((resolveStarted) => {
|
|
session.emitWorkspaceUpdatesForWorkspaceIds = async () => {
|
|
resolveStarted();
|
|
await new Promise<void>((resolveResume) => {
|
|
resumeFirstEmit = resolveResume;
|
|
});
|
|
};
|
|
});
|
|
|
|
const firstMutationPromise = Promise.resolve(
|
|
mutationListener?.({
|
|
kind: "upsert",
|
|
workspaceId: firstWorkspace.workspaceId,
|
|
workspace: firstWorkspace,
|
|
}),
|
|
);
|
|
expect(firstMutationPromise).toBeDefined();
|
|
await firstEmitStarted;
|
|
|
|
const queuedMutationPromise = Promise.resolve(
|
|
mutationListener?.({
|
|
kind: "upsert",
|
|
workspaceId: queuedWorkspace.workspaceId,
|
|
workspace: queuedWorkspace,
|
|
}),
|
|
);
|
|
|
|
await session.cleanup();
|
|
resumeFirstEmit();
|
|
await firstMutationPromise;
|
|
await queuedMutationPromise;
|
|
|
|
expect(registerCalls).toEqual([REPO_CWD]);
|
|
expect(unsubscribeCalls).toEqual([REPO_CWD]);
|
|
});
|
|
|
|
test("a workspace leaving a filtered subscription after bootstrap emits a removal", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
|
);
|
|
const descriptor = {
|
|
id: "ws-buffered",
|
|
projectId: "proj-buffered",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: REPO_CWD,
|
|
workspaceDirectory: REPO_CWD,
|
|
projectKind: "git" as const,
|
|
workspaceKind: "local_checkout" as const,
|
|
name: "repo work",
|
|
status: "done" as const,
|
|
activityAt: null,
|
|
diffStat: null,
|
|
};
|
|
let currentDescriptor: typeof descriptor | null = descriptor;
|
|
let finishListing: (result: ListFetchResult) => void = () => {};
|
|
const listing = new Promise<ListFetchResult>((resolve) => {
|
|
finishListing = resolve;
|
|
});
|
|
session.listFetchWorkspacesEntries = async () => listing;
|
|
session.buildWorkspaceDescriptorMap = async () =>
|
|
new Map(currentDescriptor ? [[currentDescriptor.id, currentDescriptor]] : []);
|
|
const bootstrap = session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-buffered-filter",
|
|
filter: { query: "repo" },
|
|
subscribe: { subscriptionId: "sub-buffered-filter" },
|
|
});
|
|
await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id]);
|
|
finishListing({
|
|
entries: [],
|
|
emptyProjects: [],
|
|
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
|
});
|
|
await bootstrap;
|
|
|
|
expect(filterByType(emitted, "workspace_update")).toEqual([
|
|
{ type: "workspace_update", payload: { kind: "upsert", workspace: descriptor } },
|
|
]);
|
|
|
|
emitted.length = 0;
|
|
currentDescriptor = { ...descriptor, name: "other work" };
|
|
await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id]);
|
|
|
|
expect(filterByType(emitted, "workspace_update")).toEqual([
|
|
{
|
|
type: "workspace_update",
|
|
payload: { kind: "remove", id: descriptor.id },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("queued workspace updates are dropped when a new workspace subscription replaces the old one", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
|
);
|
|
const descriptor = {
|
|
id: "ws-replaced-subscription",
|
|
projectId: "proj-replaced-subscription",
|
|
projectDisplayName: "repo",
|
|
projectRootPath: REPO_CWD,
|
|
workspaceDirectory: REPO_CWD,
|
|
projectKind: "git" as const,
|
|
workspaceKind: "local_checkout" as const,
|
|
name: "repo work",
|
|
status: "done" as const,
|
|
activityAt: null,
|
|
diffStat: null,
|
|
};
|
|
let currentDescriptor = descriptor;
|
|
let listCallCount = 0;
|
|
session.listFetchWorkspacesEntries = async () => {
|
|
listCallCount += 1;
|
|
return {
|
|
entries: listCallCount === 1 ? [descriptor] : [],
|
|
emptyProjects: [],
|
|
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
|
};
|
|
};
|
|
|
|
const firstBuildStarted = deferred<void>();
|
|
const releaseFirstBuild = deferred<void>();
|
|
let buildCount = 0;
|
|
session.buildWorkspaceDescriptorMap = async () => {
|
|
buildCount += 1;
|
|
if (buildCount === 1) {
|
|
firstBuildStarted.resolve();
|
|
await releaseFirstBuild.promise;
|
|
}
|
|
return new Map([[currentDescriptor.id, currentDescriptor]]);
|
|
};
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-old-workspaces",
|
|
filter: { query: "repo" },
|
|
subscribe: { subscriptionId: "sub-old-workspaces" },
|
|
});
|
|
|
|
emitted.length = 0;
|
|
const queuedUpdate = session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id]);
|
|
await firstBuildStarted.promise;
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-new-workspaces",
|
|
filter: { query: "other" },
|
|
subscribe: { subscriptionId: "sub-new-workspaces" },
|
|
});
|
|
|
|
emitted.length = 0;
|
|
releaseFirstBuild.resolve();
|
|
await queuedUpdate;
|
|
await waitForImmediate();
|
|
|
|
expect(filterByType(emitted, "workspace_update")).toEqual([]);
|
|
|
|
currentDescriptor = { ...descriptor, name: "other work" };
|
|
await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id]);
|
|
|
|
expect(filterByType(emitted, "workspace_update")).toEqual([
|
|
{
|
|
type: "workspace_update",
|
|
payload: { kind: "upsert", workspace: currentDescriptor },
|
|
},
|
|
]);
|
|
});
|
|
|
|
const ICON_PNG_1X1 = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00,
|
|
]);
|
|
|
|
test("project.icon.set.request publishes a custom icon that project.icon.get serves back", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-project-icon-test-")));
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({
|
|
paseoHome: path.join(tempDir, "paseo-home"),
|
|
onMessage: (message) => emitted.push(message),
|
|
}),
|
|
);
|
|
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true });
|
|
|
|
const projectRoot = path.join(tempDir, "project-without-icons");
|
|
mkdirSync(projectRoot, { recursive: true });
|
|
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "prj_icon",
|
|
rootPath: projectRoot,
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-07-22T12:00:00.000Z",
|
|
updatedAt: "2026-07-22T12:00:00.000Z",
|
|
});
|
|
const projects = new Map([[project.projectId, project]]);
|
|
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
|
session.projectRegistry.update = async (id, updater) => {
|
|
const current = projects.get(id);
|
|
if (!current) return null;
|
|
const updated = updater(current);
|
|
projects.set(id, updated);
|
|
return updated;
|
|
};
|
|
session.workspaceRegistry.list = async () => [];
|
|
|
|
await session.handleMessage({
|
|
type: "project.icon.set.request",
|
|
projectId: project.projectId,
|
|
source: { type: "upload", data: ICON_PNG_1X1.toString("base64") },
|
|
requestId: "req-icon-set",
|
|
});
|
|
|
|
expect(findByType(emitted, "project.icon.set.response")?.payload).toEqual({
|
|
requestId: "req-icon-set",
|
|
projectId: project.projectId,
|
|
accepted: true,
|
|
error: null,
|
|
});
|
|
const revision = projects.get(project.projectId)?.customIconRevision;
|
|
expect(revision).toEqual(expect.any(String));
|
|
expect(findByType(emitted, "project.update")?.payload).toMatchObject({
|
|
kind: "upsert",
|
|
project: { projectCustomIconRevision: revision },
|
|
});
|
|
|
|
emitted.length = 0;
|
|
await session.handleMessage({
|
|
type: "project.icon.get.request",
|
|
projectId: project.projectId,
|
|
requestId: "req-icon-custom",
|
|
});
|
|
expect(findByType(emitted, "project.icon.get.response")?.payload).toEqual({
|
|
projectId: project.projectId,
|
|
icon: { data: ICON_PNG_1X1.toString("base64"), mimeType: "image/png" },
|
|
error: null,
|
|
requestId: "req-icon-custom",
|
|
});
|
|
|
|
emitted.length = 0;
|
|
await session.handleMessage({
|
|
type: "project.icon.set.request",
|
|
projectId: project.projectId,
|
|
source: { type: "automatic" },
|
|
requestId: "req-icon-automatic",
|
|
});
|
|
expect(projects.get(project.projectId)?.customIconRevision).toBeNull();
|
|
expect(findByType(emitted, "project.icon.get.response")).toBeUndefined();
|
|
|
|
await session.handleMessage({
|
|
type: "project.icon.get.request",
|
|
projectId: project.projectId,
|
|
requestId: "req-icon-scan",
|
|
});
|
|
expect(findByType(emitted, "project.icon.get.response")?.payload).toMatchObject({
|
|
icon: null,
|
|
error: null,
|
|
});
|
|
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("project.rename.request stores customName and emits an updated workspace descriptor", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
|
);
|
|
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "prj_rename",
|
|
projectKey: "remote:github.com/acme/repo",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "acme/repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-1",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
const projects = new Map([[project.projectId, project]]);
|
|
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.projectRegistry.upsert = async (record: unknown) => {
|
|
const parsed = record as typeof project;
|
|
projects.set(parsed.projectId, parsed);
|
|
};
|
|
session.workspaceRegistry.list = async () => [workspace];
|
|
session.workspaceRegistry.get = async (id: string) =>
|
|
id === workspace.workspaceId ? workspace : null;
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-workspaces",
|
|
subscribe: { subscriptionId: "sub-workspaces" },
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "project.rename.request",
|
|
projectId: project.projectId,
|
|
customName: " My Fork ",
|
|
requestId: "req-rename-1",
|
|
});
|
|
|
|
const response = findByType(emitted, "project.rename.response");
|
|
expect(response?.payload).toEqual({
|
|
requestId: "req-rename-1",
|
|
projectId: project.projectId,
|
|
accepted: true,
|
|
customName: "My Fork",
|
|
error: null,
|
|
});
|
|
|
|
expect(projects.get(project.projectId)?.customName).toBe("My Fork");
|
|
|
|
const update = findByType(emitted, "workspace_update");
|
|
expect(update?.payload).toMatchObject({
|
|
kind: "upsert",
|
|
workspace: {
|
|
id: "ws-1",
|
|
projectDisplayName: "My Fork",
|
|
projectCustomName: "My Fork",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("project.rename.request updates a project with no workspaces", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
|
);
|
|
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true });
|
|
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "prj_empty",
|
|
rootPath: REPO_CWD,
|
|
kind: "non_git",
|
|
displayName: "repo",
|
|
createdAt: "2026-07-20T12:00:00.000Z",
|
|
updatedAt: "2026-07-20T12:00:00.000Z",
|
|
});
|
|
session.projectRegistry.get = async () => project;
|
|
session.projectRegistry.upsert = async () => project;
|
|
session.workspaceRegistry.list = async () => [];
|
|
|
|
await session.handleMessage({
|
|
type: "project.rename.request",
|
|
projectId: project.projectId,
|
|
customName: "Renamed empty project",
|
|
requestId: "req-rename-empty",
|
|
});
|
|
|
|
expect(findByType(emitted, "project.update")?.payload).toMatchObject({
|
|
kind: "upsert",
|
|
project: {
|
|
projectId: project.projectId,
|
|
projectDisplayName: "Renamed empty project",
|
|
projectCustomName: "Renamed empty project",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("project.rename.request with whitespace-only customName clears the override", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
|
);
|
|
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "remote:github.com/acme/repo",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "acme/repo",
|
|
customName: "My Fork",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
const projects = new Map([[project.projectId, project]]);
|
|
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.projectRegistry.upsert = async (record: unknown) => {
|
|
const parsed = record as typeof project;
|
|
projects.set(parsed.projectId, parsed);
|
|
};
|
|
session.workspaceRegistry.list = async () => [];
|
|
|
|
await session.handleMessage({
|
|
type: "project.rename.request",
|
|
projectId: project.projectId,
|
|
customName: " ",
|
|
requestId: "req-rename-clear",
|
|
});
|
|
|
|
const response = findByType(emitted, "project.rename.response");
|
|
expect(response?.payload).toEqual({
|
|
requestId: "req-rename-clear",
|
|
projectId: project.projectId,
|
|
accepted: true,
|
|
customName: null,
|
|
error: null,
|
|
});
|
|
expect(projects.get(project.projectId)?.customName).toBeNull();
|
|
});
|
|
|
|
test("project.rename.request returns accepted=false when project is not found", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
|
);
|
|
session.projectRegistry.get = async () => null;
|
|
await session.handleMessage({
|
|
type: "project.rename.request",
|
|
projectId: "does-not-exist",
|
|
customName: "X",
|
|
requestId: "req-rename-missing",
|
|
});
|
|
|
|
const response = findByType(emitted, "project.rename.response");
|
|
expect(response?.payload).toMatchObject({
|
|
requestId: "req-rename-missing",
|
|
projectId: "does-not-exist",
|
|
accepted: false,
|
|
customName: null,
|
|
});
|
|
expect(response?.payload.error).toBeTruthy();
|
|
});
|
|
|
|
test("workspace.title.set.request stores the title and emits an updated descriptor", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
|
);
|
|
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-1",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "acme/repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-1",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
const projects = new Map([[project.projectId, project]]);
|
|
const workspaces = new Map([[workspace.workspaceId, workspace]]);
|
|
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
|
session.projectRegistry.list = async () => Array.from(projects.values());
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
|
|
session.workspaceRegistry.update = async (id, updater) => {
|
|
const existing = workspaces.get(id);
|
|
if (!existing) return null;
|
|
const updated = updater(existing);
|
|
workspaces.set(id, updated);
|
|
return updated;
|
|
};
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-workspaces",
|
|
subscribe: { subscriptionId: "sub-workspaces" },
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.title.set.request",
|
|
workspaceId: workspace.workspaceId,
|
|
title: " Payments work ",
|
|
requestId: "req-title-1",
|
|
});
|
|
|
|
const response = findByType(emitted, "workspace.title.set.response");
|
|
expect(response?.payload).toEqual({
|
|
requestId: "req-title-1",
|
|
workspaceId: workspace.workspaceId,
|
|
accepted: true,
|
|
title: "Payments work",
|
|
error: null,
|
|
});
|
|
|
|
expect(workspaces.get(workspace.workspaceId)?.title).toBe("Payments work");
|
|
|
|
const update = findByType(emitted, "workspace_update");
|
|
expect(update?.payload).toMatchObject({
|
|
kind: "upsert",
|
|
workspace: {
|
|
id: "ws-1",
|
|
name: "Payments work",
|
|
title: "Payments work",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("workspace.pin.set.request stores the pin timestamp and emits an updated descriptor", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
|
);
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-1",
|
|
rootPath: REPO_CWD,
|
|
kind: "git",
|
|
displayName: "acme/repo",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-1",
|
|
projectId: project.projectId,
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspaces = new Map([[workspace.workspaceId, workspace]]);
|
|
session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null);
|
|
session.projectRegistry.list = async () => [project];
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceRegistry.update = async (id, updater) => {
|
|
const existing = workspaces.get(id);
|
|
if (!existing) return null;
|
|
const updated = updater(existing);
|
|
workspaces.set(id, updated);
|
|
return updated;
|
|
};
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-workspaces",
|
|
subscribe: { subscriptionId: "sub-workspaces" },
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.pin.set.request",
|
|
workspaceId: workspace.workspaceId,
|
|
pinned: true,
|
|
requestId: "req-pin-1",
|
|
});
|
|
|
|
const response = findByType(emitted, "workspace.pin.set.response");
|
|
expect(response?.payload).toMatchObject({
|
|
requestId: "req-pin-1",
|
|
workspaceId: "ws-1",
|
|
accepted: true,
|
|
error: null,
|
|
});
|
|
expect(response?.payload.pinnedAt).toEqual(expect.any(String));
|
|
expect(workspaces.get("ws-1")?.pinnedAt).toBe(response?.payload.pinnedAt);
|
|
expect(findByType(emitted, "workspace_update")?.payload).toMatchObject({
|
|
kind: "upsert",
|
|
workspace: {
|
|
id: "ws-1",
|
|
pinnedAt: response?.payload.pinnedAt,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("workspace.title.set.request with whitespace-only title clears the title", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
|
);
|
|
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-1",
|
|
projectId: "proj-1",
|
|
cwd: REPO_CWD,
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
title: "Old title",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
const workspaces = new Map([[workspace.workspaceId, workspace]]);
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
|
|
session.workspaceRegistry.update = async (id, updater) => {
|
|
const existing = workspaces.get(id);
|
|
if (!existing) return null;
|
|
const updated = updater(existing);
|
|
workspaces.set(id, updated);
|
|
return updated;
|
|
};
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.title.set.request",
|
|
workspaceId: workspace.workspaceId,
|
|
title: " ",
|
|
requestId: "req-title-clear",
|
|
});
|
|
|
|
const response = findByType(emitted, "workspace.title.set.response");
|
|
expect(response?.payload).toEqual({
|
|
requestId: "req-title-clear",
|
|
workspaceId: workspace.workspaceId,
|
|
accepted: true,
|
|
title: null,
|
|
error: null,
|
|
});
|
|
expect(workspaces.get(workspace.workspaceId)?.title).toBeNull();
|
|
});
|
|
|
|
test("workspace.title.set.request returns accepted=false when workspace is not found", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = asTestSession(
|
|
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
|
);
|
|
session.workspaceRegistry.update = async () => null;
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.title.set.request",
|
|
workspaceId: "does-not-exist",
|
|
title: "X",
|
|
requestId: "req-title-missing",
|
|
});
|
|
|
|
const response = findByType(emitted, "workspace.title.set.response");
|
|
expect(response?.payload).toMatchObject({
|
|
requestId: "req-title-missing",
|
|
workspaceId: "does-not-exist",
|
|
accepted: false,
|
|
title: null,
|
|
});
|
|
expect(response?.payload.error).toBeTruthy();
|
|
});
|
|
|
|
async function createSessionWithTerminalManager(options: {
|
|
workspaces: PersistedWorkspaceRecord[];
|
|
projects: PersistedProjectRecord[];
|
|
onMessage?: (message: SessionOutboundMessage) => void;
|
|
}): Promise<{ session: TestSession; terminalManager: TerminalManager }> {
|
|
const terminalManager = createTerminalManager();
|
|
terminalManagers.push(terminalManager);
|
|
|
|
const projectRegistry: SessionOptions["projectRegistry"] = {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => options.projects,
|
|
get: async (projectId: string) =>
|
|
options.projects.find((project) => project.projectId === projectId) ?? null,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
|
|
const workspaceRegistry: SessionOptions["workspaceRegistry"] = {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => options.workspaces,
|
|
get: async (workspaceId: string) =>
|
|
options.workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null,
|
|
upsert: async () => {},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: options.onMessage,
|
|
terminalManager,
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "sub-workspaces",
|
|
subscribe: { subscriptionId: "sub-workspaces" },
|
|
});
|
|
|
|
return { session, terminalManager };
|
|
}
|
|
|
|
async function flushTerminalContributionWork(): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
async function waitForWorkspaceUpdate(
|
|
emitted: SessionOutboundMessage[],
|
|
predicate: (message: Extract<SessionOutboundMessage, { type: "workspace_update" }>) => boolean,
|
|
description: string,
|
|
): Promise<Extract<SessionOutboundMessage, { type: "workspace_update" }>> {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < 1000) {
|
|
const match = filterByType(emitted, "workspace_update").find(predicate);
|
|
if (match) {
|
|
return match;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
throw new Error(`Timed out waiting for workspace_update: ${description}`);
|
|
}
|
|
|
|
test("overlapping workspace rebuilds publish the newest provider subagent status", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const parent = makeManagedAgent({
|
|
id: "provider-parent",
|
|
cwd: REPO_CWD,
|
|
workspaceId: "ws-repo-running",
|
|
lifecycle: "idle",
|
|
updatedAt: "2026-08-01T10:00:00.000Z",
|
|
}) as unknown as ManagedAgent;
|
|
const providerSubagents: ProviderSubagentDescriptor[] = [];
|
|
let listener: ((event: AgentManagerEvent) => void) | null = null;
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
agentStorage: { list: async () => [] },
|
|
agentManager: {
|
|
subscribe: (nextListener: (event: AgentManagerEvent) => void) => {
|
|
listener = nextListener;
|
|
return () => {};
|
|
},
|
|
listAgents: () => [parent],
|
|
listProviderSubagentActivity: () => providerSubagents,
|
|
getAgent: (agentId: string) => (agentId === parent.id ? parent : null),
|
|
},
|
|
});
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "provider-subagent-workspace-status",
|
|
subscribe: { subscriptionId: "provider-subagent-workspace-status" },
|
|
});
|
|
const initialWorkspace = findByType(emitted, "fetch_workspaces_response")?.payload.entries[0];
|
|
if (!initialWorkspace) {
|
|
throw new Error("Expected initial workspace descriptor");
|
|
}
|
|
const firstBuildStarted = deferred<void>();
|
|
const releaseFirstBuild = deferred<void>();
|
|
let buildCount = 0;
|
|
session.buildWorkspaceDescriptorMap = async () => {
|
|
buildCount += 1;
|
|
const status = providerSubagents.some((subagent) => subagent.status === "running")
|
|
? "running"
|
|
: "done";
|
|
if (buildCount === 1) {
|
|
firstBuildStarted.resolve();
|
|
await releaseFirstBuild.promise;
|
|
}
|
|
return new Map([[initialWorkspace.id, { ...initialWorkspace, status }]]);
|
|
};
|
|
emitted.length = 0;
|
|
|
|
const runningSubagent: ProviderSubagentDescriptor = {
|
|
id: "native-child",
|
|
parentAgentId: parent.id,
|
|
provider: "codex",
|
|
title: "Native child",
|
|
description: null,
|
|
status: "running",
|
|
createdAt: "2026-08-01T10:01:00.000Z",
|
|
updatedAt: "2026-08-01T10:01:00.000Z",
|
|
toolCallId: null,
|
|
cwd: REPO_CWD,
|
|
subtitle: null,
|
|
};
|
|
providerSubagents.push(runningSubagent);
|
|
listener?.({ type: "provider_subagent", event: { type: "upsert", subagent: runningSubagent } });
|
|
await firstBuildStarted.promise;
|
|
|
|
const completedSubagent = {
|
|
...runningSubagent,
|
|
status: "completed" as const,
|
|
updatedAt: "2026-08-01T10:02:00.000Z",
|
|
};
|
|
providerSubagents[0] = completedSubagent;
|
|
listener?.({ type: "provider_subagent", event: { type: "upsert", subagent: completedSubagent } });
|
|
await waitForImmediate();
|
|
releaseFirstBuild.resolve();
|
|
await vi.waitFor(() => expect(buildCount).toBe(2));
|
|
await waitForImmediate();
|
|
|
|
const statuses = filterByType(emitted, "workspace_update").flatMap((message) =>
|
|
message.payload.kind === "upsert" ? [message.payload.workspace.status] : [],
|
|
);
|
|
expect(statuses).toEqual(["running", "done"]);
|
|
});
|
|
|
|
test("title-only terminal change does not build workspace descriptors or emit workspace_update", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-title-"));
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-title",
|
|
projectId: "proj-title",
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "title-workspace",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-title",
|
|
rootPath: cwd,
|
|
kind: "non_git",
|
|
displayName: "title-project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const { session, terminalManager } = await createSessionWithTerminalManager({
|
|
workspaces: [workspace],
|
|
projects: [project],
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
const buildDescriptorMapSpy = vi.fn(
|
|
async () => new Map([[workspace.workspaceId, expect.any(Object)]]),
|
|
);
|
|
session.buildWorkspaceDescriptorMap = buildDescriptorMapSpy;
|
|
const listAgentPayloadsSpy = vi.fn(async () => []);
|
|
session.listAgentPayloads = listAgentPayloadsSpy;
|
|
|
|
const terminal = await terminalManager.createTerminal({
|
|
cwd,
|
|
workspaceId: workspace.workspaceId,
|
|
});
|
|
terminalManager.setTerminalTitle(terminal.id, "New title");
|
|
await flushTerminalContributionWork();
|
|
|
|
expect(buildDescriptorMapSpy).not.toHaveBeenCalled();
|
|
expect(listAgentPayloadsSpy).not.toHaveBeenCalled();
|
|
expect(filterByType(emitted, "workspace_update")).toHaveLength(0);
|
|
});
|
|
|
|
test("terminal activity contribution change updates the correct workspace", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-activity-"));
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-activity",
|
|
projectId: "proj-activity",
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "activity-workspace",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-activity",
|
|
rootPath: cwd,
|
|
kind: "non_git",
|
|
displayName: "activity-project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const { terminalManager } = await createSessionWithTerminalManager({
|
|
workspaces: [workspace],
|
|
projects: [project],
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
|
|
const terminal = await terminalManager.createTerminal({
|
|
cwd,
|
|
workspaceId: workspace.workspaceId,
|
|
});
|
|
await terminalManager.setTerminalActivity(terminal.id, "working");
|
|
|
|
const update = await waitForWorkspaceUpdate(
|
|
emitted,
|
|
(message) =>
|
|
message.payload.kind === "upsert" &&
|
|
message.payload.workspace.id === workspace.workspaceId &&
|
|
message.payload.workspace.status === "running",
|
|
"terminal activity marks the owning workspace running",
|
|
);
|
|
expect(update.payload).toMatchObject({
|
|
kind: "upsert",
|
|
workspace: {
|
|
id: workspace.workspaceId,
|
|
status: "running",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("same-cwd terminal activity updates only the workspace that owns the terminal", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-same-cwd-"));
|
|
const workspaceA = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-same-a",
|
|
projectId: "proj-same",
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "workspace-a",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspaceB = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-same-b",
|
|
projectId: "proj-same",
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "workspace-b",
|
|
createdAt: "2026-03-01T12:00:01.000Z",
|
|
updatedAt: "2026-03-01T12:00:01.000Z",
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-same",
|
|
rootPath: cwd,
|
|
kind: "non_git",
|
|
displayName: "same-project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const { terminalManager } = await createSessionWithTerminalManager({
|
|
workspaces: [workspaceA, workspaceB],
|
|
projects: [project],
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
|
|
const terminal = await terminalManager.createTerminal({
|
|
cwd,
|
|
workspaceId: workspaceB.workspaceId,
|
|
});
|
|
await terminalManager.setTerminalActivity(terminal.id, "working");
|
|
await waitForWorkspaceUpdate(
|
|
emitted,
|
|
(message) =>
|
|
message.payload.kind === "upsert" &&
|
|
message.payload.workspace.id === workspaceB.workspaceId &&
|
|
message.payload.workspace.status === "running",
|
|
"same-cwd terminal activity updates the workspace that owns the terminal",
|
|
);
|
|
|
|
// The sibling A does not own the terminal, so its status is never driven to
|
|
// running by it. Status is per workspaceId, not per cwd.
|
|
const updates = filterByType(emitted, "workspace_update");
|
|
const siblingRunning = updates.some(
|
|
(update) =>
|
|
update.payload.kind === "upsert" &&
|
|
update.payload.workspace.id === workspaceA.workspaceId &&
|
|
update.payload.workspace.status === "running",
|
|
);
|
|
expect(siblingRunning).toBe(false);
|
|
});
|
|
|
|
test("a worktree terminal updates only the workspace that owns it", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const rootCwd = mkdtempSync(path.join(tmpdir(), "paseo-session-nested-"));
|
|
const worktreeCwd = path.join(rootCwd, "worktree");
|
|
const terminalCwd = path.join(worktreeCwd, "subdir");
|
|
mkdirSync(terminalCwd, { recursive: true });
|
|
const workspaceRoot = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-root",
|
|
projectId: "proj-nested",
|
|
cwd: rootCwd,
|
|
kind: "local_checkout",
|
|
displayName: "root",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const workspaceWorktree = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-worktree",
|
|
projectId: "proj-nested",
|
|
cwd: worktreeCwd,
|
|
kind: "worktree",
|
|
displayName: "worktree",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-nested",
|
|
rootPath: rootCwd,
|
|
kind: "git",
|
|
displayName: "nested-project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const { terminalManager } = await createSessionWithTerminalManager({
|
|
workspaces: [workspaceRoot, workspaceWorktree],
|
|
projects: [project],
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
|
|
// The terminal is stamped with the worktree workspace at creation. Its cwd is
|
|
// a subdirectory, but ownership is the workspaceId, so only the worktree
|
|
// workspace (never the enclosing root) reflects the activity.
|
|
const terminal = await terminalManager.createTerminal({
|
|
cwd: terminalCwd,
|
|
workspaceId: workspaceWorktree.workspaceId,
|
|
});
|
|
await terminalManager.setTerminalActivity(terminal.id, "working");
|
|
await waitForWorkspaceUpdate(
|
|
emitted,
|
|
(message) =>
|
|
message.payload.kind === "upsert" &&
|
|
message.payload.workspace.id === workspaceWorktree.workspaceId &&
|
|
message.payload.workspace.status === "running",
|
|
"worktree terminal activity targets its owning workspace",
|
|
);
|
|
|
|
const updates = filterByType(emitted, "workspace_update");
|
|
const rootRunning = updates.some(
|
|
(update) =>
|
|
update.payload.kind === "upsert" &&
|
|
update.payload.workspace.id === workspaceRoot.workspaceId &&
|
|
update.payload.workspace.status === "running",
|
|
);
|
|
expect(rootRunning).toBe(false);
|
|
});
|
|
|
|
test("removing an idle terminal does not update workspace status", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-remove-idle-"));
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-remove-idle",
|
|
projectId: "proj-remove-idle",
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "remove-idle-workspace",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-remove-idle",
|
|
rootPath: cwd,
|
|
kind: "non_git",
|
|
displayName: "remove-idle-project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const { session, terminalManager } = await createSessionWithTerminalManager({
|
|
workspaces: [workspace],
|
|
projects: [project],
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
const buildDescriptorMapSpy = vi.fn(async () => new Map());
|
|
session.buildWorkspaceDescriptorMap = buildDescriptorMapSpy;
|
|
|
|
const terminal = await terminalManager.createTerminal({
|
|
cwd,
|
|
workspaceId: workspace.workspaceId,
|
|
});
|
|
terminalManager.killTerminal(terminal.id);
|
|
await flushTerminalContributionWork();
|
|
|
|
expect(buildDescriptorMapSpy).not.toHaveBeenCalled();
|
|
expect(filterByType(emitted, "workspace_update")).toHaveLength(0);
|
|
});
|
|
|
|
test("removing a contributing terminal clears workspace status", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-remove-contrib-"));
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-remove-contrib",
|
|
projectId: "proj-remove-contrib",
|
|
cwd,
|
|
kind: "directory",
|
|
displayName: "remove-contrib-workspace",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "proj-remove-contrib",
|
|
rootPath: cwd,
|
|
kind: "non_git",
|
|
displayName: "remove-contrib-project",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const { terminalManager } = await createSessionWithTerminalManager({
|
|
workspaces: [workspace],
|
|
projects: [project],
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
|
|
const terminal = await terminalManager.createTerminal({
|
|
cwd,
|
|
workspaceId: workspace.workspaceId,
|
|
});
|
|
await terminalManager.setTerminalActivity(terminal.id, "working");
|
|
await waitForWorkspaceUpdate(
|
|
emitted,
|
|
(message) =>
|
|
message.payload.kind === "upsert" &&
|
|
message.payload.workspace.id === workspace.workspaceId &&
|
|
message.payload.workspace.status === "running",
|
|
"contributing terminal enters running state",
|
|
);
|
|
emitted.length = 0;
|
|
|
|
terminalManager.killTerminal(terminal.id);
|
|
|
|
const update = await waitForWorkspaceUpdate(
|
|
emitted,
|
|
(message) =>
|
|
message.payload.kind === "upsert" &&
|
|
message.payload.workspace.id === workspace.workspaceId &&
|
|
message.payload.workspace.status === "done",
|
|
"removing a contributing terminal clears workspace status",
|
|
);
|
|
expect(update.payload).toMatchObject({
|
|
kind: "upsert",
|
|
workspace: {
|
|
id: workspace.workspaceId,
|
|
status: "done",
|
|
},
|
|
});
|
|
});
|
|
|
|
interface WorkspaceCreatePrRepoFixture {
|
|
tempDir: string;
|
|
repoDir: string;
|
|
paseoHome: string;
|
|
headRef: string;
|
|
prFileName: string;
|
|
prNumber: number;
|
|
}
|
|
|
|
function createWorkspaceCreatePrRepo(): WorkspaceCreatePrRepoFixture {
|
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-create-pr-")));
|
|
const repoDir = path.join(tempDir, "repo");
|
|
const remoteDir = path.join(tempDir, "origin.git");
|
|
const paseoHome = path.join(tempDir, ".paseo");
|
|
const prNumber = 123;
|
|
const headRef = "feature/review-pr";
|
|
const prFileName = "pr-123.txt";
|
|
|
|
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
|
|
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
|
|
cwd: repoDir,
|
|
stdio: "pipe",
|
|
});
|
|
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir, stdio: "pipe" });
|
|
writeFileSync(path.join(repoDir, "README.md"), "main\n");
|
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
|
|
|
|
execFileSync("git", ["checkout", "-b", headRef], { cwd: repoDir, stdio: "pipe" });
|
|
writeFileSync(path.join(repoDir, prFileName), "review branch\n");
|
|
execFileSync("git", ["add", prFileName], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["commit", "-m", "review branch"], { cwd: repoDir, stdio: "pipe" });
|
|
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" })
|
|
.toString()
|
|
.trim();
|
|
|
|
execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { stdio: "pipe" });
|
|
execFileSync(
|
|
"git",
|
|
[`--git-dir=${remoteDir}`, "update-ref", `refs/pull/${prNumber}/head`, prHead],
|
|
{
|
|
stdio: "pipe",
|
|
},
|
|
);
|
|
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "-d", `refs/heads/${headRef}`], {
|
|
stdio: "pipe",
|
|
});
|
|
execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["branch", "-D", headRef], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" });
|
|
|
|
return { tempDir, repoDir, paseoHome, headRef, prFileName, prNumber };
|
|
}
|
|
|
|
function createPrCheckoutGitHubService(params: { headRef: string }): ForgeService {
|
|
return {
|
|
listPullRequests: async () => [],
|
|
listIssues: async () => [],
|
|
getPullRequest: async ({ number }) => ({
|
|
number,
|
|
title: `PR ${number}`,
|
|
url: `https://github.com/acme/repo/pull/${number}`,
|
|
state: "OPEN",
|
|
body: null,
|
|
baseRefName: "main",
|
|
headRefName: params.headRef,
|
|
labels: [],
|
|
updatedAt: "2026-03-01T12:00:00Z",
|
|
}),
|
|
getPullRequestHeadRef: async () => params.headRef,
|
|
getPullRequestCheckoutTarget: async ({ number }) => ({
|
|
number,
|
|
baseRefName: "main",
|
|
headRefName: params.headRef,
|
|
headOwnerLogin: null,
|
|
headRepositorySshUrl: null,
|
|
headRepositoryUrl: null,
|
|
isCrossRepository: false,
|
|
}),
|
|
defaultCheckoutRefs: ({ changeRequestNumber }) => [
|
|
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
|
],
|
|
buildPrLocalBranchName: ({ headRef }) => headRef,
|
|
getCurrentPullRequestStatus: async () => null,
|
|
getPullRequestTimeline: async ({ prNumber }) => ({
|
|
prNumber,
|
|
repoOwner: "acme",
|
|
repoName: "repo",
|
|
items: [],
|
|
truncated: false,
|
|
error: null,
|
|
}),
|
|
getCheckDetails: async ({ checkRunId, workflowRunId }) => ({
|
|
checkRunId,
|
|
workflowRunId: workflowRunId ?? null,
|
|
name: "test",
|
|
status: null,
|
|
conclusion: null,
|
|
url: null,
|
|
detailsUrl: null,
|
|
output: null,
|
|
annotations: [],
|
|
failedJobs: [],
|
|
truncated: false,
|
|
}),
|
|
searchIssuesAndPrs: async () => ({
|
|
items: [],
|
|
featuresEnabled: true,
|
|
githubFeaturesEnabled: true,
|
|
}),
|
|
createPullRequest: async () => ({ number: 1, url: "https://github.com/acme/repo/pull/1" }),
|
|
mergePullRequest: async () => ({ success: true }),
|
|
enablePullRequestAutoMerge: async () => ({ success: true }),
|
|
disablePullRequestAutoMerge: async () => ({ success: true }),
|
|
isAuthenticated: async () => true,
|
|
invalidate: () => {},
|
|
};
|
|
}
|
|
|
|
function readCurrentBranch(cwd: string): string {
|
|
return execFileSync("git", ["branch", "--show-current"], { cwd, stdio: "pipe" })
|
|
.toString()
|
|
.trim();
|
|
}
|
|
|
|
test("workspace.create worktree source checks out a GitHub PR from githubPrNumber", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const fixture = createWorkspaceCreatePrRepo();
|
|
const projects = new Map<string, PersistedProjectRecord>();
|
|
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
|
const projectRegistry: SessionOptions["projectRegistry"] = {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => Array.from(projects.values()),
|
|
get: async (projectId: string) => projects.get(projectId) ?? null,
|
|
getOrCreateActiveByRoot: async (allocation) => {
|
|
const project = createPersistedProjectRecord({
|
|
projectId: "prj_worktreefixture",
|
|
rootPath: allocation.rootPath,
|
|
kind: allocation.kind,
|
|
displayName: allocation.displayName,
|
|
createdAt: allocation.timestamp,
|
|
updatedAt: allocation.timestamp,
|
|
});
|
|
projects.set(project.projectId, project);
|
|
return project;
|
|
},
|
|
upsert: async (record) => {
|
|
projects.set(record.projectId, record);
|
|
},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
const workspaceRegistry: SessionOptions["workspaceRegistry"] = {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => Array.from(workspaces.values()),
|
|
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
|
upsert: async (record) => {
|
|
workspaces.set(record.workspaceId, record);
|
|
},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
};
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
github: createPrCheckoutGitHubService({
|
|
headRef: fixture.headRef,
|
|
}),
|
|
paseoHome: fixture.paseoHome,
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
workspaceGitService: createNoopWorkspaceGitService({
|
|
resolveRepoRoot: async () => fixture.repoDir,
|
|
resolveDefaultBranch: async () => "main",
|
|
}),
|
|
});
|
|
|
|
try {
|
|
await session.handleMessage({
|
|
type: "workspace.create.request",
|
|
requestId: "req-workspace-create-pr",
|
|
source: {
|
|
kind: "worktree",
|
|
cwd: fixture.repoDir,
|
|
action: "checkout",
|
|
githubPrNumber: fixture.prNumber,
|
|
worktreeSlug: "review-pr-workspace",
|
|
},
|
|
});
|
|
|
|
const response = findByType(emitted, "workspace.create.response");
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace).toMatchObject({
|
|
workspaceDirectory: expect.any(String),
|
|
gitRuntime: { currentBranch: fixture.headRef },
|
|
});
|
|
const workspaceDirectory = response?.payload.workspace?.workspaceDirectory as string;
|
|
expect(readCurrentBranch(workspaceDirectory)).toBe(fixture.headRef);
|
|
expect(existsSync(path.join(workspaceDirectory, fixture.prFileName))).toBe(true);
|
|
} finally {
|
|
await flushTerminalContributionWork();
|
|
rmSync(fixture.tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Worktree-source forwarding for action/refName/worktreeSlug is also covered
|
|
// end-to-end against a daemon in workspace-create-worktree-source.e2e.test.ts.
|
|
|
|
test("failed local create_agent_request does not schedule workspace title generation", async () => {
|
|
vi.useFakeTimers();
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
let generateCalls = 0;
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
generateWorkspaceName: async () => {
|
|
generateCalls += 1;
|
|
return { title: "Should Not Be Written", branch: null };
|
|
},
|
|
});
|
|
|
|
try {
|
|
await session.handleMessage({
|
|
type: "create_agent_request",
|
|
requestId: "req-failed-local-title",
|
|
workspaceId: "ws-repo-running",
|
|
config: { provider: "codex", cwd: REPO_CWD },
|
|
initialPrompt: "This create will fail before an agent exists",
|
|
attachments: [],
|
|
});
|
|
await vi.runAllTimersAsync();
|
|
|
|
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
|
status: "agent_create_failed",
|
|
requestId: "req-failed-local-title",
|
|
});
|
|
expect(generateCalls).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("workspace auto-name keeps a manual title written before the scheduled title lands", async () => {
|
|
vi.useFakeTimers();
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-manual-title",
|
|
projectId: "proj-manual-title",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
title: "Fix login bug",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const stored = new Map([[workspace.workspaceId, workspace]]);
|
|
const emittedWorkspaceIds: string[] = [];
|
|
const workspaceAutoName = new WorkspaceAutoName({
|
|
agentManager: asAgentManager({}),
|
|
workspaceRegistry: {
|
|
update: async (workspaceId, updater) => {
|
|
const current = stored.get(workspaceId);
|
|
if (!current) return null;
|
|
const updated = updater(current);
|
|
stored.set(workspaceId, updated);
|
|
return updated;
|
|
},
|
|
},
|
|
workspaceGitService: createNoopWorkspaceGitService(),
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
|
|
gitMutation: { notifyGitMutation: async () => {} },
|
|
emitWorkspaceUpdateForCwd: async () => {},
|
|
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
|
|
emittedWorkspaceIds.push(workspaceId);
|
|
},
|
|
logger: asSessionLogger(createTestLogger()),
|
|
generateWorkspaceName: async () => ({ title: "Generated login fix", branch: null }),
|
|
});
|
|
|
|
try {
|
|
workspaceAutoName.scheduleForDirectory({
|
|
workspaceId: workspace.workspaceId,
|
|
cwd: workspace.cwd,
|
|
firstAgentContext: { prompt: "Fix login bug" },
|
|
});
|
|
stored.set(workspace.workspaceId, {
|
|
...workspace,
|
|
title: "User rename",
|
|
updatedAt: "2026-03-01T12:01:00.000Z",
|
|
});
|
|
await vi.runAllTimersAsync();
|
|
|
|
expect(stored.get(workspace.workspaceId)?.title).toBe("User rename");
|
|
expect(emittedWorkspaceIds).toEqual([workspace.workspaceId]);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("workspace auto-name replaces the unchanged prompt title", async () => {
|
|
vi.useFakeTimers();
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-prompt-title",
|
|
projectId: "proj-prompt-title",
|
|
cwd: REPO_CWD,
|
|
kind: "directory",
|
|
displayName: "repo",
|
|
title: "Fix login bug",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const stored = new Map([[workspace.workspaceId, workspace]]);
|
|
const workspaceAutoName = new WorkspaceAutoName({
|
|
agentManager: asAgentManager({}),
|
|
workspaceRegistry: {
|
|
update: async (workspaceId, updater) => {
|
|
const current = stored.get(workspaceId);
|
|
if (!current) return null;
|
|
const updated = updater(current);
|
|
stored.set(workspaceId, updated);
|
|
return updated;
|
|
},
|
|
},
|
|
workspaceGitService: createNoopWorkspaceGitService(),
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
|
|
gitMutation: { notifyGitMutation: async () => {} },
|
|
emitWorkspaceUpdateForCwd: async () => {},
|
|
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
|
logger: asSessionLogger(createTestLogger()),
|
|
generateWorkspaceName: async () => ({ title: "Generated login fix", branch: null }),
|
|
});
|
|
|
|
try {
|
|
workspaceAutoName.scheduleForDirectory({
|
|
workspaceId: workspace.workspaceId,
|
|
cwd: workspace.cwd,
|
|
firstAgentContext: { prompt: "Fix login bug" },
|
|
});
|
|
await vi.runAllTimersAsync();
|
|
|
|
expect(stored.get(workspace.workspaceId)?.title).toBe("Generated login fix");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("workspace auto-name uses the backing root for a nested worktree", async () => {
|
|
vi.useFakeTimers();
|
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-auto-name-rejected-")));
|
|
const repoDir = path.join(tempDir, "repo");
|
|
mkdirSync(repoDir);
|
|
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
|
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
|
|
cwd: repoDir,
|
|
stdio: "pipe",
|
|
});
|
|
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir, stdio: "pipe" });
|
|
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
|
|
execFileSync("git", ["branch", "-M", "placeholder-branch"], { cwd: repoDir, stdio: "pipe" });
|
|
writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" });
|
|
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
|
|
placeholderBranchName: "placeholder-branch",
|
|
});
|
|
const workspaceCwd = path.join(repoDir, "packages", "app");
|
|
mkdirSync(workspaceCwd, { recursive: true });
|
|
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-rejected-branch-title",
|
|
projectId: "proj-rejected-branch-title",
|
|
cwd: workspaceCwd,
|
|
kind: "worktree",
|
|
displayName: "Fix checkout title",
|
|
title: "Fix checkout title",
|
|
branch: "placeholder-branch",
|
|
worktreeRoot: repoDir,
|
|
isPaseoOwnedWorktree: true,
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
const stored = new Map([[workspace.workspaceId, workspace]]);
|
|
let generateCalls = 0;
|
|
const gitMutations: string[] = [];
|
|
const emittedCwds: string[] = [];
|
|
const workspaceAutoName = new WorkspaceAutoName({
|
|
agentManager: asAgentManager({}),
|
|
workspaceRegistry: {
|
|
update: async (workspaceId, updater) => {
|
|
const current = stored.get(workspaceId);
|
|
if (!current) return null;
|
|
const updated = updater(current);
|
|
stored.set(workspaceId, updated);
|
|
return updated;
|
|
},
|
|
},
|
|
workspaceGitService: createNoopWorkspaceGitService(),
|
|
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
|
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
|
|
gitMutation: {
|
|
notifyGitMutation: async (_cwd, reason) => {
|
|
gitMutations.push(reason);
|
|
},
|
|
},
|
|
emitWorkspaceUpdateForCwd: async (cwd) => {
|
|
emittedCwds.push(cwd);
|
|
},
|
|
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
|
logger: asSessionLogger(createTestLogger()),
|
|
generateWorkspaceName: async () => {
|
|
generateCalls += 1;
|
|
return { title: "Generated Invalid Branch Title", branch: "Invalid Branch Name" };
|
|
},
|
|
});
|
|
|
|
try {
|
|
workspaceAutoName.scheduleForWorktree({
|
|
workspace,
|
|
firstAgentContext: { prompt: "Fix checkout title" },
|
|
});
|
|
await vi.runAllTimersAsync();
|
|
|
|
expect(generateCalls).toBe(1);
|
|
expect(stored.get(workspace.workspaceId)).toMatchObject({
|
|
title: "Generated Invalid Branch Title",
|
|
branch: "placeholder-branch",
|
|
});
|
|
expect(
|
|
execFileSync("git", ["branch", "--show-current"], { cwd: repoDir, stdio: "pipe" })
|
|
.toString()
|
|
.trim(),
|
|
).toBe("placeholder-branch");
|
|
expect(readPaseoWorktreeMetadata(repoDir)).toMatchObject({
|
|
version: 2,
|
|
firstAgentBranchAutoName: {
|
|
status: "attempted",
|
|
placeholderBranchName: "placeholder-branch",
|
|
},
|
|
});
|
|
expect(gitMutations).toEqual([]);
|
|
expect(emittedCwds).toEqual([workspaceCwd]);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
}
|
|
});
|
|
|
|
// Phase 7: branch is a git fact derived per-descriptor from each workspace's own
|
|
// live git snapshot, and reconciliation re-persists `branch` per workspace from
|
|
// its own cwd. handleCheckoutRenameBranchRequest renames the git branch and
|
|
// re-emits, but performs NO denormalized cwd → ids branch write of its own — it
|
|
// never resolves which workspaces share the cwd to rewrite a cached branch.
|
|
test("checkout.rename_branch.request renames the branch without a denormalized branch write", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const renameCalls: Array<{ cwd: string; newName: string }> = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
renameCurrentBranch: async (cwd: string, newName: string) => {
|
|
renameCalls.push({ cwd, newName });
|
|
return { previousBranch: "feature/old-name", currentBranch: newName };
|
|
},
|
|
});
|
|
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-worktree-rename",
|
|
projectId: "proj-rename",
|
|
cwd: REPO_CWD,
|
|
kind: "worktree",
|
|
displayName: "Refactor auth flow",
|
|
branch: "feature/old-name",
|
|
title: "Refactor auth flow",
|
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
|
});
|
|
|
|
const workspaces = new Map([[workspace.workspaceId, workspace]]);
|
|
const upsertedRecords: Array<typeof workspace> = [];
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
|
|
session.workspaceRegistry.upsert = async (record: unknown) => {
|
|
const parsed = record as typeof workspace;
|
|
upsertedRecords.push(parsed);
|
|
workspaces.set(parsed.workspaceId, parsed);
|
|
};
|
|
|
|
await session.handleMessage({
|
|
type: "checkout.rename_branch.request",
|
|
cwd: REPO_CWD,
|
|
branch: "feature/new-name",
|
|
requestId: "req-rename-k3",
|
|
});
|
|
|
|
expect(renameCalls).toEqual([{ cwd: REPO_CWD, newName: "feature/new-name" }]);
|
|
|
|
const response = findByType(emitted, "checkout.rename_branch.response");
|
|
expect(response?.payload).toMatchObject({
|
|
success: true,
|
|
currentBranch: "feature/new-name",
|
|
requestId: "req-rename-k3",
|
|
});
|
|
|
|
// Phase 7: the handler performs no denormalized branch write of its own; the
|
|
// record is left for per-descriptor derivation and reconciliation to update.
|
|
expect(upsertedRecords).toEqual([]);
|
|
const persisted = workspaces.get(workspace.workspaceId);
|
|
expect(persisted?.displayName).toBe("Refactor auth flow");
|
|
expect(persisted?.title).toBe("Refactor auth flow");
|
|
});
|
|
|
|
test("workspace.create.response persists the first prompt as the initial title", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
workspaceRegistry: {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => Array.from(workspaces.values()),
|
|
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
|
upsert: async (workspace) => {
|
|
workspaces.set(workspace.workspaceId, workspace);
|
|
},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
},
|
|
});
|
|
session.listAgentPayloads = async () => [];
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.create.request",
|
|
requestId: "req-create-first-prompt",
|
|
source: { kind: "directory", path: REPO_CWD },
|
|
firstAgentContext: {
|
|
prompt: "Add retries to the payments flow\nwith exponential backoff",
|
|
},
|
|
});
|
|
|
|
const response = findByType(emitted, "workspace.create.response");
|
|
expect(response?.payload.error).toBeNull();
|
|
expect(response?.payload.workspace?.title).toBe("Add retries to the payments flow");
|
|
expect(response?.payload.workspace?.name).toBe("Add retries to the payments flow");
|
|
|
|
const workspaceId = response?.payload.workspace?.id;
|
|
expect(workspaceId).toBeDefined();
|
|
const persisted = await session.workspaceRegistry.get(workspaceId as string);
|
|
expect(persisted?.title).toBe("Add retries to the payments flow");
|
|
expect(filterByType(emitted, "workspace_update")).toHaveLength(1);
|
|
});
|
|
|
|
test("workspace create emits through a matching workspace subscription", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
let mutationListener: ((mutation: WorkspaceMutation) => void | Promise<void>) | null = null;
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
workspaceRegistry: {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => Array.from(workspaces.values()),
|
|
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
|
upsert: async (workspace, context) => {
|
|
workspaces.set(workspace.workspaceId, workspace);
|
|
await mutationListener?.({
|
|
kind: "upsert",
|
|
workspaceId: workspace.workspaceId,
|
|
workspace,
|
|
expectsInitialAgent: context?.expectsInitialAgent,
|
|
});
|
|
},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
subscribeToMutations: (listener) => {
|
|
mutationListener = listener;
|
|
return () => {
|
|
mutationListener = null;
|
|
};
|
|
},
|
|
},
|
|
});
|
|
session.listAgentPayloads = async () => [];
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-subscribe-create-match",
|
|
filter: { query: "Implement" },
|
|
subscribe: { subscriptionId: "sub-create-match" },
|
|
});
|
|
emitted.length = 0;
|
|
await session.handleMessage({
|
|
type: "workspace.create.request",
|
|
requestId: "req-create-match",
|
|
source: { kind: "directory", path: REPO_CWD },
|
|
firstAgentContext: { prompt: "Implement the requested change" },
|
|
});
|
|
|
|
const statuses = filterByType(emitted, "workspace_update").flatMap((message) =>
|
|
message.payload.kind === "upsert" ? [message.payload.workspace.status] : [],
|
|
);
|
|
expect(statuses).toContain("running");
|
|
expect(statuses).not.toContain("done");
|
|
});
|
|
|
|
test("workspace create stays out of a non-matching workspace subscription", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
workspaceRegistry: {
|
|
initialize: async () => {},
|
|
existsOnDisk: async () => true,
|
|
list: async () => Array.from(workspaces.values()),
|
|
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
|
|
upsert: async (workspace) => {
|
|
workspaces.set(workspace.workspaceId, workspace);
|
|
},
|
|
archive: async () => {},
|
|
remove: async () => {},
|
|
},
|
|
});
|
|
session.listAgentPayloads = async () => [];
|
|
|
|
await session.handleMessage({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-subscribe-create-filtered",
|
|
filter: { query: "definitely-not-this-workspace" },
|
|
subscribe: { subscriptionId: "sub-create-filtered" },
|
|
});
|
|
emitted.length = 0;
|
|
await session.handleMessage({
|
|
type: "workspace.create.request",
|
|
requestId: "req-create-filtered",
|
|
source: { kind: "directory", path: REPO_CWD },
|
|
});
|
|
|
|
expect(filterByType(emitted, "workspace_update")).toEqual([]);
|
|
});
|
|
|
|
test("workspace.create.request attaches a directory workspace to its explicit active project", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const projects = new Map([
|
|
[
|
|
"prj_explicit",
|
|
createPersistedProjectRecord({
|
|
projectId: "prj_explicit",
|
|
rootPath: path.join(REPO_CWD, "unrelated"),
|
|
kind: "non_git",
|
|
displayName: "unrelated",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
}),
|
|
],
|
|
]);
|
|
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
|
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
|
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
|
session.workspaceRegistry.upsert = async (record: unknown) => {
|
|
const workspace = record as PersistedWorkspaceRecord;
|
|
workspaces.set(workspace.workspaceId, workspace);
|
|
};
|
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
|
workspaces.get(workspaceId) ?? null;
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.create.request",
|
|
requestId: "req-explicit-project",
|
|
source: { kind: "directory", path: REPO_CWD, projectId: "prj_explicit" },
|
|
});
|
|
|
|
const response = findByType(emitted, "workspace.create.response");
|
|
expect(response?.payload).toMatchObject({
|
|
requestId: "req-explicit-project",
|
|
error: null,
|
|
workspace: { projectId: "prj_explicit" },
|
|
});
|
|
const workspaceId = response?.payload.workspace?.id;
|
|
expect(workspaceId).toEqual(expect.any(String));
|
|
expect(workspaces.get(workspaceId as string)).toMatchObject({
|
|
cwd: REPO_CWD,
|
|
projectId: "prj_explicit",
|
|
});
|
|
});
|
|
|
|
test("workspace.create.request with background persists a hidden workspace that only opted-in listings show", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const workspaces = new Map<string, PersistedWorkspaceRecord>();
|
|
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
|
session.workspaceRegistry.upsert = async (record: unknown) => {
|
|
const workspace = record as PersistedWorkspaceRecord;
|
|
workspaces.set(workspace.workspaceId, workspace);
|
|
};
|
|
session.workspaceRegistry.get = async (workspaceId: string) =>
|
|
workspaces.get(workspaceId) ?? null;
|
|
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.create.request",
|
|
requestId: "req-background-workspace",
|
|
background: true,
|
|
source: { kind: "directory", path: REPO_CWD },
|
|
});
|
|
|
|
const response = findByType(emitted, "workspace.create.response");
|
|
expect(response?.payload).toMatchObject({
|
|
requestId: "req-background-workspace",
|
|
error: null,
|
|
workspace: { background: true },
|
|
});
|
|
const workspaceId = response?.payload.workspace?.id as string;
|
|
expect(workspaces.get(workspaceId)?.background).toBe(true);
|
|
// No subscription exists, and the legacy causal update stays quiet for a hidden workspace.
|
|
expect(filterByType(emitted, "workspace_update")).toEqual([]);
|
|
|
|
const hidden = await session.listFetchWorkspacesEntries({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-list-hidden",
|
|
});
|
|
expect(hidden.entries).toEqual([]);
|
|
|
|
const shown = await session.listFetchWorkspacesEntries({
|
|
type: "fetch_workspaces_request",
|
|
requestId: "req-list-shown",
|
|
filter: { includeBackground: true },
|
|
});
|
|
expect(shown.entries.map((entry) => [entry.id, entry.background])).toEqual([[workspaceId, true]]);
|
|
});
|
|
|
|
test("workspace.create.request reports an unknown explicit project", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.create.request",
|
|
requestId: "req-unknown-project",
|
|
source: { kind: "directory", path: REPO_CWD, projectId: "prj_missing" },
|
|
});
|
|
|
|
expect(findByType(emitted, "workspace.create.response")?.payload).toMatchObject({
|
|
requestId: "req-unknown-project",
|
|
workspace: null,
|
|
errorCode: "unknown_project",
|
|
});
|
|
});
|
|
|
|
test("workspace.create.request reports an archived explicit project", async () => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const archivedProject = createPersistedProjectRecord({
|
|
projectId: "prj_archived",
|
|
rootPath: path.join(REPO_CWD, "unrelated"),
|
|
kind: "non_git",
|
|
displayName: "unrelated",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
archivedAt: "2026-03-02T00:00:00.000Z",
|
|
});
|
|
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
|
session.projectRegistry.get = async (projectId: string) =>
|
|
projectId === archivedProject.projectId ? archivedProject : null;
|
|
|
|
await session.handleMessage({
|
|
type: "workspace.create.request",
|
|
requestId: "req-archived-project",
|
|
source: { kind: "directory", path: REPO_CWD, projectId: "prj_archived" },
|
|
});
|
|
|
|
expect(findByType(emitted, "workspace.create.response")?.payload).toMatchObject({
|
|
requestId: "req-archived-project",
|
|
workspace: null,
|
|
errorCode: "archived_project",
|
|
});
|
|
});
|
|
|
|
test.each(["failed", "blocked"] as const)(
|
|
"workspace setup %s catches up from an existing directory cursor after releasing observation",
|
|
async (status) => {
|
|
const emitted: SessionOutboundMessage[] = [];
|
|
const session = createSessionForWorkspaceTests({
|
|
onMessage: (message) => emitted.push(message),
|
|
});
|
|
const source = {};
|
|
const capabilities = {
|
|
[CLIENT_CAPS.ownedSubscriptions]: true,
|
|
[CLIENT_CAPS.workspaceSetupObservation]: true,
|
|
};
|
|
session.updateClientCapabilities(capabilities, source);
|
|
await session.handleMessage(
|
|
{ type: "fetch_workspaces_request", requestId: "before-setup", subscribe: {}, sync: {} },
|
|
source,
|
|
);
|
|
const initial = findByType(emitted, "fetch_workspaces_response")!.payload;
|
|
expect(initial.entries).toEqual([
|
|
expect.objectContaining({ id: "ws-repo-running", setupStatus: null }),
|
|
]);
|
|
expect(initial.sync?.mode).toBe("snapshot");
|
|
await session.handleMessage(
|
|
{
|
|
type: "subscription.release.request",
|
|
requestId: "release-directory",
|
|
subscriptionId: initial.subscriptionId,
|
|
},
|
|
source,
|
|
);
|
|
emitted.length = 0;
|
|
session.publish({
|
|
type: "workspace_setup_progress",
|
|
payload: {
|
|
workspaceId: "ws-repo-running",
|
|
status,
|
|
error: status === "failed" ? "setup failed" : null,
|
|
detail: {
|
|
type: "worktree_setup",
|
|
worktreePath: REPO_CWD,
|
|
branchName: "main",
|
|
log: "output never requested",
|
|
commands: [],
|
|
},
|
|
},
|
|
});
|
|
await flushWorkspaceUpdateBackgroundWork();
|
|
expect(emitted).toEqual([]);
|
|
const reconnectSource = {};
|
|
session.updateClientCapabilities(capabilities, reconnectSource);
|
|
await session.handleMessage(
|
|
{
|
|
type: "fetch_workspaces_request",
|
|
requestId: "catch-up-setup",
|
|
subscribe: {},
|
|
sync: { generation: initial.sync!.generation, afterSeq: initial.sync!.headSeq },
|
|
},
|
|
reconnectSource,
|
|
);
|
|
const caughtUp = findByType(emitted, "fetch_workspaces_response")!.payload;
|
|
expect(caughtUp.sync?.mode).toBe("changes");
|
|
expect(caughtUp.entries).toEqual([
|
|
expect.objectContaining({ id: "ws-repo-running", setupStatus: status }),
|
|
]);
|
|
expect(caughtUp.entries[0].syncSeq).toBeGreaterThan(initial.sync!.headSeq);
|
|
expect(filterByType(emitted, "workspace_setup_progress")).toEqual([]);
|
|
emitted.length = 0;
|
|
await session.handleMessage(
|
|
{
|
|
type: "fetch_workspaces_request",
|
|
requestId: "after-catch-up",
|
|
sync: { generation: caughtUp.sync!.generation, afterSeq: caughtUp.sync!.headSeq },
|
|
},
|
|
reconnectSource,
|
|
);
|
|
expect(findByType(emitted, "fetch_workspaces_response")!.payload.entries).toEqual([]);
|
|
},
|
|
);
|