mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a68a5b5b | ||
|
|
56448842f0 | ||
|
|
14e6384516 | ||
|
|
cafa04d3fb | ||
|
|
938c4e2632 | ||
|
|
d57096a456 | ||
|
|
ada7ba43f0 | ||
|
|
fc8f085b9e | ||
|
|
019e5279e9 | ||
|
|
4a3ff87eec | ||
|
|
a4f1df3aed |
@@ -223,6 +223,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
|
||||
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
@@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
@@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
async function holdLegacyAgentCreation(page: Page, port: number) {
|
||||
const readers = await loadSessionMessageReaders();
|
||||
const arrived = Promise.withResolvers<void>();
|
||||
let created = Promise.withResolvers<void>();
|
||||
let createRequestId: string | undefined;
|
||||
let release = () => {};
|
||||
let holding = true;
|
||||
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
const message = readers.client(frame);
|
||||
if (message?.type === "create_agent_request") createRequestId = message.requestId;
|
||||
if (holding && message?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
server.onMessage((frame) => {
|
||||
const message = readers.server(frame);
|
||||
if (
|
||||
message?.type === "status" &&
|
||||
message.payload.status === "agent_created" &&
|
||||
message.payload.requestId === createRequestId
|
||||
) {
|
||||
created.resolve();
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => arrived.promise,
|
||||
async waitForCreated() {
|
||||
// Agent-list membership includes initialization, before the initial prompt starts.
|
||||
await created.promise;
|
||||
created = Promise.withResolvers<void>();
|
||||
},
|
||||
release() {
|
||||
const send = release;
|
||||
release = () => {};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
addCustomModel,
|
||||
expectCustomModelRetained,
|
||||
openCustomModels,
|
||||
removeCustomModel,
|
||||
} from "../support/helpers/custom-models";
|
||||
|
||||
const longModelId = "custom-model-with-a-long-name-".repeat(6);
|
||||
const shortModelId = "custom-short";
|
||||
|
||||
for (const width of [1200, 390]) {
|
||||
test(`removes a long custom model at ${width}px without removing another model`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1200, height: 1000 });
|
||||
await openCustomModels(page);
|
||||
await addCustomModel(page, shortModelId);
|
||||
await addCustomModel(page, longModelId);
|
||||
|
||||
await test.step("remove the long model at the target width", async () => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await removeCustomModel(page, longModelId);
|
||||
await expectCustomModelRetained(page, shortModelId);
|
||||
});
|
||||
|
||||
await removeCustomModel(page, shortModelId);
|
||||
});
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
@@ -444,3 +444,25 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
|
||||
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("downloads a file over direct TCP with its original filename and bytes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fileName = "rapport données.bin";
|
||||
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
|
||||
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const downloaded = await downloadFileFromExplorer(page, fileName);
|
||||
const url = new URL(downloaded.url());
|
||||
expect(url.protocol).toBe("http:");
|
||||
expect(url.port).toBe(getE2EDaemonPort());
|
||||
expect(url.pathname).toBe("/api/files/download");
|
||||
expect(url.searchParams.get("token")).toBeTruthy();
|
||||
expect(downloaded.suggestedFilename()).toBe(fileName);
|
||||
const savedFile = testInfo.outputPath(fileName);
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
expectQuestionDismissEnabled,
|
||||
expectQuestionHidden,
|
||||
expectQuestionNavigationEnabled,
|
||||
expectQuestionOptionPreview,
|
||||
expectQuestionOptionSelected,
|
||||
expectQuestionPrimaryActionDisabled,
|
||||
expectQuestionPrimaryActionEnabled,
|
||||
@@ -61,6 +62,10 @@ test.describe("Question prompt pagination", () => {
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionPreview(page, {
|
||||
option: "Behind feature flag",
|
||||
preview: 'flags.enabled("new-surface")',
|
||||
});
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 3,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
switchWorkspaceViaSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
|
||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||
await expect(rows).toHaveCount(12);
|
||||
return rows.evaluateAll(
|
||||
(elements, prefix) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
|
||||
`sidebar-workspace-row-${getServerId()}:`,
|
||||
);
|
||||
}
|
||||
|
||||
async function navigateWorkspace(
|
||||
page: Page,
|
||||
direction: "next" | "previous",
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
|
||||
}
|
||||
|
||||
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const project = await seedWorkspace({
|
||||
repoPrefix: "relative-navigation-",
|
||||
title: "Workspace 01",
|
||||
});
|
||||
try {
|
||||
for (let index = 2; index <= 12; index += 1) {
|
||||
const created = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
title: `Workspace ${String(index).padStart(2, "0")}`,
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1100 });
|
||||
await gotoAppShell(page);
|
||||
const order = await readVisibleWorkspaceOrder(page);
|
||||
|
||||
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await navigateWorkspace(page, "next", order[9]);
|
||||
await navigateWorkspace(page, "previous", order[8]);
|
||||
});
|
||||
|
||||
await test.step("wrapping uses the true first and last visible workspace", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await navigateWorkspace(page, "previous", order[11]);
|
||||
await navigateWorkspace(page, "next", order[0]);
|
||||
});
|
||||
|
||||
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
|
||||
await page.keyboard.press("Alt+9");
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: order[8],
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
# Native question option preview
|
||||
|
||||
`native-question-option-preview.android.ad` checks that every line of a long option preview is reachable on Android. Before replay, open a mock agent prompted with `emit synthetic questions` so its question card shows the first question, on a phone-sized emulator (validated on a 1080×1920 API 35 AVD). The flow opens the `rollout` question, drags slowly up from inside the "Behind feature flag" preview, and asserts the preview's last line is visible.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad --platform android --serial emulator-5554
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "label=\"rollout\"" 20000
|
||||
press "label=\"rollout\""
|
||||
wait "id=\"question-form-option-preview\"" 10000
|
||||
gesture pan 570 1120 0 -440 600
|
||||
is visible "text=\"// LAST PREVIEW LINE\""
|
||||
close
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function openCustomModels(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model", exact: true }).click();
|
||||
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
|
||||
await page
|
||||
.getByTestId("add-custom-model-sheet")
|
||||
.getByRole("button", { name: "Add", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
|
||||
await expect(remove).toBeInViewport({ ratio: 1 });
|
||||
await remove.click();
|
||||
await expect(remove).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Download, type Page } from "@playwright/test";
|
||||
import { openFilesPanel } from "./workspace-tabs";
|
||||
|
||||
function fileExplorerTree(page: Page) {
|
||||
@@ -39,3 +39,10 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise<v
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadFileFromExplorer(page: Page, fileName: string): Promise<Download> {
|
||||
await fileExplorerEntry(page, fileName).click({ button: "right" });
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,15 @@ export async function expectQuestionOptionSelected(page: Page, option: string):
|
||||
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionPreview(
|
||||
page: Page,
|
||||
input: { option: string; preview: string },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
questionOption(page, input.option).getByTestId("question-form-option-preview"),
|
||||
).toContainText(input.preview);
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
|
||||
@@ -95,11 +95,11 @@ function CustomModelRow({
|
||||
|
||||
return (
|
||||
<View style={sheetStyles.modelRow}>
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
@@ -762,6 +762,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -807,6 +808,10 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
customModelText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
modelRowFiller: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -147,4 +147,29 @@ describe("question form card core", () => {
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps option previews so options can be compared", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which layout?",
|
||||
header: "Layout",
|
||||
options: [
|
||||
{
|
||||
label: "Tabs",
|
||||
description: "One tab per section",
|
||||
preview: "<Tabs>\n <Tab />\n</Tabs>",
|
||||
},
|
||||
{ label: "Single page" },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(questions?.[0]?.options).toEqual([
|
||||
{ label: "Tabs", description: "One tab per section", preview: "<Tabs>\n <Tab />\n</Tabs>" },
|
||||
{ label: "Single page", description: undefined, preview: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface QuestionFormQuestion {
|
||||
@@ -46,6 +47,7 @@ export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion
|
||||
options.push({
|
||||
label: o.label,
|
||||
description: typeof o.description === "string" ? o.description : undefined,
|
||||
preview: readOptionalString(o, "preview"),
|
||||
});
|
||||
}
|
||||
questions.push({
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -29,6 +30,9 @@ interface QuestionFormCardProps {
|
||||
}
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
// Native previews are uncapped: inside the option's Pressable, Android never hands a
|
||||
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
|
||||
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
|
||||
|
||||
function getQuestionInputPlaceholder({
|
||||
question,
|
||||
@@ -133,6 +137,20 @@ function QuestionOptionRow({
|
||||
{option.description ? (
|
||||
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
||||
) : null}
|
||||
{option.preview ? (
|
||||
// Shown for every option at once so they can be compared before picking.
|
||||
// Long lines wrap, as in Claude Code, since most previews are prose.
|
||||
<ScrollableCodeSurface
|
||||
tone="surface0"
|
||||
maxHeight={OPTION_PREVIEW_MAX_HEIGHT}
|
||||
horizontal={false}
|
||||
selectable={false}
|
||||
textStyle={styles.optionPreviewText}
|
||||
testID="question-form-option-preview"
|
||||
>
|
||||
{option.preview}
|
||||
</ScrollableCodeSurface>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -704,6 +722,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionPreviewText: {
|
||||
lineHeight: 20,
|
||||
...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null),
|
||||
},
|
||||
selectionControl: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { useDownloadStore } from "@/stores/download-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
|
||||
@@ -12,8 +12,9 @@ interface UseFileDownloadParams {
|
||||
/**
|
||||
* Returns a stable callback that downloads a single workspace file by its
|
||||
* workspace-relative path. Shared by the file explorer tree and the git diff
|
||||
* pane so both surfaces download through the same host token + download-store
|
||||
* pipeline instead of duplicating the plumbing.
|
||||
* pane so both surfaces download through the same download-store pipeline instead
|
||||
* of duplicating the plumbing: the HTTP token download over a direct TCP connection,
|
||||
* the session socket over anything else.
|
||||
*/
|
||||
export function useFileDownload({
|
||||
serverId,
|
||||
@@ -30,7 +31,7 @@ export function useFileDownload({
|
||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const { requestFileDownloadToken } = useFileExplorerActions({
|
||||
const { requestFileDownloadToken, requestFileBytes } = useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
@@ -48,9 +49,18 @@ export function useFileDownload({
|
||||
fileName,
|
||||
path,
|
||||
daemonProfile,
|
||||
activeConnectionId: getHostRuntimeStore().getSnapshot(serverId)?.activeConnectionId ?? null,
|
||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
||||
readFile: requestFileBytes,
|
||||
});
|
||||
},
|
||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||
[
|
||||
daemonProfile,
|
||||
requestFileBytes,
|
||||
requestFileDownloadToken,
|
||||
serverId,
|
||||
startDownload,
|
||||
workspaceScopeId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,6 +253,21 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
[client, normalizedWorkspaceRoot, t],
|
||||
);
|
||||
|
||||
const requestFileBytes = useCallback(
|
||||
async (path: string, maxBytes: number) => {
|
||||
if (!normalizedWorkspaceRoot) {
|
||||
throw new Error(t("workspace.fileExplorer.states.unavailable"));
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
// No request timeout: a large file over a slow relay legitimately takes longer than the
|
||||
// 60 s default, and a dead connection still rejects on disconnect.
|
||||
return client.readFile(normalizedWorkspaceRoot, path, undefined, maxBytes, 0);
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, t],
|
||||
);
|
||||
|
||||
const createEntry = useCallback(
|
||||
async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => {
|
||||
if (!client || !normalizedWorkspaceRoot) {
|
||||
@@ -342,6 +357,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
requestFileDownloadToken,
|
||||
requestFileBytes,
|
||||
createEntry,
|
||||
renameEntry,
|
||||
duplicateEntry,
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
MAX_SESSION_DOWNLOAD_BYTES,
|
||||
useDownloadStore,
|
||||
type Download,
|
||||
type DownloadedBytes,
|
||||
} from "@/stores/download-store";
|
||||
import type {
|
||||
DirectTcpHostConnection,
|
||||
HostProfile,
|
||||
RelayHostConnection,
|
||||
} from "@/types/host-connection";
|
||||
|
||||
const FILE_BYTES = new TextEncoder().encode("relay download payload");
|
||||
const FILE_PATH = "reports/données été.bin";
|
||||
const FILE_NAME = "données été.bin";
|
||||
const MIME_TYPE = "application/octet-stream";
|
||||
|
||||
const relayConnection: RelayHostConnection = {
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "cHVibGljLWtleQ==",
|
||||
};
|
||||
|
||||
// The #3753 shape: the relay hostname stored as a directTcp entry that nothing can reach.
|
||||
const staleTcpConnection: DirectTcpHostConnection = {
|
||||
id: "direct:relay.paseo.sh:443",
|
||||
type: "directTcp",
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
};
|
||||
|
||||
const localTcpConnection: DirectTcpHostConnection = {
|
||||
id: "direct:localhost:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "localhost:6767",
|
||||
};
|
||||
|
||||
function profileWith(connections: HostProfile["connections"]): HostProfile {
|
||||
return {
|
||||
serverId: "srv_remote",
|
||||
label: "Remote",
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
connections,
|
||||
preferredConnectionId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function createTokenRequest(size: number | null = FILE_BYTES.byteLength) {
|
||||
const requested: string[] = [];
|
||||
return {
|
||||
requested,
|
||||
request: async (path: string) => {
|
||||
requested.push(path);
|
||||
return { token: "tok", fileName: FILE_NAME, mimeType: MIME_TYPE, size, error: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFileReader(read: () => Promise<{ bytes: Uint8Array; size: number }>) {
|
||||
const requested: string[] = [];
|
||||
return {
|
||||
requested,
|
||||
read: async (path: string) => {
|
||||
requested.push(path);
|
||||
return read();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFileSaver() {
|
||||
const savedFiles: DownloadedBytes[] = [];
|
||||
return {
|
||||
savedFiles,
|
||||
save: async (file: DownloadedBytes) => {
|
||||
savedFiles.push(file);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface StartInput {
|
||||
profile: HostProfile;
|
||||
activeConnectionId: string | null;
|
||||
token?: ReturnType<typeof createTokenRequest>;
|
||||
reader?: ReturnType<typeof createFileReader>;
|
||||
saver?: ReturnType<typeof createFileSaver>;
|
||||
}
|
||||
|
||||
async function startDownload(input: StartInput): Promise<Download | undefined> {
|
||||
const token = input.token ?? createTokenRequest();
|
||||
const reader =
|
||||
input.reader ??
|
||||
createFileReader(async () => ({ bytes: FILE_BYTES, size: FILE_BYTES.byteLength }));
|
||||
const saver = input.saver ?? createFileSaver();
|
||||
await useDownloadStore.getState().startDownload({
|
||||
serverId: "srv_remote",
|
||||
scopeId: "workspace-1",
|
||||
fileName: FILE_NAME,
|
||||
path: FILE_PATH,
|
||||
daemonProfile: input.profile,
|
||||
activeConnectionId: input.activeConnectionId,
|
||||
requestFileDownloadToken: token.request,
|
||||
readFile: reader.read,
|
||||
saveFile: saver.save,
|
||||
});
|
||||
const { activeDownloadId, downloads } = useDownloadStore.getState();
|
||||
return activeDownloadId ? downloads.get(activeDownloadId) : undefined;
|
||||
}
|
||||
|
||||
describe("download store transport selection", () => {
|
||||
beforeEach(() => {
|
||||
useDownloadStore.setState({ downloads: new Map(), activeDownloadId: null });
|
||||
});
|
||||
|
||||
it("downloads over the session when the active connection is the relay", async () => {
|
||||
const token = createTokenRequest();
|
||||
const reader = createFileReader(async () => ({
|
||||
bytes: FILE_BYTES,
|
||||
size: FILE_BYTES.byteLength,
|
||||
}));
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
token,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({ fileName: FILE_NAME, status: "complete" });
|
||||
expect(token.requested).toEqual([FILE_PATH]);
|
||||
expect(reader.requested).toEqual([FILE_PATH]);
|
||||
expect(saver.savedFiles).toEqual([
|
||||
{ bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE },
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds the session read even when the token reports a smaller file", async () => {
|
||||
const readRequests: Array<{ path: string; maxBytes: number }> = [];
|
||||
const saver = createFileSaver();
|
||||
await useDownloadStore.getState().startDownload({
|
||||
serverId: "srv_remote",
|
||||
scopeId: "workspace-1",
|
||||
fileName: FILE_NAME,
|
||||
path: FILE_PATH,
|
||||
daemonProfile: profileWith([relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
requestFileDownloadToken: createTokenRequest(1).request,
|
||||
readFile: async (path: string, maxBytes: number) => {
|
||||
readRequests.push({ path, maxBytes });
|
||||
return { bytes: FILE_BYTES, size: FILE_BYTES.byteLength };
|
||||
},
|
||||
saveFile: saver.save,
|
||||
});
|
||||
expect(readRequests).toEqual([{ path: FILE_PATH, maxBytes: MAX_SESSION_DOWNLOAD_BYTES }]);
|
||||
expect(saver.savedFiles).toEqual([
|
||||
{ bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a directTcp entry that is not the active connection", async () => {
|
||||
const reader = createFileReader(async () => ({
|
||||
bytes: FILE_BYTES,
|
||||
size: FILE_BYTES.byteLength,
|
||||
}));
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([staleTcpConnection, relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({ status: "complete" });
|
||||
expect(reader.requested).toEqual([FILE_PATH]);
|
||||
expect(saver.savedFiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the HTTP token download when the active connection is directTcp", async () => {
|
||||
const reader = createFileReader(async () => ({
|
||||
bytes: FILE_BYTES,
|
||||
size: FILE_BYTES.byteLength,
|
||||
}));
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([localTcpConnection, relayConnection]),
|
||||
activeConnectionId: localTcpConnection.id,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({ status: "complete" });
|
||||
expect(reader.requested).toEqual([]);
|
||||
expect(saver.savedFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails the download and saves nothing when the session read fails", async () => {
|
||||
const reader = createFileReader(async () => {
|
||||
throw new Error("Connection lost");
|
||||
});
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({ status: "error", message: "Connection lost" });
|
||||
expect(saver.savedFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a file over the session size limit before reading it", async () => {
|
||||
const token = createTokenRequest(MAX_SESSION_DOWNLOAD_BYTES + 1);
|
||||
const reader = createFileReader(async () => ({
|
||||
bytes: FILE_BYTES,
|
||||
size: FILE_BYTES.byteLength,
|
||||
}));
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
token,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({
|
||||
status: "error",
|
||||
message: i18n.t("composer.errors.fileTooLarge", { fileName: FILE_NAME, size: "64 MB" }),
|
||||
});
|
||||
expect(download?.message).toContain("64 MB");
|
||||
expect(reader.requested).toEqual([]);
|
||||
expect(saver.savedFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails instead of saving an empty file when the daemon returns no content", async () => {
|
||||
const reader = createFileReader(async () => ({ bytes: new Uint8Array(), size: 6 }));
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({
|
||||
status: "error",
|
||||
message: "File transfer incomplete: expected 6 bytes, received 0.",
|
||||
});
|
||||
expect(saver.savedFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,16 @@ interface DownloadProgress {
|
||||
eta: number;
|
||||
}
|
||||
|
||||
// The session path holds the file in memory two to three times over (client chunks, the
|
||||
// concatenated result, then the Blob or native write). Lift once the client streams chunks to disk.
|
||||
export const MAX_SESSION_DOWNLOAD_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export interface DownloadedBytes {
|
||||
bytes: Uint8Array;
|
||||
fileName: string;
|
||||
mimeType: string | null;
|
||||
}
|
||||
|
||||
export interface Download {
|
||||
id: string;
|
||||
serverId: string;
|
||||
@@ -37,12 +47,18 @@ interface DownloadState {
|
||||
fileName: string;
|
||||
path: string;
|
||||
daemonProfile: HostProfile | undefined;
|
||||
/** Connection the host runtime is using right now; `null` while disconnected. */
|
||||
activeConnectionId: string | null;
|
||||
requestFileDownloadToken: (path: string) => Promise<{
|
||||
token: string | null;
|
||||
fileName: string | null;
|
||||
mimeType: string | null;
|
||||
size: number | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
/** Reads the whole file over the session socket; used when no direct HTTP endpoint is active. */
|
||||
readFile: (path: string, maxBytes: number) => Promise<{ bytes: Uint8Array; size: number }>;
|
||||
saveFile?: typeof saveDownloadedBytes;
|
||||
}) => Promise<void>;
|
||||
|
||||
updateProgress: (id: string, progress: DownloadProgress) => void;
|
||||
@@ -66,7 +82,10 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
||||
fileName,
|
||||
path,
|
||||
daemonProfile,
|
||||
activeConnectionId,
|
||||
requestFileDownloadToken,
|
||||
readFile,
|
||||
saveFile = saveDownloadedBytes,
|
||||
}) => {
|
||||
const id = generateDownloadId();
|
||||
const download: Download = {
|
||||
@@ -89,12 +108,42 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
||||
throw new Error(tokenResponse.error ?? i18n.t("downloads.requestTokenFailed"));
|
||||
}
|
||||
|
||||
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
|
||||
const resolvedFileName = tokenResponse.fileName ?? fileName;
|
||||
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile, activeConnectionId);
|
||||
if (!downloadTarget) {
|
||||
// Relay, SSH, socket and pipe hosts have no HTTP endpoint: read the file over the
|
||||
// session socket that is already connected (#543).
|
||||
if (
|
||||
typeof tokenResponse.size === "number" &&
|
||||
tokenResponse.size > MAX_SESSION_DOWNLOAD_BYTES
|
||||
) {
|
||||
throw new Error(
|
||||
i18n.t("composer.errors.fileTooLarge", { fileName: resolvedFileName, size: "64 MB" }),
|
||||
);
|
||||
}
|
||||
const { bytes, size } = await readFile(path, MAX_SESSION_DOWNLOAD_BYTES);
|
||||
// A daemon without a binary channel answers `encoding: "none"` for a binary file: no
|
||||
// content, real size. Never save that as a complete download.
|
||||
if (bytes.byteLength !== size) {
|
||||
throw new Error(
|
||||
`File transfer incomplete: expected ${size} bytes, received ${bytes.byteLength}.`,
|
||||
);
|
||||
}
|
||||
const savedUri = await saveFile({
|
||||
bytes,
|
||||
fileName: resolvedFileName,
|
||||
mimeType: tokenResponse.mimeType,
|
||||
});
|
||||
get().completeDownload(id);
|
||||
if (savedUri !== null) {
|
||||
await shareSavedFile(savedUri, tokenResponse.mimeType, resolvedFileName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!downloadTarget.baseUrl) {
|
||||
throw new Error(i18n.t("downloads.hostUnavailable"));
|
||||
}
|
||||
|
||||
const resolvedFileName = tokenResponse.fileName ?? fileName;
|
||||
const downloadUrl = buildDownloadUrl(
|
||||
downloadTarget.baseUrl,
|
||||
tokenResponse.token,
|
||||
@@ -146,14 +195,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
||||
|
||||
get().completeDownload(id);
|
||||
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(result.uri, {
|
||||
mimeType: tokenResponse.mimeType ?? undefined,
|
||||
dialogTitle: resolvedFileName
|
||||
? i18n.t("downloads.shareFileNamed", { fileName: resolvedFileName })
|
||||
: i18n.t("downloads.shareFile"),
|
||||
});
|
||||
}
|
||||
await shareSavedFile(result.uri, tokenResponse.mimeType, resolvedFileName);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : i18n.t("downloads.failed");
|
||||
if (isWeb) {
|
||||
@@ -244,10 +286,18 @@ interface DownloadTarget {
|
||||
authCredentials: { username: string; password: string } | null;
|
||||
}
|
||||
|
||||
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
|
||||
const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null;
|
||||
if (!connection) {
|
||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
||||
/**
|
||||
* The HTTP download route is only reachable over a direct TCP connection. Returns `null` when
|
||||
* the connection in use is anything else (relay, SSH, socket, pipe, or none yet), so the caller
|
||||
* reads the file over the session socket instead.
|
||||
*/
|
||||
function resolveDaemonDownloadTarget(
|
||||
daemon: HostProfile | undefined,
|
||||
activeConnectionId: string | null,
|
||||
): DownloadTarget | null {
|
||||
const connection = daemon?.connections.find((conn) => conn.id === activeConnectionId);
|
||||
if (connection?.type !== "directTcp") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
@@ -299,6 +349,47 @@ function buildDownloadUrl(
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves bytes read over the session. Web and Electron hand them to the browser's download
|
||||
* manager and return `null`; native writes the file and returns its URI for the share sheet.
|
||||
*/
|
||||
async function saveDownloadedBytes({
|
||||
bytes,
|
||||
fileName,
|
||||
mimeType,
|
||||
}: DownloadedBytes): Promise<string | null> {
|
||||
if (isWeb) {
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([buffer], { type: mimeType ?? "application/octet-stream" }),
|
||||
);
|
||||
triggerBrowserDownload(url, fileName);
|
||||
// The browser may still be reading the blob while its save prompt is open.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
return null;
|
||||
}
|
||||
// Resolve and write in one tick so two downloads of the same name cannot pick the same path.
|
||||
const targetFile = resolveDownloadTargetFile(fileName);
|
||||
targetFile.write(bytes);
|
||||
return targetFile.uri;
|
||||
}
|
||||
|
||||
async function shareSavedFile(
|
||||
uri: string,
|
||||
mimeType: string | null,
|
||||
fileName: string,
|
||||
): Promise<void> {
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(uri, {
|
||||
mimeType: mimeType ?? undefined,
|
||||
dialogTitle: fileName
|
||||
? i18n.t("downloads.shareFileNamed", { fileName })
|
||||
: i18n.t("downloads.shareFile"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function triggerBrowserDownload(url: string, fileName: string) {
|
||||
if (typeof document === "undefined") {
|
||||
if (typeof window !== "undefined") {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||
import { routeKeyboardShortcut } from "@/keyboard/route-shortcut";
|
||||
|
||||
import {
|
||||
buildSidebarShortcutModel,
|
||||
@@ -65,6 +66,46 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
||||
}
|
||||
|
||||
describe("buildSidebarShortcutModel", () => {
|
||||
it.each([
|
||||
[9, 1, 10],
|
||||
[10, -1, 9],
|
||||
[12, 1, 1],
|
||||
[1, -1, 12],
|
||||
] as const)(
|
||||
"navigates from workspace %i by %i to workspace %i across the full visible sidebar",
|
||||
(current, delta, next) => {
|
||||
const workspaces = Array.from({ length: 12 }, (_, index) =>
|
||||
workspace({
|
||||
serverId: "s",
|
||||
workspaceId: `ws-${index + 1}`,
|
||||
workspaceDirectory: `/repo/w${index + 1}`,
|
||||
name: `w${index + 1}`,
|
||||
}),
|
||||
);
|
||||
const model = buildSidebarShortcutModel({
|
||||
projects: [project("p", workspaces)],
|
||||
collapsedProjectKeys: new Set<string>(),
|
||||
});
|
||||
|
||||
expect(
|
||||
routeKeyboardShortcut(
|
||||
{ action: "workspace.navigate.relative", payload: { delta } },
|
||||
{
|
||||
pathname: `/h/s/workspace/ws-${current}`,
|
||||
isMobile: false,
|
||||
sidebarShortcutTargets: model.shortcutTargets,
|
||||
navigationActiveWorkspace: null,
|
||||
commandCenterOpen: false,
|
||||
shortcutsDialogOpen: false,
|
||||
},
|
||||
),
|
||||
).toEqual({ kind: "navigate-workspace", serverId: "s", workspaceId: `ws-${next}` });
|
||||
expect(model.shortcutIndexByWorkspaceKey.size).toBe(9);
|
||||
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
|
||||
expect(model.shortcutIndexByWorkspaceKey.has("s:ws-10")).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
|
||||
const projects = [
|
||||
project("p1", [
|
||||
@@ -111,7 +152,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-repo2-main")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("limits shortcuts to 9", () => {
|
||||
it("keeps all traversal targets while numbering only the first 9", () => {
|
||||
const workspaces = Array.from({ length: 20 }, (_, index) =>
|
||||
workspace({
|
||||
serverId: "s",
|
||||
@@ -127,9 +168,11 @@ describe("buildSidebarShortcutModel", () => {
|
||||
collapsedProjectKeys: new Set<string>(),
|
||||
});
|
||||
|
||||
expect(model.shortcutTargets).toHaveLength(9);
|
||||
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" });
|
||||
expect(model.shortcutTargets).toHaveLength(20);
|
||||
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
|
||||
expect(model.shortcutTargets[9]).toEqual({ serverId: "s", workspaceId: "ws-10" });
|
||||
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
|
||||
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-10")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("excludes a collapsed project's workspaces regardless of project kind", () => {
|
||||
|
||||
@@ -70,13 +70,10 @@ export function buildSidebarShortcutSections(input: {
|
||||
}
|
||||
|
||||
for (const workspace of section.workspaces) {
|
||||
if (shortcutTargets.length >= maxShortcuts) {
|
||||
break;
|
||||
}
|
||||
|
||||
const shortcutNumber = shortcutTargets.length + 1;
|
||||
shortcutTargets.push(createShortcutTarget(workspace));
|
||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber);
|
||||
if (shortcutTargets.length <= maxShortcuts) {
|
||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutTargets.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import {
|
||||
createClientChannel,
|
||||
type EncryptedChannel,
|
||||
@@ -8,7 +9,11 @@ import type {
|
||||
DaemonTransportFactory,
|
||||
TransportLogger,
|
||||
} from "./daemon-client-transport-types.js";
|
||||
import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js";
|
||||
import {
|
||||
extractRelayMessage,
|
||||
normalizeTransportPayload,
|
||||
messageTooLargeError,
|
||||
} from "./daemon-client-transport-utils.js";
|
||||
|
||||
type OpenHandler = () => void;
|
||||
type CloseHandler = (event?: unknown) => void;
|
||||
@@ -137,7 +142,13 @@ export function createEncryptedTransport(
|
||||
if (!channel) {
|
||||
throw new Error("Encrypted channel not ready");
|
||||
}
|
||||
void channel.send(normalizeTransportPayload(data)).catch((error) => {
|
||||
const payload = normalizeTransportPayload(data);
|
||||
// Reject synchronously so the requesting operation fails without closing the transport.
|
||||
const wireBytes = channel.outboundWireByteLength(payload);
|
||||
if (wireBytes > MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) {
|
||||
throw messageTooLargeError(wireBytes, MAX_RELAY_WEBSOCKET_MESSAGE_BYTES);
|
||||
}
|
||||
void channel.send(payload).catch((error) => {
|
||||
emitError(error);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -133,3 +133,10 @@ export function encodeUtf8String(value: string): Uint8Array {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function messageTooLargeError(size: number, limit: number): Error {
|
||||
const toMiB = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1);
|
||||
return new Error(
|
||||
`Message is too large to send (${toMiB(size)} MiB). The limit is ${toMiB(limit)} MiB, so send fewer or smaller attachments.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2801,6 +2801,102 @@ test("readFile drops an old daemon's over-budget binary chunks and reports the r
|
||||
await expect(responsePromise).rejects.toThrow("File is too large to display");
|
||||
});
|
||||
|
||||
async function connectFileClient(clientId: string) {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId,
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
return { mock, client };
|
||||
}
|
||||
|
||||
function sendBinaryFile(
|
||||
mock: ReturnType<typeof createMockTransport>,
|
||||
requestId: string,
|
||||
announcedSize: number,
|
||||
chunks: string[],
|
||||
) {
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId,
|
||||
metadata: {
|
||||
mime: "application/octet-stream",
|
||||
size: announcedSize,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const chunk of chunks) {
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId,
|
||||
payload: new TextEncoder().encode(chunk),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test("readFile with timeout 0 outlives the default request timeout", async () => {
|
||||
useHeartbeatClock();
|
||||
const { mock, client } = await connectFileClient("clsk_file_no_timeout");
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "slow.bin", "req-slow", undefined, 0);
|
||||
await vi.advanceTimersByTimeAsync(60_001);
|
||||
|
||||
sendBinaryFile(mock, "req-slow", 5, ["hello"]);
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-slow" }),
|
||||
);
|
||||
|
||||
const result = await responsePromise;
|
||||
expect(result.size).toBe(5);
|
||||
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
||||
});
|
||||
|
||||
test("readFile rejects a binary transfer that ends before the announced size", async () => {
|
||||
const { mock, client } = await connectFileClient("clsk_file_short");
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "short.bin", "req-short");
|
||||
sendBinaryFile(mock, "req-short", 10, ["hello"]);
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-short" }),
|
||||
);
|
||||
|
||||
await expect(responsePromise).rejects.toThrow(
|
||||
"File transfer incomplete: expected 10 bytes, received 5.",
|
||||
);
|
||||
});
|
||||
|
||||
test("readFile rejects a binary transfer that exceeds the announced size", async () => {
|
||||
const { mock, client } = await connectFileClient("clsk_file_long");
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "long.bin", "req-long");
|
||||
const settled = responsePromise.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
sendBinaryFile(mock, "req-long", 4, ["hello!"]);
|
||||
|
||||
expect(() =>
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-long" }),
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(await settled).toMatchObject({
|
||||
message: "File transfer incomplete: expected 4 bytes, received 6.",
|
||||
});
|
||||
});
|
||||
|
||||
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
@@ -7527,3 +7623,29 @@ test.each([false, true])(
|
||||
await send;
|
||||
},
|
||||
);
|
||||
|
||||
test("measures an outbound message in UTF-8 bytes against the daemon's message limit", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "message_limit_unit_test",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
const mib = 1024 * 1024;
|
||||
|
||||
// "é" is one UTF-16 unit but two UTF-8 bytes, so this text is ~98 MiB on the wire.
|
||||
void client.sendMessage("agent-1", "é".repeat(49 * mib)).catch(() => undefined);
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
|
||||
// ~60M characters is under the 100 MiB limit; ~120 MiB of UTF-8 is over it.
|
||||
await expect(client.sendMessage("agent-1", "é".repeat(60 * mib))).rejects.toThrow(
|
||||
"Message is too large to send (120.0 MiB). The limit is 100.0 MiB",
|
||||
);
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
expect(client.getConnectionState().status).toBe("connected");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { messageTooLargeError } from "./daemon-client-transport-utils.js";
|
||||
import { legacyUsageIcon } from "./legacy-usage-icons.js";
|
||||
import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js";
|
||||
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
|
||||
@@ -13,6 +14,7 @@ import type { z } from "zod";
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import type { ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
|
||||
import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import {
|
||||
AgentCreateFailedStatusPayloadSchema,
|
||||
AgentCreatedStatusPayloadSchema,
|
||||
@@ -1132,6 +1134,19 @@ function getTransportFrameSize(frame: string | Uint8Array | ArrayBuffer): number
|
||||
return frame.byteLength;
|
||||
}
|
||||
|
||||
function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): void {
|
||||
if (typeof frame !== "string") {
|
||||
if (frame.byteLength > MAX_WEBSOCKET_MESSAGE_BYTES)
|
||||
throw messageTooLargeError(frame.byteLength, MAX_WEBSOCKET_MESSAGE_BYTES);
|
||||
return;
|
||||
}
|
||||
// UTF-8 needs at most three bytes per UTF-16 unit, so only a string near the limit is measured.
|
||||
if (frame.length * 3 <= MAX_WEBSOCKET_MESSAGE_BYTES) return;
|
||||
const size = new TextEncoder().encode(frame).byteLength;
|
||||
if (size > MAX_WEBSOCKET_MESSAGE_BYTES)
|
||||
throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES);
|
||||
}
|
||||
|
||||
function describeInboundTransportFrame(
|
||||
frame: unknown,
|
||||
rawBytes: Uint8Array | null,
|
||||
@@ -1767,6 +1782,8 @@ export class DaemonClient {
|
||||
if (!this.transport) {
|
||||
throw new DaemonConnectionError("Transport not connected");
|
||||
}
|
||||
// The daemon closes the connection on an oversized message, and creations resubmit on reconnect.
|
||||
assertTransportFrameFits(frame);
|
||||
const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
|
||||
kind: typeof frame === "string" ? "text" : "binary",
|
||||
size: String(getTransportFrameSize(frame)),
|
||||
@@ -4741,6 +4758,7 @@ export class DaemonClient {
|
||||
requestId?: string,
|
||||
acceptBinary = false,
|
||||
maxBytes?: number,
|
||||
timeout?: number,
|
||||
): Promise<FileExplorerPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
@@ -4753,6 +4771,7 @@ export class DaemonClient {
|
||||
...(maxBytes ? { maxBytes } : {}),
|
||||
},
|
||||
responseType: "file_explorer_response",
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4771,11 +4790,17 @@ export class DaemonClient {
|
||||
return payload.directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* `timeout` bounds the whole transfer, not each frame. `0` disables the timer (as
|
||||
* `workspace.create` does) for files that legitimately take longer than the default over a
|
||||
* slow link; a dead connection still rejects on disconnect.
|
||||
*/
|
||||
async readFile(
|
||||
cwd: string,
|
||||
path: string,
|
||||
requestId?: string,
|
||||
maxBytes?: number,
|
||||
timeout?: number,
|
||||
): Promise<FileReadResult> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
|
||||
@@ -4787,6 +4812,7 @@ export class DaemonClient {
|
||||
resolvedRequestId,
|
||||
true,
|
||||
maxBytes,
|
||||
timeout,
|
||||
);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
@@ -6523,6 +6549,18 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
|
||||
const received = transfer.chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
||||
if (received !== transfer.size) {
|
||||
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||
this.rejectWaitersForRequestId(
|
||||
frame.requestId,
|
||||
new Error(
|
||||
`File transfer incomplete: expected ${transfer.size} bytes, received ${received}.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = concatByteChunks(transfer.chunks, transfer.size);
|
||||
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||
this.completedBinaryFileReads.set(frame.requestId, {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const modulePath = fileURLToPath(new URL("./console-streams.ts", import.meta.url));
|
||||
|
||||
type Output = "stdout" | "stderr";
|
||||
|
||||
async function writeToConsole(input: { closed?: Output; invalidStdout?: boolean } = {}) {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"-e",
|
||||
`
|
||||
const { protectConsoleStreams } = require(${JSON.stringify(modulePath)});
|
||||
protectConsoleStreams();
|
||||
process.send("ready");
|
||||
process.once("message", () => {
|
||||
if (${JSON.stringify(input.invalidStdout)}) require("node:fs").closeSync(1);
|
||||
console.info("desktop info");
|
||||
console.warn("desktop warning");
|
||||
setImmediate(() => { process.send("alive"); process.disconnect(); });
|
||||
});
|
||||
`,
|
||||
],
|
||||
{
|
||||
cwd: fileURLToPath(new URL(".", import.meta.url)),
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "FORCE_COLOR")),
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let alive = false;
|
||||
child.stdout.setEncoding("utf8").on("data", (text: string) => {
|
||||
stdout += text;
|
||||
});
|
||||
child.stderr.setEncoding("utf8").on("data", (text: string) => {
|
||||
stderr += text;
|
||||
});
|
||||
child.on("message", (message) => {
|
||||
if (message === "ready") {
|
||||
if (input.closed) child[input.closed].destroy();
|
||||
child.send("write");
|
||||
}
|
||||
if (message === "alive") alive = true;
|
||||
});
|
||||
const code = await new Promise<number | null>((resolve, reject) => {
|
||||
child.once("close", resolve);
|
||||
child.once("error", reject);
|
||||
});
|
||||
return { code, stdout, stderr, alive };
|
||||
}
|
||||
|
||||
describe("desktop console streams", () => {
|
||||
for (const stream of ["stdout", "stderr"] as const) {
|
||||
it(`keeps running when the launcher closes ${stream}`, async () => {
|
||||
const result = await writeToConsole({ closed: stream });
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.alive).toBe(true);
|
||||
expect(result[stream === "stdout" ? "stderr" : "stdout"]).toBe(
|
||||
stream === "stdout" ? "desktop warning\n" : "desktop info\n",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Windows stdout uses a native handle rather than the POSIX fd closed here.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not suppress a console failure other than a closed pipe",
|
||||
async () => {
|
||||
const result = await writeToConsole({ invalidStdout: true });
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.alive).toBe(false);
|
||||
expect(result.stderr).toContain("EBADF");
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves logging while the console has readers", async () => {
|
||||
expect(await writeToConsole()).toEqual({
|
||||
code: 0,
|
||||
stdout: "desktop info\n",
|
||||
stderr: "desktop warning\n",
|
||||
alive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
function ignoreClosedConsole(error: NodeJS.ErrnoException): void {
|
||||
if (error.code !== "EPIPE") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep desktop logging independent of the program that launched its console. */
|
||||
export function protectConsoleStreams(): void {
|
||||
process.stdout.on("error", ignoreClosedConsole);
|
||||
process.stderr.on("error", ignoreClosedConsole);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { protectConsoleStreams } from "./diagnostics/console-streams.js";
|
||||
protectConsoleStreams();
|
||||
|
||||
process.emitWarning = (() => {}) as typeof process.emitWarning;
|
||||
|
||||
import log from "electron-log/main";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Largest WebSocket message the daemon accepts. A bigger one closes the connection with 1009. */
|
||||
export const MAX_WEBSOCKET_MESSAGE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/** Largest encrypted frame the relay accepts. */
|
||||
export const MAX_RELAY_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024;
|
||||
@@ -11,9 +11,10 @@ OPENAI_TTS_BASE_URL=
|
||||
|
||||
# TTS Configuration (optional - defaults shown)
|
||||
TTS_VOICE=alloy
|
||||
# Available voices: alloy, echo, fable, onyx, nova, shimmer
|
||||
# Any voice the server knows: OpenAI ships alloy, echo, fable, onyx, nova, shimmer
|
||||
TTS_MODEL=tts-1
|
||||
# Available models: tts-1 (faster), tts-1-hd (higher quality)
|
||||
# OpenAI ships tts-1 (faster) and tts-1-hd (higher quality); self-hosted
|
||||
# servers accept whatever model id they serve. Case sensitive.
|
||||
|
||||
# Server Configuration
|
||||
# Location for runtime state (agents.json, etc.). Defaults to ~/.paseo
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import type { AgentSession, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
const sessions: AgentSession[] = [];
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) await session.close();
|
||||
for (const cwd of directories.splice(0)) rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("a marked progress update stays assistant text when the session is reopened", async () => {
|
||||
const markedTexts = new Set<string>();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino(
|
||||
{ level: "trace" },
|
||||
{
|
||||
write(line: string) {
|
||||
const record = JSON.parse(line);
|
||||
const event = record.rawEvent;
|
||||
if (event?.type !== "assistant") return;
|
||||
for (const index of event.narration_block_indexes ?? []) {
|
||||
const text = event.message.content[index]?.thinking;
|
||||
if (text) markedTexts.add(text);
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
});
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-progress-replay-"));
|
||||
directories.push(cwd);
|
||||
for (const [name, text] of [
|
||||
["a", "Plan A\nCost: 3 credits\nTime: 2 days\nRisk: needs a manual review step\n"],
|
||||
["b", "Plan B\nCost: 5 credits\nTime: 1 day\nRisk: none known\n"],
|
||||
["c", "Plan C\nCost: 2 credits\nTime: 4 days\nRisk: depends on an external vendor\n"],
|
||||
])
|
||||
writeFileSync(path.join(cwd, `plan-${name}.txt`), text);
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
model: "claude-opus-5-5",
|
||||
thinkingOptionId: "xhigh",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
sessions.push(session);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "turn_failed") reject(new Error(event.error));
|
||||
if (event.type === "permission_requested" || event.type === "turn_completed") resolve();
|
||||
});
|
||||
void session
|
||||
.startTurn(
|
||||
"There are three plan files in this directory: plan-a.txt, plan-b.txt and plan-c.txt. Read each one, tell me how they compare on cost, time and risk, and then ask me which plan to pick using your AskUserQuestion tool. Keep me posted as you go.",
|
||||
)
|
||||
.catch(reject);
|
||||
});
|
||||
expect(
|
||||
markedTexts.size,
|
||||
"provider must produce a marked update for this reproduction",
|
||||
).toBeGreaterThan(0);
|
||||
const persistence = session.describePersistence();
|
||||
expect(persistence).not.toBeNull();
|
||||
await session.close();
|
||||
sessions.splice(sessions.indexOf(session), 1);
|
||||
const resumed = await client.resumeSession(persistence!, { cwd });
|
||||
sessions.push(resumed);
|
||||
const items: AgentTimelineItem[] = [];
|
||||
for await (const event of resumed.streamHistory()) {
|
||||
if (event.type === "timeline") items.push(event.item);
|
||||
}
|
||||
for (const text of markedTexts) {
|
||||
expect(items).toContainEqual(expect.objectContaining({ type: "assistant_message", text }));
|
||||
}
|
||||
}, 280_000);
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
// Reproduces #6199 through the real Claude provider. Claude Opus 5.5 writes the
|
||||
// text it means for the user between tool calls as a "progress update": a
|
||||
// thinking block that Claude Code marks with `narration_block_indexes`.
|
||||
const PROMPT =
|
||||
"There are three plan files in this directory: plan-a.txt, plan-b.txt and plan-c.txt. Read each one, tell me how they compare on cost, time and risk, and then ask me which plan to pick using your AskUserQuestion tool. Keep me posted as you go.";
|
||||
|
||||
const handles: Array<{ cwd: string; session: AgentSession }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const handle of handles.splice(0)) {
|
||||
await handle.session.close().catch(() => undefined);
|
||||
rmSync(handle.cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writePlans(cwd: string): void {
|
||||
writeFileSync(
|
||||
path.join(cwd, "plan-a.txt"),
|
||||
"Plan A\nCost: 3 credits\nTime: 2 days\nRisk: needs a manual review step\n",
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(cwd, "plan-b.txt"),
|
||||
"Plan B\nCost: 5 credits\nTime: 1 day\nRisk: none known\n",
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(cwd, "plan-c.txt"),
|
||||
"Plan C\nCost: 2 credits\nTime: 4 days\nRisk: depends on an external vendor\n",
|
||||
);
|
||||
}
|
||||
|
||||
test("the comparison written before AskUserQuestion reaches the timeline as assistant text", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "claude-progress-updates-"));
|
||||
writePlans(cwd);
|
||||
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
model: "claude-opus-5-5",
|
||||
thinkingOptionId: "xhigh",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
handles.push({ cwd, session });
|
||||
|
||||
const items: AgentTimelineItem[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.subscribe((event: AgentStreamEvent) => {
|
||||
if (event.type === "timeline") items.push(event.item);
|
||||
if (event.type === "turn_failed") reject(new Error(event.error));
|
||||
if (
|
||||
event.type === "permission_requested" ||
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed"
|
||||
) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
void session.startTurn(PROMPT).catch(reject);
|
||||
});
|
||||
|
||||
const lastReadIndex = items.findLastIndex(
|
||||
(item) => item.type === "tool_call" && item.name !== "AskUserQuestion",
|
||||
);
|
||||
const askIndex = items.findIndex(
|
||||
(item) => item.type === "tool_call" && item.name === "AskUserQuestion",
|
||||
);
|
||||
expect(askIndex).toBeGreaterThan(lastReadIndex);
|
||||
const between = items.slice(lastReadIndex + 1, askIndex);
|
||||
const assistantText = between
|
||||
.flatMap((item) => (item.type === "assistant_message" ? [item.text] : []))
|
||||
.join("");
|
||||
expect(assistantText).toMatch(/plan/i);
|
||||
}, 280_000);
|
||||
@@ -232,6 +232,7 @@ type SteeringReplayShape = "claude" | "codex";
|
||||
interface MockQuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
interface MockQuestionPromptQuestion {
|
||||
@@ -271,6 +272,16 @@ function parseSettledAssistantImageMarkdown(prompt: AgentPromptInput): string |
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
const ROLLOUT_FLAG_PREVIEW = [
|
||||
"Ship it dark behind the new-surface flag, then turn it on for internal users before everyone else.",
|
||||
"",
|
||||
'if (flags.enabled("new-surface")) {',
|
||||
" render(<NewSurface />);",
|
||||
"}",
|
||||
...Array.from({ length: 40 }, (_, i) => `// rollout step ${i + 1}`),
|
||||
"// LAST PREVIEW LINE",
|
||||
].join("\n");
|
||||
|
||||
function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null {
|
||||
const text = promptToText(prompt);
|
||||
if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) {
|
||||
@@ -309,7 +320,10 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe
|
||||
{
|
||||
question: "Which rollout should we use?",
|
||||
header: "rollout",
|
||||
options: [{ label: "Immediately" }, { label: "Behind feature flag" }],
|
||||
options: [
|
||||
{ label: "Immediately" },
|
||||
{ label: "Behind feature flag", preview: ROLLOUT_FLAG_PREVIEW },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -215,6 +215,55 @@ function createToolCatalog(): PaseoToolCatalog {
|
||||
}
|
||||
|
||||
describe("OMP agent client and session", () => {
|
||||
test("failed manual compact closes its progress row and shows the error", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
const events = await omp.compact({}, new Error("compaction refused"));
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
item: { type: "compaction", status: "loading", trigger: "manual" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
item: { type: "compaction", status: "completed", trigger: "manual" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "[Error] Failed to compact context: compaction refused",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("manual compact shows its progress and publishes the compacted context usage", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
const events = await omp.compact({
|
||||
contextUsage: { tokens: 13_447, contextWindow: 272_000 },
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: { type: "compaction", status: "loading", trigger: "manual" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: { type: "compaction", status: "completed", trigger: "manual" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
usage: expect.objectContaining({
|
||||
contextWindowUsedTokens: 13_447,
|
||||
contextWindowMaxTokens: 272_000,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("owns launch configuration and registers native host tools", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start({ modeId: "ask" }, createToolCatalog());
|
||||
|
||||
@@ -714,9 +714,7 @@ export class OmpAgentSession implements AgentSession {
|
||||
private readonly pendingPromptResults = new Map<string, OmpPromptResult>();
|
||||
private pendingNoTurnCompletionAbort: AbortController | null = null;
|
||||
private lastKnownThinkingOptionId: string | null;
|
||||
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
|
||||
private outOfBandCompactionStarted = false;
|
||||
private outOfBandCompactionCompleted = false;
|
||||
private manualCompactionRunning = false;
|
||||
private commandCache: AgentSlashCommand[] | null = null;
|
||||
private readonly subagentIndex = new OmpSubagentIndex();
|
||||
private readonly subagentCardTracker: OmpSubagentCardTracker;
|
||||
@@ -1407,30 +1405,24 @@ export class OmpAgentSession implements AgentSession {
|
||||
customInstructions: string | undefined,
|
||||
emit: (event: AgentStreamEvent) => void,
|
||||
): Promise<void> {
|
||||
if (this.outOfBandCompactionEmit) {
|
||||
if (this.manualCompactionRunning) {
|
||||
throw new Error("An OMP compact command is already running");
|
||||
}
|
||||
this.outOfBandCompactionEmit = emit;
|
||||
this.outOfBandCompactionStarted = false;
|
||||
this.outOfBandCompactionCompleted = false;
|
||||
// OMP answers a manual compact with its RPC response alone, without
|
||||
// compaction_start or compaction_end, so the request owns both rows.
|
||||
this.manualCompactionRunning = true;
|
||||
const emitCompaction = (status: "loading" | "completed") =>
|
||||
emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "compaction", status, trigger: "manual" },
|
||||
});
|
||||
emitCompaction("loading");
|
||||
try {
|
||||
await this.runtimeSession.compact(customInstructions);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
this.outOfBandCompactionEmit === emit &&
|
||||
this.outOfBandCompactionStarted &&
|
||||
!this.outOfBandCompactionCompleted
|
||||
) {
|
||||
this.emitCompactionTimeline({
|
||||
turnId: undefined,
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
trigger: "manual",
|
||||
},
|
||||
});
|
||||
}
|
||||
emitCompaction("completed");
|
||||
emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
@@ -1439,13 +1431,12 @@ export class OmpAgentSession implements AgentSession {
|
||||
text: `[Error] Failed to compact context: ${message}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) {
|
||||
this.outOfBandCompactionEmit = null;
|
||||
this.outOfBandCompactionStarted = false;
|
||||
this.outOfBandCompactionCompleted = false;
|
||||
}
|
||||
this.manualCompactionRunning = false;
|
||||
}
|
||||
emitCompaction("completed");
|
||||
await this.usagePoller.refresh();
|
||||
}
|
||||
|
||||
private async executeAutoCompactCommand(
|
||||
@@ -1853,24 +1844,8 @@ export class OmpAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
case "compaction_start":
|
||||
this.emitCompactionTimeline({
|
||||
turnId,
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "loading",
|
||||
trigger: event.reason === "manual" ? "manual" : "auto",
|
||||
},
|
||||
});
|
||||
return;
|
||||
case "compaction_end":
|
||||
this.emitCompactionTimeline({
|
||||
turnId,
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
trigger: event.reason === "manual" ? "manual" : "auto",
|
||||
},
|
||||
});
|
||||
this.handleCompactionEvent(event, turnId);
|
||||
return;
|
||||
case "agent_end": {
|
||||
const messages = event.messages ?? [];
|
||||
@@ -1924,35 +1899,24 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private emitCompactionTimeline(input: {
|
||||
turnId: string | undefined;
|
||||
item: Extract<AgentStreamEvent, { type: "timeline" }>["item"];
|
||||
}): void {
|
||||
const emitOutOfBand = this.outOfBandCompactionEmit;
|
||||
if (emitOutOfBand && input.item.type === "compaction") {
|
||||
if (input.item.status === "loading") {
|
||||
this.outOfBandCompactionStarted = true;
|
||||
}
|
||||
if (input.item.status === "completed") {
|
||||
this.outOfBandCompactionCompleted = true;
|
||||
}
|
||||
}
|
||||
const event: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
...(emitOutOfBand ? {} : { turnId: input.turnId }),
|
||||
item: input.item,
|
||||
};
|
||||
if (emitOutOfBand) {
|
||||
emitOutOfBand(event);
|
||||
if (input.item.type === "compaction" && input.item.status === "completed") {
|
||||
this.outOfBandCompactionEmit = null;
|
||||
this.outOfBandCompactionStarted = false;
|
||||
this.outOfBandCompactionCompleted = false;
|
||||
}
|
||||
private handleCompactionEvent(
|
||||
event: Extract<OmpAgentSessionEvent, { type: "compaction_start" | "compaction_end" }>,
|
||||
turnId: string | undefined,
|
||||
): void {
|
||||
// A manual compact emits its own rows around the request.
|
||||
if (this.manualCompactionRunning) {
|
||||
return;
|
||||
}
|
||||
this.emit(event);
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: event.type === "compaction_start" ? "loading" : "completed",
|
||||
trigger: event.reason === "manual" ? "manual" : "auto",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessageUpdate(
|
||||
|
||||
@@ -168,7 +168,6 @@ export class FakeOmpSession implements OmpRuntimeSession {
|
||||
subagents: FakeOmpSubagentSnapshot[] = [];
|
||||
readonly subagentSubscriptionErrors = new Map<FakeOmpSubagentSubscriptionLevel, Error>();
|
||||
compactError: Error | null = null;
|
||||
emitCompactEnd = true;
|
||||
getStateError: Error | null = null;
|
||||
promptAck: OmpPromptAck = {};
|
||||
branchResponse: { text?: string; cancelled?: boolean } = { text: "" };
|
||||
@@ -266,11 +265,8 @@ export class FakeOmpSession implements OmpRuntimeSession {
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
// Like OMP, answer a manual compact with the response alone, no compaction events.
|
||||
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
|
||||
this.emit({ type: "compaction_start", reason: "manual" });
|
||||
if (this.emitCompactEnd) {
|
||||
this.emit({ type: "compaction_end", reason: "manual" });
|
||||
}
|
||||
if (this.compactError) {
|
||||
throw this.compactError;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
type OmpProviderIdleScheduler,
|
||||
} from "../agent.js";
|
||||
import type { OmpUsagePollScheduler } from "../usage-poller.js";
|
||||
import type { OmpAgentMessage, OmpRpcSlashCommand, OmpRuntimeEvent } from "../rpc-types.js";
|
||||
import type {
|
||||
OmpAgentMessage,
|
||||
OmpRpcSlashCommand,
|
||||
OmpRuntimeEvent,
|
||||
OmpSessionStats,
|
||||
} from "../rpc-types.js";
|
||||
import { FakeOmp } from "./fake-omp.js";
|
||||
|
||||
const CWD = "/tmp/paseo-omp-agent-test";
|
||||
@@ -169,6 +174,18 @@ export class OmpHarness {
|
||||
return events;
|
||||
}
|
||||
|
||||
async compact(statsAfter: OmpSessionStats, error?: Error): Promise<AgentStreamEvent[]> {
|
||||
const handler = this.requireSession().tryHandleOutOfBand("/compact");
|
||||
if (!handler) throw new Error("OMP session did not handle /compact out-of-band");
|
||||
const runtime = this.omp.latestSession();
|
||||
const sessionEventCount = this.events.length;
|
||||
const emitted: AgentStreamEvent[] = [];
|
||||
runtime.stats = statsAfter;
|
||||
runtime.compactError = error ?? null;
|
||||
await handler.run({ emit: (event) => emitted.push(event) });
|
||||
return [...emitted, ...this.events.slice(sessionEventCount)];
|
||||
}
|
||||
|
||||
async runPrompt(
|
||||
input: string,
|
||||
output: string,
|
||||
|
||||
@@ -36,6 +36,27 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
describe("OMP usage poller", () => {
|
||||
test("refresh publishes usage without ending an active turn's polling", async () => {
|
||||
const scheduler = new ManualPollScheduler();
|
||||
const used: Array<number | undefined> = [];
|
||||
let stats: OmpSessionStats = { contextUsage: { contextWindow: 200_000, tokens: 9_000 } };
|
||||
const poller = new OmpUsagePoller({
|
||||
scheduler,
|
||||
readStats: async () => stats,
|
||||
onUsage: (update) => used.push(update.contextWindowUsedTokens),
|
||||
onPollError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
poller.startTurn();
|
||||
stats = { contextUsage: { contextWindow: 200_000, tokens: 2_000 } };
|
||||
await poller.refresh();
|
||||
|
||||
expect(used).toEqual([2_000]);
|
||||
expect(scheduler.activePollCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("emits only changed usage while active", async () => {
|
||||
const scheduler = new ManualPollScheduler();
|
||||
const updates: AgentUsage[] = [];
|
||||
|
||||
@@ -105,6 +105,17 @@ export class OmpUsagePoller {
|
||||
this.publishUsage(usage, turnId);
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
let usage: AgentUsage | undefined;
|
||||
try {
|
||||
usage = toAgentUsage(await this.options.readStats());
|
||||
} catch (error) {
|
||||
if (!this.closed) this.options.onPollError(error);
|
||||
return;
|
||||
}
|
||||
if (!this.closed) this.publishUsage(usage);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.active = false;
|
||||
|
||||
@@ -131,10 +131,10 @@ async function timeline(client: DaemonClient, agentId: string): Promise<AgentTim
|
||||
return result.entries.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
function compactionFiller(): string {
|
||||
function compactionFiller(records = 1200): string {
|
||||
const teas = ["assam", "darjeeling", "sencha", "oolong"];
|
||||
return Array.from(
|
||||
{ length: 1200 },
|
||||
{ length: records },
|
||||
(_, index) =>
|
||||
`Record ${String(index).padStart(5, "0")}: tea=${teas[index % teas.length]} origin=region-${index % 37} notes=lorem ipsum dolor sit amet`,
|
||||
).join("\n");
|
||||
@@ -234,6 +234,27 @@ afterEach(() => {
|
||||
roots.clear();
|
||||
});
|
||||
|
||||
async function waitForTimelineItem(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
predicate: (item: AgentTimelineItem) => boolean,
|
||||
): Promise<AgentTimelineItem> {
|
||||
const deadline = Date.now() + 60_000;
|
||||
let items: AgentTimelineItem[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
items = await timeline(client, agentId);
|
||||
const item = items.find(predicate);
|
||||
if (item) return item;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Timed out waiting for OMP timeline item: ${JSON.stringify(items)}`);
|
||||
}
|
||||
|
||||
async function contextWindowUsedTokens(client: DaemonClient, agentId: string) {
|
||||
const result = await client.fetchAgent(agentId);
|
||||
return result?.agent.lastUsage?.contextWindowUsedTokens;
|
||||
}
|
||||
|
||||
describe("daemon E2E (real OMP)", () => {
|
||||
test(
|
||||
"a prompt OMP rejects before its agent runs fails the turn with OMP's error",
|
||||
@@ -258,6 +279,48 @@ describe("daemon E2E (real OMP)", () => {
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"manual compact shows its progress and refreshes context usage",
|
||||
async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const agent = await createAgent(harness, "manual-compact");
|
||||
await promptAndFinish(harness, agent.id, "Reply exactly: ready");
|
||||
await promptAndFinish(
|
||||
harness,
|
||||
agent.id,
|
||||
`${compactionFiller(2500)}\n\nWhich tea is in record 00007? Reply with one word.`,
|
||||
);
|
||||
await promptAndFinish(harness, agent.id, "Reply exactly: after-filler");
|
||||
const usedBefore = await contextWindowUsedTokens(harness.client, agent.id);
|
||||
expect(usedBefore).toBeGreaterThan(20_000);
|
||||
|
||||
await harness.client.sendMessage(agent.id, "/compact");
|
||||
await waitForTimelineItem(
|
||||
harness.client,
|
||||
agent.id,
|
||||
(item) => item.type === "compaction" && item.status === "completed",
|
||||
);
|
||||
|
||||
expect(await timeline(harness.client, agent.id)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "compaction", status: "loading", trigger: "manual" }),
|
||||
expect.objectContaining({ type: "compaction", status: "completed", trigger: "manual" }),
|
||||
]),
|
||||
);
|
||||
await harness.client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) =>
|
||||
(snapshot.lastUsage?.contextWindowUsedTokens ?? Infinity) < (usedBefore ?? 0),
|
||||
60_000,
|
||||
);
|
||||
} finally {
|
||||
await closeHarness(harness);
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"prompt, native tool, and resumed follow-up",
|
||||
async () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
cwd = mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
describe("a message larger than the daemon accepts", () => {
|
||||
const oversizedImage = { data: "A".repeat(101 * 1024 * 1024), mimeType: "image/png" };
|
||||
|
||||
test("fails the creation and keeps the connection up", async () => {
|
||||
const statuses: string[] = [];
|
||||
const unsubscribe = ctx.client.subscribeConnectionStatus((state) => {
|
||||
statuses.push(state.status);
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
title: "Oversized first message",
|
||||
initialPrompt: "Describe these screens.",
|
||||
images: [oversizedImage],
|
||||
}),
|
||||
).rejects.toThrow(/too large/);
|
||||
unsubscribe();
|
||||
|
||||
expect(statuses).toEqual(["connected"]);
|
||||
expect((await ctx.client.fetchAgents()).entries).toEqual([]);
|
||||
}, 30000);
|
||||
|
||||
test("fails the send and keeps the connection up", async () => {
|
||||
const agent = await ctx.client.createAgent({ provider: "claude", cwd, title: "Agent" });
|
||||
const statuses: string[] = [];
|
||||
const unsubscribe = ctx.client.subscribeConnectionStatus((state) => {
|
||||
statuses.push(state.status);
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.client.sendMessage(agent.id, "Describe these screens.", { images: [oversizedImage] }),
|
||||
).rejects.toThrow(/too large/);
|
||||
unsubscribe();
|
||||
|
||||
expect(statuses).toEqual(["connected"]);
|
||||
}, 30000);
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { WebSocket } from "ws";
|
||||
import pino from "pino";
|
||||
import { Writable } from "node:stream";
|
||||
@@ -6,9 +8,13 @@ import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
|
||||
import { generateLocalPairingOffer } from "../pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js";
|
||||
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
|
||||
import {
|
||||
deriveSharedKey,
|
||||
@@ -73,6 +79,10 @@ function decodeOfferFromFragmentUrl(url: string): {
|
||||
return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 };
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function encodeCiphertext(ciphertext: ArrayBuffer): string {
|
||||
return Buffer.from(new Uint8Array(ciphertext)).toString("base64");
|
||||
}
|
||||
@@ -202,12 +212,20 @@ async function waitForCapturedLog(
|
||||
if (options.useLocalRelay) {
|
||||
relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:");
|
||||
}
|
||||
relayProcess = spawn("npx", relayArgs, {
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
relayProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--experimental-vm-modules",
|
||||
path.resolve(relayDir, "../../node_modules/wrangler/wrangler-dist/cli.js"),
|
||||
...relayArgs.slice(1),
|
||||
],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
},
|
||||
);
|
||||
|
||||
relayProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
@@ -241,6 +259,73 @@ async function waitForCapturedLog(
|
||||
relayProcess = null;
|
||||
};
|
||||
|
||||
test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => {
|
||||
await startRelay({ useLocalRelay: true });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
relayEnabled: true,
|
||||
relayEndpoint: `127.0.0.1:${relayPort}`,
|
||||
relayUseTls: false,
|
||||
relayPublicUseTls: false,
|
||||
});
|
||||
let client: DaemonClient | null = null;
|
||||
try {
|
||||
const offerUrl = await getPairingOfferUrl({
|
||||
paseoHome: daemon.paseoHome,
|
||||
relayEnabled: true,
|
||||
relayEndpoint: daemon.config.relayEndpoint,
|
||||
});
|
||||
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
|
||||
client = new DaemonClient({
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: `127.0.0.1:${relayPort}`,
|
||||
useTls: false,
|
||||
serverId,
|
||||
role: "client",
|
||||
}),
|
||||
e2ee: { enabled: true, daemonPublicKeyB64 },
|
||||
reconnect: { baseDelayMs: 100, maxDelayMs: 100 },
|
||||
});
|
||||
await client.connect();
|
||||
const statuses: string[] = [];
|
||||
client.subscribeConnectionStatus((state) => statuses.push(state.status));
|
||||
for (const imageMiB of [30, 61]) {
|
||||
// Attachments are base64 in the JSON, then E2EE base64 expands the frame again.
|
||||
const images = [
|
||||
{ data: "A".repeat(4 * Math.ceil((imageMiB * 1024 * 1024) / 3)), mimeType: "image/png" },
|
||||
];
|
||||
await expect(
|
||||
withTimeout(
|
||||
client.createWorkspace({
|
||||
source: { kind: "directory", path: daemon.staticDir },
|
||||
agent: {
|
||||
provider: "claude",
|
||||
cwd: daemon.staticDir,
|
||||
initialPrompt: "Describe these screens.",
|
||||
images,
|
||||
},
|
||||
}),
|
||||
10000,
|
||||
"Workspace creation did not settle",
|
||||
),
|
||||
).rejects.toThrow(/too large.*32\.0 MiB/);
|
||||
expect((await client.fetchAgents()).entries).toEqual([]);
|
||||
}
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.staticDir });
|
||||
await expect(
|
||||
client.sendMessage(agent.id, "Describe these screens.", {
|
||||
images: [{ data: "A".repeat(40 * 1024 * 1024), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow(/too large.*32\.0 MiB/);
|
||||
await client.sendMessage(agent.id, "A small follow-up still works.");
|
||||
expect((await client.fetchAgents()).entries).toHaveLength(1);
|
||||
expect(statuses).toEqual(["connected"]);
|
||||
} finally {
|
||||
await client?.close();
|
||||
await daemon.close();
|
||||
await stopRelay();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test("daemon connects to relay and client ping/pong works through relay", async () => {
|
||||
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
||||
|
||||
@@ -783,4 +868,67 @@ async function waitForCapturedLog(
|
||||
await stopRelay();
|
||||
}
|
||||
}, 90000);
|
||||
|
||||
test("reads a workspace file over the E2EE relay session byte-for-byte", async () => {
|
||||
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
||||
|
||||
const { logger, lines } = createCapturingLogger();
|
||||
let daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>> | null = null;
|
||||
let workspaceDir: string | null = null;
|
||||
const fileName = "rapport données.bin";
|
||||
const source = randomBytes(4 * FILE_EXPLORER_STREAM_CHUNK_BYTES + 123);
|
||||
let client: DaemonClient | null = null;
|
||||
|
||||
try {
|
||||
await startRelay({ useLocalRelay: true });
|
||||
daemon = await createTestPaseoDaemon({
|
||||
listen: "127.0.0.1",
|
||||
logger,
|
||||
relayEnabled: true,
|
||||
relayEndpoint: `127.0.0.1:${relayPort}`,
|
||||
});
|
||||
workspaceDir = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-download-"));
|
||||
await writeFile(path.join(workspaceDir, fileName), source);
|
||||
const offerUrl = await getPairingOfferUrl({
|
||||
paseoHome: daemon.paseoHome,
|
||||
relayEnabled: daemon.config.relayEnabled,
|
||||
relayEndpoint: daemon.config.relayEndpoint,
|
||||
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
|
||||
appBaseUrl: daemon.config.appBaseUrl,
|
||||
});
|
||||
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
|
||||
|
||||
client = new DaemonClient({
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: `127.0.0.1:${relayPort}`,
|
||||
useTls: false,
|
||||
serverId,
|
||||
role: "client",
|
||||
}),
|
||||
clientType: "cli",
|
||||
connectTimeoutMs: 30_000,
|
||||
e2ee: { enabled: true, daemonPublicKeyB64 },
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
const result = await client.readFile(workspaceDir, fileName, undefined, undefined, 0);
|
||||
|
||||
expect(result.size).toBe(source.byteLength);
|
||||
expect(result.bytes.byteLength).toBe(source.byteLength);
|
||||
expect(sha256(result.bytes)).toBe(sha256(source));
|
||||
} catch (err) {
|
||||
const tail = lines.slice(-50).join("");
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("daemon logs (tail):\n", tail);
|
||||
throw err;
|
||||
} finally {
|
||||
await client?.close().catch(() => undefined);
|
||||
await daemon?.close();
|
||||
await stopRelay();
|
||||
if (workspaceDir) {
|
||||
await rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}, 90000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js";
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"checkout and commit diffs preserve filenames that Git quotes",
|
||||
async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "git-filename-diffs-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const names = [
|
||||
'report "draft".txt',
|
||||
"back\\slash.txt",
|
||||
"tab\tname.txt",
|
||||
"normal.txt",
|
||||
"with spaces.txt",
|
||||
];
|
||||
const editLines = expect.arrayContaining([
|
||||
expect.objectContaining({ type: "remove", content: "before" }),
|
||||
expect.objectContaining({ type: "add", content: "after" }),
|
||||
]);
|
||||
const daemon = await createTestPaseoDaemon({ agentClients: {} });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
git("config", "core.quotepath", "false");
|
||||
for (const name of names) writeFileSync(join(cwd, name), "before\n");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
||||
for (const name of names) writeFileSync(join(cwd, name), "after\n");
|
||||
|
||||
await client.connect();
|
||||
const checkout = await client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
expect(checkout.error).toBeNull();
|
||||
expect(checkout.files.map((file) => file.path).sort()).toEqual([...names].sort());
|
||||
for (const file of checkout.files) {
|
||||
expect(file).toMatchObject({ status: "ok", additions: 1, deletions: 1 });
|
||||
expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines);
|
||||
}
|
||||
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
||||
const sha = git("rev-parse", "HEAD");
|
||||
for (const name of names) {
|
||||
const commit = await client.getCommitFileDiff(cwd, sha, name);
|
||||
expect(commit.file).toMatchObject({ path: name, additions: 1, deletions: 1 });
|
||||
expect(commit.file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines);
|
||||
const file = await client.readFile(cwd, name);
|
||||
expect(file.kind).toBe("text");
|
||||
expect(new TextDecoder().decode(file.bytes)).toBe("after\n");
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -773,8 +773,12 @@ describe("Hub relationship", () => {
|
||||
const second = await relationship.ownedCreateResult("second-create");
|
||||
|
||||
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
|
||||
expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } });
|
||||
expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } });
|
||||
expect(first).toMatchObject({
|
||||
payload: { success: true, executionId: "first-execution", error: null },
|
||||
});
|
||||
expect(second).toMatchObject({
|
||||
payload: { success: true, executionId: "second-execution", error: null },
|
||||
});
|
||||
expect(await relationship.durableOwnedAgentIds()).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -674,6 +674,19 @@ describe("PersistedConfigSchema voice mode config", () => {
|
||||
expect(parsed.features?.dictation?.stt?.language).toBe("fr");
|
||||
expect(parsed.features?.voiceMode?.stt?.language).toBe("de");
|
||||
});
|
||||
|
||||
test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
voiceMode: {
|
||||
tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "aiden" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0");
|
||||
expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadPersistedConfig", () => {
|
||||
|
||||
@@ -144,7 +144,8 @@ const FeatureVoiceModeSchema = z
|
||||
.object({
|
||||
provider: SpeechProviderIdSchema.optional(),
|
||||
model: z.string().min(1).optional(),
|
||||
voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(),
|
||||
// Free-form: self-hosted OpenAI-compatible servers expose their own voice names.
|
||||
voice: z.string().trim().min(1).optional(),
|
||||
speakerId: z.number().int().optional(),
|
||||
speed: z.number().optional(),
|
||||
})
|
||||
|
||||
@@ -203,4 +203,40 @@ describe("resolveOpenAiSpeechConfig", () => {
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
|
||||
expect(resolved?.tts).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sends the model and voice an OpenAI-compatible server serves, as written", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: { tts: { apiKey: "tts-key", baseUrl: "https://tts.example.com/v1" } },
|
||||
},
|
||||
features: {
|
||||
voiceMode: { tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "Aiden" } },
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
providers: ALL_OPENAI,
|
||||
});
|
||||
|
||||
expect(resolved?.tts?.model).toBe("kokoro-v1:Q8_0");
|
||||
expect(resolved?.tts?.voice).toBe("Aiden");
|
||||
});
|
||||
|
||||
test("accepts OpenAI's own gpt-4o-mini-tts model", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: { openai: { tts: { apiKey: "tts-key" } } },
|
||||
features: { voiceMode: { tts: { provider: "openai", model: "gpt-4o-mini-tts" } } },
|
||||
});
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
providers: ALL_OPENAI,
|
||||
});
|
||||
|
||||
expect(resolved?.tts?.model).toBe("gpt-4o-mini-tts");
|
||||
expect(resolved?.tts?.voice).toBe("alloy");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,6 @@ export interface OpenAiSpeechProviderConfig {
|
||||
tts?: Partial<TTSConfig> & { apiKey?: string };
|
||||
}
|
||||
|
||||
const OpenAiTtsVoiceSchema = z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
|
||||
|
||||
const OpenAiTtsModelSchema = z.enum(["tts-1", "tts-1-hd"]);
|
||||
|
||||
const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]);
|
||||
|
||||
const OptionalFiniteNumberSchema = NumberLikeSchema.pipe(
|
||||
@@ -44,14 +40,12 @@ const OpenAiSttOptionsSchema = z.object({
|
||||
sttModel: OptionalTrimmedStringSchema,
|
||||
});
|
||||
|
||||
// Voice and model are free-form so a self-hosted server can use its own ids; the
|
||||
// OpenAI catalog is only the default. Both are sent as written, because the
|
||||
// server owns those ids and their case (rosters use ids like kokoro-v1:Q8_0).
|
||||
const OpenAiTtsOptionsSchema = z.object({
|
||||
ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"),
|
||||
ttsModel: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.pipe(OpenAiTtsModelSchema)
|
||||
.default(DEFAULT_OPENAI_TTS_MODEL),
|
||||
ttsVoice: z.string().trim().default("alloy"),
|
||||
ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL),
|
||||
});
|
||||
|
||||
function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean {
|
||||
|
||||
@@ -8,8 +8,8 @@ export type { SpeechStreamResult };
|
||||
export interface TTSConfig {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model?: "tts-1" | "tts-1-hd";
|
||||
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer";
|
||||
model?: "tts-1" | "tts-1-hd" | (string & {});
|
||||
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" | (string & {});
|
||||
responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm";
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,85 @@ index 1234567..abcdefg 100644
|
||||
},
|
||||
);
|
||||
|
||||
it("decodes quoted Git patch paths before matching files", () => {
|
||||
const files = parseDiff(
|
||||
[
|
||||
'diff --git "a/report \\"draft\\".txt" "b/report \\"draft\\".txt"',
|
||||
"index 1234567..abcdefg 100644",
|
||||
'--- "a/report \\"draft\\".txt"',
|
||||
'+++ "b/report \\"draft\\".txt"',
|
||||
"@@ -1 +1 @@",
|
||||
"-before",
|
||||
"+after",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
path: 'report "draft".txt',
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1 +1 @@" },
|
||||
{ type: "remove", content: "before" },
|
||||
{ type: "add", content: "after" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["tab\\tname.txt", "tab\tname.txt"],
|
||||
["line\\nname.txt", "line\nname.txt"],
|
||||
["back\\\\slash.txt", "back\\slash.txt"],
|
||||
["bell\\aname.txt", "bell\u0007name.txt"],
|
||||
["vertical\\vname.txt", "vertical\u000bname.txt"],
|
||||
["\\346\\265\\213\\350\\257\\225.txt", "测试.txt"],
|
||||
['测试 \\"draft\\".txt', '测试 "draft".txt'],
|
||||
])("decodes C-quoted path %s without changing its diff lines", (encoded, path) => {
|
||||
const files = parseDiff(
|
||||
[
|
||||
'diff --git "a/' + encoded + '" "b/' + encoded + '"',
|
||||
"index 1234567..abcdefg 100644",
|
||||
'--- "a/' + encoded + '"',
|
||||
'+++ "b/' + encoded + '"',
|
||||
"@@ -1 +1 @@",
|
||||
"-before",
|
||||
"+after",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatchObject({ path, additions: 1, deletions: 1 });
|
||||
expect(files[0].hunks[0].lines).toEqual([
|
||||
{ type: "header", content: "@@ -1 +1 @@" },
|
||||
{ type: "remove", content: "before" },
|
||||
{ type: "add", content: "after" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['"a/old \\"draft\\".txt" b/new with spaces.txt', "new with spaces.txt"],
|
||||
['a/old with spaces.txt "b/new \\"draft\\".txt"', 'new "draft".txt'],
|
||||
['"report \\"draft\\".txt" "report \\"draft\\".txt"', 'report "draft".txt'],
|
||||
])("reads quoted paths from header-only patches: %s", (header, path) => {
|
||||
expect(parseDiff("diff --git " + header + "\nsimilarity index 100%\n")).toEqual([
|
||||
{ path, isNew: false, isDeleted: false, additions: 0, deletions: 0, hunks: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses a simple diff with one hunk", () => {
|
||||
const files = parseDiff(SIMPLE_DIFF);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { unquoteGitPath } from "../../utils/git-quoted-path.js";
|
||||
|
||||
const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000;
|
||||
|
||||
@@ -53,7 +54,7 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = line.slice(prefix.length).replace(/\t.*$/, "").trimEnd();
|
||||
const path = unquoteGitPath(line.slice(prefix.length).replace(/\t.*$/, ""));
|
||||
return path === "/dev/null" ? null : path;
|
||||
}
|
||||
|
||||
@@ -64,6 +65,17 @@ function extractPathFromDiffHeader(lines: string[]): string {
|
||||
return prefixedPathMatch[2];
|
||||
}
|
||||
|
||||
const quotedPathMatch =
|
||||
firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/);
|
||||
if (quotedPathMatch) {
|
||||
const oldPath = unquoteGitPath(quotedPathMatch[1]);
|
||||
const newPath = unquoteGitPath(quotedPathMatch[2]);
|
||||
if (usesDiffPathPrefixes(oldPath, newPath)) {
|
||||
return newPath.slice(2);
|
||||
}
|
||||
return newPath;
|
||||
}
|
||||
|
||||
const metadataPath =
|
||||
extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- ");
|
||||
if (metadataPath) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "./messages.js";
|
||||
import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import type { HostnamesConfig } from "./hostnames.js";
|
||||
import { isHostnameAllowed } from "./hostnames.js";
|
||||
import {
|
||||
@@ -822,6 +823,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
path: "/ws",
|
||||
maxPayload: MAX_WEBSOCKET_MESSAGE_BYTES,
|
||||
handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password),
|
||||
verifyClient: ({ req }, callback) => {
|
||||
this.verifyWsUpgrade(
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
||||
expect(allocatedServices).toEqual(["api", "web", "worker"]);
|
||||
});
|
||||
|
||||
it("returns the existing plan without calling the allocator on later calls", async () => {
|
||||
it("extends the existing plan with newly declared services without reallocating peers", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
const firstPlan = await ensureWorkspaceServicePortPlan({
|
||||
@@ -54,9 +54,11 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries()));
|
||||
expect(secondPlan.has("new-service")).toBe(false);
|
||||
expect(allocationCount).toBe(2);
|
||||
expect(Array.from(secondPlan.entries())).toEqual([
|
||||
...firstPlan.entries(),
|
||||
["new-service", 4300],
|
||||
]);
|
||||
expect(allocationCount).toBe(3);
|
||||
});
|
||||
|
||||
it("shares one first-plan build across concurrent callers", async () => {
|
||||
@@ -101,6 +103,103 @@ describe("ensureWorkspaceServicePortPlan", () => {
|
||||
expect(allocationCount).toBe(2);
|
||||
});
|
||||
|
||||
it("extends a pending plan for a concurrent caller with another service", async () => {
|
||||
const allocation = createDeferredPort();
|
||||
const workspaceId = "registry-concurrent-extension-workspace";
|
||||
const firstPlan = ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => allocation.promise,
|
||||
});
|
||||
let allocations = 0;
|
||||
const secondPlan = ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }],
|
||||
allocatePort: async () => {
|
||||
allocations += 1;
|
||||
return 6101;
|
||||
},
|
||||
});
|
||||
allocation.resolve(6100);
|
||||
expect(Array.from((await firstPlan).entries())).toEqual([["backend-dev", 6100]]);
|
||||
expect(Array.from((await secondPlan).entries())).toEqual([
|
||||
["backend-dev", 6100],
|
||||
["web", 6101],
|
||||
]);
|
||||
expect(allocations).toBe(1);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it("keeps the previous plan and reservations when an extension fails", async () => {
|
||||
const workspaceId = "registry-failed-extension-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => 6200,
|
||||
});
|
||||
let allocations = 0;
|
||||
await expect(
|
||||
ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }, { scriptName: "worker" }],
|
||||
allocatePort: async () => {
|
||||
allocations += 1;
|
||||
if (allocations === 1) return 6201;
|
||||
throw new Error("Allocation failed");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Allocation failed");
|
||||
const priorPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => {
|
||||
throw new Error("Existing port must survive");
|
||||
},
|
||||
});
|
||||
expect(Array.from(priorPlan.entries())).toEqual([["backend-dev", 6200]]);
|
||||
const otherWorkspace = "registry-failed-extension-other-workspace";
|
||||
const reusedPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: otherWorkspace,
|
||||
services: [{ scriptName: "web" }],
|
||||
allocatePort: async ({ reservedPorts }) => {
|
||||
expect(reservedPorts.has(6200)).toBe(true);
|
||||
expect(reservedPorts.has(6201)).toBe(false);
|
||||
return 6201;
|
||||
},
|
||||
});
|
||||
expect(reusedPlan.get("web")).toBe(6201);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
releaseWorkspaceServicePortPlan(otherWorkspace);
|
||||
});
|
||||
|
||||
it("rejects a new explicit port that collides with an existing service", async () => {
|
||||
const workspaceId = "registry-extension-explicit-collision-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => 6300,
|
||||
});
|
||||
await expect(
|
||||
ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6300 }],
|
||||
allocatePort: async () => 6301,
|
||||
}),
|
||||
).rejects.toThrow("Service 'web' has a duplicate port 6300");
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6301 }],
|
||||
allocatePort: async () => {
|
||||
throw new Error("No allocation needed");
|
||||
},
|
||||
});
|
||||
expect(Array.from(plan.entries())).toEqual([
|
||||
["backend-dev", 6300],
|
||||
["web", 6301],
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it("uses explicit configured ports without calling the allocator", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
@@ -312,6 +411,123 @@ describe("refreshWorkspaceServicePort", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves a concurrent service addition while refreshing a stopped service", async () => {
|
||||
const workspaceId = "registry-refresh-and-extend-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }],
|
||||
allocatePort: async () => 6400,
|
||||
});
|
||||
const allocation = createDeferredPort();
|
||||
const refreshing = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "backend-dev" },
|
||||
allocatePort: async () => allocation.promise,
|
||||
});
|
||||
const extending = ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6402 }],
|
||||
allocatePort: async () => {
|
||||
throw new Error("Explicit addition needs no allocation");
|
||||
},
|
||||
});
|
||||
allocation.resolve(6401);
|
||||
expect(await refreshing).toBe(6401);
|
||||
await extending;
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [],
|
||||
allocatePort: async () => {
|
||||
throw new Error("No allocation needed");
|
||||
},
|
||||
});
|
||||
expect(Array.from(plan.entries())).toEqual([
|
||||
["backend-dev", 6401],
|
||||
["web", 6402],
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it.each(["reused port", "allocation failure"])(
|
||||
"cancels a pending refresh and its waiter after workspace release (%s)",
|
||||
async (outcome) => {
|
||||
const workspaceId = "registry-cancelled-refresh-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "api" }],
|
||||
allocatePort: async () => 6500,
|
||||
});
|
||||
const allocation = createDeferredPort();
|
||||
const refreshing = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "api" },
|
||||
allocatePort: async () => {
|
||||
const port = await allocation.promise;
|
||||
if (outcome === "allocation failure") throw new Error("Allocation failed");
|
||||
return port;
|
||||
},
|
||||
});
|
||||
const waiting = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "web", port: 6501 },
|
||||
allocatePort: async () => 6501,
|
||||
});
|
||||
const assertions = Promise.all([
|
||||
expect(refreshing).rejects.toThrow("Workspace service port plan was released"),
|
||||
expect(waiting).rejects.toThrow("Workspace service port plan was released"),
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
allocation.resolve(6500);
|
||||
await assertions;
|
||||
const reusedPlan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId: "registry-after-cancelled-refresh-workspace",
|
||||
services: [{ scriptName: "api" }],
|
||||
allocatePort: async () => 6500,
|
||||
});
|
||||
expect(reusedPlan.get("api")).toBe(6500);
|
||||
releaseWorkspaceServicePortPlan("registry-after-cancelled-refresh-workspace");
|
||||
},
|
||||
);
|
||||
|
||||
it("lets a waiting service restart allocate after another service's restart fails", async () => {
|
||||
const workspaceId = "registry-independent-refresh-failure-workspace";
|
||||
await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [{ scriptName: "api" }, { scriptName: "web" }],
|
||||
allocatePort: createSequentialPortAllocator(6600),
|
||||
});
|
||||
const allocation = createDeferredPort();
|
||||
const failedRefresh = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "api" },
|
||||
allocatePort: async () => {
|
||||
await allocation.promise;
|
||||
throw new Error("API port script failed");
|
||||
},
|
||||
});
|
||||
const failedAssertion = expect(failedRefresh).rejects.toThrow("API port script failed");
|
||||
const waitingRefresh = refreshWorkspaceServicePort({
|
||||
workspaceId,
|
||||
service: { scriptName: "web" },
|
||||
allocatePort: async () => 6602,
|
||||
});
|
||||
allocation.resolve(0);
|
||||
await failedAssertion;
|
||||
expect(await waitingRefresh).toBe(6602);
|
||||
const plan = await ensureWorkspaceServicePortPlan({
|
||||
workspaceId,
|
||||
services: [],
|
||||
allocatePort: async () => {
|
||||
throw new Error("No allocation needed");
|
||||
},
|
||||
});
|
||||
expect(Array.from(plan.entries())).toEqual([
|
||||
["api", 6600],
|
||||
["web", 6602],
|
||||
]);
|
||||
releaseWorkspaceServicePortPlan(workspaceId);
|
||||
});
|
||||
|
||||
it("uses an explicit configured port without calling the allocator", async () => {
|
||||
let allocationCount = 0;
|
||||
|
||||
|
||||
@@ -24,6 +24,24 @@ interface PendingWorkspaceServicePortPlanToken {
|
||||
isReleased: boolean;
|
||||
}
|
||||
|
||||
class WorkspaceServicePortPlanReleasedError extends Error {
|
||||
constructor(workspaceId: string) {
|
||||
super(`Workspace service port plan was released while being created for '${workspaceId}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForWorkspaceServicePortPlanUpdate(
|
||||
pendingPlan: Promise<Map<string, number>>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await pendingPlan;
|
||||
} catch (error) {
|
||||
// Allocation failure belongs to the original request. A waiting request
|
||||
// retries against the rolled-back plan; workspace archival cancels both.
|
||||
if (error instanceof WorkspaceServicePortPlanReleasedError) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceServicePortPlans = new Map<string, Map<string, number>>();
|
||||
const pendingWorkspaceServicePortPlans = new Map<string, Promise<Map<string, number>>>();
|
||||
const pendingWorkspaceServicePortPlanTokens = new Map<
|
||||
@@ -37,25 +55,19 @@ const MAX_DYNAMIC_PORT_ALLOCATION_ATTEMPTS = 10;
|
||||
export async function ensureWorkspaceServicePortPlan(
|
||||
options: EnsureWorkspaceServicePortPlanOptions,
|
||||
): Promise<ReadonlyMap<string, number>> {
|
||||
const existingPlan = workspaceServicePortPlans.get(options.workspaceId);
|
||||
if (existingPlan) {
|
||||
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
||||
if (pendingPlan) {
|
||||
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
|
||||
return ensureWorkspaceServicePortPlan(options);
|
||||
}
|
||||
|
||||
const existingPlan =
|
||||
workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
|
||||
if (options.services.every((service) => existingPlan.has(service.scriptName))) {
|
||||
return new Map(existingPlan);
|
||||
}
|
||||
|
||||
let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
||||
if (!pendingPlan) {
|
||||
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
|
||||
pendingPlan = createPendingWorkspaceServicePortPlan({
|
||||
workspaceId: options.workspaceId,
|
||||
services: options.services,
|
||||
allocatePort: options.allocatePort,
|
||||
token,
|
||||
});
|
||||
pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan);
|
||||
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
|
||||
}
|
||||
|
||||
return new Map(await pendingPlan);
|
||||
return new Map(await startWorkspaceServicePortPlanUpdate({ ...options, existingPlan }));
|
||||
}
|
||||
|
||||
export function requirePlannedWorkspaceServicePort(
|
||||
@@ -82,27 +94,62 @@ export function releaseWorkspaceServicePortPlan(workspaceId: string): void {
|
||||
dynamicPortsByWorkspace.delete(workspaceId);
|
||||
}
|
||||
|
||||
async function createPendingWorkspaceServicePortPlan(options: {
|
||||
workspaceId: string;
|
||||
services: readonly WorkspaceServicePortDeclaration[];
|
||||
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
|
||||
token: PendingWorkspaceServicePortPlanToken;
|
||||
}): Promise<Map<string, number>> {
|
||||
interface WorkspaceServicePortPlanUpdateOptions extends EnsureWorkspaceServicePortPlanOptions {
|
||||
existingPlan: ReadonlyMap<string, number>;
|
||||
refreshScriptName?: string;
|
||||
}
|
||||
|
||||
function startWorkspaceServicePortPlanUpdate(
|
||||
options: WorkspaceServicePortPlanUpdateOptions,
|
||||
): Promise<Map<string, number>> {
|
||||
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
|
||||
const plan = createPendingWorkspaceServicePortPlan({ ...options, token });
|
||||
pendingWorkspaceServicePortPlans.set(options.workspaceId, plan);
|
||||
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
|
||||
return plan;
|
||||
}
|
||||
|
||||
async function createPendingWorkspaceServicePortPlan(
|
||||
options: WorkspaceServicePortPlanUpdateOptions & { token: PendingWorkspaceServicePortPlanToken },
|
||||
): Promise<Map<string, number>> {
|
||||
const previousDynamicPorts = new Map(dynamicPortsByWorkspace.get(options.workspaceId));
|
||||
const retainedPlan = new Map(options.existingPlan);
|
||||
if (options.refreshScriptName !== undefined) retainedPlan.delete(options.refreshScriptName);
|
||||
try {
|
||||
const plan = await buildWorkspaceServicePortPlan({
|
||||
const updatedPlan = await buildWorkspaceServicePortPlan({
|
||||
workspaceId: options.workspaceId,
|
||||
services: options.services,
|
||||
allocatePort: options.allocatePort,
|
||||
existingPlan: retainedPlan,
|
||||
});
|
||||
const plan = new Map(options.existingPlan);
|
||||
for (const [scriptName, port] of updatedPlan) plan.set(scriptName, port);
|
||||
if (options.token.isReleased) {
|
||||
throw new Error(
|
||||
`Workspace service port plan was released while being created for '${options.workspaceId}'`,
|
||||
);
|
||||
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
|
||||
}
|
||||
for (const [scriptName, port] of previousDynamicPorts) {
|
||||
if (plan.get(scriptName) !== port) {
|
||||
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
|
||||
}
|
||||
}
|
||||
workspaceServicePortPlans.set(options.workspaceId, plan);
|
||||
return plan;
|
||||
} catch (error) {
|
||||
releaseWorkspaceServicePortPlan(options.workspaceId);
|
||||
const dynamicPorts = dynamicPortsByWorkspace.get(options.workspaceId);
|
||||
if (dynamicPorts) {
|
||||
for (const [scriptName, port] of dynamicPorts) {
|
||||
if (options.token.isReleased || previousDynamicPorts.get(scriptName) !== port) {
|
||||
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!options.token.isReleased) {
|
||||
for (const [scriptName, port] of previousDynamicPorts) {
|
||||
reserveDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
|
||||
}
|
||||
}
|
||||
if (options.token.isReleased)
|
||||
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
|
||||
throw error;
|
||||
} finally {
|
||||
pendingWorkspaceServicePortPlans.delete(options.workspaceId);
|
||||
@@ -111,12 +158,16 @@ async function createPendingWorkspaceServicePortPlan(options: {
|
||||
}
|
||||
|
||||
async function buildWorkspaceServicePortPlan(options: {
|
||||
existingPlan: ReadonlyMap<string, number>;
|
||||
workspaceId: string;
|
||||
services: readonly WorkspaceServicePortDeclaration[];
|
||||
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
|
||||
}): Promise<Map<string, number>> {
|
||||
const explicitPortOwners = new Map<number, string>();
|
||||
const explicitPortOwners = new Map<number, string>(
|
||||
Array.from(options.existingPlan, ([scriptName, port]) => [port, scriptName]),
|
||||
);
|
||||
for (const service of options.services) {
|
||||
if (options.existingPlan.has(service.scriptName)) continue;
|
||||
if (service.port === undefined) continue;
|
||||
if (explicitPortOwners.has(service.port)) {
|
||||
throw new Error(`Service '${service.scriptName}' has a duplicate port ${service.port}`);
|
||||
@@ -124,8 +175,9 @@ async function buildWorkspaceServicePortPlan(options: {
|
||||
explicitPortOwners.set(service.port, service.scriptName);
|
||||
}
|
||||
|
||||
const plan = new Map<string, number>();
|
||||
const plan = new Map(options.existingPlan);
|
||||
for (const service of options.services) {
|
||||
if (plan.has(service.scriptName)) continue;
|
||||
if (service.port !== undefined) {
|
||||
plan.set(service.scriptName, service.port);
|
||||
continue;
|
||||
@@ -148,32 +200,20 @@ async function buildWorkspaceServicePortPlan(options: {
|
||||
export async function refreshWorkspaceServicePort(
|
||||
options: RefreshWorkspaceServicePortOptions,
|
||||
): Promise<number> {
|
||||
const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
|
||||
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
|
||||
if (pendingPlan) {
|
||||
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
|
||||
return refreshWorkspaceServicePort(options);
|
||||
}
|
||||
|
||||
const reservedPorts = new Set(plan.values());
|
||||
const previousPort = plan.get(options.service.scriptName);
|
||||
if (previousPort !== undefined) {
|
||||
reservedPorts.delete(previousPort);
|
||||
}
|
||||
const oldDynamicPort = dynamicPortsByWorkspace
|
||||
.get(options.workspaceId)
|
||||
?.get(options.service.scriptName);
|
||||
const port = await resolveServicePort({
|
||||
service: options.service,
|
||||
const plan = await startWorkspaceServicePortPlanUpdate({
|
||||
workspaceId: options.workspaceId,
|
||||
services: [options.service],
|
||||
allocatePort: options.allocatePort,
|
||||
reservedPorts,
|
||||
existingPlan: workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(),
|
||||
refreshScriptName: options.service.scriptName,
|
||||
});
|
||||
if (oldDynamicPort !== undefined && oldDynamicPort !== port) {
|
||||
releaseDynamicPort({
|
||||
workspaceId: options.workspaceId,
|
||||
scriptName: options.service.scriptName,
|
||||
port: oldDynamicPort,
|
||||
});
|
||||
}
|
||||
plan.set(options.service.scriptName, port);
|
||||
workspaceServicePortPlans.set(options.workspaceId, plan);
|
||||
return port;
|
||||
return requirePlannedWorkspaceServicePort(plan, options.service.scriptName);
|
||||
}
|
||||
|
||||
async function resolveServicePort(options: {
|
||||
|
||||
@@ -837,6 +837,54 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a service added to paseo.json without restarting the daemon", async () => {
|
||||
const backend = {
|
||||
type: "service" as const,
|
||||
command: "uv run uvicorn app.main:app --reload --port $PASEO_PORT",
|
||||
};
|
||||
commitPaseoScripts({ "backend-dev": backend });
|
||||
|
||||
const serviceProxy = new ScriptRouteStore();
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
const createTerminalCalls: CreateTerminalCall[] = [];
|
||||
const terminalRecords: StubTerminalRecord[] = [];
|
||||
const options = {
|
||||
repoRoot: repoDir,
|
||||
workspaceId: repoDir,
|
||||
projectSlug: "repo",
|
||||
branchName: "feature-added-service",
|
||||
daemonPort: 6767,
|
||||
serviceProxy,
|
||||
runtimeStore,
|
||||
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
|
||||
};
|
||||
const backendResult = await spawnWorkspaceScript({ ...options, scriptName: "backend-dev" });
|
||||
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
scripts: {
|
||||
"backend-dev": backend,
|
||||
web: { type: "service", command: "pnpm run dev -- --port $PASEO_PORT" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const webResult = await spawnWorkspaceScript({ ...options, scriptName: "web" });
|
||||
|
||||
expect(webResult.port).toEqual(expect.any(Number));
|
||||
expect(webResult.port).not.toBe(backendResult.port);
|
||||
expect(terminalRecords[1]?.sentInputs).toEqual(["pnpm run dev -- --port $PASEO_PORT\r"]);
|
||||
expect(createTerminalCalls[1]?.env?.PASEO_PORT).toBe(String(webResult.port));
|
||||
expect(createTerminalCalls[1]?.env?.PASEO_SERVICE_BACKEND_DEV_PORT).toBe(
|
||||
String(backendResult.port),
|
||||
);
|
||||
expect(serviceProxy.listRoutes()).toHaveLength(2);
|
||||
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "backend-dev" })).toMatchObject({
|
||||
lifecycle: "running",
|
||||
terminalId: backendResult.terminalId,
|
||||
});
|
||||
});
|
||||
|
||||
it("spawns services with public aliases and public service URLs", async () => {
|
||||
commitPaseoScripts(
|
||||
{
|
||||
|
||||
@@ -3,15 +3,218 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getCheckoutDiff, getCommitFileDiff } from "./checkout-git.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutShortstat,
|
||||
getCommitFileDiff,
|
||||
listCheckoutCommits,
|
||||
} from "./checkout-git.js";
|
||||
|
||||
const repos: string[] = [];
|
||||
|
||||
const EDITED_TEXT_DIFF = {
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1 +1 @@" },
|
||||
{ type: "remove", content: "before" },
|
||||
{ type: "add", content: "after" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"returns a quoted filename and its uncommitted diff using its actual path",
|
||||
async () => {
|
||||
const cwd = createChangedRepo("core.quotepath=false");
|
||||
const name = 'report "draft".txt';
|
||||
writeFileSync(join(cwd, "file.txt"), "before\n");
|
||||
writeFileSync(join(cwd, name), "before\n");
|
||||
git(cwd, "add", "--", name);
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add quoted filename");
|
||||
writeFileSync(join(cwd, name), "after\n");
|
||||
|
||||
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
|
||||
|
||||
expect(result.structured).toHaveLength(1);
|
||||
expect(result.structured?.[0]).toMatchObject({
|
||||
path: name,
|
||||
status: "ok",
|
||||
...EDITED_TEXT_DIFF,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")("quoted paths from real Git", () => {
|
||||
const names = [
|
||||
"normal.txt",
|
||||
"with spaces.txt",
|
||||
'report "draft".txt',
|
||||
"back\\slash.txt",
|
||||
"tab\tname.txt",
|
||||
"line\nname.txt",
|
||||
" leading and trailing .txt ",
|
||||
'report "old => draft".txt',
|
||||
];
|
||||
|
||||
function createQuotedPathsRepo(): string {
|
||||
const cwd = createChangedRepo("core.quotepath=false");
|
||||
writeFileSync(join(cwd, "file.txt"), "before\n");
|
||||
for (const name of names) writeFileSync(join(cwd, name), "before\n");
|
||||
git(cwd, "add", "--", ...names);
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add filenames");
|
||||
git(cwd, "checkout", "-b", "feature");
|
||||
for (const name of names) writeFileSync(join(cwd, name), "after\n");
|
||||
return cwd;
|
||||
}
|
||||
|
||||
it("preserves tracked and untracked paths, diff lines and summary counts", async () => {
|
||||
const cwd = createQuotedPathsRepo();
|
||||
const untrackedNames = names.map((name) => "untracked-" + name);
|
||||
for (const name of untrackedNames) writeFileSync(join(cwd, name), "new file\n");
|
||||
|
||||
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
|
||||
|
||||
expect(result.structured?.map((file) => file.path).sort()).toEqual(
|
||||
[...names, ...untrackedNames].sort(),
|
||||
);
|
||||
for (const name of names) {
|
||||
expect(result.structured?.find((file) => file.path === name)).toMatchObject({
|
||||
path: name,
|
||||
status: "ok",
|
||||
...EDITED_TEXT_DIFF,
|
||||
});
|
||||
}
|
||||
for (const name of untrackedNames) {
|
||||
expect(result.structured?.find((file) => file.path === name)).toMatchObject({
|
||||
path: name,
|
||||
status: "ok",
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 0,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -0,0 +1 @@" },
|
||||
{ type: "add", content: "new file" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
expect(await getCheckoutShortstat(cwd)).toEqual({
|
||||
additions: names.length + untrackedNames.length,
|
||||
deletions: names.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens base and commit diffs using the actual paths from the commit list", async () => {
|
||||
const cwd = createQuotedPathsRepo();
|
||||
git(cwd, "add", "--", ...names);
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit filenames");
|
||||
const sha = git(cwd, "rev-parse", "HEAD").trim();
|
||||
|
||||
const base = await getCheckoutDiff(cwd, {
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
includeStructured: true,
|
||||
});
|
||||
expect(base.structured?.map((file) => file.path).sort()).toEqual([...names].sort());
|
||||
for (const name of names) {
|
||||
expect(base.structured?.find((file) => file.path === name)).toMatchObject({
|
||||
path: name,
|
||||
status: "ok",
|
||||
...EDITED_TEXT_DIFF,
|
||||
});
|
||||
}
|
||||
|
||||
const history = await listCheckoutCommits({ cwd });
|
||||
const commit = history.commits[0];
|
||||
expect(commit.sha).toBe(sha);
|
||||
expect(commit.files).toEqual(
|
||||
[...names].sort().map((name) => ({
|
||||
path: name,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
})),
|
||||
);
|
||||
for (const file of commit.files) {
|
||||
expect(await getCommitFileDiff({ cwd, sha, path: file.path })).toMatchObject({
|
||||
path: file.path,
|
||||
...EDITED_TEXT_DIFF,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves quoted rename destinations and binary paths in diff and commit lists", async () => {
|
||||
const cwd = createChangedRepo("core.quotepath=false");
|
||||
const oldPath = 'old "draft".txt';
|
||||
const newPath = 'new "draft".txt';
|
||||
const binaryPath = "image\tdata.bin";
|
||||
writeFileSync(join(cwd, "file.txt"), "before\n");
|
||||
writeFileSync(join(cwd, oldPath), "unchanged\n");
|
||||
writeFileSync(join(cwd, binaryPath), Buffer.from([0, 1, 2]));
|
||||
git(cwd, "add", "--", oldPath, binaryPath);
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add rename and binary files");
|
||||
git(cwd, "checkout", "-b", "feature");
|
||||
git(cwd, "mv", "--", oldPath, newPath);
|
||||
writeFileSync(join(cwd, binaryPath), Buffer.from([0, 3, 4]));
|
||||
|
||||
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
|
||||
expect(
|
||||
result.structured?.map(({ path, oldPath: sourcePath, status }) => ({
|
||||
path,
|
||||
oldPath: sourcePath,
|
||||
status,
|
||||
})),
|
||||
).toEqual([
|
||||
{ path: binaryPath, oldPath: undefined, status: "binary" },
|
||||
{
|
||||
path: newPath,
|
||||
oldPath,
|
||||
status: "ok",
|
||||
},
|
||||
]);
|
||||
expect(result.structured?.[0]).toEqual({
|
||||
path: binaryPath,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
status: "binary",
|
||||
});
|
||||
|
||||
git(cwd, "add", "--", binaryPath);
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "rename file and edit binary");
|
||||
const history = await listCheckoutCommits({ cwd });
|
||||
expect(history.commits[0].files).toEqual([
|
||||
{ path: binaryPath, additions: 0, deletions: 0, status: "modified" },
|
||||
{ path: newPath, additions: 0, deletions: 0, status: "renamed" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["diff.mnemonicPrefix=true", "color.diff=always", "diff.noprefix=true"])(
|
||||
"preserves batched paths, spaces and literal prefix directories with %s",
|
||||
async (config) => {
|
||||
|
||||
@@ -1628,7 +1628,7 @@ const x = 1;
|
||||
expect(diff.diff).toContain(`-export const value = "old";`);
|
||||
expect(diff.diff).toContain(`+export const value = "new";`);
|
||||
expect(commands).toContain(
|
||||
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ --numstat HEAD",
|
||||
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ -z --numstat HEAD",
|
||||
);
|
||||
expect(commands).toContain(
|
||||
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)small.ts",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import { resolve, dirname, basename } from "path";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, readFile, stat as statFile } from "fs/promises";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { runGitCommand, type RunGitCommand } from "./run-git-command.js";
|
||||
import { readGitFileContents } from "./git-file-contents.js";
|
||||
import { unquoteGitPath } from "./git-quoted-path.js";
|
||||
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
||||
import {
|
||||
branchNameFromRef,
|
||||
@@ -514,22 +516,18 @@ async function listCheckoutFileChanges(
|
||||
const { stdout: nameStatusOut } = await runGitCommand(
|
||||
buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--name-status", ...getCheckoutDiffRefArgs(refs)],
|
||||
extra: ["-z", "--name-status", ...getCheckoutDiffRefArgs(refs)],
|
||||
}),
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
for (const line of nameStatusOut
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)) {
|
||||
// `--name-status` uses TAB separators, which preserves filenames with spaces.
|
||||
const tabParts = line.split("\t");
|
||||
const rawStatus = (tabParts[0] ?? "").trim();
|
||||
const nameStatusFields = nameStatusOut.split("\0");
|
||||
for (let index = 0; index < nameStatusFields.length; ) {
|
||||
const rawStatus = nameStatusFields[index++];
|
||||
if (!rawStatus) continue;
|
||||
|
||||
if (rawStatus.startsWith("R") || rawStatus.startsWith("C")) {
|
||||
const oldPath = tabParts[1];
|
||||
const newPath = tabParts[2];
|
||||
const oldPath = nameStatusFields[index++];
|
||||
const newPath = nameStatusFields[index++];
|
||||
if (newPath) {
|
||||
changes.push({
|
||||
path: newPath,
|
||||
@@ -542,7 +540,7 @@ async function listCheckoutFileChanges(
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = tabParts[1];
|
||||
const path = nameStatusFields[index++];
|
||||
if (!path) continue;
|
||||
const code = rawStatus[0];
|
||||
changes.push({
|
||||
@@ -555,16 +553,13 @@ async function listCheckoutFileChanges(
|
||||
|
||||
if (refs.includeUntracked) {
|
||||
const { stdout: untrackedOut } = await runGitCommand(
|
||||
["ls-files", "--others", "--exclude-standard"],
|
||||
["ls-files", "-z", "--others", "--exclude-standard"],
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
for (const file of untrackedOut
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)) {
|
||||
for (const file of untrackedOut.split("\0").filter(Boolean)) {
|
||||
changes.push({
|
||||
path: file,
|
||||
status: "U",
|
||||
@@ -622,18 +617,22 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
|
||||
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
|
||||
|
||||
function normalizeNumstatPath(pathField: string): string {
|
||||
if (/^"(?:\\.|[^"\\])*"$/.test(pathField)) {
|
||||
return unquoteGitPath(pathField);
|
||||
}
|
||||
|
||||
const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
||||
if (braceRenameMatch) {
|
||||
const [, prefix, , renamed, suffix] = braceRenameMatch;
|
||||
return `${prefix}${renamed}${suffix}`;
|
||||
return unquoteGitPath(`${prefix}${renamed}${suffix}`);
|
||||
}
|
||||
|
||||
const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/);
|
||||
if (inlineRenameMatch) {
|
||||
return inlineRenameMatch[2] ?? pathField;
|
||||
return unquoteGitPath(inlineRenameMatch[2] ?? pathField);
|
||||
}
|
||||
|
||||
return pathField;
|
||||
return unquoteGitPath(pathField);
|
||||
}
|
||||
|
||||
// Parsed patches need a stable wire format regardless of terminal presentation settings.
|
||||
@@ -668,7 +667,7 @@ async function getTrackedNumstatByPath(
|
||||
const result = await runGitCommand(
|
||||
buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--numstat", ...getCheckoutDiffRefArgs(refs)],
|
||||
extra: ["-z", "--numstat", ...getCheckoutDiffRefArgs(refs)],
|
||||
}),
|
||||
{
|
||||
cwd,
|
||||
@@ -679,21 +678,21 @@ async function getTrackedNumstatByPath(
|
||||
);
|
||||
|
||||
const stats = new Map<string, FileStat>();
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split("\t");
|
||||
const records = result.stdout.split("\0");
|
||||
for (let index = 0; index < records.length; index += 1) {
|
||||
const parts = records[index].split("\t");
|
||||
if (parts.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const additionsField = parts[0] ?? "";
|
||||
const deletionsField = parts[1] ?? "";
|
||||
const rawPath = parts.slice(2).join("\t");
|
||||
const path = normalizeNumstatPath(rawPath);
|
||||
let path = parts.slice(2).join("\t");
|
||||
if (!path) {
|
||||
// A rename has an empty path here, followed by separate old/new NUL fields.
|
||||
path = records[index + 2];
|
||||
index += 2;
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
continue;
|
||||
@@ -2152,13 +2151,13 @@ export async function getCheckoutSnapshotFacts(
|
||||
|
||||
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const RELAY_MAX_FRAME_BYTES = 32 * 1024 * 1024;
|
||||
const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024;
|
||||
// Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is
|
||||
// binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for
|
||||
// the surrounding WebSocket JSON envelope after inverting that exact wire expansion.
|
||||
export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES =
|
||||
maxBase64EncryptedPlaintextByteLength(RELAY_MAX_FRAME_BYTES) - CHECKOUT_DIFF_FRAME_HEADROOM_BYTES;
|
||||
maxBase64EncryptedPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) -
|
||||
CHECKOUT_DIFF_FRAME_HEADROOM_BYTES;
|
||||
|
||||
interface StructuredDiffAccumulator {
|
||||
files: ParsedDiffFile[];
|
||||
@@ -2423,7 +2422,7 @@ function parseRawStatusLine(line: string, statuses: Map<string, CheckoutCommitFi
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
statuses.set(path, status);
|
||||
statuses.set(unquoteGitPath(path), status);
|
||||
}
|
||||
|
||||
// A `--numstat` line: `<adds>\t<dels>\t<path>` (renames use `old => new`, binary
|
||||
@@ -2737,16 +2736,13 @@ async function countUntrackedAdditions(
|
||||
): Promise<number> {
|
||||
try {
|
||||
const { stdout } = await getRunGitCommand(context)(
|
||||
["ls-files", "--others", "--exclude-standard"],
|
||||
["ls-files", "-z", "--others", "--exclude-standard"],
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const files = stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const files = stdout.split("\0").filter(Boolean);
|
||||
|
||||
let additions = 0;
|
||||
for (const file of files.slice(0, UNTRACKED_SHORTSTAT_MAX_FILES)) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const GIT_PATH_ESCAPE_BYTES: Record<string, number> = {
|
||||
a: 7,
|
||||
b: 8,
|
||||
t: 9,
|
||||
n: 10,
|
||||
v: 11,
|
||||
f: 12,
|
||||
r: 13,
|
||||
'"': 34,
|
||||
"\\": 92,
|
||||
};
|
||||
|
||||
// core.quotepath=false still quotes control characters, double quotes and backslashes.
|
||||
export function unquoteGitPath(value: string): string {
|
||||
if (!value.startsWith('"') || !value.endsWith('"')) return value;
|
||||
|
||||
const encoded = value.slice(1, -1);
|
||||
const parts: Buffer[] = [];
|
||||
let offset = 0;
|
||||
for (const match of encoded.matchAll(/\\([0-7]{1,3}|[abfnrtv"\\])/g)) {
|
||||
parts.push(Buffer.from(encoded.slice(offset, match.index)));
|
||||
const escape = match[1];
|
||||
let byte: number;
|
||||
if (/^[0-7]/.test(escape)) {
|
||||
byte = Number.parseInt(escape, 8);
|
||||
} else {
|
||||
byte = GIT_PATH_ESCAPE_BYTES[escape];
|
||||
}
|
||||
parts.push(Buffer.from([byte]));
|
||||
offset = match.index + match[0].length;
|
||||
}
|
||||
parts.push(Buffer.from(encoded.slice(offset)));
|
||||
return Buffer.concat(parts).toString("utf8");
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type WorktreeConfig,
|
||||
} from "./worktree";
|
||||
import { execFileSync } from "child_process";
|
||||
import { rm } from "node:fs/promises";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
@@ -89,8 +90,8 @@ describe("paseo worktree manager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
});
|
||||
|
||||
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
|
||||
|
||||
@@ -711,7 +711,7 @@
|
||||
},
|
||||
"voice": {
|
||||
"type": "string",
|
||||
"enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
|
||||
"minLength": 1
|
||||
},
|
||||
"speakerId": {
|
||||
"type": "integer",
|
||||
|
||||
Reference in New Issue
Block a user