Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 7f29852be2 test(app): wait for the focus hint padding before checking overlap 2026-10-14 04:01:27 +02:00
paseo-bot[bot] 41a7b942bd fix(app): keep the composer focus hint off the placeholder
The web composer draws its focus shortcut hint over the input's top-right
corner. When the placeholder and the hint did not fit on one line, as in
French at a phone width or with the longer desktop placeholder, the hint
was drawn on top of the end of the placeholder. The input now reserves
the hint's width as right padding while the hint shows, so the
placeholder wraps before it.

Closes #6191
2026-10-14 04:01:27 +02:00
37 changed files with 248 additions and 614 deletions
@@ -0,0 +1,113 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const APP_SETTINGS_KEY = "@paseo:app-settings";
async function useLanguage(page: Page, language: string): Promise<void> {
await page.addInitScript(
({ key, language: value }) => {
localStorage.setItem(key, JSON.stringify({ language: value }));
},
{ key: APP_SETTINGS_KEY, language },
);
}
/**
* Lays the placeholder out the way the textarea does, in a mirror element with
* the same box and font, and reports whether any of its lines reach the hint.
*/
async function placeholderOverlaps(input: Locator, hint: Locator): Promise<boolean> {
const hintBox = await hint.boundingBox();
if (!hintBox) throw new Error("Focus hint has no layout box");
return input.evaluate((element, box) => {
const textarea = element as HTMLTextAreaElement;
const style = getComputedStyle(textarea);
const rect = textarea.getBoundingClientRect();
const mirror = document.createElement("div");
for (const property of [
"boxSizing",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"fontFamily",
"fontSize",
"fontWeight",
"letterSpacing",
"lineHeight",
"textIndent",
"wordSpacing",
] as const) {
mirror.style[property] = style[property];
}
Object.assign(mirror.style, {
position: "fixed",
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.width}px`,
borderStyle: "solid",
borderColor: "transparent",
whiteSpace: "pre-wrap",
overflowWrap: "break-word",
visibility: "hidden",
});
const text = document.createTextNode(textarea.placeholder);
mirror.appendChild(text);
document.body.appendChild(mirror);
const range = document.createRange();
range.selectNodeContents(text);
const lines = Array.from(range.getClientRects());
mirror.remove();
return lines.some(
(line) =>
line.right > box.x &&
line.left < box.x + box.width &&
line.bottom > box.y &&
line.top < box.y + box.height,
);
}, hintBox);
}
const FRENCH_LAYOUTS = [
{ name: "phone width", width: 390, placeholder: "Message, @fichiers, /commandes" },
{
name: "desktop width",
width: 1024,
placeholder:
"Écrivez à l’agent, mentionnez des @fichiers ou utilisez les /commandes et /skills",
},
];
for (const layout of FRENCH_LAYOUTS) {
test.describe(`French composer at ${layout.name}`, () => {
test.use({ viewport: { width: layout.width, height: 844 } });
test("the focus hint does not cover the placeholder", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-hint-",
title: "Composer focus hint",
});
try {
await useLanguage(page, "fr");
await openAgentRoute(page, agent);
const input = page.getByRole("textbox", { name: "Écrire à l’agent…" }).first();
await expect(input).toBeVisible();
await expect(input).toHaveAttribute("placeholder", layout.placeholder);
await input.evaluate((element) => (element as HTMLTextAreaElement).blur());
const hint = page.getByText(/pour saisir$/).first();
await expect(hint).toBeVisible();
// The input takes the hint's width on the render after the hint lays out.
await expect.poll(() => placeholderOverlaps(input, hint)).toBe(false);
} finally {
await agent.cleanup();
}
});
});
}
@@ -241,32 +241,6 @@ test.describe("Plugin sidebar items", () => {
});
});
test("a plugin screen toggles the sidebar on a wide layout", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await visibleTestId(page, "menu-button").click();
await expect(row).not.toBeVisible();
await visibleTestId(page, "menu-button").click();
await expect(row).toBeVisible();
await closeScreen(page);
// With the sidebar closed elsewhere, the screen's own toggle is the way back to it.
await visibleTestId(page, "menu-button").click();
await expect(row).not.toBeVisible();
await page.keyboard.press("Control+k");
const panel = page.getByTestId("command-center-panel");
await panel.getByTestId("command-center-input").fill("Open deploys");
await panel.getByRole("button", { name: "Open deploys", exact: true }).click();
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
await visibleTestId(page, "menu-button").click();
await expect(row).toBeVisible();
});
test("a header row's shortcut hint presses the row", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
@@ -7,7 +7,6 @@ import {
expectQuestionDismissEnabled,
expectQuestionHidden,
expectQuestionNavigationEnabled,
expectQuestionOptionPreview,
expectQuestionOptionSelected,
expectQuestionPrimaryActionDisabled,
expectQuestionPrimaryActionEnabled,
@@ -62,10 +61,6 @@ 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,
@@ -1,7 +0,0 @@
# 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
```
@@ -1,10 +0,0 @@
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
@@ -43,15 +43,6 @@ 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 },
@@ -147,29 +147,4 @@ 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,7 +1,6 @@
export interface QuestionOption {
label: string;
description?: string;
preview?: string;
}
export interface QuestionFormQuestion {
@@ -47,7 +46,6 @@ 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,7 +10,6 @@ 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,
@@ -30,9 +29,6 @@ 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,
@@ -137,20 +133,6 @@ 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>
@@ -722,10 +704,6 @@ 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,
+27 -13
View File
@@ -599,18 +599,14 @@ function MessageInputOverlay({
}
function FocusHint({
visible,
focusInputKeys,
label,
onLayout,
}: {
visible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
label: string;
onLayout: (event: LayoutChangeEvent) => void;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null;
return (
<Text style={styles.focusHintText} pointerEvents="none">
<Text style={styles.focusHintText} pointerEvents="none" onLayout={onLayout}>
{label}
</Text>
);
@@ -666,6 +662,25 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
onRenderedLinesChange(event.nativeEvent.lines.length),
[onRenderedLinesChange],
);
const { isActiveComposer } = useComposerKeyboardScope();
const [focusHintWidth, setFocusHintWidth] = useState(0);
const handleFocusHintLayout = useCallback((event: LayoutChangeEvent) => {
setFocusHintWidth(event.nativeEvent.layout.width);
}, []);
const isFocusHintShown =
isActiveComposer &&
props.focusHintVisible &&
Boolean(props.focusInputKeys) &&
props.focusHintLabel.trim().length > 0;
// The hint sits over the input's top-right corner, so the placeholder wraps
// before it instead of running underneath.
const textInputStyle = useMemo(
() =>
isFocusHintShown
? [props.textInputStyle, { paddingRight: focusHintWidth }]
: props.textInputStyle,
[focusHintWidth, isFocusHintShown, props.textInputStyle],
);
if (props.readOnly) {
return (
<View style={styles.textInputScrollWrapper}>
@@ -702,7 +717,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus}
onBlur={props.onBlur}
style={props.textInputStyle}
style={textInputStyle}
multiline
scrollEnabled={props.scrollEnabled}
editable={props.editable}
@@ -712,11 +727,9 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
onPasteError={props.onPasteError}
autoFocus={props.autoFocus}
/>
<FocusHint
visible={props.focusHintVisible}
focusInputKeys={props.focusInputKeys}
label={props.focusHintLabel}
/>
{isFocusHintShown ? (
<FocusHint label={props.focusHintLabel} onLayout={handleFocusHintLayout} />
) : null}
</View>
);
}
@@ -2262,6 +2275,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
position: "absolute",
top: 0,
right: 0,
paddingLeft: theme.spacing[2],
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
opacity: 0.5,
@@ -8,7 +8,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { CompactHeader } from "@/components/headers/compact-header";
import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostFilter } from "@/components/hosts/host-filter";
@@ -173,7 +172,6 @@ export function PluginSurfaceScreen() {
const headerLeft = useMemo(
() => (
<>
<SidebarMenuToggle />
{Icon ? (
<HeaderIconBadge>
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
@@ -1,4 +1,3 @@
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
import {
createClientChannel,
type EncryptedChannel,
@@ -9,11 +8,7 @@ import type {
DaemonTransportFactory,
TransportLogger,
} from "./daemon-client-transport-types.js";
import {
extractRelayMessage,
normalizeTransportPayload,
messageTooLargeError,
} from "./daemon-client-transport-utils.js";
import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js";
type OpenHandler = () => void;
type CloseHandler = (event?: unknown) => void;
@@ -142,13 +137,7 @@ export function createEncryptedTransport(
if (!channel) {
throw new Error("Encrypted channel not ready");
}
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) => {
void channel.send(normalizeTransportPayload(data)).catch((error) => {
emitError(error);
});
},
@@ -133,10 +133,3 @@ 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.`,
);
}
-26
View File
@@ -7527,29 +7527,3 @@ 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");
});
-17
View File
@@ -1,4 +1,3 @@
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";
@@ -14,7 +13,6 @@ 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,
@@ -1134,19 +1132,6 @@ 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,
@@ -1782,8 +1767,6 @@ 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)),
@@ -1,5 +0,0 @@
/** 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;
+2 -3
View File
@@ -11,10 +11,9 @@ OPENAI_TTS_BASE_URL=
# TTS Configuration (optional - defaults shown)
TTS_VOICE=alloy
# Any voice the server knows: OpenAI ships alloy, echo, fable, onyx, nova, shimmer
# Available voices: alloy, echo, fable, onyx, nova, shimmer
TTS_MODEL=tts-1
# OpenAI ships tts-1 (faster) and tts-1-hd (higher quality); self-hosted
# servers accept whatever model id they serve. Case sensitive.
# Available models: tts-1 (faster), tts-1-hd (higher quality)
# Server Configuration
# Location for runtime state (agents.json, etc.). Defaults to ~/.paseo
+1 -1
View File
@@ -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/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: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: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",
@@ -232,7 +232,6 @@ type SteeringReplayShape = "claude" | "codex";
interface MockQuestionOption {
label: string;
description?: string;
preview?: string;
}
interface MockQuestionPromptQuestion {
@@ -272,16 +271,6 @@ 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)) {
@@ -320,10 +309,7 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe
{
question: "Which rollout should we use?",
header: "rollout",
options: [
{ label: "Immediately" },
{ label: "Behind feature flag", preview: ROLLOUT_FLAG_PREVIEW },
],
options: [{ label: "Immediately" }, { label: "Behind feature flag" }],
multiSelect: false,
},
{
@@ -215,55 +215,6 @@ 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,7 +714,9 @@ export class OmpAgentSession implements AgentSession {
private readonly pendingPromptResults = new Map<string, OmpPromptResult>();
private pendingNoTurnCompletionAbort: AbortController | null = null;
private lastKnownThinkingOptionId: string | null;
private manualCompactionRunning = false;
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
private outOfBandCompactionStarted = false;
private outOfBandCompactionCompleted = false;
private commandCache: AgentSlashCommand[] | null = null;
private readonly subagentIndex = new OmpSubagentIndex();
private readonly subagentCardTracker: OmpSubagentCardTracker;
@@ -1405,24 +1407,30 @@ export class OmpAgentSession implements AgentSession {
customInstructions: string | undefined,
emit: (event: AgentStreamEvent) => void,
): Promise<void> {
if (this.manualCompactionRunning) {
if (this.outOfBandCompactionEmit) {
throw new Error("An OMP compact command is already running");
}
// 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");
this.outOfBandCompactionEmit = emit;
this.outOfBandCompactionStarted = false;
this.outOfBandCompactionCompleted = false;
try {
await this.runtimeSession.compact(customInstructions);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
emitCompaction("completed");
if (
this.outOfBandCompactionEmit === emit &&
this.outOfBandCompactionStarted &&
!this.outOfBandCompactionCompleted
) {
this.emitCompactionTimeline({
turnId: undefined,
item: {
type: "compaction",
status: "completed",
trigger: "manual",
},
});
}
emit({
type: "timeline",
provider: this.provider,
@@ -1431,12 +1439,13 @@ export class OmpAgentSession implements AgentSession {
text: `[Error] Failed to compact context: ${message}`,
},
});
return;
} finally {
this.manualCompactionRunning = false;
if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) {
this.outOfBandCompactionEmit = null;
this.outOfBandCompactionStarted = false;
this.outOfBandCompactionCompleted = false;
}
}
emitCompaction("completed");
await this.usagePoller.refresh();
}
private async executeAutoCompactCommand(
@@ -1844,8 +1853,24 @@ 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.handleCompactionEvent(event, turnId);
this.emitCompactionTimeline({
turnId,
item: {
type: "compaction",
status: "completed",
trigger: event.reason === "manual" ? "manual" : "auto",
},
});
return;
case "agent_end": {
const messages = event.messages ?? [];
@@ -1899,24 +1924,35 @@ export class OmpAgentSession implements AgentSession {
}
}
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;
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;
}
}
this.emit({
const event: AgentStreamEvent = {
type: "timeline",
provider: this.provider,
turnId,
item: {
type: "compaction",
status: event.type === "compaction_start" ? "loading" : "completed",
trigger: event.reason === "manual" ? "manual" : "auto",
},
});
...(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;
}
return;
}
this.emit(event);
}
private handleMessageUpdate(
@@ -168,6 +168,7 @@ 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: "" };
@@ -265,8 +266,11 @@ 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,12 +19,7 @@ import {
type OmpProviderIdleScheduler,
} from "../agent.js";
import type { OmpUsagePollScheduler } from "../usage-poller.js";
import type {
OmpAgentMessage,
OmpRpcSlashCommand,
OmpRuntimeEvent,
OmpSessionStats,
} from "../rpc-types.js";
import type { OmpAgentMessage, OmpRpcSlashCommand, OmpRuntimeEvent } from "../rpc-types.js";
import { FakeOmp } from "./fake-omp.js";
const CWD = "/tmp/paseo-omp-agent-test";
@@ -174,18 +169,6 @@ 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,27 +36,6 @@ 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,17 +105,6 @@ 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(records = 1200): string {
function compactionFiller(): string {
const teas = ["assam", "darjeeling", "sencha", "oolong"];
return Array.from(
{ length: records },
{ length: 1200 },
(_, 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,27 +234,6 @@ 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",
@@ -279,48 +258,6 @@ 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 () => {
@@ -1,58 +0,0 @@
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,6 +1,4 @@
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";
@@ -204,20 +202,12 @@ async function waitForCapturedLog(
if (options.useLocalRelay) {
relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:");
}
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 = spawn("npx", relayArgs, {
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
@@ -251,73 +241,6 @@ 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";
@@ -674,19 +674,6 @@ describe("PersistedConfigSchema voice mode config", () => {
expect(parsed.features?.dictation?.stt?.language).toBe("fr");
expect(parsed.features?.voiceMode?.stt?.language).toBe("de");
});
test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => {
const parsed = PersistedConfigSchema.parse({
features: {
voiceMode: {
tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "aiden" },
},
},
});
expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0");
expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden");
});
});
describe("loadPersistedConfig", () => {
@@ -144,8 +144,7 @@ const FeatureVoiceModeSchema = z
.object({
provider: SpeechProviderIdSchema.optional(),
model: z.string().min(1).optional(),
// Free-form: self-hosted OpenAI-compatible servers expose their own voice names.
voice: z.string().trim().min(1).optional(),
voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(),
speakerId: z.number().int().optional(),
speed: z.number().optional(),
})
@@ -203,40 +203,4 @@ 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,6 +12,10 @@ 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(
@@ -40,12 +44,14 @@ 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().default("alloy"),
ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL),
ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"),
ttsModel: z
.string()
.trim()
.toLowerCase()
.pipe(OpenAiTtsModelSchema)
.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" | (string & {});
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" | (string & {});
model?: "tts-1" | "tts-1-hd";
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer";
responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm";
}
@@ -33,7 +33,6 @@ 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 {
@@ -823,7 +822,6 @@ 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(
+2 -3
View File
@@ -1,4 +1,3 @@
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";
@@ -2153,13 +2152,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(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) -
CHECKOUT_DIFF_FRAME_HEADROOM_BYTES;
maxBase64EncryptedPlaintextByteLength(RELAY_MAX_FRAME_BYTES) - CHECKOUT_DIFF_FRAME_HEADROOM_BYTES;
interface StructuredDiffAccumulator {
files: ParsedDiffFile[];
+2 -3
View File
@@ -10,7 +10,6 @@ import {
type WorktreeConfig,
} from "./worktree";
import { execFileSync } from "child_process";
import { rm } from "node:fs/promises";
import {
mkdtempSync,
mkdirSync,
@@ -90,8 +89,8 @@ describe("paseo worktree manager", () => {
});
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
@@ -711,7 +711,7 @@
},
"voice": {
"type": "string",
"minLength": 1
"enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
},
"speakerId": {
"type": "integer",