Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] d86cb4cb2b fix(app): show the sidebar toggle on plugin screens
Plugin screens rendered only their icon and title on the left of the header,
so on wide web and Android layouts there was no way to collapse the sidebar
from them, or to reopen it after it was closed elsewhere.

Fixes #6315
2026-10-14 08:43:20 +02:00
paseo-bot[bot] fc8f085b9e Reject oversized messages without disconnecting the host (#6285)
* fix(client): reject a message over the daemon's size limit instead of reconnecting in a loop

The daemon closes the WebSocket with 1009 when a message exceeds its 100 MiB
limit. A workspace or agent creation that loses its connection mid-request
stays pending and, after reconnecting, finds no record of itself on the
daemon (the oversized frame was never read), so it resubmits the same frame
and the connection drops again every couple of seconds until the app reloads.

The client now checks each outbound frame against the same limit, which
protocol exports and the daemon sets explicitly, and fails that request with
a size error while the connection stays up.

Refs #5501

* test(client): cover the UTF-8 byte count at the message size limit

* fix(client): reject oversized relay messages without disconnecting

Check the encrypted wire size synchronously before submission, so a frame below the direct limit cannot close the relay and leave workspace creation resubmitting forever. Keep the relay limit shared with the existing checkout diff budget.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 08:20:40 +02:00
paseo-bot[bot]andGabriel Giordano 019e5279e9 Show Claude option previews in the question card, readable on Android (#6171)
* Show Claude option previews in the question card

* Wrap long lines in question option previews

* Check option previews under the option that owns them

* Show the whole option preview on native so every line is reachable

On Android, a slow drag inside a preview capped at 240px did not scroll
it, so lines past the cap could not be read. The preview's nested
scroll sits inside the option's Pressable; the same drag scrolls it
once the preview is outside the Pressable. Native now renders the
preview at full height so the chat scroll reaches every line, and a tap
on it still selects the option. Web keeps the 240px cap.

The mock agent's rollout preview is now taller than the cap so the
Android replay can check the last line.

---------

Co-authored-by: Gabriel Giordano <hello@gabrielgiordano.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 07:13:06 +02:00
paseo-bot[bot] 4a3ff87eec fix(omp): show manual /compact progress and refresh the context meter (#6106)
* fix(omp): show manual compaction progress and refresh context usage

OMP answers a manual compact with its RPC response alone, without
compaction_start or compaction_end, so /compact on an OMP agent showed no
row and left the context meter at the pre-compaction value until the next
turn. The compact request now emits the loading and completed rows itself
and publishes the session's context usage once it returns.

The fake OMP runtime no longer emits compaction events for a manual
compact, matching what OMP sends.

Closes #5966

* fix(omp): keep a running turn's usage polling through a manual compact

Refresh the compacted context usage without ending the active turn's
poll, wait for the usage update in the real OMP test, and cover a failed
manual compact.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 06:10:51 +02:00
paseo-bot[bot]andkhimaros a4f1df3aed Let voice mode TTS use the model and voice an OpenAI-compatible server serves (#6060)
* fix(server): accept voice and model names from self-hosted speech servers

features.voiceMode.tts.voice and TTS_VOICE were validated against the
OpenAI voice catalog, so a server whose voices live under other names
could not be configured at all: config.json failed to parse and an env
var threw inside the speech config resolver, which config.ts calls
without a catch, so the daemon refused to start. TTS_MODEL had the same
problem for model ids the server serves itself. Both are free-form now,
with model ids keeping their case because rosters use values like
kokoro-v1:Q8_0. Regenerate the config JSON schema.

* fix(server): send TTS voice names as written and keep the config schema current

A self-hosted OpenAI-compatible server owns its voice ids and their case, so
lowercasing the configured voice could turn a valid id into one the server
does not know. Keep the published config schema in its existing form and
change only the voice field.

Co-authored-by: khimaros <me@khimaros.com>

* fix(server): drop the TTS baseUrl from the init log

The configured baseUrl can carry a token in its query string, and logging
it is not needed to accept self-hosted voice and model names.

* test(server): await bounded worktree fixture cleanup on Windows

---------

Co-authored-by: khimaros <me@khimaros.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 05:10:35 +02:00
paseo-bot[bot]andHTeo a4b15455b7 fix(cli): list every agent in paseo agent ls, past the first 200 (#6033)
* fix(cli): list every agent page in agent ls

agent ls made one fetch_agents call and ignored pageInfo.nextCursor, so
it stopped at the daemon's default page of 200 agents without saying
more existed. Follow the cursor until the daemon reports no next page,
as workspace ls and agent delete already do.

Co-authored-by: HTeo <hteo1337@gmail.com>

* fix(cli): page agent ls in creation order

fetch_agents defaults to updated_at order, so an agent that updated
after the first page moved ahead of the cursor and was skipped. Page by
created_at, which never changes; ls sorts the rows itself afterwards.

* test(cli): exercise agent listing with a supplied client

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: HTeo <hteo1337@gmail.com>
2026-10-14 03:55:27 +02:00
paseo-bot[bot] bd9af256bd Keep a symlinked config.json linked when Paseo saves it (#6023)
* fix(server): keep a symlinked config.json linked when saving

Paseo saved config.json by renaming a temporary file over its path, which
replaced a symlink with a regular file and left the linked file unchanged.
Private file writes now resolve the symlink and replace the file it points at.

Closes #5932

* test(server): check a symlinked config.json stays private

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 03:06:36 +02:00
paseo-bot[bot]andAhmet İzgi b1a638482d Cover child completion before create_agent returns (#6008)
Adds a regression test for #5928: a child that finishes its initial turn before its turn start returns still sends one finish notification to its parent. The fix itself is on main from 0009a7e.

Co-authored-by: Ahmet İzgi <ahmetizgi@gmail.com>
2026-10-14 02:05:44 +02:00
paseo-bot[bot]andElior Fureraj 12aaded6c5 fix(app): show the configured label for the xhigh thinking option (#6005)
* fix(app): respect provider label for xhigh thinking option

Only fall back to the localized Extra high string for bare forms.
Custom labels such as XHigh (56 | 8m) now render with their suffix.

* fix(app): show Extra high only for a bare xhigh label

A configured xhigh label with extra detail keeps its text; only a label
that is just xhigh or Extra high uses the localized string.

Co-authored-by: Elior Fureraj <efureraj@gmail.com>

---------

Co-authored-by: Elior Fureraj <efureraj@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 01:05:05 +02:00
paseo-bot[bot] 0614e96ee0 fix(claude): rewind works when the provider sets CLAUDE_CONFIG_DIR (#6325)
* fix(claude): rewind a session that lives in the provider's CLAUDE_CONFIG_DIR

Rewinding the conversation forks the Claude session through the SDK's
forkSession, which looks for the transcript under the daemon's own
CLAUDE_CONFIG_DIR or ~/.claude. A provider whose env sets CLAUDE_CONFIG_DIR
has its transcripts elsewhere, so every rewind failed with
"Session <id> not found".

Fork through a session store rooted at the folder of the transcript the
agent already resolves with the provider's config dir, so the SDK reads
the session where Claude Code wrote it and writes the fork beside it.

Closes #6321

* fix(claude): pass only transcript entries to the rewind session store

The session store parsed each transcript line and cast it to a session
entry without checking it. Keep only JSON objects with a string type, and
cover transcripts with non-entry lines in the real-SDK rewind test.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 21:12:33 +00:00
45 changed files with 1033 additions and 129 deletions
@@ -241,6 +241,32 @@ 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,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,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
@@ -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");
});
});
+1 -2
View File
@@ -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");
}
@@ -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,
@@ -8,6 +8,7 @@ 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";
@@ -172,6 +173,7 @@ export function PluginSurfaceScreen() {
const headerLeft = useMemo(
() => (
<>
<SidebarMenuToggle />
{Icon ? (
<HeaderIconBadge>
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
+66 -1
View File
@@ -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"]);
});
});
+31 -3
View File
@@ -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.`,
);
}
+26
View File
@@ -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");
});
+17
View File
@@ -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,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;
+3 -2
View File
@@ -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
+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/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/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";
@@ -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(),
})
+18 -5
View File
@@ -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";
}
@@ -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(
+3 -2
View File
@@ -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";
@@ -2152,13 +2153,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[];
+3 -2
View File
@@ -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",