Key workspace status and ownership by ID, not directory

Multiple workspaces can share one checkout directory. Agent and terminal
status, sidebar tabs, and ownership now attribute strictly by workspace ID,
so a running or blocked agent in one workspace no longer shows up on its
same-directory siblings.

A one-time startup migration backfills workspace IDs onto legacy agents, and
every agent and terminal is created with one thereafter. Directory lookups
remain only for genuinely folder-derived git facts (branch, diff) and the
archive-by-path adapter.
This commit is contained in:
Mohamed Boudra
2026-06-16 13:37:58 +07:00
parent ba9f7740a8
commit 9966e49329
102 changed files with 3222 additions and 1932 deletions

View File

@@ -58,7 +58,7 @@ The asymmetry is intentional: a subagent's home is the parent's track, not the t
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to every active workspace with the same `cwd` as their owning workspace; tab and agent visibility still stays scoped to the agent's `workspaceId`. Running subagents contribute `running` to workspaces with the same `cwd` as their root parent's owning workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
Workspace status is an aggregate activity signal computed **per `workspaceId`**: a workspace's status reflects only records whose `workspaceId === workspace.id`. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. A root agent contributes its normal state bucket to its owning workspace only. Running subagents contribute `running` to their root parent's owning workspace (by the parent agent's `workspaceId`), not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
## The subagents track

View File

@@ -40,30 +40,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `workspaceId` | `string?` | Owning workspace id. Legacy cwd-only records are migrated to a stable existing workspace owner; runtime workspace membership should not infer ownership from cwd when same-CWD workspaces exist. |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `lastError` | `string?` (nullable) | Last error message, if any |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
| Field | Type | Description |
| -------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `workspaceId` | `string?` | Owning workspace id — the single source of ownership. Every agent is stamped with one at create time; legacy cwd-only records are backfilled once by `migrations/backfill-workspace-id.migration.ts` (the only place a cwd→id mapping exists). Runtime code never infers ownership or status from cwd: status is computed per `workspaceId`, and same-cwd siblings are independent. |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `lastError` | `string?` (nullable) | Last error message, if any |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
@@ -129,9 +129,9 @@ Each agent is stored as a separate JSON file, grouped by project directory.
## Runtime-only Terminal Sessions
Terminals are live daemon state, not persisted JSON records. A terminal may carry a `workspaceId` while it is running; workspace-scoped terminal lists include only terminals with the matching `workspaceId`. Legacy live terminals without an owner remain visible to unscoped terminal reads but are not migrated or inferred into same-`cwd` workspace lists.
Terminals are live daemon state, not persisted JSON records. A terminal carries a `workspaceId` while it is running; workspace-scoped terminal lists include only terminals with the matching `workspaceId`. Legacy live terminals without an owner remain visible to unscoped terminal reads but contribute to no workspace status.
Terminal activity still contributes to the aggregate workspace status bucket. When an owned terminal contributes activity, every active workspace with the owning workspace's `cwd` receives the same status bucket; terminal visibility itself stays `workspaceId`-scoped.
Terminal activity contributes to the workspace status bucket **per `workspaceId`**: a working terminal drives `running` onto the workspace it carries only. Same-`cwd` siblings are untouched; terminal visibility is likewise `workspaceId`-scoped.
---

View File

@@ -32,7 +32,9 @@ Each `onChange` delivers both the new snapshot and the `previous` one (`{ state,
The daemon consumes these transitions, not snapshots. When a transition moves from `working` to `idle`, the tracker records finished attention, so the terminal shows the same green finished dot as an idle agent that needs review. The websocket layer also fires a "Terminal finished" attention notification. A terminal that exits while still working emits no turn-end notification.
Terminal workspace membership is path-prefix based: a terminal opened in a workspace subdirectory rolls up to the deepest active parent workspace for status buckets and notification routing.
Terminal list visibility is `workspaceId`-scoped: a terminal belongs to the workspace that created it, and same-`cwd` sibling workspaces do not see it in their terminal lists. Terminal status routing starts from that owning workspace, uses the owning workspace's `cwd`, then fans the status bucket out to every active workspace with the same `cwd`.
Path-prefix routing is only a legacy fallback for unowned terminal activity contribution. If a live terminal has no `workspaceId`, the daemon resolves the deepest active parent workspace from the terminal `cwd`, then fans status out to active same-`cwd` siblings of that owner. That fallback contributes status, but it does not make the terminal visible in workspace-scoped terminal lists.
## Hook reporting

View File

@@ -23,7 +23,7 @@ async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, () => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
@@ -34,6 +34,14 @@ async function getAvailablePort(): Promise<number> {
});
}
const RESERVED_LOCAL_PORTS = new Set([
// Default developer daemon.
6767,
// OpenCode's default local server port. Some provider probes can spawn it
// during daemon startup, so the E2E daemon must not choose the same port.
61680,
]);
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
const lines: string[] = [];
return {
@@ -486,7 +494,7 @@ async function awaitRelayReady(
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
for (;;) {
const port = await getAvailablePort();
if (!excludedPorts.has(port)) {
if (!excludedPorts.has(port) && !RESERVED_LOCAL_PORTS.has(port)) {
return port;
}
}
@@ -677,8 +685,8 @@ export default async function globalSetup() {
ensureRelayBuildArtifact(repoRoot);
await loadEnvTestFile(repoRoot);
const port = await getAvailablePort();
const metroPort = await getAvailablePort();
const port = await getAvailablePortExcluding(new Set());
const metroPort = await getAvailablePortExcluding(new Set([port]));
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));

View File

@@ -44,6 +44,7 @@ export interface IdleAgentSeedClient {
model: string;
modeId: string;
cwd: string;
workspaceId: string;
title: string;
}): Promise<{ id: string }>;
waitForAgentUpsert(
@@ -66,6 +67,7 @@ export async function createIdleAgent(
model: "opencode/gpt-5-nano",
modeId: "bypassPermissions",
cwd: input.cwd,
workspaceId: opened.workspace.id,
title: input.title,
});
const snapshot = await client.waitForAgentUpsert(

View File

@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { getE2EDaemonPort } from "./daemon-port";
@@ -47,9 +48,18 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
url: resolveDaemonWsUrl(),
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
clientType: "cli",
appVersion: options.appVersion,
appVersion: options.appVersion ?? loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
});
await client.connect();
return client;
}
function loadAppVersion(): string {
const packageJsonPath = path.resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`Missing app version in ${packageJsonPath}`);
}
return packageJson.version;
}

View File

@@ -33,6 +33,7 @@ export async function seedMockAgentWorkspace(
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: options.title,
modeId: options.modeId ?? "load-test",
model: options.model ?? "ten-second-stream",

View File

@@ -14,7 +14,9 @@ type NewWorkspaceDaemonClient = Pick<
| "createPaseoWorktree"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "openProject"
| "patchDaemonConfig"
>;
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
@@ -314,18 +316,18 @@ export async function assertNewWorkspaceSidebarAndHeader(
assertHeader?: boolean;
},
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
// Wait for URL to redirect to the newly created workspace.
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
let workspaceId: string | null = null;
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
break;
}
await page.waitForTimeout(250);
}
// URL is the source of truth so concurrent sidebar rows cannot satisfy this.
await expect
.poll(
() => {
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
},
{ timeout: 60_000 },
)
.not.toBeNull();
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
}

View File

@@ -1,32 +1,5 @@
import { type Page } from "@playwright/test";
/**
* Listens for outbound WebSocket "session" frames of a given inner message type
* and accumulates them. The returned array is populated in-place as frames arrive.
*/
export function captureWsSessionFrames<T extends Record<string, unknown>>(
page: Page,
messageType: string,
extract: (inner: Record<string, unknown>) => T,
): T[] {
const captured: T[] = [];
page.on("websocket", (ws) => {
ws.on("framesent", (frame) => {
const raw = frame.payload;
const text = typeof raw === "string" ? raw : raw.toString("utf8");
try {
const outer = JSON.parse(text) as { type?: string; message?: Record<string, unknown> };
if (outer.type === "session" && outer.message?.type === messageType) {
captured.push(extract(outer.message));
}
} catch {
// Ignore non-JSON and binary frames.
}
});
});
return captured;
}
export function renameModalInput(page: Page, testIdPrefix: string) {
return page.getByTestId(`${testIdPrefix}-input`);
}

View File

@@ -58,7 +58,11 @@ export interface SeedDaemonClient {
terminal: { id: string; name: string; cwd: string; activity?: TerminalActivity | null } | null;
error: string | null;
}>;
listTerminals(cwd?: string): Promise<{
listTerminals(
cwd?: string,
requestId?: string,
options?: { workspaceId?: string },
): Promise<{
terminals: Array<{
id: string;
name: string;
@@ -80,7 +84,18 @@ export interface SeedDaemonClient {
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
entries: Array<{
agent: {
id: string;
provider: string;
cwd: string;
workspaceId?: string;
model: string | null;
currentModeId: string | null;
status: string;
title?: string | null;
};
}>;
}>;
fetchRecentProviderSessions(options: {
cwd: string;

View File

@@ -1,10 +1,10 @@
import { randomUUID } from "node:crypto";
import { test, expect, type Page } from "./fixtures";
import { seedWorkspace } from "./helpers/seed-client";
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
import { createIdleAgent, expectWorkspaceTabVisible } from "./helpers/archive-tab";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
import { renameModalInput, renameModalSubmit } from "./helpers/rename";
import { getServerId } from "./helpers/server-id";
async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId: string }) {
@@ -17,8 +17,13 @@ async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId
await expectWorkspaceTabVisible(page, agent.id);
}
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
const result = await client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
}
test.describe("Workspace agent tab rename", () => {
test("right-click rename sends update_agent_request and updates the tab label", async ({
test("right-click rename persists the agent title and updates the tab label", async ({
page,
}) => {
test.setTimeout(120_000);
@@ -32,12 +37,6 @@ test.describe("Workspace agent tab rename", () => {
title: initialTitle,
});
const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({
agentId: String(inner.agentId ?? ""),
name: String(inner.name ?? ""),
requestId: String(inner.requestId ?? ""),
}));
await openAgentInWorkspace(page, agent);
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
@@ -62,12 +61,7 @@ test.describe("Workspace agent tab rename", () => {
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(tab).toContainText(renamed, { timeout: 15_000 });
expect(updateFrames.length).toBeGreaterThan(0);
const lastFrame = updateFrames.at(-1)!;
expect(lastFrame.agentId).toBe(agent.id);
expect(lastFrame.name).toBe(renamed);
expect(lastFrame.requestId.length).toBeGreaterThan(0);
await expect.poll(() => fetchAgentTitle(workspace.client, agent.id)).toBe(renamed);
} finally {
await workspace.cleanup();
}

View File

@@ -1,135 +1,35 @@
import { test, expect, type Page } from "./fixtures";
import { test, expect } from "./fixtures";
import { expectComposerVisible, submitMessage } from "./helpers/composer";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { captureWsSessionFrames } from "./helpers/rename";
import { getServerId } from "./helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
interface WorkspaceTabProbeRecord {
at: number;
tabs: Array<{
testId: string;
text: string;
ariaLabel: string;
}>;
}
interface CapturedCreateAgentFrame {
initialPrompt: string | null;
configTitle: string | null;
}
async function installWorkspaceTabProbe(page: Page): Promise<void> {
await page.evaluate(() => {
type ProbeRecord = WorkspaceTabProbeRecord;
type ProbeWindow = Window & {
__workspaceTabTitleProbe?: { records: ProbeRecord[]; stop: () => void };
};
const win = window as ProbeWindow;
win.__workspaceTabTitleProbe?.stop();
const records: ProbeRecord[] = [];
const isVisible = (element: Element): element is HTMLElement => {
if (!(element instanceof HTMLElement)) {
return false;
}
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden"
);
};
const snapshot = () => {
records.push({
at: performance.now(),
tabs: Array.from(document.querySelectorAll('[data-testid^="workspace-tab-"]'))
.filter(isVisible)
.map((element) => ({
testId: element.getAttribute("data-testid") ?? "",
text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
ariaLabel: element.getAttribute("aria-label") ?? "",
})),
});
};
snapshot();
const observer = new MutationObserver(snapshot);
observer.observe(document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ["aria-label", "class", "data-testid", "style"],
});
const interval = window.setInterval(snapshot, 20);
win.__workspaceTabTitleProbe = {
records,
stop: () => {
observer.disconnect();
window.clearInterval(interval);
},
};
});
}
async function readWorkspaceTabProbe(page: Page): Promise<WorkspaceTabProbeRecord[]> {
return page.evaluate(() => {
type ProbeWindow = Window & {
__workspaceTabTitleProbe?: { records: WorkspaceTabProbeRecord[]; stop: () => void };
};
const probe = (window as ProbeWindow).__workspaceTabTitleProbe;
probe?.stop();
return probe?.records ?? [];
});
}
function recordHasTabLabel(record: WorkspaceTabProbeRecord, label: string): boolean {
return record.tabs.some((tab) => tab.text.includes(label) || tab.ariaLabel.includes(label));
}
function createFrameStartsWithPrompt(
frame: CapturedCreateAgentFrame,
promptTitle: string,
): boolean {
return frame.initialPrompt?.startsWith(promptTitle) ?? false;
}
function countCreateFramesForPrompt(
frames: CapturedCreateAgentFrame[],
promptTitle: string,
): number {
return frames.filter((frame) => createFrameStartsWithPrompt(frame, promptTitle)).length;
}
function tabHasLoadingTitle(tab: WorkspaceTabProbeRecord["tabs"][number]): boolean {
return /Loading agent title|Loading\.\.\./.test(`${tab.text} ${tab.ariaLabel}`);
}
function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean {
return record.tabs.some(tabHasLoadingTitle);
}
async function waitForCreatedAgentId(client: SeedDaemonClient, cwd: string): Promise<string> {
async function waitForCreatedAgentId(
client: SeedDaemonClient,
input: { cwd: string; workspaceId: string },
): Promise<string> {
await expect
.poll(
async () => {
const result = await client.fetchAgents({ scope: "active" });
return result.entries
.filter((entry) => entry.agent.cwd === cwd)
.filter(
(entry) =>
entry.agent.cwd === input.cwd && entry.agent.workspaceId === input.workspaceId,
)
.map((entry) => entry.agent.id);
},
{ timeout: 30_000 },
)
.toHaveLength(1);
const result = await client.fetchAgents({ scope: "active" });
const agent = result.entries.find((entry) => entry.agent.cwd === cwd);
const agent = result.entries.find(
(entry) => entry.agent.cwd === input.cwd && entry.agent.workspaceId === input.workspaceId,
);
if (!agent) {
throw new Error(`Expected one created agent in ${cwd}`);
throw new Error(`Expected one created agent in ${input.cwd}`);
}
return agent.agent.id;
}
@@ -142,43 +42,17 @@ async function fetchActiveAgentTitle(
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
}
async function waitForPromptTabAgentActions(page: Page, promptTitle: string): Promise<void> {
const promptTab = page.getByRole("button", { name: promptTitle }).first();
await expect(promptTab).toBeVisible({ timeout: 15_000 });
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
await promptTab.click({ button: "right" });
const renameAction = page.getByText("Rename", { exact: true }).first();
if (await renameAction.isVisible().catch(() => false)) {
await page.keyboard.press("Escape");
return;
}
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(100);
}
throw new Error("Prompt tab did not expose agent tab actions after create handoff");
}
test.describe("Workspace agent title handoff", () => {
test("keeps the prompt as the optimistic tab title until the generated title arrives", async ({
test("shows the prompt tab title and replaces it when the daemon title updates", async ({
page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1440, height: 900 });
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
const workspace = await seedWorkspace({ repoPrefix: "workspace-title-handoff-" });
try {
const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
const config = (inner.config ?? {}) as Record<string, unknown>;
return {
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
configTitle: typeof config.title === "string" ? config.title : null,
};
});
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab-inline").click();
@@ -186,21 +60,39 @@ test.describe("Workspace agent title handoff", () => {
const promptTitle = "Investigate optimistic tab title handoff";
const generatedTitle = "Generated Handoff Title";
await installWorkspaceTabProbe(page);
await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`);
await agentCreatedDelay.waitForCreateRequest();
await agentCreatedDelay.waitForDelayedCreatedStatus();
const agentId = await waitForCreatedAgentId(workspace.client, workspace.repoPath);
await expect
.poll(() => countCreateFramesForPrompt(createFrames, promptTitle), {
timeout: 10_000,
})
.toBe(1);
expect(createFrames.at(-1)).toEqual({
initialPrompt: `${promptTitle}\n\nMake the UI state deterministic.`,
configTitle: null,
await expect(page.getByRole("button", { name: promptTitle }).first()).toBeVisible({
timeout: 15_000,
});
await expect(
page.getByText(/Loading agent title|Loading\.\.\./).filter({ visible: true }),
).toHaveCount(0);
const agentId = await waitForCreatedAgentId(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
});
await waitForPromptTabAgentActions(page, promptTitle);
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0);
agentCreatedDelay.release();
const agentTab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
await expect(agentTab).toBeVisible({ timeout: 15_000 });
await expect
.poll(() => fetchActiveAgentTitle(workspace.client, agentId), { timeout: 10_000 })
.toBe(promptTitle);
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
await agentTab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({
timeout: 10_000,
});
await page.keyboard.press("Escape");
await expect(
page.getByText(/Loading agent title|Loading\.\.\./).filter({ visible: true }),
).toHaveCount(0);
await workspace.client.updateAgent(agentId, { name: generatedTitle });
await expect
@@ -209,12 +101,8 @@ test.describe("Workspace agent title handoff", () => {
await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({
timeout: 15_000,
});
const records = await readWorkspaceTabProbe(page);
expect(records.some((record) => recordHasTabLabel(record, promptTitle))).toBe(true);
expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true);
expect(records.filter(recordHasLoadingTitle)).toEqual([]);
} finally {
agentCreatedDelay.release();
await workspace.cleanup();
}
});

View File

