mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d86cb4cb2b | ||
|
|
fc8f085b9e | ||
|
|
019e5279e9 | ||
|
|
4a3ff87eec | ||
|
|
a4f1df3aed |
@@ -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 },
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import {
|
||||
createClientChannel,
|
||||
type EncryptedChannel,
|
||||
@@ -8,7 +9,11 @@ import type {
|
||||
DaemonTransportFactory,
|
||||
TransportLogger,
|
||||
} from "./daemon-client-transport-types.js";
|
||||
import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js";
|
||||
import {
|
||||
extractRelayMessage,
|
||||
normalizeTransportPayload,
|
||||
messageTooLargeError,
|
||||
} from "./daemon-client-transport-utils.js";
|
||||
|
||||
type OpenHandler = () => void;
|
||||
type CloseHandler = (event?: unknown) => void;
|
||||
@@ -137,7 +142,13 @@ export function createEncryptedTransport(
|
||||
if (!channel) {
|
||||
throw new Error("Encrypted channel not ready");
|
||||
}
|
||||
void channel.send(normalizeTransportPayload(data)).catch((error) => {
|
||||
const payload = normalizeTransportPayload(data);
|
||||
// Reject synchronously so the requesting operation fails without closing the transport.
|
||||
const wireBytes = channel.outboundWireByteLength(payload);
|
||||
if (wireBytes > MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) {
|
||||
throw messageTooLargeError(wireBytes, MAX_RELAY_WEBSOCKET_MESSAGE_BYTES);
|
||||
}
|
||||
void channel.send(payload).catch((error) => {
|
||||
emitError(error);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -133,3 +133,10 @@ export function encodeUtf8String(value: string): Uint8Array {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function messageTooLargeError(size: number, limit: number): Error {
|
||||
const toMiB = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1);
|
||||
return new Error(
|
||||
`Message is too large to send (${toMiB(size)} MiB). The limit is ${toMiB(limit)} MiB, so send fewer or smaller attachments.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7527,3 +7527,29 @@ test.each([false, true])(
|
||||
await send;
|
||||
},
|
||||
);
|
||||
|
||||
test("measures an outbound message in UTF-8 bytes against the daemon's message limit", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "message_limit_unit_test",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
const mib = 1024 * 1024;
|
||||
|
||||
// "é" is one UTF-16 unit but two UTF-8 bytes, so this text is ~98 MiB on the wire.
|
||||
void client.sendMessage("agent-1", "é".repeat(49 * mib)).catch(() => undefined);
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
|
||||
// ~60M characters is under the 100 MiB limit; ~120 MiB of UTF-8 is over it.
|
||||
await expect(client.sendMessage("agent-1", "é".repeat(60 * mib))).rejects.toThrow(
|
||||
"Message is too large to send (120.0 MiB). The limit is 100.0 MiB",
|
||||
);
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
expect(client.getConnectionState().status).toBe("connected");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { messageTooLargeError } from "./daemon-client-transport-utils.js";
|
||||
import { legacyUsageIcon } from "./legacy-usage-icons.js";
|
||||
import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js";
|
||||
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
|
||||
@@ -13,6 +14,7 @@ import type { z } from "zod";
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import type { ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
|
||||
import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import {
|
||||
AgentCreateFailedStatusPayloadSchema,
|
||||
AgentCreatedStatusPayloadSchema,
|
||||
@@ -1132,6 +1134,19 @@ function getTransportFrameSize(frame: string | Uint8Array | ArrayBuffer): number
|
||||
return frame.byteLength;
|
||||
}
|
||||
|
||||
function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): void {
|
||||
if (typeof frame !== "string") {
|
||||
if (frame.byteLength > MAX_WEBSOCKET_MESSAGE_BYTES)
|
||||
throw messageTooLargeError(frame.byteLength, MAX_WEBSOCKET_MESSAGE_BYTES);
|
||||
return;
|
||||
}
|
||||
// UTF-8 needs at most three bytes per UTF-16 unit, so only a string near the limit is measured.
|
||||
if (frame.length * 3 <= MAX_WEBSOCKET_MESSAGE_BYTES) return;
|
||||
const size = new TextEncoder().encode(frame).byteLength;
|
||||
if (size > MAX_WEBSOCKET_MESSAGE_BYTES)
|
||||
throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES);
|
||||
}
|
||||
|
||||
function describeInboundTransportFrame(
|
||||
frame: unknown,
|
||||
rawBytes: Uint8Array | null,
|
||||
@@ -1767,6 +1782,8 @@ export class DaemonClient {
|
||||
if (!this.transport) {
|
||||
throw new DaemonConnectionError("Transport not connected");
|
||||
}
|
||||
// The daemon closes the connection on an oversized message, and creations resubmit on reconnect.
|
||||
assertTransportFrameFits(frame);
|
||||
const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
|
||||
kind: typeof frame === "string" ? "text" : "binary",
|
||||
size: String(getTransportFrameSize(frame)),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Largest WebSocket message the daemon accepts. A bigger one closes the connection with 1009. */
|
||||
export const MAX_WEBSOCKET_MESSAGE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/** Largest encrypted frame the relay accepts. */
|
||||
export const MAX_RELAY_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024;
|
||||
@@ -11,9 +11,10 @@ OPENAI_TTS_BASE_URL=
|
||||
|
||||
# TTS Configuration (optional - defaults shown)
|
||||
TTS_VOICE=alloy
|
||||
# Available voices: alloy, echo, fable, onyx, nova, shimmer
|
||||
# Any voice the server knows: OpenAI ships alloy, echo, fable, onyx, nova, shimmer
|
||||
TTS_MODEL=tts-1
|
||||
# Available models: tts-1 (faster), tts-1-hd (higher quality)
|
||||
# OpenAI ships tts-1 (faster) and tts-1-hd (higher quality); self-hosted
|
||||
# servers accept whatever model id they serve. Case sensitive.
|
||||
|
||||
# Server Configuration
|
||||
# Location for runtime state (agents.json, etc.). Defaults to ~/.paseo
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -674,6 +674,19 @@ describe("PersistedConfigSchema voice mode config", () => {
|
||||
expect(parsed.features?.dictation?.stt?.language).toBe("fr");
|
||||
expect(parsed.features?.voiceMode?.stt?.language).toBe("de");
|
||||
});
|
||||
|
||||
test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
features: {
|
||||
voiceMode: {
|
||||
tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "aiden" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0");
|
||||
expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadPersistedConfig", () => {
|
||||
|
||||
@@ -144,7 +144,8 @@ const FeatureVoiceModeSchema = z
|
||||
.object({
|
||||
provider: SpeechProviderIdSchema.optional(),
|
||||
model: z.string().min(1).optional(),
|
||||
voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(),
|
||||
// Free-form: self-hosted OpenAI-compatible servers expose their own voice names.
|
||||
voice: z.string().trim().min(1).optional(),
|
||||
speakerId: z.number().int().optional(),
|
||||
speed: z.number().optional(),
|
||||
})
|
||||
|
||||
@@ -203,4 +203,40 @@ describe("resolveOpenAiSpeechConfig", () => {
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
|
||||
expect(resolved?.tts).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sends the model and voice an OpenAI-compatible server serves, as written", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: { tts: { apiKey: "tts-key", baseUrl: "https://tts.example.com/v1" } },
|
||||
},
|
||||
features: {
|
||||
voiceMode: { tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "Aiden" } },
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
providers: ALL_OPENAI,
|
||||
});
|
||||
|
||||
expect(resolved?.tts?.model).toBe("kokoro-v1:Q8_0");
|
||||
expect(resolved?.tts?.voice).toBe("Aiden");
|
||||
});
|
||||
|
||||
test("accepts OpenAI's own gpt-4o-mini-tts model", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: { openai: { tts: { apiKey: "tts-key" } } },
|
||||
features: { voiceMode: { tts: { provider: "openai", model: "gpt-4o-mini-tts" } } },
|
||||
});
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
providers: ALL_OPENAI,
|
||||
});
|
||||
|
||||
expect(resolved?.tts?.model).toBe("gpt-4o-mini-tts");
|
||||
expect(resolved?.tts?.voice).toBe("alloy");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,6 @@ export interface OpenAiSpeechProviderConfig {
|
||||
tts?: Partial<TTSConfig> & { apiKey?: string };
|
||||
}
|
||||
|
||||
const OpenAiTtsVoiceSchema = z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
|
||||
|
||||
const OpenAiTtsModelSchema = z.enum(["tts-1", "tts-1-hd"]);
|
||||
|
||||
const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]);
|
||||
|
||||
const OptionalFiniteNumberSchema = NumberLikeSchema.pipe(
|
||||
@@ -44,14 +40,12 @@ const OpenAiSttOptionsSchema = z.object({
|
||||
sttModel: OptionalTrimmedStringSchema,
|
||||
});
|
||||
|
||||
// Voice and model are free-form so a self-hosted server can use its own ids; the
|
||||
// OpenAI catalog is only the default. Both are sent as written, because the
|
||||
// server owns those ids and their case (rosters use ids like kokoro-v1:Q8_0).
|
||||
const OpenAiTtsOptionsSchema = z.object({
|
||||
ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"),
|
||||
ttsModel: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.pipe(OpenAiTtsModelSchema)
|
||||
.default(DEFAULT_OPENAI_TTS_MODEL),
|
||||
ttsVoice: z.string().trim().default("alloy"),
|
||||
ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL),
|
||||
});
|
||||
|
||||
function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean {
|
||||
|
||||
@@ -8,8 +8,8 @@ export type { SpeechStreamResult };
|
||||
export interface TTSConfig {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model?: "tts-1" | "tts-1-hd";
|
||||
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer";
|
||||
model?: "tts-1" | "tts-1-hd" | (string & {});
|
||||
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" | (string & {});
|
||||
responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm";
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type WorktreeConfig,
|
||||
} from "./worktree";
|
||||
import { execFileSync } from "child_process";
|
||||
import { rm } from "node:fs/promises";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
@@ -89,8 +90,8 @@ describe("paseo worktree manager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
});
|
||||
|
||||
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
|
||||
|
||||
@@ -711,7 +711,7 @@
|
||||
},
|
||||
"voice": {
|
||||
"type": "string",
|
||||
"enum": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
|
||||
"minLength": 1
|
||||
},
|
||||
"speakerId": {
|
||||
"type": "integer",
|
||||
|
||||
Reference in New Issue
Block a user