mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
605c3f3832 | ||
|
|
cafa04d3fb | ||
|
|
938c4e2632 | ||
|
|
d57096a456 | ||
|
|
ada7ba43f0 | ||
|
|
fc8f085b9e | ||
|
|
019e5279e9 | ||
|
|
4a3ff87eec | ||
|
|
a4f1df3aed | ||
|
|
a4b15455b7 | ||
|
|
bd9af256bd | ||
|
|
b1a638482d | ||
|
|
12aaded6c5 | ||
|
|
0614e96ee0 |
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -29,4 +29,15 @@ describe("formatThinkingOptionLabel", () => {
|
||||
expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard");
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high");
|
||||
});
|
||||
|
||||
it("preserves provider-supplied xhigh labels with extra detail", () => {
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (56 | 8m)" })).toBe(
|
||||
"X high (56 | 8m)",
|
||||
);
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (45 | 6m)" })).toBe(
|
||||
"X high (45 | 6m)",
|
||||
);
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh" })).toBe("Extra high");
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "Extra high" })).toBe("Extra high");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,10 +34,9 @@ export function formatAgentModeLabel(mode: ControlLabelInput): string {
|
||||
|
||||
export function formatThinkingOptionLabel(option: ControlLabelInput): string {
|
||||
const rawLabel = (option.label ?? option.id).trim();
|
||||
const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase();
|
||||
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
|
||||
|
||||
if (compactId === "xhigh" || compactLabel === "xhigh") {
|
||||
if (compactLabel === "xhigh" || compactLabel === "extrahigh") {
|
||||
return i18n.t("agentControls.thinking.extraHigh");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,5 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentLsFetchOptions } from "./ls.js";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { buildAgentLsFetchOptions, listAgents } from "./ls.js";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
function createPagingClient() {
|
||||
const daemonAgents: AgentSnapshotPayload[] = [3, 2, 1].map((n) => ({
|
||||
id: `${n}${n}${n}${n}${n}${n}${n}${n}-0000-4000-8000-000000000000`,
|
||||
provider: "codex",
|
||||
title: `agent ${n}`,
|
||||
status: "closed",
|
||||
archivedAt: null,
|
||||
cwd: "/tmp/project",
|
||||
createdAt: `2026-10-0${n}T00:00:00.000Z`,
|
||||
updatedAt: `2026-10-0${n}T00:00:00.000Z`,
|
||||
labels: {},
|
||||
model: null,
|
||||
lastUserMessageAt: null,
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
}));
|
||||
|
||||
// The fake daemon pages two agents at a time, newest first by the requested key
|
||||
// (updated_at by default), and agent 1 is updated right after the first page is served.
|
||||
const fetchAgents = async (options?: {
|
||||
sort?: { key: string }[];
|
||||
page?: { cursor?: string };
|
||||
}) => {
|
||||
const key: "createdAt" | "updatedAt" =
|
||||
options?.sort?.[0]?.key === "created_at" ? "createdAt" : "updatedAt";
|
||||
const cursor = options?.page?.cursor;
|
||||
const ordered = daemonAgents
|
||||
.filter((agent) => cursor === undefined || agent[key] < cursor)
|
||||
.sort((left, right) => right[key].localeCompare(left[key]));
|
||||
const page = ordered.slice(0, 2);
|
||||
const hasMore = ordered.length > 2;
|
||||
if (cursor === undefined) {
|
||||
daemonAgents[2]!.updatedAt = "2026-10-09T00:00:00.000Z";
|
||||
}
|
||||
return {
|
||||
entries: page.map((agent) => ({ agent })),
|
||||
pageInfo: { nextCursor: hasMore ? page[page.length - 1]![key] : null },
|
||||
};
|
||||
};
|
||||
return { fetchAgents, close: async () => {} };
|
||||
}
|
||||
|
||||
describe("buildAgentLsFetchOptions", () => {
|
||||
it("fetches active agents by default", () => {
|
||||
@@ -66,3 +121,13 @@ describe("buildAgentLsFetchOptions", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runLsCommand", () => {
|
||||
it("lists agents on every page, including one updated during the listing", async () => {
|
||||
const result = await listAgents({ daemonTarget, all: true, global: true }, async () =>
|
||||
createPagingClient(),
|
||||
);
|
||||
|
||||
expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,6 +165,14 @@ export function buildAgentLsFetchOptions(
|
||||
return fetchOptions;
|
||||
}
|
||||
|
||||
interface AgentLsClient {
|
||||
fetchAgents(options?: FetchAgentsOptions): Promise<{
|
||||
entries: { agent: AgentSnapshotPayload }[];
|
||||
pageInfo: { nextCursor: string | null };
|
||||
}>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent ls command semantics:
|
||||
* - `paseo agent ls` → active non-archived agents
|
||||
@@ -176,7 +184,16 @@ export async function runLsCommand(
|
||||
options: AgentLsOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentLsResult> {
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
return listAgents(options);
|
||||
}
|
||||
|
||||
export async function listAgents(
|
||||
options: AgentLsOptions,
|
||||
connect: (
|
||||
options: Parameters<typeof connectToDaemon>[0],
|
||||
) => Promise<AgentLsClient> = connectToDaemon,
|
||||
): Promise<AgentLsResult> {
|
||||
const client = await connect({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const normalizedThinkingOptionId = options.thinking?.trim();
|
||||
@@ -189,8 +206,19 @@ export async function runLsCommand(
|
||||
}
|
||||
|
||||
const labelFilters = parseLabelFilters(options.label);
|
||||
const fetchPayload = await client.fetchAgents(buildAgentLsFetchOptions(options));
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent);
|
||||
const fetchOptions = buildAgentLsFetchOptions(options);
|
||||
let agents: AgentSnapshotPayload[] = [];
|
||||
let cursor: string | undefined;
|
||||
// createdAt never changes, so agents cannot move past the cursor while the pages are read.
|
||||
do {
|
||||
const payload = await client.fetchAgents({
|
||||
...fetchOptions,
|
||||
sort: [{ key: "created_at", direction: "desc" }],
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
agents.push(...payload.entries.map((entry) => entry.agent));
|
||||
cursor = payload.pageInfo.nextCursor ?? undefined;
|
||||
} while (cursor);
|
||||
|
||||
// By default, exclude archived agents. `-a` includes them.
|
||||
if (!options.all) {
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7527,3 +7527,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)),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3296,6 +3296,50 @@ describe("create_agent MCP tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("notifies the caller when a created child finishes its initial turn before the turn start returns", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-create-fast-child-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const parentClient = new HeldTurnAgentClient("claude", false);
|
||||
const childClient = new HeldTurnAgentClient("codex", false, { finishTurnsDuringStart: true });
|
||||
const agentManager = new AgentManager({
|
||||
clients: { claude: parentClient, codex: childClient },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const parent = await agentManager.createAgent(
|
||||
{ provider: "claude", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
|
||||
const response = await invokeToolWithParsedInput(registeredTool(server, "create_agent"), {
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Fast Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Finish immediately",
|
||||
});
|
||||
const childId = z.object({ agentId: z.string() }).parse(response.structuredContent).agentId;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const parentPrompts = parentClient.sessions[0]!.prompts;
|
||||
expect(parentPrompts).toHaveLength(1);
|
||||
expect(parentPrompts[0]).toContain(childId);
|
||||
expect(parentPrompts[0]).toContain("finished");
|
||||
});
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
|
||||
it("creates detached caller agents without a parent label", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
@@ -3770,6 +3814,11 @@ const HELD_TURN_CAPABILITIES = {
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
interface HeldTurnOptions {
|
||||
/** Run the whole turn before the turn-start request returns. */
|
||||
finishTurnsDuringStart?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider session that records every prompt it receives. With `holdTurns`, a started
|
||||
* turn stays running until `finishTurn()` so a caller's bounded wait can run out first.
|
||||
@@ -3786,6 +3835,7 @@ class HeldTurnAgentSession implements AgentSession {
|
||||
constructor(
|
||||
readonly provider: AgentProvider,
|
||||
private readonly holdTurns: boolean,
|
||||
private readonly options: HeldTurnOptions = {},
|
||||
) {}
|
||||
|
||||
/** Hold the next turn's acknowledgment, as a provider awaiting its turn-start request does. */
|
||||
@@ -3819,6 +3869,13 @@ class HeldTurnAgentSession implements AgentSession {
|
||||
throw error;
|
||||
}
|
||||
const turnId = randomUUID();
|
||||
if (this.options.finishTurnsDuringStart) {
|
||||
// The whole turn runs before the turn-start request returns, as a provider whose
|
||||
// start acknowledgment arrives together with the turn's completion does.
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
return { turnId };
|
||||
}
|
||||
this.activeTurnId = turnId;
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
@@ -3926,6 +3983,7 @@ class HeldTurnAgentClient implements AgentClient {
|
||||
constructor(
|
||||
readonly provider: AgentProvider,
|
||||
private readonly holdTurns: boolean,
|
||||
private readonly options: HeldTurnOptions = {},
|
||||
) {}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
@@ -3933,7 +3991,7 @@ class HeldTurnAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async createSession(): Promise<AgentSession> {
|
||||
const session = new HeldTurnAgentSession(this.provider, this.holdTurns);
|
||||
const session = new HeldTurnAgentSession(this.provider, this.holdTurns, this.options);
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -2898,9 +2898,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.startFreshConversationSession();
|
||||
return;
|
||||
}
|
||||
const currentSessionId = this.claudeSessionId;
|
||||
await revertClaudeConversation({
|
||||
sdk: this.rewindSdk,
|
||||
sessionId: this.claudeSessionId,
|
||||
sessionId: currentSessionId,
|
||||
transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null,
|
||||
messageId: target.messageId,
|
||||
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
|
||||
setSessionId: (sessionId) => {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import {
|
||||
revertClaudeConversation,
|
||||
revertClaudeConversationAndFiles,
|
||||
revertClaudeFiles,
|
||||
realClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
|
||||
|
||||
@@ -16,6 +21,7 @@ describe("Claude rewind", () => {
|
||||
await revertClaudeConversation({
|
||||
sdk: claude,
|
||||
sessionId,
|
||||
transcriptPath: "/claude/projects/repo/original-session.jsonl",
|
||||
messageId: "user-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
sessionId = nextSessionId;
|
||||
@@ -33,6 +39,7 @@ describe("Claude rewind", () => {
|
||||
await revertClaudeConversation({
|
||||
sdk: claude,
|
||||
sessionId,
|
||||
transcriptPath: "/claude/projects/repo/original-session.jsonl",
|
||||
messageId: "timeline-message-1",
|
||||
resolveMessageId: () => "claude-jsonl-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
@@ -76,6 +83,7 @@ describe("Claude rewind", () => {
|
||||
sdk: claude,
|
||||
query: claude.createQuery() as Query,
|
||||
sessionId,
|
||||
transcriptPath: "/claude/projects/repo/original-session.jsonl",
|
||||
messageId: "user-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
sessionId = nextSessionId;
|
||||
@@ -87,3 +95,126 @@ describe("Claude rewind", () => {
|
||||
expect(sessionId).toBe("forked-before-rehydrate");
|
||||
});
|
||||
});
|
||||
|
||||
interface TranscriptLine {
|
||||
type: "user" | "assistant";
|
||||
uuid: string;
|
||||
parentUuid: string | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function writeTranscript(
|
||||
transcriptPath: string,
|
||||
sessionId: string,
|
||||
lines: Array<TranscriptLine | string>,
|
||||
): void {
|
||||
const entries = lines.map((line) => {
|
||||
if (typeof line === "string") return line;
|
||||
const { type, uuid, parentUuid, text } = line;
|
||||
return JSON.stringify({
|
||||
type,
|
||||
uuid,
|
||||
parentUuid,
|
||||
sessionId,
|
||||
cwd: "/repo",
|
||||
timestamp: new Date().toISOString(),
|
||||
message: { role: type, content: text },
|
||||
});
|
||||
});
|
||||
writeFileSync(transcriptPath, `${entries.join("\n")}\n`, "utf8");
|
||||
}
|
||||
|
||||
function readMessageTexts(transcriptPath: string): string[] {
|
||||
const entries = readFileSync(transcriptPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { message?: { content: string } });
|
||||
return entries.flatMap((entry) => (entry.message ? [entry.message.content] : []));
|
||||
}
|
||||
|
||||
describe("Claude rewind against the Claude SDK", () => {
|
||||
let tempRoot: string | null = null;
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
|
||||
tempRoot = null;
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||
}
|
||||
});
|
||||
|
||||
test("forks a session that lives in a provider's own CLAUDE_CONFIG_DIR", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-"));
|
||||
// The provider sets CLAUDE_CONFIG_DIR for Claude Code; the daemon's own environment does not.
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = randomUUID();
|
||||
const firstUser = randomUUID();
|
||||
const firstReply = randomUUID();
|
||||
const secondUser = randomUUID();
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
writeTranscript(transcriptPath, sessionId, [
|
||||
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
|
||||
{ type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" },
|
||||
{ type: "user", uuid: secondUser, parentUuid: firstReply, text: "second" },
|
||||
{ type: "assistant", uuid: randomUUID(), parentUuid: secondUser, text: "second reply" },
|
||||
]);
|
||||
let currentSessionId = sessionId;
|
||||
|
||||
await revertClaudeConversation({
|
||||
sdk: realClaudeRewindSdk,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
messageId: firstReply,
|
||||
setSessionId: (nextSessionId) => {
|
||||
currentSessionId = nextSessionId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(currentSessionId).not.toBe(sessionId);
|
||||
expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([
|
||||
"first",
|
||||
"first reply",
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips transcript lines that are not entries when forking", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-"));
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = randomUUID();
|
||||
const firstUser = randomUUID();
|
||||
const firstReply = randomUUID();
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
writeTranscript(transcriptPath, sessionId, [
|
||||
"null",
|
||||
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
|
||||
"[1, 2]",
|
||||
'"text"',
|
||||
"{}",
|
||||
{ type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" },
|
||||
{ type: "user", uuid: randomUUID(), parentUuid: firstReply, text: "second" },
|
||||
]);
|
||||
let currentSessionId = sessionId;
|
||||
|
||||
await revertClaudeConversation({
|
||||
sdk: realClaudeRewindSdk,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
messageId: firstReply,
|
||||
setSessionId: (nextSessionId) => {
|
||||
currentSessionId = nextSessionId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([
|
||||
"first",
|
||||
"first reply",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,90 @@
|
||||
import { forkSession as claudeForkSession, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { appendFile, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
forkSession as claudeForkSession,
|
||||
type Query,
|
||||
type SessionStore,
|
||||
type SessionStoreEntry,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export interface ClaudeRewindSdk {
|
||||
forkSession(
|
||||
sessionId: string,
|
||||
options: { upToMessageId: string },
|
||||
options: { upToMessageId: string; transcriptPath: string },
|
||||
): Promise<{ sessionId: string }>;
|
||||
}
|
||||
|
||||
// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a
|
||||
// provider can point Claude Code at another one. Forking through a store rooted at the
|
||||
// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it.
|
||||
export const realClaudeRewindSdk: ClaudeRewindSdk = {
|
||||
forkSession: claudeForkSession,
|
||||
forkSession: (sessionId, options) =>
|
||||
claudeForkSession(sessionId, {
|
||||
upToMessageId: options.upToMessageId,
|
||||
sessionStore: projectFolderSessionStore(path.dirname(options.transcriptPath)),
|
||||
}),
|
||||
};
|
||||
|
||||
function projectFolderSessionStore(projectFolder: string): SessionStore {
|
||||
const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`);
|
||||
return {
|
||||
async load({ sessionId }) {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(transcriptPath(sessionId), "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
return parseTranscriptEntries(text);
|
||||
},
|
||||
async append({ sessionId }, entries) {
|
||||
const lines = entries.map((entry) => `${JSON.stringify(entry)}\n`).join("");
|
||||
await appendFile(transcriptPath(sessionId), lines, "utf8");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTranscriptEntries(text: string): SessionStoreEntry[] {
|
||||
const entries: SessionStoreEntry[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
// Skip a line that is not JSON, as the SDK's own transcript reader does.
|
||||
continue;
|
||||
}
|
||||
if (isSessionStoreEntry(parsed)) entries.push(parsed);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as { type?: unknown }).type === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export async function revertClaudeConversation(input: {
|
||||
sdk: ClaudeRewindSdk;
|
||||
sessionId: string | null;
|
||||
transcriptPath: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
}): Promise<void> {
|
||||
if (!input.sessionId) {
|
||||
if (!input.sessionId || !input.transcriptPath) {
|
||||
throw new Error("Claude session is not ready for rewind");
|
||||
}
|
||||
const messageId = (await input.resolveMessageId?.(input.messageId)) ?? input.messageId;
|
||||
const fork = await input.sdk.forkSession(input.sessionId, {
|
||||
upToMessageId: messageId,
|
||||
transcriptPath: input.transcriptPath,
|
||||
});
|
||||
input.setSessionId(fork.sessionId);
|
||||
}
|
||||
@@ -44,6 +105,7 @@ export async function revertClaudeConversationAndFiles(input: {
|
||||
sdk: ClaudeRewindSdk;
|
||||
query: Query;
|
||||
sessionId: string | null;
|
||||
transcriptPath: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
|
||||
@@ -12,7 +12,7 @@ export class FakeClaudeSdk implements ClaudeRewindSdk {
|
||||
|
||||
async forkSession(
|
||||
_sessionId: string,
|
||||
options: { upToMessageId: string },
|
||||
options: { upToMessageId: string; transcriptPath: string },
|
||||
): Promise<{ sessionId: string }> {
|
||||
this.recordedForks.push({ upToMessageId: options.upToMessageId });
|
||||
return { sessionId: this.nextSessionId };
|
||||
|
||||
@@ -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";
|
||||
@@ -202,12 +204,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 +251,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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
@@ -664,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", () => {
|
||||
@@ -843,3 +866,35 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"config.json symlinked from another directory",
|
||||
() => {
|
||||
test("saving writes through the symlink and leaves it in place", () => {
|
||||
const parent = createTempHome();
|
||||
const home = path.join(parent, "home");
|
||||
const dotfiles = path.join(parent, "dotfiles");
|
||||
const linkedConfigPath = path.join(dotfiles, "config.json");
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
mkdirSync(home);
|
||||
mkdirSync(dotfiles, { mode: 0o755 });
|
||||
chmodSync(dotfiles, 0o755);
|
||||
writeFileSync(linkedConfigPath, '{ "version": 1 }\n');
|
||||
symlinkSync(linkedConfigPath, configPath);
|
||||
|
||||
savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } });
|
||||
|
||||
expect(lstatSync(configPath).isSymbolicLink()).toBe(true);
|
||||
expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({
|
||||
version: 1,
|
||||
daemon: { relay: { enabled: false } },
|
||||
});
|
||||
expect(modeOf(dotfiles)).toBe(0o755);
|
||||
expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -28,14 +28,27 @@ export function writePrivateFileAtomicSync(
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
): void {
|
||||
ensurePrivateDirectory(path.dirname(filePath));
|
||||
const parent = path.dirname(filePath);
|
||||
const temporary = path.join(parent, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}`);
|
||||
const destination = resolveSymlinkedFile(filePath);
|
||||
const temporary = path.join(
|
||||
path.dirname(destination),
|
||||
`.${path.basename(destination)}.${process.pid}.${randomUUID()}`,
|
||||
);
|
||||
try {
|
||||
writeFileSync(temporary, data, { mode: PRIVATE_FILE_MODE });
|
||||
renameSync(temporary, filePath);
|
||||
ensurePrivateFile(filePath);
|
||||
renameSync(temporary, destination);
|
||||
ensurePrivateFile(destination);
|
||||
} catch (error) {
|
||||
rmSync(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming onto a symlink replaces the link, so write to the file it points at.
|
||||
function resolveSymlinkedFile(filePath: string): string {
|
||||
try {
|
||||
return realpathSync(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return filePath;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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