@@ -1,6 +1,6 @@
import { expect, test } from "./fixtures";
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
import { captureWsSessionFrames } from "./helpers/rename";
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "./helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import {
expectTerminalSurfaceVisible,
focusTerminalSurface,
@@ -9,13 +9,25 @@ import {
waitForTerminalContent,
} from "./helpers/terminal-perf";
interface CreateAgentFrame {
initialPrompt: string | null;
interface CreatedAgentCwdAssertion {
workspaceId: string;
cwd: string | null;
}
function cwdForPrompt(frames: CreateAgentFrame[], prompt: string): string | null {
return frames.find((frame) => frame.initialPrompt === prompt)?.cwd ?? null;
async function fetchSingleAgentForWorkspace(
workspace: SeededWorkspace,
): Promise<CreatedAgentCwdAssertion | null> {
const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries
.map((entry) => entry.agent)
.filter((agent) => agent.workspaceId === workspace.workspaceId);
if (agents.length !== 1) {
return null;
}
const [agent] = agents;
return {
workspaceId: workspace.workspaceId,
cwd: agent.cwd,
};
}
test.describe("Workspace cwd correctness", () => {
@@ -36,37 +48,36 @@ test.describe("Workspace cwd correctness", () => {
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
});
test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => {
test("draft tab creates an agent in the workspace cwd", async ({ page }) => {
test.setTimeout(60_000);
const createAgentFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
const config = (inner.config ?? {}) as Record<string, unknown>;
return {
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
cwd: typeof config.cwd === "string" ? config.cwd : null,
};
});
const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" });
await workspace.navigateTo();
await clickNewChat(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const message = `cwd draft create ${Date.now()}`;
await expect(composer).toBeEditable({ timeout: 15_000 });
await composer.fill(message);
await composer.press("Enter");
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await clickNewChat(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const message = `cwd draft create ${Date.now()}`;
await expect(composer).toBeEditable({ timeout: 15_000 });
await composer.fill(message);
await composer.press("Enter");
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
timeout: 30_000,
});
await expect
.poll(() => cwdForPrompt(createAgentFrames, message), { timeout: 30_000 })
.toBe(workspace.repoPath);
await expect
.poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 })
.toEqual({
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
});
} finally {
await workspace.cleanup();
}
});
test("worktree workspace opens terminals in the worktree directory", async ({

View File

@@ -1,24 +1,140 @@
import { test, expect } from "./fixtures";
import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer";
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
import { captureWsSessionFrames } from "./helpers/rename";
import {
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectWorkspaceBacking,
submitNewWorkspaceEmpty,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { getServerId } from "./helpers/server-id";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { expectWorkspaceHeader, waitForSidebarHydration } from "./helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
type NewWorkspaceDaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
type WorkspaceIndicator = "attention" | "done" | "failed" | "loading" | "needs_input" | "running";
interface CreateAgentFrame extends Record<string, unknown> {
initialPrompt: string | null;
workspaceId: string | null;
provider: string | null;
cwd: string | null;
interface CreatedAgentAssertion {
workspaceId: string;
provider: string;
cwd: string;
modeId: string | null;
model: string | null;
}
function createFrameForPrompt(frames: CreateAgentFrame[], prompt: string): CreateAgentFrame | null {
return frames.find((frame) => frame.initialPrompt === prompt) ?? null;
async function fetchCreatedAgentForWorkspace(
seeded: SeededWorkspace,
workspaceId: string,
): Promise<CreatedAgentAssertion | null> {
const result = await seeded.client.fetchAgents({ scope: "active" });
const agents = result.entries
.map((entry) => entry.agent)
.filter((agent) => agent.cwd === seeded.repoPath && agent.workspaceId === workspaceId);
if (agents.length !== 1) {
return null;
}
const [agent] = agents;
return {
workspaceId,
provider: agent.provider,
cwd: agent.cwd,
modeId: agent.currentModeId,
model: agent.model,
};
}
async function fetchWorkspaceName(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<string | null> {
const result = await client.fetchWorkspaces();
return result.entries.find((entry) => entry.id === workspaceId)?.name ?? null;
}
async function fetchWorkspaceStatuses(
client: NewWorkspaceDaemonClient,
workspaceIds: string[],
): Promise<Record<string, string | null>> {
const result = await client.fetchWorkspaces();
const entriesById = new Map(result.entries.map((entry) => [entry.id, entry.status]));
return Object.fromEntries(
workspaceIds.map((workspaceId) => [workspaceId, entriesById.get(workspaceId) ?? null]),
);
}
async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promise<string | null> {
const result = await seeded.client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.status ?? null;
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
timeout: 30_000,
});
}
function statusGroupRows(page: import("@playwright/test").Page, bucket: string) {
return page.getByTestId(`sidebar-status-group-rows-${bucket}`);
}
async function expectWorkspaceRowInStatusBucket(
page: import("@playwright/test").Page,
input: { rowTestId: string; bucket: string },
) {
await expect(statusGroupRows(page, input.bucket).getByTestId(input.rowTestId)).toBeVisible({
timeout: 30_000,
});
}
async function expectWorkspaceRowNotInStatusBuckets(
page: import("@playwright/test").Page,
input: { rowTestId: string; buckets: string[] },
) {
for (const bucket of input.buckets) {
await expect(statusGroupRows(page, bucket).getByTestId(input.rowTestId)).toHaveCount(0, {
timeout: 5_000,
});
}
}
async function expectWorkspaceRowHasOnlyIndicator(
page: import("@playwright/test").Page,
input: { rowTestId: string; indicator: WorkspaceIndicator },
) {
const row = page.getByTestId(input.rowTestId);
await expect(row).toBeVisible({ timeout: 30_000 });
for (const indicator of [
"attention",
"done",
"failed",
"loading",
"needs_input",
"running",
] satisfies WorkspaceIndicator[]) {
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
if (indicator === input.indicator) {
await expect(locator).toBeVisible({ timeout: 30_000 });
} else {
await expect(locator).toHaveCount(0);
}
}
}
async function expectWorkspaceRowDoesNotShowIndicator(
page: import("@playwright/test").Page,
input: { rowTestId: string; indicator: WorkspaceIndicator },
) {
const row = page.getByTestId(input.rowTestId);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(
row.locator(`[data-testid="workspace-status-indicator-${input.indicator}"]`),
).toHaveCount(0, { timeout: 5_000 });
}
test.describe("Workspace model regressions", () => {
@@ -34,14 +150,17 @@ test.describe("Workspace model regressions", () => {
await client?.close().catch(() => undefined);
});
test("same-directory workspace does not inherit legacy cwd-only agent tabs", async ({ page }) => {
test("same-directory workspace does not show agents owned by another workspace", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "workspace-legacy-agents-" });
try {
const legacyAgent = await seeded.client.createAgent({
const ownedAgent = await seeded.client.createAgent({
provider: "mock",
cwd: seeded.repoPath,
title: "Legacy cwd-only agent",
workspaceId: seeded.workspaceId,
title: "Agent owned by original workspace",
modeId: "load-test",
model: "ten-second-stream",
});
@@ -62,7 +181,7 @@ test.describe("Workspace model regressions", () => {
await gotoWorkspace(page, seeded.workspaceId);
await expect
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${legacyAgent.id}`);
.toContain(`workspace-tab-agent_${ownedAgent.id}`);
} finally {
await seeded.cleanup();
}
@@ -74,21 +193,6 @@ test.describe("Workspace model regressions", () => {
const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "workspace-new-agent-model-",
});
const createFrames = captureWsSessionFrames<CreateAgentFrame>(
page,
"create_agent_request",
(inner) => {
const config = (inner.config ?? {}) as Record<string, unknown>;
return {
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
workspaceId: typeof inner.workspaceId === "string" ? inner.workspaceId : null,
provider: typeof config.provider === "string" ? config.provider : null,
cwd: typeof config.cwd === "string" ? config.cwd : null,
modeId: typeof config.modeId === "string" ? config.modeId : null,
model: typeof config.model === "string" ? config.model : null,
};
},
);
try {
const secondWorkspace = await seeded.client.createWorkspace({
@@ -98,6 +202,7 @@ test.describe("Workspace model regressions", () => {
if (!secondWorkspace.workspace) {
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
}
const workspace = secondWorkspace.workspace;
await page.addInitScript(() => {
localStorage.setItem(
@@ -110,7 +215,7 @@ test.describe("Workspace model regressions", () => {
}),
);
});
await gotoWorkspace(page, secondWorkspace.workspace.id);
await gotoWorkspace(page, workspace.id);
await clickNewChat(page);
await expectComposerVisible(page);
@@ -121,12 +226,11 @@ test.describe("Workspace model regressions", () => {
0,
);
await expect
.poll(() => createFrameForPrompt(createFrames, prompt), {
.poll(() => fetchCreatedAgentForWorkspace(seeded, workspace.id), {
timeout: 10_000,
})
.toEqual({
initialPrompt: prompt,
workspaceId: secondWorkspace.workspace.id,
workspaceId: workspace.id,
provider: "mock",
cwd: seeded.repoPath,
modeId: "load-test",
@@ -136,4 +240,291 @@ test.describe("Workspace model regressions", () => {
await seeded.cleanup();
}
});
test("local same-directory workspace with an initial prompt shows a generated title", async ({
page,
}) => {
const serverId = getServerId();
const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "workspace-generated-local-title-",
});
const previousConfig = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({
metadataGeneration: {
providers: [{ provider: "mock", model: "ten-second-stream" }],
},
});
await gotoWorkspace(page, seeded.workspaceId);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local");
await submitNewWorkspacePrompt(page, "Fix login bug");
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: seeded.workspaceId,
projectDisplayName: seeded.projectDisplayName,
});
const createdRow = page.getByTestId(
`sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`,
);
await expect
.poll(() => fetchWorkspaceName(client, createdWorkspace.workspaceId), {
timeout: 30_000,
})
.toBe("Fix login bug");
await expectWorkspaceHeader(page, {
title: "Fix login bug",
subtitle: seeded.projectDisplayName,
});
await expect(createdRow).toContainText("Fix login bug", { timeout: 30_000 });
expect(createdWorkspace.workspaceDirectory).toBe(seeded.workspaceDirectory);
} finally {
await client
.patchDaemonConfig({
metadataGeneration: {
providers: previousConfig.config.metadataGeneration.providers,
},
})
.catch(() => undefined);
await seeded.cleanup();
}
});
test("running agent in one same-directory workspace only shows the loader on its owning row", async ({
page,
}) => {
const serverId = getServerId();
const seeded = await seedWorkspace({
repoPrefix: "workspace-same-cwd-running-",
});
try {
const secondWorkspace = await seeded.client.createWorkspace({
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
title: "Existing sibling workspace",
});
if (!secondWorkspace.workspace) {
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
}
const secondWorkspaceId = secondWorkspace.workspace.id;
const runningAgent = await seeded.client.createAgent({
provider: "mock",
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Running agent",
modeId: "load-test",
model: "five-minute-stream",
initialPrompt: "stay running",
});
await seeded.client.waitForAgentUpsert(
runningAgent.id,
(snapshot) => snapshot.status === "running",
15_000,
);
await gotoWorkspace(page, seeded.workspaceId);
await waitForSidebarHydration(page);
const firstRowTestId = `sidebar-workspace-row-${serverId}:${seeded.workspaceId}`;
const secondRowTestId = `sidebar-workspace-row-${serverId}:${secondWorkspaceId}`;
await expectWorkspaceRowHasOnlyIndicator(page, {
rowTestId: firstRowTestId,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: secondRowTestId,
indicator: "running",
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: seeded.workspaceId,
projectDisplayName: seeded.projectDisplayName,
});
const createdRowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expect
.poll(() => fetchAgentStatus(seeded, runningAgent.id), { timeout: 10_000 })
.toBe("running");
await expect
.poll(
() =>
fetchWorkspaceStatuses(client, [
seeded.workspaceId,
secondWorkspaceId,
createdWorkspace.workspaceId,
]),
{ timeout: 10_000 },
)
.toEqual({
[seeded.workspaceId]: "running",
[secondWorkspaceId]: "done",
[createdWorkspace.workspaceId]: "done",
});
await expectWorkspaceRowHasOnlyIndicator(page, {
rowTestId: firstRowTestId,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: secondRowTestId,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: createdRowTestId,
indicator: "running",
});
await switchSidebarToStatusGrouping(page);
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: firstRowTestId,
bucket: "running",
});
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: secondRowTestId,
bucket: "done",
});
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: createdRowTestId,
bucket: "done",
});
await expectWorkspaceRowNotInStatusBuckets(page, {
rowTestId: secondRowTestId,
buckets: ["running", "needs_input", "attention"],
});
await expectWorkspaceRowNotInStatusBuckets(page, {
rowTestId: createdRowTestId,
buckets: ["running", "needs_input", "attention"],
});
} finally {
await seeded.cleanup();
}
});
test("pending permission in one same-directory workspace marks only its own row needing input", async ({
page,
}) => {
const serverId = getServerId();
const seeded = await seedWorkspace({
repoPrefix: "workspace-same-cwd-permission-",
});
try {
const secondWorkspace = await seeded.client.createWorkspace({
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
title: "Permission sibling workspace",
});
if (!secondWorkspace.workspace) {
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
}
const secondWorkspaceId = secondWorkspace.workspace.id;
const agent = await seeded.client.createAgent({
provider: "mock",
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Permission agent",
modeId: "load-test",
model: "ten-second-stream",
});
await seeded.client.sendAgentMessage(agent.id, "Emit synthetic plan approval.");
const parked = await seeded.client.waitForFinish(agent.id, 15_000);
expect(parked.status).toBe("permission");
await gotoWorkspace(page, seeded.workspaceId);
await waitForSidebarHydration(page);
const firstRowTestId = `sidebar-workspace-row-${serverId}:${seeded.workspaceId}`;
const secondRowTestId = `sidebar-workspace-row-${serverId}:${secondWorkspaceId}`;
await expectWorkspaceRowHasOnlyIndicator(page, {
rowTestId: firstRowTestId,
indicator: "needs_input",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: secondRowTestId,
indicator: "needs_input",
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: seeded.workspaceId,
projectDisplayName: seeded.projectDisplayName,
});
const createdRowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expect
.poll(
() =>
fetchWorkspaceStatuses(client, [
seeded.workspaceId,
secondWorkspaceId,
createdWorkspace.workspaceId,
]),
{ timeout: 10_000 },
)
.toEqual({
[seeded.workspaceId]: "needs_input",
[secondWorkspaceId]: "done",
[createdWorkspace.workspaceId]: "done",
});
await expectWorkspaceRowHasOnlyIndicator(page, {
rowTestId: firstRowTestId,
indicator: "needs_input",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: secondRowTestId,
indicator: "needs_input",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: createdRowTestId,
indicator: "needs_input",
});
await switchSidebarToStatusGrouping(page);
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: firstRowTestId,
bucket: "needs_input",
});
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: secondRowTestId,
bucket: "done",
});
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: createdRowTestId,
bucket: "done",
});
await expectWorkspaceRowNotInStatusBuckets(page, {
rowTestId: secondRowTestId,
buckets: ["running", "needs_input", "attention"],
});
await expectWorkspaceRowNotInStatusBuckets(page, {
rowTestId: createdRowTestId,
buckets: ["running", "needs_input", "attention"],
});
} finally {
await seeded.cleanup();
}
});
});

View File

@@ -0,0 +1,537 @@
import { randomUUID } from "node:crypto";
import { execSync, spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
import { test, expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { buildSeededHost } from "./helpers/daemon-registry";
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
submitNewWorkspaceEmpty,
} from "./helpers/new-workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
const LEGACY_AGENT_ID = "legacy-cwd-only-agent";
const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
interface RestartDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
fetchWorkspaces(): Promise<{
entries: Array<{
id: string;
name: string;
status: string;
workspaceDirectory: string;
}>;
}>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
agent: {
id: string;
workspaceId?: string;
status: string;
};
}>;
}>;
}
interface RestartDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
appVersion: string;
webSocketFactory: NodeWebSocketFactory;
}
interface SeededRestartHome {
paseoHome: string;
cwd: string;
projectId: string;
projectDisplayName: string;
workspaceA: string;
workspaceB: string;
cleanup(): void;
}
interface StartedDaemon {
port: number;
close(): Promise<void>;
}
function nowIso(): string {
return new Date().toISOString();
}
async function seedRestartHome(): Promise<SeededRestartHome> {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-playwright-restart-home-"));
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-playwright-restart-cwd-"));
const projectsDir = path.join(paseoHome, "projects");
const agentDir = path.join(paseoHome, "agents", projectDirNameFromCwd(cwd));
mkdirSync(projectsDir, { recursive: true });
mkdirSync(agentDir, { recursive: true });
const projectDisplayName = path.basename(cwd);
const project = {
projectId: `prj_restart_${randomUUID().slice(0, 8)}`,
rootPath: cwd,
kind: "non_git",
displayName: projectDisplayName,
customName: null,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
};
const workspaceA = {
workspaceId: "wks_restart_a",
projectId: project.projectId,
cwd,
kind: "directory",
displayName: "Original restart workspace",
title: null,
branch: null,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
};
const workspaceB = {
workspaceId: "wks_restart_b",
projectId: project.projectId,
cwd,
kind: "directory",
displayName: "Restart sibling workspace",
title: null,
branch: null,
createdAt: "2026-03-02T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
};
writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify([project]));
writeFileSync(
path.join(projectsDir, "workspaces.json"),
JSON.stringify([workspaceA, workspaceB]),
);
writeFileSync(
path.join(agentDir, `${LEGACY_AGENT_ID}.json`),
JSON.stringify({
id: LEGACY_AGENT_ID,
provider: "codex",
cwd,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
title: "Legacy cwd-only running agent",
labels: {},
lastStatus: "running",
lastModeId: "default",
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
}),
);
return {
paseoHome,
cwd,
projectId: project.projectId,
projectDisplayName,
workspaceA: workspaceA.workspaceId,
workspaceB: workspaceB.workspaceId,
cleanup: () => {
rmSync(paseoHome, { recursive: true, force: true });
rmSync(cwd, { recursive: true, force: true });
},
};
}
function projectDirNameFromCwd(cwd: string): string {
const { root } = path.win32.parse(cwd);
const withoutRoot = cwd.slice(root.length).replace(/[\\/]+$/, "");
const sanitizedRoot = root.replace(/[:\\/]+/g, "-").replace(/^-+|-+$/g, "");
const prefix = sanitizedRoot ? `${sanitizedRoot}-` : "";
if (!withoutRoot) {
return sanitizedRoot || "root";
}
return prefix + withoutRoot.replace(/[\\/]+/g, "-");
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
return;
}
server.close(() => resolve(address.port));
});
});
}
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const startedAt = Date.now();
let lastConnectionError: unknown = null;
while (Date.now() - startedAt < 20_000) {
if (child.exitCode !== null) {
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
}
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, "127.0.0.1", () => {
socket.end();
resolve();
});
socket.setTimeout(1000, () => {
socket.destroy();
reject(new Error(`Connection timed out to daemon port ${port}`));
});
socket.on("error", reject);
});
return;
} catch (error) {
lastConnectionError = error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
throw new Error(
`Restart test daemon did not listen on ${port}. Last error: ${
lastConnectionError instanceof Error
? lastConnectionError.message
: String(lastConnectionError)
}`,
);
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 5000);
try {
await once(child, "exit");
} finally {
clearTimeout(timeout);
}
}
async function startRestartDaemon(input: {
paseoHome: string;
origin: string;
}): Promise<StartedDaemon> {
const port = await getAvailablePort();
if (port === 6767 || String(port) === process.env.E2E_DAEMON_PORT) {
return startRestartDaemon(input);
}
const serverDir = path.resolve(__dirname, "../../server");
const tsxBin = execSync("which tsx").toString().trim();
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: input.paseoHome,
PASEO_SERVER_ID: SERVER_ID,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_CORS_ORIGINS: input.origin,
PASEO_RELAY_ENABLED: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
},
stdio: ["ignore", "ignore", "pipe"],
detached: false,
});
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
stderr = stderr.split("\n").slice(-40).join("\n");
});
try {
await waitForServer(port, child);
} catch (error) {
await stopProcess(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
);
}
return {
port,
close: () => stopProcess(child),
};
}
async function connectRestartDaemonClient(port: number): Promise<RestartDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor<
RestartDaemonClientConfig,
RestartDaemonClient
>();
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
clientId: `restart-playwright-${randomUUID()}`,
clientType: "cli",
appVersion: loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
});
await client.connect();
return client;
}
function loadAppVersion(): string {
const packageJson = JSON.parse(
readFileSync(path.resolve(__dirname, "../package.json"), "utf8"),
) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error("Missing app package version");
}
return packageJson.version;
}
async function seedBrowserForDaemon(page: Page, input: { serverId: string; port: number }) {
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await page.route(
"**/*",
async (route) => {
await route.fulfill({
status: 200,
contentType: "text/html",
body: "<!doctype html><html><body>storage seed</body></html>",
});
},
{ times: 1 },
);
await page.goto("/");
const host = buildSeededHost({
serverId: input.serverId,
endpoint: `127.0.0.1:${input.port}`,
label: "restart daemon",
nowIso: nowIso(),
});
await page.evaluate(
({ daemon, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{
daemon: host,
preferences: {
serverId: input.serverId,
provider: "codex",
providerPreferences: {
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
},
},
},
);
}
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
const pathname = new URL(page.url()).pathname;
const match = pathname.match(
new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`),
);
if (!match?.[1]) return null;
return decodeWorkspaceIdFromPathSegment(match[1]);
}
async function expectWorkspaceRowHasOnlyIndicator(
page: Page,
input: { serverId: string; workspaceId: string; indicator: string },
) {
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
if (indicator === input.indicator) {
await expect(locator).toBeVisible({ timeout: 30_000 });
} else {
await expect(locator).toHaveCount(0);
}
}
}
async function expectWorkspaceRowDoesNotShowIndicator(
page: Page,
input: { serverId: string; workspaceId: string; indicator: string },
) {
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(
row.locator(`[data-testid="workspace-status-indicator-${input.indicator}"]`),
).toHaveCount(0, { timeout: 5_000 });
}
async function expectWorkspaceRowInStatusBucket(
page: Page,
input: { serverId: string; workspaceId: string; bucket: string },
) {
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(
page
.getByTestId(`sidebar-status-group-rows-${input.bucket}`)
.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`),
).toBeVisible({ timeout: 30_000 });
}
async function fetchLegacyAgent(client: RestartDaemonClient) {
const agents = await client.fetchAgents({ scope: "active" });
return agents.entries.find(hasLegacyAgentId)?.agent ?? null;
}
function hasLegacyAgentId(entry: { agent: { id: string } }): boolean {
return entry.agent.id === LEGACY_AGENT_ID;
}
async function fetchWorkspaceStatuses(
client: RestartDaemonClient,
workspaceIds: string[],
): Promise<Record<string, string>> {
const workspaces = await client.fetchWorkspaces();
const wantedWorkspaceIds = new Set(workspaceIds);
const statuses: Record<string, string> = {};
for (const workspace of workspaces.entries) {
if (wantedWorkspaceIds.has(workspace.id)) {
statuses[workspace.id] = workspace.status;
}
}
return statuses;
}
test.describe("Workspace model restart regressions", () => {
test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({
page,
baseURL,
}) => {
test.setTimeout(90_000);
const seeded = await seedRestartHome();
const origin = new URL(baseURL ?? "http://localhost").origin;
const daemon = await startRestartDaemon({ paseoHome: seeded.paseoHome, origin });
const serverId = SERVER_ID;
const client = await connectRestartDaemonClient(daemon.port);
try {
await seedBrowserForDaemon(page, { serverId, port: daemon.port });
await expect
.poll(() => fetchLegacyAgent(client))
.toMatchObject({
id: LEGACY_AGENT_ID,
workspaceId: seeded.workspaceA,
status: "running",
});
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await waitForSidebarHydration(page);
await expectWorkspaceRowHasOnlyIndicator(page, {
serverId,
workspaceId: seeded.workspaceA,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
indicator: "running",
});
await expect
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await submitNewWorkspaceEmpty(page);
await expect
.poll(() => {
const workspaceId = parseWorkspaceIdFromPageUrl(page, serverId);
return workspaceId && workspaceId !== seeded.workspaceA ? workspaceId : null;
})
.not.toBeNull();
const createdWorkspaceId = parseWorkspaceIdFromPageUrl(page, serverId);
if (!createdWorkspaceId) {
throw new Error(`Expected browser to navigate to created workspace, got ${page.url()}`);
}
await expect
.poll(() =>
fetchWorkspaceStatuses(client, [
seeded.workspaceA,
seeded.workspaceB,
createdWorkspaceId,
]),
)
.toEqual({
[seeded.workspaceA]: "running",
[seeded.workspaceB]: "done",
[createdWorkspaceId]: "done",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: createdWorkspaceId,
indicator: "running",
});
await expectWorkspaceRowInStatusBucket(page, {
serverId,
workspaceId: seeded.workspaceA,
bucket: "running",
});
await expectWorkspaceRowInStatusBucket(page, {
serverId,
workspaceId: seeded.workspaceB,
bucket: "done",
});
await expectWorkspaceRowInStatusBucket(page, {
serverId,
workspaceId: createdWorkspaceId,
bucket: "done",
});
await expect
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toEqual([]);
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await expect
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
seeded.cleanup();
}
});
});

View File

@@ -1,42 +1,67 @@
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
import { clickNewTerminal, gotoWorkspace } from "./helpers/launcher";
import { renameModalInput, renameModalSubmit } from "./helpers/rename";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
async function fetchTerminalTitle(
workspace: SeededWorkspace,
terminalId: string,
): Promise<string | null> {
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
workspaceId: workspace.workspaceId,
});
const terminal = result.terminals.find((entry) => entry.id === terminalId);
return terminal?.title ?? null;
}
async function waitForCreatedTerminalId(workspace: SeededWorkspace): Promise<string> {
await expect
.poll(
async () => {
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
workspaceId: workspace.workspaceId,
});
return result.terminals.map((entry) => entry.id);
},
{ timeout: 30_000 },
)
.toHaveLength(1);
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
workspaceId: workspace.workspaceId,
});
const terminal = result.terminals[0];
if (!terminal) {
throw new Error("Expected one created terminal");
}
return terminal.id;
}
test.describe("Workspace terminal tab rename", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "workspace-terminal-rename-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
test("right-click rename persists the terminal title and updates the tab label", async ({
page,
}) => {
test.setTimeout(60_000);
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
terminalId: String(inner.terminalId ?? ""),
title: String(inner.title ?? ""),
requestId: String(inner.requestId ?? ""),
}));
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
let terminalId: string | null = null;
await withTerminalInApp(page, harness, { name: "rename-target" }, async (terminal) => {
const tab = page.getByTestId(`workspace-tab-terminal_${terminal.id}`).first();
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
terminalId = await waitForCreatedTerminalId(workspace);
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminal.id}`)).toBeVisible({
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
timeout: 10_000,
});
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminal.id}-rename`);
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminal.id}`;
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
const input = renameModalInput(page, modalPrefix);
await expect(input).toBeVisible({ timeout: 10_000 });
@@ -45,12 +70,14 @@ test.describe("Workspace terminal tab rename", () => {
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
expect(renameFrames.length).toBeGreaterThan(0);
const lastFrame = renameFrames.at(-1)!;
expect(lastFrame.terminalId).toBe(terminal.id);
expect(lastFrame.title).toBe("My Renamed Terminal");
expect(lastFrame.requestId.length).toBeGreaterThan(0);
});
await expect
.poll(() => fetchTerminalTitle(workspace, terminalId!))
.toBe("My Renamed Terminal");
} finally {
if (terminalId) {
await workspace.client.killTerminal(terminalId).catch(() => undefined);
}
await workspace.cleanup();
}
});
});

View File

@@ -75,7 +75,6 @@ import {
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
@@ -276,13 +275,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = resolveWorkspaceIdByDirectory({
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
workspaceDirectory: workspaceRoot,
});
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId: workspaceId ?? undefined,
workspaceId: agent.workspaceId,
workspaceRoot,
});
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
@@ -334,10 +329,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return;
}
if (workspaceId) {
if (agent.workspaceId) {
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
workspaceId: agent.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}

View File

@@ -2,10 +2,9 @@ import { useEffect, useRef } from "react";
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
@@ -28,16 +27,16 @@ function HostAgentReadyRouteContent() {
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentCwd = useSessionStore((state) => {
const agentWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
});
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd);
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
useEffect(() => {
if (redirectedRef.current) {
@@ -67,14 +66,14 @@ function HostAgentReadyRouteContent() {
if (!serverId || !agentId) {
return;
}
if (agentCwd?.trim() && !hasHydratedWorkspaces) {
if (agentWorkspaceId && !hasHydratedWorkspaces) {
return;
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
}
}, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -91,15 +90,7 @@ function HostAgentReadyRouteContent() {
if (cancelled || redirectedRef.current) {
return;
}
const cwd = result?.agent?.cwd?.trim();
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceId = resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: cwd,
});
if (!workspaceId && !hasHydratedWorkspaces) {
return;
}
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
redirectedRef.current = true;
if (workspaceId) {
navigateToPreparedWorkspaceTab({
@@ -124,7 +115,7 @@ function HostAgentReadyRouteContent() {
return () => {
cancelled = true;
};
}, [agentId, client, hasHydratedWorkspaces, isConnected, pathname, router, serverId]);
}, [agentId, client, isConnected, pathname, router, serverId]);
return null;
}

View File

@@ -202,7 +202,7 @@ function WorkspaceStatusIndicator({
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-needs_input">
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
</View>
);
@@ -222,7 +222,7 @@ function WorkspaceStatusIndicator({
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.workspaceStatusDot}>
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<StatusDotOverlay

View File

@@ -28,11 +28,10 @@ function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
}
describe("workspace archive pending suppression", () => {
it("tracks a locally pending workspace archive by id and directory", () => {
it("tracks a locally pending workspace archive by id", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(
@@ -41,12 +40,6 @@ describe("workspace archive pending suppression", () => {
workspaceId: "/repo/worktree",
}),
).toBe(true);
expect(
isWorkspaceArchivePending({
serverId: "server-1",
workspaceDirectory: "/repo/worktree",
}),
).toBe(true);
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
@@ -68,19 +61,34 @@ describe("workspace archive pending suppression", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
workspace: workspace({ archivingAt: null }),
}),
).toBe(true);
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
});
it("does not suppress a same-cwd sibling whose id is not the one archived", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
});
expect(
shouldSuppressWorkspaceForLocalArchive({
serverId: "server-1",
workspace: workspace({ id: "sibling", workspaceDirectory: "/repo/worktree" }),
}),
).toBe(false);
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
});
it("allows upserts when this client did not start the archive", () => {
expect(
shouldSuppressWorkspaceForLocalArchive({
@@ -94,7 +102,6 @@ describe("workspace archive pending suppression", () => {
markWorkspaceArchivePending({
serverId: "server-1",
workspaceId: "/repo/worktree",
workspaceDirectory: "/repo/worktree",
});
expect(

View File

@@ -1,12 +1,7 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
interface PendingWorkspaceArchive {
workspaceId: string;
workspaceDirectory: string | null;
}
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
const pendingWorkspaceArchivesByServer = new Map<string, Set<string>>();
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
@@ -15,7 +10,6 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st
export function markWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
@@ -23,11 +17,8 @@ export function markWorkspaceArchivePending(input: {
return;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
workspaceId,
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
});
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Set<string>();
archives.add(pendingArchiveKey({ serverId, workspaceId }));
pendingWorkspaceArchivesByServer.set(serverId, archives);
}
@@ -54,34 +45,15 @@ export function clearWorkspaceArchivePending(input: {
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId?: string | null;
workspaceDirectory?: string | null;
}): boolean {
const serverId = input.serverId.trim();
if (!serverId) {
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!serverId || !workspaceId) {
return false;
}
const archives = pendingWorkspaceArchivesByServer.get(serverId);
if (!archives) {
return false;
}
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
return true;
}
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!workspaceDirectory) {
return false;
}
for (const archive of archives.values()) {
if (archive.workspaceDirectory === workspaceDirectory) {
return true;
}
}
return false;
return archives?.has(pendingArchiveKey({ serverId, workspaceId })) ?? false;
}
export function shouldSuppressWorkspaceForLocalArchive(input: {
@@ -91,6 +63,5 @@ export function shouldSuppressWorkspaceForLocalArchive(input: {
return isWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspace.id,
workspaceDirectory: input.workspace.workspaceDirectory,
});
}

View File

@@ -314,7 +314,7 @@ describe("checkout-git-actions-store", () => {
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
@@ -371,7 +371,9 @@ describe("checkout-git-actions-store", () => {
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
await expect(
useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }),
useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId }),
).rejects.toThrow("archive failed");
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(

View File

@@ -13,10 +13,7 @@ import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import {
resolveWorkspaceIdByDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { i18n } from "@/i18n/i18next";
@@ -155,15 +152,11 @@ function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): b
function snapshotWorktreeArchiveState(input: {
serverId: string;
worktreePath: string;
workspaceId: string | undefined;
}): WorktreeArchiveSnapshot {
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
const workspaceId = resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: input.worktreePath,
});
const workspaceKey = workspaceId
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId })
const workspaceKey = input.workspaceId
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: input.workspaceId })
: null;
return {
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
@@ -506,16 +499,16 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
actionId: "archive-worktree",
run: async () => {
const client = resolveClient(serverId);
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
const snapshot = snapshotWorktreeArchiveState({ serverId, workspaceId });
// The server archive is keyed by worktreePath and must always run. The
// optimistic client-side updates are keyed by workspace id, so they only
// apply when the workspace is resolved in the local store.
// apply when the caller passes the workspace id and it resolves in the
// local store.
const workspace = snapshot.workspace;
if (workspace) {
markWorkspaceArchivePending({
serverId,
workspaceId: workspace.id,
workspaceDirectory: workspace.workspaceDirectory,
});
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
}

View File

@@ -1,5 +1,4 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import { router, type Href } from "expo-router";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useTranslation } from "react-i18next";
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
@@ -15,9 +14,8 @@ import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { buildHostRootRoute } from "@/utils/host-routes";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import {
confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels,
@@ -61,6 +59,9 @@ function formatBaseRefLabel(baseRef: string | undefined, fallbackLabel: string):
function isLastWorktreeReference(workspaces: WorkspaceDescriptor[], worktreePath: string): boolean {
let references = 0;
for (const candidate of workspaces) {
// Git-fact: counting how many workspaces still reference the worktree
// directory on disk, not attributing ownership. Same-cwd siblings keep the
// directory alive, so the disk-deletion offer must compare directories.
if (candidate.workspaceDirectory === worktreePath) {
references += 1;
}
@@ -227,6 +228,7 @@ interface UseGitActionsResult {
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
const { t } = useTranslation();
const toast = useToast();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr");
@@ -533,27 +535,25 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const runArchiveWorktreeRecord = useCallback(
(worktreePath: string, deleteWorktreeFromDisk: boolean) => {
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceList = Array.from(workspaces?.values() ?? []);
const archivedWorkspaceId = resolveWorkspaceIdByDirectory({
workspaces: workspaceList,
workspaceDirectory: worktreePath,
});
const redirectRoute = archivedWorkspaceId
? buildWorkspaceArchiveRedirectRoute({
serverId,
archivedWorkspaceId,
workspaces: workspaceList,
})
: buildHostRootRoute(serverId);
router.replace(redirectRoute as Href);
// These git actions only ever target the workspace the screen is showing,
// so the redirect is sourced from the active workspace selection (the
// screen's own route context), not from any cwd→workspace inference here.
if (activeWorkspaceSelection) {
redirectIfArchivingActiveWorkspace({
serverId,
workspaceId: activeWorkspaceSelection.workspaceId,
activeWorkspaceSelection,
});
}
// The server archive is keyed by worktreePath; the daemon owns the
// directory→workspace resolution for archive-by-path.
void runArchiveWorktree({ serverId, cwd, worktreePath, deleteWorktreeFromDisk }).catch(
(err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedArchive"));
},
);
},
[cwd, runArchiveWorktree, serverId, t, toastActionError],
[activeWorkspaceSelection, cwd, runArchiveWorktree, serverId, t, toastActionError],
);
const worktreeDeletePrompt = useWorktreeDeletePrompt(runArchiveWorktreeRecord);
@@ -567,9 +567,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceList = Array.from(workspaces?.values() ?? []);
const workspace = workspaceList.find(
(candidate) => candidate.workspaceDirectory === worktreePath,
);
// Git-fact lookup: find the workspace backing this worktree directory to show
// its name and diff stat in the confirmation. This reads display data by
// directory, the same as the disk-deletion reference counting, and never
// derives an id used to key the archive (the archive runs by path).
const workspace =
workspaceList.find((candidate) => candidate.workspaceDirectory === worktreePath) ?? null;
const confirmed = await confirmRiskyWorktreeArchive(
{
worktreeName: workspace?.name ?? branchLabel,

View File

@@ -6,6 +6,7 @@ export interface AgentScreenAgent {
id: string;
status: "initializing" | "idle" | "running" | "error" | "closed";
cwd: string;
workspaceId?: string;
capabilities?: AgentCapabilityFlags;
lastError?: string | null;
projectPlacement?: {

View File

@@ -5,7 +5,6 @@ import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-f
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
@@ -132,15 +131,10 @@ function getRootAgentWorkspaceActivity(input: {
workspace: WorkspaceDescriptor;
agents: Map<string, Agent> | undefined;
}): WorkspaceAgentActivity | null {
const workspaceDirectory = normalizeWorkspacePath(input.workspace.workspaceDirectory);
if (!workspaceDirectory) {
return null;
}
let latest: WorkspaceAgentActivity | null = null;
for (const agent of input.agents?.values() ?? []) {
if (agent.archivedAt || agent.parentAgentId) continue;
if (normalizeWorkspacePath(agent.cwd) !== workspaceDirectory) continue;
if (agent.workspaceId !== input.workspace.id) continue;
const status = deriveSidebarStateBucket({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,

View File

@@ -254,6 +254,21 @@ describe("translation resources", () => {
expect(en.openProject.tiles.addProject.title).toBe("Add a project");
});
it("resolves the worktree delete prompt keys used by the sidebar prompt", () => {
const strings = flattenStrings(en);
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.title"]).toBe(
"Archive workspace",
);
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.message"]).toBe(
"Also remove the worktree from disk?",
);
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.keep"]).toBe(
"Keep on disk",
);
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.delete"]).toBe("Delete");
});
it("includes provider selector and pairing keys for the Batch 4D migration", () => {
expect(en.modelSelector.title).toBe("Select provider");
expect(en.modelSelector.favorites).toBe("Favorites");

View File

@@ -811,12 +811,12 @@ export const ar: TranslationResources = {
'إخفاء "{{workspaceName}}" من الشريط الجانبي؟\n\n لن يتم تغيير الملفات الموجودة على القرص.',
hideConfirm: "يخفي",
cancel: "يلغي",
},
deleteWorktreePrompt: {
title: "أرشفة مساحة العمل",
message: "هل تريد أيضًا إزالة شجرة العمل من القرص؟",
keep: "الاحتفاظ على القرص",
delete: "حذف",
deleteWorktreePrompt: {
title: "أرشفة مساحة العمل",
message: "هل تريد أيضًا إزالة شجرة العمل من القرص؟",
keep: "الاحتفاظ على القرص",
delete: "حذف",
},
},
rename: {
title: "إعادة تسمية مساحة العمل",

View File

@@ -817,12 +817,12 @@ export const en = {
'Hide "{{workspaceName}}" from the sidebar?\n\nFiles on disk will not be changed.',
hideConfirm: "Hide",
cancel: "Cancel",
},
deleteWorktreePrompt: {
title: "Archive workspace",
message: "Also remove the worktree from disk?",
keep: "Keep on disk",
delete: "Delete",
deleteWorktreePrompt: {
title: "Archive workspace",
message: "Also remove the worktree from disk?",
keep: "Keep on disk",
delete: "Delete",
},
},
rename: {
title: "Rename workspace",

View File

@@ -837,12 +837,12 @@ export const es: TranslationResources = {
'¿Ocultar "{{workspaceName}}" de la barra lateral?\n\nLos archivos en el disco no se cambiarán.',
hideConfirm: "Esconder",
cancel: "Cancelar",
},
deleteWorktreePrompt: {
title: "Archivar espacio de trabajo",
message: "¿También eliminar el worktree del disco?",
keep: "Conservar en disco",
delete: "Eliminar",
deleteWorktreePrompt: {
title: "Archivar espacio de trabajo",
message: "¿También eliminar el worktree del disco?",
keep: "Conservar en disco",
delete: "Eliminar",
},
},
rename: {
title: "Cambiar nombre del espacio de trabajo",

View File

@@ -836,12 +836,12 @@ export const fr: TranslationResources = {
"Masquer «{{workspaceName}}» dans la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.",
hideConfirm: "Cacher",
cancel: "Annuler",
},
deleteWorktreePrompt: {
title: "Archiver l'espace de travail",
message: "Supprimer aussi le worktree du disque?",
keep: "Conserver sur le disque",
delete: "Supprimer",
deleteWorktreePrompt: {
title: "Archiver l'espace de travail",
message: "Supprimer aussi le worktree du disque?",
keep: "Conserver sur le disque",
delete: "Supprimer",
},
},
rename: {
title: "Renommer l'espace de travail",

View File

@@ -829,12 +829,12 @@ export const ru: TranslationResources = {
"Скрыть «{{workspaceName}}» на боковой панели?\n\n Файлы на диске не будут изменены.",
hideConfirm: "Скрывать",
cancel: "Отмена",
},
deleteWorktreePrompt: {
title: "Архивировать рабочее пространство",
message: "Также удалить рабочее дерево с диска?",
keep: "Оставить на диске",
delete: "Удалить",
deleteWorktreePrompt: {
title: "Архивировать рабочее пространство",
message: "Также удалить рабочее дерево с диска?",
keep: "Оставить на диске",
delete: "Удалить",
},
},
rename: {
title: "Переименовать рабочую область",

View File

@@ -802,12 +802,12 @@ export const zhCN: TranslationResources = {
hideMessage: "从侧边栏隐藏「{{workspaceName}}」?\n\n磁盘上的文件不会被更改。",
hideConfirm: "隐藏",
cancel: "取消",
},
deleteWorktreePrompt: {
title: "归档 workspace",
message: "同时从磁盘删除 worktree",
keep: "保留在磁盘上",
delete: "删除",
deleteWorktreePrompt: {
title: "归档 workspace",
message: "同时从磁盘删除 worktree",
keep: "保留在磁盘上",
delete: "删除",
},
},
rename: {
title: "重命名 workspace",

View File

@@ -93,6 +93,7 @@ interface ChatAgentStateShape {
id: string | null;
status: Agent["status"] | null;
cwd: string | null;
workspaceId?: string;
capabilities?: Agent["capabilities"];
lastError?: Agent["lastError"] | null;
}
@@ -136,6 +137,7 @@ function selectChatAgentState(
id: agent.id,
status: agent.status,
cwd: agent.cwd,
workspaceId: agent.workspaceId,
capabilities: agent.capabilities,
lastError: agent.lastError ?? null,
archivedAt: agent.archivedAt ?? null,
@@ -156,6 +158,7 @@ function buildChatAgentFromState(
id: state.id,
status: state.status,
cwd: state.cwd,
workspaceId: state.workspaceId,
capabilities: state.capabilities,
lastError: state.lastError ?? null,
projectPlacement,
@@ -545,15 +548,13 @@ function AgentPanelBody({
);
const agentState = useSessionStore(
useShallow((state) => {
const session = state.sessions[serverId];
const agent = agentId
? (session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null)
: null;
const agent = resolveChatAgentFromSession(state, serverId, agentId);
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
status: agent?.status ?? null,
cwd: agent?.cwd ?? null,
workspaceId: agent?.workspaceId,
lastError: agent?.lastError ?? null,
archivedAt: agent?.archivedAt ?? null,
};
@@ -646,6 +647,7 @@ function AgentPanelBody({
id: agentState.id,
status: agentState.status,
cwd: agentState.cwd,
workspaceId: agentState.workspaceId,
lastError: agentState.lastError ?? null,
projectPlacement,
}

View File

@@ -75,6 +75,21 @@ function resolveCheckoutRequest(
};
}
function buildFirstAgentContext(input: {
prompt: string;
attachments: AgentAttachment[];
}): { prompt?: string; attachments?: AgentAttachment[] } | undefined {
const trimmedPrompt = input.prompt.trim();
if (!trimmedPrompt && input.attachments.length === 0) {
return undefined;
}
return {
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
attachments: input.attachments,
};
}
interface NewWorkspaceScreenProps {
serverId: string;
sourceDirectory?: string;
@@ -779,6 +794,8 @@ async function createMultiplicityWorkspace(input: {
selectedItem: PickerItem | null;
currentBranch: string | null;
withInitialAgent: boolean;
prompt: string;
attachments: AgentAttachment[];
mergeWorkspaces: (
serverId: string,
workspaces: ReturnType<typeof normalizeWorkspaceDescriptor>[],
@@ -790,6 +807,10 @@ async function createMultiplicityWorkspace(input: {
const baseBranch = isWorktree
? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined)
: undefined;
const firstAgentContext = buildFirstAgentContext({
prompt: input.prompt,
attachments: input.attachments,
});
const payload = await input.client.createWorkspace({
source: isWorktree
? {
@@ -804,6 +825,7 @@ async function createMultiplicityWorkspace(input: {
path: input.project.iconWorkingDir,
projectId: input.project.projectKey,
},
...(firstAgentContext ? { firstAgentContext } : {}),
});
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? input.createFailedMessage);
@@ -1292,21 +1314,13 @@ export function NewWorkspaceScreen({
throw new Error("Choose a project");
}
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
const trimmedPrompt = input.prompt.trim();
const hasFirstAgentContext = trimmedPrompt.length > 0 || input.attachments.length > 0;
const firstAgentContext = buildFirstAgentContext(input);
return {
cwd: selectedProject.iconWorkingDir,
projectId: selectedProject.projectKey,
worktreeSlug: createNameId(),
...(hasFirstAgentContext
? {
firstAgentContext: {
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
...(input.attachments.length > 0 ? { attachments: input.attachments } : {}),
},
}
: {}),
...(firstAgentContext ? { firstAgentContext } : {}),
...checkoutRequest,
};
},
@@ -1334,6 +1348,8 @@ export function NewWorkspaceScreen({
selectedItem,
currentBranch,
withInitialAgent: input.withInitialAgent,
prompt: input.prompt,
attachments: input.attachments,
mergeWorkspaces,
serverId,
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),

View File

@@ -186,17 +186,18 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
const paneWorkspaceId = normalizedWorkspaceId || undefined;
const unsubscribeChanged = client.on("terminals_changed", (message) => {
// The terminal subscription is keyed by cwd at the protocol level, so this
// gate only routes the event to the pane watching that cwd; it is not an
// ownership decision.
if (message.payload.cwd !== workspaceDirectory) {
return;
}
// Two workspaces can share a cwd, so the push can carry terminals from a
// sibling workspace. Keep only the ones whose workspaceId matches this
// pane; terminals without a workspaceId predate Model B and belong to
// whichever pane is watching the cwd.
// sibling workspace. A terminal belongs to a workspace only by its
// workspaceId, so keep only the ones whose workspaceId matches this pane.
const matchingTerminals = message.payload.terminals.filter(
(terminal) =>
terminal.workspaceId === undefined || terminal.workspaceId === paneWorkspaceId,
(terminal) => terminal.workspaceId === paneWorkspaceId,
);
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) => ({

View File

@@ -1791,7 +1791,6 @@ function WorkspaceScreenContent({
sessionAgents: state.sessions[normalizedServerId]?.agents,
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
workspaceId: normalizedWorkspaceId,
workspaceDirectory,
}),
workspaceAgentVisibilityEqual,
);

View File

@@ -77,6 +77,7 @@ describe("workspace navigation", () => {
const agent = {
id: "agent-1",
cwd: "/repo/workspace-a",
workspaceId: "workspace-a",
requiresAttention: true,
attentionReason: "permission",
} as unknown as Agent;

View File

@@ -6,7 +6,7 @@ import {
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
resolveWorkspaceIdByDirectory,
normalizeWorkspaceOpaqueId,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
@@ -76,11 +76,7 @@ export function navigateToWorkspace(
});
const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(serverId)).filter(
(agent) =>
resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: agent.cwd,
}) === resolvedWorkspaceId,
(agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId,
)
: [];
const attentionAgentId = pickAttentionAgent(workspaceAgents);

View File

@@ -7,7 +7,6 @@ import {
selectHasWorkspaces,
selectProjectOrder,
selectRecommendedProjectPaths,
selectResolveWorkspaceIdByCwd,
selectWorkspace,
selectWorkspaceDirectory,
selectWorkspaceExists,
@@ -135,17 +134,6 @@ export function useHasWorkspaces(serverId: string | null): boolean {
);
}
export function useResolveWorkspaceIdByCwd(
serverId: string | null,
cwd: string | null | undefined,
): string | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectResolveWorkspaceIdByCwd(state, serverId, cwd),
workspaceEqualityFns.identity,
);
}
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
return useStoreWithEqualityFn(
useSessionStore,

View File

@@ -6,7 +6,6 @@ import {
selectHasWorkspaces,
selectProjectOrder,
selectRecommendedProjectPaths,
selectResolveWorkspaceIdByCwd,
selectWorkspace,
selectWorkspaceDirectory,
selectWorkspaceFields,
@@ -385,33 +384,6 @@ describe("selectHasWorkspaces", () => {
});
});
describe("selectResolveWorkspaceIdByCwd", () => {
it("resolves by cwd and stays stable under unrelated updates", () => {
const workspaceA = createWorkspace({
id: "workspace-a",
workspaceDirectory: "/repo/a",
});
const workspaceB = createWorkspace({
id: "workspace-b",
workspaceDirectory: "/repo/b",
});
initializeWorkspaces([workspaceA, workspaceB]);
const tracked = trackSelector(
useSessionStore,
(state) => selectResolveWorkspaceIdByCwd(state, SERVER_ID, "/repo/a"),
workspaceEqualityFns.identity,
);
const before = tracked.current;
expect(before).toBe("workspace-a");
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
expect(tracked.current).toBe(before);
tracked.stop();
});
});
describe("selectWorkspaceStatusesForBadges", () => {
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });

View File

@@ -5,10 +5,7 @@ import {
type WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import {
resolveWorkspaceIdByDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "../session-store";
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
@@ -250,21 +247,6 @@ export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string |
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
}
export function selectResolveWorkspaceIdByCwd(
state: SessionsSnapshot,
serverId: string | null,
cwd: string | null | undefined,
): string | null {
if (!serverId || !cwd) {
return null;
}
const workspaces = state.sessions[serverId]?.workspaces;
return resolveWorkspaceIdByDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: cwd,
});
}
export function selectWorkspaceStatusesForBadges(
state: SessionsSnapshot,
): DesktopBadgeWorkspaceStatus[] {

View File

@@ -56,6 +56,7 @@ const AGENT_DEFAULTS: Agent = {
lastError: null,
title: "Agent",
cwd: WORKSPACE_DIRECTORY,
workspaceId: WORKSPACE_ID,
model: null,
features: undefined,
thinkingOptionId: undefined,
@@ -91,7 +92,7 @@ function deriveVisibilityFromSession(): WorkspaceAgentVisibility {
const sessionAgents = useSessionStore.getState().sessions[SERVER_ID]?.agents ?? new Map();
return deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceDirectory: WORKSPACE_DIRECTORY,
workspaceId: WORKSPACE_ID,
});
}

View File

@@ -11,8 +11,7 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
return {
workspaces: session?.workspaces.values(),
agentCwd: agent?.cwd,
agentWorkspaceId: agent?.workspaceId,
};
},
navigateToHostAgent: (route) => {

View File

@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
resolveNavigateToAgent,
type AgentNavTarget,
@@ -11,24 +10,6 @@ const SERVER_ID = "server-1";
const WORKSPACE_ID = "workspace-1";
const AGENT_ID = "agent-1";
function createWorkspace(): WorkspaceDescriptor {
return {
id: WORKSPACE_ID,
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo",
workspaceDirectory: "/repo/worktree",
projectKind: "git",
workspaceKind: "local_checkout",
name: "worktree",
status: "done",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
};
}
interface RecordedHostNav {
route: string;
}
@@ -59,10 +40,9 @@ function createFakeNavigators(target: AgentNavTarget): {
}
describe("resolveNavigateToAgent", () => {
it("opens the resolved workspace tab when the agent's cwd matches a workspace", () => {
it("opens the workspace tab carried by the agent's workspaceId", () => {
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
workspaces: [createWorkspace()],
agentCwd: "/repo/worktree",
agentWorkspaceId: WORKSPACE_ID,
});
const route = resolveNavigateToAgent(
@@ -83,10 +63,9 @@ describe("resolveNavigateToAgent", () => {
]);
});
it("falls back to the host agent route when the workspace is unknown", () => {
it("falls back to the host agent route when the agent has no workspaceId", () => {
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
workspaces: [],
agentCwd: null,
agentWorkspaceId: null,
});
const route = resolveNavigateToAgent({ serverId: SERVER_ID, agentId: "missing-agent" }, deps);

View File

@@ -1,6 +1,5 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab";
export interface NavigateToAgentInput {
@@ -11,8 +10,7 @@ export interface NavigateToAgentInput {
}
export interface AgentNavTarget {
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
agentCwd: string | null | undefined;
agentWorkspaceId: string | null | undefined;
}
export interface NavigateToAgentDeps {
@@ -25,14 +23,11 @@ export function resolveNavigateToAgent(
input: NavigateToAgentInput,
deps: NavigateToAgentDeps,
): string {
const { workspaces, agentCwd } = deps.readAgentNavTarget({
const { agentWorkspaceId } = deps.readAgentNavTarget({
serverId: input.serverId,
agentId: input.agentId,
});
const workspaceId = resolveWorkspaceIdByDirectory({
workspaces,
workspaceDirectory: agentCwd,
});
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
if (!workspaceId) {
const route = buildHostAgentDetailRoute(input.serverId, input.agentId);

View File

@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
resolveWorkspaceIdByDirectory,
resolveWorkspaceMapKeyByIdentity,
resolveWorkspaceRouteId,
} from "./workspace-identity";
import { resolveWorkspaceMapKeyByIdentity, resolveWorkspaceRouteId } from "./workspace-identity";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
@@ -38,42 +34,6 @@ describe("resolveWorkspaceRouteId", () => {
});
});
describe("resolveWorkspaceIdByDirectory", () => {
it("matches workspace directories", () => {
const workspaces = [
createWorkspace({
id: "workspace-1",
projectRootPath: "/repo",
workspaceDirectory: "/repo/.paseo/worktrees/feature",
}),
];
expect(
resolveWorkspaceIdByDirectory({
workspaces,
workspaceDirectory: "/repo/.paseo/worktrees/feature",
}),
).toBe("workspace-1");
});
it("does not match project root metadata", () => {
const workspaces = [
createWorkspace({
id: "workspace-1",
projectRootPath: "/repo",
workspaceDirectory: "/repo/.paseo/worktrees/feature",
}),
];
expect(
resolveWorkspaceIdByDirectory({
workspaces,
workspaceDirectory: "/repo",
}),
).toBeNull();
});
});
describe("resolveWorkspaceMapKeyByIdentity", () => {
it("returns the existing map key when the identity already matches a key", () => {
const workspaces = new Map<string, WorkspaceDescriptor>([

View File

@@ -31,26 +31,6 @@ export function resolveWorkspaceRouteId(input: {
return normalizeWorkspaceOpaqueId(input.routeWorkspaceId);
}
// Single approved cwd→workspaceId inference site.
// Do not add cwd-to-id inference elsewhere; prefer agent.workspaceId when the daemon provides it.
export function resolveWorkspaceIdByDirectory(input: {
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
workspaceDirectory: string | null | undefined;
}): string | null {
const normalizedWorkspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!normalizedWorkspaceDirectory) {
return null;
}
for (const workspace of input.workspaces ?? []) {
if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) {
return workspace.id;
}
}
return null;
}
export function resolveWorkspaceMapKeyByIdentity(input: {
workspaces: Map<string, WorkspaceDescriptor> | null | undefined;
workspaceId: string | null | undefined;

View File

@@ -57,16 +57,19 @@ function makeAgent(input: {
};
}
const WORKSPACE_ID = "ws-1";
describe("workspace agent visibility", () => {
it("keeps subagents active and known while excluding them from auto-open", () => {
const workspaceDirectory = "/repo/worktree";
const parent = makeAgent({
id: "parent-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
});
const child = makeAgent({
id: "child-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
parentAgentId: "parent-agent",
});
@@ -75,7 +78,7 @@ describe("workspace agent visibility", () => {
[parent.id, parent],
[child.id, child],
]),
workspaceDirectory,
workspaceId: WORKSPACE_ID,
});
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
@@ -84,17 +87,17 @@ describe("workspace agent visibility", () => {
});
it("keeps archived subagents known but excludes them from active and auto-open", () => {
const workspaceDirectory = "/repo/worktree";
const archivedChild = makeAgent({
id: "archived-child",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
parentAgentId: "parent-agent",
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
});
const result = deriveWorkspaceAgentVisibility({
sessionAgents: new Map<string, Agent>([[archivedChild.id, archivedChild]]),
workspaceDirectory,
workspaceId: WORKSPACE_ID,
});
expect(result.activeAgentIds).toEqual(new Set<string>());
@@ -103,15 +106,16 @@ describe("workspace agent visibility", () => {
});
it("excludes a child from auto-open even when its snapshot arrives before the parent", () => {
const workspaceDirectory = "/repo/worktree";
const child = makeAgent({
id: "child-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
parentAgentId: "parent-agent",
});
const parent = makeAgent({
id: "parent-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
});
const result = deriveWorkspaceAgentVisibility({
@@ -119,7 +123,7 @@ describe("workspace agent visibility", () => {
[child.id, child],
[parent.id, parent],
]),
workspaceDirectory,
workspaceId: WORKSPACE_ID,
});
expect(result.activeAgentIds).toEqual(new Set(["child-agent", "parent-agent"]));
@@ -128,21 +132,23 @@ describe("workspace agent visibility", () => {
});
it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => {
const workspaceDirectory = "/repo/worktree";
const visible = makeAgent({
id: "visible-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
createdAt: new Date("2026-03-04T00:00:00.000Z"),
});
const archived = makeAgent({
id: "archived-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
createdAt: new Date("2026-03-04T00:01:00.000Z"),
});
const otherWorkspace = makeAgent({
id: "other-workspace-agent",
cwd: "/repo/other",
workspaceId: "ws-other",
});
const sessionAgents = new Map<string, Agent>([
@@ -153,7 +159,7 @@ describe("workspace agent visibility", () => {
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceDirectory,
workspaceId: WORKSPACE_ID,
});
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
@@ -164,18 +170,22 @@ describe("workspace agent visibility", () => {
});
it("treats lazy historical details as known without making them active", () => {
const workspaceDirectory = "/repo/worktree";
const active = makeAgent({ id: "active-agent", cwd: workspaceDirectory });
const active = makeAgent({
id: "active-agent",
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
});
const historicalDetail = makeAgent({
id: "historical-agent",
cwd: workspaceDirectory,
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
});
const result = deriveWorkspaceAgentVisibility({
sessionAgents: new Map([[active.id, active]]),
agentDetails: new Map([[historicalDetail.id, historicalDetail]]),
workspaceDirectory,
workspaceId: WORKSPACE_ID,
});
expect(result.activeAgentIds).toEqual(new Set(["active-agent"]));
@@ -226,49 +236,6 @@ describe("workspace agent visibility", () => {
).toBe(true);
});
it("matches workspace agents when cwd and route workspace differ only by trailing slash", () => {
const sessionAgents = new Map<string, Agent>([
[
"slash-agent",
makeAgent({
id: "slash-agent",
cwd: "/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid/",
}),
],
]);
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceDirectory:
"/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid",
});
expect(result.activeAgentIds).toEqual(new Set(["slash-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["slash-agent"]));
expect(result.knownAgentIds.has("slash-agent")).toBe(true);
});
it("matches workspace agents using the workspace directory even when the route uses a numeric workspace id", () => {
const sessionAgents = new Map<string, Agent>([
[
"recent-agent",
makeAgent({
id: "recent-agent",
cwd: "/tmp/workspace-lifecycle-main",
}),
],
]);
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceDirectory: "/tmp/workspace-lifecycle-main",
});
expect(result.activeAgentIds).toEqual(new Set(["recent-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["recent-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["recent-agent"]));
});
it("matches agents by workspaceId regardless of cwd", () => {
const sessionAgents = new Map<string, Agent>([
[
@@ -284,7 +251,6 @@ describe("workspace agent visibility", () => {
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceId: "ws-1",
workspaceDirectory: "/repo/worktree",
});
expect(result.activeAgentIds).toEqual(new Set(["stamped-agent"]));
@@ -306,26 +272,24 @@ describe("workspace agent visibility", () => {
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceId: "ws-1",
workspaceDirectory: "/repo/worktree",
});
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.knownAgentIds).toEqual(new Set<string>());
});
it("falls back to cwd matching for legacy agents without a workspaceId", () => {
it("excludes agents without a workspaceId", () => {
const sessionAgents = new Map<string, Agent>([
["legacy-agent", makeAgent({ id: "legacy-agent", cwd: "/repo/worktree" })],
["ownerless-agent", makeAgent({ id: "ownerless-agent", cwd: "/repo/worktree" })],
]);
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceId: "ws-1",
workspaceDirectory: "/repo/worktree",
});
expect(result.activeAgentIds).toEqual(new Set(["legacy-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["legacy-agent"]));
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.knownAgentIds).toEqual(new Set<string>());
});
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {

View File

@@ -1,11 +1,7 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
import { shouldAutoOpenAgentTab } from "@/subagents/policies";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
function normalizeWorkspaceDirectory(value: string | null | undefined): string {
return normalizeWorkspacePath(value) ?? "";
}
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
export interface WorkspaceAgentVisibility {
activeAgentIds: Set<string>;
@@ -13,31 +9,18 @@ export interface WorkspaceAgentVisibility {
knownAgentIds: Set<string>;
}
function agentBelongsToWorkspace(input: {
agent: Agent;
workspaceId: string | null;
normalizedWorkspaceDirectory: string;
}): boolean {
const agentWorkspaceId = normalizeWorkspaceOpaqueId(input.agent.workspaceId);
if (agentWorkspaceId) {
return input.workspaceId !== null && agentWorkspaceId === input.workspaceId;
}
// COMPAT(workspaceOwnership): legacy agents predate workspaceId stamping. Fall
// back to the single approved cwd→id inference by comparing the agent's cwd to
// this workspace's directory. Drop when the daemon floor always stamps workspaceId.
return normalizeWorkspaceDirectory(input.agent.cwd) === input.normalizedWorkspaceDirectory;
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId;
}
export function deriveWorkspaceAgentVisibility(input: {
sessionAgents: Map<string, Agent> | undefined;
agentDetails?: Map<string, Agent> | undefined;
workspaceId?: string | null | undefined;
workspaceDirectory: string | null | undefined;
workspaceId: string | null | undefined;
}): WorkspaceAgentVisibility {
const { sessionAgents, agentDetails, workspaceDirectory } = input;
const { sessionAgents, agentDetails } = input;
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
const normalizedWorkspaceDirectory = normalizeWorkspaceDirectory(workspaceDirectory);
if ((!sessionAgents && !agentDetails) || (!workspaceId && !normalizedWorkspaceDirectory)) {
if ((!sessionAgents && !agentDetails) || !workspaceId) {
return {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
@@ -49,7 +32,7 @@ export function deriveWorkspaceAgentVisibility(input: {
const autoOpenAgentIds = new Set<string>();
const knownAgentIds = new Set<string>();
for (const agent of sessionAgents?.values() ?? []) {
if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) {
if (!agentBelongsToWorkspace(agent, workspaceId)) {
continue;
}
knownAgentIds.add(agent.id);
@@ -61,7 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: {
}
}
for (const agent of agentDetails?.values() ?? []) {
if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) {
if (!agentBelongsToWorkspace(agent, workspaceId)) {
continue;
}
knownAgentIds.add(agent.id);

View File

@@ -32,6 +32,9 @@ export function useIsLastWorktreeReference(workspace: SidebarWorkspaceEntry): bo
if (candidate.id === workspace.workspaceId) {
continue;
}
// Git-fact: comparing directories to detect a sibling worktree reference on
// disk, not attributing ownership. The disk-deletion decision is about the
// backing directory, which same-cwd siblings genuinely share.
if (normalizeWorkspacePath(candidate.workspaceDirectory) === directory) {
return false;
}

View File

@@ -51,7 +51,6 @@ function target(input?: Partial<WorkspaceArchiveTarget>): WorkspaceArchiveTarget
return {
serverId: SERVER_ID,
workspaceId: base.id,
workspaceDirectory: base.workspaceDirectory,
...input,
};
}
@@ -107,7 +106,6 @@ describe("archiveWorkspaceOptimistically", () => {
isWorkspaceArchivePending({
serverId: SERVER_ID,
workspaceId: archived.id,
workspaceDirectory: archived.workspaceDirectory,
}),
).toBe(true);
@@ -178,10 +176,7 @@ describe("archiveWorkspacesOptimistically", () => {
const failures = await archiveWorkspacesOptimistically({
client,
workspaces: [
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
],
workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })],
});
expect(failures).toHaveLength(1);

View File

@@ -8,7 +8,6 @@ import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export interface WorkspaceArchiveTarget {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}
interface WorkspaceArchiveClient {
@@ -46,7 +45,6 @@ function hideWorkspaceOptimistically(
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return { workspace: snapshot };

View File

@@ -1102,6 +1102,73 @@ test("sends structured first-agent context attachments with create_paseo_worktre
});
});
test("sends first-agent prompt context with workspace.create.request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createWorkspace(
{
source: {
kind: "directory",
path: "/tmp/project",
projectId: "local:/tmp/project",
},
firstAgentContext: {
prompt: "Fix login bug",
attachments: [],
},
},
"req-local-title",
);
expect(mock.sent).toHaveLength(1);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "workspace.create.request",
requestId: "req-local-title",
source: {
kind: "directory",
path: "/tmp/project",
projectId: "local:/tmp/project",
},
firstAgentContext: {
prompt: "Fix login bug",
attachments: [],
},
});
mock.triggerMessage(
wrapSessionMessage({
type: "workspace.create.response",
payload: {
requestId: "req-local-title",
workspace: null,
error: "local title sentinel",
setupTerminalId: null,
},
}),
);
await expect(createPromise).resolves.toEqual({
requestId: "req-local-title",
workspace: null,
error: "local title sentinel",
setupTerminalId: null,
});
});
test("sends worktree base-ref fields in create_paseo_worktree_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();

View File

@@ -1583,6 +1583,7 @@ test("importProviderSession imports the selected session without listing and pub
provider: "codex",
providerHandleId: "thread-selected",
cwd: workdir,
workspaceId: "ws-imported",
});
expect(client.listCalls).toBe(0);

View File

@@ -733,35 +733,37 @@ export class AgentManager {
}
async listProviderAvailability(): Promise<ProviderAvailability[]> {
const checks = Array.from(this.clients.keys()).map(async (provider) => {
const client = this.clients.get(provider);
if (!client) {
return {
provider,
available: false,
error: `No client registered for provider '${provider}'`,
} satisfies ProviderAvailability;
}
return Promise.all(
Array.from(this.clients.keys()).map((provider) => this.getProviderAvailability(provider)),
);
}
try {
const available = await client.isAvailable();
return {
provider,
available,
error: null,
} satisfies ProviderAvailability;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn({ err: error, provider }, "Failed to check provider availability");
return {
provider,
available: false,
error: message,
} satisfies ProviderAvailability;
}
});
async getProviderAvailability(provider: AgentProvider): Promise<ProviderAvailability> {
const client = this.clients.get(provider);
if (!client) {
return {
provider,
available: false,
error: `No client registered for provider '${provider}'`,
};
}
return Promise.all(checks);
try {
const available = await client.isAvailable();
return {
provider,
available,
error: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn({ err: error, provider }, "Failed to check provider availability");
return {
provider,
available: false,
error: message,
};
}
}
async listDraftCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
@@ -931,6 +933,7 @@ export class AgentManager {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
@@ -962,6 +965,7 @@ export class AgentManager {
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
labels: input.labels,
workspaceId: input.workspaceId,
timelineRows,
timelineNextSeq: timelineRows.length + 1,
persistence: imported.persistence,

View File

@@ -135,10 +135,22 @@ describe("generateStructuredAgentResponseWithFallback", () => {
function createManager(
availability: Array<{ provider: string; available: boolean; error: string | null }>,
) {
): AgentManager & { checkedProviders: string[] } {
const checkedProviders: string[] = [];
const availabilityByProvider = new Map(availability.map((entry) => [entry.provider, entry]));
return {
listProviderAvailability: async () => availability,
} as unknown as AgentManager;
checkedProviders,
getProviderAvailability: async (provider: string) => {
checkedProviders.push(provider);
return (
availabilityByProvider.get(provider) ?? {
provider,
available: false,
error: `No client registered for provider '${provider}'`,
}
);
},
} as unknown as AgentManager & { checkedProviders: string[] };
}
it("uses the first available provider in the waterfall", async () => {
@@ -171,6 +183,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
expect(result).toEqual({ summary: "ok" });
expect(calls).toEqual([{ provider: "claude", model: "haiku", persistSession: false }]);
expect(manager.checkedProviders).toEqual(["claude"]);
});
it("skips unavailable providers and uses the next available one", async () => {
@@ -201,6 +214,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
expect(result).toEqual({ summary: "ok" });
expect(calls).toEqual([{ provider: "codex", model: "gpt-5.4-mini" }]);
expect(manager.checkedProviders).toEqual(["claude", "codex"]);
});
it("falls back when an available provider fails", async () => {
@@ -237,6 +251,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.4-mini" },
]);
expect(manager.checkedProviders).toEqual(["claude", "codex"]);
});
it("throws a fallback error when all providers are unavailable or fail", async () => {

View File

@@ -403,13 +403,11 @@ export async function generateStructuredAgentResponseWithFallback<T>(
const runStructured =
runner ??
((input: StructuredAgentGenerationOptions<T>) => generateStructuredAgentResponse<T>(input));
const availability = await manager.listProviderAvailability();
const availabilityByProvider = new Map(availability.map((entry) => [entry.provider, entry]));
const attempts: StructuredGenerationAttempt[] = [];
for (const candidate of providers) {
const availabilityEntry = availabilityByProvider.get(candidate.provider);
if (availabilityEntry && !availabilityEntry.available) {
const availabilityEntry = await manager.getProviderAvailability(candidate.provider);
if (!availabilityEntry.available) {
const reason = availabilityEntry.error ?? "unavailable";
attempts.push({
provider: candidate.provider,

View File

@@ -29,7 +29,7 @@ interface CreateAgentLifecycleDispatchDependencies {
workspaceGitService: WorkspaceGitService;
createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn;
archiveAgentForClose: (agentId: string) => Promise<unknown>;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: (message: SessionOutboundMessage) => void;
@@ -211,7 +211,7 @@ export class CreateAgentLifecycleDispatch {
workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager,
agentStorage: this.dependencies.agentStorage,
resolveWorkspaceIdForCwd: this.dependencies.resolveWorkspaceIdForCwd,
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,

View File

@@ -54,6 +54,10 @@ interface CreateAgentCommandDependencies {
providerSnapshotManager: ProviderSnapshotManager;
daemonConfig?: StructuredGenerationDaemonConfig | null;
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh workspace for a cwd and returns its id. Used when an agent is
// created with no parent and no worktree: it owns a brand-new workspace rather
// than being attributed to an existing same-cwd workspace by path.
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
}
export interface CreateAgentFromSessionInput {
@@ -258,8 +262,11 @@ async function resolveMcpCreateAgent(
// workspace. When a new worktree is created the child lives in that fresh
// workspace, so it is stamped with the new worktree's workspaceId instead
// (mirrors the session path) — keeping the agent discoverable by
// workspaceId-scoped archive.
const workspaceId = setupContinuation ? createdWorkspaceId : parentAgent?.workspaceId;
// workspaceId-scoped archive. With neither a parent nor a worktree, the agent
// mints its own workspace; ownership is never resolved from cwd.
const workspaceId = setupContinuation
? createdWorkspaceId
: (parentAgent?.workspaceId ?? (await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd)));
const { modeId: resolvedMode, featureValues: resolvedFeatures } =
await dependencies.providerSnapshotManager.resolveCreateConfig({
@@ -305,6 +312,16 @@ async function resolveMcpCreateAgent(
};
}
async function ensureWorkspaceForMcpCreate(
dependencies: CreateAgentCommandDependencies,
cwd: string,
): Promise<string | undefined> {
if (!dependencies.ensureWorkspaceForCreate) {
return undefined;
}
return dependencies.ensureWorkspaceForCreate(cwd);
}
async function sendInitialPrompt(
dependencies: CreateAgentCommandDependencies,
resolved: ResolvedCreateAgent,

View File

@@ -388,6 +388,7 @@ test("importProviderSession imports a selected provider session without listing"
providerHandleId: "provider-thread-imported",
cwd,
},
workspaceId: "ws-imported",
agentManager,
agentStorage,
logger: { warn: vi.fn(), error: vi.fn() } as never,
@@ -398,6 +399,7 @@ test("importProviderSession imports a selected provider session without listing"
provider: "custom-codex",
providerHandleId: "provider-thread-imported",
cwd,
workspaceId: "ws-imported",
labels: undefined,
});
expect(scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
@@ -437,6 +439,7 @@ test("importProviderSession passes labels through the manager import operation",
cwd,
labels: { source: "import" },
},
workspaceId: "ws-imported",
agentManager,
agentStorage,
logger: { warn: vi.fn(), error: vi.fn() } as never,
@@ -446,6 +449,7 @@ test("importProviderSession passes labels through the manager import operation",
provider: "codex",
providerHandleId: "thread-imported",
cwd,
workspaceId: "ws-imported",
labels: { source: "import" },
});
});
@@ -460,6 +464,7 @@ test("importProviderSession requires cwd from the selected provider row", async
provider: "opencode",
providerHandleId: "thread-imported",
},
workspaceId: "ws-imported",
agentManager,
agentStorage: { list: vi.fn() } as unknown as AgentStorage,
logger: { warn: vi.fn(), error: vi.fn() } as never,

View File

@@ -62,6 +62,7 @@ export interface ListImportableProviderSessionsResult {
export interface ImportProviderSessionInput {
request: NormalizedImportAgentRequest;
workspaceId: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
@@ -163,6 +164,7 @@ export async function importProviderSession(
provider,
providerHandleId,
cwd,
workspaceId: input.workspaceId,
labels,
});
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);

View File

@@ -1448,7 +1448,7 @@ describe("create_agent MCP tool", () => {
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"),
findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"),
listActiveWorkspaces: vi.fn(async () => []),
archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds,
@@ -1532,7 +1532,7 @@ describe("create_agent MCP tool", () => {
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"),
findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"),
listActiveWorkspaces: vi.fn(async () => []),
archiveWorkspaceRecord: vi.fn(async () => undefined),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),

View File

@@ -93,13 +93,16 @@ export interface AgentMcpServerOptions {
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>;
resolveWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["resolveWorkspaceIdForCwd"];
findWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["findWorkspaceIdForCwd"];
listActiveWorkspaces?: ArchivePaseoWorktreeDependencies["listActiveWorkspaces"];
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
clearWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["clearWorkspaceArchiving"];
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh workspace for a cwd and returns its id, used when an agent is
// created with no parent and no worktree.
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
paseoHome?: string;
worktreesRoot?: string;
/**
@@ -572,6 +575,25 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
return expandUserPath(trimmedCwd);
};
async function resolveTerminalWorkspaceId(resolvedCwd: string): Promise<string> {
// An MCP-spawned terminal belongs to the caller agent's workspace. Only if
// the caller has no workspace do we mint one for the cwd.
const callerAgent = callerAgentId ? agentManager.getAgent(callerAgentId) : null;
if (callerAgent?.workspaceId) {
return callerAgent.workspaceId;
}
if (!options.ensureWorkspaceForCreate) {
throw new Error(
callerAgentId
? `Caller agent ${callerAgentId} has no workspace and workspace minting is not configured`
: "workspaceId is required outside an agent-scoped session",
);
}
return options.ensureWorkspaceForCreate(resolvedCwd);
}
const buildCallerAgentScheduleConfigExtras = (
callerAgent: NonNullable<ReturnType<typeof resolveCallerAgent>>,
): Record<string, unknown> => {
@@ -926,6 +948,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
terminalManager,
providerSnapshotManager,
createPaseoWorktree: options.createPaseoWorktree,
...(options.ensureWorkspaceForCreate
? { ensureWorkspaceForCreate: options.ensureWorkspaceForCreate }
: {}),
},
{
kind: "mcp",
@@ -1520,8 +1545,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
throw new Error("Terminal manager is not configured");
}
const resolvedCwd = resolveScopedCwd(cwd, { required: true });
const workspaceId = await resolveTerminalWorkspaceId(resolvedCwd);
const terminal = await terminalManager.createTerminal({
cwd: resolveScopedCwd(cwd, { required: true }),
cwd: resolvedCwd,
workspaceId,
...(name?.trim() ? { name: name.trim() } : {}),
});
@@ -2411,7 +2440,7 @@ function archiveWorktreeDependencies(
if (!options.archiveWorkspaceRecord) {
throw new Error("Workspace registry archiver is required to archive worktrees");
}
if (!options.resolveWorkspaceIdForCwd) {
if (!options.findWorkspaceIdForCwd) {
throw new Error("Workspace resolver is required to archive worktrees");
}
if (!options.listActiveWorkspaces) {
@@ -2433,7 +2462,7 @@ function archiveWorktreeDependencies(
workspaceGitService: options.workspaceGitService,
agentManager: context.agentManager,
agentStorage: context.agentStorage,
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
listActiveWorkspaces: options.listActiveWorkspaces,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,

View File

@@ -45,6 +45,63 @@ describe("MockLoadTestAgentClient", () => {
});
});
test("returns schema-shaped JSON for structured branch-name generation", async () => {
vi.useFakeTimers();
const client = new MockLoadTestAgentClient();
const session = await client.createSession({
provider: "mock",
cwd: process.cwd(),
model: "ten-second-stream",
});
const resultPromise = session.run(
[
"Generate a git branch name for a coding agent based on the user prompt and attachments.",
"Title: a short human-readable sentence-case label for the task (no slug rules, max 80 characters).",
"Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"User context:",
"Fix login bug",
].join("\n"),
);
await vi.advanceTimersByTimeAsync(0);
await expect(resultPromise).resolves.toMatchObject({
sessionId: session.id,
finalText: JSON.stringify({ title: "Fix login bug", branch: "fix-login-bug" }),
canceled: false,
});
});
test("returns schema-shaped JSON for structured agent-title generation", async () => {
vi.useFakeTimers();
const client = new MockLoadTestAgentClient();
const session = await client.createSession({
provider: "mock",
cwd: process.cwd(),
model: "ten-second-stream",
});
const resultPromise = session.run(
[
"Generate metadata for a coding agent based on the user prompt.",
"Title: short descriptive label (<= 80 chars).",
"Return JSON only with a single field 'title'.",
"",
"User prompt:",
"Fix login bug",
].join("\n"),
);
await vi.advanceTimersByTimeAsync(0);
await expect(resultPromise).resolves.toMatchObject({
sessionId: session.id,
finalText: JSON.stringify({ title: "Fix login bug" }),
canceled: false,
});
});
test("emits sub-word tokens, reasoning, and sequential tool calls during a foreground turn", async () => {
vi.useFakeTimers();
const client = new MockLoadTestAgentClient();

View File

@@ -270,6 +270,78 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre
};
}
function parseStructuredBranchNamePrompt(
prompt: AgentPromptInput,
): { title: string; branch: string } | null {
const text = promptToText(prompt);
const hasBranchNamePrompt =
text.includes("Generate a git branch name for a coding agent") &&
(text.includes("Return JSON only with fields 'title' and 'branch'.") ||
text.includes('"title"') ||
text.includes('"branch"'));
if (
!hasBranchNamePrompt &&
!(
text.includes("You must respond with JSON only that matches this JSON Schema") &&
text.includes('"title"') &&
text.includes('"branch"')
)
) {
return null;
}
const seed = text.split("User context:\n").at(-1)?.trim() ?? "";
const firstLine =
seed
.split("\n")
.find((line) => line.trim().length > 0)
?.trim() ?? "Mock task";
const title = firstLine
.replace(/^["'`]+|["'`]+$/g, "")
.replace(/\s+/g, " ")
.slice(0, 80)
.trim();
const branch =
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 100) || "mock-task";
return { title: title || "Mock task", branch };
}
function parseStructuredAgentTitlePrompt(prompt: AgentPromptInput): { title: string } | null {
const text = promptToText(prompt);
const hasAgentTitlePrompt =
text.includes("Generate metadata for a coding agent based on the user prompt.") &&
(text.includes("Return JSON only with a single field 'title'.") || text.includes('"title"'));
if (
!hasAgentTitlePrompt &&
!(
text.includes("You must respond with JSON only that matches this JSON Schema") &&
text.includes('"title"') &&
text.includes("User prompt:")
)
) {
return null;
}
const seed = text.split("User prompt:\n").at(-1)?.trim() ?? "";
const firstLine =
seed
.split("\n")
.find((line) => line.trim().length > 0)
?.trim() ?? "Mock task";
const title = firstLine
.replace(/^["'`]+|["'`]+$/g, "")
.replace(/\s+/g, " ")
.slice(0, 80)
.trim();
return { title: title || "Mock task" };
}
function buildRepeatedPayload(bytes: number, prefix: string): string {
const line = `${prefix} ${"x".repeat(96)}\n`;
let output = "";
@@ -605,7 +677,13 @@ export class MockLoadTestAgentSession implements AgentSession {
const largePayload = parseLargeAgentStreamPayloadPrompt(prompt);
const stress = parseAgentStreamStressPrompt(prompt);
const questionPrompt = parseMockQuestionPrompt(prompt);
if (shouldEmitPlanApprovalPrompt(prompt)) {
const structuredBranchName = parseStructuredBranchNamePrompt(prompt);
const structuredAgentTitle = parseStructuredAgentTitlePrompt(prompt);
if (structuredBranchName) {
this.scheduleStructuredJsonTurn(turn, structuredBranchName);
} else if (structuredAgentTitle) {
this.scheduleStructuredJsonTurn(turn, structuredAgentTitle);
} else if (shouldEmitPlanApprovalPrompt(prompt)) {
this.schedulePlanApprovalTurn(turn);
} else if (questionPrompt) {
this.scheduleQuestionPromptTurn(turn, questionPrompt);
@@ -791,6 +869,49 @@ export class MockLoadTestAgentSession implements AgentSession {
turn.timer.unref?.();
}
private scheduleStructuredJsonTurn(turn: ActiveTurn, result: Record<string, string>): void {
turn.timer = setTimeout(() => {
this.emitStructuredJsonTurn(turn, result);
}, 0);
turn.timer.unref?.();
}
private emitStructuredJsonTurn(turn: ActiveTurn, result: Record<string, string>): void {
if (this.activeTurn !== turn) {
return;
}
this.clearTurnTimer(turn);
this.emit({
type: "turn_started",
provider: this.provider,
turnId: turn.turnId,
});
const finalText = JSON.stringify(result);
this.emitTimeline(turn.turnId, {
type: "assistant_message",
text: finalText,
});
this.activeTurn = null;
this.emit({
type: "turn_completed",
provider: this.provider,
turnId: turn.turnId,
});
turn.resolve({
sessionId: this.id,
finalText,
timeline: [
{
type: "assistant_message",
text: finalText,
},
],
canceled: false,
});
}
private emitPlanApprovalTurn(turn: ActiveTurn): void {
if (this.activeTurn !== turn) {
return;

View File

@@ -1,13 +1,42 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { resolveStructuredGenerationProviders } from "./structured-generation-providers.js";
import type { ProviderSnapshotEntry } from "./agent-sdk-types.js";
const READY = "ready" as const;
const ERROR = "error" as const;
class ProviderSnapshots {
readonly calls: Array<{ cwd?: string; wait?: boolean }> = [];
constructor(private readonly entries: ProviderSnapshotEntry[]) {}
async listProviders(input: { cwd?: string; wait?: boolean } = {}) {
this.calls.push({ cwd: input.cwd, wait: input.wait });
return this.entries;
}
}
describe("resolveStructuredGenerationProviders", () => {
test("prefers configured providers, resolves dynamic defaults, and dedupes duplicates", async () => {
const listProviders = vi.fn(async () => [
test("uses explicit configured provider models without refreshing provider snapshots", async () => {
const snapshots = new ProviderSnapshots([]);
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
providerSnapshotManager: snapshots,
daemonConfig: {
metadataGeneration: {
providers: [{ provider: "mock", model: "ten-second-stream" }],
},
},
});
expect(providers).toEqual([{ provider: "mock", model: "ten-second-stream" }]);
expect(snapshots.calls).toEqual([]);
});
test("falls back to dynamic defaults and current selection when no provider is configured", async () => {
const snapshots = new ProviderSnapshots([
{
provider: "work-claude",
status: READY,
@@ -47,15 +76,7 @@ describe("resolveStructuredGenerationProviders", () => {
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
providerSnapshotManager: { listProviders },
daemonConfig: {
metadataGeneration: {
providers: [
{ provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" },
{ provider: "work-claude" },
],
},
},
providerSnapshotManager: snapshots,
currentSelection: {
provider: "focused-provider",
model: "focused-model",
@@ -64,36 +85,35 @@ describe("resolveStructuredGenerationProviders", () => {
});
expect(providers).toEqual([
{ provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" },
{ provider: "work-claude", model: "claude-haiku-2026" },
{ provider: "work-codex", model: "gpt-5.4-mini-2026", thinkingOptionId: "low" },
{ provider: "router", model: "minimax-m2.5-free" },
{ provider: "router", model: "nemotron-3-super-free" },
{ provider: "focused-provider", model: "focused-model", thinkingOptionId: "high" },
]);
expect(listProviders).toHaveBeenCalledWith({ cwd: "/tmp/repo", wait: true });
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
});
test("falls back to the current selection when defaults do not match", async () => {
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
providerSnapshotManager: {
listProviders: vi.fn(async () => [
const snapshots = new ProviderSnapshots([
{
provider: "current-provider",
status: READY,
enabled: true,
models: [
{
provider: "current-provider",
status: READY,
enabled: true,
models: [
{
provider: "current-provider",
id: "selected-model",
label: "Selected Model",
isDefault: true,
},
],
id: "selected-model",
label: "Selected Model",
isDefault: true,
},
]),
],
},
]);
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
providerSnapshotManager: snapshots,
currentSelection: {
provider: "current-provider",
model: "selected-model",
@@ -107,26 +127,26 @@ describe("resolveStructuredGenerationProviders", () => {
});
test("resolves a provider-only current selection to that provider's default model", async () => {
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
providerSnapshotManager: {
listProviders: vi.fn(async () => [
const snapshots = new ProviderSnapshots([
{
provider: "focused-provider",
status: READY,
enabled: true,
models: [
{
provider: "focused-provider",
status: READY,
enabled: true,
models: [
{
provider: "focused-provider",
id: "focused-default",
label: "Focused Default",
isDefault: true,
defaultThinkingOptionId: "balanced",
},
],
id: "focused-default",
label: "Focused Default",
isDefault: true,
defaultThinkingOptionId: "balanced",
},
]),
],
},
]);
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
providerSnapshotManager: snapshots,
currentSelection: { provider: "focused-provider" },
});
@@ -135,30 +155,30 @@ describe("resolveStructuredGenerationProviders", () => {
]);
});
test("normalizes nested OpenCode provider entries to the top-level provider and full model id", async () => {
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
providerSnapshotManager: {
listProviders: vi.fn(async () => [
test("uses explicit configured provider models as-is instead of waiting to normalize aliases", async () => {
const snapshots = new ProviderSnapshots([
{
provider: "opencode",
status: READY,
enabled: true,
models: [
{
provider: "opencode",
status: READY,
enabled: true,
models: [
{
provider: "opencode",
id: "plexus/small-fast",
label: "Small Fast",
isDefault: true,
metadata: {
providerId: "plexus",
modelId: "small-fast",
},
},
],
id: "plexus/small-fast",
label: "Small Fast",
isDefault: true,
metadata: {
providerId: "plexus",
modelId: "small-fast",
},
},
]),
],
},
]);
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
providerSnapshotManager: snapshots,
daemonConfig: {
metadataGeneration: {
providers: [{ provider: "plexus", model: "small-fast" }],
@@ -166,22 +186,23 @@ describe("resolveStructuredGenerationProviders", () => {
},
});
expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]);
expect(providers).toEqual([{ provider: "plexus", model: "small-fast" }]);
expect(snapshots.calls).toEqual([]);
});
test("keeps explicit candidates when provider snapshots are in error state", async () => {
const snapshots = new ProviderSnapshots([
{
provider: "current-provider",
status: ERROR,
enabled: true,
error: "timed out",
},
]);
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
providerSnapshotManager: {
listProviders: vi.fn(async () => [
{
provider: "current-provider",
status: ERROR,
enabled: true,
error: "timed out",
},
]),
},
providerSnapshotManager: snapshots,
daemonConfig: {
metadataGeneration: {
providers: [{ provider: "current-provider", model: "configured-model" }],
@@ -194,9 +215,7 @@ describe("resolveStructuredGenerationProviders", () => {
},
});
expect(providers).toEqual([
{ provider: "current-provider", model: "configured-model" },
{ provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" },
]);
expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]);
expect(snapshots.calls).toEqual([]);
});
});

View File

@@ -43,6 +43,23 @@ export interface ResolveStructuredGenerationProvidersOptions {
export async function resolveStructuredGenerationProviders(
options: ResolveStructuredGenerationProvidersOptions,
): Promise<StructuredGenerationProvider[]> {
const configuredProviders = readConfiguredProviders(options.daemonConfig);
if (configuredProviders.length > 0) {
const explicitProviders = resolveExplicitConfiguredProviders(configuredProviders);
if (explicitProviders.length === configuredProviders.length) {
return dedupeProviders(explicitProviders);
}
const providerEntries = await options.providerSnapshotManager.listProviders({
cwd: options.cwd,
wait: false,
});
const providers = resolveConfiguredProviders(configuredProviders, providerEntries);
if (providers.length > 0) {
return dedupeProviders(providers);
}
}
const providerEntries = await options.providerSnapshotManager.listProviders({
cwd: options.cwd,
wait: true,
@@ -52,7 +69,7 @@ export async function resolveStructuredGenerationProviders(
const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
const providers: StructuredGenerationProvider[] = [];
for (const configured of readConfiguredProviders(options.daemonConfig)) {
for (const configured of configuredProviders) {
const resolvedConfigured = resolveConfiguredCandidate(
configured,
modelEntries,
@@ -83,6 +100,42 @@ export async function resolveStructuredGenerationProviders(
return dedupeProviders(providers);
}
function resolveConfiguredProviders(
configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
providerEntries: readonly ProviderSnapshotEntry[],
): StructuredGenerationProvider[] {
const enabledEntries = providerEntries.filter((entry) => entry.enabled);
const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0);
const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
const providers: StructuredGenerationProvider[] = [];
for (const configured of configuredProviders) {
const resolved = resolveConfiguredCandidate(configured, modelEntries, entriesByProvider);
if (resolved) {
providers.push(resolved);
}
}
return providers;
}
function resolveExplicitConfiguredProviders(
configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
): StructuredGenerationProvider[] {
const providers: StructuredGenerationProvider[] = [];
for (const configured of configuredProviders) {
const provider = configured.provider.trim();
const model = configured.model?.trim();
if (!provider || !model) {
continue;
}
providers.push({
provider,
model,
...(configured.thinkingOptionId ? { thinkingOptionId: configured.thinkingOptionId } : {}),
});
}
return providers;
}
function resolveCurrentSelection(
selection: ResolveStructuredGenerationProvidersOptions["currentSelection"],
readyEntries: readonly ProviderSnapshotEntry[],

View File

@@ -93,7 +93,7 @@ function createHarness(overrides?: {
agentManager: {} as AutoArchiveArchiveOptions["agentManager"],
agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"],
terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"],
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
findWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
listActiveWorkspaces: vi.fn(async () => []),
archiveWorkspaceRecord: vi.fn(),
markWorkspaceArchiving: vi.fn(),

View File

@@ -25,7 +25,7 @@ export interface AutoArchiveArchiveOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager: TerminalManager;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
@@ -105,7 +105,7 @@ export async function archiveIfSafe(input: {
workspaceGitService: options.workspaceGitService,
agentManager: options.agentManager,
agentStorage: options.agentStorage,
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
listActiveWorkspaces: options.listActiveWorkspaces,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,

View File

@@ -89,7 +89,10 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null {
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { createGitHubService } from "../services/github-service.js";
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
import {
createPaseoWorktree as createRegisteredPaseoWorktree,
createLocalCheckoutWorkspace,
} from "./paseo-worktree-service.js";
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
@@ -110,7 +113,7 @@ import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore } from "./daemon-config-store.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import { resolveRegisteredWorkspaceIdForCwd } from "./workspace-directory.js";
import { resolveWorkspaceIdForPath } from "./workspace-ownership.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
import type { ActiveWorkspaceRef } from "./paseo-worktree-archive-service.js";
@@ -748,8 +751,17 @@ export async function createPaseoDaemon(
workspaceRegistry,
});
};
const resolveWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
return resolveRegisteredWorkspaceIdForCwd(cwd, await workspaceRegistry.list());
// external path→workspace adapter, not ownership: archive-by-path requests that
// arrive with a worktree path and no workspaceId (old clients / CLI).
const findWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
return resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list());
};
const ensureWorkspaceForCreateExternal = async (cwd: string): Promise<string> => {
const workspace = await createLocalCheckoutWorkspace(
{ cwd },
{ projectRegistry, workspaceRegistry, workspaceGitService },
);
return workspace.workspaceId;
};
const listActiveWorkspacesExternal = async (): Promise<ActiveWorkspaceRef[]> => {
const workspaces = await workspaceRegistry.list();
@@ -795,7 +807,7 @@ export async function createPaseoDaemon(
agentStorage,
terminalManager,
logger,
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
listActiveWorkspaces: listActiveWorkspacesExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
@@ -819,12 +831,13 @@ export async function createPaseoDaemon(
providerSnapshotManager,
github,
workspaceGitService,
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
listActiveWorkspaces: listActiveWorkspacesExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal,
createPaseoWorktree: async (input, serviceOptions) => {
return createPaseoWorktreeWorkflow(
{
@@ -850,13 +863,8 @@ export async function createPaseoDaemon(
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
);
void emitOptions;
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
await emitWorkspaceUpdatesExternal([workspaceId]);
},
cacheWorkspaceSetupSnapshot: () => {},
emit: emitExternalSessionMessage,

View File

@@ -0,0 +1,150 @@
import { homedir } from "node:os";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { AgentStorage } from "../agent/agent-storage.js";
import { createTestLogger } from "../../test-utils/test-logger.js";
import {
FileBackedWorkspaceRegistry,
createPersistedWorkspaceRecord,
} from "../workspace-registry.js";
import { backfillWorkspaceIdForLegacyAgents } from "./backfill-workspace-id.migration.js";
function workspaceRecord(
cwd: string,
workspaceId: string,
overrides?: { createdAt?: string; archivedAt?: string },
) {
return createPersistedWorkspaceRecord({
workspaceId,
projectId: workspaceId,
cwd,
kind: "directory",
displayName: path.basename(cwd) || cwd,
createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
archivedAt: overrides?.archivedAt ?? null,
});
}
describe("backfillWorkspaceIdForLegacyAgents", () => {
let home: string;
let agentStorage: AgentStorage;
let workspaceRegistry: FileBackedWorkspaceRegistry;
beforeEach(async () => {
home = mkdtempSync(path.join(tmpdir(), "paseo-backfill-"));
agentStorage = new AgentStorage(path.join(home, "agents"), createTestLogger());
await agentStorage.initialize();
workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(home, "workspaces.json"),
createTestLogger(),
);
await workspaceRegistry.initialize();
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
async function seedLegacyAgent(cwd: string, id: string): Promise<void> {
await agentStorage.upsert({
id,
provider: "codex",
cwd,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "closed",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
});
}
test("stamps the oldest exact-cwd workspace onto an unstamped legacy agent", async () => {
await workspaceRegistry.upsert(
workspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }),
);
await workspaceRegistry.upsert(
workspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00:00:00.000Z" }),
);
await seedLegacyAgent("/tmp/repo", "legacy-agent");
const migrated = await backfillWorkspaceIdForLegacyAgents({
agentStorage,
workspaceRegistry,
logger: createTestLogger(),
});
expect(migrated).toBe(1);
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-older");
});
test("attributes to the deepest enclosing workspace when there is no exact match", async () => {
await workspaceRegistry.upsert(workspaceRecord("/tmp/repo", "ws-root"));
await workspaceRegistry.upsert(workspaceRecord("/tmp/repo/packages/app", "ws-app"));
await seedLegacyAgent("/tmp/repo/packages/app/src", "legacy-agent");
await backfillWorkspaceIdForLegacyAgents({
agentStorage,
workspaceRegistry,
logger: createTestLogger(),
});
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBe("ws-app");
});
test("leaves already-stamped records untouched", async () => {
await workspaceRegistry.upsert(workspaceRecord("/tmp/repo", "ws-cwd"));
await agentStorage.upsert({
id: "stamped-agent",
provider: "codex",
cwd: "/tmp/repo",
workspaceId: "ws-explicit",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "closed",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
});
const migrated = await backfillWorkspaceIdForLegacyAgents({
agentStorage,
workspaceRegistry,
logger: createTestLogger(),
});
expect(migrated).toBe(0);
expect((await agentStorage.get("stamped-agent"))?.workspaceId).toBe("ws-explicit");
});
test("does not let the home directory own descendants", async () => {
const userHome = homedir();
await workspaceRegistry.upsert(workspaceRecord(userHome, "ws-home"));
await seedLegacyAgent(path.join(userHome, "repo"), "legacy-agent");
const migrated = await backfillWorkspaceIdForLegacyAgents({
agentStorage,
workspaceRegistry,
logger: createTestLogger(),
});
expect(migrated).toBe(0);
expect((await agentStorage.get("legacy-agent"))?.workspaceId).toBeUndefined();
});
});

View File

@@ -0,0 +1,88 @@
// COMPAT(workspaceIdBackfill): one-time legacy backfill, delete after 2026-12-16
// once floor >= the release that always stamps workspaceId at create time.
//
// This is the ONLY place in the codebase that maps a cwd to a workspaceId.
// Every other code path treats a record's `workspaceId` field as authoritative
// ownership. Legacy agent records persisted before workspaceId stamping have no
// owner, so this migration stamps each one with the workspace that owned its
// directory at the time it was written. It runs once at startup and writes the
// id back to storage, after which all runtime code can assume the field exists.
import { homedir } from "node:os";
import { resolve, sep } from "node:path";
import type { Logger } from "pino";
import type { AgentStorage } from "../agent/agent-storage.js";
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../workspace-registry.js";
// Picks the workspace that owned `cwd` for a legacy, unstamped agent record.
// Prefers an exact-cwd workspace (oldest wins) and otherwise attributes to the
// deepest enclosing workspace directory, never letting the home directory own
// descendants. Used only by the one-time backfill below.
function resolveLegacyWorkspaceOwner(
cwd: string,
workspaces: Iterable<PersistedWorkspaceRecord>,
): string | null {
const normalizedCwd = resolve(cwd);
const userHome = resolve(homedir());
const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt);
const exactMatches = activeWorkspaces.filter(
(workspace) => resolve(workspace.cwd) === normalizedCwd,
);
if (exactMatches.length > 0) {
return oldestWorkspace(exactMatches).workspaceId;
}
const prefixMatches = activeWorkspaces.filter((workspace) => {
const workspaceCwd = resolve(workspace.cwd);
if (workspaceCwd === userHome) {
return false;
}
return normalizedCwd === workspaceCwd || normalizedCwd.startsWith(`${workspaceCwd}${sep}`);
});
if (prefixMatches.length === 0) {
return null;
}
const deepestPrefixLength = Math.max(
...prefixMatches.map((workspace) => resolve(workspace.cwd).length),
);
return oldestWorkspace(
prefixMatches.filter((workspace) => resolve(workspace.cwd).length === deepestPrefixLength),
).workspaceId;
}
function oldestWorkspace(workspaces: PersistedWorkspaceRecord[]): PersistedWorkspaceRecord {
return workspaces.reduce((oldest, candidate) =>
candidate.createdAt < oldest.createdAt ? candidate : oldest,
);
}
export async function backfillWorkspaceIdForLegacyAgents(options: {
agentStorage: AgentStorage;
workspaceRegistry: WorkspaceRegistry;
logger: Logger;
}): Promise<number> {
const workspaceRecords = await options.workspaceRegistry.list();
const records = await options.agentStorage.list();
let migrated = 0;
for (const record of records) {
if (record.workspaceId) {
continue;
}
const workspaceId = resolveLegacyWorkspaceOwner(record.cwd, workspaceRecords);
if (!workspaceId) {
continue;
}
await options.agentStorage.upsert({ ...record, workspaceId });
migrated += 1;
}
if (migrated > 0) {
options.logger.info({ migrated }, "Backfilled workspaceId for legacy agent records");
}
return migrated;
}

View File

@@ -27,7 +27,10 @@ export interface ArchivePaseoWorktreeDependencies {
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "list">;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
// Resolves the worktree at a path to its workspaceId for archive-by-path. The
// path uniquely identifies a worktree workspace; this is a directory lookup for
// the archive target, not status/ownership.
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
// Active (non-archived) workspaces, used to decide whether the workspace being
// archived is the last reference to its backing worktree directory, and to
// break a same-cwd tie in favor of the worktree-kind record when archiving by
@@ -150,7 +153,7 @@ export async function archivePaseoWorktree(
async function resolveTargetWorkspaceId(
dependencies: Pick<
ArchivePaseoWorktreeDependencies,
"resolveWorkspaceIdForCwd" | "listActiveWorkspaces"
"findWorkspaceIdForCwd" | "listActiveWorkspaces"
>,
targetPath: string,
): Promise<string | null> {
@@ -162,7 +165,7 @@ async function resolveTargetWorkspaceId(
if (worktreeMatch) {
return worktreeMatch.workspaceId;
}
return dependencies.resolveWorkspaceIdForCwd(targetPath);
return dependencies.findWorkspaceIdForCwd(targetPath);
}
export type ArchiveWorkspaceContentsDependencies = Pick<

View File

@@ -162,8 +162,8 @@ import {
deriveProjectGroupingName,
deriveWorkspaceDisplayName,
generateWorkspaceId,
resolveWorkspaceIdForRecord,
} from "./workspace-registry-model.js";
import { resolveWorkspaceIdForPath } from "./workspace-ownership.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -241,6 +241,7 @@ import {
} from "../services/github-service.js";
import {
summarizeFetchWorkspacesEntries,
workspaceIdsOnCheckout,
WorkspaceDirectory,
type WorkspaceUpdatesFilter,
} from "./workspace-directory.js";
@@ -252,10 +253,6 @@ import {
type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import {
migrateLegacyAgentWorkspaceOwnership,
resolveLegacyWorkspaceOwnerForCwd,
} from "./workspace-registry-bootstrap.js";
import {
generateBranchNameFromFirstAgentContext,
type GeneratedWorkspaceName,
@@ -839,9 +836,6 @@ export class Session {
private readonly github: GitHubService;
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext;
// Resolves when the most recently scheduled background workspace-naming write
// completes. Tests await this (via the getter below) instead of sleeping.
private pendingWorkspaceNaming: Promise<void> = Promise.resolve();
private readonly workspaceGitService: WorkspaceGitService;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly mcpBaseUrl: string | null;
@@ -1006,7 +1000,7 @@ export class Session {
createPaseoWorktreeWorkflow: (input, workflowOptions) =>
this.createPaseoWorktreeWorkflow(input, workflowOptions),
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
@@ -1123,10 +1117,6 @@ export class Session {
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
}
async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable<string>): Promise<void> {
await Promise.all(Array.from(cwds, (cwd) => this.emitWorkspaceUpdateForCwd(cwd)));
}
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
await this.syncWorkspaceGitObserverForWorkspace(workspace);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
@@ -1664,7 +1654,7 @@ export class Session {
): Promise<PersistedWorkspaceRecord | null> {
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options);
const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(normalizedCwd, workspaces);
const workspaceId = resolveWorkspaceIdForPath(normalizedCwd, workspaces);
if (!workspaceId) {
return null;
}
@@ -1778,7 +1768,11 @@ export class Session {
}
}
await this.emitWorkspaceUpdateForCwd(payload.cwd);
// A lifecycle change updates exactly the agent's owning workspace, never
// every workspace sharing its cwd. Ownership is the agent's workspaceId.
if (payload.workspaceId) {
await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
}
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to emit agent update");
}
@@ -2403,9 +2397,9 @@ export class Session {
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
const knownCwd =
this.agentManager.getAgent(agentId)?.cwd ??
(await this.agentStorage.get(agentId))?.cwd ??
const knownWorkspaceId =
this.agentManager.getAgent(agentId)?.workspaceId ??
(await this.agentStorage.get(agentId))?.workspaceId ??
null;
// File-backed storage still needs an early delete fence before closeAgent().
@@ -2446,8 +2440,8 @@ export class Session {
});
}
if (knownCwd) {
await this.emitWorkspaceUpdateForCwd(knownCwd);
if (knownWorkspaceId) {
await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId);
}
}
@@ -2506,7 +2500,9 @@ export class Session {
agentId,
});
}
await this.emitWorkspaceUpdateForCwd(payload.cwd);
if (payload.workspaceId) {
await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
}
}
return { agentId, archivedAt };
@@ -3286,10 +3282,13 @@ export class Session {
const createAgentConfig: AgentSessionConfig = createdWorktree
? { ...config, cwd: createdWorktree.worktree.worktreePath }
: config;
// Ownership comes from an explicit id (worktree or request). An agent
// created with no explicit workspace mints a fresh one — we never resolve
// an existing workspace by cwd, because many workspaces may share a cwd.
const workspaceId =
createdWorktree?.workspace.workspaceId ??
msg.workspaceId ??
(await this.resolveLegacyWorkspaceOwnerForAgentCreate(createAgentConfig.cwd));
(await this.createWorkspaceForDirectory(createAgentConfig.cwd)).workspaceId;
const { snapshot, liveSnapshot } = await createAgentCommand(
{
@@ -3324,11 +3323,6 @@ export class Session {
);
createdAgentId = snapshot.id;
await this.forwardAgentUpdate(snapshot);
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
autoArchive,
agentId: snapshot.id,
createdWorktree,
});
if (!createdWorktree && trimmedPrompt) {
await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({
workspaceId,
@@ -3336,6 +3330,11 @@ export class Session {
firstAgentContext,
});
}
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
autoArchive,
agentId: snapshot.id,
createdWorktree,
});
if (requestId) {
const agentPayload = await this.buildAgentPayload(liveSnapshot);
this.emit({
@@ -3483,8 +3482,15 @@ export class Session {
);
try {
if (!normalized.cwd) {
throw new Error("Import requires cwd from the selected provider session");
}
// An imported agent mints its own workspace; ownership is its workspaceId,
// never an existing same-cwd workspace resolved by path.
const workspace = await this.createWorkspaceForDirectory(normalized.cwd);
const { snapshot, timelineSize } = await importProviderSession({
request: normalized,
workspaceId: workspace.workspaceId,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
workspaceGitService: this.workspaceGitService,
@@ -3493,7 +3499,7 @@ export class Session {
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
await this.registerWorkspaceForImportedAgent(snapshot.cwd);
await this.registerWorkspaceForImportedAgent(workspace);
const agentPayload = await this.buildAgentPayload(snapshot);
this.emit({
type: "status",
@@ -3718,7 +3724,7 @@ export class Session {
firstAgentContext: FirstAgentContext;
}): Promise<void> {
// Capture the generated title from the generator callback so we can write
// displayName := title after the branch rename completes.
// title := generated title after the branch rename completes.
let generatedTitle: string | null = null;
const result = await attemptFirstAgentBranchAutoName({
cwd: input.workspace.cwd,
@@ -3755,10 +3761,10 @@ export class Session {
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
}
// applyGeneratedWorkspaceTitle writes displayName := title (and, for a worktree
// rename, the new branch) for a workspace. It re-reads the current record from
// the registry so concurrent upserts that happened after workspace creation are
// not clobbered, and writes only its own fields (K4 fix).
// applyGeneratedWorkspaceTitle fills the generated title only while the
// workspace is still untitled. It re-reads the current record from the
// registry so concurrent upserts that happened after workspace creation are
// not clobbered, while still persisting branch metadata from the rename path.
private async applyGeneratedWorkspaceTitle(
workspaceId: string,
input: { title: string; branch?: string | null },
@@ -3769,7 +3775,7 @@ export class Session {
}
await this.workspaceRegistry.upsert({
...current,
displayName: input.title,
title: current.title || input.title,
...(input.branch ? { branch: input.branch } : {}),
updatedAt: new Date().toISOString(),
});
@@ -3812,18 +3818,15 @@ export class Session {
// K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing.
// Directory workspaces have no branch — write only the title.
await this.applyGeneratedWorkspaceTitle(input.workspaceId, { title });
await this.emitWorkspaceUpdateForCwd(input.cwd);
await this.emitWorkspaceUpdateForWorkspaceId(input.workspaceId);
}
private async scheduleAutoNameLocalWorkspaceTitleForFirstAgent(input: {
workspaceId?: string;
workspaceId: string;
cwd: string;
firstAgentContext: FirstAgentContext;
}): Promise<void> {
const workspaceId = input.workspaceId ?? (await this.resolveWorkspaceIdForCwd(input.cwd));
if (!workspaceId) {
return;
}
const workspaceId = input.workspaceId;
this.scheduleWorkspaceNaming(
() =>
this.maybeAutoNameDirectoryWorkspaceTitle({
@@ -5348,29 +5351,10 @@ export class Session {
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
// K3: persist the new git branch for all workspaces at this cwd. We write
// only branch+updatedAt — displayName holds the human title and title is the
// user override; a branch rename must touch neither (name and branch are
// decoupled fields by construction).
// Branch is a git fact derived per-descriptor from each workspace's own
// live git snapshot (id → cwd); the reconciliation pass re-persists the
// `branch` field per workspace from its own cwd. No cwd → ids fan-out here.
// TODO(K10): PR-binding on branch rename is deferred — see plan K10.
if (result.currentBranch) {
const allWorkspaces = await this.workspaceRegistry.list();
const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd(
cwd,
allWorkspaces,
);
await Promise.all(
workspaceIds.map(async (workspaceId) => {
const current = await this.workspaceRegistry.get(workspaceId);
if (!current) return;
await this.workspaceRegistry.upsert({
...current,
branch: result.currentBranch as string,
updatedAt: new Date().toISOString(),
});
}),
);
}
// Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher.
@@ -6074,7 +6058,7 @@ export class Session {
workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
@@ -6822,16 +6806,13 @@ export class Session {
return this.workspaceDirectory.buildDescriptorMap(options);
}
private resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string | null {
return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
}
private async resolveWorkspaceIdForCwd(cwd: string): Promise<string | null> {
// external path→workspace adapter, not ownership. Used by archive-by-path flows
// where the request carries a worktree path (unique to one workspace) rather
// than a workspaceId. This is a directory lookup for an archive target, not a
// status/ownership attribution.
private async findWorkspaceIdForCwd(cwd: string): Promise<string | null> {
const workspaces = await this.workspaceRegistry.list();
return this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
return resolveWorkspaceIdForPath(cwd, workspaces);
}
private matchesWorkspaceFilter(input: {
@@ -7324,32 +7305,21 @@ export class Session {
private async emitWorkspaceUpdateForTerminalContribution(
event: TerminalWorkspaceContributionChangedEvent,
): Promise<void> {
if (event.workspaceId) {
const workspaces = await this.workspaceRegistry.list();
const workspaceId = resolveWorkspaceIdForRecord(
{ workspaceId: event.workspaceId, cwd: event.cwd },
workspaces,
);
const owningWorkspace = workspaceId
? workspaces.find(
(workspace) => workspace.workspaceId === workspaceId && !workspace.archivedAt,
)
: null;
if (!owningWorkspace) {
return;
}
const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd(
owningWorkspace.cwd,
workspaces,
);
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, {
skipReconcile: true,
});
// A terminal's activity contributes only to the workspace it carries. A
// terminal with no workspaceId attributes to nothing — status is per-id.
if (!event.workspaceId) {
return;
}
await this.emitWorkspaceUpdateForCwd(event.cwd, { skipReconcile: true });
await this.emitWorkspaceUpdatesForWorkspaceIds([event.workspaceId], {
skipReconcile: true,
});
}
// A git fact (branch, diff, dirty, PR) changed at `cwd`. Every workspace whose
// OWN cwd is this folder re-derives its git facts from that folder (id → cwd)
// and emits its own per-id descriptor. This is a deliberate same-folder fan,
// not a cwd → id ownership lookup: git never resolves which workspace owns a
// path. See `workspaceIdsOnCheckout`.
private async emitWorkspaceUpdateForCwd(
cwd: string,
options?: {
@@ -7357,11 +7327,7 @@ export class Session {
dedupeGitState?: boolean;
},
): Promise<void> {
const workspaces = await this.workspaceRegistry.list();
const workspaceIds = this.workspaceDirectory.resolveRegisteredWorkspaceIdsForCwd(
cwd,
workspaces,
);
const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.list(), cwd);
if (workspaceIds.length === 0) {
return;
}
@@ -7594,15 +7560,16 @@ export class Session {
return { snapshotByWorkspaceId };
}
private async registerWorkspaceForImportedAgent(cwd: string): Promise<void> {
private async registerWorkspaceForImportedAgent(
workspace: PersistedWorkspaceRecord,
): Promise<void> {
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(cwd);
await this.syncWorkspaceGitObserverForWorkspace(workspace);
await this.describeWorkspaceRecord(workspace);
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
} catch (error) {
this.sessionLogger.warn(
{ err: error, cwd },
{ err: error, workspaceId: workspace.workspaceId, cwd: workspace.cwd },
"Failed to register workspace for imported agent",
);
}
@@ -7658,13 +7625,6 @@ export class Session {
return;
}
await migrateLegacyAgentWorkspaceOwnership({
agentStorage: this.agentStorage,
workspaceRegistry: this.workspaceRegistry,
logger: this.sessionLogger,
cwds: [cwd],
});
const workspace = await createLocalCheckoutWorkspace(
{ cwd, title: request.title ?? null },
{
@@ -7684,7 +7644,7 @@ export class Session {
error: null,
},
});
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
void this.workspaceGitService
.getSnapshot(workspace.cwd, { force: true, includeGitHub: true, reason: "open_project" })
.catch((error) => {
@@ -7693,7 +7653,7 @@ export class Session {
"Background snapshot refresh failed after workspace.create",
);
});
if (request.firstAgentContext?.prompt) {
if (request.firstAgentContext) {
const firstAgentContext = request.firstAgentContext;
this.scheduleWorkspaceNaming(
() =>
@@ -7707,34 +7667,17 @@ export class Session {
}
}
private async resolveLegacyWorkspaceOwnerForAgentCreate(
cwd: string,
): Promise<string | undefined> {
return resolveLegacyWorkspaceOwnerForCwd(cwd, await this.workspaceRegistry.list()) ?? undefined;
}
// Schedules a background workspace-naming write off the request path and
// records the resulting promise so tests can await completion deterministically
// (no wall-clock sleeps). The setTimeout(0) keeps the LLM call off the hot path.
// Schedules a background workspace-naming write off the request path. The
// setTimeout(0) keeps the LLM call off the hot path.
private scheduleWorkspaceNaming(
run: () => Promise<void>,
context: { cwd: string; message: string },
): void {
this.pendingWorkspaceNaming = new Promise<void>((resolveNaming) => {
setTimeout(() => {
void run()
.catch((error) => {
this.sessionLogger.warn({ err: error, cwd: context.cwd }, context.message);
})
.finally(resolveNaming);
}, 0);
});
}
// Test-only handle: resolves when the most recent scheduled workspace-naming
// write finishes, so tests await real completion instead of a wall-clock sleep.
get pendingWorkspaceNamingForTests(): Promise<void> {
return this.pendingWorkspaceNaming;
setTimeout(() => {
void run().catch((error) => {
this.sessionLogger.warn({ err: error, cwd: context.cwd }, context.message);
});
}, 0);
}
private async handleWorkspaceCreateWorktree(
@@ -7855,7 +7798,7 @@ export class Session {
const project = await this.projectRegistry.get(workspace.projectId);
await this.syncWorkspaceGitObserverForWorkspace(workspace);
const descriptor = await this.describeWorkspaceRecord(workspace);
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
this.sessionLogger.info(
{
requestedCwd,
@@ -8078,8 +8021,8 @@ export class Session {
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput),
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
this.workspaceSetupSnapshots.set(workspaceId, snapshot);
},
@@ -8145,7 +8088,7 @@ export class Session {
} finally {
this.clearWorkspaceArchiving([existing.workspaceId]);
}
await this.emitWorkspaceUpdateForCwd(existing.cwd);
await this.emitWorkspaceUpdateForWorkspaceId(existing.workspaceId);
this.emit({
type: "archive_workspace_response",
payload: {
@@ -8217,10 +8160,12 @@ export class Session {
throw new Error(`Workspace not found: ${requestedWorkspaceId}`);
}
const workspaceCwd = resolve(workspace.cwd);
// Clearing attention is scoped to the workspace that OWNS the agent, by
// workspaceId — never by comparing cwd strings. A sibling workspace
// sharing the same directory keeps its own agents' attention.
const clearableAgentIds = agents
.filter((agent) => !agent.archivedAt)
.filter((agent) => resolve(agent.cwd) === workspaceCwd)
.filter((agent) => agent.workspaceId === workspace.workspaceId)
.filter((agent) => agent.requiresAttention === true)
.filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0)
.filter((agent) => agent.attentionReason !== "permission")

View File

@@ -1,8 +1,8 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, vi } from "vitest";
import { afterEach, expect, test, vi } from "vitest";
import { z } from "zod";
import { Session } from "./session.js";
@@ -119,10 +119,6 @@ interface SessionTestAccess {
getAgentPayloadById(agentId: string): Promise<unknown>;
buildProjectPlacementForCwd(cwd: string): Promise<unknown>;
buildProjectPlacement(cwd: string): Promise<unknown>;
resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: ReturnType<typeof createPersistedWorkspaceRecord>[],
): string;
buildWorkspaceDescriptorMap(...args: unknown[]): Promise<Map<string, unknown>>;
describeWorkspaceRecord(...args: unknown[]): Promise<unknown>;
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<unknown>;
@@ -134,7 +130,6 @@ interface SessionTestAccess {
workspaceId: string,
input: { title: string; branch?: string | null },
): Promise<void>;
pendingWorkspaceNamingForTests: Promise<void>;
emit(message: unknown): void;
onMessage(message: unknown): void;
paseoHome: string;
@@ -783,142 +778,6 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
}
});
test("create_agent_request with an initial prompt renames the local workspace title", async () => {
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-local-title-"));
try {
const repoDir = path.join(workdir, "repo");
mkdirSync(repoDir, { 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),
);
const workspaceGitService = createNoopWorkspaceGitService({
getCheckout: async (cwd: string) => ({
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: null,
worktreeRoot: repoDir,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
});
await projectRegistry.upsert(
createPersistedProjectRecord({
projectId: "proj-local-title",
rootPath: repoDir,
kind: "git",
displayName: "repo",
createdAt: "2026-05-07T00:00:00.000Z",
updatedAt: "2026-05-07T00:00:00.000Z",
}),
);
await workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId: "ws-local-title",
projectId: "proj-local-title",
cwd: repoDir,
kind: "local_checkout",
displayName: "main",
branch: "main",
createdAt: "2026-05-07T00:00:00.000Z",
updatedAt: "2026-05-07T00:00:00.000Z",
}),
);
const emitted: SessionOutboundMessage[] = [];
const session = asTestSession(
new Session({
clientId: "test-client",
appVersion: null,
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
paseoHome: path.join(workdir, "paseo-home"),
agentManager,
agentStorage,
projectRegistry,
workspaceRegistry,
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: repoDir, files: [], error: null },
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
}),
dispose: () => {},
}),
workspaceGitService,
generateWorkspaceName: async () => ({
title: "Investigate Agent Health",
branch: null,
}),
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-local-title",
workspaceId: "ws-local-title",
config: { provider: "codex", cwd: repoDir },
initialPrompt: "Investigate the agent health dashboard",
attachments: [],
});
await session.pendingWorkspaceNamingForTests;
expect(findByType(emitted, "status")?.payload).toMatchObject({
status: "agent_created",
agent: { workspaceId: "ws-local-title" },
});
await expect(workspaceRegistry.get("ws-local-title")).resolves.toMatchObject({
displayName: "Investigate Agent Health",
branch: "main",
});
} finally {
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 = {
@@ -1350,6 +1209,7 @@ test("workspace clear attention clears stored-only agents and responds", async (
makeAgent({
id: storedRecord.id,
cwd: storedRecord.cwd,
workspaceId: workspace.workspaceId,
status: "closed",
updatedAt: storedRecord.updatedAt,
requiresAttention: true,
@@ -1458,16 +1318,18 @@ test("workspace clear attention can clear multiple workspaces in one request", a
storedRecords.set(storedRecord.id, storedRecord);
};
session.listAgentPayloads = async () =>
Array.from(storedRecords.values()).map((record) =>
makeAgent({
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",
@@ -2714,18 +2576,21 @@ test("branch/detached policies and dominant status bucket are deterministic", as
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,
@@ -2741,7 +2606,7 @@ test("branch/detached policies and dominant status bucket are deterministic", as
expect(result.entries[0]?.status).toBe("needs_input");
});
test("subdirectory agents map to an existing parent workspace descriptor", async () => {
test("subdirectory agents contribute to their owning workspace descriptor", async () => {
const session = createSessionForWorkspaceTests();
session.workspaceRegistry.list = async () => [
createPersistedWorkspaceRecord({
@@ -2754,10 +2619,13 @@ test("subdirectory agents map to an existing parent workspace descriptor", async
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",
}),
@@ -2771,7 +2639,7 @@ test("subdirectory agents map to an existing parent workspace descriptor", async
expect(result.entries).toHaveLength(1);
expect(result.entries[0]).toMatchObject({
id: "ws-repo-subdir",
status: "done",
status: "running",
activityAt: null,
});
});
@@ -4543,13 +4411,17 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
expect(descriptor.statusEnteredAt).toBeNull();
}
// 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 () => [
makeAgent({
owned({
id: "agent-done",
cwd: workspace.cwd,
status: "idle",
@@ -4567,7 +4439,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
const { session, workspace } = setupSession();
const updatedAt = "2026-05-12T09:45:00.000Z";
session.listAgentPayloads = async () => [
makeAgent({
owned({
id: "agent-initializing",
cwd: workspace.cwd,
status: "initializing",
@@ -4588,19 +4460,19 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
const runningUpdatedAt = "2026-05-12T10:00:00.000Z";
const needsInputUpdatedAt = "2026-05-12T10:15:00.000Z";
session.listAgentPayloads = async () => [
makeAgent({
owned({
id: "agent-done",
cwd: workspace.cwd,
status: "idle",
updatedAt: doneUpdatedAt,
}),
makeAgent({
owned({
id: "agent-running",
cwd: workspace.cwd,
status: "running",
updatedAt: runningUpdatedAt,
}),
makeAgent({
owned({
id: "agent-needs-input",
cwd: workspace.cwd,
status: "idle",
@@ -4620,7 +4492,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
const earlyUpdatedAt = "2026-05-12T08:00:00.000Z";
const lateUpdatedAt = "2026-05-12T08:30:00.000Z";
session.listAgentPayloads = async () => [
makeAgent({
owned({
id: "agent-done-early",
cwd: workspace.cwd,
status: "idle",
@@ -4634,13 +4506,13 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
// Second call: same winning bucket, newer agent updatedAt must not move
// the workspace bucket entry time forward.
session.listAgentPayloads = async () => [
makeAgent({
owned({
id: "agent-done-early",
cwd: workspace.cwd,
status: "idle",
updatedAt: earlyUpdatedAt,
}),
makeAgent({
owned({
id: "agent-done-late",
cwd: workspace.cwd,
status: "idle",
@@ -4661,13 +4533,13 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
const doneUpdatedAt = "2026-05-12T08:00:00.000Z";
const needsInputUpdatedAt = "2026-05-12T07:00:00.000Z";
session.listAgentPayloads = async () => [
makeAgent({
owned({
id: "agent-done",
cwd: workspace.cwd,
status: "idle",
updatedAt: doneUpdatedAt,
}),
makeAgent({
owned({
id: "agent-needs-input",
cwd: workspace.cwd,
status: "idle",
@@ -4680,7 +4552,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
// Drop the needs_input agent. The unmask time is "now", not doneUpdatedAt.
session.listAgentPayloads = async () => [
makeAgent({
owned({
id: "agent-done",
cwd: workspace.cwd,
status: "idle",
@@ -4699,7 +4571,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
const attentionTs = "2026-05-12T11:00:00.000Z";
const updatedAt = "2026-05-12T10:00:00.000Z";
session.listAgentPayloads = async () => [
makeAgent({
owned({
id: "agent-attention",
cwd: workspace.cwd,
status: "idle",
@@ -4716,7 +4588,7 @@ test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fi
}
});
test("same-cwd workspace descriptors share agent status by cwd", async () => {
test("same-cwd workspace descriptors compute agent status per workspaceId", async () => {
const session = createSessionForWorkspaceTests();
const project = createPersistedProjectRecord({
projectId: "proj-same-cwd-status",
@@ -4747,6 +4619,7 @@ test("same-cwd workspace descriptors share agent status by cwd", async () => {
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",
@@ -4758,8 +4631,9 @@ test("same-cwd workspace descriptors share agent status by cwd", async () => {
];
const runningDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
expect(runningDescriptors.get(workspaceA.workspaceId)?.status).toBe("running");
expect(runningDescriptors.get(workspaceB.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",
@@ -4773,7 +4647,7 @@ test("same-cwd workspace descriptors share agent status by cwd", async () => {
}),
];
const attentionDescriptors = await session.buildWorkspaceDescriptorMap({ includeGitData: false });
expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("attention");
expect(attentionDescriptors.get(workspaceA.workspaceId)?.status).toBe("done");
expect(attentionDescriptors.get(workspaceB.workspaceId)?.status).toBe("attention");
});
@@ -4805,6 +4679,7 @@ test("buildWorkspaceDescriptorMap keeps a done workspace recent after its agents
makeAgent({
id: "agent-done",
cwd: workspace.cwd,
workspaceId: workspace.workspaceId,
status: "idle",
updatedAt: doneEnteredAt,
}),
@@ -4819,6 +4694,7 @@ test("buildWorkspaceDescriptorMap keeps a done workspace recent after its agents
...makeAgent({
id: "agent-done",
cwd: workspace.cwd,
workspaceId: workspace.workspaceId,
status: "idle",
updatedAt: doneEnteredAt,
}),
@@ -5591,24 +5467,6 @@ test("workspace.title.set.request returns accepted=false when workspace is not f
expect(response?.payload.error).toBeTruthy();
});
test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a prefix", () => {
const session = createSessionForWorkspaceTests();
const home = homedir();
const childCwd = path.join(home, "projects/new-app");
const homeWorkspace = createPersistedWorkspaceRecord({
workspaceId: "ws-home",
projectId: "proj-home",
cwd: home,
kind: "directory",
displayName: "home",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBeNull();
expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe("ws-home");
});
function createSessionWithTerminalManager(options: {
workspaces: PersistedWorkspaceRecord[];
projects: PersistedProjectRecord[];
@@ -5770,7 +5628,7 @@ test("terminal activity contribution change updates the correct workspace", asyn
});
});
test("same-cwd terminal activity updates every workspace status bucket for that cwd", async () => {
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({
@@ -5816,25 +5674,22 @@ test("same-cwd terminal activity updates every workspace status bucket for that
message.payload.kind === "upsert" &&
message.payload.workspace.id === workspaceB.workspaceId &&
message.payload.workspace.status === "running",
"same-cwd terminal activity updates the stamped workspace",
);
await waitForWorkspaceUpdate(
emitted,
(message) =>
message.payload.kind === "upsert" &&
message.payload.workspace.id === workspaceA.workspaceId &&
message.payload.workspace.status === "running",
"same-cwd terminal activity updates the sibling workspace status bucket",
"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 upserts = updates.filter((update) => update.payload.kind === "upsert");
const targetIds = new Set(upserts.map((update) => update.payload.workspace.id));
expect(targetIds.has(workspaceB.workspaceId)).toBe(true);
expect(targetIds.has(workspaceA.workspaceId)).toBe(true);
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("nested worktree attribution targets the deepest active workspace", async () => {
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");
@@ -5872,7 +5727,13 @@ test("nested worktree attribution targets the deepest active workspace", async (
onMessage: (message) => emitted.push(message),
});
const terminal = await terminalManager.createTerminal({ cwd: terminalCwd });
// 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,
@@ -5880,23 +5741,17 @@ test("nested worktree attribution targets the deepest active workspace", async (
message.payload.kind === "upsert" &&
message.payload.workspace.id === workspaceWorktree.workspaceId &&
message.payload.workspace.status === "running",
"nested terminal activity targets the deepest workspace",
"worktree terminal activity targets its owning workspace",
);
const updates = filterByType(emitted, "workspace_update");
const upserts = updates.filter((update) => update.payload.kind === "upsert");
const targetIds = new Set(upserts.map((update) => update.payload.workspace.id));
// The terminal has no workspaceId, so it falls back to cwd attribution.
// The deepest active workspace covering `terminalCwd` should win.
expect(targetIds.has(workspaceWorktree.workspaceId)).toBe(true);
expect(targetIds.has(workspaceRoot.workspaceId)).toBe(false);
expect(upserts[upserts.length - 1]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: workspaceWorktree.workspaceId,
status: "running",
},
});
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 () => {
@@ -6004,10 +5859,43 @@ test("removing a contributing terminal clears workspace status", async () => {
// observable branch proves the request fields reached createWorktreeCore. We do
// not intercept the private workflow here.
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();
}
});
// K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing so a
// concurrent upsert that happened between workspace creation and the async name
// write is not clobbered.
test("applyGeneratedWorkspaceTitle writes only displayName and does not clobber concurrent title writes", async () => {
test("applyGeneratedWorkspaceTitle writes branch metadata and does not clobber concurrent title writes", async () => {
const session = createSessionForWorkspaceTests();
// The record at create-time: no title override.
@@ -6045,63 +5933,20 @@ test("applyGeneratedWorkspaceTitle writes only displayName and does not clobber
});
const saved = stored.get("ws-worktree-1");
// The generated title is written as the displayName.
expect(saved?.displayName).toBe("Generated Task Title");
// The branch-shaped display name stays branch-shaped.
expect(saved?.displayName).toBe("task-branch");
// The renamed branch is persisted into the dedicated branch field.
expect(saved?.branch).toBe("task-branch-renamed");
// The concurrent user-set title is NOT clobbered.
expect(saved?.title).toBe("User-set title");
});
// U8: Directory workspaces get a generated title when firstAgentContext has a prompt.
// The generation is workspace-level (title only — no branch rename for directory workspaces).
test("workspace.create.request directory source with firstAgentContext schedules title generation and writes displayName", async () => {
const emitted: SessionOutboundMessage[] = [];
// Inject the workspace-name generator so no LLM is called and we control the
// returned title. Directory workspaces generate a title only (no branch).
const session = createSessionForWorkspaceTests({
onMessage: (message) => emitted.push(message),
generateWorkspaceName: async () => ({ title: "Fix login bug", branch: null }),
});
// Track workspaceRegistry upserts so we can read the final stored displayName.
const stored = new Map<string, Record<string, unknown>>();
session.workspaceRegistry.upsert = async (record: unknown) => {
const r = record as Record<string, unknown>;
stored.set(r.workspaceId as string, r);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
(stored.get(workspaceId) ?? null) as unknown;
// Silence workspace update side-effects.
session.emitWorkspaceUpdateForCwd = async () => {};
session.emitWorkspaceUpdatesForWorkspaceIds = async () => {};
await session.handleMessage({
type: "workspace.create.request",
requestId: "req-dir-title",
source: { kind: "directory", path: REPO_CWD },
firstAgentContext: { prompt: "Fix the login bug so users can sign in", attachments: [] },
});
// Await the background title-generation write deterministically (no sleep):
// scheduleWorkspaceNaming records the pending write the test awaits here.
await session.pendingWorkspaceNamingForTests;
const response = emitted.find((m) => m.type === "workspace.create.response");
expect(response?.payload).toMatchObject({ requestId: "req-dir-title", error: null });
// displayName must be the generated human title, NOT the directory basename.
const saved = [...stored.values()].find((r) => r.kind === "directory") as
| Record<string, unknown>
| undefined;
expect(saved?.displayName).toBe("Fix login bug");
});
// K3: handleCheckoutRenameBranchRequest must persist the new git branch into the
// dedicated branch field and must NOT touch displayName (the human title) or the
// user-set title. branch and name are decoupled fields by construction.
test("checkout.rename_branch.request persists the new branch and leaves name/title untouched", async () => {
// 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({
@@ -6125,10 +5970,12 @@ test("checkout.rename_branch.request persists the new branch and leaves name/tit
});
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);
};
@@ -6148,11 +5995,10 @@ test("checkout.rename_branch.request persists the new branch and leaves name/tit
requestId: "req-rename-k3",
});
// K3: the new git branch must be persisted into the dedicated branch field.
// 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?.branch).toBe("feature/new-name");
// K3: rename must NOT touch displayName (the human name) or the user-set title.
expect(persisted?.displayName).toBe("Refactor auth flow");
expect(persisted?.title).toBe("Refactor auth flow");
});

View File

@@ -9,7 +9,6 @@ import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js";
import { resolveActiveWorkspaceRecordForCwd } from "./workspace-registry-model.js";
import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
@@ -570,6 +569,7 @@ export class VoiceAssistantWebSocketServer {
void this.broadcastTerminalAttention({
terminalId: event.terminalId,
cwd: event.cwd,
...(event.workspaceId ? { workspaceId: event.workspaceId } : {}),
terminalName: event.name,
reason,
}).catch((err) => {
@@ -1827,14 +1827,10 @@ export class VoiceAssistantWebSocketServer {
}
}
private async resolveWorkspaceIdForCwd(cwd: string): Promise<string | undefined> {
const workspaces = await this.workspaceRegistry.list();
return resolveActiveWorkspaceRecordForCwd(cwd, workspaces)?.workspaceId;
}
private async broadcastTerminalAttention(params: {
terminalId: string;
cwd: string;
workspaceId?: string;
terminalName: string;
reason: TerminalAttentionReason;
}): Promise<void> {
@@ -1852,7 +1848,7 @@ export class VoiceAssistantWebSocketServer {
const allStates = clientEntries.map((e) => e.state);
const nowMs = Date.now();
const workspaceId = await this.resolveWorkspaceIdForCwd(params.cwd);
const workspaceId = params.workspaceId;
const plan = computeNotificationPlan({
allStates,

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { createTestLogger } from "../test-utils/test-logger.js";
import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.js";
import { WorkspaceDirectory, resolveRegisteredWorkspaceIdsForCwd } from "./workspace-directory.js";
import { WorkspaceDirectory } from "./workspace-directory.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
@@ -91,7 +91,13 @@ class WorkspaceStatus {
});
hasRootAgent(input: AgentState): void {
this.agents.push(createAgent({ ...input, cwd: this.workspace.cwd }));
this.agents.push(
createAgent({
...input,
cwd: this.workspace.cwd,
workspaceId: this.workspace.workspaceId,
}),
);
}
hasSiblingWorkspaceSameCwd(): void {
@@ -99,8 +105,8 @@ class WorkspaceStatus {
}
// A root agent owned by a specific workspace, even though both same-cwd
// workspaces share the directory. Ownership follows workspaceId; aggregate
// status intentionally fans out to every workspace for that cwd.
// workspaces share the directory. Ownership follows workspaceId, and status is
// computed per id: only the owning workspace reflects this agent's bucket.
hasStampedRootAgent(input: AgentState & { workspaceId: string }): void {
this.agents.push(
createAgent({ ...input, cwd: this.workspace.cwd, workspaceId: input.workspaceId }),
@@ -112,6 +118,7 @@ class WorkspaceStatus {
createAgent({
...input,
cwd: this.workspace.cwd,
workspaceId: this.workspace.workspaceId,
labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" },
}),
);
@@ -126,6 +133,7 @@ class WorkspaceStatus {
createAgent({
...input,
cwd: this.worktreeWorkspace.cwd,
workspaceId: this.worktreeWorkspace.workspaceId,
labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" },
}),
);
@@ -150,13 +158,14 @@ class WorkspaceStatus {
hasWorkingTerminal(changedAt: number): void {
this.terminals.push({
cwd: this.workspace.cwd,
workspaceId: this.workspace.workspaceId,
activity: { state: "working", changedAt },
});
}
// A working terminal owned by a specific same-cwd workspace. Ownership follows
// workspaceId; aggregate status intentionally fans out to every workspace for
// that cwd.
// workspaceId, and status is computed per id: only the owning workspace
// reflects this terminal's activity.
hasStampedWorkingTerminal(input: { workspaceId: string; changedAt: number }): void {
this.terminals.push({
cwd: this.workspace.cwd,
@@ -165,9 +174,12 @@ class WorkspaceStatus {
});
}
// A terminal opened in a subdirectory still carries the owning workspace's id
// (stamped at creation); the subdir cwd is cosmetic, ownership is the id.
hasWorkingTerminalInSubdirectory(changedAt: number): void {
this.terminals.push({
cwd: `${this.workspace.cwd}/packages/app`,
workspaceId: this.workspace.workspaceId,
activity: { state: "working", changedAt },
});
}
@@ -175,6 +187,7 @@ class WorkspaceStatus {
hasIdleTerminal(changedAt: number): void {
this.terminals.push({
cwd: this.workspace.cwd,
workspaceId: this.workspace.workspaceId,
activity: { state: "idle", changedAt },
});
}
@@ -182,6 +195,7 @@ class WorkspaceStatus {
hasFinishedTerminal(changedAt: number): void {
this.terminals.push({
cwd: this.workspace.cwd,
workspaceId: this.workspace.workspaceId,
activity: { state: "idle", attentionReason: "finished", changedAt },
});
}
@@ -189,6 +203,7 @@ class WorkspaceStatus {
hasUnknownTerminal(): void {
this.terminals.push({
cwd: this.workspace.cwd,
workspaceId: this.workspace.workspaceId,
activity: null,
});
}
@@ -280,7 +295,7 @@ describe("WorkspaceDirectory", () => {
await expect(workspace.workspaceStatus()).resolves.toBe("running");
});
test("same-cwd workspaces share agent status buckets", async () => {
test("same-cwd workspaces attribute agent status only to the owner", async () => {
const workspace = new WorkspaceStatus();
workspace.hasSiblingWorkspaceSameCwd();
@@ -289,19 +304,16 @@ describe("WorkspaceDirectory", () => {
status: "running",
workspaceId: "workspace-1-sibling",
});
workspace.hasStampedRootAgent({
id: "agent-b",
status: "idle",
workspaceId: "workspace-1",
});
// The running agent belongs to the sibling; workspace-1 owns nothing active
// and stays done. Status never fans out across same-cwd workspaces.
await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "running",
"workspace-1": "done",
"workspace-1-sibling": "running",
});
});
test("same-cwd workspaces share agent attention buckets", async () => {
test("same-cwd workspaces attribute agent attention only to the owner", async () => {
const workspace = new WorkspaceStatus();
workspace.hasSiblingWorkspaceSameCwd();
@@ -313,24 +325,34 @@ describe("WorkspaceDirectory", () => {
});
await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "needs_input",
"workspace-1": "done",
"workspace-1-sibling": "needs_input",
});
});
test("same-cwd workspaces share legacy unstamped agent status buckets", async () => {
test("each same-cwd workspace reflects only its own agent", async () => {
const workspace = new WorkspaceStatus();
workspace.hasSiblingWorkspaceSameCwd();
workspace.hasRootAgent({ id: "legacy-agent", status: "running" });
workspace.hasStampedRootAgent({
id: "agent-a",
status: "running",
workspaceId: "workspace-1-sibling",
});
workspace.hasStampedRootAgent({
id: "agent-b",
status: "idle",
pendingPermissionCount: 1,
workspaceId: "workspace-1",
});
await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "running",
"workspace-1": "needs_input",
"workspace-1-sibling": "running",
});
});
test("same-cwd workspaces share terminal status buckets", async () => {
test("terminal status attributes only to the owning workspace", async () => {
const workspace = new WorkspaceStatus();
const changedAt = new Date(NOW).getTime();
@@ -338,7 +360,7 @@ describe("WorkspaceDirectory", () => {
workspace.hasStampedWorkingTerminal({ workspaceId: "workspace-1-sibling", changedAt });
await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "running",
"workspace-1": "done",
"workspace-1-sibling": "running",
});
});
@@ -530,71 +552,3 @@ describe("WorkspaceDirectory empty projects", () => {
expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]);
});
});
describe("resolveRegisteredWorkspaceIdsForCwd", () => {
const sharedCwd = "/workspace/project";
const workspace1: PersistedWorkspaceRecord = {
workspaceId: "ws-1",
projectId: "proj-1",
cwd: sharedCwd,
kind: "local_checkout",
displayName: "main",
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
};
const workspace2: PersistedWorkspaceRecord = {
workspaceId: "ws-2",
projectId: "proj-1",
cwd: sharedCwd,
kind: "local_checkout",
displayName: "main-2",
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
};
const workspace3: PersistedWorkspaceRecord = {
workspaceId: "ws-3",
projectId: "proj-2",
cwd: "/workspace/other",
kind: "local_checkout",
displayName: "other",
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
};
test("returns both ids when two workspaces share the same cwd", () => {
const result = resolveRegisteredWorkspaceIdsForCwd(sharedCwd, [
workspace1,
workspace2,
workspace3,
]);
expect(result.sort()).toEqual(["ws-1", "ws-2"]);
});
test("returns a single id when only one workspace matches the cwd", () => {
const result = resolveRegisteredWorkspaceIdsForCwd("/workspace/other", [
workspace1,
workspace2,
workspace3,
]);
expect(result).toEqual(["ws-3"]);
});
test("returns prefix-matched id when no exact cwd match exists", () => {
const subdir = `${sharedCwd}/packages/app`;
// workspace1 and workspace2 both have sharedCwd as a prefix; the plural
// resolver must return both when multiple workspaces share the best prefix.
const result = resolveRegisteredWorkspaceIdsForCwd(subdir, [workspace1, workspace2]);
expect(result.sort()).toEqual(["ws-1", "ws-2"]);
});
test("returns empty array when no workspace matches", () => {
const result = resolveRegisteredWorkspaceIdsForCwd("/unrelated/path", [workspace1, workspace2]);
expect(result).toEqual([]);
});
});

View File

@@ -1,5 +1,4 @@
import { homedir } from "node:os";
import { resolve, sep } from "node:path";
import { resolve } from "node:path";
import type pino from "pino";
import type {
AgentSnapshotPayload,
@@ -16,17 +15,11 @@ import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol
import { SortablePager } from "./pagination/sortable-pager.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { resolveProjectDisplayName } from "./workspace-registry.js";
import {
resolveActiveWorkspaceRecordForCwd,
resolveWorkspaceIdForRecord,
} from "./workspace-registry-model.js";
import {
deriveTerminalActivityStatusBucket,
type TerminalActivity,
} from "@getpaseo/protocol/terminal-activity";
type WorkspaceIdResolver = (cwd: string) => string | undefined;
const FETCH_WORKSPACES_SORT_KEYS = [
"status_priority",
"activity_at",
@@ -63,46 +56,6 @@ type WorkspaceProjectDescriptor = FetchWorkspacesResponsePayload["emptyProjects"
export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter;
export function resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string | null {
const ids = resolveRegisteredWorkspaceIdsForCwd(cwd, workspaces);
return ids[0] ?? null;
}
export function resolveRegisteredWorkspaceIdsForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string[] {
const resolvedCwd = resolve(cwd);
const exactMatches = workspaces.filter((workspace) => workspace.cwd === resolvedCwd);
if (exactMatches.length > 0) {
return exactMatches.map((workspace) => workspace.workspaceId);
}
const userHome = homedir();
let bestMatchLength = 0;
const prefixMatches: PersistedWorkspaceRecord[] = [];
for (const workspace of workspaces) {
if (workspace.cwd === userHome) continue;
if (workspace.archivedAt) continue;
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
if (!resolvedCwd.startsWith(prefix)) {
continue;
}
if (workspace.cwd.length > bestMatchLength) {
bestMatchLength = workspace.cwd.length;
prefixMatches.length = 0;
prefixMatches.push(workspace);
} else if (workspace.cwd.length === bestMatchLength) {
prefixMatches.push(workspace);
}
}
return prefixMatches.map((workspace) => workspace.workspaceId);
}
export interface WorkspaceDirectoryDeps {
logger: pino.Logger;
projectRegistry: {
@@ -159,6 +112,23 @@ export function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspace
};
}
/**
* Git facts (branch, diff, dirty, PR) belong to a checkout on disk, not to a
* workspace identity. Every workspace whose own cwd is that checkout re-derives
* its git facts from the same folder. This returns the ids of those workspaces
* so a git change can fan out to all of them. This is git-fact display, NOT
* ownership: do not use it to decide which workspace owns an arbitrary path.
*/
export function workspaceIdsOnCheckout(
workspaces: Iterable<PersistedWorkspaceRecord>,
cwd: string,
): string[] {
const resolvedCwd = resolve(cwd);
return Array.from(workspaces)
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === resolvedCwd)
.map((workspace) => workspace.workspaceId);
}
export class WorkspaceDirectory {
private readonly archivingByWorkspaceId = new Map<string, string>();
/**
@@ -232,9 +202,6 @@ export class WorkspaceDirectory {
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
const activeWorkspaceIds = new Set(activeRecords.map((workspace) => workspace.workspaceId));
const resolveActiveWorkspaceIdForCwd: WorkspaceIdResolver = (cwd) =>
resolveActiveWorkspaceRecordForCwd(cwd, activeRecords)?.workspaceId;
const includedWorkspaces = activeRecords.filter(
(workspace) => !workspaceIds || workspaceIds.has(workspace.workspaceId),
);
@@ -260,22 +227,17 @@ export class WorkspaceDirectory {
);
this.applyAgentBucketContributions({
activeAgents,
activeRecords,
activeWorkspaceIds,
descriptorsByWorkspaceId,
});
// Terminal activity contributions: working terminal → running bucket.
const terminalEntriesByWorkspaceId = this.applyTerminalContributions(
terminalContributions,
activeRecords,
resolveActiveWorkspaceIdForCwd,
descriptorsByWorkspaceId,
);
const contributingAgentsByWorkspaceId = groupAgentsByWorkspaceId(
activeAgents,
activeRecords,
activeWorkspaceIds,
);
@@ -305,15 +267,14 @@ export class WorkspaceDirectory {
}
// Aggregate each agent's state bucket into its owning workspace descriptor,
// keeping the highest-priority bucket. Delegated agents contribute to their
// delegation root's workspace; their own status is ignored unless running.
// keeping the highest-priority bucket. A record's owner IS its `workspaceId`;
// status never fans out to same-cwd siblings. Delegated agents contribute to
// their delegation root's workspace; their own status is ignored unless running.
private applyAgentBucketContributions(params: {
activeAgents: AgentSnapshotPayload[];
activeRecords: PersistedWorkspaceRecord[];
activeWorkspaceIds: ReadonlySet<string>;
descriptorsByWorkspaceId: Map<string, WorkspaceDescriptorPayload>;
}): void {
const { activeAgents, activeRecords, activeWorkspaceIds, descriptorsByWorkspaceId } = params;
const { activeAgents, descriptorsByWorkspaceId } = params;
const activeAgentsById = new Map(activeAgents.map((agent) => [agent.id, agent] as const));
for (const agent of activeAgents) {
@@ -338,70 +299,56 @@ export class WorkspaceDirectory {
});
}
const workspaceIds = resolveWorkspaceIdsForStatusContribution(workspaceAgent, activeRecords);
for (const workspaceId of workspaceIds) {
if (!activeWorkspaceIds.has(workspaceId)) {
continue;
}
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
}
const workspaceId = workspaceAgent.workspaceId;
if (!workspaceId) {
continue;
}
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
}
}
}
// Apply working terminal contributions to descriptor statuses and build a map
// of terminal timestamp entries per workspace for use in `resolveStatusEnteredAt`.
// A terminal contributes only to the workspace it carries; same-cwd siblings
// are untouched.
private applyTerminalContributions(
terminalContributions: Array<{
cwd: string;
workspaceId?: string;
activity: TerminalActivity | null;
}>,
activeRecords: PersistedWorkspaceRecord[],
resolveWorkspaceIdForCwd: WorkspaceIdResolver,
descriptorsByWorkspaceId: Map<string, WorkspaceDescriptorPayload>,
): Map<string, Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>> {
const terminalEntriesByWorkspaceId = new Map<
string,
Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>
>();
for (const { cwd, workspaceId: contributedWorkspaceId, activity } of terminalContributions) {
if (!activity) {
for (const { workspaceId, activity } of terminalContributions) {
if (!activity || !workspaceId) {
continue;
}
const bucket = deriveTerminalActivityStatusBucket(activity);
if (!bucket) continue;
const resolvedWorkspaceId = contributedWorkspaceId
? resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords)
: resolveWorkspaceIdForCwd(cwd);
const workspaceIds = resolvedWorkspaceId
? resolveWorkspaceIdsForStatusContribution(
{ workspaceId: resolvedWorkspaceId, cwd },
activeRecords,
)
: resolveWorkspaceIdsForStatusContribution({ cwd }, activeRecords);
for (const workspaceId of workspaceIds) {
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
}
const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() });
terminalEntriesByWorkspaceId.set(workspaceId, entries);
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
if (
getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status)
) {
existing.status = bucket;
}
const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? [];
entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() });
terminalEntriesByWorkspaceId.set(workspaceId, entries);
}
return terminalEntriesByWorkspaceId;
}
@@ -508,20 +455,6 @@ export class WorkspaceDirectory {
return candidates.at(-1) ?? null;
}
resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string | null {
return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
}
resolveRegisteredWorkspaceIdsForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string[] {
return resolveRegisteredWorkspaceIdsForCwd(cwd, workspaces);
}
// Project parents that have no active workspaces. These persist as first-class
// empty projects so the sidebar can render an empty project row with a
// "+ New workspace" affordance.
@@ -651,49 +584,21 @@ export class WorkspaceDirectory {
function groupAgentsByWorkspaceId(
agents: AgentSnapshotPayload[],
activeRecords: PersistedWorkspaceRecord[],
activeWorkspaceIds: ReadonlySet<string>,
): Map<string, AgentSnapshotPayload[]> {
const byWorkspaceId = new Map<string, AgentSnapshotPayload[]>();
for (const agent of agents) {
const workspaceIds = resolveWorkspaceIdsForStatusContribution(agent, activeRecords);
for (const workspaceId of workspaceIds) {
if (!activeWorkspaceIds.has(workspaceId)) {
continue;
}
const entries = byWorkspaceId.get(workspaceId) ?? [];
entries.push(agent);
byWorkspaceId.set(workspaceId, entries);
const workspaceId = agent.workspaceId;
if (!workspaceId || !activeWorkspaceIds.has(workspaceId)) {
continue;
}
const entries = byWorkspaceId.get(workspaceId) ?? [];
entries.push(agent);
byWorkspaceId.set(workspaceId, entries);
}
return byWorkspaceId;
}
function resolveWorkspaceIdsForStatusContribution(
record: { workspaceId?: string; cwd: string },
activeWorkspaces: PersistedWorkspaceRecord[],
): string[] {
const owningWorkspaceId = resolveWorkspaceIdForRecord(record, activeWorkspaces);
if (!owningWorkspaceId) {
const recordCwd = resolve(record.cwd);
return activeWorkspaces
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === recordCwd)
.map((workspace) => workspace.workspaceId);
}
const owningWorkspace = activeWorkspaces.find(
(workspace) => workspace.workspaceId === owningWorkspaceId && !workspace.archivedAt,
);
if (!owningWorkspace) {
return [];
}
const owningCwd = resolve(owningWorkspace.cwd);
return activeWorkspaces
.filter((workspace) => !workspace.archivedAt && resolve(workspace.cwd) === owningCwd)
.map((workspace) => workspace.workspaceId);
}
function resolveDelegationRootAgent(
agent: AgentSnapshotPayload,
activeAgentsById: ReadonlyMap<string, AgentSnapshotPayload>,

View File

@@ -0,0 +1,69 @@
import { homedir } from "node:os";
import { basename } from "node:path";
import { describe, expect, test } from "vitest";
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
import { resolveWorkspaceIdForPath } from "./workspace-ownership.js";
function createWorkspaceRecord(
cwd: string,
workspaceId: string,
overrides?: { createdAt?: string; archivedAt?: string },
) {
return createPersistedWorkspaceRecord({
workspaceId,
projectId: workspaceId,
cwd,
kind: "directory",
displayName: basename(cwd) || cwd,
createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
archivedAt: overrides?.archivedAt ?? null,
});
}
// resolveWorkspaceIdForPath is the external path→workspace adapter for
// archive-by-path and agent project-placement display. It is NOT ownership: a
// record's owner is its workspaceId. The per-id status law is exercised in
// workspace-directory.test.ts.
describe("resolveWorkspaceIdForPath", () => {
test("returns a single id when multiple workspaces share the exact cwd", () => {
const id = resolveWorkspaceIdForPath("/workspace/project", [
createWorkspaceRecord("/workspace/project", "ws-1"),
createWorkspaceRecord("/workspace/project", "ws-2"),
createWorkspaceRecord("/workspace/other", "ws-3"),
]);
expect(["ws-1", "ws-2"]).toContain(id);
});
test("resolves an exact archived workspace match for archive-by-path", () => {
expect(
resolveWorkspaceIdForPath("/workspace/project", [
createWorkspaceRecord("/workspace/project", "ws-archived", {
archivedAt: "2026-03-05T00:00:00.000Z",
}),
]),
).toEqual("ws-archived");
});
test("resolves the deepest enclosing workspace for a subdirectory path", () => {
expect(
resolveWorkspaceIdForPath("/workspace/project/packages/app", [
createWorkspaceRecord("/workspace/project", "ws-1"),
createWorkspaceRecord("/workspace", "ws-root"),
]),
).toEqual("ws-1");
});
test("does not match the home directory as a prefix", () => {
const home = homedir();
expect(
resolveWorkspaceIdForPath(`${home}/child`, [createWorkspaceRecord(home, "ws-home")]),
).toBeNull();
expect(resolveWorkspaceIdForPath(home, [createWorkspaceRecord(home, "ws-home")])).toEqual(
"ws-home",
);
});
});

View File

@@ -0,0 +1,46 @@
import { homedir } from "node:os";
import { resolve, sep } from "node:path";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
// external path→workspace adapter, not ownership.
//
// Resolves a raw filesystem path to a single workspace id for the two flows that
// receive a path rather than an id: archive-by-path (an old client / CLI passes a
// worktree path) and agent project-placement display (an agent's cwd, which may
// be a subdirectory of its workspace). This is NEVER used to attribute a record's
// status to a workspace — status is per `workspaceId`, and git facts derive from a
// workspace's OWN cwd (id → cwd).
//
// Resolution: an exact directory match wins; otherwise the deepest enclosing
// workspace directory, never the home directory; null when nothing encloses it.
export function resolveWorkspaceIdForPath(
cwd: string,
workspaces: Iterable<PersistedWorkspaceRecord>,
): string | null {
const workspaceRecords = Array.from(workspaces);
const resolvedCwd = resolve(cwd);
const exactMatch = workspaceRecords.find((workspace) => resolve(workspace.cwd) === resolvedCwd);
if (exactMatch) {
return exactMatch.workspaceId;
}
const userHome = resolve(homedir());
let bestMatchLength = 0;
let bestMatch: PersistedWorkspaceRecord | null = null;
for (const workspace of workspaceRecords) {
if (workspace.archivedAt) continue;
const workspaceCwd = resolve(workspace.cwd);
if (workspaceCwd === userHome) continue;
const prefix = workspaceCwd.endsWith(sep) ? workspaceCwd : `${workspaceCwd}${sep}`;
if (!resolvedCwd.startsWith(prefix)) {
continue;
}
if (workspaceCwd.length > bestMatchLength) {
bestMatchLength = workspaceCwd.length;
bestMatch = workspace;
}
}
return bestMatch?.workspaceId ?? null;
}

View File

@@ -101,6 +101,7 @@ function createWorkspaceGitServiceStub(
projectDisplayName: string;
workspaceDisplayName: string;
gitRemote?: string | null;
currentBranch?: string | null;
}
>,
) {
@@ -580,7 +581,7 @@ describe("WorkspaceReconciliationService", () => {
expect(projects.get("p1")!.customName).toBe("My Fork");
});
test("updates workspace display name when branch changes", async () => {
test("updates workspace branch metadata without clobbering the workspace name", async () => {
const dir = createTempGitRepo("reconcile-branch-");
tempDirs.push(dir);
@@ -606,7 +607,8 @@ describe("WorkspaceReconciliationService", () => {
projectId: "p1",
cwd: dir,
kind: "local_checkout",
displayName: "main",
displayName: "Human workspace title",
branch: "main",
createdAt: timestamp,
updatedAt: timestamp,
}),
@@ -621,6 +623,7 @@ describe("WorkspaceReconciliationService", () => {
projectKind: "git",
projectDisplayName: path.basename(dir),
workspaceDisplayName: "feature-branch",
currentBranch: "feature-branch",
},
}),
});
@@ -629,7 +632,12 @@ describe("WorkspaceReconciliationService", () => {
const wsUpdate = result.changesApplied.find((c) => c.kind === "workspace_updated");
expect(wsUpdate).toBeDefined();
expect(workspaces.get("w1")!.displayName).toBe("feature-branch");
expect(wsUpdate).toMatchObject({
kind: "workspace_updated",
fields: { branch: "feature-branch" },
});
expect(workspaces.get("w1")!.displayName).toBe("Human workspace title");
expect(workspaces.get("w1")!.branch).toBe("feature-branch");
});
test("does not modify already-archived records", async () => {

View File

@@ -50,7 +50,7 @@ export type ReconciliationChange =
kind: "workspace_updated";
workspaceId: string;
directory: string;
fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "displayName" | "kind">>;
fields: Partial<Pick<PersistedWorkspaceRecord, "projectId" | "branch" | "kind">>;
};
export interface ReconciliationResult {
@@ -343,11 +343,10 @@ export class WorkspaceReconciliationService {
const expectedKind = deriveWorkspaceKindFromMetadata(wsGit);
const workspaceUpdates: Partial<Pick<PersistedWorkspaceRecord, "displayName" | "kind">> =
{};
const workspaceUpdates: Partial<Pick<PersistedWorkspaceRecord, "branch" | "kind">> = {};
if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) {
workspaceUpdates.displayName = wsGit.workspaceDisplayName;
if (wsGit.projectKind === "git" && workspace.branch !== wsGit.currentBranch) {
workspaceUpdates.branch = wsGit.currentBranch;
}
if (workspace.kind !== expectedKind) {

View File

@@ -8,11 +8,11 @@ import {
classifyDirectoryForProjectMembership,
generateWorkspaceId,
} from "./workspace-registry-model.js";
import { backfillWorkspaceIdForLegacyAgents } from "./migrations/backfill-workspace-id.migration.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from "./workspace-registry.js";
@@ -45,71 +45,6 @@ function resolveAgentUpdatedAt(record: StoredAgentRecord): string {
return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString();
}
export function resolveLegacyWorkspaceOwnerForCwd(
cwd: string,
workspaces: Iterable<PersistedWorkspaceRecord>,
): string | null {
const normalizedCwd = path.resolve(cwd);
const activeWorkspaces = Array.from(workspaces).filter((workspace) => !workspace.archivedAt);
const exactMatches = activeWorkspaces.filter(
(workspace) => path.resolve(workspace.cwd) === normalizedCwd,
);
if (exactMatches.length > 0) {
return oldestWorkspace(exactMatches).workspaceId;
}
const prefixMatches = activeWorkspaces.filter((workspace) => {
const workspaceCwd = path.resolve(workspace.cwd);
return normalizedCwd === workspaceCwd || normalizedCwd.startsWith(`${workspaceCwd}${path.sep}`);
});
if (prefixMatches.length === 0) {
return null;
}
const longestPrefixLength = Math.max(
...prefixMatches.map((workspace) => path.resolve(workspace.cwd).length),
);
return oldestWorkspace(
prefixMatches.filter((workspace) => path.resolve(workspace.cwd).length === longestPrefixLength),
).workspaceId;
}
export async function migrateLegacyAgentWorkspaceOwnership(options: {
agentStorage: AgentStorage;
workspaceRegistry: WorkspaceRegistry;
logger: Logger;
cwds?: Iterable<string>;
}): Promise<number> {
const workspaceRecords = await options.workspaceRegistry.list();
const cwdFilter = options.cwds
? new Set(Array.from(options.cwds, (cwd) => path.resolve(cwd)))
: null;
const records = await options.agentStorage.list();
let migrated = 0;
for (const record of records) {
if (record.workspaceId) {
continue;
}
if (cwdFilter && !cwdFilter.has(path.resolve(record.cwd))) {
continue;
}
const workspaceId = resolveLegacyWorkspaceOwnerForCwd(record.cwd, workspaceRecords);
if (!workspaceId) {
continue;
}
await options.agentStorage.upsert({ ...record, workspaceId });
migrated += 1;
}
if (migrated > 0) {
options.logger.info({ migrated }, "Migrated legacy agent workspace ownership");
}
return migrated;
}
export async function bootstrapWorkspaceRegistries(options: {
paseoHome: string;
agentStorage: AgentStorage;
@@ -126,7 +61,7 @@ export async function bootstrapWorkspaceRegistries(options: {
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]);
if (projectsExists && workspacesExists) {
await migrateLegacyAgentWorkspaceOwnership(options);
await backfillWorkspaceIdForLegacyAgents(options);
return;
}
@@ -235,7 +170,7 @@ export async function bootstrapWorkspaceRegistries(options: {
),
);
await migrateLegacyAgentWorkspaceOwnership(options);
await backfillWorkspaceIdForLegacyAgents(options);
options.logger.info(
{
@@ -247,9 +182,3 @@ export async function bootstrapWorkspaceRegistries(options: {
"Workspace registries bootstrapped from existing agent storage",
);
}
function oldestWorkspace(workspaces: PersistedWorkspaceRecord[]): PersistedWorkspaceRecord {
return workspaces.reduce((oldest, candidate) =>
candidate.createdAt < oldest.createdAt ? candidate : oldest,
);
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { basename, isAbsolute, resolve } from "node:path";
import {
@@ -9,7 +9,6 @@ import {
deriveWorkspaceKind,
detectStaleWorkspaces,
generateWorkspaceId,
resolveWorkspaceIdForRecord,
} from "./workspace-registry-model.js";
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
@@ -62,18 +61,22 @@ describe("deriveProjectGroupingName", () => {
describe("detectStaleWorkspaces", () => {
test("returns workspace ids whose directories no longer exist", async () => {
const checkDirectoryExists = vi.fn(async (cwd: string) => cwd !== "/tmp/missing");
const checkedDirectories: string[] = [];
const existingDirectories = new Set(["/tmp/existing"]);
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [
createWorkspaceRecord("/tmp/existing", "ws-existing"),
createWorkspaceRecord("/tmp/missing", "ws-missing"),
],
checkDirectoryExists,
checkDirectoryExists: async (cwd) => {
checkedDirectories.push(cwd);
return existingDirectories.has(cwd);
},
});
expect(Array.from(staleWorkspaceIds)).toEqual(["ws-missing"]);
expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]);
expect(checkedDirectories).toEqual(["/tmp/existing", "/tmp/missing"]);
});
test("keeps workspaces whose directories exist even when all agents are archived", async () => {
@@ -232,65 +235,3 @@ describe("git worktree grouping", () => {
).toBe("worktree");
});
});
describe("resolveWorkspaceIdForRecord", () => {
test("resolves a stamped record to its workspaceId, ignoring cwd matches", () => {
const workspaces = [
createWorkspaceRecord("/tmp/repo", "ws-a"),
createWorkspaceRecord("/tmp/repo", "ws-b"),
];
const resolved = resolveWorkspaceIdForRecord(
{ workspaceId: "ws-b", cwd: "/tmp/repo" },
workspaces,
);
expect(resolved).toBe("ws-b");
});
test("falls back to the single cwd match for a legacy record without workspaceId", () => {
const workspaces = [
createWorkspaceRecord("/tmp/repo", "ws-only"),
createWorkspaceRecord("/tmp/other", "ws-other"),
];
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
expect(resolved).toBe("ws-only");
});
test("does not resolve an unstamped legacy record with multiple cwd matches", () => {
const workspaces = [
createWorkspaceRecord("/tmp/repo", "ws-newer", { createdAt: "2026-03-02T00:00:00.000Z" }),
createWorkspaceRecord("/tmp/repo", "ws-older", { createdAt: "2026-03-01T00:00:00.000Z" }),
];
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
expect(resolved).toBeNull();
});
test("returns null for a legacy record with no cwd match", () => {
const workspaces = [createWorkspaceRecord("/tmp/other", "ws-other")];
const resolved = resolveWorkspaceIdForRecord({ cwd: "/tmp/repo" }, workspaces);
expect(resolved).toBeNull();
});
test("does not move a stamped record to another workspace when its owner is archived", () => {
const workspaces = [
createWorkspaceRecord("/tmp/repo", "ws-archived", {
archivedAt: "2026-03-05T00:00:00.000Z",
}),
createWorkspaceRecord("/tmp/repo", "ws-live"),
];
const resolved = resolveWorkspaceIdForRecord(
{ workspaceId: "ws-archived", cwd: "/tmp/repo" },
workspaces,
);
expect(resolved).toBeNull();
});
});

View File

@@ -1,5 +1,4 @@
import { randomBytes } from "node:crypto";
import { homedir } from "node:os";
import { resolve } from "node:path";
import type {
@@ -7,7 +6,6 @@ import type {
ProjectPlacementPayload,
} from "@getpaseo/protocol/messages";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import { isSameOrDescendantPath } from "./path-utils.js";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
export type PersistedProjectKind = "git" | "non_git";
@@ -34,60 +32,6 @@ export function generateWorkspaceId(): string {
return `wks_${randomBytes(8).toString("hex")}`;
}
// COMPAT(workspaceOwnership): added in v0.1.97, drop after 2026-12-15 once floor >= v0.1.97.
// Resolves the owning workspace for a record (agent/terminal) that may predate
// workspaceId stamping. New records always carry workspaceId and hit the first
// branch; legacy records fall back only when cwd has a single active owner.
// Duplicate-cwd records must be stamped by migration before runtime projection;
// otherwise cwd-only membership leaks into every same-directory workspace.
export function resolveWorkspaceIdForRecord(
record: { workspaceId?: string; cwd: string },
activeWorkspaces: Iterable<PersistedWorkspaceRecord>,
): string | null {
const workspaces = Array.from(activeWorkspaces);
if (record.workspaceId) {
const exact = workspaces.find(
(workspace) => !workspace.archivedAt && workspace.workspaceId === record.workspaceId,
);
if (exact) {
return exact.workspaceId;
}
return null;
}
const resolvedCwd = resolve(record.cwd);
const cwdMatches = workspaces.filter(
(workspace) => !workspace.archivedAt && resolve(workspace.cwd) === resolvedCwd,
);
if (cwdMatches.length === 1) {
return cwdMatches[0].workspaceId;
}
return null;
}
export function resolveActiveWorkspaceRecordForCwd(
cwd: string,
workspaces: Iterable<PersistedWorkspaceRecord>,
): PersistedWorkspaceRecord | null {
const resolvedCwd = resolve(cwd);
const userHome = resolve(homedir());
let bestMatch: { workspace: PersistedWorkspaceRecord; cwd: string } | null = null;
for (const workspace of workspaces) {
if (workspace.archivedAt) continue;
const workspaceCwd = resolve(workspace.cwd);
if (workspaceCwd === userHome && resolvedCwd !== workspaceCwd) continue;
if (!isSameOrDescendantPath(workspaceCwd, resolvedCwd)) continue;
if (!bestMatch || workspaceCwd.length > bestMatch.cwd.length) {
bestMatch = { workspace, cwd: workspaceCwd };
}
}
return bestMatch?.workspace ?? null;
}
// Path-derived grouping key for a workspace directory. This is NOT the opaque
// workspace identity (see generateWorkspaceId); never persist or compare it as one.
export function deriveWorkspaceDirectoryKey(

View File

@@ -5,7 +5,10 @@ import path from "node:path";
import { DaemonClient } from "./test-utils/index.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestLogger } from "../test-utils/test-logger.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { getAskModeConfig } from "./daemon-e2e/agent-configs.js";
import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -13,10 +16,12 @@ import {
const WORKSPACE_A = "wks_same_cwd_a";
const WORKSPACE_B = "wks_same_cwd_b";
const LEGACY_OWNER_WORKSPACE = "wks_legacy_owner";
const PERMISSION_WAIT_MS = 15_000;
// Seed two active workspaces that share one cwd, so we can prove ownership
// stays workspaceId-scoped while aggregate status is cwd-scoped. Both
// registry files must exist on disk before the daemon starts:
// Seed two active workspaces that share one cwd, so we can prove agent
// ownership and status stay workspaceId-scoped — a sibling that owns nothing
// active stays done. Both registry files must exist on disk before the daemon starts:
// bootstrapWorkspaceRegistries skips materialization when both files are
// present, leaving these seeded records untouched.
function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } {
@@ -63,12 +68,291 @@ function seedSameCwdWorkspaces(): { paseoHomeRoot: string; cwd: string } {
return { paseoHomeRoot, cwd };
}
async function seedWorkspaceWithLegacyAgent(): Promise<{ paseoHomeRoot: string; cwd: string }> {
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-legacy-agent-home-"));
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-legacy-agent-dir-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const projectsDir = path.join(paseoHome, "projects");
mkdirSync(projectsDir, { recursive: true });
const project = createPersistedProjectRecord({
projectId: "prj_legacy_agent",
rootPath: cwd,
kind: "non_git",
displayName: path.basename(cwd),
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: LEGACY_OWNER_WORKSPACE,
projectId: project.projectId,
cwd,
kind: "directory",
displayName: "original",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify([project]));
writeFileSync(path.join(projectsDir, "workspaces.json"), JSON.stringify([workspace]));
const agentStorage = new AgentStorage(path.join(paseoHome, "agents"), createTestLogger());
await agentStorage.initialize();
await agentStorage.upsert({
id: "legacy-cwd-only-agent",
provider: "codex",
cwd,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
title: "Legacy cwd-only agent",
labels: {},
lastStatus: "running",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
});
return { paseoHomeRoot, cwd };
}
async function statusByWorkspaceId(client: DaemonClient): Promise<Map<string, string>> {
const workspaces = await client.fetchWorkspaces();
return new Map(workspaces.entries.map((entry) => [entry.id, entry.status]));
}
test("two workspaces sharing one cwd share agent status without leaking ownership", async () => {
async function workspaceName(client: DaemonClient, workspaceId: string): Promise<string | null> {
const workspaces = await client.fetchWorkspaces();
return workspaces.entries.find((entry) => entry.id === workspaceId)?.name ?? null;
}
async function legacyAgentWorkspaceId(client: DaemonClient): Promise<string | null | undefined> {
const agents = await client.fetchAgents({ scope: "active" });
return agents.entries.find((entry) => entry.agent.id === "legacy-cwd-only-agent")?.agent
.workspaceId;
}
async function agentIdsOwnedByWorkspace(
client: DaemonClient,
workspaceId: string,
): Promise<string[]> {
const agents = await client.fetchAgents({ scope: "active" });
return agents.entries
.filter((entry) => entry.agent.workspaceId === workspaceId)
.map((entry) => entry.agent.id);
}
async function waitForPermission(client: DaemonClient, agentId: string) {
const parked = await client.waitForFinish(agentId, PERMISSION_WAIT_MS);
expect(parked.status).toBe("permission");
return parked;
}
test("daemon bootstrap migrates cwd-only legacy agents before same-cwd workspaces are added", async () => {
const { paseoHomeRoot, cwd } = await seedWorkspaceWithLegacyAgent();
const daemon = await createTestPaseoDaemon({ paseoHomeRoot });
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
});
try {
await client.connect();
expect(await legacyAgentWorkspaceId(client)).toBe(LEGACY_OWNER_WORKSPACE);
expect(await statusByWorkspaceId(client)).toEqual(
new Map([[LEGACY_OWNER_WORKSPACE, "running"]]),
);
const created = await client.createWorkspace({
source: { kind: "directory", path: cwd, projectId: "prj_legacy_agent" },
title: "Fresh same-cwd workspace",
});
const createdWorkspaceId = created.workspace?.id;
if (!createdWorkspaceId) {
throw new Error(created.error ?? "Expected same-cwd workspace to be created");
}
// The migrated legacy agent stays owned by LEGACY_OWNER. The freshly created
// same-cwd workspace owns nothing, so its status is done — status is per id,
// never shared across same-cwd workspaces.
expect(await legacyAgentWorkspaceId(client)).toBe(LEGACY_OWNER_WORKSPACE);
expect(await agentIdsOwnedByWorkspace(client, LEGACY_OWNER_WORKSPACE)).toEqual([
"legacy-cwd-only-agent",
]);
expect(await agentIdsOwnedByWorkspace(client, createdWorkspaceId)).toEqual([]);
expect(await statusByWorkspaceId(client)).toEqual(
new Map([
[LEGACY_OWNER_WORKSPACE, "running"],
[createdWorkspaceId, "done"],
]),
);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
});
test("workspace.create directory source with firstAgentContext generates a daemon-visible workspace title", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-named-local-dir-"));
const daemon = await createTestPaseoDaemon({
agentClients: { mock: new MockLoadTestAgentClient() },
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
});
try {
await client.connect();
await client.patchDaemonConfig({
metadataGeneration: { providers: [{ provider: "mock", model: "ten-second-stream" }] },
});
const created = await client.createWorkspace({
source: { kind: "directory", path: cwd },
firstAgentContext: {
prompt: "Fix login bug",
attachments: [],
},
});
const workspaceId = created.workspace?.id;
if (!workspaceId) {
throw new Error(created.error ?? "Expected workspace to be created");
}
await expect
.poll(() => workspaceName(client, workspaceId), { timeout: 10_000 })
.toBe("Fix login bug");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 20_000);
test("create_agent_request with initialPrompt generates a daemon-visible workspace title", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-agent-submit-title-"));
const daemon = await createTestPaseoDaemon({
agentClients: { mock: new MockLoadTestAgentClient() },
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
});
try {
await client.connect();
await client.patchDaemonConfig({
metadataGeneration: { providers: [{ provider: "mock", model: "ten-second-stream" }] },
});
const created = await client.createWorkspace({
source: { kind: "directory", path: cwd },
});
const workspaceId = created.workspace?.id;
if (!workspaceId) {
throw new Error(created.error ?? "Expected workspace to be created");
}
const agent = await client.createAgent({
provider: "mock",
cwd,
workspaceId,
model: "ten-second-stream",
initialPrompt: "Fix login bug",
});
expect(agent.workspaceId).toBe(workspaceId);
await expect
.poll(() => workspaceName(client, workspaceId), { timeout: 10_000 })
.toBe("Fix login bug");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 20_000);
test("creating another same-cwd local workspace keeps running status on the owning workspace only", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-running-same-cwd-create-"));
const daemon = await createTestPaseoDaemon({
agentClients: { mock: new MockLoadTestAgentClient() },
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
});
try {
await client.connect();
const first = await client.createWorkspace({
source: { kind: "directory", path: cwd },
title: "First same-cwd workspace",
});
const firstWorkspaceId = first.workspace?.id;
if (!firstWorkspaceId) {
throw new Error(first.error ?? "Expected first workspace to be created");
}
const second = await client.createWorkspace({
source: { kind: "directory", path: cwd },
title: "Second same-cwd workspace",
});
const secondWorkspaceId = second.workspace?.id;
if (!secondWorkspaceId) {
throw new Error(second.error ?? "Expected second workspace to be created");
}
const agent = await client.createAgent({
provider: "mock",
cwd,
workspaceId: firstWorkspaceId,
model: "five-minute-stream",
initialPrompt: "stay running",
});
expect(agent.workspaceId).toBe(firstWorkspaceId);
await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000);
// Only the workspace that owns the agent is running. The same-cwd sibling
// owns nothing active and stays done — status never fans out across a cwd.
expect(await statusByWorkspaceId(client)).toEqual(
new Map([
[firstWorkspaceId, "running"],
[secondWorkspaceId, "done"],
]),
);
const third = await client.createWorkspace({
source: { kind: "directory", path: cwd },
title: "Third same-cwd workspace",
});
const thirdWorkspaceId = third.workspace?.id;
if (!thirdWorkspaceId) {
throw new Error(third.error ?? "Expected third workspace to be created");
}
expect((await client.fetchAgent(agent.id))?.agent.status).toBe("running");
expect(await statusByWorkspaceId(client)).toEqual(
new Map([
[firstWorkspaceId, "running"],
[secondWorkspaceId, "done"],
[thirdWorkspaceId, "done"],
]),
);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
test("two workspaces sharing one cwd compute agent status per workspaceId", async () => {
const { paseoHomeRoot, cwd } = seedSameCwdWorkspaces();
const daemon = await createTestPaseoDaemon({ paseoHomeRoot });
const client = new DaemonClient({
@@ -88,9 +372,9 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi
);
// 1. Agent created in workspace A carries workspaceId A. Ask mode + a
// write parks the agent on a pending permission, which contributes the
// deterministic "needs_input" aggregate signal to all same-cwd
// workspaces.
// write parks the agent on a pending permission, which drives the
// "needs_input" signal onto workspace A only — its same-cwd sibling B
// owns nothing and stays done.
const agentA = await client.createAgent({
...getAskModeConfig("codex"),
cwd,
@@ -103,8 +387,7 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi
agentA.id,
'Use your shell tool to run: `printf "ok" > a.txt`. Request permission and wait.',
);
const parkedA = await client.waitForFinish(agentA.id, 60000);
expect(parkedA.status).toBe("permission");
await waitForPermission(client, agentA.id);
const fetchedA = await client.fetchAgent(agentA.id);
expect(fetchedA?.agent.workspaceId).toBe(WORKSPACE_A);
@@ -112,27 +395,12 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi
expect(await statusByWorkspaceId(client)).toEqual(
new Map([
[WORKSPACE_A, "needs_input"],
[WORKSPACE_B, "needs_input"],
[WORKSPACE_B, "done"],
]),
);
// 2. Terminal created in A is delivered to A's directory subscription and to
// A's list, but never to B's — exercises the workspaceId terminal filter.
const terminalSnapshotsByWorkspace = new Map<string | undefined, Set<string>>();
const unsubscribeTerminals = client.on("terminals_changed", (message) => {
if (message.type !== "terminals_changed") {
return;
}
for (const terminal of message.payload.terminals) {
const seen = terminalSnapshotsByWorkspace.get(terminal.workspaceId) ?? new Set<string>();
seen.add(terminal.id);
terminalSnapshotsByWorkspace.set(terminal.workspaceId, seen);
}
});
client.subscribeTerminals({ cwd, workspaceId: WORKSPACE_A });
client.subscribeTerminals({ cwd, workspaceId: WORKSPACE_B });
// 2. Terminal created in A is visible in A's list, but never in B's —
// exercises the workspaceId terminal filter at the daemon boundary.
const createdTerminal = await client.createTerminal(cwd, "A terminal", undefined, {
workspaceId: WORKSPACE_A,
});
@@ -142,26 +410,15 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi
throw new Error("Expected a created terminal id");
}
// A's directory subscription receives a snapshot attributing the terminal
// to A. Poll the observed snapshot state instead of sleeping for a fixed
// window, so the assertion never races the daemon's snapshot push.
await expect
.poll(() => terminalSnapshotsByWorkspace.get(WORKSPACE_A)?.has(terminalId) ?? false)
.toBe(true);
unsubscribeTerminals();
// No snapshot ever attributed the terminal to B (the only other same-cwd
// workspace).
expect(terminalSnapshotsByWorkspace.get(WORKSPACE_B)?.has(terminalId) ?? false).toBe(false);
const listForA = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_A });
expect(listForA.terminals.some((terminal) => terminal.id === terminalId)).toBe(true);
const listForB = await client.listTerminals(cwd, undefined, { workspaceId: WORKSPACE_B });
expect(listForB.terminals.some((terminal) => terminal.id === terminalId)).toBe(false);
// 3. Agent created in workspace B carries workspaceId B while the aggregate
// needs_input status stays shared across both same-cwd workspace rows.
// 3. Agent created in workspace B carries workspaceId B and parks too. Now
// each workspace owns its own parked agent, so both read needs_input —
// by per-id ownership, not by sharing a cwd.
const agentB = await client.createAgent({
...getAskModeConfig("codex"),
cwd,
@@ -174,8 +431,7 @@ test("two workspaces sharing one cwd share agent status without leaking ownershi
agentB.id,
'Use your shell tool to run: `printf "ok" > b.txt`. Request permission and wait.',
);
const parkedB = await client.waitForFinish(agentB.id, 60000);
expect(parkedB.status).toBe("permission");
await waitForPermission(client, agentB.id);
const fetchedB = await client.fetchAgent(agentB.id);
expect(fetchedB?.agent.workspaceId).toBe(WORKSPACE_B);

View File

@@ -804,11 +804,19 @@ async function acquireWorkspaceScriptTerminal(params: {
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
terminalManager: TerminalManager;
repoRoot: string;
workspaceId: string;
scriptName: string;
env: Record<string, string> | undefined;
}): Promise<{ terminal: TerminalSession; reusableTerminal: TerminalSession | null }> {
const { serviceScript, existingRuntimeEntry, terminalManager, repoRoot, scriptName, env } =
params;
const {
serviceScript,
existingRuntimeEntry,
terminalManager,
repoRoot,
workspaceId,
scriptName,
env,
} = params;
let reusableTerminal: TerminalSession | null = null;
if (!serviceScript && existingRuntimeEntry?.terminalId) {
reusableTerminal = terminalManager.getTerminal(existingRuntimeEntry.terminalId) ?? null;
@@ -817,6 +825,7 @@ async function acquireWorkspaceScriptTerminal(params: {
reusableTerminal ??
(await terminalManager.createTerminal({
cwd: repoRoot,
workspaceId,
name: scriptName,
title: scriptName,
env,
@@ -892,6 +901,7 @@ export async function spawnWorkspaceScript(
existingRuntimeEntry,
terminalManager,
repoRoot,
workspaceId,
scriptName,
env,
});

View File

@@ -104,7 +104,7 @@ function createWorkflowForRequestTest(options: {
paseoHome: options.paseoHome,
createPaseoWorktree,
warmWorkspaceGitData: options.warmWorkspaceGitData ?? (async () => {}),
emitWorkspaceUpdateForCwd: async () => {},
emitWorkspaceUpdateForWorkspaceId: async () => {},
cacheWorkspaceSetupSnapshot: () => {},
emit: () => {},
sessionLogger: createLogger(),
@@ -428,7 +428,7 @@ describe("create-agent worktree setup boundary", () => {
paseoHome,
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
warmWorkspaceGitData: async () => {},
emitWorkspaceUpdateForCwd: async () => {},
emitWorkspaceUpdateForWorkspaceId: async () => {},
cacheWorkspaceSetupSnapshot: () => {},
emit: (message) => workspaceSetupEvents.push(message),
sessionLogger: createLogger(),
@@ -497,7 +497,7 @@ function createAgentStorageStub(): Pick<AgentStorage, "list"> {
function createWorkspaceArchivingDeps() {
return {
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"),
findWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"),
listActiveWorkspaces: vi.fn(async () => []),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
markWorkspaceArchiving: vi.fn(),
@@ -588,13 +588,13 @@ describe("runWorktreeSetupInBackground", () => {
const snapshots = new Map<string, unknown>();
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await runWorktreeSetupInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
emitWorkspaceUpdateForWorkspaceId,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
@@ -659,7 +659,7 @@ describe("runWorktreeSetupInBackground", () => {
expect(terminalManager.terminals).toHaveLength(0);
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("42");
});
test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => {
@@ -686,14 +686,14 @@ describe("runWorktreeSetupInBackground", () => {
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const logger = createLogger();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
const workspaceId = "ws-broken-feature";
await runWorktreeSetupInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
emitWorkspaceUpdateForWorkspaceId,
cacheWorkspaceSetupSnapshot: (snapshotWorkspaceId, snapshot) =>
snapshots.set(snapshotWorkspaceId, snapshot),
emit: (message) => emitted.push(message),
@@ -732,7 +732,7 @@ describe("runWorktreeSetupInBackground", () => {
error: expect.stringMatching(/Failed to parse paseo\.json at .*paseo\.json/),
});
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId);
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith(workspaceId);
});
// POSIX-only: setup command is hardcoded to sh, printf, and sleep.
@@ -761,13 +761,13 @@ describe("runWorktreeSetupInBackground", () => {
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const logger = createLogger();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await runWorktreeSetupInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
emitWorkspaceUpdateForWorkspaceId,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
@@ -885,13 +885,13 @@ describe("runWorktreeSetupInBackground", () => {
const snapshots = new Map<string, unknown>();
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await runWorktreeSetupInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
emitWorkspaceUpdateForWorkspaceId,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
@@ -947,7 +947,7 @@ describe("runWorktreeSetupInBackground", () => {
error: null,
});
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath);
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("44");
});
test("keeps setup completed without attempting script launch afterward", async () => {
@@ -980,13 +980,13 @@ describe("runWorktreeSetupInBackground", () => {
throw new Error("terminal spawn failed");
},
});
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await runWorktreeSetupInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
emitWorkspaceUpdateForWorkspaceId,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
@@ -1033,7 +1033,7 @@ describe("runWorktreeSetupInBackground", () => {
error: null,
});
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("45");
});
test("does not auto-start scripts in socket mode", async () => {
@@ -1062,13 +1062,13 @@ describe("runWorktreeSetupInBackground", () => {
const snapshots = new Map<string, unknown>();
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await runWorktreeSetupInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
emitWorkspaceUpdateForWorkspaceId,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
@@ -1096,7 +1096,7 @@ describe("runWorktreeSetupInBackground", () => {
error: null,
});
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("46");
});
test("returns the cached workspace setup snapshot for status requests", async () => {
@@ -1816,7 +1816,7 @@ describe("archivePaseoWorktree", () => {
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
),
killTerminalsForWorkspace,
@@ -1924,7 +1924,7 @@ describe("archivePaseoWorktree", () => {
archivedWorkspaceRecords.push(id);
},
...createWorkspaceArchivingDeps(),
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === sharedCwd ? workspaceA : null,
),
killTerminalsForWorkspace: (workspaceId) =>
@@ -2030,7 +2030,7 @@ describe("archivePaseoWorktree", () => {
archiveSnapshot,
},
agentStorage: createAgentStorageStub(),
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
),
listActiveWorkspaces: vi.fn(async () => []),
@@ -2135,7 +2135,7 @@ describe("archivePaseoWorktree", () => {
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord,
...createWorkspaceArchivingDeps(),
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
),
killTerminalsForWorkspace: vi.fn(async () => {}),
@@ -2184,7 +2184,7 @@ describe("archivePaseoWorktree", () => {
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? "ws-archive-refresh" : null,
),
killTerminalsForWorkspace: vi.fn(async () => {}),
@@ -2233,7 +2233,7 @@ describe("archivePaseoWorktree", () => {
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
),
// No other active workspace references the worktree dir.
@@ -2285,7 +2285,7 @@ describe("archivePaseoWorktree", () => {
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === sharedCwd ? workspaceA : null,
),
// Sibling workspace B still references the same worktree directory.
@@ -2336,7 +2336,7 @@ describe("archivePaseoWorktree", () => {
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async () => {}),
...createWorkspaceArchivingDeps(),
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
findWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
),
listActiveWorkspaces,

View File

@@ -97,7 +97,7 @@ interface BuildAgentSessionConfigDependencies {
interface CreatePaseoWorktreeInBackgroundDependencies {
paseoHome?: string;
worktreesRoot?: string;
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise<void>;
cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void;
emit: EmitSessionMessage;
sessionLogger: Logger;
@@ -764,6 +764,6 @@ export async function runWorktreeSetupInBackground(
return;
}
} finally {
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
await dependencies.emitWorkspaceUpdateForWorkspaceId(options.workspaceId);
}
}

View File

@@ -64,7 +64,7 @@ it("returns empty list for new cwd", async () => {
it("returns existing terminals on subsequent calls", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const created = await manager.createTerminal({ cwd });
const created = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
const first = await manager.getTerminals(cwd);
const second = await manager.getTerminals(cwd);
@@ -89,8 +89,8 @@ it("creates separate terminals for different cwds", async () => {
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-first-"));
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-second-"));
temporaryDirs.push(firstCwd, secondCwd);
const tmpTerminals = [await manager.createTerminal({ cwd: firstCwd })];
const homeTerminals = [await manager.createTerminal({ cwd: secondCwd })];
const tmpTerminals = [await manager.createTerminal({ cwd: firstCwd, workspaceId: "ws-test" })];
const homeTerminals = [await manager.createTerminal({ cwd: secondCwd, workspaceId: "ws-test" })];
expect(tmpTerminals.length).toBe(1);
expect(homeTerminals.length).toBe(1);
@@ -104,7 +104,11 @@ it("lists subdirectory terminals when querying the workspace root", async () =>
mkdirSync(subdirCwd, { recursive: true });
temporaryDirs.push(rootCwd);
const created = await manager.createTerminal({ cwd: subdirCwd, name: "Mobile" });
const created = await manager.createTerminal({
cwd: subdirCwd,
name: "Mobile",
workspaceId: "ws-test",
});
const rootTerminals = await manager.getTerminals(rootCwd);
expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]);
@@ -113,7 +117,7 @@ it("lists subdirectory terminals when querying the workspace root", async () =>
it("includes only stamped terminals in workspace-scoped queries", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const legacy = await manager.createTerminal({ cwd });
const legacy = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
const owned = await manager.createTerminal({ cwd, workspaceId: "ws-owned" });
const sibling = await manager.createTerminal({ cwd, workspaceId: "ws-sibling" });
@@ -127,8 +131,8 @@ it("includes only stamped terminals in workspace-scoped queries", async () => {
it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
await manager.createTerminal({ cwd });
const second = await manager.createTerminal({ cwd });
await manager.createTerminal({ cwd, workspaceId: "ws-test" });
const second = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
expect(second.name).toBe("Terminal 2");
@@ -138,7 +142,11 @@ it("creates additional terminal with auto-incrementing name", async () => {
it("uses custom name when provided", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()), name: "Dev Server" });
const session = await manager.createTerminal({
cwd: realpathSync(tmpdir()),
name: "Dev Server",
workspaceId: "ws-test",
});
expect(session.name).toBe("Dev Server");
});
@@ -146,7 +154,7 @@ it("uses custom name when provided", async () => {
it("creates first terminal if none exist", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const session = await manager.createTerminal({ cwd });
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
expect(session.name).toBe("Terminal 1");
@@ -157,7 +165,9 @@ it("creates first terminal if none exist", async () => {
it("throws for relative paths", async () => {
manager = createTerminalManager();
await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path");
await expect(manager.createTerminal({ cwd: "tmp", workspaceId: "ws-test" })).rejects.toThrow(
"cwd must be absolute path",
);
});
it("does not reject Windows absolute paths as relative", async () => {
@@ -166,7 +176,7 @@ it("does not reject Windows absolute paths as relative", async () => {
temporaryDirs.push(cwd);
try {
await manager.createTerminal({ cwd });
await manager.createTerminal({ cwd, workspaceId: "ws-test" });
} catch (error) {
expect((error as Error).message).not.toBe("cwd must be absolute path");
}
@@ -183,6 +193,7 @@ it("inherits registered env for the worktree root cwd", async () => {
env: { PASEO_WORKTREE_PORT: "45678" },
});
await manager.createTerminal({
workspaceId: "ws-test",
cwd,
command: process.execPath,
args: [
@@ -208,6 +219,7 @@ it("inherits registered env for subdirectories within the worktree", async () =>
env: { PASEO_WORKTREE_PORT: "45679" },
});
await manager.createTerminal({
workspaceId: "ws-test",
cwd: subdirCwd,
command: process.execPath,
args: [
@@ -222,7 +234,10 @@ it("inherits registered env for subdirectories within the worktree", async () =>
it("returns terminal by id", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
const session = await manager.createTerminal({
cwd: realpathSync(tmpdir()),
workspaceId: "ws-test",
});
const found = manager.getTerminal(session.id);
expect(found).toBe(session);
@@ -237,7 +252,10 @@ it("returns undefined for unknown id", () => {
it("removes terminal from manager", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
const session = await manager.createTerminal({
cwd: realpathSync(tmpdir()),
workspaceId: "ws-test",
});
const id = session.id;
manager.killTerminal(id);
@@ -248,7 +266,7 @@ it("removes terminal from manager", async () => {
it("removes cwd entry when last terminal is killed", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const created = await manager.createTerminal({ cwd });
const created = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
manager.killTerminal(created.id);
const remaining = await manager.getTerminals(cwd);
@@ -259,8 +277,8 @@ it("removes cwd entry when last terminal is killed", async () => {
it("keeps cwd entry when other terminals remain", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
await manager.createTerminal({ cwd });
const second = await manager.createTerminal({ cwd });
await manager.createTerminal({ cwd, workspaceId: "ws-test" });
const second = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
const terminals = await manager.getTerminals(cwd);
manager.killTerminal(terminals[0].id);
@@ -279,7 +297,7 @@ it("is no-op for unknown id", () => {
it("auto-removes terminal when shell exits", async () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
const session = await manager.createTerminal({ cwd });
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
const exitedId = session.id;
session.kill();
@@ -301,8 +319,8 @@ it("returns all cwds with active terminals", async () => {
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-first-"));
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-list-second-"));
temporaryDirs.push(firstCwd, secondCwd);
await manager.createTerminal({ cwd: firstCwd });
await manager.createTerminal({ cwd: secondCwd });
await manager.createTerminal({ cwd: firstCwd, workspaceId: "ws-test" });
await manager.createTerminal({ cwd: secondCwd, workspaceId: "ws-test" });
const dirs = manager.listDirectories();
expect(dirs).toContain(firstCwd);
@@ -315,8 +333,8 @@ it("kills all terminals and clears state", async () => {
const firstCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-first-"));
const secondCwd = mkdtempSync(join(tmpdir(), "terminal-manager-kill-second-"));
temporaryDirs.push(firstCwd, secondCwd);
const tmpSession = await manager.createTerminal({ cwd: firstCwd });
const homeSession = await manager.createTerminal({ cwd: secondCwd });
const tmpSession = await manager.createTerminal({ cwd: firstCwd, workspaceId: "ws-test" });
const homeSession = await manager.createTerminal({ cwd: secondCwd, workspaceId: "ws-test" });
const tmpId = tmpSession.id;
const homeId = homeSession.id;
@@ -340,8 +358,8 @@ it("emits cwd snapshots when terminals are created", async () => {
});
});
await manager.createTerminal({ cwd });
await manager.createTerminal({ cwd, name: "Dev Server" });
await manager.createTerminal({ cwd, workspaceId: "ws-test" });
await manager.createTerminal({ cwd, name: "Dev Server", workspaceId: "ws-test" });
expect(snapshots).toContainEqual({
cwd,
@@ -381,6 +399,7 @@ it("emits updated terminal titles after debounced title changes", async () => {
});
const session = await manager.createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
command: process.execPath,
args: ["-e", "process.stdout.write('\\x1b]0;Logs\\x07'); setTimeout(() => {}, 10000);"],
@@ -402,7 +421,7 @@ it("emits empty snapshot when last terminal is removed", async () => {
});
});
const session = await manager.createTerminal({ cwd });
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
manager.killTerminal(session.id);
expect(snapshots).toContainEqual({
@@ -416,6 +435,7 @@ it("emits empty snapshot when last terminal is removed", async () => {
it("setTerminalTitle returns false for unknown terminal ids without changing existing terminals", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
title: "Existing title",
});
@@ -439,7 +459,10 @@ it("setTerminalTitle returns false for unknown terminal ids without changing exi
it("setTerminalTitle returns true and updates the terminal title for existing terminals", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
const session = await manager.createTerminal({
cwd: realpathSync(tmpdir()),
workspaceId: "ws-test",
});
expect(manager.setTerminalTitle(session.id, "x")).toBe(true);
expect(session.getTitle()).toBe("x");
@@ -447,7 +470,10 @@ it("setTerminalTitle returns true and updates the terminal title for existing te
it("new terminal starts with unknown activity", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
const session = await manager.createTerminal({
cwd: realpathSync(tmpdir()),
workspaceId: "ws-test",
});
expect(session.getActivity()).toBeNull();
});
@@ -465,7 +491,7 @@ it("includes nullable activity in terminals-changed snapshot items", async () =>
);
});
const session = await manager.createTerminal({ cwd });
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
expect(snapshots.length).toBeGreaterThan(0);
const lastSnapshot = snapshots[snapshots.length - 1];
@@ -491,7 +517,7 @@ it("setTerminalActivity returns true and emits terminals-changed", async () => {
);
});
const session = await manager.createTerminal({ cwd });
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
await expect(manager.setTerminalActivity(session.id, "working")).resolves.toBe(true);
const hasWorkingActivity = (entries: ActivityEntry[]) =>
@@ -519,7 +545,7 @@ it("clearTerminalAttention returns attention terminals to idle", async () => {
);
});
const session = await manager.createTerminal({ cwd });
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
await manager.setTerminalActivity(session.id, "attention");
await expect(manager.clearTerminalAttention(session.id)).resolves.toBe(true);
@@ -536,7 +562,10 @@ it("clearTerminalAttention returns attention terminals to idle", async () => {
it("clearTerminalAttention leaves non-attention terminals unchanged", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
const session = await manager.createTerminal({
cwd: realpathSync(tmpdir()),
workspaceId: "ws-test",
});
await manager.setTerminalActivity(session.id, "working");
await expect(manager.clearTerminalAttention(session.id)).resolves.toBe(false);
@@ -574,7 +603,7 @@ it("does not emit workspace contribution event for title-only changes", async ()
events.push(event);
});
const session = await manager.createTerminal({ cwd });
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
manager.setTerminalTitle(session.id, "New title");
expect(events).toEqual([]);
@@ -614,7 +643,7 @@ it("does not emit workspace contribution event when an idle terminal is removed"
events.push(event);
});
const session = await manager.createTerminal({ cwd });
const session = await manager.createTerminal({ cwd, workspaceId: "ws-test" });
manager.killTerminal(session.id);
expect(events).toEqual([]);

View File

@@ -16,7 +16,7 @@ export interface TerminalListItem {
id: string;
name: string;
cwd: string;
workspaceId?: string;
workspaceId: string;
title?: string;
activity: TerminalActivity | null;
}
@@ -32,6 +32,7 @@ export interface TerminalActivityTransitionEvent {
terminalId: string;
name: string;
cwd: string;
workspaceId: string;
activity: TerminalActivity | null;
previous: TerminalActivity | null;
}
@@ -41,7 +42,7 @@ export type TerminalActivityListener = (event: TerminalActivityTransitionEvent)
export interface TerminalWorkspaceContributionChangedEvent {
terminalId: string;
cwd: string;
workspaceId?: string;
workspaceId: string;
}
export type TerminalWorkspaceContributionChangedListener = (
@@ -53,7 +54,7 @@ export interface TerminalManager {
createTerminal(options: {
id?: string;
cwd: string;
workspaceId?: string;
workspaceId: string;
name?: string;
title?: string;
env?: Record<string, string>;
@@ -159,7 +160,7 @@ export function createTerminalManager(
emitTerminalWorkspaceContributionChanged({
terminalId: session.id,
cwd: session.cwd,
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
workspaceId: session.workspaceId,
});
}
@@ -200,7 +201,7 @@ export function createTerminalManager(
emitTerminalWorkspaceContributionChanged({
terminalId: session.id,
cwd: session.cwd,
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
workspaceId: session.workspaceId,
});
}
});
@@ -215,7 +216,7 @@ export function createTerminalManager(
id: input.session.id,
name: input.session.name,
cwd: input.session.cwd,
...(input.session.workspaceId ? { workspaceId: input.session.workspaceId } : {}),
workspaceId: input.session.workspaceId,
title: input.session.getTitle(),
activity: input.session.getActivity(),
};
@@ -254,6 +255,7 @@ export function createTerminalManager(
terminalId: input.session.id,
name: input.session.name,
cwd: input.session.cwd,
workspaceId: input.session.workspaceId,
activity: input.transition.activity,
previous: input.transition.previous,
};
@@ -307,7 +309,7 @@ export function createTerminalManager(
async createTerminal(options: {
id?: string;
cwd: string;
workspaceId?: string;
workspaceId: string;
name?: string;
title?: string;
env?: Record<string, string>;
@@ -341,7 +343,7 @@ export function createTerminalManager(
await createTerminal({
id: terminalId,
cwd: options.cwd,
...(options.workspaceId ? { workspaceId: options.workspaceId } : {}),
workspaceId: options.workspaceId,
name: options.name ?? defaultName,
...(options.title ? { title: options.title } : {}),
...(options.command ? { command: options.command } : {}),

View File

@@ -60,6 +60,7 @@ describe("terminal-session-controller restore", () => {
id: "term-1",
name: "Terminal",
cwd: "/tmp",
workspaceId: "ws-test",
send: vi.fn(),
subscribe: (listener) => {
terminalListener = listener;
@@ -154,6 +155,7 @@ function listSession(input: { id: string; name: string; cwd: string }): Terminal
id: input.id,
name: input.name,
cwd: input.cwd,
workspaceId: "ws-test",
send: vi.fn(),
subscribe: () => vi.fn(),
onExit: () => vi.fn(),
@@ -193,6 +195,7 @@ describe("terminal-session-controller wrap-flag gating", () => {
id: "term-1",
name: "Terminal",
cwd: "/tmp",
workspaceId: "ws-test",
send: vi.fn(),
subscribe: (listener) => {
queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
@@ -329,7 +332,7 @@ describe("terminal-session-controller subdirectory aggregation", () => {
changedListener?.({
cwd: subdirCwd,
terminals: [{ id: "subdir-term", name: "Mobile", cwd: subdirCwd }],
terminals: [{ id: "subdir-term", name: "Mobile", cwd: subdirCwd, workspaceId: "ws-test" }],
});
await flushMicrotasks();
@@ -339,8 +342,8 @@ describe("terminal-session-controller subdirectory aggregation", () => {
payload: {
cwd: rootCwd,
terminals: [
{ id: "root-term", name: "Terminal 1", activity: null },
{ id: "subdir-term", name: "Mobile", activity: null },
{ id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null },
{ id: "subdir-term", name: "Mobile", workspaceId: "ws-test", activity: null },
],
},
},
@@ -403,7 +406,9 @@ describe("terminal-session-controller subdirectory aggregation", () => {
type: "list_terminals_response",
payload: {
cwd: rootCwd,
terminals: [{ id: "root-term", name: "Terminal 1", activity: null }],
terminals: [
{ id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null },
],
requestId: "req-root",
},
},
@@ -411,7 +416,9 @@ describe("terminal-session-controller subdirectory aggregation", () => {
type: "list_terminals_response",
payload: {
cwd: worktreeCwd,
terminals: [{ id: "worktree-term", name: "Feature", activity: null }],
terminals: [
{ id: "worktree-term", name: "Feature", workspaceId: "ws-test", activity: null },
],
requestId: "req-worktree",
},
},
@@ -500,6 +507,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
id: "term-1",
name: "Terminal",
cwd: "/tmp",
workspaceId: "ws-test",
send: vi.fn(),
subscribe: (listener) => {
terminalListener = listener;

View File

@@ -298,7 +298,7 @@ export class TerminalSessionController {
terminals: Array<{
id: string;
name: string;
workspaceId?: string;
workspaceId: string;
title?: string;
activity: TerminalActivity | null;
}>;
@@ -317,7 +317,7 @@ export class TerminalSessionController {
): {
id: string;
name: string;
workspaceId?: string;
workspaceId: string;
title?: string;
activity: TerminalActivity | null;
} {
@@ -326,7 +326,7 @@ export class TerminalSessionController {
return {
id: terminal.id,
name: terminal.name,
...(terminal.workspaceId ? { workspaceId: terminal.workspaceId } : {}),
workspaceId: terminal.workspaceId,
...(title ? { title } : {}),
activity,
};
@@ -520,9 +520,21 @@ export class TerminalSessionController {
return;
}
if (!msg.workspaceId) {
this.emit({
type: "create_terminal_response",
payload: {
terminal: null,
error: "workspaceId is required",
requestId: msg.requestId,
},
});
return;
}
const session = await this.terminalManager.createTerminal({
cwd: msg.cwd,
...(msg.workspaceId ? { workspaceId: msg.workspaceId } : {}),
workspaceId: msg.workspaceId,
name: msg.name,
command: msg.command,
args: msg.args,
@@ -535,7 +547,7 @@ export class TerminalSessionController {
id: session.id,
name: session.name,
cwd: session.cwd,
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
workspaceId: session.workspaceId,
...(session.getTitle() ? { title: session.getTitle() } : {}),
activity: session.getActivity(),
},

View File

@@ -66,7 +66,7 @@ function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo {
id: session.id,
name: session.name,
cwd: session.cwd,
...(session.workspaceId ? { workspaceId: session.workspaceId } : {}),
workspaceId: session.workspaceId,
...(session.getTitle() ? { title: session.getTitle() } : {}),
activity: session.getActivity(),
};
@@ -201,7 +201,11 @@ async function handleCreateTerminalRequest(message: TerminalCreateRequest): Prom
};
inFlightTerminalCreateRequest = request;
try {
const session = await manager.createTerminal(message.options);
const { workspaceId } = message.options;
if (!workspaceId) {
throw new Error("workspaceId is required");
}
const session = await manager.createTerminal({ ...message.options, workspaceId });
if (request.errorReported) {
session.kill();
return;

View File

@@ -302,6 +302,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("executes a simple echo command", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -328,6 +329,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("executes multiple commands sequentially", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -353,6 +355,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("captures output from pwd in specified cwd", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -382,6 +385,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: homeDir,
command: "/bin/zsh",
args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'],
@@ -409,6 +413,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: packageRoot,
command: process.execPath,
args: [scriptPath, "run", "dev"],
@@ -432,6 +437,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("emits OSC title updates to title listeners", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -457,6 +463,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("keeps preset titles instead of applying OSC title updates", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -481,6 +488,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: packageRoot,
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -514,6 +522,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: packageRoot,
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -538,6 +547,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("debounces rapid title changes and emits only the final title", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -581,6 +591,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: workingDir,
shell: "/bin/zsh",
env: {
@@ -663,6 +674,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: workingDir,
shell: "/bin/zsh",
env: {
@@ -692,6 +704,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("captures ANSI 16 color codes (mode 1)", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
@@ -721,6 +734,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("captures 256 color codes (mode 2)", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
@@ -746,6 +760,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("captures true color RGB (mode 3)", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
@@ -774,6 +789,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("captures background colors", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
@@ -800,6 +816,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("receives a snapshot on initial subscription", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
}),
);
@@ -820,6 +837,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("receives output messages on updates without replay snapshots", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -849,6 +867,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("does not emit snapshot messages for resize-only updates", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -877,6 +896,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("emits output only after getState reflects the new data", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -902,6 +922,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("unsubscribe stops receiving messages", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -931,6 +952,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -950,6 +972,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -967,6 +990,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -986,6 +1010,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -1005,6 +1030,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -1024,6 +1050,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -1043,6 +1070,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("streams raw output messages without replay metadata", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -1070,6 +1098,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -1112,6 +1141,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("returns current terminal state with grid", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -1135,6 +1165,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("captures cursor presentation modes emitted by terminal apps", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -1164,6 +1195,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("grid cells have char and color attributes", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -1184,6 +1216,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("preserves scrollback buffer", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -1222,6 +1255,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("terminates the shell process", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -1240,6 +1274,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
it("send after kill is a no-op", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },

View File

@@ -251,6 +251,7 @@ describe("createTerminal", () => {
it("creates a terminal session with an id, name, and cwd", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
}),
);
@@ -268,6 +269,7 @@ describe("createTerminal", () => {
: "/bin/sh";
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
shell,
env: { PS1: "$ " },
@@ -281,6 +283,7 @@ describe("createTerminal", () => {
it("uses default shell if not specified", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
}),
);
@@ -294,6 +297,7 @@ describe("createTerminal", () => {
: "/bin/sh";
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
shell,
env: { PS1: "$ " },
@@ -311,6 +315,7 @@ describe("createTerminal", () => {
: "/bin/sh";
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
shell,
env: { PS1: "$ " },
@@ -327,6 +332,7 @@ describe("createTerminal", () => {
it("reports per-row soft-wrap flags only when wrap flags are requested", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
cols: 40,
rows: 10,
@@ -358,6 +364,7 @@ describe("createTerminal", () => {
it("captures exit diagnostics from the terminal buffer", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
command: process.execPath,
args: [
@@ -389,6 +396,7 @@ describe("createTerminal", () => {
// summary races the async xterm parse and reads a stale buffer.
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
command: process.execPath,
args: [
@@ -429,6 +437,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => {
it("executes a simple echo command", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -455,6 +464,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => {
it("captures output from pwd in specified cwd", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -484,6 +494,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: homeDir,
command: "/bin/zsh",
args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'],
@@ -511,6 +522,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: packageRoot,
command: process.execPath,
args: [scriptPath, "run", "dev"],
@@ -534,6 +546,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
it("emits OSC title updates to title listeners", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -559,6 +572,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
it("keeps preset titles instead of applying OSC title updates", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -583,6 +597,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: packageRoot,
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -616,6 +631,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: packageRoot,
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -640,6 +656,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
it("debounces rapid title changes and emits only the final title", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -683,6 +700,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: workingDir,
shell: "/bin/zsh",
env: {
@@ -765,6 +783,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: workingDir,
shell: "/bin/zsh",
env: {
@@ -792,6 +811,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
it("clears already scheduled OSC title debounce timers when setting a user title", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -825,6 +845,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
it("ignores later OSC title updates after setting a user title", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -854,6 +875,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => {
it("trims user-set titles and treats empty titles as no-ops", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
@@ -884,6 +906,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => {
it("captures ANSI 16 color codes (mode 1)", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
@@ -913,6 +936,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => {
it("captures true color RGB (mode 3)", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
@@ -941,6 +965,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => {
it("captures background colors", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
@@ -967,6 +992,7 @@ describe("resize", () => {
it("updates terminal dimensions on resize", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
rows: 24,
cols: 80,
@@ -985,6 +1011,7 @@ describe("resize", () => {
it("grid reflects new dimensions after resize", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
rows: 24,
cols: 80,
@@ -1002,6 +1029,7 @@ describe("resize", () => {
it("exposes the current size without extracting full state", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
rows: 24,
cols: 80,
@@ -1021,6 +1049,7 @@ describe("mouse events", () => {
it("accepts mouse events without throwing", async () => {
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
}),
);

View File

@@ -77,7 +77,7 @@ export interface TerminalSession {
id: string;
name: string;
cwd: string;
workspaceId?: string;
workspaceId: string;
send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void;
onExit(listener: (info: TerminalExitInfo) => void): () => void;
@@ -118,7 +118,7 @@ function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | nu
export interface CreateTerminalOptions {
id?: string;
cwd: string;
workspaceId?: string;
workspaceId: string;
shell?: string;
env?: Record<string, string>;
activityEnv?: Record<string, string>;
@@ -865,7 +865,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
env: {
...env,
...activityEnv,
...(workspaceId ? { PASEO_WORKSPACE_ID: workspaceId } : {}),
PASEO_WORKSPACE_ID: workspaceId,
},
}),
});
@@ -1448,7 +1448,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
id,
name,
cwd,
...(workspaceId ? { workspaceId } : {}),
workspaceId,
send,
subscribe,
onExit,

View File

@@ -166,6 +166,7 @@ it("creates a terminal through the worker and streams output", async () => {
manager = createWorkerTerminalManager();
const session = trackTerminal(
await manager.createTerminal({
workspaceId: "ws-test",
cwd,
...nodeTerminalCommand(`
process.stdin.on("data", (chunk) => {
@@ -212,6 +213,7 @@ it("delivers rapid small writes complete and in order through worker coalescing"
// on platform-specific PTY input echo/canonical-mode behavior.
const session = trackTerminal(
await manager.createTerminal({
workspaceId: "ws-test",
cwd,
env: { PASEO_TERMINAL_BURST_GATE: burstGatePath },
...nodeTerminalCommand(`
@@ -272,6 +274,7 @@ it("pulls fresh terminal state from the worker authority", async () => {
manager = createWorkerTerminalManager();
const session = trackTerminal(
await manager.createTerminal({
workspaceId: "ws-test",
cwd,
...nodeTerminalCommand(`
process.stdout.write("worker-state-ready\\n");
@@ -304,6 +307,7 @@ it.skipIf(isPlatform("win32"))(
// tracker records and reflects in its replay preamble (\x1b[=1;1u).
const session = trackTerminal(
await manager.createTerminal({
workspaceId: "ws-test",
cwd,
...nodeTerminalCommand(`
process.stdout.write("\\u001b[>1u");
@@ -327,6 +331,7 @@ it("refreshes cached terminal title after worker title changes", async () => {
manager = createWorkerTerminalManager();
const session = trackTerminal(
await manager.createTerminal({
workspaceId: "ws-test",
cwd,
...nodeTerminalCommand(`
process.stdout.write("\\u001b]0;Build Output\\u0007");
@@ -344,7 +349,7 @@ it("refreshes cached terminal size after worker resize", async () => {
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-resize-"));
temporaryDirs.push(cwd);
manager = createWorkerTerminalManager();
const session = trackTerminal(await manager.createTerminal({ cwd }));
const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" }));
session.send({ type: "resize", rows: 10, cols: 40 });
@@ -361,7 +366,7 @@ it("captures terminal output from the worker authority", async () => {
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-capture-"));
temporaryDirs.push(cwd);
manager = createWorkerTerminalManager();
const session = trackTerminal(await manager.createTerminal({ cwd }));
const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" }));
session.send({ type: "input", data: "echo hello world\r" });
@@ -388,6 +393,7 @@ it("does not surface fire-and-forget send timeouts as unhandled rejections", asy
id: "terminal-1",
name: "Terminal",
cwd: "/tmp",
workspaceId: "ws-test",
activity: { state: "idle", changedAt: 0 },
},
state: createTerminalState(),
@@ -423,6 +429,7 @@ it("keeps registered cwd env inheritance behind the worker manager interface", a
});
trackTerminal(
await manager.createTerminal({
workspaceId: "ws-test",
cwd,
...nodeTerminalCommand(`
require("node:fs").writeFileSync(
@@ -450,6 +457,7 @@ it("injects parent-minted terminal activity env through the worker", async () =>
const session = trackTerminal(
await manager.createTerminal({
workspaceId: "ws-test",
cwd,
...nodeTerminalCommand(`
require("node:fs").writeFileSync(
@@ -495,7 +503,7 @@ it("starts the default shell through the worker and accepts quoted commands", as
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-shell-"));
temporaryDirs.push(cwd);
const markerPath = join(cwd, "shell quoted marker.txt");
const session = trackTerminal(await manager.createTerminal({ cwd }));
const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" }));
const command = [
"node",
"-e",
@@ -517,6 +525,7 @@ it("lists subdirectory terminals when querying the workspace root", async () =>
manager = createWorkerTerminalManager();
const created = trackTerminal(
await manager.createTerminal({
workspaceId: "ws-test",
cwd: subdirCwd,
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
}),
@@ -540,6 +549,7 @@ it("lists terminals locally without waiting on the worker", async () => {
id: "terminal-root",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: { state: "idle", changedAt: 0 },
},
state: createTerminalState(),
@@ -550,6 +560,7 @@ it("lists terminals locally without waiting on the worker", async () => {
id: "terminal-subdir",
name: "Shell",
cwd: "/workspace/apps/mobile",
workspaceId: "ws-test",
activity: { state: "idle", changedAt: 0 },
},
state: createTerminalState(),
@@ -579,6 +590,7 @@ it("includes only stamped terminals in workspace-scoped local reads", async () =
id: "terminal-legacy",
name: "Legacy",
cwd: "/workspace",
workspaceId: "ws-test",
activity: null,
},
state: createTerminalState(),
@@ -640,6 +652,7 @@ it("surfaces worker activity changes via getActivity, onActivityChange, and term
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: { state: "idle", changedAt: 0 },
},
state: createTerminalState(),
@@ -681,6 +694,7 @@ it("surfaces worker activity changes via getActivity, onActivityChange, and term
terminalId: "terminal-a",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: workingActivity,
previous: idleActivity,
},
@@ -700,6 +714,7 @@ it("sets terminal activity through a worker request", async () => {
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: null,
},
state: createTerminalState(),
@@ -732,6 +747,7 @@ it("clears terminal attention through a worker request", async () => {
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: { state: "idle", attentionReason: "finished", changedAt: 1000 },
},
state: createTerminalState(),
@@ -757,6 +773,7 @@ it("clears finished attention on a real terminal", async () => {
manager = createWorkerTerminalManager();
const session = trackTerminal(
await manager.createTerminal({
workspaceId: "ws-test",
cwd,
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
}),
@@ -792,6 +809,7 @@ it("removes worker terminals after killAndWait", async () => {
manager = createWorkerTerminalManager();
const session = trackTerminal(
await manager.createTerminal({
workspaceId: "ws-test",
cwd,
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
}),
@@ -822,6 +840,7 @@ it("produces one terminals-changed snapshot per title change", async () => {
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: null,
},
state: createTerminalState(),

View File

@@ -36,6 +36,20 @@ import type {
const REQUEST_TIMEOUT_MS = 10000;
type RequiredWorkerTerminalInfo = WorkerTerminalInfo & { workspaceId: string };
function requiredWorkspaceId(workspaceId: string | undefined): string {
if (workspaceId === undefined) {
throw new Error("workspaceId is required");
}
return workspaceId;
}
function asRequiredWorkerTerminalInfo(info: WorkerTerminalInfo): RequiredWorkerTerminalInfo {
requiredWorkspaceId(info.workspaceId);
return info as RequiredWorkerTerminalInfo;
}
type TerminalWorkerRequestInput = TerminalWorkerRequest extends infer Request
? Request extends TerminalWorkerRequest
? Omit<Request, "requestId">
@@ -49,7 +63,7 @@ interface PendingRequest {
}
interface WorkerTerminalRecord {
info: WorkerTerminalInfo;
info: RequiredWorkerTerminalInfo;
state: TerminalState;
activity: TerminalActivity | null;
// Cached input-mode preamble from the worker (the authoritative tracker lives
@@ -114,12 +128,12 @@ function isResponse(message: TerminalWorkerToParentMessage): message is Terminal
return message.type === "response";
}
function cloneTerminalInfo(info: WorkerTerminalInfo): WorkerTerminalInfo {
function cloneTerminalInfo(info: RequiredWorkerTerminalInfo): RequiredWorkerTerminalInfo {
return {
id: info.id,
name: info.name,
cwd: info.cwd,
...(info.workspaceId ? { workspaceId: info.workspaceId } : {}),
workspaceId: info.workspaceId,
...(info.title ? { title: info.title } : {}),
activity: info.activity,
};
@@ -196,7 +210,7 @@ export function createWorkerTerminalManager(
id: record.info.id,
name: record.info.name,
cwd: record.info.cwd,
...(record.info.workspaceId ? { workspaceId: record.info.workspaceId } : {}),
workspaceId: record.info.workspaceId,
...(record.info.title ? { title: record.info.title } : {}),
activity: record.activity,
});
@@ -205,7 +219,7 @@ export function createWorkerTerminalManager(
}
function registerRecord(input: {
info: WorkerTerminalInfo;
info: RequiredWorkerTerminalInfo;
state: TerminalState;
}): TerminalSession {
const existing = recordsById.get(input.info.id);
@@ -438,7 +452,7 @@ export function createWorkerTerminalManager(
emitTerminalWorkspaceContributionChanged({
terminalId: removedRecord.info.id,
cwd: removedRecord.info.cwd,
...(removedRecord.info.workspaceId ? { workspaceId: removedRecord.info.workspaceId } : {}),
workspaceId: removedRecord.info.workspaceId,
});
}
emitTerminalsChanged({
@@ -506,6 +520,7 @@ export function createWorkerTerminalManager(
terminalId: record.info.id,
name: record.info.name,
cwd: record.info.cwd,
workspaceId: record.info.workspaceId,
activity: message.activity,
previous: message.previous,
});
@@ -515,7 +530,7 @@ export function createWorkerTerminalManager(
emitTerminalWorkspaceContributionChanged({
terminalId: record.info.id,
cwd: record.info.cwd,
...(record.info.workspaceId ? { workspaceId: record.info.workspaceId } : {}),
workspaceId: record.info.workspaceId,
});
}
emitTerminalsChanged({
@@ -527,7 +542,10 @@ export function createWorkerTerminalManager(
function handleWorkerEvent(message: TerminalWorkerToParentMessage): void {
switch (message.type) {
case "terminalCreated": {
registerRecord({ info: message.terminal, state: message.state });
registerRecord({
info: asRequiredWorkerTerminalInfo(message.terminal),
state: message.state,
});
emitTerminalsChanged({
cwd: message.terminal.cwd,
terminals: listTerminalItemsForCwd(message.terminal.cwd),
@@ -661,13 +679,15 @@ export function createWorkerTerminalManager(
return sessions;
},
async createTerminal(options: WorkerCreateTerminalOptions): Promise<TerminalSession> {
async createTerminal(
options: WorkerCreateTerminalOptions & { workspaceId: string },
): Promise<TerminalSession> {
const terminalId = options.id ?? randomUUID();
const activityToken = createActivityToken();
const terminalActivityUrl = managerOptions.getTerminalActivityUrl?.() ?? null;
terminalActivityTokenById.set(terminalId, activityToken);
let result: {
terminal: WorkerTerminalInfo;
terminal: RequiredWorkerTerminalInfo;
state: TerminalState;
};
try {
@@ -680,7 +700,7 @@ export function createWorkerTerminalManager(
activityUrl: terminalActivityUrl,
},
})) as {
terminal: WorkerTerminalInfo;
terminal: RequiredWorkerTerminalInfo;
state: TerminalState;
};
} catch (error) {

Some files were not shown because too many files have changed in this diff Show More