mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c05f69402 | ||
|
|
8fdc41f06c | ||
|
|
928c124356 | ||
|
|
0a398833d8 | ||
|
|
71c66823aa | ||
|
|
a1821d5863 | ||
|
|
5db070a4d9 | ||
|
|
461be47c91 | ||
|
|
92d7066601 | ||
|
|
d28e174b38 | ||
|
|
a257a1c5f6 | ||
|
|
144f951a79 | ||
|
|
51fea4b7e0 | ||
|
|
5ae53c7e55 |
@@ -1,11 +1,8 @@
|
||||
name: Deploy Relay
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/relay/**"
|
||||
- ".github/workflows/deploy-relay.yml"
|
||||
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
|
||||
# A release or main push must not redeploy the temporary Cloudflare bridge.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.106-beta.1 - 2026-07-11
|
||||
## 0.1.106 - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reduced Android chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
|
||||
- Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
|
||||
- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger))
|
||||
- Switching between workspaces no longer flashes a white screen
|
||||
- Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
## 0.1.105 - 2026-07-10
|
||||
|
||||
@@ -96,6 +96,18 @@ Keep workspace identity and retention outside native-stack `getId` and
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
|
||||
Expo Router stack. React Navigation otherwise paints each native stack screen
|
||||
with its light default background. A screen-level wrapper can hide that surface
|
||||
while settled, but Android may expose it for one frame when navigation crosses
|
||||
from a nested stack to its parent stack. This is especially visible when an
|
||||
app-wide route such as `/new` opens from a dark workspace.
|
||||
|
||||
Do not read the active theme with `useUnistyles()` in a layout to build
|
||||
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
|
||||
a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
|
||||
@@ -52,6 +52,8 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
|
||||
|
||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||
|
||||
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
|
||||
|
||||
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
||||
|
||||
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
|
||||
|
||||
@@ -9,6 +9,11 @@ The invariant is:
|
||||
|
||||
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output and failed
|
||||
shell error text are capped at 64 KiB of UTF-8 data, and the same bounded item is used for durable
|
||||
timeline rows and live stream events. Provider history hydration applies the same rule so reopening
|
||||
an agent cannot restore an oversized tool payload.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
Client heartbeat reports presence:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-cU6qRXY9fnr80pllmqJts5w8+OiE6F99SRo2d9G0HDA=
|
||||
sha256-lYvP0RKgxshO68+bjkkUXX9YJtzgoGIp8kGdGFYOT+o=
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35152,7 +35152,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36170,12 +36170,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.106-beta.1",
|
||||
"@getpaseo/protocol": "0.1.106-beta.1",
|
||||
"@getpaseo/server": "0.1.106-beta.1",
|
||||
"@getpaseo/client": "0.1.106",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/server": "0.1.106",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36421,10 +36421,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.106-beta.1",
|
||||
"@getpaseo/relay": "0.1.106-beta.1",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/relay": "0.1.106",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36435,7 +36435,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36678,7 +36678,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37574,7 +37574,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37806,7 +37806,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37819,7 +37819,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38037,15 +38037,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.106-beta.1",
|
||||
"@getpaseo/highlight": "0.1.106-beta.1",
|
||||
"@getpaseo/protocol": "0.1.106-beta.1",
|
||||
"@getpaseo/relay": "0.1.106-beta.1",
|
||||
"@getpaseo/client": "0.1.106",
|
||||
"@getpaseo/highlight": "0.1.106",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/relay": "0.1.106",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38582,7 +38582,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -784,21 +785,15 @@ function FaviconStatusSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ROOT_STACK_SCREEN_OPTIONS = {
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
};
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
const { theme } = useUnistyles();
|
||||
const stackScreenOptions = useMemo(
|
||||
() => ({
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
contentStyle: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
return (
|
||||
<Stack screenOptions={stackScreenOptions}>
|
||||
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
@@ -815,7 +810,7 @@ function RootStack() {
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
</Stack>
|
||||
</ThemedStack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { HostRouteProvider } from "@/navigation/host-route-context";
|
||||
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
const HOST_STACK_SCREEN_OPTIONS = {
|
||||
@@ -33,14 +34,14 @@ function KnownHostRoute() {
|
||||
}
|
||||
|
||||
const stack = (
|
||||
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="workspace/[workspaceId]/index" />
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="settings" />
|
||||
</Stack>
|
||||
</ThemedStack>
|
||||
);
|
||||
|
||||
if (!routeServerId) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
||||
import { Stack } from "expo-router";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
|
||||
interface ThemedStackBaseProps {
|
||||
backgroundColor: string;
|
||||
children?: ReactNode;
|
||||
screenOptions?: NativeStackNavigationOptions;
|
||||
}
|
||||
|
||||
function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedStackBaseProps) {
|
||||
const themedScreenOptions = useMemo<NativeStackNavigationOptions>(
|
||||
() => ({
|
||||
...screenOptions,
|
||||
contentStyle: [{ backgroundColor }, screenOptions?.contentStyle],
|
||||
}),
|
||||
[backgroundColor, screenOptions],
|
||||
);
|
||||
|
||||
return <Stack screenOptions={themedScreenOptions}>{children}</Stack>;
|
||||
}
|
||||
|
||||
export const ThemedStack = withUnistyles(ThemedStackBase, (theme) => ({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.106-beta.1",
|
||||
"@getpaseo/protocol": "0.1.106-beta.1",
|
||||
"@getpaseo/server": "0.1.106-beta.1",
|
||||
"@getpaseo/client": "0.1.106",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/server": "0.1.106",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.106-beta.1",
|
||||
"@getpaseo/relay": "0.1.106-beta.1",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/relay": "0.1.106",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createCutoverProxy } from "./cutover-proxy.js";
|
||||
import type { ConnectionRole, RelaySessionAttachment } from "./types.js";
|
||||
|
||||
type RelayProtocolVersion = "1" | "2";
|
||||
@@ -97,6 +98,7 @@ function getGlobalWebSocketPair(): (new () => WebSocketPair) | undefined {
|
||||
|
||||
interface Env {
|
||||
RELAY: DurableObjectNamespace;
|
||||
PASEO_RELAY_UPSTREAM?: string;
|
||||
}
|
||||
|
||||
interface DurableObjectNamespace {
|
||||
@@ -572,6 +574,10 @@ export class RelayDurableObject {
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (env.PASEO_RELAY_UPSTREAM) {
|
||||
return createCutoverProxy(env.PASEO_RELAY_UPSTREAM).fetch(request);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Health check
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createServer, type IncomingMessage, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import relayWorker from "./cloudflare-adapter.js";
|
||||
|
||||
type RelayEnv = Parameters<typeof relayWorker.fetch>[1];
|
||||
|
||||
interface ReceivedRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
probe: string;
|
||||
}
|
||||
|
||||
class RecordingOrigin {
|
||||
private readonly requests: ReceivedRequest[] = [];
|
||||
private server: Server | null = null;
|
||||
|
||||
async start(): Promise<string> {
|
||||
this.server = createServer((request, response) => {
|
||||
this.requests.push(recordRequest(request));
|
||||
response.writeHead(202, { "content-type": "application/json", "x-relay-origin": "fly" });
|
||||
response.end(JSON.stringify({ status: "forwarded" }));
|
||||
});
|
||||
await new Promise<void>((resolve) => this.server!.listen(0, "127.0.0.1", resolve));
|
||||
const address = this.server.address() as AddressInfo;
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
received(): ReceivedRequest[] {
|
||||
return this.requests;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const server = this.server;
|
||||
this.server = null;
|
||||
if (!server) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function recordRequest(request: IncomingMessage): ReceivedRequest {
|
||||
return {
|
||||
method: request.method ?? "",
|
||||
path: request.url ?? "",
|
||||
probe: String(request.headers["x-relay-probe"] ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
describe("cutover proxy", () => {
|
||||
const origins: RecordingOrigin[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(origins.splice(0).map((origin) => origin.close()));
|
||||
});
|
||||
|
||||
it("forwards the request to the configured origin without changing its route", async () => {
|
||||
const origin = new RecordingOrigin();
|
||||
origins.push(origin);
|
||||
const originUrl = await origin.start();
|
||||
|
||||
const response = await relayWorker.fetch(
|
||||
new Request("https://relay.paseo.sh/ws?serverId=srv_prod&role=server&v=2", {
|
||||
headers: { "x-relay-probe": "production" },
|
||||
}),
|
||||
{ PASEO_RELAY_UPSTREAM: originUrl } as RelayEnv,
|
||||
);
|
||||
|
||||
expect(origin.received()).toEqual([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/ws?serverId=srv_prod&role=server&v=2",
|
||||
probe: "production",
|
||||
},
|
||||
]);
|
||||
expect(response.status).toBe(202);
|
||||
await expect(response.json()).resolves.toEqual({ status: "forwarded" });
|
||||
expect(response.headers.get("x-relay-origin")).toBe("fly");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
interface CutoverProxy {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
}
|
||||
|
||||
export function createCutoverProxy(origin: string): CutoverProxy {
|
||||
const originUrl = new URL(origin);
|
||||
|
||||
return {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const upstreamUrl = new URL(request.url);
|
||||
upstreamUrl.protocol = originUrl.protocol;
|
||||
upstreamUrl.host = originUrl.host;
|
||||
|
||||
return fetch(new Request(upstreamUrl, request));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
// This live test uses the hosted relay's real TLS endpoint. Self-hosted relay TLS
|
||||
// opt-in is covered at URL-building/integration level so the local E2E does not
|
||||
// need to provision trusted certificates.
|
||||
const RELAY_BASE_URL = "wss://relay.paseo.sh";
|
||||
const RELAY_BASE_URL = process.env.PASEO_LIVE_RELAY_URL ?? "wss://relay.paseo.sh";
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
@@ -119,6 +119,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
|
||||
// === Connect ===
|
||||
const daemonControlWs = new WebSocket(serverControlUrl);
|
||||
const clientWs = new WebSocket(clientUrl);
|
||||
const connected = waitForConnected(daemonControlWs, connectionId);
|
||||
let daemonWs: WebSocket | null = null;
|
||||
|
||||
try {
|
||||
@@ -127,7 +128,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
|
||||
waitOpen(clientWs, "client"),
|
||||
]);
|
||||
|
||||
await waitForConnected(daemonControlWs, connectionId);
|
||||
await connected;
|
||||
|
||||
daemonWs = new WebSocket(serverDataUrl);
|
||||
await waitOpen(daemonWs, "server-data");
|
||||
|
||||
@@ -8,6 +8,9 @@ routes = [{ pattern = "relay.paseo.sh", custom_domain = true }]
|
||||
[observability]
|
||||
enabled = true
|
||||
|
||||
[vars]
|
||||
PASEO_RELAY_UPSTREAM = "https://paseo-relay-next.fly.dev"
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "RELAY"
|
||||
class_name = "RelayDurableObject"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -67,10 +67,10 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.106-beta.1",
|
||||
"@getpaseo/highlight": "0.1.106-beta.1",
|
||||
"@getpaseo/protocol": "0.1.106-beta.1",
|
||||
"@getpaseo/relay": "0.1.106-beta.1",
|
||||
"@getpaseo/client": "0.1.106",
|
||||
"@getpaseo/highlight": "0.1.106",
|
||||
"@getpaseo/protocol": "0.1.106",
|
||||
"@getpaseo/relay": "0.1.106",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
|
||||
const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS;
|
||||
const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0);
|
||||
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: false,
|
||||
@@ -377,6 +378,148 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("target coalesced behavior", () => {
|
||||
test("bounds tool output before persisting and streaming it", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
|
||||
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([expectedItem]);
|
||||
expect(
|
||||
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
|
||||
).toEqual([expectedItem]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds tool output by UTF-8 bytes", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const output = "漢".repeat(TOOL_CALL_CONTENT_MAX_BYTES);
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "漢".repeat(Math.floor(TOOL_CALL_CONTENT_MAX_BYTES / 3)),
|
||||
});
|
||||
|
||||
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
expectedItem,
|
||||
]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds appended tool output before persisting and streaming it", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
|
||||
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
|
||||
|
||||
const rows = await harness.manager.getTimelineRows(agentId);
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
|
||||
expect(getTimelineItems(rows)).toEqual([expectedItem]);
|
||||
expect(
|
||||
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
|
||||
).toEqual([expectedItem]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds tool output while hydrating provider history", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
|
||||
|
||||
await harness.manager.hydrateTimelineFromProvider(agentId);
|
||||
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
expectedItem,
|
||||
]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds tool output emitted only to the live stream", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
|
||||
await harness.manager.emitLiveTimelineItem(
|
||||
agentId,
|
||||
toolCall({ status: "completed", output }),
|
||||
);
|
||||
|
||||
const events = getTimelineStreamEvents(harness.events, agentId);
|
||||
expect(await harness.manager.getTimelineRows(agentId)).toEqual([]);
|
||||
expect(
|
||||
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
|
||||
).toEqual([expectedItem]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds failed shell output carried in error content and message", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "failed",
|
||||
error: {
|
||||
content: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
message: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
},
|
||||
});
|
||||
|
||||
session.pushEvent(
|
||||
timelineEvent(toolCall({ status: "failed", error: { content, message: content } })),
|
||||
);
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
expectedItem,
|
||||
]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test(`coalesces a same-tick assistant burst after the ${COALESCE_WINDOW_MS}ms window`, async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
|
||||
@@ -2314,6 +2314,22 @@ test("importProviderSession imports the selected session without listing and pub
|
||||
item: { type: "assistant_message" as const, text: "Done" },
|
||||
timestamp: "2026-01-02T00:00:01.000Z",
|
||||
},
|
||||
{
|
||||
item: {
|
||||
type: "tool_call" as const,
|
||||
callId: "large-shell-result",
|
||||
name: "shell",
|
||||
status: "completed" as const,
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell" as const,
|
||||
command: "print output",
|
||||
output: "x".repeat(1024 * 1024),
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
timestamp: "2026-01-02T00:00:02.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -2343,6 +2359,19 @@ test("importProviderSession imports the selected session without listing and pub
|
||||
expect(manager.getTimeline(imported.id)).toEqual([
|
||||
{ type: "user_message", text: "Trace provider imports" },
|
||||
{ type: "assistant_message", text: "Done" },
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "large-shell-result",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "print output",
|
||||
output: "x".repeat(64 * 1024),
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
|
||||
AgentStreamCoalescer,
|
||||
} from "./agent-stream-coalescer.js";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
|
||||
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
@@ -493,7 +494,7 @@ function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): A
|
||||
rows.push({
|
||||
seq: rows.length + 1,
|
||||
timestamp: entry.timestamp ?? new Date().toISOString(),
|
||||
item: entry.item,
|
||||
item: limitAgentTimelineItemContent(entry.item),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
@@ -1752,6 +1753,7 @@ export class AgentManager {
|
||||
|
||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
item = limitAgentTimelineItemContent(item);
|
||||
this.touchUpdatedAt(agent);
|
||||
const row = this.recordTimeline(agentId, item);
|
||||
this.dispatchStream(
|
||||
@@ -2990,17 +2992,22 @@ export class AgentManager {
|
||||
agent.historyPrimed = true;
|
||||
|
||||
for (const event of historyEvents) {
|
||||
const item = limitAgentTimelineItemContent(event.item);
|
||||
const row = this.recordTimeline(
|
||||
agent.id,
|
||||
event.item,
|
||||
item,
|
||||
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
||||
);
|
||||
if (options?.broadcast) {
|
||||
this.dispatchStream(agent.id, event, {
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
{ ...event, item },
|
||||
{
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
@@ -3057,6 +3064,12 @@ export class AgentManager {
|
||||
event: AgentStreamEvent,
|
||||
options?: HandleStreamEventOptions,
|
||||
): Promise<boolean> {
|
||||
if (event.type === "timeline") {
|
||||
event = {
|
||||
...event,
|
||||
item: limitAgentTimelineItemContent(event.item),
|
||||
};
|
||||
}
|
||||
const eventTurnId = getAgentStreamEventTurnId(event);
|
||||
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
|
||||
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
|
||||
@@ -3553,6 +3566,7 @@ export class AgentManager {
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string },
|
||||
): AgentTimelineRow {
|
||||
item = limitAgentTimelineItemContent(item);
|
||||
const row = this.timelineStore.append(agentId, item, options);
|
||||
this.enqueueDurableTimelineAppend(agentId, row);
|
||||
return row;
|
||||
@@ -3742,6 +3756,12 @@ export class AgentManager {
|
||||
event: AgentStreamEvent,
|
||||
metadata?: { seq?: number; epoch?: string; timestamp?: string },
|
||||
): void {
|
||||
if (event.type === "timeline") {
|
||||
event = {
|
||||
...event,
|
||||
item: limitAgentTimelineItemContent(event.item),
|
||||
};
|
||||
}
|
||||
const agent = this.agents.get(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
|
||||
|
||||
function limitTextContent(value: string): string {
|
||||
if (Buffer.byteLength(value, "utf8") <= TOOL_CALL_CONTENT_MAX_BYTES) {
|
||||
return value;
|
||||
}
|
||||
const bytes = Buffer.from(value, "utf8").subarray(0, TOOL_CALL_CONTENT_MAX_BYTES);
|
||||
return new StringDecoder("utf8").write(bytes);
|
||||
}
|
||||
|
||||
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
|
||||
if (
|
||||
item.type !== "tool_call" ||
|
||||
item.detail.type !== "shell" ||
|
||||
item.status !== "failed" ||
|
||||
typeof item.error !== "object" ||
|
||||
item.error === null
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const error: Record<string, unknown> = { ...item.error };
|
||||
let changed = false;
|
||||
for (const key of ["content", "message"] as const) {
|
||||
const value = error[key];
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const limitedValue = limitTextContent(value);
|
||||
if (limitedValue !== value) {
|
||||
error[key] = limitedValue;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem {
|
||||
item = limitFailedShellError(item);
|
||||
if (
|
||||
item.type !== "tool_call" ||
|
||||
item.detail.type !== "shell" ||
|
||||
typeof item.detail.output !== "string"
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
const output = limitTextContent(item.detail.output);
|
||||
if (output === item.detail.output) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
detail: {
|
||||
...item.detail,
|
||||
output,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1197,6 +1197,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
test("keeps a turn active while OpenCode is retrying", async () => {
|
||||
vi.useFakeTimers();
|
||||
const eventsGate = createTestDeferred<void>();
|
||||
let eventStreamSignal: AbortSignal | undefined;
|
||||
const retryStream: AsyncIterable<unknown> = {
|
||||
[Symbol.asyncIterator]: () => {
|
||||
let emitted = false;
|
||||
@@ -1222,14 +1223,27 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
},
|
||||
};
|
||||
}
|
||||
return new Promise(() => {});
|
||||
return new Promise<IteratorResult<unknown>>((resolve) => {
|
||||
if (eventStreamSignal?.aborted) {
|
||||
resolve({ done: true, value: undefined });
|
||||
return;
|
||||
}
|
||||
eventStreamSignal?.addEventListener(
|
||||
"abort",
|
||||
() => resolve({ done: true, value: undefined }),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const fakeClient = {
|
||||
global: {
|
||||
event: vi.fn().mockResolvedValue({ stream: retryStream }),
|
||||
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => {
|
||||
eventStreamSignal = signal;
|
||||
return { stream: retryStream };
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
abort: vi.fn().mockResolvedValue({ error: null }),
|
||||
@@ -1319,6 +1333,52 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(fakeClient.session.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("waits for the OpenCode event stream to finish after close aborts it", async () => {
|
||||
const streamAborted = createTestDeferred<void>();
|
||||
const finishStreamCleanup = createTestDeferred<void>();
|
||||
const fakeClient = {
|
||||
global: {
|
||||
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
|
||||
stream: {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: async () => {
|
||||
if (!signal.aborted) {
|
||||
await waitForAbort(signal);
|
||||
}
|
||||
streamAborted.resolve();
|
||||
await finishStreamCleanup.promise;
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
}),
|
||||
},
|
||||
})),
|
||||
},
|
||||
session: {
|
||||
abort: vi.fn().mockResolvedValue({ error: null }),
|
||||
update: vi.fn().mockResolvedValue({ error: null }),
|
||||
},
|
||||
} as never;
|
||||
const session = new __openCodeInternals.OpenCodeAgentSession(
|
||||
{ provider: "opencode", cwd: "/tmp/test" },
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
);
|
||||
let closeSettled = false;
|
||||
|
||||
const closePromise = session.close().then(() => {
|
||||
closeSettled = true;
|
||||
return undefined;
|
||||
});
|
||||
await streamAborted.promise;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(closeSettled).toBe(false);
|
||||
|
||||
finishStreamCleanup.resolve();
|
||||
await closePromise;
|
||||
});
|
||||
|
||||
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
|
||||
const fakeClient = {
|
||||
session: {
|
||||
@@ -2178,6 +2238,15 @@ function createTestDeferred<T>(): {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function waitForAbort(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable<OpenCodeEvent> {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => {
|
||||
|
||||
@@ -2789,6 +2789,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private releaseServer: (() => void) | null;
|
||||
private eventStreamAbortController: AbortController | null = null;
|
||||
private eventStreamReady: Deferred<void> | null = null;
|
||||
private eventStreamTask: Promise<void> | null = null;
|
||||
private suppressTerminalUntilNextUserMessage = false;
|
||||
private closed = false;
|
||||
private readonly persistSession: boolean;
|
||||
@@ -3160,11 +3161,24 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const eventStreamReady = createDeferred<void>();
|
||||
this.eventStreamAbortController = eventStreamAbortController;
|
||||
this.eventStreamReady = eventStreamReady;
|
||||
void this.consumeEventStream(eventStreamAbortController, eventStreamReady).finally(() => {
|
||||
const eventStreamTask = this.consumeEventStream(
|
||||
eventStreamAbortController,
|
||||
eventStreamReady,
|
||||
).finally(() => {
|
||||
if (this.eventStreamAbortController === eventStreamAbortController) {
|
||||
this.eventStreamAbortController = null;
|
||||
this.eventStreamReady = null;
|
||||
}
|
||||
if (this.eventStreamTask === eventStreamTask) {
|
||||
this.eventStreamTask = null;
|
||||
}
|
||||
});
|
||||
this.eventStreamTask = eventStreamTask;
|
||||
void eventStreamTask.catch((error) => {
|
||||
this.logger.warn(
|
||||
{ err: error, sessionId: this.sessionId },
|
||||
"OpenCode event stream task failed",
|
||||
);
|
||||
});
|
||||
|
||||
return eventStreamReady.promise;
|
||||
@@ -3561,9 +3575,19 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
// unhandled rejection in whichever test the daemon hops to next.
|
||||
this.closed = true;
|
||||
this.abortController?.abort();
|
||||
const eventStreamTask = this.eventStreamTask;
|
||||
this.eventStreamAbortController?.abort();
|
||||
if (eventStreamTask) {
|
||||
await eventStreamTask.catch((error) => {
|
||||
this.logger.debug(
|
||||
{ err: error, sessionId: this.sessionId },
|
||||
"OpenCode event stream failed during close",
|
||||
);
|
||||
});
|
||||
}
|
||||
this.eventStreamAbortController = null;
|
||||
this.eventStreamReady = null;
|
||||
this.eventStreamTask = null;
|
||||
this.subscribers.clear();
|
||||
await reconcileOpenCodeSessionClose({
|
||||
client: this.client,
|
||||
|
||||
+36
-1
@@ -156,7 +156,10 @@ export class TestOpenCodeClient {
|
||||
global: {
|
||||
event: async (options: unknown) => {
|
||||
this.calls.globalEvent.push(options);
|
||||
return { stream: this.eventStream };
|
||||
const signal = (options as { signal?: AbortSignal }).signal;
|
||||
return {
|
||||
stream: signal ? stopEventStreamOnAbort(this.eventStream, signal) : this.eventStream,
|
||||
};
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
@@ -247,6 +250,38 @@ export class TestOpenCodeClient {
|
||||
}
|
||||
}
|
||||
|
||||
function stopEventStreamOnAbort(
|
||||
stream: AsyncIterable<unknown>,
|
||||
signal: AbortSignal,
|
||||
): AsyncIterable<unknown> {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => {
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
return {
|
||||
next: () => {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}
|
||||
return new Promise<IteratorResult<unknown>>((resolve, reject) => {
|
||||
const onAbort = () => resolve({ done: true, value: undefined });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
void iterator.next().then(
|
||||
(result) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
return resolve(result);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
return reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createEventStream(events: unknown[]): AsyncGenerator<unknown> {
|
||||
return (async function* () {
|
||||
for (const event of events) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.106-beta.1",
|
||||
"version": "0.1.106",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user