mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abf9a6ff77 | ||
|
|
ae153721c8 |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
@@ -11,10 +11,7 @@ import {
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import {
|
||||
expectAgentAbsentFromWorkspaceLayout,
|
||||
seedForeignPinnedAgentTab,
|
||||
} from "../support/helpers/workspace-layout";
|
||||
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
|
||||
import { clickNewChat } from "../support/helpers/launcher";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
@@ -108,83 +105,6 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("removes a foreign pinned agent when restoring a saved workspace layout", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const serverId = getServerId();
|
||||
const firstWorkspace = await seedWorkspace({
|
||||
repoPrefix: "workspace-owner-",
|
||||
title: "Original workspace",
|
||||
});
|
||||
const secondWorkspace = await seedWorkspace({
|
||||
repoPrefix: "workspace-restored-",
|
||||
title: "Restored workspace",
|
||||
});
|
||||
try {
|
||||
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
|
||||
cwd: firstWorkspace.repoPath,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
title: "Original workspace agent",
|
||||
});
|
||||
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
|
||||
cwd: secondWorkspace.repoPath,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
title: "Restored workspace agent",
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await seedForeignPinnedAgentTab(page, {
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
agentId: firstAgent.id,
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: secondWorkspace.workspaceName,
|
||||
subtitle: secondWorkspace.projectDisplayName,
|
||||
});
|
||||
await testInfo.attach("hydrated-workspace", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expectAgentAbsentFromWorkspaceLayout(page, {
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
agentId: firstAgent.id,
|
||||
});
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||
await testInfo.attach("restored-workspace", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||
const agents = await firstWorkspace.client.fetchAgents();
|
||||
expect(
|
||||
agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId,
|
||||
).toBe(firstWorkspace.workspaceId);
|
||||
expect(
|
||||
agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId,
|
||||
).toBe(secondWorkspace.workspaceId);
|
||||
await testInfo.attach("original-workspace", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await secondWorkspace.cleanup();
|
||||
await firstWorkspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps an offline notification on the agent route until the host connects", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,47 +1,12 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
|
||||
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTab,
|
||||
} from "../../../src/workspace-tabs/model";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function seedForeignPinnedAgentTab(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; agentId: string },
|
||||
): Promise<void> {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
|
||||
if (raw === null) throw new Error("Workspace layout was not persisted");
|
||||
const stored = z
|
||||
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() })
|
||||
.parse(JSON.parse(raw));
|
||||
const key = buildWorkspaceTabPersistenceKey(input);
|
||||
const layout = key ? stored.state.layoutByWorkspace[key] : null;
|
||||
if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`);
|
||||
const opened = openTabInLayoutFocused({
|
||||
layout,
|
||||
target: { kind: "agent", agentId: input.agentId },
|
||||
now: 1,
|
||||
placement: { mode: "focused" },
|
||||
explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null,
|
||||
});
|
||||
if (!opened) throw new Error("Failed to seed foreign agent tab");
|
||||
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
|
||||
...stored.state,
|
||||
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout },
|
||||
pinnedAgentIdsByWorkspace: {
|
||||
...stored.state.pinnedAgentIdsByWorkspace,
|
||||
[key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId],
|
||||
},
|
||||
});
|
||||
await page.evaluate(
|
||||
(value) => localStorage.setItem("workspace-layout-state", value),
|
||||
JSON.stringify(stored),
|
||||
);
|
||||
}
|
||||
|
||||
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
|
||||
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
|
||||
|
||||
@@ -271,7 +271,6 @@ export interface WorkspaceTabSnapshot {
|
||||
terminalsHydrated: boolean;
|
||||
activeAgentIds: Iterable<string>;
|
||||
autoOpenAgentIds: Iterable<string>;
|
||||
foreignAgentIds?: Iterable<string>;
|
||||
knownTerminalIds?: Iterable<string>;
|
||||
standaloneTerminalIds: Iterable<string>;
|
||||
hasActivePendingTerminalCreate?: boolean;
|
||||
@@ -2444,16 +2443,13 @@ export function reconcileWorkspaceTabs(
|
||||
const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []);
|
||||
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
|
||||
const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds);
|
||||
const foreignAgentIds = normalizeStringSet(snapshot.foreignAgentIds ?? []);
|
||||
// Explicit opens survive absent directory entries, but not known foreign ownership.
|
||||
// Once an agent joins this workspace's active directory, normal archive rules apply.
|
||||
// An explicit open owns the tab until the agent joins the active directory.
|
||||
// From then on it follows the normal server archive lifecycle. Detail/cache
|
||||
// hydration never decides whether the user's target is allowed to stay open.
|
||||
const pinnedAgentIds = new Set(
|
||||
[...(state.pinnedAgentIds ?? [])].filter((agentId) => {
|
||||
if (!snapshot.agentsHydrated) {
|
||||
return true;
|
||||
}
|
||||
return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId);
|
||||
}),
|
||||
[...(state.pinnedAgentIds ?? [])].filter(
|
||||
(agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId),
|
||||
),
|
||||
);
|
||||
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
|
||||
const knownTerminalIds = snapshot.knownTerminalIds
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
createWorkspaceAgentVisibilitySelector,
|
||||
@@ -64,118 +63,6 @@ function makeAgent(input: {
|
||||
const WORKSPACE_ID = "ws-1";
|
||||
|
||||
describe("workspace agent visibility", () => {
|
||||
it("removes a restored pinned tab from an unrelated workspace once its owner is known", async () => {
|
||||
const values = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: async (key: string) => values.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
const original = createWorkspaceLayoutStore(undefined, storage);
|
||||
await original.persist.rehydrate();
|
||||
const agent = makeAgent({ id: "restored-agent", cwd: "/project-a", workspaceId: "ws-a" });
|
||||
for (const workspaceId of ["ws-a", "ws-b"]) {
|
||||
original.getState().openTab({
|
||||
workspaceKey: `srv:${workspaceId}`,
|
||||
target: { kind: "agent", agentId: agent.id },
|
||||
intent: "reveal",
|
||||
pin: true,
|
||||
});
|
||||
}
|
||||
const restored = createWorkspaceLayoutStore(undefined, storage);
|
||||
await restored.persist.rehydrate();
|
||||
const sessionAgents = new Map([[agent.id, agent]]);
|
||||
const beforeHydration = buildWorkspaceTabSnapshot({
|
||||
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-b" }),
|
||||
agentsHydrated: false,
|
||||
terminalsHydrated: true,
|
||||
knownTerminalIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
hasActivePendingTerminalCreate: false,
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
restored.getState().reconcileTabs("srv:ws-b", beforeHydration);
|
||||
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toEqual(new Set([agent.id]));
|
||||
for (const workspaceId of ["ws-a", "ws-b"]) {
|
||||
restored.getState().reconcileTabs(
|
||||
`srv:${workspaceId}`,
|
||||
buildWorkspaceTabSnapshot({
|
||||
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId }),
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
knownTerminalIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
hasActivePendingTerminalCreate: false,
|
||||
hasActivePendingDraftCreate: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
restored
|
||||
.getState()
|
||||
.getWorkspaceTabs("srv:ws-a")
|
||||
.filter((tab) => tab.target.kind === "agent")
|
||||
.map((tab) => tab.target),
|
||||
).toEqual([{ kind: "agent", agentId: agent.id }]);
|
||||
expect(
|
||||
restored
|
||||
.getState()
|
||||
.getWorkspaceTabs("srv:ws-b")
|
||||
.filter((tab) => tab.target.kind === "agent"),
|
||||
).toEqual([]);
|
||||
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses directory ownership before historical details when identifying foreign agents", () => {
|
||||
const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID });
|
||||
const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" });
|
||||
const unknown = makeAgent({ id: "unknown", cwd: "/repo" });
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sessionAgents: new Map([
|
||||
[own.id, own],
|
||||
[foreign.id, foreign],
|
||||
[unknown.id, unknown],
|
||||
]),
|
||||
agentDetails: new Map([
|
||||
[own.id, { ...own, workspaceId: "ws-2" }],
|
||||
[foreign.id, { ...foreign, workspaceId: WORKSPACE_ID }],
|
||||
[unknown.id, { ...unknown, workspaceId: "ws-2" }],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set([own.id]));
|
||||
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
|
||||
});
|
||||
|
||||
it("recognizes foreign historical ownership without making archived or unknown agents foreign", () => {
|
||||
const archived = makeAgent({
|
||||
id: "archived",
|
||||
cwd: "/repo",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
|
||||
});
|
||||
const foreign = { ...archived, id: "foreign", workspaceId: "ws-2" };
|
||||
const unknown = { ...archived, id: "unknown", workspaceId: undefined };
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sessionAgents: new Map(),
|
||||
agentDetails: new Map([
|
||||
[archived.id, archived],
|
||||
[foreign.id, foreign],
|
||||
[unknown.id, unknown],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set());
|
||||
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
|
||||
});
|
||||
|
||||
it("keeps subagents active while excluding them from auto-open", () => {
|
||||
const parent = makeAgent({
|
||||
id: "parent-agent",
|
||||
@@ -199,7 +86,6 @@ describe("workspace agent visibility", () => {
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
|
||||
expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"]));
|
||||
expect(result.foreignAgentIds).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("excludes archived subagents from active and auto-open", () => {
|
||||
@@ -409,7 +295,6 @@ describe("workspace agent visibility", () => {
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set<string>());
|
||||
expect(result.foreignAgentIds).toEqual(new Set(["other-ws-agent"]));
|
||||
});
|
||||
|
||||
it("excludes agents without a workspaceId", () => {
|
||||
@@ -423,14 +308,12 @@ describe("workspace agent visibility", () => {
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set<string>());
|
||||
expect(result.foreignAgentIds).toEqual(new Set<string>());
|
||||
});
|
||||
|
||||
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
|
||||
const agentVisibility = {
|
||||
activeAgentIds: new Set(["active-agent"]),
|
||||
autoOpenAgentIds: new Set(["root-agent"]),
|
||||
foreignAgentIds: new Set(["foreign-agent"]),
|
||||
};
|
||||
|
||||
expect(
|
||||
@@ -448,7 +331,6 @@ describe("workspace agent visibility", () => {
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: agentVisibility.activeAgentIds,
|
||||
autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
|
||||
foreignAgentIds: agentVisibility.foreignAgentIds,
|
||||
knownTerminalIds: ["terminal-1", "script-terminal"],
|
||||
standaloneTerminalIds: ["terminal-1"],
|
||||
hasActivePendingTerminalCreate: false,
|
||||
@@ -587,24 +469,6 @@ describe("workspace agent visibility", () => {
|
||||
expect(after.activeAgentIds).toEqual(new Set([child.id]));
|
||||
expect(after.autoOpenAgentIds).toEqual(new Set([child.id]));
|
||||
});
|
||||
|
||||
it("recomputes when only a previously unknown agent's foreign ownership becomes known", () => {
|
||||
seedSession(new Map([[parent.id, parent]]));
|
||||
const select = createWorkspaceAgentVisibilitySelector({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
const before = select(useSessionStore.getState());
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentDetails(SERVER_ID, new Map([[otherWorkspace.id, otherWorkspace]]));
|
||||
const after = select(useSessionStore.getState());
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
expect(after.activeAgentIds).toEqual(before.activeAgentIds);
|
||||
expect(after.autoOpenAgentIds).toEqual(before.autoOpenAgentIds);
|
||||
expect(after.foreignAgentIds).toEqual(new Set([otherWorkspace.id]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceAgentVisibilityEqual", () => {
|
||||
@@ -612,12 +476,10 @@ describe("workspace agent visibility", () => {
|
||||
const a = {
|
||||
activeAgentIds: new Set(["a", "b"]),
|
||||
autoOpenAgentIds: new Set(["a"]),
|
||||
foreignAgentIds: new Set(["c"]),
|
||||
};
|
||||
const b = {
|
||||
activeAgentIds: new Set(["a", "b"]),
|
||||
autoOpenAgentIds: new Set(["a"]),
|
||||
foreignAgentIds: new Set(["c"]),
|
||||
};
|
||||
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
|
||||
});
|
||||
@@ -626,12 +488,10 @@ describe("workspace agent visibility", () => {
|
||||
const a = {
|
||||
activeAgentIds: new Set(["a"]),
|
||||
autoOpenAgentIds: new Set(["a"]),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
const b = {
|
||||
activeAgentIds: new Set(["b"]),
|
||||
autoOpenAgentIds: new Set(["a"]),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
|
||||
});
|
||||
@@ -640,12 +500,10 @@ describe("workspace agent visibility", () => {
|
||||
const a = {
|
||||
activeAgentIds: new Set(["a", "b"]),
|
||||
autoOpenAgentIds: new Set(["a"]),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
const b = {
|
||||
activeAgentIds: new Set(["a", "b"]),
|
||||
autoOpenAgentIds: new Set(["b"]),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
|
||||
});
|
||||
@@ -654,12 +512,10 @@ describe("workspace agent visibility", () => {
|
||||
const a = {
|
||||
activeAgentIds: new Set<string>(),
|
||||
autoOpenAgentIds: new Set<string>(),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
const b = {
|
||||
activeAgentIds: new Set<string>(),
|
||||
autoOpenAgentIds: new Set<string>(),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,10 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
export interface WorkspaceAgentVisibility {
|
||||
activeAgentIds: Set<string>;
|
||||
autoOpenAgentIds: Set<string>;
|
||||
foreignAgentIds: Set<string>;
|
||||
}
|
||||
|
||||
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
|
||||
return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId;
|
||||
}
|
||||
|
||||
export function deriveWorkspaceAgentVisibility(input: {
|
||||
@@ -16,23 +19,17 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
}): WorkspaceAgentVisibility {
|
||||
const { sessionAgents, agentDetails } = input;
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!workspaceId) {
|
||||
if ((!sessionAgents && !agentDetails) || !workspaceId) {
|
||||
return {
|
||||
activeAgentIds: new Set<string>(),
|
||||
autoOpenAgentIds: new Set<string>(),
|
||||
foreignAgentIds: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
const activeAgentIds = new Set<string>();
|
||||
const autoOpenAgentIds = new Set<string>();
|
||||
const foreignAgentIds = new Set<string>();
|
||||
for (const agent of sessionAgents?.values() ?? []) {
|
||||
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
|
||||
if (ownerId !== workspaceId) {
|
||||
if (ownerId) {
|
||||
foreignAgentIds.add(agent.id);
|
||||
}
|
||||
if (!agentBelongsToWorkspace(agent, workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
if (!agent.archivedAt) {
|
||||
@@ -45,16 +42,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const agent of agentDetails?.values() ?? []) {
|
||||
if (sessionAgents?.has(agent.id)) {
|
||||
continue;
|
||||
}
|
||||
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
|
||||
if (ownerId && ownerId !== workspaceId) {
|
||||
foreignAgentIds.add(agent.id);
|
||||
}
|
||||
}
|
||||
return { activeAgentIds, autoOpenAgentIds, foreignAgentIds };
|
||||
return { activeAgentIds, autoOpenAgentIds };
|
||||
}
|
||||
|
||||
interface AgentMapsSnapshot {
|
||||
@@ -111,7 +99,6 @@ export function buildWorkspaceTabSnapshot(input: {
|
||||
terminalsHydrated: input.terminalsHydrated,
|
||||
activeAgentIds: input.agentVisibility.activeAgentIds,
|
||||
autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
|
||||
foreignAgentIds: input.agentVisibility.foreignAgentIds,
|
||||
knownTerminalIds: input.knownTerminalIds,
|
||||
standaloneTerminalIds: input.standaloneTerminalIds,
|
||||
hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate,
|
||||
@@ -126,8 +113,7 @@ export function workspaceAgentVisibilityEqual(
|
||||
return (
|
||||
a === b ||
|
||||
(setsEqual(a.activeAgentIds, b.activeAgentIds) &&
|
||||
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) &&
|
||||
setsEqual(a.foreignAgentIds, b.foreignAgentIds))
|
||||
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ export async function ensureAgentLoaded(
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
env: record.launchEnv,
|
||||
});
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||
}
|
||||
|
||||
@@ -2869,6 +2869,134 @@ test("createAgent passes daemon launch env through the provider launch context",
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ provider: "codex", hasHandle: true },
|
||||
{ provider: "opencode", hasHandle: true },
|
||||
{ provider: "codex", hasHandle: false },
|
||||
{ provider: "opencode", hasHandle: false },
|
||||
])(
|
||||
"$provider preserves explicit launch env across reloads and storage-backed resumes (handle: $hasHandle)",
|
||||
async ({ provider, hasHandle }) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
class LaunchEnvClient extends TestAgentClient {
|
||||
lastLaunchContext: AgentLaunchContext | undefined;
|
||||
|
||||
override async createSession(
|
||||
config: AgentSessionConfig,
|
||||
context?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastLaunchContext = context;
|
||||
const session = await super.createSession(config);
|
||||
session.describePersistence = () => ({ provider, sessionId: session.id });
|
||||
return session;
|
||||
}
|
||||
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
context?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastLaunchContext = context;
|
||||
const session = await super.resumeSession(handle, config, context);
|
||||
session.describePersistence = () => ({ provider, sessionId: handle.sessionId });
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new LaunchEnvClient(provider);
|
||||
let storage = new AgentStorage(storagePath, logger);
|
||||
let manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
|
||||
const env = { XDG_DATA_HOME: join(workdir, "agent-data"), CUSTOM_EMPTY: "" };
|
||||
const expectedEnv = { ...env };
|
||||
const agent = await manager.createAgent({ provider, cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
env,
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
env.XDG_DATA_HOME = "caller-mutated";
|
||||
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
|
||||
if (!hasHandle) {
|
||||
const record = await storage.get(agent.id);
|
||||
if (!record) throw new Error("Created agent was not persisted");
|
||||
await storage.upsert({ ...record, persistence: null });
|
||||
}
|
||||
|
||||
// Fresh storage and manager instances must read the environment from disk.
|
||||
for (let restart = 0; restart < 2; restart += 1) {
|
||||
storage = new AgentStorage(storagePath, logger);
|
||||
manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
|
||||
const resumed = await ensureAgentLoaded(agent.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
expect(client.lastLaunchContext).toEqual({
|
||||
agentId: agent.id,
|
||||
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
|
||||
});
|
||||
await manager.reloadAgentSession(agent.id);
|
||||
expect(client.lastLaunchContext).toEqual({
|
||||
agentId: agent.id,
|
||||
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
|
||||
});
|
||||
expect(toAgentPayload(resumed)).not.toHaveProperty("launchEnv");
|
||||
expect(toAgentPayload(resumed).persistence?.metadata).not.toHaveProperty("launchEnv");
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("session-open hooks override persisted launch env without persisting per-opening values", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-hook-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const observedEnv: Array<Record<string, string> | undefined> = [];
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
pluginLifecycle: {
|
||||
emit() {},
|
||||
async before(name, request) {
|
||||
if (name === "agent.create" && "env" in request) {
|
||||
request.env = { ...request.env, FROM_CREATE_HOOK: "saved" };
|
||||
}
|
||||
if (name === "agent.session_open" && "env" in request) {
|
||||
observedEnv.push({ ...request.env });
|
||||
request.env = { ...request.env, XDG_DATA_HOME: "current-hook-data" };
|
||||
}
|
||||
return request;
|
||||
},
|
||||
},
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
env: { XDG_DATA_HOME: "original-data" },
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
await manager.reloadAgentSession(agent.id);
|
||||
expect(observedEnv).toEqual([
|
||||
{ XDG_DATA_HOME: "original-data", FROM_CREATE_HOOK: "saved" },
|
||||
{ XDG_DATA_HOME: "original-data", FROM_CREATE_HOOK: "saved" },
|
||||
]);
|
||||
expect((await storage.get(agent.id))?.launchEnv).toEqual({
|
||||
XDG_DATA_HOME: "original-data",
|
||||
FROM_CREATE_HOOK: "saved",
|
||||
});
|
||||
});
|
||||
|
||||
test("createAgent passes persistSession to provider create options", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -414,6 +414,8 @@ interface ManagedAgentBase {
|
||||
owner?: AgentOwner;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
config: AgentSessionConfig;
|
||||
/** Explicit launch overrides, before per-opening hooks and daemon-owned variables. */
|
||||
launchEnv?: Record<string, string>;
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -1323,6 +1325,7 @@ export class AgentManager {
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
launchEnv: options.env ? { ...options.env } : undefined,
|
||||
historyPrimed: true,
|
||||
});
|
||||
if (!agent.internal) {
|
||||
@@ -1414,7 +1417,7 @@ export class AgentManager {
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
mergedConfig,
|
||||
resolvedAgentId,
|
||||
{ purpose },
|
||||
{ purpose, env: record?.launchEnv },
|
||||
);
|
||||
const client = this.requireClient(handle.provider);
|
||||
const available = await client.isAvailable();
|
||||
@@ -1429,7 +1432,7 @@ export class AgentManager {
|
||||
client,
|
||||
storedConfig.cwd,
|
||||
paseoToolPolicy,
|
||||
undefined,
|
||||
record?.launchEnv,
|
||||
{
|
||||
reason: "resume",
|
||||
purpose,
|
||||
@@ -1446,6 +1449,7 @@ export class AgentManager {
|
||||
await this.requireExternalMcpSupport(session, storedConfig);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
...options,
|
||||
launchEnv: record?.launchEnv,
|
||||
persistence: handle,
|
||||
restoring: true,
|
||||
});
|
||||
@@ -1577,6 +1581,7 @@ export class AgentManager {
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
refreshConfig,
|
||||
agentId,
|
||||
{ env: existing.launchEnv },
|
||||
);
|
||||
const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId);
|
||||
const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId);
|
||||
@@ -1585,7 +1590,7 @@ export class AgentManager {
|
||||
client,
|
||||
storedConfig.cwd,
|
||||
paseoToolPolicy,
|
||||
undefined,
|
||||
existing.launchEnv,
|
||||
{ reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId },
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1630,6 +1635,7 @@ export class AgentManager {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
launchEnv: existing.launchEnv,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
lastUserMessageAt: existing.lastUserMessageAt,
|
||||
@@ -3668,6 +3674,7 @@ export class AgentManager {
|
||||
publishWhenReady?: boolean;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
launchEnv?: Record<string, string>;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
let registered = false;
|
||||
@@ -3846,6 +3853,7 @@ export class AgentManager {
|
||||
persistence?: AgentPersistenceHandle;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
launchEnv?: Record<string, string>;
|
||||
}
|
||||
| undefined;
|
||||
}): ActiveManagedAgent {
|
||||
@@ -3856,6 +3864,7 @@ export class AgentManager {
|
||||
cwd: config.cwd,
|
||||
workspaceId: options?.workspaceId,
|
||||
owner: options?.owner,
|
||||
launchEnv: options?.launchEnv,
|
||||
session,
|
||||
capabilities: session.capabilities,
|
||||
config,
|
||||
|
||||
@@ -95,6 +95,7 @@ export function toStoredAgentRecord(
|
||||
lastStatus: agent.lifecycle,
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
|
||||
runtimeInfo,
|
||||
features: normalizeFeatures(agent.features),
|
||||
persistence,
|
||||
|
||||
@@ -56,6 +56,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
lastStatus: AgentStatusSchema.default("closed"),
|
||||
lastModeId: z.string().nullable().optional(),
|
||||
config: SERIALIZABLE_CONFIG_SCHEMA,
|
||||
launchEnv: z.record(z.string(), z.string()).optional(),
|
||||
runtimeInfo: z
|
||||
.object({
|
||||
provider: z.string(),
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
existsSync,
|
||||
truncateSync,
|
||||
readdirSync,
|
||||
symlinkSync,
|
||||
} from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -24,71 +23,7 @@ function makeTmpDir(): string {
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
// A tar.bz2 containing the three Kokoro files ("complete model data") and espeak-ng-data/.
|
||||
// Generated with Python tarfile + bz2, so testing extraction needs no system compressor.
|
||||
const compressedModelArchive = Buffer.from(
|
||||
"QlpoOTFBWSZTWYGlJLIAAOjfgcqBQAP/ICEAAAD+r99AABAICDAA1mQkopo0P1JkeoMTBDE9RgyiADQZAAAACSVPTU9TT1B6g0aGJ6hkyafQaedKK3SkotKCAUvkgkMWhnICQFABoykPAQBIYAZHteVO8PnEPulpWBWogKIMAi5XOBFWQPCYTsU1U7snVwYKXg/axQ0QgDBKDgsClwZB7z0TLGwJV+3hIFscrlfwje/SLh4QDkqFxkXTaE+x7Zh/DDrAILu8MdygDAzOzD88IBQpa8wkDqd3cEH8XckU4UJCBpSSyA==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
describe("sherpa model downloader", () => {
|
||||
test.skipIf(process.platform !== "linux")(
|
||||
"installs a cached bzip2 model without a system bzip2 executable",
|
||||
async () => {
|
||||
const root = makeTmpDir();
|
||||
const modelsDir = path.join(root, "models");
|
||||
const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19");
|
||||
const archive = path.join(
|
||||
modelsDir,
|
||||
".downloads",
|
||||
path.basename(new URL(spec.archiveUrl).pathname),
|
||||
);
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
const binDir = path.join(root, "bin");
|
||||
mkdirSync(binDir);
|
||||
symlinkSync(
|
||||
execFileSync("which", ["tar"], { encoding: "utf8" }).trim(),
|
||||
path.join(binDir, "tar"),
|
||||
);
|
||||
mkdirSync(path.dirname(archive), { recursive: true });
|
||||
writeFileSync(archive, compressedModelArchive);
|
||||
// GNU tar is available, but the bzip2 executable it invokes is not.
|
||||
process.env.PATH = binDir;
|
||||
const modelDir = await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger });
|
||||
expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data");
|
||||
expect(existsSync(path.join(modelDir, "espeak-ng-data"))).toBe(true);
|
||||
expect(existsSync(archive)).toBe(false);
|
||||
} finally {
|
||||
if (originalPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = originalPath;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
test.skipIf(process.platform !== "linux")(
|
||||
"installs the same cached bzip2 archive when system tools are available",
|
||||
async () => {
|
||||
const root = makeTmpDir();
|
||||
const modelsDir = path.join(root, "models");
|
||||
const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19");
|
||||
const archive = path.join(
|
||||
modelsDir,
|
||||
".downloads",
|
||||
path.basename(new URL(spec.archiveUrl).pathname),
|
||||
);
|
||||
try {
|
||||
mkdirSync(path.dirname(archive), { recursive: true });
|
||||
writeFileSync(archive, compressedModelArchive);
|
||||
const modelDir = await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger });
|
||||
expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data");
|
||||
expect(existsSync(archive)).toBe(false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("does not expose a partial model when tar extraction fails", async () => {
|
||||
const root = makeTmpDir();
|
||||
const modelsDir = path.join(root, "models");
|
||||
|
||||
Reference in New Issue
Block a user