mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3448e8b023 | ||
|
|
6524677960 | ||
|
|
09a1fe46fe | ||
|
|
06a8f952db | ||
|
|
893e3376b0 | ||
|
|
7c8b290e2f | ||
|
|
d55e1622cb | ||
|
|
69715a77e9 | ||
|
|
e1b27fc584 | ||
|
|
9c16bc474b | ||
|
|
59d48d235a | ||
|
|
6aa73baaab | ||
|
|
0cf1717e04 | ||
|
|
2822c02543 | ||
|
|
db4376d17a | ||
|
|
9a8912b3ef | ||
|
|
fd022bc44b |
@@ -64,6 +64,7 @@ CLAUDE.local.md
|
||||
|
||||
.debug.conversations/
|
||||
.debug/
|
||||
.dev/
|
||||
.paseo/
|
||||
.wrangler/
|
||||
**/.wrangler/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"ignorePatterns": [".dev/**"],
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
|
||||
@@ -49,7 +49,9 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npm run dev # Start daemon + Expo in Tmux
|
||||
npm run dev # Start the dev daemon
|
||||
npm run dev:app # Start Expo against the dev daemon
|
||||
npm run dev:desktop # Start Electron desktop dev
|
||||
npm run cli -- ls -a -g # List all agents
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run typecheck # Always run after changes
|
||||
@@ -58,6 +60,8 @@ npm run format # Auto-format with Biome
|
||||
npm run format:check # Check formatting without writing
|
||||
```
|
||||
|
||||
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
|
||||
|
||||
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Critical rules
|
||||
|
||||
+23
-11
@@ -8,18 +8,27 @@
|
||||
## Running the dev server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
```
|
||||
|
||||
`scripts/dev.sh` runs the daemon and Expo together via `concurrently`, fronted by [`portless`](https://www.npmjs.com/package/portless) so each service is reachable at a stable name like `https://daemon.localhost` / `https://app.localhost` instead of a fixed port. The underlying TCP ports are ephemeral — never hardcode them. (Windows uses `scripts/dev.ps1`, which still binds the daemon to `localhost:6767` directly.)
|
||||
Root checkout dev is intentionally split across terminals:
|
||||
|
||||
- `npm run dev:server` runs the daemon on `127.0.0.1:6768`.
|
||||
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
|
||||
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
|
||||
|
||||
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
|
||||
|
||||
### PASEO_HOME
|
||||
|
||||
`PASEO_HOME` is the directory that holds runtime state (agents, sockets, daemon log). Resolution rules:
|
||||
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
|
||||
|
||||
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
|
||||
- **`npm run dev` from a git worktree** derives a stable home like `~/.paseo-<worktree-name>` and, on first run, seeds it from `~/.paseo` by copying agent/project JSON metadata and `config.json`. Checkout/worktree directories are not copied.
|
||||
- **`npm run dev` from the main checkout** (not a worktree) uses a fresh `mktemp` directory under `$TMPDIR` and removes it on exit. Set `PASEO_HOME` explicitly to keep state across runs.
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
|
||||
Override knobs:
|
||||
|
||||
@@ -32,16 +41,19 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
|
||||
### Daemon endpoints
|
||||
|
||||
- Stable daemon launched by the desktop app: `localhost:6767`.
|
||||
- `npm run dev` (macOS/Linux): portless URLs only — read them from the `dev.sh` banner or `portless get daemon` / `portless get app`.
|
||||
- Root checkout dev daemon: `localhost:6768`.
|
||||
- Root checkout Expo: `http://localhost:8081`.
|
||||
- Root checkout desktop dev Expo: first free port from `8082` through `8089`.
|
||||
- `npm run dev` (Windows): `localhost:6767` for the daemon.
|
||||
|
||||
In any worktree-style or portless setup, never assume default ports.
|
||||
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
|
||||
|
||||
### Desktop renderer profiling
|
||||
|
||||
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
|
||||
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
|
||||
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
It launches its own Electron-flavored Expo server and passes that URL to Electron.
|
||||
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
### React render profiling
|
||||
|
||||
@@ -185,7 +197,7 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
|
||||
|
||||
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
|
||||
|
||||
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
|
||||
`npm run dev:server` builds the server-side workspace packages once, then keeps `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon runs. If you change protocol schemas or client code outside that watch workflow, rebuild the producer before trusting runtime behavior.
|
||||
|
||||
Use the named root build targets instead of remembering workspace dependency chains:
|
||||
|
||||
@@ -223,7 +235,7 @@ install.
|
||||
|
||||
## CLI reference
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
@@ -277,7 +289,7 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/Linux) that is the portless URL printed in the dev banner — typically `https://app.localhost`. If you start Expo directly with `expo start --web` (no portless), Metro defaults to `http://localhost:8081`.
|
||||
Point Playwright MCP at the running Expo web target. For root checkout dev, `npm run dev:app` reserves `http://localhost:8081`. For Paseo-managed worktree app services, use the service URL or port shown by Paseo for that worktree.
|
||||
|
||||
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-U04ZysdAC9Qf8GXyBjbsz6Eu87jpJU8iV/RCXbkFlBI=
|
||||
sha256-EpxQim3vnwfPLPi8IAtF82tGrLNZiZP8IwN7rR9P7xo=
|
||||
|
||||
+5
-5
@@ -34,12 +34,12 @@
|
||||
"packages/cli"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev": "npm run dev:server",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"dev:server": "npm run build:server-deps && npm run dev:server:watch",
|
||||
"dev:server": "cross-env PASEO_LISTEN=127.0.0.1:6768 ./scripts/dev-daemon.sh",
|
||||
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
|
||||
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"prepare": "lefthook install --force",
|
||||
@@ -82,11 +82,11 @@
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import { createDefaultLayout } from "@/stores/workspace-layout-store";
|
||||
import { openTabInLayoutFocused } from "@/stores/workspace-layout-actions";
|
||||
import { resolveBrowserNewTabRequest, type BrowserNewTabRequest } from ".";
|
||||
|
||||
function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
|
||||
return openTabInLayoutFocused({
|
||||
layout: createDefaultLayout(),
|
||||
target: { kind: "browser", browserId },
|
||||
now: 1,
|
||||
}).layout;
|
||||
}
|
||||
|
||||
describe("browser new-tab requests", () => {
|
||||
it("accepts desktop requests from browser tabs in the current workspace", () => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload: {
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "https://example.com/target",
|
||||
},
|
||||
workspaceLayout: createLayoutWithBrowser("browser-1"),
|
||||
});
|
||||
|
||||
expect(request).toEqual<BrowserNewTabRequest>({
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "https://example.com/target",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores desktop requests from another workspace", () => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload: {
|
||||
sourceBrowserId: "browser-from-other-workspace",
|
||||
url: "https://example.com/target",
|
||||
},
|
||||
workspaceLayout: createLayoutWithBrowser("browser-1"),
|
||||
});
|
||||
|
||||
expect(request).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unsupported desktop request URLs", () => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload: {
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "file:///etc/passwd",
|
||||
},
|
||||
workspaceLayout: createLayoutWithBrowser("browser-1"),
|
||||
});
|
||||
|
||||
expect(request).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect } from "react";
|
||||
import { getDesktopHost, type DesktopBrowserNewTabRequestEvent } from "@/desktop/host";
|
||||
import { collectAllTabs, type WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
|
||||
export type BrowserNewTabRequest = DesktopBrowserNewTabRequestEvent;
|
||||
|
||||
function isAllowedBrowserNewTabUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (
|
||||
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readDesktopBrowserNewTabRequest(payload: unknown): BrowserNewTabRequest | null {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = payload as Partial<BrowserNewTabRequest>;
|
||||
if (typeof candidate.sourceBrowserId !== "string" || !candidate.sourceBrowserId.trim()) {
|
||||
return null;
|
||||
}
|
||||
if (typeof candidate.url !== "string" || !isAllowedBrowserNewTabUrl(candidate.url)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sourceBrowserId: candidate.sourceBrowserId,
|
||||
url: candidate.url,
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceContainsBrowser(input: {
|
||||
workspaceLayout: WorkspaceLayout | null | undefined;
|
||||
browserId: string;
|
||||
}): boolean {
|
||||
if (!input.workspaceLayout) {
|
||||
return false;
|
||||
}
|
||||
return collectAllTabs(input.workspaceLayout.root).some((tab) => {
|
||||
return tab.target.kind === "browser" && tab.target.browserId === input.browserId;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBrowserNewTabRequest(input: {
|
||||
payload: unknown;
|
||||
workspaceLayout: WorkspaceLayout | null | undefined;
|
||||
}): BrowserNewTabRequest | null {
|
||||
const request = readDesktopBrowserNewTabRequest(input.payload);
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!workspaceContainsBrowser({
|
||||
workspaceLayout: input.workspaceLayout,
|
||||
browserId: request.sourceBrowserId,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
export function useDesktopBrowserNewTabRequests(input: {
|
||||
enabled: boolean;
|
||||
workspaceLayout: WorkspaceLayout | null | undefined;
|
||||
openUrl: (url: string) => void;
|
||||
}): void {
|
||||
const handleNewTabRequest = useStableEvent((payload: unknown) => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload,
|
||||
workspaceLayout: input.workspaceLayout,
|
||||
});
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
input.openUrl(request.url);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!input.enabled || !getIsElectron()) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = getDesktopHost()?.events?.on?.(
|
||||
"browser-new-tab-request",
|
||||
handleNewTabRequest,
|
||||
);
|
||||
if (typeof unsubscribe === "function") {
|
||||
return unsubscribe;
|
||||
}
|
||||
return () => {
|
||||
void unsubscribe?.then((dispose) => dispose());
|
||||
};
|
||||
}, [handleNewTabRequest, input.enabled]);
|
||||
}
|
||||
@@ -9,24 +9,39 @@ import {
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetTextInput,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
|
||||
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||
return `${agent.serverId}:${agent.id}`;
|
||||
}
|
||||
|
||||
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
interface CommandCenterRowProps {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
onPress: () => void;
|
||||
registerRow: (el: View | null) => void;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
}
|
||||
|
||||
const CommandCenterRow = memo(function CommandCenterRow({
|
||||
@@ -34,6 +49,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
|
||||
children,
|
||||
onPress,
|
||||
registerRow,
|
||||
onLayout,
|
||||
}: CommandCenterRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -48,7 +64,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress}>
|
||||
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress} onLayout={onLayout}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -59,6 +75,7 @@ interface CommandCenterRowContainerProps {
|
||||
active: boolean;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onPress: () => void;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -67,6 +84,7 @@ function CommandCenterRowContainer({
|
||||
active,
|
||||
rowRefs,
|
||||
onPress,
|
||||
onLayout,
|
||||
children,
|
||||
}: CommandCenterRowContainerProps) {
|
||||
const registerRow = useCallback(
|
||||
@@ -77,7 +95,12 @@ function CommandCenterRowContainer({
|
||||
[rowRefs, rowIndex],
|
||||
);
|
||||
return (
|
||||
<CommandCenterRow active={active} registerRow={registerRow} onPress={onPress}>
|
||||
<CommandCenterRow
|
||||
active={active}
|
||||
registerRow={registerRow}
|
||||
onPress={onPress}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{children}
|
||||
</CommandCenterRow>
|
||||
);
|
||||
@@ -88,6 +111,7 @@ interface CommandCenterActionRowProps {
|
||||
rowIndex: number;
|
||||
active: boolean;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
}
|
||||
|
||||
@@ -96,6 +120,7 @@ function CommandCenterActionRow({
|
||||
rowIndex,
|
||||
active,
|
||||
rowRefs,
|
||||
onLayout,
|
||||
onSelect,
|
||||
}: CommandCenterActionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -119,6 +144,7 @@ function CommandCenterActionRow({
|
||||
active={active}
|
||||
rowRefs={rowRefs}
|
||||
onPress={handlePress}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowMain}>
|
||||
@@ -142,6 +168,7 @@ interface CommandCenterAgentRowProps {
|
||||
rowIndex: number;
|
||||
active: boolean;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -150,6 +177,7 @@ function CommandCenterAgentRow({
|
||||
rowIndex,
|
||||
active,
|
||||
rowRefs,
|
||||
onLayout,
|
||||
onSelect,
|
||||
item,
|
||||
children,
|
||||
@@ -161,6 +189,7 @@ function CommandCenterAgentRow({
|
||||
active={active}
|
||||
rowRefs={rowRefs}
|
||||
onPress={handlePress}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{children}
|
||||
</CommandCenterRowContainer>
|
||||
@@ -209,6 +238,9 @@ interface AgentItemsSectionProps {
|
||||
actionItemsLength: number;
|
||||
activeIndex: number;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onRowLayout: (
|
||||
rowIndex: number,
|
||||
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
||||
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
@@ -219,6 +251,7 @@ function AgentItemsSection({
|
||||
actionItemsLength,
|
||||
activeIndex,
|
||||
rowRefs,
|
||||
onRowLayout,
|
||||
onSelect,
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
@@ -237,6 +270,7 @@ function AgentItemsSection({
|
||||
rowIndex={rowIndex}
|
||||
active={rowIndex === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onLayout={onRowLayout(rowIndex)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<CommandCenterAgentRowContent agent={agent} />
|
||||
@@ -249,50 +283,119 @@ function AgentItemsSection({
|
||||
|
||||
export function CommandCenter() {
|
||||
const { theme } = useUnistyles();
|
||||
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
|
||||
useCommandCenter();
|
||||
const {
|
||||
open,
|
||||
inputRef,
|
||||
query,
|
||||
setQuery,
|
||||
activeIndex,
|
||||
items,
|
||||
handleClose,
|
||||
handleSelectItem,
|
||||
handleKeyEvent,
|
||||
} = useCommandCenter();
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
|
||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
|
||||
const resultsRef = useRef<ScrollView>(null);
|
||||
const nativeScrollY = useRef(0);
|
||||
const nativeViewHeight = useRef(0);
|
||||
// BottomSheetTextInput wraps a different TextInput type (from react-native-gesture-handler).
|
||||
// Use a loose ref to avoid the type mismatch — same pattern as AdaptiveTextInput.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const bottomSheetInputRef = useRef<any>(null);
|
||||
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: open,
|
||||
isEnabled: showBottomSheet,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
// Focus the bottom sheet input when the sheet opens on mobile
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
if (showBottomSheet && open) {
|
||||
const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const scrollNode =
|
||||
(
|
||||
resultsRef.current as
|
||||
| (ScrollView & {
|
||||
getScrollableNode?: () => HTMLElement | null;
|
||||
})
|
||||
| null
|
||||
)?.getScrollableNode?.() ?? null;
|
||||
const rowEl = row as unknown as HTMLElement;
|
||||
}, [showBottomSheet, open]);
|
||||
|
||||
if (!scrollNode) {
|
||||
rowEl.scrollIntoView?.({ block: "nearest" });
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// Scroll active row into view
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (isWeb) {
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") return;
|
||||
const scrollNode =
|
||||
(
|
||||
resultsRef.current as
|
||||
| (ScrollView & {
|
||||
getScrollableNode?: () => HTMLElement | null;
|
||||
})
|
||||
| null
|
||||
)?.getScrollableNode?.() ?? null;
|
||||
const rowEl = row as unknown as HTMLElement;
|
||||
|
||||
if (!scrollNode) {
|
||||
rowEl.scrollIntoView?.({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rowTop = rowEl.offsetTop;
|
||||
const rowBottom = rowTop + rowEl.offsetHeight;
|
||||
const visibleTop = scrollNode.scrollTop;
|
||||
const visibleBottom = visibleTop + scrollNode.clientHeight;
|
||||
|
||||
if (rowTop < visibleTop) {
|
||||
scrollNode.scrollTop = rowTop;
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowBottom > visibleBottom) {
|
||||
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rowTop = rowEl.offsetTop;
|
||||
const rowBottom = rowTop + rowEl.offsetHeight;
|
||||
const visibleTop = scrollNode.scrollTop;
|
||||
const visibleBottom = visibleTop + scrollNode.clientHeight;
|
||||
// Native: use onLayout-measured positions
|
||||
const layout = rowLayouts.current.get(activeIndex);
|
||||
if (!layout || !resultsRef.current) return;
|
||||
|
||||
const rowTop = layout.y;
|
||||
const rowBottom = rowTop + layout.height;
|
||||
const visibleTop = nativeScrollY.current;
|
||||
const visibleBottom = visibleTop + nativeViewHeight.current;
|
||||
|
||||
if (rowTop < visibleTop) {
|
||||
scrollNode.scrollTop = rowTop;
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowBottom > visibleBottom) {
|
||||
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
|
||||
resultsRef.current.scrollTo?.({ y: rowTop, animated: true });
|
||||
} else if (rowBottom > visibleBottom) {
|
||||
resultsRef.current.scrollTo?.({
|
||||
y: rowBottom - nativeViewHeight.current,
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
}, [activeIndex, open]);
|
||||
|
||||
const handleRowLayout = useCallback(
|
||||
(rowIndex: number) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => {
|
||||
rowLayouts.current.set(rowIndex, {
|
||||
y: event.nativeEvent.layout.y,
|
||||
height: event.nativeEvent.layout.height,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]);
|
||||
const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
|
||||
|
||||
@@ -324,8 +427,101 @@ export function CommandCenter() {
|
||||
[theme.colors.border],
|
||||
);
|
||||
|
||||
if (isNative || !open) return null;
|
||||
const handleKeyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
|
||||
handleKeyEvent(key);
|
||||
},
|
||||
[handleKeyEvent],
|
||||
);
|
||||
|
||||
const handleSubmitEditing = useCallback(() => {
|
||||
handleKeyEvent("Enter");
|
||||
}, [handleKeyEvent]);
|
||||
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
|
||||
const resultList =
|
||||
items.length === 0 ? (
|
||||
<Text style={emptyTextStyle}>No matches</Text>
|
||||
) : (
|
||||
<>
|
||||
{actionItems.length > 0 ? (
|
||||
<>
|
||||
<Text style={sectionLabelStyle}>Actions</Text>
|
||||
{actionItems.map((item, index) => (
|
||||
<CommandCenterActionRow
|
||||
key={`action:${item.action.id}`}
|
||||
item={item}
|
||||
rowIndex={index}
|
||||
active={index === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onLayout={handleRowLayout(index)}
|
||||
onSelect={handleSelectItem}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{agentItems.length > 0 ? (
|
||||
<AgentItemsSection
|
||||
agentItems={agentItems}
|
||||
actionItemsLength={actionItems.length}
|
||||
activeIndex={activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onRowLayout={handleRowLayout}
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
// Mobile: bottom sheet
|
||||
if (showBottomSheet) {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef as unknown as React.Ref<never>}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
onKeyPress={handleKeyPress}
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
placeholder="Type a command or search agents..."
|
||||
style={inputStyle}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{resultList}
|
||||
</BottomSheetScrollView>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Desktop web: centered overlay panel
|
||||
return (
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
|
||||
<View style={styles.overlay}>
|
||||
@@ -354,39 +550,7 @@ export function CommandCenter() {
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<Text style={emptyTextStyle}>No matches</Text>
|
||||
) : (
|
||||
<>
|
||||
{actionItems.length > 0 ? (
|
||||
<>
|
||||
<Text style={sectionLabelStyle}>Actions</Text>
|
||||
{actionItems.map((item, index) => (
|
||||
<CommandCenterActionRow
|
||||
key={`action:${item.action.id}`}
|
||||
item={item}
|
||||
rowIndex={index}
|
||||
active={index === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onSelect={handleSelectItem}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{agentItems.length > 0 ? (
|
||||
<AgentItemsSection
|
||||
agentItems={agentItems}
|
||||
actionItemsLength={actionItems.length}
|
||||
activeIndex={activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{resultList}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
@@ -395,6 +559,12 @@ export function CommandCenter() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-start",
|
||||
|
||||
@@ -84,7 +84,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
||||
initialAttempt?: CreateAttempt | null;
|
||||
allowEmptyText?: boolean;
|
||||
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
|
||||
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
|
||||
onBeforeSubmit?: (ctx: CreateRequestContext) => Promise<void> | void;
|
||||
onCreateStart?: () => void;
|
||||
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>;
|
||||
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent;
|
||||
@@ -168,7 +168,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
throw error;
|
||||
}
|
||||
|
||||
onBeforeSubmit?.({
|
||||
await onBeforeSubmit?.({
|
||||
attempt,
|
||||
text: attempt.text,
|
||||
images: attempt.images,
|
||||
|
||||
@@ -441,8 +441,8 @@ export function WorkspaceDraftAgentTab({
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
hasClient: Boolean(client),
|
||||
}),
|
||||
onBeforeSubmit: () => {
|
||||
void composerState.persistFormPreferences();
|
||||
onBeforeSubmit: async () => {
|
||||
await composerState.persistFormPreferences();
|
||||
if (isWeb) {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CreateAgentPreferencesService } from "./service";
|
||||
import {
|
||||
mergeCreateAgentSelectionPreferences,
|
||||
mergeProviderPreferences,
|
||||
parseFormPreferences,
|
||||
} from "./preferences";
|
||||
import { FakeCreateAgentPreferenceStorage } from "./test-utils/fake-preference-storage";
|
||||
|
||||
describe("create agent preferences", () => {
|
||||
it("keeps the selected mode after saving model and thinking", async () => {
|
||||
const storage = new FakeCreateAgentPreferenceStorage();
|
||||
const preferences = new CreateAgentPreferencesService(storage);
|
||||
|
||||
const modelWrite = preferences.update((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: "codex",
|
||||
updates: { model: "gpt-5.5", thinkingByModel: { "gpt-5.5": "high" } },
|
||||
}),
|
||||
);
|
||||
await storage.nextWrite();
|
||||
|
||||
const modeWrite = preferences.update((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: "codex",
|
||||
updates: { mode: "full-access" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(storage.pendingWriteCount()).toBe(1);
|
||||
storage.finishOldestWrite();
|
||||
await modelWrite;
|
||||
|
||||
await storage.nextWrite();
|
||||
storage.finishOldestWrite();
|
||||
await modeWrite;
|
||||
|
||||
expect(storage.savedPreferences()).toEqual({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.5",
|
||||
thinkingByModel: { "gpt-5.5": "high" },
|
||||
mode: "full-access",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("flushes the full create-agent selection into provider preferences", async () => {
|
||||
const storage = new FakeCreateAgentPreferenceStorage();
|
||||
const preferences = new CreateAgentPreferencesService(storage);
|
||||
|
||||
const saveSelection = preferences.update((current) =>
|
||||
mergeCreateAgentSelectionPreferences({
|
||||
preferences: current,
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.5",
|
||||
modeId: "full-access",
|
||||
thinkingOptionId: "high",
|
||||
featureValues: { fast_mode: true },
|
||||
}),
|
||||
);
|
||||
|
||||
await storage.nextWrite();
|
||||
storage.finishOldestWrite();
|
||||
await saveSelection;
|
||||
|
||||
expect(storage.savedPreferences()).toEqual({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.5",
|
||||
mode: "full-access",
|
||||
thinkingByModel: { "gpt-5.5": "high" },
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads invalid stored preferences as empty preferences", () => {
|
||||
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export interface FavoriteModelPreference {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface FavoriteModelRow {
|
||||
favoriteKey: string;
|
||||
provider: string;
|
||||
providerLabel: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const providerPreferencesSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
thinkingByModel: z.record(z.string()).optional(),
|
||||
featureValues: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const formPreferencesSchema = z.object({
|
||||
provider: z.string().optional(),
|
||||
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
||||
favoriteModels: z
|
||||
.array(
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
modelId: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
|
||||
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
|
||||
|
||||
export const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
|
||||
|
||||
export function parseFormPreferences(value: unknown): FormPreferences {
|
||||
const result = formPreferencesSchema.safeParse(value);
|
||||
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
|
||||
}
|
||||
|
||||
export function mergeProviderPreferences(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: AgentProvider;
|
||||
updates: Partial<ProviderPreferences>;
|
||||
}): FormPreferences {
|
||||
const { preferences, provider, updates } = args;
|
||||
const existingProviderPreferences = preferences.providerPreferences ?? {};
|
||||
const existing = existingProviderPreferences[provider] ?? {};
|
||||
const nextThinkingByModel =
|
||||
updates.thinkingByModel === undefined
|
||||
? existing.thinkingByModel
|
||||
: {
|
||||
...existing.thinkingByModel,
|
||||
...updates.thinkingByModel,
|
||||
};
|
||||
const nextFeatureValues =
|
||||
updates.featureValues === undefined
|
||||
? existing.featureValues
|
||||
: {
|
||||
...existing.featureValues,
|
||||
...updates.featureValues,
|
||||
};
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
provider,
|
||||
providerPreferences: {
|
||||
...existingProviderPreferences,
|
||||
[provider]: {
|
||||
...existing,
|
||||
...updates,
|
||||
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
|
||||
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCreateAgentSelectionPreferences(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: AgentProvider | null;
|
||||
modelId?: string | null;
|
||||
modeId?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): FormPreferences {
|
||||
if (!args.provider) {
|
||||
return args.preferences;
|
||||
}
|
||||
|
||||
const modelId = args.modelId?.trim() ?? "";
|
||||
const modeId = args.modeId?.trim() ?? "";
|
||||
const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
|
||||
|
||||
return mergeProviderPreferences({
|
||||
preferences: args.preferences,
|
||||
provider: args.provider,
|
||||
updates: {
|
||||
model: modelId || undefined,
|
||||
mode: modeId || undefined,
|
||||
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
|
||||
...(args.featureValues ? { featureValues: args.featureValues } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
|
||||
return `${input.provider}:${input.modelId}`;
|
||||
}
|
||||
|
||||
export function isFavoriteModel(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}): boolean {
|
||||
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
|
||||
return (args.preferences.favoriteModels ?? []).some(
|
||||
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function toggleFavoriteModel(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}): FormPreferences {
|
||||
const favorite = { provider: args.provider, modelId: args.modelId };
|
||||
const favoriteKey = buildFavoriteModelKey(favorite);
|
||||
const existingFavorites = args.preferences.favoriteModels ?? [];
|
||||
const hasFavorite = existingFavorites.some(
|
||||
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
|
||||
);
|
||||
|
||||
return {
|
||||
...args.preferences,
|
||||
favoriteModels: hasFavorite
|
||||
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
|
||||
: [...existingFavorites, favorite],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { AsyncStorageCreateAgentPreferenceStorage } from "./storage";
|
||||
import {
|
||||
DEFAULT_FORM_PREFERENCES,
|
||||
parseFormPreferences,
|
||||
type FormPreferences,
|
||||
} from "./preferences";
|
||||
import type { CreateAgentPreferenceStorage } from "./storage";
|
||||
|
||||
export type FormPreferenceUpdate =
|
||||
| Partial<FormPreferences>
|
||||
| ((current: FormPreferences) => FormPreferences);
|
||||
|
||||
export class CreateAgentPreferencesService {
|
||||
private preferences: FormPreferences = DEFAULT_FORM_PREFERENCES;
|
||||
private isLoaded = false;
|
||||
private loadPromise: Promise<FormPreferences> | null = null;
|
||||
private writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(private readonly storage: CreateAgentPreferenceStorage) {}
|
||||
|
||||
async load(): Promise<FormPreferences> {
|
||||
if (this.isLoaded) {
|
||||
return this.preferences;
|
||||
}
|
||||
if (!this.loadPromise) {
|
||||
this.loadPromise = this.storage.read().then((stored) => {
|
||||
this.preferences = parseFormPreferences(stored);
|
||||
this.isLoaded = true;
|
||||
return this.preferences;
|
||||
});
|
||||
}
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
async update(update: FormPreferenceUpdate): Promise<FormPreferences> {
|
||||
const previousWrite = this.writeQueue;
|
||||
const operation = this.applyQueuedUpdate(previousWrite, update);
|
||||
|
||||
this.writeQueue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async applyQueuedUpdate(
|
||||
previousWrite: Promise<void>,
|
||||
update: FormPreferenceUpdate,
|
||||
): Promise<FormPreferences> {
|
||||
await previousWrite;
|
||||
const current = await this.load();
|
||||
const next = typeof update === "function" ? update(current) : { ...current, ...update };
|
||||
this.preferences = parseFormPreferences(next);
|
||||
this.isLoaded = true;
|
||||
await this.storage.write(this.preferences);
|
||||
return this.preferences;
|
||||
}
|
||||
}
|
||||
|
||||
export const createAgentPreferencesService = new CreateAgentPreferencesService(
|
||||
new AsyncStorageCreateAgentPreferenceStorage(),
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { FormPreferences } from "./preferences";
|
||||
|
||||
export const CREATE_AGENT_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
export interface CreateAgentPreferenceStorage {
|
||||
read(): Promise<unknown>;
|
||||
write(preferences: FormPreferences): Promise<void>;
|
||||
}
|
||||
|
||||
export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
|
||||
async read(): Promise<unknown> {
|
||||
const stored = await AsyncStorage.getItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async write(preferences: FormPreferences): Promise<void> {
|
||||
await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { FormPreferences } from "../preferences";
|
||||
import type { CreateAgentPreferenceStorage } from "../storage";
|
||||
|
||||
interface PendingWrite {
|
||||
preferences: FormPreferences;
|
||||
finish: () => void;
|
||||
}
|
||||
|
||||
export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
|
||||
private stored: unknown;
|
||||
private readonly pendingWrites: PendingWrite[] = [];
|
||||
private readonly pendingWriteWaiters: Array<(write: PendingWrite) => void> = [];
|
||||
|
||||
constructor(input: { stored?: unknown } = {}) {
|
||||
this.stored = input.stored ?? null;
|
||||
}
|
||||
|
||||
async read(): Promise<unknown> {
|
||||
return this.stored;
|
||||
}
|
||||
|
||||
write(preferences: FormPreferences): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const write = {
|
||||
preferences,
|
||||
finish: () => {
|
||||
this.stored = clone(preferences);
|
||||
resolve();
|
||||
},
|
||||
};
|
||||
this.pendingWrites.push(write);
|
||||
this.pendingWriteWaiters.shift()?.(write);
|
||||
});
|
||||
}
|
||||
|
||||
nextWrite(): Promise<PendingWrite> {
|
||||
const next = this.pendingWrites[0];
|
||||
if (next) {
|
||||
return Promise.resolve(next);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.pendingWriteWaiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
pendingWriteCount(): number {
|
||||
return this.pendingWrites.length;
|
||||
}
|
||||
|
||||
finishOldestWrite(): void {
|
||||
const write = this.pendingWrites.shift();
|
||||
if (!write) {
|
||||
throw new Error("No pending create-agent preference write");
|
||||
}
|
||||
write.finish();
|
||||
}
|
||||
|
||||
savedPreferences(): unknown {
|
||||
return this.stored;
|
||||
}
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
@@ -111,6 +111,11 @@ export interface DesktopBrowserShortcutEvent {
|
||||
action: "focus-url";
|
||||
}
|
||||
|
||||
export interface DesktopBrowserNewTabRequestEvent {
|
||||
sourceBrowserId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface DesktopBrowserBridge {
|
||||
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
|
||||
openDevTools?: (browserId: string) => Promise<unknown>;
|
||||
|
||||
@@ -252,6 +252,38 @@ describe("git-actions-policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps pull-and-push unavailable when the branch only has outgoing commits", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 2,
|
||||
behindOfOrigin: 0,
|
||||
}),
|
||||
);
|
||||
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
|
||||
|
||||
expect(action).toMatchObject({
|
||||
label: "Pull and push",
|
||||
unavailableMessage: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps pull-and-push unavailable when the branch only has incoming commits", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 2,
|
||||
}),
|
||||
);
|
||||
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
|
||||
|
||||
expect(action).toMatchObject({
|
||||
label: "Pull and push",
|
||||
unavailableMessage: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("explains why pull-and-push is unavailable when the branch is in sync", () => {
|
||||
const actions = buildGitActions(createInput({ hasRemote: true }));
|
||||
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
|
||||
|
||||
@@ -616,6 +616,12 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string |
|
||||
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
|
||||
return "Pull and push isn't available because this branch is already in sync";
|
||||
}
|
||||
if (input.behindOfOrigin === 0) {
|
||||
return "Pull and push isn't available because there are no incoming changes to pull first";
|
||||
}
|
||||
if (input.aheadOfOrigin === 0) {
|
||||
return "Pull and push isn't available because there is nothing new to send after pulling";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const EMPTY_AGENTS: AggregatedAgent[] = [];
|
||||
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
|
||||
@@ -319,33 +320,25 @@ export function useCommandCenter() {
|
||||
}
|
||||
}, [activeIndex, items.length, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const handleKeyEvent = useCallback(
|
||||
(key: string): boolean => {
|
||||
if (!open) return false;
|
||||
const currentItems = itemsRef.current;
|
||||
const key = event.key;
|
||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleCloseRef.current();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
if (currentItems.length === 0) return;
|
||||
event.preventDefault();
|
||||
if (currentItems.length === 0) return false;
|
||||
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
|
||||
handleSelectItemRef.current(currentItems[index]);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
if (currentItems.length === 0) return;
|
||||
event.preventDefault();
|
||||
if (currentItems.length === 0) return false;
|
||||
setActiveIndex((current) => {
|
||||
const delta = key === "ArrowDown" ? 1 : -1;
|
||||
const next = current + delta;
|
||||
@@ -353,13 +346,35 @@ export function useCommandCenter() {
|
||||
if (next >= currentItems.length) return 0;
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[open],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isWeb) return;
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key !== "ArrowDown" &&
|
||||
event.key !== "ArrowUp" &&
|
||||
event.key !== "Enter" &&
|
||||
event.key !== "Escape"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (handleKeyEvent(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// react-native-web can stop propagation on key events, so listen in capture phase.
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [open]);
|
||||
}, [open, handleKeyEvent]);
|
||||
|
||||
return {
|
||||
open,
|
||||
@@ -371,5 +386,6 @@ export function useCommandCenter() {
|
||||
items,
|
||||
handleClose,
|
||||
handleSelectItem,
|
||||
handleKeyEvent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,137 +1,35 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
buildFavoriteModelKey,
|
||||
DEFAULT_FORM_PREFERENCES,
|
||||
isFavoriteModel,
|
||||
mergeProviderPreferences,
|
||||
toggleFavoriteModel,
|
||||
type FavoriteModelPreference,
|
||||
type FavoriteModelRow,
|
||||
type FormPreferences,
|
||||
type ProviderPreferences,
|
||||
} from "@/create-agent-preferences/preferences";
|
||||
import {
|
||||
createAgentPreferencesService,
|
||||
type FormPreferenceUpdate,
|
||||
} from "@/create-agent-preferences/service";
|
||||
|
||||
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
||||
|
||||
export interface FavoriteModelPreference {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
export type { FavoriteModelPreference, FavoriteModelRow, FormPreferences, ProviderPreferences };
|
||||
|
||||
export interface FavoriteModelRow {
|
||||
favoriteKey: string;
|
||||
provider: string;
|
||||
providerLabel: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const providerPreferencesSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
thinkingByModel: z.record(z.string()).optional(),
|
||||
featureValues: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const formPreferencesSchema = z.object({
|
||||
provider: z.string().optional(),
|
||||
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
||||
favoriteModels: z
|
||||
.array(
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
modelId: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
|
||||
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
|
||||
|
||||
const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
|
||||
export { buildFavoriteModelKey, isFavoriteModel, mergeProviderPreferences, toggleFavoriteModel };
|
||||
|
||||
async function loadFormPreferences(): Promise<FormPreferences> {
|
||||
const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY);
|
||||
if (!stored) return DEFAULT_FORM_PREFERENCES;
|
||||
const result = formPreferencesSchema.safeParse(JSON.parse(stored));
|
||||
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
|
||||
return createAgentPreferencesService.load();
|
||||
}
|
||||
|
||||
export interface UseFormPreferencesReturn {
|
||||
preferences: FormPreferences;
|
||||
isLoading: boolean;
|
||||
updatePreferences: (
|
||||
updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences),
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export function mergeProviderPreferences(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: AgentProvider;
|
||||
updates: Partial<ProviderPreferences>;
|
||||
}): FormPreferences {
|
||||
const { preferences, provider, updates } = args;
|
||||
const existingProviderPreferences = preferences.providerPreferences ?? {};
|
||||
const existing = existingProviderPreferences[provider] ?? {};
|
||||
const nextThinkingByModel =
|
||||
updates.thinkingByModel === undefined
|
||||
? existing.thinkingByModel
|
||||
: {
|
||||
...existing.thinkingByModel,
|
||||
...updates.thinkingByModel,
|
||||
};
|
||||
const nextFeatureValues =
|
||||
updates.featureValues === undefined
|
||||
? existing.featureValues
|
||||
: {
|
||||
...existing.featureValues,
|
||||
...updates.featureValues,
|
||||
};
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
provider,
|
||||
providerPreferences: {
|
||||
...existingProviderPreferences,
|
||||
[provider]: {
|
||||
...existing,
|
||||
...updates,
|
||||
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
|
||||
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
|
||||
return `${input.provider}:${input.modelId}`;
|
||||
}
|
||||
|
||||
export function isFavoriteModel(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}): boolean {
|
||||
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
|
||||
return (args.preferences.favoriteModels ?? []).some(
|
||||
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function toggleFavoriteModel(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}): FormPreferences {
|
||||
const favorite = { provider: args.provider, modelId: args.modelId };
|
||||
const favoriteKey = buildFavoriteModelKey(favorite);
|
||||
const existingFavorites = args.preferences.favoriteModels ?? [];
|
||||
const hasFavorite = existingFavorites.some(
|
||||
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
|
||||
);
|
||||
|
||||
return {
|
||||
...args.preferences,
|
||||
favoriteModels: hasFavorite
|
||||
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
|
||||
: [...existingFavorites, favorite],
|
||||
};
|
||||
updatePreferences: (updates: FormPreferenceUpdate) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
@@ -146,13 +44,9 @@ export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
const preferences = data ?? DEFAULT_FORM_PREFERENCES;
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
async (updates: Partial<FormPreferences> | ((current: FormPreferences) => FormPreferences)) => {
|
||||
const prev =
|
||||
queryClient.getQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY) ??
|
||||
DEFAULT_FORM_PREFERENCES;
|
||||
const next = typeof updates === "function" ? updates(prev) : { ...prev, ...updates };
|
||||
async (updates: FormPreferenceUpdate) => {
|
||||
const next = await createAgentPreferencesService.update(updates);
|
||||
queryClient.setQueryData<FormPreferences>(FORM_PREFERENCES_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
@@ -747,9 +747,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
provider,
|
||||
clientMessageId,
|
||||
timestamp,
|
||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? { modeId: composerState.selectedMode }
|
||||
: {}),
|
||||
...(composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}),
|
||||
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
||||
...(composerState.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||
@@ -1063,6 +1061,7 @@ export function NewWorkspaceScreen({
|
||||
async (payload: MessagePayload) => {
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
await composerState?.persistFormPreferences();
|
||||
if (isEmptyWorkspaceSubmission(payload)) {
|
||||
setPendingAction("empty");
|
||||
await runCreateEmptyWorkspace({
|
||||
|
||||
@@ -130,6 +130,7 @@ import {
|
||||
buildWorkspaceTabMenuEntries,
|
||||
type WorkspaceTabMenuEntry,
|
||||
} from "@/screens/workspace/workspace-tab-menu";
|
||||
import { useDesktopBrowserNewTabRequests } from "@/browser/new-tab-requests";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
resolveWorkspaceHeaderRenderState,
|
||||
@@ -2329,6 +2330,12 @@ function WorkspaceScreenContent({
|
||||
[openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
|
||||
useDesktopBrowserNewTabRequests({
|
||||
enabled: Boolean(persistenceKey),
|
||||
workspaceLayout,
|
||||
openUrl: handleOpenUrlInBrowserTab,
|
||||
});
|
||||
|
||||
const handleSelectSwitcherTab = useCallback(
|
||||
(key: string) => {
|
||||
navigateToTabId(key);
|
||||
|
||||
@@ -99,6 +99,21 @@ function todoTimeline(items: { text: string; completed: boolean }[]): AgentStrea
|
||||
};
|
||||
}
|
||||
|
||||
function compactionTimeline(
|
||||
status: "loading" | "completed",
|
||||
trigger?: "auto" | "manual",
|
||||
): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: {
|
||||
type: "compaction",
|
||||
status,
|
||||
...(trigger ? { trigger } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined {
|
||||
return state.find(
|
||||
(item): item is AgentToolCallItem =>
|
||||
@@ -693,6 +708,27 @@ describe("stream reducer canonical tool calls", () => {
|
||||
assert.strictEqual(todos.items[1]?.completed, true);
|
||||
});
|
||||
|
||||
it("preserves compaction trigger when completed update replaces loading marker", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: compactionTimeline("loading", "auto"),
|
||||
timestamp: new Date("2025-01-01T10:50:00Z"),
|
||||
},
|
||||
{
|
||||
event: compactionTimeline("completed"),
|
||||
timestamp: new Date("2025-01-01T10:50:01Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const compactions = state.filter(
|
||||
(item): item is Extract<StreamItem, { kind: "compaction" }> => item.kind === "compaction",
|
||||
);
|
||||
|
||||
assert.strictEqual(compactions.length, 1);
|
||||
assert.strictEqual(compactions[0].status, "completed");
|
||||
assert.strictEqual(compactions[0].trigger, "auto");
|
||||
});
|
||||
|
||||
it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
|
||||
@@ -726,8 +726,8 @@ function reduceTimelineCompaction(
|
||||
const updated: CompactionItem = {
|
||||
...existing,
|
||||
status: "completed",
|
||||
trigger: item.trigger,
|
||||
preTokens: item.preTokens,
|
||||
trigger: item.trigger ?? existing.trigger,
|
||||
preTokens: item.preTokens ?? existing.preTokens,
|
||||
};
|
||||
return [...state.slice(0, loadingIdx), updated, ...state.slice(loadingIdx + 1)];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server";
|
||||
@@ -351,6 +351,123 @@ async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean>
|
||||
return poll();
|
||||
}
|
||||
|
||||
async function waitForDaemonUnreachable(
|
||||
state: LocalDaemonState,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
if (!host) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const reachableHost = host;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
async function poll(): Promise<boolean> {
|
||||
const client = await tryConnectToDaemon({ host: reachableHost, timeout: 500 });
|
||||
if (!client) {
|
||||
return true;
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
if (Date.now() >= deadline) {
|
||||
const finalClient = await tryConnectToDaemon({
|
||||
host: reachableHost,
|
||||
timeout: PID_POLL_INTERVAL_MS,
|
||||
});
|
||||
if (!finalClient) {
|
||||
return true;
|
||||
}
|
||||
await finalClient.close().catch(() => undefined);
|
||||
return false;
|
||||
}
|
||||
await sleep(PID_POLL_INTERVAL_MS);
|
||||
return poll();
|
||||
}
|
||||
|
||||
return poll();
|
||||
}
|
||||
|
||||
function removeStalePidFile(state: LocalDaemonState): void {
|
||||
if (!state.stalePidFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(state.pidPath);
|
||||
} catch {
|
||||
// Best-effort cleanup only. The successful lifecycle stop is authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
function createNotRunningStopResult(
|
||||
state: LocalDaemonState,
|
||||
pid: number | null,
|
||||
message: string,
|
||||
): StopLocalDaemonResult {
|
||||
return {
|
||||
action: "not_running",
|
||||
home: state.home,
|
||||
pid,
|
||||
forced: false,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function createStopTimeoutError(
|
||||
state: LocalDaemonState,
|
||||
pid: number | null,
|
||||
timeoutMs: number,
|
||||
): Error {
|
||||
if (!state.running) {
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
return new Error(
|
||||
`Timed out waiting for daemon${host ? ` at ${host}` : ""} to stop after ${Math.ceil(
|
||||
timeoutMs / 1000,
|
||||
)}s`,
|
||||
);
|
||||
}
|
||||
return new Error(
|
||||
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
|
||||
);
|
||||
}
|
||||
|
||||
async function signalDaemonOwnerForStop(
|
||||
state: LocalDaemonState,
|
||||
pid: number | null,
|
||||
): Promise<StopLocalDaemonResult | null> {
|
||||
if (pid === null) {
|
||||
return createNotRunningStopResult(state, null, "Daemon is not running");
|
||||
}
|
||||
|
||||
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
|
||||
if (signaled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createNotRunningStopResult(state, pid, "Daemon process was already stopped");
|
||||
}
|
||||
|
||||
async function waitForStopAfterRequest(args: {
|
||||
state: LocalDaemonState;
|
||||
pid: number | null;
|
||||
timeoutMs: number;
|
||||
killTimeoutMs: number;
|
||||
force?: boolean;
|
||||
}): Promise<{ stopped: boolean; forced: boolean }> {
|
||||
const { state, pid, timeoutMs, killTimeoutMs, force } = args;
|
||||
let stopped =
|
||||
state.running && pid !== null
|
||||
? await waitForPidExit(pid, timeoutMs)
|
||||
: await waitForDaemonUnreachable(state, timeoutMs);
|
||||
|
||||
if (!stopped && force && state.running && pid !== null) {
|
||||
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
|
||||
stopped = await waitForPidExit(pid, killTimeoutMs);
|
||||
return { stopped, forced: true };
|
||||
}
|
||||
|
||||
return { stopped, forced: false };
|
||||
}
|
||||
|
||||
type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string };
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
@@ -567,49 +684,41 @@ export async function stopLocalDaemon(
|
||||
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
|
||||
const state = resolveLocalDaemonState({ home: options.home });
|
||||
|
||||
if (!state.pidInfo || !state.running) {
|
||||
const staleSuffix =
|
||||
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
|
||||
return {
|
||||
action: "not_running",
|
||||
home: state.home,
|
||||
pid: state.pidInfo?.pid ?? null,
|
||||
forced: false,
|
||||
message: `Daemon is not running${staleSuffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
const pid = state.pidInfo.pid;
|
||||
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs);
|
||||
const lifecycleRequested = shutdownAttempt.requested;
|
||||
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
|
||||
let forced = false;
|
||||
if (!lifecycleRequested) {
|
||||
const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM");
|
||||
if (!signaled) {
|
||||
return {
|
||||
action: "not_running",
|
||||
home: state.home,
|
||||
pid,
|
||||
forced: false,
|
||||
message: "Daemon process was already stopped",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let stopped = await waitForPidExit(pid, timeoutMs);
|
||||
if (!stopped && options.force) {
|
||||
forced = true;
|
||||
await signalProcessTreeOrOwnerSafely(pid, "SIGKILL");
|
||||
stopped = await waitForPidExit(pid, killTimeoutMs);
|
||||
}
|
||||
|
||||
if (!stopped) {
|
||||
throw new Error(
|
||||
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
|
||||
if (!state.pidInfo || (!state.running && !lifecycleRequested)) {
|
||||
const staleSuffix =
|
||||
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
|
||||
return createNotRunningStopResult(
|
||||
state,
|
||||
state.pidInfo?.pid ?? null,
|
||||
`Daemon is not running${staleSuffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pid = state.pidInfo?.pid ?? null;
|
||||
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
|
||||
if (!lifecycleRequested) {
|
||||
const notRunningResult = await signalDaemonOwnerForStop(state, pid);
|
||||
if (notRunningResult) return notRunningResult;
|
||||
}
|
||||
|
||||
const { stopped, forced } = await waitForStopAfterRequest({
|
||||
state,
|
||||
pid,
|
||||
timeoutMs,
|
||||
killTimeoutMs,
|
||||
force: options.force,
|
||||
});
|
||||
if (!stopped) {
|
||||
throw createStopTimeoutError(state, pid, timeoutMs);
|
||||
}
|
||||
|
||||
if (lifecycleRequested) {
|
||||
removeStalePidFile(state);
|
||||
}
|
||||
|
||||
return {
|
||||
action: "stopped",
|
||||
home: state.home,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Regression: `paseo daemon stop` must stop a reachable daemon even when the
|
||||
* local pid file points at a dead supervisor owner.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { $ } from "zx";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
$.verbose = false;
|
||||
|
||||
const pollIntervalMs = 100;
|
||||
const testEnv = {
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
check: () => Promise<boolean> | boolean,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (await check()) return;
|
||||
if (Date.now() >= deadline) throw new Error(message);
|
||||
await sleep(pollIntervalMs);
|
||||
return poll();
|
||||
}
|
||||
|
||||
return poll();
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
localDaemon: string | null;
|
||||
connectedDaemon: string | null;
|
||||
pid: number | null;
|
||||
}
|
||||
|
||||
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
|
||||
if (result.exitCode !== 0) {
|
||||
return { localDaemon: null, connectedDaemon: null, pid: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as {
|
||||
localDaemon?: unknown;
|
||||
connectedDaemon?: unknown;
|
||||
pid?: unknown;
|
||||
};
|
||||
return {
|
||||
localDaemon: typeof parsed.localDaemon === "string" ? parsed.localDaemon : null,
|
||||
connectedDaemon: typeof parsed.connectedDaemon === "string" ? parsed.connectedDaemon : null,
|
||||
pid:
|
||||
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
return { localDaemon: null, connectedDaemon: null, pid: null };
|
||||
}
|
||||
}
|
||||
|
||||
function findUnusedPid(): number {
|
||||
for (let pid = 999_999; pid > 900_000; pid--) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
throw new Error("Unable to find unused pid for stale pid fixture");
|
||||
}
|
||||
|
||||
console.log("=== Daemon Stop (stale pid, reachable worker regression) ===\n");
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-stop-stale-reachable-"));
|
||||
const cliRoot = join(import.meta.dirname, "..");
|
||||
const host = `127.0.0.1:${port}`;
|
||||
const pidPath = join(paseoHome, "paseo.pid");
|
||||
const stalePid = findUnusedPid();
|
||||
|
||||
let workerProcess: ChildProcess | null = null;
|
||||
|
||||
try {
|
||||
console.log("Test 1: start daemon worker with stale supervisor pid file");
|
||||
|
||||
await writeFile(
|
||||
pidPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
pid: stalePid,
|
||||
startedAt: new Date().toISOString(),
|
||||
hostname: "stale-supervisor-fixture.local",
|
||||
uid: typeof process.getuid === "function" ? process.getuid() : undefined,
|
||||
listen: host,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
workerProcess = spawn(
|
||||
process.execPath,
|
||||
["--import", "tsx", "../server/src/server/daemon-worker.ts"],
|
||||
{
|
||||
cwd: cliRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...testEnv,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: host,
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(paseoHome);
|
||||
return status.localDaemon === "stale_pid" && status.connectedDaemon === "reachable";
|
||||
},
|
||||
120000,
|
||||
"daemon did not enter stale_pid + reachable state in time",
|
||||
);
|
||||
|
||||
const statusBeforeStop = await readDaemonStatus(paseoHome);
|
||||
assert.strictEqual(statusBeforeStop.pid, stalePid, "status should report the stale owner pid");
|
||||
assert(workerProcess.pid && isProcessRunning(workerProcess.pid), "worker should be running");
|
||||
console.log(`✓ fixture has stale pid ${stalePid} and live worker ${workerProcess.pid}\n`);
|
||||
|
||||
console.log(
|
||||
"Test 2: `paseo daemon stop` should stop reachable worker instead of saying not_running",
|
||||
);
|
||||
const stopResult =
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow();
|
||||
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`);
|
||||
const stopJson = JSON.parse(stopResult.stdout) as {
|
||||
action?: unknown;
|
||||
pid?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
assert.strictEqual(stopJson.action, "stopped", "stop should report stopped action");
|
||||
assert.strictEqual(
|
||||
stopJson.pid,
|
||||
String(stalePid),
|
||||
"stop should report the stale pid it recovered from",
|
||||
);
|
||||
assert.strictEqual(
|
||||
stopJson.message,
|
||||
"Daemon stopped gracefully",
|
||||
"stop should route through lifecycle shutdown",
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => !isProcessRunning(workerProcess?.pid ?? -1),
|
||||
15000,
|
||||
"worker remained running after stop",
|
||||
);
|
||||
assert.strictEqual(existsSync(pidPath), false, "stale pid file should be removed after stop");
|
||||
console.log("✓ stop recovered stale supervisor pid state\n");
|
||||
} finally {
|
||||
if (workerProcess?.pid && isProcessRunning(workerProcess.pid)) {
|
||||
workerProcess.kill("SIGTERM");
|
||||
await waitFor(
|
||||
() => !isProcessRunning(workerProcess!.pid ?? -1),
|
||||
5000,
|
||||
"worker cleanup timed out",
|
||||
).catch(() => {
|
||||
workerProcess?.kill("SIGKILL");
|
||||
});
|
||||
}
|
||||
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("=== Stale reachable stop regression test passed ===");
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Regression: a supervised daemon worker must exit when its supervisor IPC
|
||||
* channel closes, instead of becoming an orphaned daemon.
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { $ } from "zx";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
|
||||
$.verbose = false;
|
||||
|
||||
const pollIntervalMs = 100;
|
||||
const testEnv = {
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
|
||||
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
|
||||
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readWorkerPid(supervisorPid: number): number | null {
|
||||
if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" });
|
||||
if (result.status !== 0 || result.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const [pidToken, ppidToken] = trimmed.split(/\s+/);
|
||||
const pid = Number.parseInt(pidToken ?? "", 10);
|
||||
const ppid = Number.parseInt(ppidToken ?? "", 10);
|
||||
if (ppid === supervisorPid && pid > 0) {
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
check: () => Promise<boolean> | boolean,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (await check()) return;
|
||||
if (Date.now() >= deadline) throw new Error(message);
|
||||
await sleep(pollIntervalMs);
|
||||
return poll();
|
||||
}
|
||||
|
||||
return poll();
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
localDaemon: string | null;
|
||||
pid: number | null;
|
||||
}
|
||||
|
||||
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
|
||||
if (result.exitCode !== 0) {
|
||||
return { localDaemon: null, pid: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown };
|
||||
return {
|
||||
localDaemon: typeof parsed.localDaemon === "string" ? parsed.localDaemon : null,
|
||||
pid:
|
||||
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
|
||||
? parsed.pid
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
return { localDaemon: null, pid: null };
|
||||
}
|
||||
}
|
||||
|
||||
console.log("=== Daemon Worker Supervisor Disconnect Regression ===\n");
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-worker-supervisor-disconnect-"));
|
||||
const cliRoot = join(import.meta.dirname, "..");
|
||||
|
||||
let supervisorProcess: ChildProcess | null = null;
|
||||
let recentSupervisorLogs = "";
|
||||
|
||||
try {
|
||||
console.log("Test 1: start supervised daemon with isolated PASEO_HOME");
|
||||
|
||||
supervisorProcess = spawn(
|
||||
process.execPath,
|
||||
["--import", "tsx", "../server/scripts/supervisor-entrypoint.ts", "--dev"],
|
||||
{
|
||||
cwd: cliRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...testEnv,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_RELAY_ENABLED: "false",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
supervisorProcess.stdout?.on("data", (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
supervisorProcess.stderr?.on("data", (chunk) => {
|
||||
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
const status = await readDaemonStatus(paseoHome);
|
||||
return (
|
||||
status.localDaemon === "running" && status.pid !== null && isProcessRunning(status.pid)
|
||||
);
|
||||
},
|
||||
120000,
|
||||
"daemon did not become running in time",
|
||||
);
|
||||
|
||||
const statusBeforeKill = await readDaemonStatus(paseoHome);
|
||||
const supervisorPid = statusBeforeKill.pid;
|
||||
assert(supervisorPid !== null, "supervisor pid should exist once daemon starts");
|
||||
const workerPid = readWorkerPid(supervisorPid);
|
||||
assert(workerPid !== null, "supervisor should have a worker process");
|
||||
assert(isProcessRunning(workerPid), "worker process should be running");
|
||||
console.log(`✓ daemon running with supervisor ${supervisorPid} and worker ${workerPid}\n`);
|
||||
|
||||
console.log("Test 2: killing supervisor should make worker exit via IPC disconnect");
|
||||
supervisorProcess.kill("SIGKILL");
|
||||
await waitFor(
|
||||
() => !isProcessRunning(supervisorPid),
|
||||
15000,
|
||||
"supervisor remained running after SIGKILL",
|
||||
);
|
||||
await waitFor(
|
||||
() => !isProcessRunning(workerPid),
|
||||
15000,
|
||||
"worker remained running after supervisor IPC disconnect",
|
||||
);
|
||||
console.log("✓ worker exited after supervisor disconnect\n");
|
||||
} finally {
|
||||
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
|
||||
supervisorProcess.kill("SIGKILL");
|
||||
}
|
||||
|
||||
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (recentSupervisorLogs.trim().length === 0) {
|
||||
console.log("(no supervisor logs captured)");
|
||||
}
|
||||
|
||||
console.log("=== Worker supervisor disconnect regression test passed ===");
|
||||
@@ -2,6 +2,7 @@ import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
||||
import {
|
||||
decodeFileTransferFrame,
|
||||
encodeFileTransferFrame,
|
||||
FileTransferOpcode,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
@@ -103,6 +104,12 @@ function assertStr(data: string | Uint8Array | ArrayBuffer | undefined): string
|
||||
return data;
|
||||
}
|
||||
|
||||
function assertUint8Array(data: string | Uint8Array | ArrayBuffer | undefined): Uint8Array {
|
||||
if (data instanceof Uint8Array) return data;
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
||||
throw new Error("Expected binary frame");
|
||||
}
|
||||
|
||||
function parseSentFrame(
|
||||
data: string | Uint8Array | ArrayBuffer | undefined,
|
||||
): Record<string, unknown> {
|
||||
@@ -567,6 +574,110 @@ test("readFile resolves from binary file frames when the daemon supports them",
|
||||
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
||||
});
|
||||
|
||||
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.uploadFile({
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
bytes: new TextEncoder().encode("hello world"),
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId: "req-upload",
|
||||
chunkSize: 5,
|
||||
});
|
||||
|
||||
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file.upload.request",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId: "req-upload",
|
||||
},
|
||||
});
|
||||
expect(mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame)).toEqual([
|
||||
{
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId: "req-upload",
|
||||
metadata: {
|
||||
mime: "text/plain",
|
||||
size: 11,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
fileName: "notes.txt",
|
||||
},
|
||||
payload: new Uint8Array(),
|
||||
},
|
||||
{
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: "req-upload",
|
||||
payload: new TextEncoder().encode("hello"),
|
||||
},
|
||||
{
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: "req-upload",
|
||||
payload: new TextEncoder().encode(" worl"),
|
||||
},
|
||||
{
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: "req-upload",
|
||||
payload: new TextEncoder().encode("d"),
|
||||
},
|
||||
{
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId: "req-upload",
|
||||
payload: new Uint8Array(),
|
||||
},
|
||||
]);
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "file.upload.response",
|
||||
payload: {
|
||||
requestId: "req-upload",
|
||||
file: {
|
||||
type: "uploaded_file",
|
||||
id: "upload_req-upload",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
requestId: "req-upload",
|
||||
file: {
|
||||
type: "uploaded_file",
|
||||
id: "upload_req-upload",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
CreateAgentRequestMessage,
|
||||
CreatePaseoWorktreeRequest,
|
||||
FileDownloadTokenResponse,
|
||||
FileUploadResponse,
|
||||
FileExplorerResponse,
|
||||
FetchAgentTimelineResponseMessage,
|
||||
GitSetupOptions,
|
||||
@@ -90,6 +91,7 @@ import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
||||
import {
|
||||
asUint8Array,
|
||||
decodeFileTransferFrame,
|
||||
encodeFileTransferFrame,
|
||||
decodeTerminalStreamFrame,
|
||||
FileTransferOpcode,
|
||||
TerminalStreamOpcode,
|
||||
@@ -318,6 +320,15 @@ export interface FileReadResult {
|
||||
kind: LegacyFileExplorerFilePayload["kind"];
|
||||
modifiedAt: string;
|
||||
}
|
||||
export interface FileUploadInput {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
bytes: Uint8Array | ArrayBuffer;
|
||||
modifiedAt?: string;
|
||||
requestId?: string;
|
||||
chunkSize?: number;
|
||||
}
|
||||
export type FileUploadResult = FileUploadResponse["payload"];
|
||||
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
||||
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
|
||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||
@@ -3316,6 +3327,63 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(input: FileUploadInput): Promise<FileUploadResult> {
|
||||
const bytes = asUint8Array(input.bytes);
|
||||
if (!bytes) {
|
||||
throw new Error("File bytes are required.");
|
||||
}
|
||||
const resolvedRequestId = this.createRequestId(input.requestId);
|
||||
const modifiedAt = input.modifiedAt ?? new Date().toISOString();
|
||||
const responsePromise = this.sendCorrelatedRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message: {
|
||||
type: "file.upload.request",
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType,
|
||||
size: bytes.byteLength,
|
||||
modifiedAt,
|
||||
requestId: resolvedRequestId,
|
||||
},
|
||||
responseType: "file.upload.response",
|
||||
timeout: 60000,
|
||||
options: { skipQueue: true },
|
||||
});
|
||||
|
||||
this.sendBinaryFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId: resolvedRequestId,
|
||||
metadata: {
|
||||
mime: input.mimeType,
|
||||
size: bytes.byteLength,
|
||||
encoding: "binary",
|
||||
modifiedAt,
|
||||
fileName: input.fileName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const chunkSize = input.chunkSize ?? 1024 * 1024;
|
||||
for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
|
||||
this.sendBinaryFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: resolvedRequestId,
|
||||
payload: bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.sendBinaryFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId: resolvedRequestId,
|
||||
}),
|
||||
);
|
||||
|
||||
return responsePromise;
|
||||
}
|
||||
|
||||
async requestDownloadToken(
|
||||
cwd: string,
|
||||
path: string,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const desktopDir = path.resolve(scriptDir, "..");
|
||||
const rootDir = path.resolve(desktopDir, "../..");
|
||||
const appDir = path.resolve(desktopDir, "../app");
|
||||
const require = createRequire(import.meta.url);
|
||||
const electron = require("electron");
|
||||
|
||||
const expoPort = Number(process.env.EXPO_PORT);
|
||||
if (!Number.isInteger(expoPort) || expoPort <= 0) {
|
||||
console.error("[dev] EXPO_PORT must be set before running desktop dev");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const expoDevUrl = process.env.EXPO_DEV_URL || `http://localhost:${expoPort}`;
|
||||
const daemonEndpoint = process.env.PASEO_DAEMON_ENDPOINT || process.env.PASEO_LISTEN || "";
|
||||
const colorEnv = {
|
||||
FORCE_COLOR: process.env.FORCE_COLOR || "1",
|
||||
npm_config_color: process.env.npm_config_color || "always",
|
||||
};
|
||||
|
||||
const children = new Map();
|
||||
let stopping = false;
|
||||
let exitCode = 0;
|
||||
|
||||
function prefixStream(name, stream, target) {
|
||||
let buffered = "";
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (chunk) => {
|
||||
buffered += chunk;
|
||||
const lines = buffered.split(/\r?\n/);
|
||||
buffered = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
target.write(line ? `[${name}] ${line}\n` : `[${name}]\n`);
|
||||
}
|
||||
});
|
||||
stream.on("end", () => {
|
||||
if (buffered) {
|
||||
target.write(`[${name}] ${buffered}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function spawnChild(name, command, args, options = {}) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
...colorEnv,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...options,
|
||||
});
|
||||
|
||||
children.set(name, child);
|
||||
prefixStream(name, child.stdout, process.stdout);
|
||||
prefixStream(name, child.stderr, process.stderr);
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`[${name}] failed to start: ${error.message}`);
|
||||
exitCode = 1;
|
||||
stopAll("SIGTERM");
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
children.delete(name);
|
||||
if (!stopping) {
|
||||
if (code !== 0) {
|
||||
exitCode = code ?? 1;
|
||||
console.error(`[${name}] exited with ${signal ?? code}`);
|
||||
}
|
||||
stopAll("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
function killChild(child, signal) {
|
||||
if (!child.pid || child.killed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (child.detached) {
|
||||
process.kill(-child.pid, signal);
|
||||
} else {
|
||||
child.kill(signal);
|
||||
}
|
||||
} catch {
|
||||
// The child may have exited between the liveness check and the signal.
|
||||
}
|
||||
}
|
||||
|
||||
function stopAll(signal) {
|
||||
if (stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopping = true;
|
||||
for (const child of children.values()) {
|
||||
killChild(child, signal);
|
||||
}
|
||||
|
||||
const forceKill = setTimeout(() => {
|
||||
for (const child of children.values()) {
|
||||
killChild(child, "SIGKILL");
|
||||
}
|
||||
}, 2500);
|
||||
forceKill.unref();
|
||||
|
||||
const finish = setInterval(() => {
|
||||
if (children.size === 0) {
|
||||
clearInterval(finish);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
async function waitForPort(port, host = "127.0.0.1", timeoutMs = 60_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await canConnect(port, host)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${host}:${port}`);
|
||||
}
|
||||
|
||||
function canConnect(port, host) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ port, host });
|
||||
socket.setTimeout(1000);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => stopAll("SIGTERM"));
|
||||
process.on("SIGTERM", () => stopAll("SIGTERM"));
|
||||
|
||||
spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], {
|
||||
cwd: appDir,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...colorEnv,
|
||||
BROWSER: "none",
|
||||
APP_VARIANT: "development",
|
||||
PASEO_WEB_PLATFORM: "electron",
|
||||
EXPO_PUBLIC_LOCAL_DAEMON: daemonEndpoint,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForPort(expoPort);
|
||||
} catch (error) {
|
||||
console.error(`[dev] ${error.message}`);
|
||||
exitCode = 1;
|
||||
stopAll("SIGTERM");
|
||||
}
|
||||
|
||||
if (!stopping) {
|
||||
spawnChild("electron", electron, [desktopDir], {
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...colorEnv,
|
||||
EXPO_DEV_URL: expoDevUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,88 +3,41 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
APP_DIR="$(cd "$DESKTOP_DIR/../app" && pwd)"
|
||||
ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)"
|
||||
|
||||
# Build the Electron main process
|
||||
npm run build:main
|
||||
source "$ROOT_DIR/scripts/dev-home.sh"
|
||||
|
||||
# Prefer Metro's stable default port so dev browser storage keeps the same
|
||||
# localhost origin across restarts. Fall back only when earlier ports are busy.
|
||||
EXPO_PORT=$(NO_COLOR=1 FORCE_COLOR=0 "$ROOT_DIR/node_modules/.bin/get-port" 8081 8082 8083 8084 8085)
|
||||
export PATH="$ROOT_DIR/node_modules/.bin:$PATH"
|
||||
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:6768}"
|
||||
configure_dev_paseo_home
|
||||
|
||||
DEV_ROOT="${PASEO_DEV_ROOT:-$(default_dev_paseo_root)}"
|
||||
export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_ROOT/.dev/user-data}"
|
||||
mkdir -p "$PASEO_ELECTRON_USER_DATA_DIR"
|
||||
|
||||
if [ -z "${EXPO_PORT:-}" ]; then
|
||||
EXPO_PORT=$(NO_COLOR=1 FORCE_COLOR=0 "$ROOT_DIR/node_modules/.bin/get-port" 8082 8083 8084 8085 8086 8087 8088 8089)
|
||||
fi
|
||||
export EXPO_PORT
|
||||
export EXPO_DEV_URL="http://localhost:${EXPO_PORT}"
|
||||
|
||||
DAEMON_ENDPOINT="$(resolve_dev_daemon_endpoint)"
|
||||
export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT"
|
||||
|
||||
REMOTE_DEBUGGING_PORT="${PASEO_ELECTRON_REMOTE_DEBUGGING_PORT:-9223}"
|
||||
export PASEO_ELECTRON_FLAGS="${PASEO_ELECTRON_FLAGS:+$PASEO_ELECTRON_FLAGS }--remote-debugging-port=$REMOTE_DEBUGGING_PORT"
|
||||
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
|
||||
|
||||
# Allow any origin in dev so Electron on random localhost ports can reach
|
||||
# the daemon websocket. Safe here because this script is development-only
|
||||
# and the daemon still binds to localhost.
|
||||
export PASEO_CORS_ORIGINS="*"
|
||||
|
||||
# Fully isolate the dev instance from a production Paseo install so `npm run dev`
|
||||
# works while the installed app is open. Without this the dev build (a) loses the
|
||||
# Electron single-instance lock to the installed app and quits, and (b) ends up
|
||||
# pointed at the production daemon, whose CORS allowlist rejects the Metro origin.
|
||||
# PASEO_HOME defaults to a script-managed dev home. If you override it (to point
|
||||
# dev at real data), we DON'T touch your config.json — only the managed home gets
|
||||
# its daemon config seeded below, so we never rewrite a production ~/.paseo config.
|
||||
# - PASEO_ELECTRON_USER_DATA_DIR: a separate Electron profile → separate
|
||||
# single-instance lock, so dev and the installed app coexist.
|
||||
# - PASEO_LISTEN: a distinct port so the dev daemon never collides with prod's 6767.
|
||||
DEV_STATE_DIR="$DESKTOP_DIR/.dev"
|
||||
if [ -n "${PASEO_HOME:-}" ]; then
|
||||
PASEO_HOME_MANAGED=0
|
||||
else
|
||||
PASEO_HOME="$DEV_STATE_DIR/paseo-home"
|
||||
PASEO_HOME_MANAGED=1
|
||||
fi
|
||||
export PASEO_HOME
|
||||
export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_STATE_DIR/user-data}"
|
||||
mkdir -p "$PASEO_HOME" "$PASEO_ELECTRON_USER_DATA_DIR"
|
||||
|
||||
DEV_DAEMON_PORT="${PASEO_DEV_DAEMON_PORT:-6788}"
|
||||
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:$DEV_DAEMON_PORT}"
|
||||
|
||||
# Seed the isolated daemon config. The desktop daemon-manager decides whether a
|
||||
# daemon is already running by reading `daemon.listen` from this config.json
|
||||
# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without
|
||||
# this it reads the default 6767, finds a production daemon there, and connects
|
||||
# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin
|
||||
# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon.
|
||||
# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME
|
||||
# (that could clobber a production config.json with the dev port + wildcard CORS).
|
||||
if [ "$PASEO_HOME_MANAGED" = "1" ]; then
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [path, port] = [process.argv[1], process.argv[2]];
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
|
||||
cfg.version = cfg.version || 1;
|
||||
cfg.daemon = cfg.daemon || {};
|
||||
cfg.daemon.listen = `127.0.0.1:${port}`;
|
||||
cfg.daemon.cors = cfg.daemon.cors || {};
|
||||
cfg.daemon.cors.allowedOrigins = ["*"];
|
||||
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
|
||||
' "$PASEO_HOME/config.json" "$DEV_DAEMON_PORT"
|
||||
else
|
||||
echo " (custom PASEO_HOME — leaving its config.json untouched)"
|
||||
fi
|
||||
npm run build:main
|
||||
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Paseo Desktop Dev"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Metro: http://localhost:${EXPO_PORT}"
|
||||
echo " Metro: ${EXPO_DEV_URL}"
|
||||
echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}"
|
||||
echo " Daemon: ${PASEO_LISTEN} (isolated)"
|
||||
echo " PASEO_HOME: ${PASEO_HOME}"
|
||||
echo " Daemon: ${PASEO_LISTEN}"
|
||||
echo " Home: ${PASEO_HOME}"
|
||||
echo " userData: ${PASEO_ELECTRON_USER_DATA_DIR}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
# Launch Metro + Electron together, kill both on exit
|
||||
exec "$ROOT_DIR/node_modules/.bin/concurrently" \
|
||||
--kill-others \
|
||||
--names "metro,electron" \
|
||||
--prefix-colors "magenta,cyan" \
|
||||
"cd '$APP_DIR' && PASEO_WEB_PLATFORM=electron npx expo start --port $EXPO_PORT" \
|
||||
"$ROOT_DIR/node_modules/.bin/wait-on tcp:$EXPO_PORT && EXPO_DEV_URL=http://localhost:$EXPO_PORT electron '$DESKTOP_DIR'"
|
||||
exec node "$SCRIPT_DIR/dev-runner.mjs"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
|
||||
import { createDaemonCommandHandlers } from "./daemon-manager";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home",
|
||||
settings: {
|
||||
releaseChannel: "stable",
|
||||
daemon: {
|
||||
@@ -21,7 +23,7 @@ vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => "/tmp/paseo-user-data"),
|
||||
getVersion: vi.fn(() => "1.2.3"),
|
||||
isPackaged: false,
|
||||
isPackaged: true,
|
||||
},
|
||||
ipcMain: { handle: vi.fn() },
|
||||
powerMonitor: { getSystemIdleTime: vi.fn(() => 0) },
|
||||
@@ -32,7 +34,7 @@ vi.mock("electron-log/main", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@getpaseo/server", () => ({
|
||||
resolvePaseoHome: vi.fn(() => "/tmp/paseo-home"),
|
||||
resolvePaseoHome: vi.fn(() => mocks.paseoHome),
|
||||
spawnProcess: mocks.spawnProcess,
|
||||
}));
|
||||
|
||||
@@ -72,8 +74,6 @@ function desktopSettingsWithManagement(enabled: boolean) {
|
||||
}
|
||||
|
||||
type MockChildProcess = EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
pid: number;
|
||||
spawnfile: string;
|
||||
spawnargs: string[];
|
||||
@@ -82,8 +82,6 @@ type MockChildProcess = EventEmitter & {
|
||||
|
||||
function createMockChildProcess(): MockChildProcess {
|
||||
const child = new EventEmitter() as MockChildProcess;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.pid = 1234;
|
||||
child.spawnfile = "node";
|
||||
child.spawnargs = ["node", "daemon.js"];
|
||||
@@ -91,10 +89,8 @@ function createMockChildProcess(): MockChildProcess {
|
||||
return child;
|
||||
}
|
||||
|
||||
function scheduleFailedStartupOutput(child: MockChildProcess): void {
|
||||
function scheduleFailedStartup(child: MockChildProcess): void {
|
||||
setImmediate(() => {
|
||||
child.stdout.emit("data", Buffer.from(`${"x".repeat(80_000)}stdout-tail`));
|
||||
child.stderr.emit("data", Buffer.from(`${"y".repeat(80_000)}stderr-tail`));
|
||||
child.emit("exit", 1, null);
|
||||
});
|
||||
}
|
||||
@@ -105,6 +101,11 @@ describe("daemon-manager commands", () => {
|
||||
mocks.runExternalCliJsonCommand.mockReset();
|
||||
mocks.runExternalCliTextCommand.mockReset();
|
||||
mocks.spawnProcess.mockReset();
|
||||
rmSync(mocks.paseoHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(mocks.paseoHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("refuses start and restart while built-in daemon management is disabled", async () => {
|
||||
@@ -136,7 +137,7 @@ describe("daemon-manager commands", () => {
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "/tmp/paseo-home",
|
||||
home: mocks.paseoHome,
|
||||
version: null,
|
||||
desktopManaged: false,
|
||||
error: null,
|
||||
@@ -167,7 +168,7 @@ describe("daemon-manager commands", () => {
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "/tmp/paseo-home",
|
||||
home: mocks.paseoHome,
|
||||
version: null,
|
||||
desktopManaged: false,
|
||||
error: null,
|
||||
@@ -195,7 +196,50 @@ describe("daemon-manager commands", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses a reachable daemon when the PID file is stale", async () => {
|
||||
it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => {
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stale_pid",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-1",
|
||||
pid: 7675,
|
||||
listen: "127.0.0.1:6767",
|
||||
daemonVersion: "1.2.2",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped" })
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
connectedDaemon: "unreachable",
|
||||
serverId: "",
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await expect(handlers.stop_desktop_daemon()).resolves.toEqual({
|
||||
serverId: "",
|
||||
status: "stopped",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: mocks.paseoHome,
|
||||
version: null,
|
||||
desktopManaged: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [
|
||||
"daemon",
|
||||
"stop",
|
||||
"--json",
|
||||
"--timeout",
|
||||
"5",
|
||||
"--force",
|
||||
"--kill-timeout",
|
||||
"5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses a stale reachable desktop daemon when the version matches", async () => {
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "stale_pid",
|
||||
connectedDaemon: "reachable",
|
||||
@@ -203,7 +247,7 @@ describe("daemon-manager commands", () => {
|
||||
pid: 7675,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
daemonVersion: "1.2.2",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: true,
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
@@ -214,16 +258,86 @@ describe("daemon-manager commands", () => {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
pid: null,
|
||||
home: "/tmp/paseo-home",
|
||||
version: "1.2.2",
|
||||
desktopManaged: false,
|
||||
home: mocks.paseoHome,
|
||||
version: "1.2.3",
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(mocks.spawnProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds captured daemon startup output", async () => {
|
||||
it("restarts a stale reachable desktop daemon when the version differs", async () => {
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stale_pid",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-1",
|
||||
pid: 7675,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
daemonVersion: "1.2.2",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stale_pid",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-1",
|
||||
pid: 7675,
|
||||
listen: "127.0.0.1:6767",
|
||||
daemonVersion: "1.2.2",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped" })
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
connectedDaemon: "unreachable",
|
||||
serverId: "",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "running",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-2",
|
||||
pid: 8888,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: true,
|
||||
});
|
||||
mocks.spawnProcess.mockReturnValue(createMockChildProcess());
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await expect(handlers.start_desktop_daemon()).resolves.toEqual({
|
||||
serverId: "server-2",
|
||||
status: "running",
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
pid: 8888,
|
||||
home: mocks.paseoHome,
|
||||
version: "1.2.3",
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [
|
||||
"daemon",
|
||||
"stop",
|
||||
"--json",
|
||||
"--timeout",
|
||||
"5",
|
||||
"--force",
|
||||
"--kill-timeout",
|
||||
"5",
|
||||
]);
|
||||
expect(mocks.spawnProcess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the managed daemon detached from desktop stdio and reports daemon log failures", async () => {
|
||||
mkdirSync(mocks.paseoHome, { recursive: true });
|
||||
writeFileSync(
|
||||
`${mocks.paseoHome}/daemon.log`,
|
||||
["old log line", "recent daemon failure"].join("\n"),
|
||||
);
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "stopped",
|
||||
connectedDaemon: "unreachable",
|
||||
@@ -231,7 +345,7 @@ describe("daemon-manager commands", () => {
|
||||
});
|
||||
mocks.spawnProcess.mockImplementation(() => {
|
||||
const child = createMockChildProcess();
|
||||
scheduleFailedStartupOutput(child);
|
||||
scheduleFailedStartup(child);
|
||||
return child;
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
@@ -246,9 +360,15 @@ describe("daemon-manager commands", () => {
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
const message = thrown?.message ?? "";
|
||||
expect(message).toContain("Daemon failed to start: exit code 1");
|
||||
expect(message).toContain("output truncated to the last 65536 chars");
|
||||
expect(message).toContain("stdout-tail");
|
||||
expect(message).toContain("stderr-tail");
|
||||
expect(message.length).toBeLessThan(150_000);
|
||||
expect(message).toContain(`Recent logs (${mocks.paseoHome}/daemon.log):`);
|
||||
expect(message).toContain("recent daemon failure");
|
||||
expect(mocks.spawnProcess).toHaveBeenCalledWith(
|
||||
"node",
|
||||
[],
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,6 @@ const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const STARTUP_POLL_INTERVAL_MS = 200;
|
||||
const STARTUP_POLL_MAX_ATTEMPTS = 150;
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||
const STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS = 64 * 1024;
|
||||
|
||||
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||
|
||||
@@ -70,11 +69,6 @@ interface DesktopPairingOffer {
|
||||
qr: string | null;
|
||||
}
|
||||
|
||||
interface StartupOutputCapture {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function parseReleaseChannel(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): AppReleaseChannel | undefined {
|
||||
@@ -151,30 +145,6 @@ function tailFile(filePath: string, lines = 50): string {
|
||||
}
|
||||
}
|
||||
|
||||
function createStartupOutputCapture(): StartupOutputCapture {
|
||||
return { text: "", truncated: false };
|
||||
}
|
||||
|
||||
function appendStartupOutput(capture: StartupOutputCapture, chunk: Buffer): StartupOutputCapture {
|
||||
const nextText = capture.text + chunk.toString();
|
||||
if (nextText.length <= STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS) {
|
||||
return { text: nextText, truncated: capture.truncated };
|
||||
}
|
||||
|
||||
return {
|
||||
text: nextText.slice(-STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS),
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
function formatStartupOutput(capture: StartupOutputCapture): string {
|
||||
if (!capture.truncated) {
|
||||
return capture.text;
|
||||
}
|
||||
|
||||
return `[output truncated to the last ${STARTUP_OUTPUT_CAPTURE_LIMIT_CHARS} chars]\n${capture.text}`;
|
||||
}
|
||||
|
||||
function logDesktopDaemonLifecycle(message: string, details?: Record<string, unknown>): void {
|
||||
log.info("[desktop daemon]", message, {
|
||||
pid: process.pid,
|
||||
@@ -231,6 +201,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
||||
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
|
||||
const hasRunningLocalProcess = localDaemon === "running";
|
||||
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
|
||||
const desktopManaged = payload.desktopManaged === true;
|
||||
const apiReachable = connectedDaemon === "reachable";
|
||||
let status: DesktopDaemonState = "stopped";
|
||||
if (apiReachable || hasRunningLocalProcess) {
|
||||
@@ -248,7 +219,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
||||
pid: hasLocalProcess && typeof payload.pid === "number" ? payload.pid : null,
|
||||
home,
|
||||
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
|
||||
desktopManaged: hasRunningLocalProcess && payload.desktopManaged === true,
|
||||
desktopManaged,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -287,19 +258,15 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void {
|
||||
}
|
||||
}
|
||||
|
||||
function buildStartupFailureError(
|
||||
result: { code: number | null; signal: string | null; error?: Error },
|
||||
stdout: StartupOutputCapture,
|
||||
stderr: StartupOutputCapture,
|
||||
): Error {
|
||||
function buildStartupFailureError(result: {
|
||||
code: number | null;
|
||||
signal: string | null;
|
||||
error?: Error;
|
||||
}): Error {
|
||||
const reason = result.error
|
||||
? result.error.message
|
||||
: `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`;
|
||||
const parts = [`Daemon failed to start: ${reason}`];
|
||||
const formattedStderr = formatStartupOutput(stderr).trim();
|
||||
const formattedStdout = formatStartupOutput(stdout).trim();
|
||||
if (formattedStderr) parts.push(`stderr:\n${formattedStderr}`);
|
||||
if (formattedStdout) parts.push(`stdout:\n${formattedStdout}`);
|
||||
const logs = tailFile(logFilePath(), 15);
|
||||
if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`);
|
||||
return new Error(parts.join("\n\n"));
|
||||
@@ -377,16 +344,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
envMode: "internal",
|
||||
env: invocation.env,
|
||||
envOverlay: { PASEO_DESKTOP_MANAGED: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = createStartupOutputCapture();
|
||||
let stderr = createStartupOutputCapture();
|
||||
child.stdout!.on("data", (data: Buffer) => {
|
||||
stdout = appendStartupOutput(stdout, data);
|
||||
});
|
||||
child.stderr!.on("data", (data: Buffer) => {
|
||||
stderr = appendStartupOutput(stderr, data);
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
});
|
||||
|
||||
logDesktopDaemonLifecycle("detached spawn returned", {
|
||||
@@ -424,8 +382,6 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
logDesktopDaemonLifecycle("detached startup grace period completed", {
|
||||
childPid: child.pid ?? null,
|
||||
exitedEarly: result.exitedEarly,
|
||||
stdout: formatStartupOutput(stdout).slice(0, 2000),
|
||||
stderr: formatStartupOutput(stderr).slice(0, 2000),
|
||||
...(result.exitedEarly
|
||||
? {
|
||||
exitCode: result.code,
|
||||
@@ -436,7 +392,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
});
|
||||
|
||||
if (result.exitedEarly) {
|
||||
throw buildStartupFailureError(result, stdout, stderr);
|
||||
throw buildStartupFailureError(result);
|
||||
}
|
||||
|
||||
return pollForRunningDaemon();
|
||||
@@ -444,7 +400,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
|
||||
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
const status = await resolveDesktopDaemonStatus();
|
||||
if (status.status !== "running" || !status.pid) return status;
|
||||
if (status.status !== "running") return status;
|
||||
|
||||
await stopDesktopDaemonViaCli();
|
||||
return await resolveDesktopDaemonStatus();
|
||||
|
||||
+47
@@ -1,8 +1,34 @@
|
||||
import { webContents as allWebContents, type WebContents } from "electron";
|
||||
import {
|
||||
BROWSER_NEW_TAB_REQUEST_EVENT,
|
||||
handleBrowserWindowOpenRequest,
|
||||
isAllowedBrowserWebviewUrl,
|
||||
} from "./window-open.js";
|
||||
|
||||
export { BROWSER_NEW_TAB_REQUEST_EVENT, handleBrowserWindowOpenRequest };
|
||||
|
||||
const browserIdsByWebContentsId = new Map<number, string>();
|
||||
let workspaceActiveBrowserId: string | null = null;
|
||||
|
||||
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
|
||||
const prefix = "persist:paseo-browser-";
|
||||
if (!partition?.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const browserId = partition.slice(prefix.length).trim();
|
||||
return browserId.length > 0 ? browserId : null;
|
||||
}
|
||||
|
||||
export function readBrowserIdFromWebviewAttach(input: {
|
||||
src?: string;
|
||||
partition?: string;
|
||||
}): string | null {
|
||||
if (!isAllowedBrowserWebviewUrl(input.src)) {
|
||||
return null;
|
||||
}
|
||||
return getBrowserIdFromWebviewPartition(input.partition);
|
||||
}
|
||||
|
||||
export function listRegisteredPaseoBrowserIds(): string[] {
|
||||
return Array.from(new Set(browserIdsByWebContentsId.values())).sort();
|
||||
}
|
||||
@@ -45,3 +71,24 @@ export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null
|
||||
}
|
||||
return getPaseoBrowserWebContents(workspaceActiveBrowserId);
|
||||
}
|
||||
|
||||
function preventUnsafeBrowserWebviewNavigation(
|
||||
event: { preventDefault: () => void },
|
||||
url: string | undefined,
|
||||
): void {
|
||||
if (!isAllowedBrowserWebviewUrl(url)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerBrowserWebviewNavigationGuards(contents: WebContents): void {
|
||||
contents.on("will-navigate", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
contents.on("will-frame-navigate", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
contents.on("will-redirect", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { handleBrowserWindowOpenRequest } from ".";
|
||||
|
||||
describe("browser webview window-open requests", () => {
|
||||
it("denies Electron window creation and requests a Paseo browser tab", () => {
|
||||
const requestNewTab = vi.fn();
|
||||
|
||||
const result = handleBrowserWindowOpenRequest({
|
||||
url: "https://example.com/target",
|
||||
sourceBrowserId: "browser-1",
|
||||
requestNewTab,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ action: "deny" });
|
||||
expect(requestNewTab).toHaveBeenCalledWith({
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "https://example.com/target",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies unsupported window-open requests before asking for a Paseo browser tab", () => {
|
||||
const requestNewTab = vi.fn();
|
||||
|
||||
const result = handleBrowserWindowOpenRequest({
|
||||
url: "file:///etc/passwd",
|
||||
sourceBrowserId: "browser-1",
|
||||
requestNewTab,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ action: "deny" });
|
||||
expect(requestNewTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export const BROWSER_NEW_TAB_REQUEST_EVENT = "paseo:event:browser-new-tab-request";
|
||||
|
||||
export interface BrowserNewTabRequestPayload {
|
||||
sourceBrowserId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function isAllowedBrowserWebviewUrl(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (
|
||||
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleBrowserWindowOpenRequest(input: {
|
||||
url: string;
|
||||
sourceBrowserId: string | null;
|
||||
requestNewTab: (payload: BrowserNewTabRequestPayload) => void;
|
||||
}): { action: "deny" } {
|
||||
if (!isAllowedBrowserWebviewUrl(input.url) || !input.sourceBrowserId) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
|
||||
input.requestNewTab({
|
||||
sourceBrowserId: input.sourceBrowserId,
|
||||
url: input.url,
|
||||
});
|
||||
return { action: "deny" };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { app, Menu, BrowserWindow, ipcMain } from "electron";
|
||||
import { getWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews.js";
|
||||
import { getWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews/index.js";
|
||||
|
||||
interface ShowContextMenuInput {
|
||||
kind?: "terminal";
|
||||
|
||||
@@ -46,12 +46,16 @@ import { registerOpenerHandlers } from "./features/opener.js";
|
||||
import { registerEditorTargetHandlers } from "./features/editor-targets.js";
|
||||
import { setupApplicationMenu } from "./features/menu.js";
|
||||
import {
|
||||
BROWSER_NEW_TAB_REQUEST_EVENT,
|
||||
getPaseoBrowserIdForWebContents,
|
||||
getPaseoBrowserWebContents,
|
||||
handleBrowserWindowOpenRequest,
|
||||
listRegisteredPaseoBrowserIds,
|
||||
readBrowserIdFromWebviewAttach,
|
||||
registerBrowserWebviewNavigationGuards,
|
||||
registerPaseoBrowserWebContents,
|
||||
setWorkspaceActivePaseoBrowserId,
|
||||
} from "./features/browser-webviews.js";
|
||||
} from "./features/browser-webviews/index.js";
|
||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||
import { PendingOpenProjectStore } from "./pending-open-project-store.js";
|
||||
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
||||
@@ -73,28 +77,6 @@ const PASEO_DEBUG = process.env.PASEO_DEBUG === "1";
|
||||
const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1";
|
||||
const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo";
|
||||
|
||||
function isAllowedBrowserWebviewUrl(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (
|
||||
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function preventUnsafeBrowserWebviewNavigation(
|
||||
event: Electron.Event,
|
||||
url: string | undefined,
|
||||
): void {
|
||||
if (!isAllowedBrowserWebviewUrl(url)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
|
||||
const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key";
|
||||
|
||||
@@ -127,15 +109,6 @@ const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE";
|
||||
const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop";
|
||||
app.setName(APP_NAME);
|
||||
|
||||
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
|
||||
const prefix = "persist:paseo-browser-";
|
||||
if (!partition?.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const browserId = partition.slice(prefix.length).trim();
|
||||
return browserId.length > 0 ? browserId : null;
|
||||
}
|
||||
|
||||
const pendingBrowserWebviewIds: string[] = [];
|
||||
|
||||
function isBrowserRefreshInput(input: Electron.Input): boolean {
|
||||
@@ -470,11 +443,7 @@ async function createWindow(
|
||||
setupDefaultContextMenu(mainWindow);
|
||||
setupDragDropPrevention(mainWindow);
|
||||
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
|
||||
if (!isAllowedBrowserWebviewUrl(params.src)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const browserId = getBrowserIdFromWebviewPartition(params.partition);
|
||||
const browserId = readBrowserIdFromWebviewAttach(params);
|
||||
if (!browserId) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -534,25 +503,19 @@ async function createWindow(
|
||||
});
|
||||
}
|
||||
});
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
if (!isAllowedBrowserWebviewUrl(url)) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
contents.loadURL(url).catch(() => undefined);
|
||||
return { action: "deny" };
|
||||
});
|
||||
contents.setWindowOpenHandler(({ url }) =>
|
||||
handleBrowserWindowOpenRequest({
|
||||
url,
|
||||
sourceBrowserId: getPaseoBrowserIdForWebContents(contents),
|
||||
requestNewTab: (payload) => {
|
||||
mainWindow.webContents.send(BROWSER_NEW_TAB_REQUEST_EVENT, payload);
|
||||
},
|
||||
}),
|
||||
);
|
||||
contents.on("context-menu", (_contextMenuEvent, params) => {
|
||||
showBrowserWebviewContextMenu(mainWindow, contents, params);
|
||||
});
|
||||
contents.on("will-navigate", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
contents.on("will-frame-navigate", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
contents.on("will-redirect", (event) => {
|
||||
preventUnsafeBrowserWebviewNavigation(event, event.url);
|
||||
});
|
||||
registerBrowserWebviewNavigationGuards(contents);
|
||||
});
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
decodeBinaryFrame,
|
||||
encodeFileTransferFrame,
|
||||
encodeTerminalStreamFrame,
|
||||
FileTransferOpcode,
|
||||
TerminalStreamOpcode,
|
||||
} from "./index.js";
|
||||
|
||||
describe("binary frame demux", () => {
|
||||
it("routes terminal frames by opcode", () => {
|
||||
expect(
|
||||
decodeBinaryFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
slot: 7,
|
||||
payload: "ls",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
kind: "terminal",
|
||||
frame: {
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
slot: 7,
|
||||
payload: new TextEncoder().encode("ls"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("routes file-transfer frames by opcode", () => {
|
||||
expect(
|
||||
decodeBinaryFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: "req-upload",
|
||||
payload: new TextEncoder().encode("hello"),
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
kind: "file_transfer",
|
||||
frame: {
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: "req-upload",
|
||||
payload: new TextEncoder().encode("hello"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown binary opcodes", () => {
|
||||
expect(decodeBinaryFrame(new Uint8Array([0xff, 0]))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
decodeFileTransferFrame,
|
||||
FileTransferOpcode,
|
||||
type FileTransferFrame,
|
||||
} from "./file-transfer.js";
|
||||
import {
|
||||
decodeTerminalStreamFrame,
|
||||
TerminalStreamOpcode,
|
||||
type TerminalStreamFrame,
|
||||
} from "./terminal.js";
|
||||
|
||||
export type BinaryFrame =
|
||||
| { kind: "terminal"; frame: TerminalStreamFrame }
|
||||
| { kind: "file_transfer"; frame: FileTransferFrame };
|
||||
|
||||
export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
|
||||
switch (bytes[0]) {
|
||||
case TerminalStreamOpcode.Output:
|
||||
case TerminalStreamOpcode.Input:
|
||||
case TerminalStreamOpcode.Resize:
|
||||
case TerminalStreamOpcode.Snapshot:
|
||||
case TerminalStreamOpcode.Restore: {
|
||||
const frame = decodeTerminalStreamFrame(bytes);
|
||||
return frame ? { kind: "terminal", frame } : null;
|
||||
}
|
||||
case FileTransferOpcode.FileBegin:
|
||||
case FileTransferOpcode.FileChunk:
|
||||
case FileTransferOpcode.FileEnd: {
|
||||
const frame = decodeFileTransferFrame(bytes);
|
||||
return frame ? { kind: "file_transfer", frame } : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const FileBeginMetadataSchema = z.object({
|
||||
size: z.number().int().nonnegative(),
|
||||
encoding: z.enum(["utf-8", "binary"]),
|
||||
modifiedAt: z.string(),
|
||||
fileName: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface FileBegin {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./demux.js";
|
||||
export * from "./file-transfer.js";
|
||||
export * from "./terminal.js";
|
||||
|
||||
@@ -839,11 +839,21 @@ export const ReviewAttachmentSchema = z.object({
|
||||
comments: z.array(ReviewAttachmentCommentSchema),
|
||||
});
|
||||
|
||||
export const UploadedFileAttachmentSchema = z.object({
|
||||
type: z.literal("uploaded_file"),
|
||||
id: z.string(),
|
||||
fileName: z.string(),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int().nonnegative(),
|
||||
path: z.string(),
|
||||
});
|
||||
|
||||
export const AgentAttachmentSchema = z.discriminatedUnion("type", [
|
||||
GitHubPrAttachmentSchema,
|
||||
GitHubIssueAttachmentSchema,
|
||||
TextAttachmentSchema,
|
||||
ReviewAttachmentSchema,
|
||||
UploadedFileAttachmentSchema,
|
||||
]);
|
||||
|
||||
function normalizeAgentAttachments(input: unknown): AgentAttachment[] {
|
||||
@@ -1680,6 +1690,15 @@ export const FileDownloadTokenRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const FileUploadRequestSchema = z.object({
|
||||
type: z.literal("file.upload.request"),
|
||||
fileName: z.string().min(1),
|
||||
mimeType: z.string().min(1),
|
||||
size: z.number().int().nonnegative(),
|
||||
modifiedAt: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ClearAgentAttentionMessageSchema = z.object({
|
||||
type: z.literal("clear_agent_attention"),
|
||||
agentId: z.union([z.string(), z.array(z.string())]),
|
||||
@@ -1911,6 +1930,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FileExplorerRequestSchema,
|
||||
ProjectIconRequestSchema,
|
||||
FileDownloadTokenRequestSchema,
|
||||
FileUploadRequestSchema,
|
||||
ClearAgentAttentionMessageSchema,
|
||||
ClientHeartbeatMessageSchema,
|
||||
PingMessageSchema,
|
||||
@@ -3436,6 +3456,15 @@ export const FileDownloadTokenResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const FileUploadResponseSchema = z.object({
|
||||
type: z.literal("file.upload.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
file: UploadedFileAttachmentSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ListProviderModelsResponseMessageSchema = z.object({
|
||||
type: z.literal("list_provider_models_response"),
|
||||
payload: z.object({
|
||||
@@ -3756,6 +3785,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FileExplorerResponseSchema,
|
||||
ProjectIconResponseSchema,
|
||||
FileDownloadTokenResponseSchema,
|
||||
FileUploadResponseSchema,
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
ListProviderModesResponseMessageSchema,
|
||||
ListProviderFeaturesResponseMessageSchema,
|
||||
@@ -4056,6 +4086,8 @@ export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
|
||||
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
|
||||
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
|
||||
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
|
||||
export type FileUploadRequest = z.infer<typeof FileUploadRequestSchema>;
|
||||
export type FileUploadResponse = z.infer<typeof FileUploadResponseSchema>;
|
||||
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
|
||||
export type ShutdownServerRequestMessage = z.infer<typeof ShutdownServerRequestMessageSchema>;
|
||||
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
|
||||
|
||||
@@ -24,6 +24,10 @@ type WorkerLifecycleMessage =
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
interface SupervisorHeartbeatMessage {
|
||||
type: "paseo:supervisor-heartbeat";
|
||||
}
|
||||
|
||||
interface SupervisorOptions {
|
||||
name: string;
|
||||
startupMessage: string;
|
||||
@@ -183,6 +187,15 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
});
|
||||
}
|
||||
|
||||
const currentChild = child;
|
||||
const heartbeat = setInterval(() => {
|
||||
const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" };
|
||||
if (currentChild.connected) {
|
||||
currentChild.send?.(message, () => undefined);
|
||||
}
|
||||
}, 1000);
|
||||
heartbeat.unref();
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
process.stdout.write(chunk);
|
||||
writeDurableChunk(chunk);
|
||||
@@ -224,6 +237,7 @@ export function runSupervisor(options: SupervisorOptions): void {
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
clearInterval(heartbeat);
|
||||
const exitDescriptor = describeExit(code, signal);
|
||||
writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor });
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { PaseoToolingRuntime } from "./paseo-tooling/runtime.js";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
@@ -522,7 +523,20 @@ test("createAgent injects daemon append system prompt at runtime only", async ()
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const client = new TestAgentClient();
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastLaunchContext: AgentLaunchContext | null = null;
|
||||
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastLaunchContext = launchContext ?? null;
|
||||
return super.resumeSession(handle, config, launchContext);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
@@ -542,7 +556,7 @@ test("createAgent injects daemon append system prompt at runtime only", async ()
|
||||
|
||||
expect(client.createdConfigs[0]?.systemPrompt).toBe("Agent instructions.");
|
||||
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions.");
|
||||
expect(snapshot.config.daemonAppendSystemPrompt).toBe("Daemon instructions.");
|
||||
expect(snapshot.config).not.toHaveProperty("daemonAppendSystemPrompt");
|
||||
expect(record?.config?.systemPrompt).toBe("Agent instructions.");
|
||||
expect(record?.config).not.toHaveProperty("daemonAppendSystemPrompt");
|
||||
});
|
||||
@@ -551,7 +565,20 @@ test("daemon append system prompt is injected into Pi configs", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const client = new TestAgentClient();
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastLaunchContext: AgentLaunchContext | null = null;
|
||||
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastLaunchContext = launchContext ?? null;
|
||||
return super.resumeSession(handle, config, launchContext);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
pi: client as unknown as AgentClient,
|
||||
@@ -948,28 +975,38 @@ test("createAgent passes persistSession to provider create options", async () =>
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => {
|
||||
test("createAgent passes internal paseo tooling only through runtime launch context", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastConfig: AgentSessionConfig | null = null;
|
||||
lastLaunchContext: AgentLaunchContext | null = null;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
override async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastConfig = config;
|
||||
this.lastLaunchContext = launchContext ?? null;
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const paseoToolingRuntime = new PaseoToolingRuntime();
|
||||
paseoToolingRuntime.setEndpoints({
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
httpBaseUrl: "http://127.0.0.1:6767",
|
||||
});
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
paseoToolingRuntime,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000103",
|
||||
});
|
||||
|
||||
@@ -985,16 +1022,134 @@ test("createAgent injects paseo MCP server when manager has an MCP base URL", as
|
||||
});
|
||||
|
||||
expect(snapshot.config.mcpServers).toEqual({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
|
||||
},
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
});
|
||||
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
|
||||
expect(client.lastConfig?.mcpServers).toEqual({
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
});
|
||||
expect(client.lastLaunchContext?.paseoTooling?.mcpUrl).toBe(
|
||||
`http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
|
||||
);
|
||||
expect(client.lastLaunchContext?.paseoTooling?.httpBaseUrl).toBe("http://127.0.0.1:6767");
|
||||
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.config?.mcpServers).toEqual({
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("resumeAgentFromPersistence strips stored internal paseo MCP and passes current runtime tooling", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
class CaptureClient extends TestAgentClient {
|
||||
lastLaunchContext: AgentLaunchContext | null = null;
|
||||
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastLaunchContext = launchContext ?? null;
|
||||
return super.resumeSession(handle, config, launchContext);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new CaptureClient();
|
||||
const paseoToolingRuntime = new PaseoToolingRuntime();
|
||||
paseoToolingRuntime.setEndpoints({
|
||||
mcpBaseUrl: "http://127.0.0.1:6768/mcp/agents",
|
||||
httpBaseUrl: "http://127.0.0.1:6768",
|
||||
});
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
paseoToolingRuntime,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000105",
|
||||
});
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "codex",
|
||||
sessionId: "session-123",
|
||||
metadata: {
|
||||
cwd: workdir,
|
||||
},
|
||||
};
|
||||
|
||||
const snapshot = await manager.resumeAgentFromPersistence(handle, {
|
||||
cwd: workdir,
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=stale-agent",
|
||||
},
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(client.resumeOverrides[0]?.mcpServers).toEqual({
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
});
|
||||
expect(client.lastLaunchContext?.paseoTooling?.mcpUrl).toBe(
|
||||
`http://127.0.0.1:6768/mcp/agents?callerAgentId=${snapshot.id}`,
|
||||
);
|
||||
expect(snapshot.config.mcpServers).toEqual({
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime injection is disabled", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const client = new TestAgentClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "codex",
|
||||
sessionId: "session-123",
|
||||
metadata: {
|
||||
cwd: workdir,
|
||||
},
|
||||
};
|
||||
|
||||
const snapshot = await manager.resumeAgentFromPersistence(handle, {
|
||||
cwd: workdir,
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=stale-agent",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(client.resumeOverrides[0]?.mcpServers).toBeUndefined();
|
||||
expect(snapshot.config.mcpServers).toBeUndefined();
|
||||
});
|
||||
|
||||
test("createAgent preserves a user-provided paseo MCP config", async () => {
|
||||
@@ -1018,7 +1173,6 @@ test("createAgent preserves a user-provided paseo MCP config", async () => {
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest
|
||||
import { IMPORTABLE_PROVIDERS } from "./provider-registry.js";
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
|
||||
import { stripInternalPaseoMcpServer } from "./runtime-mcp-config.js";
|
||||
import type { PaseoToolingRuntime } from "./paseo-tooling/runtime.js";
|
||||
|
||||
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
|
||||
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
||||
@@ -74,6 +76,11 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
|
||||
type TimeoutResult = "completed" | "timed_out";
|
||||
|
||||
interface PreparedSessionConfig {
|
||||
storedConfig: AgentSessionConfig;
|
||||
launchConfig: AgentSessionConfig;
|
||||
}
|
||||
|
||||
interface TimeoutOptions {
|
||||
operation: Promise<void>;
|
||||
timeoutMs: number;
|
||||
@@ -105,7 +112,7 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
config.systemPrompt = record.config.systemPrompt;
|
||||
}
|
||||
if (record.config.mcpServers != null) config.mcpServers = record.config.mcpServers;
|
||||
return config;
|
||||
return stripInternalPaseoMcpServer(config);
|
||||
}
|
||||
|
||||
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
|
||||
@@ -183,7 +190,7 @@ export interface AgentManagerOptions {
|
||||
onAgentAttention?: AgentAttentionCallback;
|
||||
durableTimelineStore?: AgentTimelineStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
mcpBaseUrl?: string;
|
||||
paseoToolingRuntime?: PaseoToolingRuntime;
|
||||
appendSystemPrompt?: string;
|
||||
agentStreamCoalesceWindowMs?: number;
|
||||
rescueTimeouts?: AgentManagerRescueTimeouts;
|
||||
@@ -426,7 +433,7 @@ export class AgentManager {
|
||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
private readonly paseoToolingRuntime: PaseoToolingRuntime | null;
|
||||
private appendSystemPrompt: string;
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private onAgentArchived?: AgentArchivedCallback;
|
||||
@@ -438,7 +445,7 @@ export class AgentManager {
|
||||
this.registry = options?.registry;
|
||||
this.durableTimelineStore = options?.durableTimelineStore;
|
||||
this.onAgentAttention = options?.onAgentAttention;
|
||||
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
||||
this.paseoToolingRuntime = options?.paseoToolingRuntime ?? null;
|
||||
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
|
||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||
this.rescueTimeouts = {
|
||||
@@ -494,10 +501,6 @@ export class AgentManager {
|
||||
this.onAgentArchived = callback;
|
||||
}
|
||||
|
||||
setMcpBaseUrl(url: string | null): void {
|
||||
this.mcpBaseUrl = url;
|
||||
}
|
||||
|
||||
setAppendSystemPrompt(prompt: string | null | undefined): void {
|
||||
this.appendSystemPrompt = prompt ?? "";
|
||||
}
|
||||
@@ -811,30 +814,15 @@ export class AgentManager {
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
const injectedConfig =
|
||||
this.mcpBaseUrl == null
|
||||
? config
|
||||
: {
|
||||
...config,
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http" as const,
|
||||
url: `${this.mcpBaseUrl}?callerAgentId=${resolvedAgentId}`,
|
||||
},
|
||||
...config.mcpServers,
|
||||
},
|
||||
};
|
||||
this.requireEnabledProvider(injectedConfig.provider);
|
||||
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
|
||||
await this.normalizeConfig(injectedConfig),
|
||||
);
|
||||
const { storedConfig, launchConfig } = await this.prepareSessionConfig(config, resolvedAgentId);
|
||||
this.requireEnabledProvider(storedConfig.provider);
|
||||
const launchContext = this.buildLaunchContext(resolvedAgentId, options?.env);
|
||||
const client = await this.requireAvailableClient({
|
||||
provider: normalizedConfig.provider,
|
||||
provider: storedConfig.provider,
|
||||
});
|
||||
const createOptions = this.buildCreateSessionOptions(options);
|
||||
const session = await client.createSession(normalizedConfig, launchContext, createOptions);
|
||||
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
|
||||
const session = await client.createSession(launchConfig, launchContext, createOptions);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
labels: options?.labels,
|
||||
workspaceId: options?.workspaceId,
|
||||
initialTitle: options?.initialTitle,
|
||||
@@ -872,26 +860,10 @@ export class AgentManager {
|
||||
...overrides,
|
||||
provider: handle.provider,
|
||||
} as AgentSessionConfig;
|
||||
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
|
||||
await this.normalizeConfig(mergedConfig),
|
||||
const { storedConfig, launchConfig } = await this.prepareSessionConfig(
|
||||
mergedConfig,
|
||||
resolvedAgentId,
|
||||
);
|
||||
const resumeOverrides: Partial<AgentSessionConfig> = { ...overrides };
|
||||
let hasResumeOverrides = overrides !== undefined;
|
||||
|
||||
if (normalizedConfig.model !== mergedConfig.model) {
|
||||
resumeOverrides.model = normalizedConfig.model;
|
||||
hasResumeOverrides = true;
|
||||
}
|
||||
|
||||
if (normalizedConfig.modeId !== mergedConfig.modeId) {
|
||||
resumeOverrides.modeId = normalizedConfig.modeId;
|
||||
hasResumeOverrides = true;
|
||||
}
|
||||
|
||||
if (metadata.daemonAppendSystemPrompt !== normalizedConfig.daemonAppendSystemPrompt) {
|
||||
resumeOverrides.daemonAppendSystemPrompt = normalizedConfig.daemonAppendSystemPrompt;
|
||||
hasResumeOverrides = true;
|
||||
}
|
||||
|
||||
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||
const client = this.requireClient(handle.provider);
|
||||
@@ -901,12 +873,8 @@ export class AgentManager {
|
||||
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
|
||||
);
|
||||
}
|
||||
const session = await client.resumeSession(
|
||||
handle,
|
||||
hasResumeOverrides ? resumeOverrides : undefined,
|
||||
launchContext,
|
||||
);
|
||||
return this.registerSession(session, normalizedConfig, resolvedAgentId, options);
|
||||
const session = await client.resumeSession(handle, launchConfig, launchContext);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, options);
|
||||
}
|
||||
|
||||
// Hot-reload an active agent session with config overrides. By default the
|
||||
@@ -938,14 +906,12 @@ export class AgentManager {
|
||||
...overrides,
|
||||
provider,
|
||||
} as AgentSessionConfig;
|
||||
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
|
||||
await this.normalizeConfig(refreshConfig),
|
||||
);
|
||||
const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId);
|
||||
const launchContext = this.buildLaunchContext(agentId);
|
||||
|
||||
const session = handle
|
||||
? await client.resumeSession(handle, normalizedConfig, launchContext)
|
||||
: await client.createSession(normalizedConfig, launchContext);
|
||||
? await client.resumeSession(handle, launchConfig, launchContext)
|
||||
: await client.createSession(launchConfig, launchContext);
|
||||
|
||||
this.agentStreamCoalescer.flushAndDiscard(agentId);
|
||||
// Remove the existing agent entry before swapping sessions
|
||||
@@ -966,7 +932,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
// Preserve existing labels and timeline during reload.
|
||||
return this.registerSession(session, normalizedConfig, agentId, {
|
||||
return this.registerSession(session, storedConfig, agentId, {
|
||||
labels: existing.labels,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
@@ -3489,6 +3455,15 @@ export class AgentManager {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async prepareSessionConfig(
|
||||
config: AgentSessionConfig,
|
||||
_agentId: string,
|
||||
): Promise<PreparedSessionConfig> {
|
||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config));
|
||||
const launchConfig = this.applyDaemonAppendSystemPrompt(storedConfig);
|
||||
return { storedConfig, launchConfig };
|
||||
}
|
||||
|
||||
private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig {
|
||||
const daemonAppendSystemPrompt = this.appendSystemPrompt.trim();
|
||||
const next = { ...config };
|
||||
@@ -3505,6 +3480,7 @@ export class AgentManager {
|
||||
private buildLaunchContext(agentId: string, env?: Record<string, string>): AgentLaunchContext {
|
||||
return {
|
||||
agentId,
|
||||
paseoTooling: this.paseoToolingRuntime?.createLaunchContext(agentId),
|
||||
env: {
|
||||
...env,
|
||||
PASEO_AGENT_ID: agentId,
|
||||
|
||||
@@ -546,9 +546,23 @@ export interface AgentSessionConfig {
|
||||
internal?: boolean;
|
||||
}
|
||||
|
||||
export interface PaseoToolingProviderSessionRef {
|
||||
provider: AgentProvider;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface PaseoToolingLaunchContext {
|
||||
agentId: string;
|
||||
mcpUrl: string | null;
|
||||
httpBaseUrl: string | null;
|
||||
token: string;
|
||||
bindProviderSession(ref: PaseoToolingProviderSessionRef): () => void;
|
||||
}
|
||||
|
||||
export interface AgentLaunchContext {
|
||||
agentId?: string;
|
||||
env?: Record<string, string>;
|
||||
paseoTooling?: PaseoToolingLaunchContext;
|
||||
}
|
||||
|
||||
export interface AgentCreateSessionOptions {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
import type express from "express";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentProvider } from "../agent-sdk-types.js";
|
||||
import type { PaseoToolRegistry } from "./registry.js";
|
||||
import type { PaseoToolingRuntime } from "./runtime.js";
|
||||
|
||||
const ToolExecuteBodySchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
sessionId: z.string().min(1),
|
||||
tool: z.string().min(1),
|
||||
input: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export interface MountPaseoToolingHttpAdapterOptions {
|
||||
app: express.Express;
|
||||
registry: PaseoToolRegistry;
|
||||
runtime: PaseoToolingRuntime;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export function mountPaseoToolingHttpAdapter(options: MountPaseoToolingHttpAdapterOptions): void {
|
||||
const { app, registry, runtime, logger } = options;
|
||||
const requireAuth: express.RequestHandler = (req, res, next) => {
|
||||
if (req.header("authorization") === `Bearer ${runtime.token}`) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
};
|
||||
|
||||
app.get("/api/paseo-tooling/manifest", requireAuth, (_req, res) => {
|
||||
res.json({ tools: registry.listManifest() });
|
||||
});
|
||||
|
||||
const runExecuteRequest = async (req: express.Request, res: express.Response): Promise<void> => {
|
||||
const parsed = ToolExecuteBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const callerAgentId = runtime.resolveProviderSession({
|
||||
provider: parsed.data.provider as AgentProvider,
|
||||
sessionId: parsed.data.sessionId,
|
||||
});
|
||||
if (!callerAgentId) {
|
||||
res.status(404).json({ error: "Provider session is not bound to a Paseo agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await registry.executeTool({
|
||||
name: parsed.data.tool,
|
||||
input: parsed.data.input ?? {},
|
||||
callerAgentId,
|
||||
});
|
||||
res.json({ output: formatPaseoToolResult(result) });
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{
|
||||
err,
|
||||
provider: parsed.data.provider,
|
||||
sessionId: parsed.data.sessionId,
|
||||
tool: parsed.data.tool,
|
||||
},
|
||||
"Paseo tooling HTTP execution failed",
|
||||
);
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : "Tool execution failed" });
|
||||
}
|
||||
};
|
||||
|
||||
app.post("/api/paseo-tooling/execute", requireAuth, (req, res) => {
|
||||
void runExecuteRequest(req, res);
|
||||
});
|
||||
}
|
||||
|
||||
function formatPaseoToolResult(result: unknown): unknown {
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
||||
return result;
|
||||
}
|
||||
const record = result as Record<string, unknown>;
|
||||
if (record.structuredContent !== undefined) {
|
||||
return record.structuredContent;
|
||||
}
|
||||
if (!Array.isArray(record.content)) {
|
||||
return result;
|
||||
}
|
||||
const text = record.content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== "object" || Array.isArray(part)) {
|
||||
return null;
|
||||
}
|
||||
const value = (part as Record<string, unknown>).text;
|
||||
return typeof value === "string" ? value : null;
|
||||
})
|
||||
.filter((part): part is string => part !== null)
|
||||
.join("\n");
|
||||
return text || record.content;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
|
||||
import {
|
||||
createPaseoToolRegistry,
|
||||
type AgentMcpServerOptions,
|
||||
type PaseoToolingRegistryOptions,
|
||||
} from "./registry.js";
|
||||
|
||||
export async function createAgentMcpServer(options: AgentMcpServerOptions): Promise<McpServer> {
|
||||
const server = new McpServer({
|
||||
name: "agent-mcp",
|
||||
version: "2.0.0",
|
||||
});
|
||||
const registry = createPaseoToolRegistry(options);
|
||||
for (const tool of registry.listTools()) {
|
||||
server.registerTool(tool.name, tool.config, tool.handler);
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
export type { AgentMcpServerOptions, PaseoToolingRegistryOptions };
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AgentLaunchContext, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
|
||||
const PASEO_MCP_SERVER_NAME = "paseo";
|
||||
|
||||
export function withPaseoToolingMcpServer(
|
||||
config: AgentSessionConfig,
|
||||
launchContext: AgentLaunchContext | undefined,
|
||||
): AgentSessionConfig {
|
||||
const mcpUrl = launchContext?.paseoTooling?.mcpUrl;
|
||||
if (!mcpUrl || config.mcpServers?.[PASEO_MCP_SERVER_NAME]) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
mcpServers: {
|
||||
[PASEO_MCP_SERVER_NAME]: {
|
||||
type: "http",
|
||||
url: mcpUrl,
|
||||
},
|
||||
...config.mcpServers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { createPaseoToolRegistry } from "./registry.js";
|
||||
|
||||
describe("Paseo tooling registry", () => {
|
||||
test("executes tools with runtime caller identity", async () => {
|
||||
const callerCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-tooling-caller-"));
|
||||
const requestedCwd = path.join(callerCwd, "child");
|
||||
const createdTerminals: Array<{ cwd: string; name?: string }> = [];
|
||||
const terminalManager = {
|
||||
createTerminal: async (input: { cwd: string; name?: string }) => {
|
||||
createdTerminals.push(input);
|
||||
return { id: "terminal-1", name: input.name ?? "Terminal", cwd: input.cwd };
|
||||
},
|
||||
};
|
||||
const agentManager = {
|
||||
getAgent: (agentId: string) =>
|
||||
agentId === "agent-1" ? { cwd: callerCwd, config: {} } : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const registry = createPaseoToolRegistry({
|
||||
agentManager: agentManager as never,
|
||||
agentStorage: {} as never,
|
||||
terminalManager: terminalManager as never,
|
||||
providerSnapshotManager: {} as never,
|
||||
agentScopedTools: true,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await registry.executeTool({
|
||||
name: "create_terminal",
|
||||
callerAgentId: "agent-1",
|
||||
input: {
|
||||
cwd: "child",
|
||||
name: "Build",
|
||||
},
|
||||
});
|
||||
|
||||
expect(createdTerminals).toEqual([{ cwd: requestedCwd, name: "Build" }]);
|
||||
expect(result.structuredContent).toEqual({
|
||||
id: "terminal-1",
|
||||
name: "Build",
|
||||
cwd: requestedCwd,
|
||||
});
|
||||
} finally {
|
||||
rmSync(callerCwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import type {
|
||||
AgentProvider,
|
||||
PaseoToolingLaunchContext,
|
||||
PaseoToolingProviderSessionRef,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
interface PaseoToolingRuntimeEndpoints {
|
||||
mcpBaseUrl: string | null;
|
||||
httpBaseUrl: string | null;
|
||||
}
|
||||
|
||||
export class PaseoToolingRuntime {
|
||||
private mcpBaseUrl: string | null = null;
|
||||
private httpBaseUrl: string | null = null;
|
||||
private enabled = true;
|
||||
private readonly sessionBindings = new Map<string, string>();
|
||||
readonly token = randomBytes(32).toString("hex");
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
setEndpoints(endpoints: PaseoToolingRuntimeEndpoints): void {
|
||||
this.mcpBaseUrl = endpoints.mcpBaseUrl;
|
||||
this.httpBaseUrl = endpoints.httpBaseUrl;
|
||||
}
|
||||
|
||||
createLaunchContext(agentId: string): PaseoToolingLaunchContext | undefined {
|
||||
if (!this.enabled || (!this.mcpBaseUrl && !this.httpBaseUrl)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
agentId,
|
||||
mcpUrl: this.mcpBaseUrl ? `${this.mcpBaseUrl}?callerAgentId=${agentId}` : null,
|
||||
httpBaseUrl: this.httpBaseUrl,
|
||||
token: this.token,
|
||||
bindProviderSession: (ref) => this.bindProviderSession({ ...ref, agentId }),
|
||||
};
|
||||
}
|
||||
|
||||
bindProviderSession(ref: PaseoToolingProviderSessionRef & { agentId: string }): () => void {
|
||||
const key = providerSessionKey(ref.provider, ref.sessionId);
|
||||
this.sessionBindings.set(key, ref.agentId);
|
||||
return () => {
|
||||
if (this.sessionBindings.get(key) === ref.agentId) {
|
||||
this.sessionBindings.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
resolveProviderSession(ref: PaseoToolingProviderSessionRef): string | null {
|
||||
return this.sessionBindings.get(providerSessionKey(ref.provider, ref.sessionId)) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function providerSessionKey(provider: AgentProvider, sessionId: string): string {
|
||||
return `${provider}:${sessionId}`;
|
||||
}
|
||||
@@ -96,6 +96,26 @@ describe("prompt attachments", () => {
|
||||
).toBe("<browser-element>button.primary</browser-element>");
|
||||
});
|
||||
|
||||
it("renders uploaded file attachments as local file references", () => {
|
||||
expect(
|
||||
renderPromptAttachmentAsText({
|
||||
type: "uploaded_file",
|
||||
id: "upload_req-upload",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
path: "/tmp/paseo/uploads/upload_req-upload/notes.txt",
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"Uploaded file: notes.txt",
|
||||
"Path: /tmp/paseo/uploads/upload_req-upload/notes.txt",
|
||||
"MIME: text/plain",
|
||||
"Size: 11 bytes",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined when firstAgentContext is empty", () => {
|
||||
expect(buildAgentBranchNameSeed(undefined)).toBeUndefined();
|
||||
expect(buildAgentBranchNameSeed({})).toBeUndefined();
|
||||
|
||||
@@ -54,6 +54,14 @@ export function renderPromptAttachmentAsText(attachment: AgentAttachment): strin
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
case "uploaded_file": {
|
||||
return [
|
||||
`Uploaded file: ${attachment.fileName}`,
|
||||
`Path: ${attachment.path}`,
|
||||
`MIME: ${attachment.mimeType}`,
|
||||
`Size: ${attachment.size} bytes`,
|
||||
].join("\n");
|
||||
}
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { withPaseoToolingMcpServer } from "../paseo-tooling/provider-mcp.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
|
||||
|
||||
@@ -596,29 +597,30 @@ export class ACPAgentClient implements AgentClient {
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.assertProvider(config);
|
||||
const session = new ACPAgentSession(
|
||||
const launchConfig = withPaseoToolingMcpServer(
|
||||
{ ...config, provider: this.provider },
|
||||
{
|
||||
provider: this.provider,
|
||||
logger: this.logger,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
defaultCommand: this.defaultCommand,
|
||||
defaultModes: this.defaultModes,
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
beforeModeWriter: this.beforeModeWriter,
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
},
|
||||
launchContext,
|
||||
);
|
||||
const session = new ACPAgentSession(launchConfig, {
|
||||
provider: this.provider,
|
||||
logger: this.logger,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
defaultCommand: this.defaultCommand,
|
||||
defaultModes: this.defaultModes,
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
beforeModeWriter: this.beforeModeWriter,
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
});
|
||||
await session.initializeNewSession();
|
||||
return session;
|
||||
}
|
||||
@@ -638,12 +640,15 @@ export class ACPAgentClient implements AgentClient {
|
||||
throw new Error(`${this.provider} resume requires the original working directory`);
|
||||
}
|
||||
|
||||
const mergedConfig: AgentSessionConfig = {
|
||||
...storedConfig,
|
||||
...overrides,
|
||||
provider: this.provider,
|
||||
cwd,
|
||||
};
|
||||
const mergedConfig = withPaseoToolingMcpServer(
|
||||
{
|
||||
...storedConfig,
|
||||
...overrides,
|
||||
provider: this.provider,
|
||||
cwd,
|
||||
},
|
||||
launchContext,
|
||||
);
|
||||
const session = new ACPAgentSession(mergedConfig, {
|
||||
provider: this.provider,
|
||||
logger: this.logger,
|
||||
|
||||
@@ -45,6 +45,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
||||
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { withPaseoToolingMcpServer } from "../../paseo-tooling/provider-mcp.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
|
||||
import {
|
||||
@@ -1297,7 +1298,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const claudeConfig = this.assertConfig(config);
|
||||
const claudeConfig = this.assertConfig(withPaseoToolingMcpServer(config, launchContext));
|
||||
return new ClaudeAgentSession(claudeConfig, {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
@@ -1325,7 +1326,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
provider: "claude",
|
||||
cwd: merged.cwd,
|
||||
};
|
||||
const claudeConfig = this.assertConfig(mergedConfig);
|
||||
const claudeConfig = this.assertConfig(withPaseoToolingMcpServer(mergedConfig, launchContext));
|
||||
return new ClaudeAgentSession(claudeConfig, {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
|
||||
@@ -534,6 +534,37 @@ describe("Codex app-server provider", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("initializes Codex app-server without making Paseo the request originator", async () => {
|
||||
let initializeParams: unknown;
|
||||
const appServer = createFakeCodexAppServer({
|
||||
initialize: (params) => {
|
||||
initializeParams = params;
|
||||
return {};
|
||||
},
|
||||
"collaborationMode/list": () => ({ data: [] }),
|
||||
"skills/list": () => ({ data: [] }),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
|
||||
await session.connect();
|
||||
|
||||
expect(initializeParams).toEqual({
|
||||
clientInfo: {
|
||||
name: "codex_app_server_daemon",
|
||||
title: "Codex App Server Daemon",
|
||||
version: "0.0.0",
|
||||
},
|
||||
capabilities: { experimentalApi: true },
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rewinds the conversation to a freshly emitted Codex user message id", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
@@ -2703,7 +2734,17 @@ describe("Codex persisted sessions", () => {
|
||||
}),
|
||||
);
|
||||
expect(calls).toEqual([
|
||||
{ method: "initialize", params: expect.any(Object) },
|
||||
{
|
||||
method: "initialize",
|
||||
params: {
|
||||
clientInfo: {
|
||||
name: "codex_app_server_daemon",
|
||||
title: "Codex App Server Daemon",
|
||||
version: "0.0.0",
|
||||
},
|
||||
capabilities: { experimentalApi: true },
|
||||
},
|
||||
},
|
||||
{ method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
import { runProviderTurn } from "./provider-runner.js";
|
||||
import { withPaseoToolingMcpServer } from "../paseo-tooling/provider-mcp.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
|
||||
function assertChildWithPipes(
|
||||
@@ -103,6 +104,14 @@ const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||
const INTERRUPT_TIMEOUT_MS = 2_000;
|
||||
const CODEX_PROVIDER = "codex" as const;
|
||||
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
||||
// Codex treats most app-server client names as the model-request originator.
|
||||
// This reserved Codex name is non-originating, so requests keep Codex's default
|
||||
// CLI identity instead of showing up as Paseo in provider usage logs.
|
||||
const CODEX_NON_ORIGINATING_APP_SERVER_CLIENT_INFO = {
|
||||
name: "codex_app_server_daemon",
|
||||
title: "Codex App Server Daemon",
|
||||
version: "0.0.0",
|
||||
} as const;
|
||||
const ASSISTANT_MESSAGE_BOUNDARY_MARKDOWN = "\n\n---\n\n";
|
||||
const CODEX_TOOL_THREAD_ITEM_TYPES = new Set([
|
||||
"commandExecution",
|
||||
@@ -2818,11 +2827,7 @@ function buildCodexAppServerInitializeParams(): {
|
||||
capabilities: { experimentalApi: true };
|
||||
} {
|
||||
return {
|
||||
clientInfo: {
|
||||
name: "paseo",
|
||||
title: "Paseo",
|
||||
version: "0.0.0",
|
||||
},
|
||||
clientInfo: CODEX_NON_ORIGINATING_APP_SERVER_CLIENT_INFO,
|
||||
capabilities: {
|
||||
experimentalApi: true,
|
||||
},
|
||||
@@ -5429,7 +5434,10 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
// TODO: Honor persistSession=false if app-server adds support, or route
|
||||
// utility generations through `codex exec --ephemeral` in a larger change.
|
||||
}
|
||||
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
||||
const sessionConfig = withPaseoToolingMcpServer(
|
||||
{ ...config, provider: CODEX_PROVIDER },
|
||||
launchContext,
|
||||
);
|
||||
const goalsEnabled = await this.resolveGoalsEnabled();
|
||||
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
@@ -5454,12 +5462,15 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const storedConfig = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
const merged: AgentSessionConfig = {
|
||||
...storedConfig,
|
||||
...overrides,
|
||||
provider: CODEX_PROVIDER,
|
||||
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
|
||||
};
|
||||
const merged = withPaseoToolingMcpServer(
|
||||
{
|
||||
...storedConfig,
|
||||
...overrides,
|
||||
provider: CODEX_PROVIDER,
|
||||
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
|
||||
},
|
||||
launchContext,
|
||||
);
|
||||
const goalsEnabled = await this.resolveGoalsEnabled();
|
||||
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
import type {
|
||||
AgentLaunchContext,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
ToolCallTimelineItem,
|
||||
@@ -112,6 +113,66 @@ function assistantTurnEvents({
|
||||
];
|
||||
}
|
||||
|
||||
function manualCompactEvents({
|
||||
sessionId = "session-1",
|
||||
summaryText = "## Goal\n- Preserve context while continuing the task.",
|
||||
}: {
|
||||
sessionId?: string;
|
||||
summaryText?: string;
|
||||
} = {}): OpenCodeEvent[] {
|
||||
return [
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_compact_user",
|
||||
sessionID: sessionId,
|
||||
role: "user",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "prt_compact",
|
||||
sessionID: sessionId,
|
||||
messageID: "msg_compact_user",
|
||||
type: "compaction",
|
||||
auto: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "msg_compact_summary",
|
||||
sessionID: sessionId,
|
||||
role: "assistant",
|
||||
providerID: "test-provider",
|
||||
modelID: "gpt-5.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "prt_compact_summary",
|
||||
sessionID: sessionId,
|
||||
messageID: "msg_compact_summary",
|
||||
type: "text",
|
||||
text: summaryText,
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "session.compacted", properties: { sessionID: sessionId } },
|
||||
{ type: "session.idle", properties: { sessionID: sessionId } },
|
||||
];
|
||||
}
|
||||
|
||||
describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
const logger = createTestLogger();
|
||||
const buildConfig = (cwd: string): AgentSessionConfig => ({
|
||||
@@ -168,6 +229,41 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 120_000);
|
||||
|
||||
test("manual compact hides the generated summary text", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.sessionSummarizeEvents = manualCompactEvents();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
model: "test-provider/gpt-5.5",
|
||||
});
|
||||
|
||||
const turn = await collectTurnEvents(streamSession(session, "/compact"));
|
||||
|
||||
expect(turn.turnCompleted).toBe(true);
|
||||
expect(turn.assistantMessages).toEqual([]);
|
||||
expect(turn.allTimelineItems).toEqual([
|
||||
{ type: "user_message", text: "/compact", messageId: "msg_compact_user" },
|
||||
{ type: "compaction", status: "loading", trigger: "manual" },
|
||||
{ type: "compaction", status: "completed" },
|
||||
]);
|
||||
expect(openCodeClient.calls.sessionSummarize).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionID: "session-1",
|
||||
directory: cwd,
|
||||
providerID: "test-provider",
|
||||
modelID: "gpt-5.5",
|
||||
}),
|
||||
]);
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 120_000);
|
||||
|
||||
test("listModels returns models with required fields", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
@@ -757,6 +853,55 @@ describe("OpenCode adapter context-window normalization", () => {
|
||||
});
|
||||
|
||||
describe("OpenCode adapter startTurn error handling", () => {
|
||||
test("uses native Paseo tooling plugin instead of registering internal MCP", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const boundSessions: string[] = [];
|
||||
const unboundSessions: string[] = [];
|
||||
const launchContext: AgentLaunchContext = {
|
||||
agentId: "agent-1",
|
||||
env: { PASEO_AGENT_ID: "agent-1" },
|
||||
paseoTooling: {
|
||||
agentId: "agent-1",
|
||||
mcpUrl: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
||||
httpBaseUrl: "http://127.0.0.1:6767",
|
||||
token: "tool-token",
|
||||
bindProviderSession: ({ provider, sessionId }) => {
|
||||
expect(provider).toBe("opencode");
|
||||
boundSessions.push(sessionId);
|
||||
return () => unboundSessions.push(sessionId);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const session = await client.createSession(
|
||||
{
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
},
|
||||
launchContext,
|
||||
);
|
||||
|
||||
await collectTurnEvents(streamSession(session, "hello"));
|
||||
|
||||
expect(runtime.acquisitions[0]?.env?.PASEO_AGENT_ID).toBe("agent-1");
|
||||
expect(runtime.acquisitions[0]?.env?.OPENCODE_CONFIG_CONTENT).toContain(
|
||||
"paseo-tooling-plugin.mjs",
|
||||
);
|
||||
expect(openCodeClient.calls.mcpAdd).toEqual([]);
|
||||
expect(boundSessions).toEqual(["session-1"]);
|
||||
|
||||
await session.close();
|
||||
expect(unboundSessions).toEqual(["session-1"]);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("dynamically adds injected MCP servers without config-backed connect", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
@@ -1595,6 +1740,155 @@ describe("OpenCodeAgentClient env", () => {
|
||||
});
|
||||
|
||||
describe("OpenCode persisted sessions", () => {
|
||||
test("replay hides summaries produced by manual compact", () => {
|
||||
const timeline = __openCodeInternals.buildOpenCodeSessionTimeline([
|
||||
{
|
||||
info: {
|
||||
id: "msg_compact_user",
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 1000 },
|
||||
agent: "build",
|
||||
model: { providerID: "test-provider", modelID: "gpt-5.5" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_compact_text",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_compact_user",
|
||||
type: "text",
|
||||
text: "/compact",
|
||||
},
|
||||
{
|
||||
id: "prt_compact",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_compact_user",
|
||||
type: "compaction",
|
||||
auto: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: "msg_compact_summary",
|
||||
sessionID: "ses_1",
|
||||
role: "assistant",
|
||||
time: { created: 1001, completed: 1002 },
|
||||
providerID: "test-provider",
|
||||
modelID: "gpt-5.5",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_summary",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_compact_summary",
|
||||
type: "text",
|
||||
text: "## Goal\n- Preserve context while continuing the task.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: "msg_next_user",
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 1003 },
|
||||
agent: "build",
|
||||
model: { providerID: "test-provider", modelID: "gpt-5.5" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_next_user",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_next_user",
|
||||
type: "text",
|
||||
text: "continue",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(timeline).toEqual([
|
||||
{ type: "user_message", text: "/compact", messageId: "msg_compact_user" },
|
||||
{ type: "compaction", status: "completed", trigger: "manual" },
|
||||
{ type: "user_message", text: "continue", messageId: "msg_next_user" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("replay suppresses OpenCode compaction summary messages", () => {
|
||||
const timeline = __openCodeInternals.buildOpenCodeSessionTimeline([
|
||||
{
|
||||
info: {
|
||||
id: "msg_compaction_user",
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 1000 },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "big-pickle" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_compaction",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_compaction_user",
|
||||
type: "compaction",
|
||||
auto: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: "msg_compaction_summary",
|
||||
sessionID: "ses_1",
|
||||
role: "assistant",
|
||||
time: { created: 1001, completed: 1002 },
|
||||
parentID: "msg_compaction_user",
|
||||
providerID: "opencode",
|
||||
modelID: "big-pickle",
|
||||
mode: "compaction",
|
||||
agent: "compaction",
|
||||
path: { cwd: "/workspace/repo", root: "/workspace/repo" },
|
||||
summary: true,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_summary",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_compaction_summary",
|
||||
type: "text",
|
||||
text: "## Goal\n- Preserve context while continuing the task.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: "msg_user_after_compaction",
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 1003 },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "big-pickle" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user_after_compaction",
|
||||
sessionID: "ses_1",
|
||||
messageID: "msg_user_after_compaction",
|
||||
type: "text",
|
||||
text: "/create-pr",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(timeline).toEqual([
|
||||
{ type: "compaction", status: "completed", trigger: "auto" },
|
||||
{ type: "user_message", text: "/create-pr", messageId: "msg_user_after_compaction" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
|
||||
@@ -78,6 +78,11 @@ import {
|
||||
type OpenCodeRuntime,
|
||||
type OpenCodeServerAcquisition,
|
||||
} from "./opencode/runtime.js";
|
||||
import {
|
||||
bindOpenCodePaseoToolingSession,
|
||||
ensureOpenCodePaseoToolingRuntime,
|
||||
withOpenCodePaseoToolingEnv,
|
||||
} from "./opencode/paseo-tooling.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
|
||||
|
||||
@@ -1040,6 +1045,21 @@ function buildOpenCodeReplayPartTimelineEvent(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function isOpenCodeCompactionSummaryMessage(message: OpenCodeMessage): boolean {
|
||||
return (
|
||||
message.role === "assistant" &&
|
||||
(message.summary === true || message.agent === "compaction" || message.mode === "compaction")
|
||||
);
|
||||
}
|
||||
|
||||
function findOpenCodeCompactionPart(
|
||||
message: OpenCodeSessionMessage,
|
||||
): Extract<OpenCodePart, { type: "compaction" }> | undefined {
|
||||
return message.parts.find(
|
||||
(part): part is Extract<OpenCodePart, { type: "compaction" }> => part.type === "compaction",
|
||||
);
|
||||
}
|
||||
|
||||
async function readOpenCodeSessionMessagesFromSdk(
|
||||
client: Pick<OpencodeClient, "session">,
|
||||
session: OpenCodePersistedSession,
|
||||
@@ -1059,9 +1079,30 @@ async function readOpenCodeSessionMessagesFromSdk(
|
||||
function buildOpenCodeSessionTimeline(
|
||||
messages: ReadonlyArray<OpenCodeSessionMessage>,
|
||||
): AgentTimelineItem[] {
|
||||
return messages.flatMap((message) =>
|
||||
buildOpenCodeReplayTimelineEvents(message).map((event) => event.item),
|
||||
);
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
let hideNextAssistantAfterCompaction = false;
|
||||
|
||||
for (const message of messages) {
|
||||
const compactionPart = findOpenCodeCompactionPart(message);
|
||||
if (message.info.role === "assistant" && hideNextAssistantAfterCompaction) {
|
||||
hideNextAssistantAfterCompaction = false;
|
||||
continue;
|
||||
}
|
||||
if (message.info.role === "user" && !compactionPart) {
|
||||
hideNextAssistantAfterCompaction = false;
|
||||
}
|
||||
|
||||
timeline.push(...buildOpenCodeReplayTimelineEvents(message).map((event) => event.item));
|
||||
|
||||
if (message.info.role === "user" && compactionPart) {
|
||||
timeline.push(
|
||||
createCompactionTimelineItem("completed", compactionPart.auto ? "auto" : "manual"),
|
||||
);
|
||||
hideNextAssistantAfterCompaction = true;
|
||||
}
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
function filterOpenCodeRevertedMessages(
|
||||
@@ -1120,6 +1161,9 @@ function buildOpenCodeReplayTimelineEvents(
|
||||
message: OpenCodeSessionMessage,
|
||||
): Extract<AgentStreamEvent, { type: "timeline" }>[] {
|
||||
const { info, parts } = message;
|
||||
if (isOpenCodeCompactionSummaryMessage(info)) {
|
||||
return [];
|
||||
}
|
||||
if (info.role === "user") {
|
||||
const text = parts
|
||||
.filter((part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text")
|
||||
@@ -1165,6 +1209,7 @@ function buildOpenCodeReplayTimelineEvents(
|
||||
|
||||
export const __openCodeInternals = {
|
||||
buildOpenCodePromptParts,
|
||||
buildOpenCodeSessionTimeline,
|
||||
buildOpenCodeModelContextWindowLookup,
|
||||
buildOpenCodeModelDefinition,
|
||||
buildOpenCodeModelLookupKey,
|
||||
@@ -1240,9 +1285,14 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const paseoToolingRuntime = ensureOpenCodePaseoToolingRuntime();
|
||||
const acquisition = await this.runtime.acquireServer({
|
||||
force: false,
|
||||
env: launchContext?.env,
|
||||
env: withOpenCodePaseoToolingEnv({
|
||||
env: launchContext?.env,
|
||||
launchContext,
|
||||
runtime: paseoToolingRuntime,
|
||||
}),
|
||||
});
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
@@ -1267,6 +1317,10 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
const unbindPaseoToolingSession = bindOpenCodePaseoToolingSession({
|
||||
launchContext,
|
||||
sessionId: session.id,
|
||||
});
|
||||
|
||||
return new OpenCodeAgentSession(
|
||||
openCodeConfig,
|
||||
@@ -1277,6 +1331,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
acquisition.release,
|
||||
options?.persistSession,
|
||||
launchContext?.agentId,
|
||||
unbindPaseoToolingSession,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
@@ -1302,7 +1357,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
cwd,
|
||||
};
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const paseoToolingRuntime = ensureOpenCodePaseoToolingRuntime();
|
||||
const acquisition = await this.runtime.acquireServer({
|
||||
force: false,
|
||||
env: withOpenCodePaseoToolingEnv({
|
||||
env: launchContext?.env,
|
||||
launchContext,
|
||||
runtime: paseoToolingRuntime,
|
||||
}),
|
||||
});
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
baseUrl: url,
|
||||
@@ -1311,6 +1374,10 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
try {
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
const unbindPaseoToolingSession = bindOpenCodePaseoToolingSession({
|
||||
launchContext,
|
||||
sessionId: handle.sessionId,
|
||||
});
|
||||
|
||||
return new OpenCodeAgentSession(
|
||||
openCodeConfig,
|
||||
@@ -1321,6 +1388,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
acquisition.release,
|
||||
undefined,
|
||||
launchContext?.agentId,
|
||||
unbindPaseoToolingSession,
|
||||
);
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
@@ -1591,6 +1659,9 @@ export interface OpenCodeEventTranslationState {
|
||||
sessionTotalCostUsd?: number;
|
||||
streamedPartKeys: Set<string>;
|
||||
emittedStructuredMessageIds: Set<string>;
|
||||
compactionSummaryMessageIds: Set<string>;
|
||||
emittedCompactionPartIds: Set<string>;
|
||||
suppressAssistantMessagesUntilIdle?: { active: boolean };
|
||||
/** Tracks the type of each part by ID, learned from message.part.updated events. */
|
||||
partTypes: Map<string, string>;
|
||||
subAgentsByCallId?: Map<string, OpenCodeSubAgentActivityState>;
|
||||
@@ -1951,6 +2022,11 @@ export function translateOpenCodeEvent(
|
||||
function resetOpenCodeTurnTrackingState(state: OpenCodeEventTranslationState): void {
|
||||
state.streamedPartKeys.clear();
|
||||
state.partTypes.clear();
|
||||
state.compactionSummaryMessageIds.clear();
|
||||
state.emittedCompactionPartIds.clear();
|
||||
if (state.suppressAssistantMessagesUntilIdle) {
|
||||
state.suppressAssistantMessagesUntilIdle.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenCodeSubAgentMaps(state: OpenCodeEventTranslationState): {
|
||||
@@ -2263,6 +2339,14 @@ function appendOpenCodeMessageUpdated(
|
||||
if (info.role !== "assistant") {
|
||||
return;
|
||||
}
|
||||
if (state.suppressAssistantMessagesUntilIdle?.active) {
|
||||
state.compactionSummaryMessageIds.add(info.id);
|
||||
return;
|
||||
}
|
||||
if (isOpenCodeCompactionSummaryMessage(info)) {
|
||||
state.compactionSummaryMessageIds.add(info.id);
|
||||
return;
|
||||
}
|
||||
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info);
|
||||
if (modelLookupKey) {
|
||||
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
|
||||
@@ -2320,6 +2404,15 @@ function appendOpenCodeMessagePartUpdated(
|
||||
const messageRole = state.messageRoles.get(part.messageID);
|
||||
state.partTypes.set(part.id, part.type);
|
||||
|
||||
if (state.compactionSummaryMessageIds.has(part.messageID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSuppressOpenCodeAssistantPart(part, messageRole, state)) {
|
||||
state.compactionSummaryMessageIds.add(part.messageID);
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
appendOpenCodeTextPart(part, messageRole, state, events);
|
||||
return;
|
||||
@@ -2336,6 +2429,10 @@ function appendOpenCodeMessagePartUpdated(
|
||||
return;
|
||||
}
|
||||
if (part.type === "compaction") {
|
||||
if (state.emittedCompactionPartIds.has(part.id)) {
|
||||
return;
|
||||
}
|
||||
state.emittedCompactionPartIds.add(part.id);
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
@@ -2361,6 +2458,18 @@ function appendOpenCodeMessagePartUpdated(
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSuppressOpenCodeAssistantPart(
|
||||
part: Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
|
||||
messageRole: OpenCodeMessageRole | undefined,
|
||||
state: OpenCodeEventTranslationState,
|
||||
): boolean {
|
||||
return (
|
||||
state.suppressAssistantMessagesUntilIdle?.active === true &&
|
||||
part.type === "text" &&
|
||||
messageRole !== "user"
|
||||
);
|
||||
}
|
||||
|
||||
function appendOpenCodeTextPart(
|
||||
part: Extract<
|
||||
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
|
||||
@@ -2691,6 +2800,9 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private streamedPartKeys = new Set<string>();
|
||||
/** Tracks assistant messages already emitted from structured payloads. */
|
||||
private emittedStructuredMessageIds = new Set<string>();
|
||||
private compactionSummaryMessageIds = new Set<string>();
|
||||
private emittedCompactionPartIds = new Set<string>();
|
||||
private suppressAssistantMessagesUntilIdle = { active: false };
|
||||
/** Tracks the type of each part by ID, learned from message.part.updated events. */
|
||||
private partTypes = new Map<string, string>();
|
||||
private availableModesCache: AgentMode[] | null = null;
|
||||
@@ -2718,6 +2830,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
releaseServer?: () => void,
|
||||
persistSession = true,
|
||||
private readonly agentId?: string,
|
||||
private readonly unbindPaseoToolingSession?: () => void,
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
@@ -2874,6 +2987,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
const parts = buildOpenCodePromptParts(prompt);
|
||||
this.pendingUserMessageText = buildOpenCodeUserTimelineText(prompt);
|
||||
this.suppressAssistantMessagesUntilIdle.active = false;
|
||||
const model = this.parseModel(this.config.model);
|
||||
const thinkingOptionId = this.config.thinkingOptionId;
|
||||
const effectiveVariant = thinkingOptionId ?? undefined;
|
||||
@@ -2895,6 +3009,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
if (slashCommand) {
|
||||
if (slashCommand.commandName === "compact" || slashCommand.commandName === "summarize") {
|
||||
this.suppressAssistantMessagesUntilIdle.active = true;
|
||||
void this.client.session
|
||||
.summarize({
|
||||
sessionID: this.sessionId,
|
||||
@@ -2903,6 +3018,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
this.suppressAssistantMessagesUntilIdle.active = false;
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_failed",
|
||||
@@ -2911,15 +3027,11 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
} else {
|
||||
this.finishForegroundTurn(
|
||||
{ type: "turn_completed", provider: "opencode", usage: undefined },
|
||||
turnId,
|
||||
);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.suppressAssistantMessagesUntilIdle.active = false;
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_failed",
|
||||
@@ -3491,6 +3603,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
await this.deleteProviderSessionIfEphemeral();
|
||||
this.activeForegroundTurnId = null;
|
||||
} finally {
|
||||
this.unbindPaseoToolingSession?.();
|
||||
this.releaseServer?.();
|
||||
this.releaseServer = null;
|
||||
}
|
||||
@@ -3640,6 +3753,9 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
sessionTotalCostUsd: this.sessionTotalCostUsd,
|
||||
streamedPartKeys: this.streamedPartKeys,
|
||||
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
|
||||
compactionSummaryMessageIds: this.compactionSummaryMessageIds,
|
||||
emittedCompactionPartIds: this.emittedCompactionPartIds,
|
||||
suppressAssistantMessagesUntilIdle: this.suppressAssistantMessagesUntilIdle,
|
||||
partTypes: this.partTypes,
|
||||
subAgentsByCallId: this.subAgentsByCallId,
|
||||
subAgentCallIdByChildSessionId: this.subAgentCallIdByChildSessionId,
|
||||
|
||||
@@ -31,6 +31,8 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
|
||||
accumulatedUsage: {},
|
||||
streamedPartKeys: new Set(),
|
||||
emittedStructuredMessageIds: new Set(),
|
||||
compactionSummaryMessageIds: new Set(),
|
||||
emittedCompactionPartIds: new Set(),
|
||||
partTypes: new Map(),
|
||||
};
|
||||
}
|
||||
@@ -801,6 +803,107 @@ describe("translateOpenCodeEvent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not render OpenCode compaction summaries as assistant messages", () => {
|
||||
const state = createState();
|
||||
|
||||
const loading = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "compaction-part-1",
|
||||
sessionID: "session-1",
|
||||
messageID: "message-compaction-user-1",
|
||||
type: "compaction",
|
||||
auto: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "message-compaction-summary-1",
|
||||
sessionID: "session-1",
|
||||
role: "assistant",
|
||||
mode: "compaction",
|
||||
agent: "compaction",
|
||||
summary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
const summaryText = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "summary-text-part-1",
|
||||
sessionID: "session-1",
|
||||
messageID: "message-compaction-summary-1",
|
||||
type: "text",
|
||||
text: "## Goal\n- Preserve context while continuing the task.",
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
const duplicateLoading = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "compaction-part-1",
|
||||
sessionID: "session-1",
|
||||
messageID: "message-compaction-user-1",
|
||||
type: "compaction",
|
||||
auto: true,
|
||||
tail_start_id: "message-tail-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
const completed = translateOpenCodeEvent(
|
||||
{
|
||||
type: "session.compacted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect([...loading, ...summaryText, ...duplicateLoading, ...completed]).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "loading",
|
||||
trigger: "auto",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits compaction completed timeline items from session.compacted", () => {
|
||||
const state = createState();
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { ensureOpenCodePaseoToolingRuntime } from "./paseo-tooling.js";
|
||||
|
||||
describe("OpenCode Paseo tooling plugin", () => {
|
||||
const originalPaseoHome = process.env.PASEO_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("normalizes OpenCode string arguments before executing Paseo tools", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "opencode-paseo-tooling-test-"));
|
||||
process.env.PASEO_HOME = paseoHome;
|
||||
const runtime = ensureOpenCodePaseoToolingRuntime();
|
||||
const postedBodies: unknown[] = [];
|
||||
const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const pathname = new URL(url.toString()).pathname;
|
||||
if (pathname === "/api/paseo-tooling/manifest") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
tools: [
|
||||
{
|
||||
name: "list_agents",
|
||||
description: "List agents",
|
||||
inputJsonSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
includeArchived: { type: "boolean", default: false },
|
||||
sinceHours: { type: "integer", default: 48 },
|
||||
statuses: {
|
||||
type: "array",
|
||||
items: { type: "string", enum: ["running", "idle"] },
|
||||
},
|
||||
limit: { type: "integer", default: 50 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
postedBodies.push(JSON.parse(String(init?.body)) as unknown);
|
||||
return new Response(JSON.stringify({ output: "ok" }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
try {
|
||||
const plugin = (await import(
|
||||
`${pathToFileURL(runtime.pluginPath).href}?test=${Date.now()}`
|
||||
)) as {
|
||||
server: (
|
||||
input: unknown,
|
||||
options: { baseUrl: string; token: string },
|
||||
) => Promise<{
|
||||
tool: Record<
|
||||
string,
|
||||
{ execute: (args: unknown, context: { sessionID: string }) => Promise<string> }
|
||||
>;
|
||||
}>;
|
||||
};
|
||||
const server = await plugin.server({}, { baseUrl: "http://127.0.0.1:6767", token: "t" });
|
||||
|
||||
await server.tool.paseo_list_agents?.execute(
|
||||
{
|
||||
includeArchived: "False",
|
||||
sinceHours: "",
|
||||
statuses: '["running"]',
|
||||
limit: "5",
|
||||
},
|
||||
{ sessionID: "session-1" },
|
||||
);
|
||||
|
||||
expect(postedBodies).toEqual([
|
||||
{
|
||||
provider: "opencode",
|
||||
sessionId: "session-1",
|
||||
tool: "list_agents",
|
||||
input: {
|
||||
includeArchived: false,
|
||||
statuses: ["running"],
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import type { AgentLaunchContext } from "../../agent-sdk-types.js";
|
||||
|
||||
const PASEO_OPENCODE_PLUGIN_FILENAME = "paseo-tooling-plugin.mjs";
|
||||
|
||||
export interface OpenCodePaseoToolingRuntime {
|
||||
pluginPath: string;
|
||||
}
|
||||
|
||||
function resolvePaseoHome(): string {
|
||||
return process.env.PASEO_HOME?.trim() || path.join(homedir(), ".paseo");
|
||||
}
|
||||
|
||||
function resolveRuntimeDirectory(): string {
|
||||
return path.join(resolvePaseoHome(), "opencode");
|
||||
}
|
||||
|
||||
export function ensureOpenCodePaseoToolingRuntime(): OpenCodePaseoToolingRuntime {
|
||||
const dir = resolveRuntimeDirectory();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const pluginPath = path.join(dir, PASEO_OPENCODE_PLUGIN_FILENAME);
|
||||
const require = createRequire(import.meta.url);
|
||||
const zodImportUrl = pathToFileURL(require.resolve("zod")).href;
|
||||
writeFileSync(pluginPath, createOpenCodePaseoToolingPluginSource(zodImportUrl), "utf8");
|
||||
return { pluginPath };
|
||||
}
|
||||
|
||||
export function withOpenCodePaseoToolingEnv(params: {
|
||||
env?: Record<string, string>;
|
||||
launchContext?: AgentLaunchContext;
|
||||
runtime: OpenCodePaseoToolingRuntime;
|
||||
}): Record<string, string> | undefined {
|
||||
const tooling = params.launchContext?.paseoTooling;
|
||||
if (!tooling?.httpBaseUrl) {
|
||||
return params.env;
|
||||
}
|
||||
|
||||
const pluginSpec = [
|
||||
pathToFileURL(params.runtime.pluginPath).href,
|
||||
{
|
||||
baseUrl: tooling.httpBaseUrl,
|
||||
token: tooling.token,
|
||||
},
|
||||
];
|
||||
return {
|
||||
...params.env,
|
||||
OPENCODE_CONFIG_CONTENT: mergeOpenCodeConfigContent(
|
||||
params.env?.OPENCODE_CONFIG_CONTENT,
|
||||
pluginSpec,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function bindOpenCodePaseoToolingSession(params: {
|
||||
launchContext?: AgentLaunchContext;
|
||||
sessionId: string;
|
||||
}): (() => void) | undefined {
|
||||
return params.launchContext?.paseoTooling?.bindProviderSession({
|
||||
provider: "opencode",
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeOpenCodeConfigContent(existing: string | undefined, pluginSpec: unknown[]): string {
|
||||
const base = parseConfigContent(existing);
|
||||
const existingPlugins = Array.isArray(base.plugin) ? base.plugin : [];
|
||||
const pluginUrl = pluginSpec[0];
|
||||
const withoutExistingPaseoPlugin = existingPlugins.filter((entry) => {
|
||||
const spec = Array.isArray(entry) ? entry[0] : entry;
|
||||
return spec !== pluginUrl;
|
||||
});
|
||||
return JSON.stringify({
|
||||
...base,
|
||||
plugin: [...withoutExistingPaseoPlugin, pluginSpec],
|
||||
});
|
||||
}
|
||||
|
||||
function parseConfigContent(existing: string | undefined): Record<string, unknown> {
|
||||
if (!existing?.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(existing) as unknown;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function createOpenCodePaseoToolingPluginSource(zodImportUrl: string): string {
|
||||
return `
|
||||
import { z } from ${JSON.stringify(zodImportUrl)};
|
||||
|
||||
export async function server(_input, options) {
|
||||
const manifestResponse = await fetch(new URL("/api/paseo-tooling/manifest", options.baseUrl), {
|
||||
headers: { authorization: "Bearer " + options.token },
|
||||
});
|
||||
if (!manifestResponse.ok) {
|
||||
throw new Error("Failed to load Paseo tooling manifest: " + manifestResponse.status);
|
||||
}
|
||||
const manifest = await manifestResponse.json();
|
||||
const tools = {};
|
||||
for (const item of manifest.tools ?? []) {
|
||||
const id = "paseo_" + item.name;
|
||||
tools[id] = {
|
||||
description: item.description,
|
||||
args: jsonSchemaObjectToZodShape(item.inputJsonSchema),
|
||||
execute: async (args, context) => {
|
||||
const input = normalizeJsonSchemaInput(args, item.inputJsonSchema);
|
||||
const response = await fetch(new URL("/api/paseo-tooling/execute", options.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer " + options.token,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: "opencode",
|
||||
sessionId: context.sessionID,
|
||||
tool: item.name,
|
||||
input,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? "Paseo tool failed: " + response.status);
|
||||
}
|
||||
return typeof payload.output === "string" ? payload.output : JSON.stringify(payload.output ?? payload, null, 2);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { tool: tools };
|
||||
}
|
||||
|
||||
function jsonSchemaObjectToZodShape(schema) {
|
||||
const properties = schema?.properties && typeof schema.properties === "object" ? schema.properties : {};
|
||||
const required = new Set(Array.isArray(schema?.required) ? schema.required : []);
|
||||
const shape = {};
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
let parsed = jsonSchemaToZod(value);
|
||||
if (!required.has(key)) parsed = parsed.optional();
|
||||
shape[key] = parsed;
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
|
||||
function jsonSchemaToZod(schema) {
|
||||
if (!schema || typeof schema !== "object") return z.any();
|
||||
if (schema.anyOf) return unionToZod(schema.anyOf);
|
||||
if (schema.oneOf) return unionToZod(schema.oneOf);
|
||||
if (schema.const !== undefined) return z.literal(schema.const);
|
||||
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
||||
if (schema.enum.length === 1) return z.literal(schema.enum[0]);
|
||||
if (schema.enum.every((item) => typeof item === "string")) return z.enum(schema.enum);
|
||||
return z.union(schema.enum.map((item) => z.literal(item)));
|
||||
}
|
||||
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
||||
const nullable = types.includes("null");
|
||||
const type = types.find((item) => item !== "null");
|
||||
let result;
|
||||
switch (type) {
|
||||
case "string":
|
||||
result = z.string();
|
||||
if (typeof schema.minLength === "number") result = result.min(schema.minLength);
|
||||
if (typeof schema.maxLength === "number") result = result.max(schema.maxLength);
|
||||
break;
|
||||
case "integer":
|
||||
result = z.number().int();
|
||||
if (typeof schema.minimum === "number") result = result.min(schema.minimum);
|
||||
if (typeof schema.maximum === "number") result = result.max(schema.maximum);
|
||||
break;
|
||||
case "number":
|
||||
result = z.number();
|
||||
if (typeof schema.minimum === "number") result = result.min(schema.minimum);
|
||||
if (typeof schema.maximum === "number") result = result.max(schema.maximum);
|
||||
break;
|
||||
case "boolean":
|
||||
result = z.boolean();
|
||||
break;
|
||||
case "array":
|
||||
result = z.array(jsonSchemaToZod(schema.items));
|
||||
break;
|
||||
case "object":
|
||||
result = z.object(jsonSchemaObjectToZodShape(schema));
|
||||
break;
|
||||
default:
|
||||
result = z.any();
|
||||
break;
|
||||
}
|
||||
if (schema.description && typeof result.describe === "function") result = result.describe(schema.description);
|
||||
return nullable ? result.nullable() : result;
|
||||
}
|
||||
|
||||
function normalizeJsonSchemaInput(input, schema) {
|
||||
if (!schema || typeof schema !== "object") return input;
|
||||
|
||||
if (schema.anyOf) return normalizeUnionInput(input, schema.anyOf);
|
||||
if (schema.oneOf) return normalizeUnionInput(input, schema.oneOf);
|
||||
|
||||
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
||||
const type = types.find((item) => item !== "null");
|
||||
|
||||
if (typeof input === "string" && !types.includes("string")) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || trimmed === "undefined" || trimmed === "null") return undefined;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "boolean":
|
||||
if (typeof input === "string") {
|
||||
const normalized = input.trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "yes" || normalized === "1") return true;
|
||||
if (normalized === "false" || normalized === "no" || normalized === "0") return false;
|
||||
}
|
||||
return input;
|
||||
case "integer":
|
||||
case "number":
|
||||
if (typeof input === "string" && input.trim() !== "" && /^-?\\d+(\\.\\d+)?$/.test(input.trim())) {
|
||||
return Number(input);
|
||||
}
|
||||
return input;
|
||||
case "array": {
|
||||
const value = parseJsonString(input);
|
||||
if (!Array.isArray(value)) return value;
|
||||
return value.map((item) => normalizeJsonSchemaInput(item, schema.items)).filter((item) => item !== undefined);
|
||||
}
|
||||
case "object": {
|
||||
const value = parseJsonString(input);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const properties = schema.properties && typeof schema.properties === "object" ? schema.properties : {};
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
const normalized = {};
|
||||
for (const [key, propertySchema] of Object.entries(properties)) {
|
||||
if (!(key in value)) continue;
|
||||
const next = normalizeJsonSchemaInput(value[key], propertySchema);
|
||||
if (next !== undefined || required.has(key)) normalized[key] = next;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
default:
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUnionInput(input, schemas) {
|
||||
const preferred = schemas.find((item) => item?.type && item.type !== "null") ?? schemas[0];
|
||||
return normalizeJsonSchemaInput(input, preferred);
|
||||
}
|
||||
|
||||
function parseJsonString(value) {
|
||||
if (typeof value !== "string") return value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) return value;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function unionToZod(items) {
|
||||
const nonNull = items.filter((item) => item?.type !== "null");
|
||||
const nullable = nonNull.length !== items.length;
|
||||
const parsed = nonNull.map(jsonSchemaToZod);
|
||||
const result = parsed.length === 0 ? z.null() : parsed.length === 1 ? parsed[0] : z.union(parsed);
|
||||
return nullable ? result.nullable() : result;
|
||||
}
|
||||
`;
|
||||
}
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import type { Event as OpenCodeEvent, OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js";
|
||||
|
||||
@@ -92,6 +92,7 @@ export class TestOpenCodeClient {
|
||||
sessionMessagesResponse: OpenCodeResponse = { data: [] };
|
||||
sessionPromptAsyncEvents: unknown[] = [idleEvent()];
|
||||
sessionPromptAsyncResponse: OpenCodeResponse = {};
|
||||
sessionSummarizeEvents: OpenCodeEvent[] = [];
|
||||
sessionSummarizeResponse: OpenCodeResponse = { data: {} };
|
||||
sessionUpdateResponse: OpenCodeResponse = {};
|
||||
private readonly queuedEventStream = createQueuedEventStream();
|
||||
@@ -208,6 +209,9 @@ export class TestOpenCodeClient {
|
||||
},
|
||||
summarize: async (parameters: unknown) => {
|
||||
this.calls.sessionSummarize.push(parameters);
|
||||
for (const event of this.sessionSummarizeEvents) {
|
||||
this.emitEvent(event);
|
||||
}
|
||||
return this.sessionSummarizeResponse;
|
||||
},
|
||||
update: async (parameters: unknown) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
|
||||
import { FakePi } from "./test-utils/fake-pi.js";
|
||||
|
||||
@@ -693,12 +693,202 @@ describe("PiRpcAgentClient", () => {
|
||||
];
|
||||
|
||||
await expect(session.listCommands()).resolves.toEqual([
|
||||
{
|
||||
name: "compact",
|
||||
description: "Manually compact the session context",
|
||||
argumentHint: "[instructions]",
|
||||
},
|
||||
{
|
||||
name: "autocompact",
|
||||
description: "Toggle automatic context compaction",
|
||||
argumentHint: "[on|off|toggle]",
|
||||
},
|
||||
{ name: "review", description: "Review changes", argumentHint: "" },
|
||||
{ name: "fix-tests", description: "Fix tests", argumentHint: "" },
|
||||
{ name: "skill:docs", description: "Read docs", argumentHint: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("lists Pi compact even when RPC get_commands omits built-in slash commands", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
pi.latestSession().commands = [
|
||||
{ name: "review", description: "Review changes", source: "extension" },
|
||||
];
|
||||
|
||||
await expect(session.listCommands()).resolves.toContainEqual({
|
||||
name: "compact",
|
||||
description: "Manually compact the session context",
|
||||
argumentHint: "[instructions]",
|
||||
});
|
||||
await expect(session.listCommands()).resolves.toContainEqual({
|
||||
name: "autocompact",
|
||||
description: "Toggle automatic context compaction",
|
||||
argumentHint: "[on|off|toggle]",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves known argument hints when RPC get_commands returns built-in slash commands", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
pi.latestSession().commands = [
|
||||
{ name: "compact", description: "Compact from RPC", source: "extension" },
|
||||
{ name: "autocompact", description: "Auto compact from RPC", source: "extension" },
|
||||
];
|
||||
|
||||
await expect(session.listCommands()).resolves.toEqual([
|
||||
{
|
||||
name: "compact",
|
||||
description: "Compact from RPC",
|
||||
argumentHint: "[instructions]",
|
||||
},
|
||||
{
|
||||
name: "autocompact",
|
||||
description: "Auto compact from RPC",
|
||||
argumentHint: "[on|off|toggle]",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("executes Pi compact through RPC instead of prompt text", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const handler = (session as AgentSession).tryHandleOutOfBand?.("/compact focus on tests");
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
expect(handler).not.toBeNull();
|
||||
await handler?.run({ emit: (event) => events.push(event) });
|
||||
|
||||
expect(fakeSession.compactRequests).toEqual([{ customInstructions: "focus on tests" }]);
|
||||
expect(fakeSession.prompts).toEqual([]);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: { type: "compaction", status: "loading", trigger: "manual" },
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: { type: "compaction", status: "completed", trigger: "manual" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.emitCompactEnd = false;
|
||||
fakeSession.compactError = new Error("summarizer failed");
|
||||
const handler = (session as AgentSession).tryHandleOutOfBand?.("/compact");
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
expect(handler).not.toBeNull();
|
||||
await handler?.run({ emit: (event) => events.push(event) });
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: { type: "compaction", status: "loading", trigger: "manual" },
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: { type: "compaction", status: "completed", trigger: "manual" },
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "[Error] Failed to compact context: summarizer failed",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("executes Pi autocompact through RPC instead of prompt text", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact off");
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
expect(handler).not.toBeNull();
|
||||
await handler?.run({ emit: (event) => events.push(event) });
|
||||
|
||||
expect(fakeSession.setAutoCompactionRequests).toEqual([false]);
|
||||
expect(fakeSession.prompts).toEqual([]);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: { type: "assistant_message", text: "Auto-compaction disabled." },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects unknown Pi autocompact mode instead of toggling", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact banana");
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
expect(handler).not.toBeNull();
|
||||
await handler?.run({ emit: (event) => events.push(event) });
|
||||
|
||||
expect(fakeSession.setAutoCompactionRequests).toEqual([]);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "[Error] Usage: /autocompact [on|off|toggle]",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("toggles Pi autocompact through current RPC state", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.state.autoCompactionEnabled = false;
|
||||
const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact");
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
expect(handler).not.toBeNull();
|
||||
await handler?.run({ emit: (event) => events.push(event) });
|
||||
|
||||
expect(fakeSession.setAutoCompactionRequests).toEqual([true]);
|
||||
expect(events).toContainEqual({
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: { type: "assistant_message", text: "Auto-compaction enabled." },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects Pi autocompact toggle when current RPC state is unavailable", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
delete fakeSession.state.autoCompactionEnabled;
|
||||
const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact");
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
expect(handler).not.toBeNull();
|
||||
await handler?.run({ emit: (event) => events.push(event) });
|
||||
|
||||
expect(fakeSession.setAutoCompactionRequests).toEqual([]);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "[Error] Auto-compaction state is unavailable. Use /autocompact on or /autocompact off.",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("rewinds conversation through the Pi tree navigation bridge", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
pi.latestSession().capturedUserEntries = [
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type PersistedAgentDescriptor,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { runProviderTurn } from "../provider-runner.js";
|
||||
import { withPaseoToolingMcpServer } from "../../paseo-tooling/provider-mcp.js";
|
||||
import {
|
||||
checkProviderLaunchAvailable,
|
||||
resolveProviderLaunch,
|
||||
@@ -86,6 +87,19 @@ const QUESTION_COMMENT_HEADER = "Comment";
|
||||
const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
|
||||
const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment";
|
||||
|
||||
const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
|
||||
{
|
||||
name: "compact",
|
||||
description: "Manually compact the session context",
|
||||
argumentHint: "[instructions]",
|
||||
},
|
||||
{
|
||||
name: "autocompact",
|
||||
description: "Toggle automatic context compaction",
|
||||
argumentHint: "[on|off|toggle]",
|
||||
},
|
||||
];
|
||||
|
||||
const PI_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
@@ -205,6 +219,13 @@ interface ExtensionUiMappingOptions {
|
||||
allowFreeform?: boolean;
|
||||
}
|
||||
|
||||
interface PiSlashCommandInvocation {
|
||||
commandName: string;
|
||||
args?: string;
|
||||
}
|
||||
|
||||
type AutoCompactMode = boolean | "toggle" | "unknown";
|
||||
|
||||
function normalizePiModelLabel(label: string): string {
|
||||
return label.trim().replace(/[_\s]+/g, " ");
|
||||
}
|
||||
@@ -247,6 +268,20 @@ function normalizePiThinkingOption(value: string | null | undefined): PiThinking
|
||||
return isPiThinkingLevel(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseAutoCompactMode(value: string | undefined): AutoCompactMode {
|
||||
const mode = (value ?? "toggle").trim().toLowerCase();
|
||||
if (mode === "on" || mode === "true" || mode === "enable" || mode === "enabled") {
|
||||
return true;
|
||||
}
|
||||
if (mode === "off" || mode === "false" || mode === "disable" || mode === "disabled") {
|
||||
return false;
|
||||
}
|
||||
if (mode === "toggle") {
|
||||
return "toggle";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function mapThinkingOption(option: (typeof PI_THINKING_OPTIONS)[number]) {
|
||||
const mappedOption = {
|
||||
id: option.id,
|
||||
@@ -922,6 +957,9 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private readonly seenUserEntryIds = new Set<string>();
|
||||
private readonly pendingUserMessages: PendingPiUserMessage[] = [];
|
||||
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
|
||||
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
|
||||
private outOfBandCompactionStarted = false;
|
||||
private outOfBandCompactionCompleted = false;
|
||||
private state: PiSessionState;
|
||||
private closed = false;
|
||||
|
||||
@@ -1127,11 +1165,46 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
const commands = await this.runtimeSession.getCommands();
|
||||
return commands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description ?? command.source,
|
||||
argumentHint: "",
|
||||
}));
|
||||
const mappedCommands = new Map<string, AgentSlashCommand>(
|
||||
PI_HANDLED_BUILTIN_SLASH_COMMANDS.map((command) => [command.name, { ...command }]),
|
||||
);
|
||||
for (const command of commands) {
|
||||
const knownCommand = mappedCommands.get(command.name);
|
||||
mappedCommands.set(command.name, {
|
||||
name: command.name,
|
||||
description: command.description ?? command.source,
|
||||
argumentHint: knownCommand?.argumentHint ?? "",
|
||||
});
|
||||
}
|
||||
return [...mappedCommands.values()];
|
||||
}
|
||||
|
||||
tryHandleOutOfBand(
|
||||
prompt: AgentPromptInput,
|
||||
): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void> } | null {
|
||||
if (typeof prompt !== "string") {
|
||||
return null;
|
||||
}
|
||||
const parsed = this.parseSlashCommandInput(prompt);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const commandName = parsed.commandName.toLowerCase();
|
||||
if (commandName === "compact") {
|
||||
return {
|
||||
run: async ({ emit }) => {
|
||||
await this.executeCompactCommand(parsed.args, emit);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (commandName === "autocompact") {
|
||||
return {
|
||||
run: async ({ emit }) => {
|
||||
await this.executeAutoCompactCommand(parsed.args, emit);
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
@@ -1172,6 +1245,128 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
return this.activeTurnId ?? undefined;
|
||||
}
|
||||
|
||||
private parseSlashCommandInput(text: string): PiSlashCommandInvocation | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
const withoutPrefix = trimmed.slice(1);
|
||||
const firstWhitespaceIdx = withoutPrefix.search(/\s/);
|
||||
const commandName =
|
||||
firstWhitespaceIdx === -1 ? withoutPrefix : withoutPrefix.slice(0, firstWhitespaceIdx);
|
||||
if (!commandName || commandName.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
const rawArgs =
|
||||
firstWhitespaceIdx === -1 ? "" : withoutPrefix.slice(firstWhitespaceIdx + 1).trim();
|
||||
return rawArgs.length > 0 ? { commandName, args: rawArgs } : { commandName };
|
||||
}
|
||||
|
||||
private async executeCompactCommand(
|
||||
customInstructions: string | undefined,
|
||||
emit: (event: AgentStreamEvent) => void,
|
||||
): Promise<void> {
|
||||
if (this.outOfBandCompactionEmit) {
|
||||
throw new Error("A Pi compact command is already running");
|
||||
}
|
||||
this.outOfBandCompactionEmit = emit;
|
||||
this.outOfBandCompactionStarted = false;
|
||||
this.outOfBandCompactionCompleted = false;
|
||||
try {
|
||||
await this.runtimeSession.compact(customInstructions);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
this.outOfBandCompactionEmit === emit &&
|
||||
this.outOfBandCompactionStarted &&
|
||||
!this.outOfBandCompactionCompleted
|
||||
) {
|
||||
this.emitCompactionTimeline({
|
||||
turnId: undefined,
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
trigger: "manual",
|
||||
},
|
||||
});
|
||||
}
|
||||
emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: `[Error] Failed to compact context: ${message}`,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) {
|
||||
this.outOfBandCompactionEmit = null;
|
||||
this.outOfBandCompactionStarted = false;
|
||||
this.outOfBandCompactionCompleted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAutoCompactCommand(
|
||||
mode: string | undefined,
|
||||
emit: (event: AgentStreamEvent) => void,
|
||||
): Promise<void> {
|
||||
let enabled = parseAutoCompactMode(mode);
|
||||
if (enabled === "unknown") {
|
||||
emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "[Error] Usage: /autocompact [on|off|toggle]",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (enabled === "toggle") {
|
||||
const state = await this.runtimeSession.getState();
|
||||
if (typeof state.autoCompactionEnabled !== "boolean") {
|
||||
emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "[Error] Auto-compaction state is unavailable. Use /autocompact on or /autocompact off.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
enabled = !state.autoCompactionEnabled;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.runtimeSession.setAutoCompaction(enabled);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: `[Error] Failed to set auto-compaction: ${message}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.state = {
|
||||
...this.state,
|
||||
autoCompactionEnabled: enabled,
|
||||
};
|
||||
emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: `Auto-compaction ${enabled ? "enabled" : "disabled"}.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async requestEntryCapture(reason: string): Promise<void> {
|
||||
const requestId = randomUUID();
|
||||
const resultPromise = this.waitForExtensionResult(requestId);
|
||||
@@ -1433,9 +1628,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
case "compaction_start":
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
this.emitCompactionTimeline({
|
||||
turnId,
|
||||
item: {
|
||||
type: "compaction",
|
||||
@@ -1445,13 +1638,12 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
});
|
||||
return;
|
||||
case "compaction_end":
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
this.emitCompactionTimeline({
|
||||
turnId,
|
||||
item: {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
trigger: event.reason === "manual" ? "manual" : "auto",
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -1463,6 +1655,37 @@ export class PiRpcAgentSession 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: PI_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;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.emit(event);
|
||||
}
|
||||
|
||||
private handleMessageUpdate(
|
||||
event: Extract<PiAgentSessionEvent, { type: "message_update" }>,
|
||||
turnId: string | undefined,
|
||||
@@ -1616,18 +1839,19 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
|
||||
const launchConfig = withPaseoToolingMcpServer(config, launchContext);
|
||||
const mcpConfig = await this.prepareMcpConfig(launchConfig.cwd, launchConfig.mcpServers);
|
||||
const paseoExtension = createPiPaseoExtensionFile();
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
try {
|
||||
runtimeSession = await this.runtime.startSession({
|
||||
cwd: config.cwd,
|
||||
model: config.model,
|
||||
cwd: launchConfig.cwd,
|
||||
model: launchConfig.model,
|
||||
thinkingOptionId:
|
||||
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
|
||||
normalizePiThinkingOption(launchConfig.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
|
||||
systemPrompt: composeSystemPromptParts(
|
||||
config.systemPrompt,
|
||||
config.daemonAppendSystemPrompt,
|
||||
launchConfig.systemPrompt,
|
||||
launchConfig.daemonAppendSystemPrompt,
|
||||
),
|
||||
env: launchContext?.env,
|
||||
mcpConfigPath: mcpConfig?.path,
|
||||
@@ -1641,7 +1865,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
try {
|
||||
return new PiRpcAgentSession({
|
||||
runtimeSession,
|
||||
config,
|
||||
config: launchConfig,
|
||||
initialState: await runtimeSession.getState(),
|
||||
capabilities: withPiMcpCapability(mcpConfig !== null),
|
||||
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
||||
@@ -1657,7 +1881,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const sessionFile = handle.nativeHandle;
|
||||
if (!sessionFile) {
|
||||
@@ -1667,7 +1891,8 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
|
||||
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides);
|
||||
|
||||
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers);
|
||||
const launchConfig = withPaseoToolingMcpServer(resumeConfig.config, launchContext);
|
||||
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, launchConfig.mcpServers);
|
||||
const paseoExtension = createPiPaseoExtensionFile();
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
try {
|
||||
@@ -1677,8 +1902,8 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
model: resumeConfig.model,
|
||||
thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined,
|
||||
systemPrompt: composeSystemPromptParts(
|
||||
resumeConfig.config.systemPrompt,
|
||||
resumeConfig.config.daemonAppendSystemPrompt,
|
||||
launchConfig.systemPrompt,
|
||||
launchConfig.daemonAppendSystemPrompt,
|
||||
),
|
||||
mcpConfigPath: mcpConfig?.path,
|
||||
extensionPaths: [paseoExtension.path],
|
||||
@@ -1691,7 +1916,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
try {
|
||||
return new PiRpcAgentSession({
|
||||
runtimeSession,
|
||||
config: resumeConfig.config,
|
||||
config: launchConfig,
|
||||
initialState: await runtimeSession.getState(),
|
||||
capabilities: withPiMcpCapability(mcpConfig !== null),
|
||||
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
||||
|
||||
@@ -129,6 +129,17 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) });
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
await this.request({
|
||||
type: "compact",
|
||||
...(customInstructions ? { customInstructions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async setAutoCompaction(enabled: boolean): Promise<void> {
|
||||
await this.request({ type: "set_auto_compaction", enabled });
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
await this.request({ type: "abort" });
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface PiSessionState {
|
||||
thinkingLevel: PiThinkingLevel;
|
||||
isStreaming: boolean;
|
||||
isCompacting: boolean;
|
||||
autoCompactionEnabled?: boolean;
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
@@ -111,6 +112,8 @@ export interface PiRpcSlashCommand {
|
||||
|
||||
export type PiRpcCommand =
|
||||
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
|
||||
| { id?: string; type: "compact"; customInstructions?: string }
|
||||
| { id?: string; type: "set_auto_compaction"; enabled: boolean }
|
||||
| { id?: string; type: "abort" }
|
||||
| { id?: string; type: "get_state" }
|
||||
| { id?: string; type: "get_messages" }
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface PiRuntimeSession {
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<void>;
|
||||
compact(customInstructions?: string): Promise<void>;
|
||||
setAutoCompaction(enabled: boolean): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
getState(): Promise<PiSessionState>;
|
||||
getMessages(): Promise<PiAgentMessage[]>;
|
||||
|
||||
@@ -51,6 +51,8 @@ export class FakePi implements PiRuntime {
|
||||
|
||||
export class FakePiSession implements PiRuntimeSession {
|
||||
readonly prompts: Array<{ message: string; imageCount: number }> = [];
|
||||
readonly compactRequests: Array<{ customInstructions?: string }> = [];
|
||||
readonly setAutoCompactionRequests: boolean[] = [];
|
||||
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
|
||||
readonly setThinkingLevelRequests: string[] = [];
|
||||
readonly treeNavigationRequests: string[] = [];
|
||||
@@ -69,6 +71,8 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
cost: 0,
|
||||
};
|
||||
commands: PiRpcSlashCommand[] = [];
|
||||
compactError: Error | null = null;
|
||||
emitCompactEnd = true;
|
||||
state: PiSessionState;
|
||||
|
||||
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
|
||||
@@ -79,6 +83,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
autoCompactionEnabled: true,
|
||||
sessionFile: launch.session ?? "/tmp/pi-session",
|
||||
sessionId: "pi-session-1",
|
||||
messageCount: 0,
|
||||
@@ -102,6 +107,25 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
this.handleEntryCaptureCommand(message);
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async setAutoCompaction(enabled: boolean): Promise<void> {
|
||||
this.setAutoCompactionRequests.push(enabled);
|
||||
this.state = {
|
||||
...this.state,
|
||||
autoCompactionEnabled: enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.abortRequested = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js";
|
||||
|
||||
const PASEO_MCP_SERVER_NAME = "paseo";
|
||||
const PASEO_MCP_PATHNAME = "/mcp/agents";
|
||||
|
||||
export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig {
|
||||
const mcpServers = config.mcpServers;
|
||||
if (!mcpServers) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const paseoServer = mcpServers[PASEO_MCP_SERVER_NAME];
|
||||
if (!paseoServer || !isInternalPaseoMcpServer(paseoServer)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const nextMcpServers = { ...mcpServers };
|
||||
delete nextMcpServers[PASEO_MCP_SERVER_NAME];
|
||||
|
||||
const next = { ...config };
|
||||
if (Object.keys(nextMcpServers).length > 0) {
|
||||
next.mcpServers = nextMcpServers;
|
||||
} else {
|
||||
delete next.mcpServers;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isInternalPaseoMcpServer(config: McpServerConfig): boolean {
|
||||
if (config.type !== "http" && config.type !== "sse") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(config.url).pathname === PASEO_MCP_PATHNAME;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -233,15 +233,15 @@ describe("archiveIfSafe", () => {
|
||||
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does nothing when the upstream status is unknown", async () => {
|
||||
test("archives when the PR is merged and the upstream branch was deleted", async () => {
|
||||
const harness = createHarness({
|
||||
getSnapshot: async () => createSnapshot({ git: { aheadOfOrigin: null } }),
|
||||
getSnapshot: async () =>
|
||||
createSnapshot({ git: { aheadOfOrigin: null, behindOfOrigin: null } }),
|
||||
});
|
||||
|
||||
await runArchiveIfSafe(harness);
|
||||
|
||||
expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled();
|
||||
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled();
|
||||
expect(harness.deps.archivePaseoWorktree).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("does nothing when the cwd is not a Paseo-owned worktree", async () => {
|
||||
|
||||
@@ -78,10 +78,10 @@ export async function archiveIfSafe(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot.git.isDirty === true || snapshot.git.aheadOfOrigin === null) {
|
||||
if (snapshot.git.isDirty === true) {
|
||||
return;
|
||||
}
|
||||
if (snapshot.git.aheadOfOrigin > 0) {
|
||||
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,10 @@ import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
import { createPaseoToolRegistry } from "./agent/paseo-tooling/registry.js";
|
||||
import { mountPaseoToolingHttpAdapter } from "./agent/paseo-tooling/http-adapter.js";
|
||||
import { PaseoToolingRuntime } from "./agent/paseo-tooling/runtime.js";
|
||||
import type { PaseoToolingRegistryOptions } from "./agent/paseo-tooling/registry.js";
|
||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
@@ -165,6 +169,14 @@ function createAgentMcpBaseUrl(listenTarget: ListenTarget | null): string | null
|
||||
).toString();
|
||||
}
|
||||
|
||||
function createDaemonHttpBaseUrl(listenTarget: ListenTarget | null): string | null {
|
||||
if (!listenTarget || listenTarget.type !== "tcp") {
|
||||
return null;
|
||||
}
|
||||
const host = resolveAgentMcpClientHost(listenTarget.host);
|
||||
return `http://${formatHostForHttpUrl(host)}:${listenTarget.port}`;
|
||||
}
|
||||
|
||||
function summarizeAgentMcpDebugMessage(body: unknown): Record<string, unknown> {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return {
|
||||
@@ -542,10 +554,13 @@ export async function createPaseoDaemon(
|
||||
extraClients: config.agentClients,
|
||||
});
|
||||
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
|
||||
const paseoToolingRuntime = new PaseoToolingRuntime();
|
||||
paseoToolingRuntime.setEnabled(config.mcpInjectIntoAgents !== false);
|
||||
const agentManager = new AgentManager({
|
||||
clients: initialAgentManagerState.clients,
|
||||
providerDefinitions: initialAgentManagerState.providerDefinitions,
|
||||
registry: agentStorage,
|
||||
paseoToolingRuntime,
|
||||
appendSystemPrompt: config.appendSystemPrompt,
|
||||
logger,
|
||||
});
|
||||
@@ -689,79 +704,85 @@ export async function createPaseoDaemon(
|
||||
const agentMcpRoute = "/mcp/agents";
|
||||
const agentMcpTransports: AgentMcpTransportMap = new Map();
|
||||
|
||||
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
||||
const agentMcpServer = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
scheduleService,
|
||||
providerSnapshotManager,
|
||||
github,
|
||||
workspaceGitService,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
createPaseoWorktree: async (input, serviceOptions) => {
|
||||
return createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
||||
return createRegisteredPaseoWorktree(workflowInput, {
|
||||
github,
|
||||
...(workflowOptions?.resolveDefaultBranch
|
||||
? {
|
||||
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||
}
|
||||
: {}),
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
},
|
||||
warmWorkspaceGitData: async (workspace) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
|
||||
);
|
||||
},
|
||||
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
|
||||
);
|
||||
void emitOptions;
|
||||
},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: emitExternalSessionMessage,
|
||||
sessionLogger: logger,
|
||||
terminalManager,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
getDaemonTcpHost: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
|
||||
serviceProxyPublicBaseUrl,
|
||||
onScriptsChanged: null,
|
||||
const buildPaseoToolingRegistryOptions = (
|
||||
callerAgentId?: string,
|
||||
): PaseoToolingRegistryOptions => ({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
terminalManager,
|
||||
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
||||
scheduleService,
|
||||
providerSnapshotManager,
|
||||
github,
|
||||
workspaceGitService,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
createPaseoWorktree: async (input, serviceOptions) => {
|
||||
return createPaseoWorktreeWorkflow(
|
||||
{
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
||||
return createRegisteredPaseoWorktree(workflowInput, {
|
||||
github,
|
||||
...(workflowOptions?.resolveDefaultBranch
|
||||
? {
|
||||
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
||||
}
|
||||
: {}),
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
});
|
||||
},
|
||||
input,
|
||||
serviceOptions,
|
||||
);
|
||||
},
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
callerAgentId,
|
||||
enableVoiceTools: false,
|
||||
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
|
||||
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
|
||||
logger,
|
||||
});
|
||||
warmWorkspaceGitData: async (workspace) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
|
||||
);
|
||||
},
|
||||
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
|
||||
await Promise.all(
|
||||
wsServer
|
||||
?.listActiveSessions()
|
||||
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
|
||||
);
|
||||
void emitOptions;
|
||||
},
|
||||
cacheWorkspaceSetupSnapshot: () => {},
|
||||
emit: emitExternalSessionMessage,
|
||||
sessionLogger: logger,
|
||||
terminalManager,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
serviceProxy,
|
||||
scriptRuntimeStore,
|
||||
getDaemonTcpPort: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
||||
getDaemonTcpHost: () =>
|
||||
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
|
||||
serviceProxyPublicBaseUrl,
|
||||
onScriptsChanged: null,
|
||||
},
|
||||
input,
|
||||
serviceOptions,
|
||||
);
|
||||
},
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
callerAgentId,
|
||||
enableVoiceTools: false,
|
||||
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
|
||||
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
|
||||
logger,
|
||||
});
|
||||
|
||||
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
||||
const agentMcpServer = await createAgentMcpServer(
|
||||
buildPaseoToolingRegistryOptions(callerAgentId),
|
||||
);
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
@@ -870,6 +891,16 @@ export async function createPaseoDaemon(
|
||||
void runAgentMcpRequest(req, res);
|
||||
};
|
||||
|
||||
mountPaseoToolingHttpAdapter({
|
||||
app,
|
||||
registry: createPaseoToolRegistry({
|
||||
...buildPaseoToolingRegistryOptions(),
|
||||
agentScopedTools: true,
|
||||
}),
|
||||
runtime: paseoToolingRuntime,
|
||||
logger,
|
||||
});
|
||||
|
||||
app.post(agentMcpRoute, handleAgentMcpRequest);
|
||||
app.get(agentMcpRoute, handleAgentMcpRequest);
|
||||
app.delete(agentMcpRoute, handleAgentMcpRequest);
|
||||
@@ -917,10 +948,18 @@ export async function createPaseoDaemon(
|
||||
const logAndResolve = async () => {
|
||||
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
||||
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
|
||||
const httpBaseUrl = createDaemonHttpBaseUrl(boundListenTarget);
|
||||
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
|
||||
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
|
||||
paseoToolingRuntime.setEndpoints({
|
||||
mcpBaseUrl: agentMcpBaseUrl,
|
||||
httpBaseUrl: config.mcpInjectIntoAgents === false ? null : httpBaseUrl,
|
||||
});
|
||||
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
||||
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
||||
paseoToolingRuntime.setEnabled(value !== false);
|
||||
paseoToolingRuntime.setEndpoints({
|
||||
mcpBaseUrl: value ? mcpBaseUrl : null,
|
||||
httpBaseUrl: value ? httpBaseUrl : null,
|
||||
});
|
||||
});
|
||||
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
|
||||
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
|
||||
|
||||
@@ -137,6 +137,44 @@ test("createAgent without an initial prompt returns an idle snapshot", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("DaemonClient uploads file bytes to daemon temp storage", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
const result = await client.uploadFile({
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
bytes: new TextEncoder().encode("hello world"),
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId: "req-upload-e2e",
|
||||
chunkSize: 5,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
requestId: "req-upload-e2e",
|
||||
file: {
|
||||
type: "uploaded_file",
|
||||
id: "upload_req-upload-e2e",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
path: path.join(daemon.paseoHome, "uploads", "upload_req-upload-e2e", "notes.txt"),
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
await expect(readFile(result.file?.path ?? "", "utf8")).resolves.toBe("hello world");
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -24,6 +24,7 @@ process.env.PASEO_SUPERVISED = "0";
|
||||
|
||||
const PI_TEST_TIMEOUT_MS = 240_000;
|
||||
const PI_REAL_TEST_MODEL = getRealProviderConfig("pi").model;
|
||||
const PI_FREE_COMPACTION_TEST_MODEL = "openrouter/openai/gpt-oss-20b:free";
|
||||
|
||||
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
@@ -31,6 +32,18 @@ function tmpCwd(prefix = "daemon-real-pi-"): string {
|
||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function writePiCompactionSettings(
|
||||
cwd: string,
|
||||
compaction: { enabled: boolean; reserveTokens?: number; keepRecentTokens?: number },
|
||||
): void {
|
||||
mkdirSync(path.join(cwd, ".pi"), { recursive: true });
|
||||
writeFileSync(path.join(cwd, ".pi/settings.json"), JSON.stringify({ compaction }, null, 2));
|
||||
}
|
||||
|
||||
function createPiClient(): AgentClient {
|
||||
return createRealProviderClient("pi", pino({ level: "silent" }));
|
||||
}
|
||||
@@ -68,6 +81,27 @@ async function fetchCanonicalTimeline(
|
||||
return timeline.entries.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
async function waitForTimelineItem(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
predicate: (item: AgentTimelineItem) => boolean,
|
||||
timeoutMs = PI_TEST_TIMEOUT_MS,
|
||||
): Promise<AgentTimelineItem> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastItems: AgentTimelineItem[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
lastItems = await fetchCanonicalTimeline(client, agentId);
|
||||
const item = lastItems.find(predicate);
|
||||
if (item) {
|
||||
return item;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for Pi timeline item. Last timeline: ${JSON.stringify(lastItems)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function withConnectedPiDaemon(
|
||||
run: (context: { client: DaemonClient }) => Promise<void>,
|
||||
): Promise<void> {
|
||||
@@ -101,6 +135,200 @@ beforeEach((context) => {
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"real Pi daemon lists Paseo-handled compact slash commands",
|
||||
async () => {
|
||||
const cwd = tmpCwd("pi-compact-commands-");
|
||||
|
||||
try {
|
||||
await withConnectedPiDaemon(async ({ client }) => {
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "pi-compact-commands",
|
||||
provider: "pi",
|
||||
model: PI_FREE_COMPACTION_TEST_MODEL,
|
||||
});
|
||||
|
||||
const result = await client.listCommands(agent.id);
|
||||
expect(result.commands).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "compact",
|
||||
description: "Manually compact the session context",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "autocompact",
|
||||
description: "Toggle automatic context compaction",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
PI_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"real Pi daemon executes manual compact out-of-band instead of prompt text",
|
||||
async () => {
|
||||
const cwd = tmpCwd("pi-manual-compact-");
|
||||
|
||||
try {
|
||||
writePiCompactionSettings(cwd, {
|
||||
enabled: true,
|
||||
keepRecentTokens: 1,
|
||||
});
|
||||
|
||||
await withConnectedPiDaemon(async ({ client }) => {
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "pi-manual-compact",
|
||||
provider: "pi",
|
||||
model: PI_FREE_COMPACTION_TEST_MODEL,
|
||||
});
|
||||
|
||||
await client.sendMessage(agent.id, "Reply exactly: compact-ready");
|
||||
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
|
||||
await client.sendMessage(agent.id, "/compact summarize this e2e run");
|
||||
|
||||
await waitForTimelineItem(
|
||||
client,
|
||||
agent.id,
|
||||
(item) => item.type === "compaction" && item.status === "completed",
|
||||
);
|
||||
|
||||
const items = await fetchCanonicalTimeline(client, agent.id);
|
||||
expect(items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "compaction",
|
||||
status: "loading",
|
||||
trigger: "manual",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
items.some((item) => item.type === "user_message" && item.text.includes("/compact")),
|
||||
).toBe(false);
|
||||
expect(
|
||||
items.some(
|
||||
(item) => item.type === "assistant_message" && item.text.includes("Failed to compact"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
PI_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"real Pi daemon toggles auto-compaction out-of-band instead of prompt text",
|
||||
async () => {
|
||||
const cwd = tmpCwd("pi-autocompact-toggle-");
|
||||
|
||||
try {
|
||||
await withConnectedPiDaemon(async ({ client }) => {
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "pi-autocompact-toggle",
|
||||
provider: "pi",
|
||||
model: PI_FREE_COMPACTION_TEST_MODEL,
|
||||
});
|
||||
|
||||
await client.sendMessage(agent.id, "/autocompact off");
|
||||
await waitForTimelineItem(
|
||||
client,
|
||||
agent.id,
|
||||
(item) => item.type === "assistant_message" && item.text === "Auto-compaction disabled.",
|
||||
);
|
||||
|
||||
await client.sendMessage(agent.id, "/autocompact");
|
||||
await waitForTimelineItem(
|
||||
client,
|
||||
agent.id,
|
||||
(item) => item.type === "assistant_message" && item.text === "Auto-compaction enabled.",
|
||||
);
|
||||
|
||||
const items = await fetchCanonicalTimeline(client, agent.id);
|
||||
expect(
|
||||
items.some((item) => item.type === "user_message" && item.text.includes("/autocompact")),
|
||||
).toBe(false);
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
PI_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"real Pi daemon surfaces automatic threshold compaction events",
|
||||
async () => {
|
||||
const cwd = tmpCwd("pi-auto-compact-");
|
||||
|
||||
try {
|
||||
writePiCompactionSettings(cwd, {
|
||||
enabled: true,
|
||||
reserveTokens: 126_000,
|
||||
keepRecentTokens: 1,
|
||||
});
|
||||
|
||||
await withConnectedPiDaemon(async ({ client }) => {
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "pi-auto-compact",
|
||||
provider: "pi",
|
||||
model: PI_FREE_COMPACTION_TEST_MODEL,
|
||||
});
|
||||
|
||||
await client.sendMessage(agent.id, "Reply exactly: auto-compact-ready");
|
||||
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
|
||||
expect(finish.status, JSON.stringify(finish)).toBe("idle");
|
||||
|
||||
await waitForTimelineItem(
|
||||
client,
|
||||
agent.id,
|
||||
(item) => item.type === "compaction" && item.status === "completed",
|
||||
);
|
||||
|
||||
const items = await fetchCanonicalTimeline(client, agent.id);
|
||||
expect(items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "compaction",
|
||||
status: "loading",
|
||||
trigger: "auto",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
items.some(
|
||||
(item) =>
|
||||
item.type === "assistant_message" && item.text.includes("Auto-compaction failed"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
PI_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
test(
|
||||
"bash tool call records completed shell detail and output",
|
||||
async () => {
|
||||
|
||||
@@ -19,12 +19,28 @@ type SupervisorLifecycleMessage =
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
interface SupervisorHeartbeatMessage {
|
||||
type: "paseo:supervisor-heartbeat";
|
||||
}
|
||||
|
||||
interface BootstrapResult {
|
||||
paseoHome: string;
|
||||
logger: ReturnType<typeof createRootLogger>;
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (typeof err === "object" && err !== null && "code" in err && err.code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrapFromEnvironment(): BootstrapResult {
|
||||
try {
|
||||
const paseoHome = resolvePaseoHome();
|
||||
@@ -150,6 +166,46 @@ async function main() {
|
||||
beginShutdown("restart lifecycle intent", { successExitCode: 0 });
|
||||
};
|
||||
|
||||
const installSupervisorLivenessGuard = () => {
|
||||
if (typeof process.send !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const supervisorPid = process.ppid;
|
||||
let lastSupervisorHeartbeatAt = Date.now();
|
||||
let supervisorShutdownRequested = false;
|
||||
const requestSupervisorShutdown = (reason: string) => {
|
||||
if (supervisorShutdownRequested) {
|
||||
return;
|
||||
}
|
||||
supervisorShutdownRequested = true;
|
||||
beginShutdown(reason);
|
||||
};
|
||||
|
||||
process.on("message", (message: unknown) => {
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"type" in message &&
|
||||
(message as SupervisorHeartbeatMessage).type === "paseo:supervisor-heartbeat"
|
||||
) {
|
||||
lastSupervisorHeartbeatAt = Date.now();
|
||||
}
|
||||
});
|
||||
process.on("disconnect", () => requestSupervisorShutdown("supervisor disconnect"));
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const ipcConnected = typeof process.connected === "boolean" ? process.connected : true;
|
||||
const heartbeatExpired = Date.now() - lastSupervisorHeartbeatAt > 3500;
|
||||
if (ipcConnected === false || !isPidAlive(supervisorPid) || heartbeatExpired) {
|
||||
requestSupervisorShutdown("supervisor disconnect");
|
||||
}
|
||||
}, 1000);
|
||||
timer.unref();
|
||||
};
|
||||
|
||||
installSupervisorLivenessGuard();
|
||||
|
||||
try {
|
||||
daemon = await createPaseoDaemon(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
decodeFileTransferFrame,
|
||||
encodeFileTransferFrame,
|
||||
FileTransferOpcode,
|
||||
type FileTransferFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import { FileUploadStore } from "./index.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
describe("file uploads", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("stores chunked upload bytes and returns an uploaded-file attachment", async () => {
|
||||
const paseoHome = makePaseoHome();
|
||||
const uploads = new FileUploadStore({ paseoHome });
|
||||
|
||||
uploads.beginUpload({
|
||||
type: "file.upload.request",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId: "req-upload",
|
||||
});
|
||||
await expect(uploads.receiveFrame(uploadBegins("req-upload"))).resolves.toBeNull();
|
||||
await expect(uploads.receiveFrame(uploadChunk("req-upload", "hello"))).resolves.toBeNull();
|
||||
await expect(uploads.receiveFrame(uploadChunk("req-upload", " world"))).resolves.toBeNull();
|
||||
|
||||
const path = join(paseoHome, "uploads", "upload_req-upload", "notes.txt");
|
||||
await expect(uploads.receiveFrame(uploadEnds("req-upload"))).resolves.toEqual({
|
||||
type: "file.upload.response",
|
||||
payload: {
|
||||
requestId: "req-upload",
|
||||
file: {
|
||||
type: "uploaded_file",
|
||||
id: "upload_req-upload",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
path,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
expect(readFileSync(path, "utf8")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("rejects chunks beyond the declared size and removes the partial file", async () => {
|
||||
const paseoHome = makePaseoHome();
|
||||
const uploads = new FileUploadStore({ paseoHome });
|
||||
|
||||
uploads.beginUpload({
|
||||
type: "file.upload.request",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 5,
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId: "req-overflow",
|
||||
});
|
||||
await expect(uploads.receiveFrame(uploadBegins("req-overflow"))).resolves.toBeNull();
|
||||
|
||||
const uploadDir = join(paseoHome, "uploads", "upload_req-overflow");
|
||||
const path = join(uploadDir, "notes.txt");
|
||||
await expect(uploads.receiveFrame(uploadChunk("req-overflow", "hello!"))).resolves.toEqual({
|
||||
type: "file.upload.response",
|
||||
payload: {
|
||||
requestId: "req-overflow",
|
||||
file: null,
|
||||
error: "Upload exceeded declared size: expected 5, received 6.",
|
||||
},
|
||||
});
|
||||
expect(existsSync(path)).toBe(false);
|
||||
expect(existsSync(uploadDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves chunk order when frames arrive before earlier disk writes finish", async () => {
|
||||
const paseoHome = makePaseoHome();
|
||||
const uploads = new FileUploadStore({ paseoHome });
|
||||
|
||||
uploads.beginUpload({
|
||||
type: "file.upload.request",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId: "req-queued",
|
||||
});
|
||||
|
||||
const results = await Promise.all([
|
||||
uploads.receiveFrame(uploadBegins("req-queued")),
|
||||
uploads.receiveFrame(uploadChunk("req-queued", "hello")),
|
||||
uploads.receiveFrame(uploadChunk("req-queued", " world")),
|
||||
uploads.receiveFrame(uploadEnds("req-queued")),
|
||||
]);
|
||||
|
||||
expect(results.slice(0, 3)).toEqual([null, null, null]);
|
||||
expect(results[3]?.payload.error).toBeNull();
|
||||
expect(readFileSync(join(paseoHome, "uploads", "upload_req-queued", "notes.txt"), "utf8")).toBe(
|
||||
"hello world",
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces duplicate upload starts without letting the old stale timeout evict the replacement", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const paseoHome = makePaseoHome();
|
||||
const uploads = new FileUploadStore({ paseoHome, staleUploadTimeoutMs: 50 });
|
||||
|
||||
uploads.beginUpload({
|
||||
type: "file.upload.request",
|
||||
fileName: "old.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 3,
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId: "req-duplicate",
|
||||
});
|
||||
await expect(uploads.receiveFrame(uploadBegins("req-duplicate"))).resolves.toBeNull();
|
||||
await expect(uploads.receiveFrame(uploadChunk("req-duplicate", "old"))).resolves.toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
uploads.beginUpload({
|
||||
type: "file.upload.request",
|
||||
fileName: "new.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 3,
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId: "req-duplicate",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
|
||||
const path = join(paseoHome, "uploads", "upload_req-duplicate_2", "new.txt");
|
||||
await expect(uploads.receiveFrame(uploadBegins("req-duplicate"))).resolves.toBeNull();
|
||||
await expect(uploads.receiveFrame(uploadChunk("req-duplicate", "new"))).resolves.toBeNull();
|
||||
await expect(uploads.receiveFrame(uploadEnds("req-duplicate"))).resolves.toEqual({
|
||||
type: "file.upload.response",
|
||||
payload: {
|
||||
requestId: "req-duplicate",
|
||||
file: {
|
||||
type: "uploaded_file",
|
||||
id: "upload_req-duplicate_2",
|
||||
fileName: "new.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 3,
|
||||
path,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
expect(readFileSync(path, "utf8")).toBe("new");
|
||||
});
|
||||
|
||||
it("keeps an active upload alive beyond the initial stale timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const paseoHome = makePaseoHome();
|
||||
const uploads = new FileUploadStore({ paseoHome, staleUploadTimeoutMs: 50 });
|
||||
|
||||
uploads.beginUpload({
|
||||
type: "file.upload.request",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
requestId: "req-slow-active",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await expect(uploads.receiveFrame(uploadBegins("req-slow-active"))).resolves.toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
await expect(uploads.receiveFrame(uploadChunk("req-slow-active", "hello"))).resolves.toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(30);
|
||||
await expect(
|
||||
uploads.receiveFrame(uploadChunk("req-slow-active", " world")),
|
||||
).resolves.toBeNull();
|
||||
|
||||
const path = join(paseoHome, "uploads", "upload_req-slow-active", "notes.txt");
|
||||
await expect(uploads.receiveFrame(uploadEnds("req-slow-active"))).resolves.toEqual({
|
||||
type: "file.upload.response",
|
||||
payload: {
|
||||
requestId: "req-slow-active",
|
||||
file: {
|
||||
type: "uploaded_file",
|
||||
id: "upload_req-slow-active",
|
||||
fileName: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 11,
|
||||
path,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
expect(readFileSync(path, "utf8")).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
function makePaseoHome(): string {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "file-upload-test-")));
|
||||
tempDirs.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function uploadBegins(requestId: string): FileTransferFrame {
|
||||
return decodeUploadFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId,
|
||||
metadata: {
|
||||
mime: "text/plain",
|
||||
size: 11,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
fileName: "notes.txt",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function uploadChunk(requestId: string, text: string): FileTransferFrame {
|
||||
return decodeUploadFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId,
|
||||
payload: new TextEncoder().encode(text),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function uploadEnds(requestId: string): FileTransferFrame {
|
||||
return decodeUploadFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function decodeUploadFrame(bytes: Uint8Array): FileTransferFrame {
|
||||
const frame = decodeFileTransferFrame(bytes);
|
||||
if (!frame) {
|
||||
throw new Error("Expected file transfer frame");
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { appendFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
import { FileTransferOpcode, type FileTransferFrame } from "@getpaseo/protocol/binary-frames/index";
|
||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||
import type { FileUploadRequest, FileUploadResponse } from "../messages.js";
|
||||
|
||||
interface FileUploadStoreOptions {
|
||||
paseoHome: string;
|
||||
staleUploadTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface PendingUpload {
|
||||
requestId: string;
|
||||
id: string;
|
||||
attempt: number;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
path: string;
|
||||
receivedBytes: number;
|
||||
started: boolean;
|
||||
staleTimeout: ReturnType<typeof setTimeout>;
|
||||
queue: Promise<void>;
|
||||
}
|
||||
|
||||
export class FileUploadStore {
|
||||
private static readonly defaultStaleUploadTimeoutMs = 10 * 60 * 1000;
|
||||
|
||||
private readonly paseoHome: string;
|
||||
private readonly staleUploadTimeoutMs: number;
|
||||
private readonly pending = new Map<string, PendingUpload>();
|
||||
|
||||
constructor(options: FileUploadStoreOptions) {
|
||||
this.paseoHome = options.paseoHome;
|
||||
this.staleUploadTimeoutMs =
|
||||
options.staleUploadTimeoutMs ?? FileUploadStore.defaultStaleUploadTimeoutMs;
|
||||
}
|
||||
|
||||
beginUpload(request: FileUploadRequest): void {
|
||||
const existingUpload = this.pending.get(request.requestId);
|
||||
if (existingUpload) {
|
||||
this.clearPendingUpload(existingUpload);
|
||||
void existingUpload.queue.then(() => this.removeUploadDirectory(existingUpload));
|
||||
}
|
||||
|
||||
const fileName = sanitizeFileName(request.fileName);
|
||||
const attempt = existingUpload ? existingUpload.attempt + 1 : 1;
|
||||
const id = buildUploadId(request.requestId, attempt);
|
||||
const uploadDir = join(this.paseoHome, "uploads", id);
|
||||
const upload: PendingUpload = {
|
||||
requestId: request.requestId,
|
||||
id,
|
||||
attempt,
|
||||
fileName,
|
||||
mimeType: request.mimeType,
|
||||
size: request.size,
|
||||
path: join(uploadDir, fileName),
|
||||
receivedBytes: 0,
|
||||
started: false,
|
||||
staleTimeout: this.createStaleUploadTimeout(request.requestId),
|
||||
queue: Promise.resolve(),
|
||||
};
|
||||
this.pending.set(request.requestId, upload);
|
||||
}
|
||||
|
||||
async receiveFrame(frame: FileTransferFrame): Promise<FileUploadResponse | null> {
|
||||
const upload = this.pending.get(frame.requestId);
|
||||
if (!upload) {
|
||||
return null;
|
||||
}
|
||||
this.refreshStaleUploadTimeout(upload);
|
||||
|
||||
const operation = upload.queue.then(() => this.applyFrame(upload, frame));
|
||||
upload.queue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async applyFrame(
|
||||
upload: PendingUpload,
|
||||
frame: FileTransferFrame,
|
||||
): Promise<FileUploadResponse | null> {
|
||||
if (this.pending.get(upload.requestId) !== upload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (frame.opcode === FileTransferOpcode.FileBegin) {
|
||||
await this.startWriting(upload);
|
||||
return null;
|
||||
}
|
||||
if (frame.opcode === FileTransferOpcode.FileChunk) {
|
||||
await this.writeChunk(upload, frame.payload);
|
||||
return null;
|
||||
}
|
||||
return await this.completeUpload(upload);
|
||||
} catch (error) {
|
||||
await this.removeFailedUpload(upload);
|
||||
return buildUploadResponse(upload, getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async startWriting(upload: PendingUpload): Promise<void> {
|
||||
await mkdir(join(this.paseoHome, "uploads", upload.id), { recursive: true });
|
||||
await writeFile(upload.path, new Uint8Array());
|
||||
upload.started = true;
|
||||
}
|
||||
|
||||
private async writeChunk(upload: PendingUpload, bytes: Uint8Array): Promise<void> {
|
||||
if (!upload.started) {
|
||||
throw new Error("Upload chunks arrived before file begin.");
|
||||
}
|
||||
const nextReceivedBytes = upload.receivedBytes + bytes.byteLength;
|
||||
if (nextReceivedBytes > upload.size) {
|
||||
throw new Error(
|
||||
`Upload exceeded declared size: expected ${upload.size}, received ${nextReceivedBytes}.`,
|
||||
);
|
||||
}
|
||||
await appendFile(upload.path, bytes);
|
||||
upload.receivedBytes += bytes.byteLength;
|
||||
}
|
||||
|
||||
private async completeUpload(upload: PendingUpload): Promise<FileUploadResponse> {
|
||||
this.clearPendingUpload(upload);
|
||||
if (upload.receivedBytes !== upload.size) {
|
||||
await this.removeUploadDirectory(upload);
|
||||
return buildUploadResponse(
|
||||
upload,
|
||||
`Upload size mismatch: expected ${upload.size}, received ${upload.receivedBytes}.`,
|
||||
);
|
||||
}
|
||||
return buildUploadResponse(upload, null);
|
||||
}
|
||||
|
||||
private createStaleUploadTimeout(requestId: string): ReturnType<typeof setTimeout> {
|
||||
const timeout = setTimeout(() => {
|
||||
this.expireStaleUpload(requestId);
|
||||
}, this.staleUploadTimeoutMs);
|
||||
timeout.unref?.();
|
||||
return timeout;
|
||||
}
|
||||
|
||||
private refreshStaleUploadTimeout(upload: PendingUpload): void {
|
||||
clearTimeout(upload.staleTimeout);
|
||||
upload.staleTimeout = this.createStaleUploadTimeout(upload.requestId);
|
||||
}
|
||||
|
||||
private expireStaleUpload(requestId: string): void {
|
||||
const upload = this.pending.get(requestId);
|
||||
if (!upload) {
|
||||
return;
|
||||
}
|
||||
this.clearPendingUpload(upload);
|
||||
const cleanup = upload.queue.then(
|
||||
() => this.removeUploadDirectory(upload),
|
||||
() => this.removeUploadDirectory(upload),
|
||||
);
|
||||
upload.queue = cleanup.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private clearPendingUpload(upload: PendingUpload): void {
|
||||
clearTimeout(upload.staleTimeout);
|
||||
if (this.pending.get(upload.requestId) === upload) {
|
||||
this.pending.delete(upload.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private async removeFailedUpload(upload: PendingUpload): Promise<void> {
|
||||
this.clearPendingUpload(upload);
|
||||
await this.removeUploadDirectory(upload);
|
||||
}
|
||||
|
||||
private async removeUploadDirectory(upload: PendingUpload): Promise<void> {
|
||||
await rm(join(this.paseoHome, "uploads", upload.id), { recursive: true, force: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUploadResponse(upload: PendingUpload, error: string | null): FileUploadResponse {
|
||||
return {
|
||||
type: "file.upload.response",
|
||||
payload: {
|
||||
requestId: upload.requestId,
|
||||
file: error
|
||||
? null
|
||||
: {
|
||||
type: "uploaded_file",
|
||||
id: upload.id,
|
||||
fileName: upload.fileName,
|
||||
mimeType: upload.mimeType,
|
||||
size: upload.size,
|
||||
path: upload.path,
|
||||
},
|
||||
error,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeUploadId(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "file";
|
||||
}
|
||||
|
||||
function buildUploadId(requestId: string, attempt: number): string {
|
||||
const baseId = `upload_${sanitizeUploadId(requestId)}`;
|
||||
return attempt === 1 ? baseId : `${baseId}_${attempt}`;
|
||||
}
|
||||
|
||||
function sanitizeFileName(value: string): string {
|
||||
const name = basename(value)
|
||||
.replace(/[^a-zA-Z0-9._ -]/g, "_")
|
||||
.trim();
|
||||
return name.length > 0 && name !== "." && name !== ".." ? name : "upload";
|
||||
}
|
||||
@@ -95,6 +95,54 @@ describe("persistence hooks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("buildConfigOverrides drops persisted internal paseo MCP server", () => {
|
||||
const record = createRecord({
|
||||
config: {
|
||||
modeId: "default",
|
||||
model: "gpt-5.4-mini",
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=stale-agent",
|
||||
},
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildConfigOverrides(record).mcpServers).toEqual({
|
||||
custom: {
|
||||
type: "stdio",
|
||||
command: "custom-mcp",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("buildConfigOverrides preserves user-provided paseo MCP server", () => {
|
||||
const record = createRecord({
|
||||
config: {
|
||||
modeId: "default",
|
||||
model: "gpt-5.4-mini",
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildConfigOverrides(record).mcpServers).toEqual({
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "https://example.com/custom-paseo",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSessionConfig accepts providers from the canonical manifest", () => {
|
||||
const record = createRecord({
|
||||
provider: "claude",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import { stripInternalPaseoMcpServer } from "./agent/runtime-mcp-config.js";
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
@@ -49,7 +50,8 @@ export function attachAgentStoragePersistence(
|
||||
}
|
||||
|
||||
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
|
||||
return {
|
||||
return stripInternalPaseoMcpServer({
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
|
||||
model: record.config?.model ?? undefined,
|
||||
@@ -58,7 +60,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
|
||||
extra: record.config?.extra ?? undefined,
|
||||
systemPrompt: record.config?.systemPrompt ?? undefined,
|
||||
mcpServers: record.config?.mcpServers ?? undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSessionConfig(
|
||||
@@ -71,7 +73,7 @@ export function buildSessionConfig(
|
||||
return null;
|
||||
}
|
||||
const overrides = buildConfigOverrides(record);
|
||||
return {
|
||||
return stripInternalPaseoMcpServer({
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
modeId: overrides.modeId,
|
||||
@@ -81,7 +83,7 @@ export function buildSessionConfig(
|
||||
extra: overrides.extra,
|
||||
systemPrompt: overrides.systemPrompt,
|
||||
mcpServers: overrides.mcpServers,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function isStoredAgentProviderAvailable(
|
||||
|
||||
@@ -236,6 +236,7 @@ interface SessionForTestOptions {
|
||||
providerSnapshotManager?: ProviderSnapshotManager;
|
||||
stt?: SessionOptions["stt"];
|
||||
voice?: SessionOptions["voice"];
|
||||
paseoHome?: string;
|
||||
messages?: unknown[];
|
||||
binaryMessages?: Uint8Array[];
|
||||
}
|
||||
@@ -272,7 +273,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
logger,
|
||||
downloadTokenStore: asDownloadTokenStore(),
|
||||
pushTokenStore: asPushTokenStore(),
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
paseoHome: options.paseoHome ?? "/tmp/paseo-home",
|
||||
agentManager: asAgentManager({
|
||||
listAgents: vi.fn(() => []),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type SessionOutboundMessage,
|
||||
type FileExplorerRequest,
|
||||
type FileDownloadTokenRequest,
|
||||
type FileUploadRequest,
|
||||
type GitSetupOptions,
|
||||
type CheckoutRenameBranchRequest,
|
||||
type StartWorkspaceScriptRequest,
|
||||
@@ -30,10 +31,12 @@ import {
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { TerminalSessionController } from "../terminal/terminal-session-controller.js";
|
||||
import {
|
||||
type BinaryFrame,
|
||||
encodeFileTransferFrame,
|
||||
FileTransferOpcode,
|
||||
type TerminalStreamFrame,
|
||||
type FileTransferFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import { FileUploadStore } from "./file-upload/index.js";
|
||||
import { CursorError } from "./pagination/cursor.js";
|
||||
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
|
||||
import { TTSManager } from "./agent/tts-manager.js";
|
||||
@@ -809,6 +812,7 @@ export class Session {
|
||||
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
|
||||
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
|
||||
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
|
||||
private readonly fileUploads: FileUploadStore;
|
||||
private readonly workspaceDirectory: WorkspaceDirectory;
|
||||
private registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void;
|
||||
private unregisterVoiceSpeakHandler?: (agentId: string) => void;
|
||||
@@ -877,6 +881,7 @@ export class Session {
|
||||
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.pushTokenStore = pushTokenStore;
|
||||
this.fileUploads = new FileUploadStore({ paseoHome });
|
||||
this.paseoHome = paseoHome;
|
||||
this.worktreesRoot = worktreesRoot;
|
||||
this.sessionLogger = logger.child({
|
||||
@@ -2108,6 +2113,9 @@ export class Session {
|
||||
return this.handleProjectIconRequest(msg);
|
||||
case "file_download_token_request":
|
||||
return this.handleFileDownloadTokenRequest(msg);
|
||||
case "file.upload.request":
|
||||
this.handleFileUploadRequest(msg);
|
||||
return undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -2212,8 +2220,12 @@ export class Session {
|
||||
this.peakInflightRequests = this.inflightRequests;
|
||||
}
|
||||
|
||||
public handleBinaryFrame(frame: TerminalStreamFrame): void {
|
||||
this.terminalController.handleBinaryFrame(frame);
|
||||
public async handleBinaryFrame(binaryFrame: BinaryFrame): Promise<void> {
|
||||
if (binaryFrame.kind === "file_transfer") {
|
||||
await this.handleFileTransferFrame(binaryFrame.frame);
|
||||
return;
|
||||
}
|
||||
this.terminalController.handleBinaryFrame(binaryFrame.frame);
|
||||
}
|
||||
|
||||
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> {
|
||||
@@ -5823,6 +5835,17 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private handleFileUploadRequest(request: FileUploadRequest): void {
|
||||
this.fileUploads.beginUpload(request);
|
||||
}
|
||||
|
||||
private async handleFileTransferFrame(frame: FileTransferFrame): Promise<void> {
|
||||
const response = await this.fileUploads.receiveFrame(frame);
|
||||
if (response) {
|
||||
this.emit(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle project icon request for a given cwd
|
||||
*/
|
||||
|
||||
@@ -156,6 +156,7 @@ export async function initializeLocalSpeechServices(params: {
|
||||
|
||||
const workerClient = localConfig
|
||||
? new LocalSpeechWorkerClient({
|
||||
logger,
|
||||
config: {
|
||||
modelsDir: localConfig.modelsDir,
|
||||
voiceSttModel: localModels.voiceLocalSttModel,
|
||||
|
||||
@@ -20,6 +20,8 @@ import { bufferToWorkerBytes, workerBytesToBuffer } from "./worker-bytes.js";
|
||||
class FakeLocalSpeechWorker extends EventEmitter {
|
||||
public connected = true;
|
||||
public killed = false;
|
||||
public pid = 12345;
|
||||
public readonly stderr = new EventEmitter() as NodeJS.ReadableStream;
|
||||
public readonly sent: LocalSpeechWorkerRequest[] = [];
|
||||
public disconnects = 0;
|
||||
public kills = 0;
|
||||
@@ -88,9 +90,9 @@ class PausedIpcWorker {
|
||||
}
|
||||
|
||||
on(event: "message", listener: (message: LocalSpeechWorkerToParentMessage) => void): this;
|
||||
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
||||
on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
||||
on(
|
||||
event: "message" | "exit",
|
||||
event: "message" | "close",
|
||||
listener:
|
||||
| ((message: LocalSpeechWorkerToParentMessage) => void)
|
||||
| ((code: number | null, signal: NodeJS.Signals | null) => void),
|
||||
@@ -103,6 +105,7 @@ class PausedIpcWorker {
|
||||
function createClient(options?: { idleTtlMs?: number }) {
|
||||
const workers: FakeLocalSpeechWorker[] = [];
|
||||
const client = new LocalSpeechWorkerClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
config: {
|
||||
modelsDir: "/tmp/models",
|
||||
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
|
||||
@@ -120,6 +123,19 @@ function createClient(options?: { idleTtlMs?: number }) {
|
||||
return { client, workers };
|
||||
}
|
||||
|
||||
function createCapturingLogger(): { logger: pino.Logger; records: Array<Record<string, unknown>> } {
|
||||
const records: Array<Record<string, unknown>> = [];
|
||||
const logger = pino(
|
||||
{ level: "trace" },
|
||||
{
|
||||
write(line: string) {
|
||||
records.push(JSON.parse(line) as Record<string, unknown>);
|
||||
},
|
||||
},
|
||||
);
|
||||
return { logger, records };
|
||||
}
|
||||
|
||||
async function waitForMicrotasks(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
@@ -217,6 +233,7 @@ describe("LocalSpeechWorkerClient", () => {
|
||||
it("does not surface real IPC backpressure when replaying native-sized dictation frames", async () => {
|
||||
const workers: PausedIpcWorker[] = [];
|
||||
const client = new LocalSpeechWorkerClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
config: {
|
||||
modelsDir: "/tmp/models",
|
||||
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
|
||||
@@ -257,6 +274,80 @@ describe("LocalSpeechWorkerClient", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("logs worker exit details and includes actionable context in the surfaced error", async () => {
|
||||
const { logger, records } = createCapturingLogger();
|
||||
const workers: FakeLocalSpeechWorker[] = [];
|
||||
const client = new LocalSpeechWorkerClient({
|
||||
logger,
|
||||
config: {
|
||||
modelsDir: "/tmp/models",
|
||||
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
|
||||
dictationSttModel: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceTtsModel: "kokoro-en-v0_19",
|
||||
},
|
||||
forkWorker: () => {
|
||||
const worker = new FakeLocalSpeechWorker();
|
||||
workers.push(worker);
|
||||
return worker;
|
||||
},
|
||||
});
|
||||
const provider = new WorkerBackedSpeechToTextProvider(client, "dictationStt");
|
||||
const session = provider.createSession({ logger: pino({ level: "silent" }) });
|
||||
|
||||
const connect = session.connect();
|
||||
workers[0].emit("exit", null, "SIGABRT");
|
||||
workers[0].stderr.emit("data", "dyld: Library not loaded: libsherpa-onnx-c-api.dylib");
|
||||
workers[0].emit("close", null, "SIGABRT");
|
||||
|
||||
await expect(connect).rejects.toThrow(
|
||||
"Local speech worker exited (signal SIGABRT) while handling session.create (dictationStt). Last stderr: dyld: Library not loaded: libsherpa-onnx-c-api.dylib",
|
||||
);
|
||||
const exitRecord = records.find((record) => record.msg === "Local speech worker exited");
|
||||
expect(exitRecord).toMatchObject({
|
||||
workerPid: 12345,
|
||||
signal: "SIGABRT",
|
||||
stderrTail: "dyld: Library not loaded: libsherpa-onnx-c-api.dylib",
|
||||
pendingRequests: [expect.objectContaining({ type: "session.create", kind: "dictationStt" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not log intentional shutdowns as worker crashes", async () => {
|
||||
const { logger, records } = createCapturingLogger();
|
||||
const workers: FakeLocalSpeechWorker[] = [];
|
||||
const client = new LocalSpeechWorkerClient({
|
||||
logger,
|
||||
config: {
|
||||
modelsDir: "/tmp/models",
|
||||
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
|
||||
dictationSttModel: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceTtsModel: "kokoro-en-v0_19",
|
||||
},
|
||||
forkWorker: () => {
|
||||
const worker = new FakeLocalSpeechWorker();
|
||||
workers.push(worker);
|
||||
return worker;
|
||||
},
|
||||
});
|
||||
|
||||
const synthesize = client.synthesizeSpeech("hello");
|
||||
workers[0].respond(workers[0].sent[0], {
|
||||
audio: bufferToWorkerBytes(Buffer.from([1])),
|
||||
format: "pcm;rate=24000",
|
||||
});
|
||||
await synthesize;
|
||||
|
||||
client.shutdown();
|
||||
workers[0].emit("close", null, "SIGTERM");
|
||||
|
||||
expect(records.find((record) => record.msg === "Local speech worker exited")).toBeUndefined();
|
||||
expect(
|
||||
records.find((record) => record.msg === "Local speech worker closed after shutdown"),
|
||||
).toMatchObject({
|
||||
workerPid: 12345,
|
||||
signal: "SIGTERM",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards VAD session events through the shared worker", async () => {
|
||||
const { client, workers } = createClient();
|
||||
const provider = new WorkerBackedTurnDetectionProvider(client);
|
||||
|
||||
@@ -28,6 +28,8 @@ import { bufferToWorkerBytes, workerBytesToBuffer } from "./worker-bytes.js";
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
||||
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_LOCAL_SAMPLE_RATE = 16000;
|
||||
const STDERR_TAIL_MAX_CHARS = 8000;
|
||||
const USER_ERROR_STDERR_MAX_CHARS = 1000;
|
||||
|
||||
type LocalSpeechWorkerRequestInput = LocalSpeechWorkerRequest extends infer Request
|
||||
? Request extends LocalSpeechWorkerRequest
|
||||
@@ -38,21 +40,27 @@ type LocalSpeechWorkerRequestInput = LocalSpeechWorkerRequest extends infer Requ
|
||||
interface LocalSpeechWorkerProcess {
|
||||
connected: boolean;
|
||||
killed: boolean;
|
||||
pid?: number;
|
||||
stderr?: Readable | null;
|
||||
send(message: LocalSpeechWorkerRequest, callback: (error: Error | null) => void): boolean;
|
||||
disconnect(): void;
|
||||
kill(): boolean;
|
||||
on(event: "message", listener: (message: LocalSpeechWorkerToParentMessage) => void): this;
|
||||
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
||||
on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
type: LocalSpeechWorkerRequest["type"];
|
||||
summary: Record<string, unknown>;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
interface LocalSpeechWorkerClientOptions {
|
||||
config: LocalSpeechWorkerConfig;
|
||||
logger: pino.Logger;
|
||||
requestTimeoutMs?: number;
|
||||
idleTtlMs?: number;
|
||||
forkWorker?: () => LocalSpeechWorkerProcess;
|
||||
@@ -90,7 +98,7 @@ function forkLocalSpeechWorker(): LocalSpeechWorkerProcess {
|
||||
env,
|
||||
execArgv: resolveWorkerExecArgv(),
|
||||
serialization: "advanced",
|
||||
stdio: ["ignore", "ignore", "inherit", "ipc"],
|
||||
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
||||
}) as LocalSpeechWorkerProcess;
|
||||
}
|
||||
|
||||
@@ -100,8 +108,75 @@ function isResponse(
|
||||
return message.type === "response";
|
||||
}
|
||||
|
||||
function truncateStart(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) {
|
||||
return value;
|
||||
}
|
||||
return value.slice(value.length - maxChars);
|
||||
}
|
||||
|
||||
function summarizeWorkerRequest(message: LocalSpeechWorkerRequest): Record<string, unknown> {
|
||||
switch (message.type) {
|
||||
case "session.create":
|
||||
return { kind: message.kind, sessionId: message.sessionId };
|
||||
case "session.append":
|
||||
return { sessionId: message.sessionId, audioBytes: message.audio.byteLength };
|
||||
case "session.commit":
|
||||
case "session.clear":
|
||||
case "session.flush":
|
||||
case "session.reset":
|
||||
case "session.close":
|
||||
return { sessionId: message.sessionId };
|
||||
case "stt.transcribe":
|
||||
return {
|
||||
model: message.model,
|
||||
audioBytes: message.audio.byteLength,
|
||||
format: message.format,
|
||||
};
|
||||
case "tts.synthesize":
|
||||
return { textLength: message.text.length };
|
||||
}
|
||||
}
|
||||
|
||||
function formatExitStatus(code: number | null, signal: NodeJS.Signals | null): string {
|
||||
const parts: string[] = [];
|
||||
if (code !== null) {
|
||||
parts.push(`code ${code}`);
|
||||
}
|
||||
if (signal) {
|
||||
parts.push(`signal ${signal}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(", ") : "unknown exit status";
|
||||
}
|
||||
|
||||
function formatPendingRequestForMessage(request: Record<string, unknown>): string {
|
||||
const type = typeof request.type === "string" ? request.type : "unknown request";
|
||||
const kind = typeof request.kind === "string" ? ` (${request.kind})` : "";
|
||||
return `${type}${kind}`;
|
||||
}
|
||||
|
||||
function buildWorkerExitMessage(params: {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
pendingRequests: Array<Record<string, unknown>>;
|
||||
stderrTail: string;
|
||||
}): string {
|
||||
const pending =
|
||||
params.pendingRequests.length > 0
|
||||
? ` while handling ${params.pendingRequests
|
||||
.slice(0, 3)
|
||||
.map(formatPendingRequestForMessage)
|
||||
.join(", ")}`
|
||||
: "";
|
||||
const stderr = params.stderrTail
|
||||
? ` Last stderr: ${truncateStart(params.stderrTail, USER_ERROR_STDERR_MAX_CHARS)}`
|
||||
: " Check daemon.log and macOS DiagnosticReports for Paseo Voice crash details.";
|
||||
return `Local speech worker exited (${formatExitStatus(params.code, params.signal)})${pending}.${stderr}`;
|
||||
}
|
||||
|
||||
export class LocalSpeechWorkerClient {
|
||||
private readonly config: LocalSpeechWorkerConfig;
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly idleTtlMs: number;
|
||||
private readonly forkWorker: () => LocalSpeechWorkerProcess;
|
||||
@@ -109,11 +184,16 @@ export class LocalSpeechWorkerClient {
|
||||
private readonly activeSessionIds = new Set<string>();
|
||||
private readonly sessionEmitters = new Map<string, EventEmitter>();
|
||||
private worker: LocalSpeechWorkerProcess | null = null;
|
||||
private workerPid: number | null = null;
|
||||
private stderrTail = "";
|
||||
private stderrLineBuffer = "";
|
||||
private inFlightRequests = 0;
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly intentionalWorkerCloses = new WeakSet<LocalSpeechWorkerProcess>();
|
||||
|
||||
constructor(options: LocalSpeechWorkerClientOptions) {
|
||||
this.config = options.config;
|
||||
this.logger = options.logger.child({ component: "local-speech-worker-client" });
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
||||
this.forkWorker = options.forkWorker ?? forkLocalSpeechWorker;
|
||||
@@ -214,7 +294,9 @@ export class LocalSpeechWorkerClient {
|
||||
this.sessionEmitters.clear();
|
||||
const worker = this.worker;
|
||||
this.worker = null;
|
||||
this.workerPid = null;
|
||||
if (worker && !worker.killed) {
|
||||
this.intentionalWorkerCloses.add(worker);
|
||||
try {
|
||||
worker.disconnect();
|
||||
} catch {
|
||||
@@ -232,6 +314,7 @@ export class LocalSpeechWorkerClient {
|
||||
const worker = this.ensureWorker();
|
||||
const requestId = randomUUID();
|
||||
const message = { ...input, requestId } as LocalSpeechWorkerRequest;
|
||||
const requestSummary = summarizeWorkerRequest(message);
|
||||
this.inFlightRequests++;
|
||||
this.clearIdleTimer();
|
||||
|
||||
@@ -246,6 +329,9 @@ export class LocalSpeechWorkerClient {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
timeout,
|
||||
type: message.type,
|
||||
summary: requestSummary,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
worker.send(message, (error) => {
|
||||
@@ -271,11 +357,44 @@ export class LocalSpeechWorkerClient {
|
||||
}
|
||||
const worker = this.forkWorker();
|
||||
this.worker = worker;
|
||||
this.workerPid = worker.pid ?? null;
|
||||
this.stderrTail = "";
|
||||
this.stderrLineBuffer = "";
|
||||
this.logger.info(
|
||||
{
|
||||
workerPid: this.workerPid,
|
||||
modelsDir: this.config.modelsDir,
|
||||
voiceSttModel: this.config.voiceSttModel,
|
||||
dictationSttModel: this.config.dictationSttModel,
|
||||
voiceTtsModel: this.config.voiceTtsModel,
|
||||
},
|
||||
"Local speech worker spawned",
|
||||
);
|
||||
worker.stderr?.on("data", (chunk: Buffer | string) => this.handleWorkerStderr(chunk));
|
||||
worker.on("message", (message) => this.handleWorkerMessage(message));
|
||||
worker.on("exit", () => this.handleWorkerExit());
|
||||
worker.on("close", (code, signal) => this.handleWorkerExit(worker, code, signal));
|
||||
return worker;
|
||||
}
|
||||
|
||||
private handleWorkerStderr(chunk: Buffer | string): void {
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
||||
this.stderrTail = truncateStart(this.stderrTail + text, STDERR_TAIL_MAX_CHARS);
|
||||
|
||||
this.stderrLineBuffer += text;
|
||||
const lines = this.stderrLineBuffer.split(/\r?\n/);
|
||||
this.stderrLineBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
this.logger.warn(
|
||||
{ workerPid: this.workerPid, stderr: trimmed },
|
||||
"Local speech worker stderr",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleWorkerMessage(message: LocalSpeechWorkerToParentMessage): void {
|
||||
if (isResponse(message)) {
|
||||
const pending = this.pendingRequests.get(message.requestId);
|
||||
@@ -317,13 +436,84 @@ export class LocalSpeechWorkerClient {
|
||||
}
|
||||
}
|
||||
|
||||
private handleWorkerExit(): void {
|
||||
this.worker = null;
|
||||
private handleWorkerExit(
|
||||
worker: LocalSpeechWorkerProcess,
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): void {
|
||||
const wasCurrentWorker = this.worker === worker;
|
||||
const wasIntentionalClose = this.intentionalWorkerCloses.has(worker);
|
||||
this.intentionalWorkerCloses.delete(worker);
|
||||
const workerPid = worker.pid ?? (wasCurrentWorker ? this.workerPid : null);
|
||||
const pendingRequests = this.describePendingRequests();
|
||||
const activeSessionCount = this.activeSessionIds.size;
|
||||
const stderrTail = this.getStderrTail();
|
||||
|
||||
if (wasIntentionalClose) {
|
||||
this.logger.info(
|
||||
{
|
||||
workerPid,
|
||||
code,
|
||||
signal,
|
||||
pendingRequests,
|
||||
activeSessionCount,
|
||||
stderrTail: stderrTail || null,
|
||||
},
|
||||
"Local speech worker closed after shutdown",
|
||||
);
|
||||
if (wasCurrentWorker) {
|
||||
this.worker = null;
|
||||
this.workerPid = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wasCurrentWorker) {
|
||||
this.logger.warn(
|
||||
{
|
||||
workerPid,
|
||||
code,
|
||||
signal,
|
||||
pendingRequests,
|
||||
activeSessionCount,
|
||||
stderrTail: stderrTail || null,
|
||||
},
|
||||
"Stale local speech worker closed",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const error = new Error(
|
||||
buildWorkerExitMessage({
|
||||
code,
|
||||
signal,
|
||||
pendingRequests,
|
||||
stderrTail,
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.error(
|
||||
{
|
||||
err: error,
|
||||
workerPid,
|
||||
code,
|
||||
signal,
|
||||
pendingRequests,
|
||||
activeSessionCount,
|
||||
stderrTail: stderrTail || null,
|
||||
},
|
||||
"Local speech worker exited",
|
||||
);
|
||||
|
||||
if (wasCurrentWorker) {
|
||||
this.worker = null;
|
||||
this.workerPid = null;
|
||||
}
|
||||
this.clearIdleTimer();
|
||||
this.rejectAllPending(new Error("Local speech worker exited"));
|
||||
this.rejectAllPending(error);
|
||||
for (const [sessionId, emitter] of this.sessionEmitters) {
|
||||
if (this.activeSessionIds.has(sessionId)) {
|
||||
emitter.emit("error", new Error("Local speech worker exited"));
|
||||
this.emitErrorIfObserved(sessionId, emitter, error, workerPid);
|
||||
}
|
||||
}
|
||||
this.activeSessionIds.clear();
|
||||
@@ -331,6 +521,24 @@ export class LocalSpeechWorkerClient {
|
||||
this.inFlightRequests = 0;
|
||||
}
|
||||
|
||||
private describePendingRequests(): Array<Record<string, unknown>> {
|
||||
const now = Date.now();
|
||||
return Array.from(this.pendingRequests.entries()).map(([requestId, request]) =>
|
||||
Object.assign(
|
||||
{
|
||||
requestId,
|
||||
type: request.type,
|
||||
ageMs: Math.max(0, now - request.startedAt),
|
||||
},
|
||||
request.summary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private getStderrTail(): string {
|
||||
return truncateStart(this.stderrTail.trim(), STDERR_TAIL_MAX_CHARS);
|
||||
}
|
||||
|
||||
private rejectAllPending(error: Error): void {
|
||||
for (const [requestId, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timeout);
|
||||
@@ -344,7 +552,27 @@ export class LocalSpeechWorkerClient {
|
||||
if (!emitter) {
|
||||
return;
|
||||
}
|
||||
emitter.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||
this.emitErrorIfObserved(
|
||||
sessionId,
|
||||
emitter,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
|
||||
private emitErrorIfObserved(
|
||||
sessionId: string,
|
||||
emitter: EventEmitter,
|
||||
error: Error,
|
||||
workerPid: number | null = this.workerPid,
|
||||
): void {
|
||||
if (emitter.listenerCount("error") > 0) {
|
||||
emitter.emit("error", error);
|
||||
return;
|
||||
}
|
||||
this.logger.warn(
|
||||
{ err: error, workerPid, sessionId },
|
||||
"Local speech worker session error had no listener",
|
||||
);
|
||||
}
|
||||
|
||||
private scheduleIdleShutdownIfReady(): void {
|
||||
|
||||
@@ -105,6 +105,7 @@ import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
||||
|
||||
interface WebSocketServerInternals {
|
||||
attachSocket(ws: unknown, req: unknown): Promise<void>;
|
||||
socketMessageQueues: Map<unknown, Promise<void>>;
|
||||
}
|
||||
|
||||
const TEST_DAEMON_VERSION = "1.2.3-test";
|
||||
@@ -125,9 +126,12 @@ function parseSentEnvelope(data: unknown): z.infer<typeof WireEnvelopeSchema> {
|
||||
}
|
||||
|
||||
const BinaryFrameSchema = z.object({
|
||||
opcode: z.number(),
|
||||
slot: z.number(),
|
||||
payload: z.instanceof(Uint8Array),
|
||||
kind: z.literal("terminal"),
|
||||
frame: z.object({
|
||||
opcode: z.number(),
|
||||
slot: z.number(),
|
||||
payload: z.instanceof(Uint8Array),
|
||||
}),
|
||||
});
|
||||
|
||||
class MockSocket {
|
||||
@@ -369,7 +373,7 @@ async function attachRelayAndHello(params: {
|
||||
}) {
|
||||
await params.server.attachExternalSocket(params.socket, { transport: "relay" });
|
||||
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
|
||||
await Promise.resolve();
|
||||
await waitForSocketMessages(params.server, params.socket);
|
||||
expect(params.socket.sent.length).toBeGreaterThan(0);
|
||||
const envelope = parseSentEnvelope(params.socket.sent[0]);
|
||||
expect(envelope.type).toBe("session");
|
||||
@@ -389,7 +393,7 @@ async function attachDirectAndHello(params: {
|
||||
createDirectRequest(),
|
||||
);
|
||||
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
|
||||
await Promise.resolve();
|
||||
await waitForSocketMessages(params.server, params.socket);
|
||||
expect(params.socket.sent.length).toBeGreaterThan(0);
|
||||
const envelope = parseSentEnvelope(params.socket.sent[0]);
|
||||
expect(envelope.type).toBe("session");
|
||||
@@ -399,6 +403,13 @@ async function attachDirectAndHello(params: {
|
||||
return serverInfo!;
|
||||
}
|
||||
|
||||
async function waitForSocketMessages(
|
||||
server: VoiceAssistantWebSocketServer,
|
||||
socket: MockSocket,
|
||||
): Promise<void> {
|
||||
await asInternals<WebSocketServerInternals>(server).socketMessageQueues.get(socket);
|
||||
}
|
||||
|
||||
describe("relay external socket reconnect behavior", () => {
|
||||
beforeEach(() => {
|
||||
sessionMock.instances.length = 0;
|
||||
@@ -453,7 +464,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await waitForSocketMessages(server, socket);
|
||||
|
||||
expect(sessionMock.instances).toHaveLength(1);
|
||||
const session = sessionMock.instances[0];
|
||||
@@ -550,7 +561,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await waitForSocketMessages(server, socket);
|
||||
|
||||
expect(closeCode).toBe(4002);
|
||||
expect(["Invalid hello", "Session message before hello"]).toContain(closeReason);
|
||||
@@ -757,10 +768,10 @@ describe("relay external socket reconnect behavior", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await waitForSocketMessages(server, socket);
|
||||
|
||||
expect(session.handleBinaryFrame).toHaveBeenCalledTimes(1);
|
||||
const frame = BinaryFrameSchema.parse(session.handleBinaryFrame.mock.calls[0]?.[0]);
|
||||
const { frame } = BinaryFrameSchema.parse(session.handleBinaryFrame.mock.calls[0]?.[0]);
|
||||
expect(frame.opcode).toBe(TerminalStreamOpcode.Input);
|
||||
expect(frame.slot).toBe(9);
|
||||
expect(new TextDecoder().decode(frame.payload)).toBe("ls\r");
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type WSOutboundMessage,
|
||||
wrapSessionMessage,
|
||||
} from "./messages.js";
|
||||
import { asUint8Array, decodeTerminalStreamFrame } from "@getpaseo/protocol/binary-frames/index";
|
||||
import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { HostnamesConfig } from "./hostnames.js";
|
||||
import { isHostnameAllowed } from "./hostnames.js";
|
||||
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
||||
@@ -324,6 +324,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly pendingConnections: Map<WebSocketLike, PendingConnection> = new Map();
|
||||
private readonly sessions: Map<WebSocketLike, SessionConnection> = new Map();
|
||||
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
|
||||
private readonly socketMessageQueues: Map<WebSocketLike, Promise<void>> = new Map();
|
||||
private readonly serverId: string;
|
||||
private readonly daemonVersion: string;
|
||||
private readonly daemonRuntimeConfig:
|
||||
@@ -1096,7 +1097,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private bindSocketHandlers(ws: WebSocketLike): void {
|
||||
ws.on("message", (...args: unknown[]) => {
|
||||
const data = args[0] as Buffer | ArrayBuffer | Buffer[] | string;
|
||||
void this.handleRawMessage(ws, data);
|
||||
this.enqueueRawMessage(ws, data);
|
||||
});
|
||||
|
||||
ws.on("close", async (...args: unknown[]) => {
|
||||
@@ -1119,6 +1120,25 @@ export class VoiceAssistantWebSocketServer {
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueRawMessage(
|
||||
ws: WebSocketLike,
|
||||
data: Buffer | ArrayBuffer | Buffer[] | string,
|
||||
): void {
|
||||
const previous = this.socketMessageQueues.get(ws) ?? Promise.resolve();
|
||||
const next = previous.then(
|
||||
() => this.handleRawMessage(ws, data),
|
||||
() => this.handleRawMessage(ws, data),
|
||||
);
|
||||
this.socketMessageQueues.set(ws, next);
|
||||
void next
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.socketMessageQueues.get(ws) === next) {
|
||||
this.socketMessageQueues.delete(ws);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public resolveVoiceSpeakHandler(callerAgentId: string): VoiceSpeakHandler | null {
|
||||
return this.voiceSpeakHandlers.get(callerAgentId) ?? null;
|
||||
}
|
||||
@@ -1298,19 +1318,19 @@ export class VoiceAssistantWebSocketServer {
|
||||
);
|
||||
}
|
||||
|
||||
private maybeHandleBinaryFrame(params: {
|
||||
private async maybeHandleBinaryFrame(params: {
|
||||
ws: WebSocketLike;
|
||||
buffer: Buffer;
|
||||
activeConnection: SessionConnection | undefined;
|
||||
log: pino.Logger;
|
||||
}): boolean {
|
||||
}): Promise<boolean> {
|
||||
const { ws, buffer, activeConnection, log } = params;
|
||||
const asBytes = asUint8Array(buffer);
|
||||
if (!asBytes) {
|
||||
return false;
|
||||
}
|
||||
const frame = decodeTerminalStreamFrame(asBytes);
|
||||
if (!frame) {
|
||||
const decodedFrame = decodeBinaryFrame(asBytes);
|
||||
if (!decodedFrame) {
|
||||
return false;
|
||||
}
|
||||
if (!activeConnection) {
|
||||
@@ -1324,7 +1344,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
activeConnection.session.handleBinaryFrame(frame);
|
||||
await activeConnection.session.handleBinaryFrame(decodedFrame);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1369,7 +1389,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
try {
|
||||
const buffer = bufferFromWsData(data);
|
||||
const binaryHandled = this.maybeHandleBinaryFrame({
|
||||
const binaryHandled = await this.maybeHandleBinaryFrame({
|
||||
ws,
|
||||
buffer,
|
||||
activeConnection,
|
||||
|
||||
+6
-4
@@ -2,6 +2,7 @@
|
||||
"worktree": {
|
||||
"setup": [
|
||||
"npm ci",
|
||||
"cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=\"$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" ./scripts/dev-home.sh",
|
||||
"npm run build:server",
|
||||
"npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\""
|
||||
@@ -10,14 +11,15 @@
|
||||
"scripts": {
|
||||
"daemon": {
|
||||
"type": "service",
|
||||
"command": "PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:$PASEO_PORT ./scripts/dev-daemon.sh"
|
||||
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:$PASEO_PORT ./scripts/dev-daemon.sh"
|
||||
},
|
||||
"app": {
|
||||
"type": "service",
|
||||
"command": "BROWSER=none EXPO_PUBLIC_LOCAL_DAEMON=\"localhost:${PASEO_SERVICE_DAEMON_PORT}\" npm run start:expo --workspace=@getpaseo/app -- --port $PASEO_PORT"
|
||||
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} EXPO_PORT=$PASEO_PORT ./scripts/dev-app.sh"
|
||||
},
|
||||
"typecheck": {
|
||||
"command": "npm run typecheck"
|
||||
"desktop": {
|
||||
"type": "service",
|
||||
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} EXPO_PORT=$PASEO_PORT npm run dev --workspace=@getpaseo/desktop"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
export PATH="$ROOT_DIR/node_modules/.bin:$PATH"
|
||||
|
||||
source "$SCRIPT_DIR/dev-home.sh"
|
||||
|
||||
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:6768}"
|
||||
configure_dev_paseo_home
|
||||
|
||||
EXPO_PORT="${EXPO_PORT:-8081}"
|
||||
DAEMON_ENDPOINT="$(resolve_dev_daemon_endpoint)"
|
||||
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Paseo App Dev"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Metro: http://localhost:${EXPO_PORT}"
|
||||
echo " Daemon: ${DAEMON_ENDPOINT}"
|
||||
echo " Home: ${PASEO_HOME}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
exec cross-env \
|
||||
BROWSER="${BROWSER:-none}" \
|
||||
APP_VARIANT=development \
|
||||
EXPO_PUBLIC_LOCAL_DAEMON="$DAEMON_ENDPOINT" \
|
||||
npm run start:expo --workspace=@getpaseo/app -- --port "$EXPO_PORT"
|
||||
@@ -5,6 +5,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PATH="$SCRIPT_DIR/../node_modules/.bin:$PATH"
|
||||
|
||||
source "$SCRIPT_DIR/dev-home.sh"
|
||||
|
||||
export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:6768}"
|
||||
configure_dev_paseo_home
|
||||
|
||||
if [ -z "${PASEO_LOCAL_MODELS_DIR}" ]; then
|
||||
@@ -17,6 +19,7 @@ echo " Paseo Dev Daemon"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Home: ${PASEO_HOME}"
|
||||
echo " Models: ${PASEO_LOCAL_MODELS_DIR}"
|
||||
echo " Listen: ${PASEO_LISTEN}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
|
||||
@@ -26,4 +29,4 @@ if [ "${PASEO_SKIP_DEV_SERVER_BUILD:-0}" = "1" ]; then
|
||||
exec npm run dev:server:watch
|
||||
fi
|
||||
|
||||
exec npm run dev:server
|
||||
exec sh -c 'npm run build:server-deps && npm run dev:server:watch'
|
||||
|
||||
+73
-13
@@ -1,5 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
default_dev_paseo_root() {
|
||||
git rev-parse --show-toplevel 2>/dev/null || pwd
|
||||
}
|
||||
|
||||
copy_json_tree() {
|
||||
local source_dir="$1"
|
||||
local target_dir="$2"
|
||||
@@ -58,27 +62,83 @@ seed_worktree_paseo_home() {
|
||||
echo " Seed: copied metadata from ${source_home}"
|
||||
}
|
||||
|
||||
configure_dev_daemon_config() {
|
||||
if [ -z "${PASEO_LISTEN:-}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$PASEO_HOME"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [path, listen] = [process.argv[1], process.argv[2]];
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
|
||||
cfg.version = cfg.version || 1;
|
||||
cfg.daemon = cfg.daemon || {};
|
||||
cfg.daemon.listen = listen;
|
||||
cfg.daemon.cors = cfg.daemon.cors || {};
|
||||
cfg.daemon.cors.allowedOrigins = ["*"];
|
||||
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
|
||||
' "$PASEO_HOME/config.json" "$PASEO_LISTEN"
|
||||
}
|
||||
|
||||
resolve_dev_daemon_endpoint() {
|
||||
if [ -n "${PASEO_DEV_DAEMON_ENDPOINT:-}" ]; then
|
||||
echo "$PASEO_DEV_DAEMON_ENDPOINT"
|
||||
return
|
||||
fi
|
||||
|
||||
case "${PASEO_LISTEN:-127.0.0.1:6768}" in
|
||||
0.0.0.0:*) echo "localhost:${PASEO_LISTEN#0.0.0.0:}" ;;
|
||||
127.0.0.1:*) echo "localhost:${PASEO_LISTEN#127.0.0.1:}" ;;
|
||||
*) echo "$PASEO_LISTEN" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
configure_dev_paseo_home() {
|
||||
if [ -n "${PASEO_HOME:-}" ]; then
|
||||
export PASEO_HOME
|
||||
if [ -n "${PASEO_DEV_SEED_HOME:-}" ]; then
|
||||
seed_worktree_paseo_home "$PASEO_HOME"
|
||||
fi
|
||||
mkdir -p "$PASEO_HOME"
|
||||
if [ "${PASEO_DEV_MANAGED_HOME:-0}" = "1" ] || [ -n "${PASEO_DEV_SEED_HOME:-}" ]; then
|
||||
configure_dev_daemon_config
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
export PASEO_HOME
|
||||
local git_dir
|
||||
local git_common_dir
|
||||
git_dir="$(git rev-parse --git-dir 2>/dev/null || true)"
|
||||
git_common_dir="$(git rev-parse --git-common-dir 2>/dev/null || true)"
|
||||
if [ -n "$git_dir" ] && [ -n "$git_common_dir" ] && [ "$git_dir" != "$git_common_dir" ]; then
|
||||
local worktree_root
|
||||
local worktree_name
|
||||
worktree_root="$(git rev-parse --show-toplevel)"
|
||||
worktree_name="$(basename "$worktree_root" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//')"
|
||||
PASEO_HOME="$HOME/.paseo-${worktree_name}"
|
||||
local dev_root
|
||||
dev_root="${PASEO_DEV_ROOT:-$(default_dev_paseo_root)}"
|
||||
PASEO_HOME="$dev_root/.dev/paseo-home"
|
||||
export PASEO_DEV_MANAGED_HOME=1
|
||||
|
||||
if [ -n "${PASEO_DEV_SEED_HOME:-}" ]; then
|
||||
seed_worktree_paseo_home "$PASEO_HOME"
|
||||
return
|
||||
fi
|
||||
|
||||
PASEO_HOME="$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX")"
|
||||
trap "rm -rf '$PASEO_HOME'" EXIT
|
||||
mkdir -p "$PASEO_HOME"
|
||||
configure_dev_daemon_config
|
||||
}
|
||||
|
||||
configure_dev_command_env() {
|
||||
if [ -z "${PASEO_LISTEN:-}" ]; then
|
||||
if [ -n "${PASEO_SERVICE_DAEMON_PORT:-}" ]; then
|
||||
export PASEO_LISTEN="0.0.0.0:${PASEO_SERVICE_DAEMON_PORT}"
|
||||
else
|
||||
export PASEO_LISTEN="127.0.0.1:6768"
|
||||
fi
|
||||
fi
|
||||
|
||||
configure_dev_paseo_home
|
||||
}
|
||||
|
||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
||||
if [ "$#" -gt 0 ]; then
|
||||
configure_dev_command_env
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
configure_dev_paseo_home
|
||||
fi
|
||||
|
||||
+1
-40
@@ -1,43 +1,4 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Ensure node_modules/.bin is in PATH (for when script runs directly)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PATH="$SCRIPT_DIR/../node_modules/.bin:$PATH"
|
||||
|
||||
source "$SCRIPT_DIR/dev-home.sh"
|
||||
configure_dev_paseo_home
|
||||
|
||||
# Share speech models with the main install to avoid duplicate downloads
|
||||
if [ -z "${PASEO_LOCAL_MODELS_DIR}" ]; then
|
||||
export PASEO_LOCAL_MODELS_DIR="$HOME/.paseo/models/local-speech"
|
||||
mkdir -p "$PASEO_LOCAL_MODELS_DIR"
|
||||
fi
|
||||
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Paseo Dev"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " Home: ${PASEO_HOME}"
|
||||
echo " Models: ${PASEO_LOCAL_MODELS_DIR}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
npm run build:server
|
||||
npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||
|
||||
# Configure the daemon for the Portless app origin and let the app bootstrap
|
||||
# through the daemon's Portless URL instead of a fixed localhost port.
|
||||
APP_ORIGIN="$(portless get app)"
|
||||
DAEMON_ENDPOINT="$(portless get daemon | sed -E 's#^https?://##')"
|
||||
# Allow any origin in dev so Electron on random ports and Portless URLs all work.
|
||||
# SECURITY: wildcard CORS is unsafe in production — only acceptable here because
|
||||
# the daemon binds to localhost and this script is never used for production.
|
||||
export PASEO_CORS_ORIGINS="*"
|
||||
|
||||
# Run both with concurrently
|
||||
# BROWSER=none prevents auto-opening browser
|
||||
# EXPO_PUBLIC_LOCAL_DAEMON configures the app to auto-connect to this daemon
|
||||
concurrently \
|
||||
--names "daemon,metro" \
|
||||
--prefix-colors "cyan,magenta" \
|
||||
"portless run --name daemon sh -c 'PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:\$PORT exec ./scripts/dev-daemon.sh'" \
|
||||
"BROWSER=none APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON='${DAEMON_ENDPOINT}' portless run --name app npm run start:expo --workspace=@getpaseo/app"
|
||||
exec npm run dev:server
|
||||
|
||||
Reference in New Issue
Block a user