Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andThePharmer 4062bc5fe1 test(relay): reproduce password rejection lost on hosted relay close
Preserve the failing client-path reproduction for #7929 and a close-timing control. The hosted relay loses the rejection after the daemon send completes; the internal relay cause and proposed upstream correction still require verification.

Co-authored-by: ThePharmer <77949461+ThePharmer@users.noreply.github.com>
2026-10-26 12:45:48 +00:00
18 changed files with 300 additions and 732 deletions
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))
);
}
@@ -811,46 +811,6 @@ describe("ACPAgentSession terminal tools", () => {
truncated: false,
});
});
test("trims large terminal output without rescanning the retained suffix", async () => {
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: process.execPath,
args: ["-e", 'process.stdout.write("x".repeat(1024 * 1024))'],
cwd: process.cwd(),
outputByteLimit: 1024,
});
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
await expect(
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
).resolves.toEqual({
output: "x".repeat(1024),
truncated: true,
exitStatus: { exitCode: 0, signal: null },
});
}, 1000);
test("trims terminal output at a UTF-8 character boundary", async () => {
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: process.execPath,
args: ["-e", 'process.stdout.write("😀ab")'],
cwd: process.cwd(),
outputByteLimit: 3,
});
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
await expect(
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
).resolves.toEqual({
output: "ab",
truncated: true,
exitStatus: { exitCode: 0, signal: null },
});
});
});
describe("mapACPUsage", () => {
@@ -4093,16 +4093,10 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void {
if (!limit) {
return;
}
const output = Buffer.from(entry.output, "utf8");
if (output.length <= limit) {
return;
while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) {
entry.output = entry.output.slice(1);
entry.truncated = true;
}
let start = output.length - limit;
while (start < output.length && (output[start] & 0xc0) === 0x80) {
start += 1;
}
entry.output = output.subarray(start).toString("utf8");
entry.truncated = true;
}
function readRecord(value: unknown): Record<string, unknown> | null {
@@ -3063,81 +3063,6 @@ describe("PiRpcAgentClient", () => {
]);
});
test.each([
{ outcome: "successful", error: null },
{ outcome: "failed", error: new Error("summarizer failed") },
])("waits for $outcome manual compaction before sending a new prompt", async ({ error }) => {
const { pi, session, events } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
fakeSession.compactError = error;
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
const { turnId } = await session.startTurn("continue after compaction");
await flushTurnScheduling();
expect(fakeSession.compactRequests).toEqual([{}]);
expect(fakeSession.prompts).toEqual([]);
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.prompts).toEqual([{ message: "continue after compaction", imageCount: 0 }]);
fakeSession.finishTurn();
await events.nextTurnCompletion();
expect(events.turnLifecycleEvents()).toContainEqual({ type: "turn_completed", turnId });
expect(events.eventTypes()).not.toContain("turn_failed");
});
test.each(["interrupt", "close"] as const)(
"does not send a prompt waiting on compaction after %s",
async (action) => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
await session.startTurn("do not send this prompt");
await session[action]();
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.prompts).toEqual([]);
},
);
test("does not send a prompt when compaction finishes during interruption", async () => {
const { pi, session, events } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const clearQueueFinished = Promise.withResolvers<void>();
fakeSession.clearQueueCompletion = clearQueueFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
const { turnId } = await session.startTurn("do not send this prompt");
const interruption = session.interrupt();
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.controlRequests).toEqual(["clear_queue"]);
expect(fakeSession.prompts).toEqual([]);
clearQueueFinished.resolve();
await interruption;
expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_canceled", turnId }]);
expect(fakeSession.prompts).toEqual([]);
});
test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
@@ -1189,7 +1189,6 @@ export class PiRpcAgentSession implements AgentSession {
private readonly contextUserEntries: PiCapturedEntry[] = [];
private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>();
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
private compactionPromise: Promise<void> | null = null;
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
private outOfBandCompactionStarted = false;
private outOfBandCompactionCompleted = false;
@@ -1297,15 +1296,6 @@ export class PiRpcAgentSession implements AgentSession {
void (async () => {
try {
if (this.compactionPromise) {
// Pi RPC rejects prompts until the manual compaction request settles.
await this.compactionPromise.catch(() => undefined);
const turnCanceled =
this.closed || this.activeTurnId !== turnId || this.interruptingTurn?.turnId === turnId;
if (turnCanceled) {
return;
}
}
const ack = await this.runtimeSession.prompt(payload.text, payload.images);
this.activePromptRequestId = ack.requestId ?? null;
const correlatedResult = ack.requestId
@@ -1814,8 +1804,7 @@ export class PiRpcAgentSession implements AgentSession {
this.outOfBandCompactionStarted = false;
this.outOfBandCompactionCompleted = false;
try {
this.compactionPromise = this.runtimeSession.compact(customInstructions);
await this.compactionPromise;
await this.runtimeSession.compact(customInstructions);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (
@@ -1841,7 +1830,6 @@ export class PiRpcAgentSession implements AgentSession {
},
});
} finally {
this.compactionPromise = null;
if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) {
this.outOfBandCompactionEmit = null;
this.outOfBandCompactionStarted = false;
@@ -108,7 +108,6 @@ export class FakePiSession implements PiRuntimeSession {
steerError: Error | null = null;
readonly controlRequests: string[] = [];
clearQueueError: Error | null = null;
clearQueueCompletion: Promise<void> | null = null;
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = [];
@@ -141,7 +140,6 @@ export class FakePiSession implements PiRuntimeSession {
subagentSubscriptionError: Error | null = null;
setSessionNameError: Error | null = null;
compactError: Error | null = null;
compactCompletion: Promise<void> | null = null;
emitCompactEnd = true;
getStateError: Error | null = null;
getSessionStatsError: Error | null = null;
@@ -235,7 +233,6 @@ export class FakePiSession implements PiRuntimeSession {
async clearQueue(): Promise<void> {
this.controlRequests.push("clear_queue");
await this.clearQueueCompletion;
if (this.clearQueueError) {
throw this.clearQueueError;
}
@@ -244,7 +241,6 @@ export class FakePiSession implements PiRuntimeSession {
async compact(customInstructions?: string): Promise<void> {
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
this.emit({ type: "compaction_start", reason: "manual" });
await this.compactCompletion;
if (this.emitCompactEnd) {
this.emit({ type: "compaction_end", reason: "manual" });
}
@@ -1,147 +0,0 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it } from "vitest";
import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js";
let directory: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-"));
});
afterEach(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it.each(["EPERM", "EACCES", "EBUSY"])(
"commits a completed receipt after transient Windows %s replace failures",
async (code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
let failures = 3;
let elapsed = 0;
await writeJsonFileAtomic(
file,
{ state: "completed" },
{
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
elapsed += milliseconds;
},
rename: async (source, destination) => {
if (failures-- > 0) {
expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({
state: "pending",
});
expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" });
throw Object.assign(new Error("destination held open"), { code });
}
await fs.rename(source, destination);
},
},
);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
expect(elapsed).toBeGreaterThan(0);
},
);
it.each(["EPERM", "EACCES", "EBUSY"])(
"preserves the previous file and removes the temp file when Windows %s persists",
async (code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
const error = Object.assign(new Error("destination held open"), { code });
const waits: number[] = [];
let elapsed = 0;
const attempts: number[] = [];
await expect(
writeJsonFileAtomic(
file,
{ state: "completed" },
{
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
waits.push(milliseconds);
elapsed += milliseconds;
},
rename: async () => {
attempts.push(elapsed);
throw error;
},
},
),
).rejects.toBe(error);
expect(elapsed).toBe(1000);
expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]);
expect(Math.max(...waits)).toBe(100);
expect(attempts.every((time) => time < 1000)).toBe(true);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
},
);
it.each([
["linux", "EPERM"],
["darwin", "EACCES"],
["linux", "EBUSY"],
["win32", "ENOENT"],
["win32", "ENOSPC"],
["win32", "EXDEV"],
] as const)("does not retry %s %s failures", async (platform, code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
const error = Object.assign(new Error("rename failed"), { code });
let attempts = 0;
const waits: number[] = [];
await expect(
writeFileAtomic(file, "replacement", {
platform,
now: () => 0,
sleep: async (milliseconds) => {
waits.push(milliseconds);
},
rename: async () => {
attempts++;
throw error;
},
}),
).rejects.toBe(error);
expect(attempts).toBe(1);
expect(waits).toEqual([]);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
});
it("does not retry after a rename consumes the Windows retry budget", async () => {
const file = path.join(directory, "receipt.json");
const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" });
let elapsed = 0;
const waits: number[] = [];
await expect(
writeFileAtomic(file, "replacement", {
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
waits.push(milliseconds);
},
rename: async () => {
elapsed += 1000;
throw error;
},
}),
).rejects.toBe(error);
expect(waits).toEqual([]);
expect(await fs.readdir(directory)).toEqual([]);
});
it("creates parent directories and replaces binary and JSON files through the default runtime", async () => {
const file = path.join(directory, "nested", "file");
const bytes = Buffer.from([0, 255, 128, 1]);
await writeFileAtomic(file, bytes);
expect(await fs.readFile(file)).toEqual(bytes);
await writeJsonFileAtomic(file, { state: "completed" });
expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}');
expect(await fs.readdir(path.dirname(file))).toEqual(["file"]);
});
+3 -54
View File
@@ -1,26 +1,10 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
interface AtomicFileRuntime {
platform: NodeJS.Platform;
rename: (source: string, destination: string) => Promise<void>;
now: () => number;
sleep: (milliseconds: number) => Promise<void>;
}
const runtime: AtomicFileRuntime = {
platform: process.platform,
rename: fs.rename,
now: () => performance.now(),
sleep,
};
export async function writeFileAtomic(
filePath: string,
data: string | NodeJS.ArrayBufferView,
operations: AtomicFileRuntime = runtime,
): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = path.join(
@@ -29,48 +13,13 @@ export async function writeFileAtomic(
);
try {
await fs.writeFile(tempPath, data, "utf8");
await replaceFile(tempPath, filePath, operations);
await fs.rename(tempPath, filePath);
} catch (error) {
await fs.rm(tempPath, { force: true });
throw error;
}
}
async function replaceFile(
source: string,
destination: string,
operations: AtomicFileRuntime,
): Promise<void> {
// Windows readers/scanners can briefly deny replacement. Yield with backoff,
// but keep interactive persistence failures bounded to a one-second retry budget.
const deadline = operations.now() + 1000;
let delay = 10;
for (;;) {
try {
await operations.rename(source, destination);
return;
} catch (error) {
if (
operations.platform !== "win32" ||
!(error instanceof Error) ||
!("code" in error) ||
!["EPERM", "EACCES", "EBUSY"].includes(String(error.code))
) {
throw error;
}
const remaining = deadline - operations.now();
if (remaining <= 0) throw error;
await operations.sleep(Math.min(delay, remaining));
if (operations.now() >= deadline) throw error;
delay = Math.min(delay * 2, 100);
}
}
}
export async function writeJsonFileAtomic(
filePath: string,
value: unknown,
operations: AtomicFileRuntime = runtime,
): Promise<void> {
await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations);
export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void> {
await writeFileAtomic(filePath, JSON.stringify(value, null, 2));
}
@@ -1544,7 +1544,7 @@ test(
);
test(
"remote agent reports, local CLI sends and automatic completion reach the same recipient",
"remote agent reports, local CLI sends and automatic completion share the recipient batch",
{ timeout: 30000 },
async () => {
const parentPrompts: string[] = [];
@@ -1633,10 +1633,7 @@ test(
});
expect(report.isError).not.toBe(true);
expect(report.structuredContent).toMatchObject({ deliveryStatus: "queued", serverId: aId });
// A CLI process can take longer than the two-second quiet window to start.
// Exercise each delivery across windows; delivery.test.ts owns batching with a clock.
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1);
expect(parentPrompts[0]).toContain("REMOTE_REPORT &lt;ready&gt;");
expect(parentPrompts).toEqual([]);
const cli = await promisify(execFile)(
process.execPath,
@@ -1659,8 +1656,7 @@ test(
},
);
expect(JSON.parse(cli.stdout)).toMatchObject({ status: "queued" });
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2);
expect(parentPrompts[1]).toContain("LOCAL_CLI_REPORT");
expect(parentPrompts).toEqual([]);
const receiptStates = async () => {
const directory = join(a.paseoHome, "agent-requests");
const receipts = await Promise.all(
@@ -1675,10 +1671,13 @@ test(
.map((receipt) => receipt.state)
.sort();
};
expect(await receiptStates()).toEqual(["pending", "pending"]);
finishChild.resolve();
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(3);
expect(parentPrompts[2]).toContain('event="finished"');
expect(parentPrompts[2]).toContain(`source-agent-id="${child.id}"`);
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1);
expect(parentPrompts[0]).toContain("REMOTE_REPORT &lt;ready&gt;");
expect(parentPrompts[0]).toContain("LOCAL_CLI_REPORT");
expect(parentPrompts[0]).toContain('event="finished"');
expect(parentPrompts[0]).toContain(`source-agent-id="${child.id}"`);
await expect
.poll(receiptStates, {
timeout: 10000,
@@ -1871,7 +1870,6 @@ test(
});
const a = await daemon({
mcpEnabled: true,
logger: completionLogger(),
agentClients: createTestAgentClients({
onStartTurn: (prompt) => {
if (String(prompt).includes('kind="agent-notification"'))
@@ -1933,14 +1931,8 @@ test(
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2);
expect(parentPrompts[1]).toContain('event="finished"');
await expect
// Provider start precedes dispatch acknowledgment and the durable removal commit.
.poll(
async () =>
JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")),
{
timeout: 10000,
message: "Parent accepted completion; waiting for durable intent removal",
},
.poll(async () =>
JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")),
)
.toEqual([]);
},
@@ -219,7 +219,7 @@ test(
);
test(
"real Pi daemon accepts a prompt after manual compact",
"real Pi daemon executes manual compact out-of-band instead of prompt text",
async () => {
const cwd = tmpCwd("pi-manual-compact-");
@@ -234,24 +234,20 @@ test(
cwd,
title: "pi-manual-compact",
provider: "pi",
model: PI_REAL_TEST_MODEL,
model: PI_COMPACTION_TEST_MODEL,
});
await client.sendMessage(agent.id, "Reply exactly: compact-ready");
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status, JSON.stringify(await fetchCanonicalTimeline(client, agent.id))).toBe(
"idle",
);
expect(finish.status).toBe("idle");
await client.sendMessage(agent.id, "/compact summarize this e2e run");
await waitForTimelineItem(
client,
agent.id,
(item) => item.type === "compaction" && item.status === "completed",
);
await client.sendMessage(agent.id, "Reply exactly: after-compaction");
const followUpFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(followUpFinish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id);
expect(items).toEqual(
@@ -267,12 +263,9 @@ test(
}),
]),
);
expect(items).toContainEqual(
expect.objectContaining({
type: "assistant_message",
text: "after-compaction",
}),
);
expect(
items.some((item) => item.type === "user_message" && item.text.includes("/compact")),
).toBe(false);
expect(
items
.filter((item) => item.type === "assistant_message")
@@ -0,0 +1,260 @@
import { test, expect } from "vitest";
import { hashSync } from "bcryptjs";
import { WebSocket } from "ws";
import { DaemonClient, getDaemonAuthFailureReason } from "@getpaseo/client/internal/daemon-client";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { randomUUID } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import pino from "pino";
import { generateKeyPair, exportPublicKey } from "@getpaseo/relay/e2ee";
import { startRelayTransport } from "../relay-transport.js";
import { admitSecuredRelaySocket } from "../websocket/secure-relay-ingress.js";
const liveTest = process.env.RUN_LIVE_RELAY_E2E === "1" ? test : test.skip;
liveTest(
"hosted relay preserves password rejection from a real daemon",
async () => {
const daemon = await createTestPaseoDaemon({
auth: { password: hashSync("diagnostic-password", 4) },
relayEnabled: true,
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
mcpEnabled: false,
pluginsEnabled: false,
agentClients: {},
});
const observations: Array<Record<string, unknown>> = [];
try {
const pairing = await generateLocalPairingOffer({
paseoHome: daemon.paseoHome,
relayEnabled: true,
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
includeQr: false,
});
const offer = parseConnectionOfferFromUrl(pairing.url!);
if (!offer) throw new Error("No relay pairing offer");
for (const mode of ["direct", "relay"] as const) {
for (const credential of ["missing", "wrong", "correct"] as const) {
for (let attempt = 0; attempt < (mode === "relay" ? 3 : 1); attempt++) {
const frames: string[] = [];
let closeCode: number | null = null;
let closeReason: string | null = null;
const client = new DaemonClient({
url:
mode === "direct"
? `ws://127.0.0.1:${daemon.port}/ws`
: buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
useTls: true,
serverId: offer.serverId,
role: "client",
}),
clientId: `diagnostic-${mode}-${credential}-${attempt}`,
clientType: "cli",
connectTimeoutMs: 20_000,
reconnect: { enabled: false },
password: { missing: undefined, wrong: "incorrect", correct: "diagnostic-password" }[
credential
],
...(mode === "relay"
? { e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 } }
: {}),
webSocketFactory: (url, options) => {
const socket = new WebSocket(url, options?.protocols, {
headers: options?.headers,
});
socket.on("message", (data, binary) => frames.push(frameKind(data, binary)));
socket.on("close", (code, reason) => {
closeCode = code;
closeReason = reason.toString();
});
return socket;
},
});
const result = await connectOutcome(client);
const observation = {
mode,
credential,
attempt,
...result,
frames,
closeCode,
closeReason,
};
observations.push(observation);
console.log(JSON.stringify(observation));
}
}
}
} finally {
await daemon.close();
}
for (const observation of observations) {
expect(observation, JSON.stringify(observation)).toMatchObject(
observation.credential === "correct"
? { connected: true }
: {
connected: false,
reason:
observation.credential === "missing" ? "password_required" : "incorrect_password",
},
);
}
},
180_000,
);
async function connectOutcome(client: DaemonClient): Promise<Record<string, unknown>> {
try {
await client.connect();
return { connected: true };
} catch (error) {
return {
connected: false,
error: error instanceof Error ? error.message : String(error),
reason: getDaemonAuthFailureReason(error),
};
} finally {
await client.close();
}
}
function frameKind(data: WebSocket.RawData, binary: boolean): string {
if (binary) return "encrypted-record";
try {
const parsed = JSON.parse(data.toString());
return ["e2ee_ready", "hello.rejected", "session"].includes(parsed.type)
? parsed.type
: "other-json";
} catch {
return "encrypted-record";
}
}
liveTest(
"close timing isolates loss after the daemon send callback",
async () => {
const observations: Array<{
delayMs: number;
reason: string | null;
sendCompleted: boolean;
frames: string[];
}> = [];
for (const delayMs of [0, 500]) {
const daemonKeyPair = generateKeyPair();
const serverId = `diagnostic-${randomUUID()}`;
let markReady!: () => void;
const ready = new Promise<void>((resolve) => {
markReady = resolve;
});
let sendCompleted = false;
const controller = startRelayTransport({
serverId,
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
logger: pino({ level: "silent" }),
createWebSocket: (url) => {
const socket = new WebSocket(url);
if (!new URL(url).searchParams.has("connectionId")) {
socket.on("message", (data) => {
if (JSON.parse(data.toString()).type === "sync") markReady();
});
}
return socket;
},
attachSocket: async (raw) =>
admitSecuredRelaySocket(
raw,
{
daemonKeyPair,
logger: pino({ level: "silent" }),
requireProtectedMode: () => false,
},
async ({ socket }) => {
async function rejectHello() {
try {
await socket.send(
JSON.stringify({
type: "hello.rejected",
reason: "password_required",
accepts: ["password"],
}),
);
sendCompleted = true;
if (delayMs) await delay(delayMs);
socket.close(4401, "Password required");
} catch {
socket.close(1011, "Diagnostic send failed");
}
}
socket.on("message", rejectHello);
},
),
});
try {
await Promise.race([
ready,
new Promise<never>((_resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Relay control did not become ready")),
10_000,
);
timeout.unref();
}),
]);
for (let attempt = 0; attempt < 3; attempt++) {
sendCompleted = false;
const frames: string[] = [];
const client = new DaemonClient({
url: buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
useTls: true,
serverId,
role: "client",
}),
clientId: `close-timing-${attempt}`,
clientType: "cli",
reconnect: { enabled: false },
connectTimeoutMs: 20_000,
e2ee: { enabled: true, daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey) },
webSocketFactory: (url) => {
const socket = new WebSocket(url);
socket.on("message", (data, binary) => frames.push(frameKind(data, binary)));
return socket;
},
});
let reason: string | null = null;
try {
await client.connect();
} catch (error) {
reason = getDaemonAuthFailureReason(error);
} finally {
await client.close();
}
const observation = { delayMs, reason, sendCompleted, frames };
observations.push(observation);
process.stdout.write(`${JSON.stringify(observation)}\n`);
}
} finally {
await controller.stop();
}
}
expect(observations.filter((item) => item.delayMs === 500)).toEqual(
Array.from({ length: 3 }, () => ({
delayMs: 500,
reason: "password_required",
sendCompleted: true,
frames: ["e2ee_ready", "encrypted-record"],
})),
);
expect(
observations.filter((item) => item.delayMs === 0).every((item) => item.sendCompleted),
).toBe(true);
},
120_000,
);
@@ -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");