Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 0c05f69402 fix(server): cap shell timeline text by UTF-8 bytes 2026-07-12 14:28:33 +00:00
Mohamed Boudra 8fdc41f06c fix(server): cover imported and failed shell output 2026-07-12 16:08:15 +02:00
Mohamed Boudra 928c124356 refactor(server): slice oversized tool output directly 2026-07-12 16:01:11 +02:00
Mohamed Boudra 0a398833d8 fix(server): bound shell tool output in timelines
Provider tool output could enter timeline persistence and live streams without a size limit. Bound canonical shell output before coalescing, storage, history hydration, and dispatch so every provider follows the same 64 KiB budget.
2026-07-12 15:55:13 +02:00
paseo-ai[bot] 71c66823aa fix: update lockfile signatures and Nix hash [skip ci] 2026-07-12 08:47:55 +00:00
Mohamed Boudra a1821d5863 chore(release): cut 0.1.106 2026-07-12 10:42:56 +02:00
Mohamed Boudra 5db070a4d9 docs: promote 0.1.106 release notes 2026-07-12 10:40:19 +02:00
Matt Cowgerandgreptile-apps[bot] 461be47c91 fix(server): await OpenCode event stream shutdown (#2015)
* fix(server): await OpenCode event stream shutdown

* Update opencode-agent.test.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-12 16:20:13 +08:00
Mohamed Boudra 92d7066601 fix(app): prevent white flashes between workspace routes
Nested native stacks fell back to the navigation library's light background during cross-stack swaps. Share the active app surface color across every stack without subscribing route layouts to all theme runtime changes.
2026-07-12 00:27:40 +02:00
Mohamed Boudra d28e174b38 ops(relay): require manual deployment 2026-07-12 00:09:15 +02:00
paseo-ai[bot] a257a1c5f6 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-11 18:59:45 +00:00
Mohamed Boudra 144f951a79 ops(relay): merge production bridge to Fly so release deploys preserve it 2026-07-11 20:55:56 +02:00
Mohamed Boudra 51fea4b7e0 docs: clarify mobile beta release note 2026-07-11 20:47:48 +02:00
Mohamed Boudra 5ae53c7e55 ops(relay): bridge production traffic to Fly 2026-07-11 16:49:15 +02:00
33 changed files with 619 additions and 77 deletions
+2 -5
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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
+2
View File
@@ -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".
+5
View File
@@ -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
View File
@@ -1 +1 @@
sha256-cU6qRXY9fnr80pllmqJts5w8+OiE6F99SRo2d9G0HDA=
sha256-lYvP0RKgxshO68+bjkkUXX9YJtzgoGIp8kGdGFYOT+o=
+21 -21
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.106-beta.1",
"version": "0.1.106",
"private": true,
"main": "index.ts",
"scripts": {
+8 -13
View File
@@ -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,
}));
+4 -4
View File
@@ -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",
+3 -3
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.106-beta.1",
"version": "0.1.106",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -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 -1
View File
@@ -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",
+6
View File
@@ -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
+88
View File
@@ -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");
});
});
+17
View File
@@ -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));
},
};
}
+3 -2
View File
@@ -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");
+3
View File
@@ -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"
+5 -5
View File
@@ -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,
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.106-beta.1",
"version": "0.1.106",
"private": true,
"type": "module",
"scripts": {