Compare commits

..
Author SHA1 Message Date
f56a1add91 fix(usage): complete enterprise spending percentages
Derive missing remaining percentages so CLI spending output matches the Usage card. Preserve explicitly reported percentages and cover missing currency and absent rolling windows.

Co-authored-by: Matt Cowger <matt@cowger.us>
Co-authored-by: JasprW <wang@jaspr.me>
2026-11-07 00:21:38 +00:00
Matt Cowger c9b03eb309 fix(usage): report enterprise spending limits 2026-11-06 12:48:19 -08:00
paseo-bot[bot]andSebastian Arena 9f68af0274 Keep uploaded terminal file paths separate (#8946)
* M1: terminal input-sink bridge, upload button in tab-bar slot and key bar, picked-file routing, space-separated pasted paths

* M2: terminal dictation: mic in tab-bar slot and key bar, recording pill and overlay, per-terminal recording lifetime incl. pending mic acquisition, Cmd+D on macOS by keyboard focus, i18n

* M2: review fixes: retry attempt guard, cancellable native pending start, measured terminal slot width, compact-web Cmd+D for terminal dictation

* fix(terminal): separate uploaded file paths when pasting

Keep the remote file-drop regression and its test coverage from the contributor change, without introducing new terminal controls. Each uploaded path needs a trailing separator because adjacent quoted paths are one shell word.

Co-authored-by: Sebastian Arena <sebastian@walkofcode.io>

---------

Co-authored-by: Sebastian Arena <sebastian@walkofcode.io>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 18:57:29 +00:00
paseo-bot[bot] 79f2db8d56 test(server): retry locked provider fixture cleanup on Windows (#8942)
Availability probes already await process completion. Allow bounded filesystem cleanup retries for transient executable locks, matching the neighboring Windows launch tests.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 18:30:36 +00:00
1efcb31a6c Give agents their own workspace identity through create and resume (#8939)
* fix(server): give agents their workspace id as PASEO_WORKSPACE_ID

Agents got PASEO_AGENT_ID and PASEO_AGENT_CWD but not their workspace id, so an
agent couldn't rename or archive its own workspace, or name it to create_agent,
when several workspaces share its cwd. The launch env now sets
PASEO_WORKSPACE_ID whenever the agent has a workspace, including on resume after
a daemon restart, where the id now comes from the stored record. OpenCode treats
it as a per-session key, so it doesn't force a dedicated server.

Refs #5098

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): keep the workspace when resuming an agent by handle

resume_agent_request matched the stored agent but dropped its workspace id, so
the resumed agent had no PASEO_WORKSPACE_ID and no workspace. Pass the matched
record's workspace id into the resume.

resumeAgentFromPersistence now resolves the workspace once and gives it to both
the launch env and the registered agent, so a caller that omits it no longer
erases the stored value on the next snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(server): exercise current daemon creation and permission contracts

The workspace resume regression shares this suite with creation checks that still waited for legacy status frames and permission listeners without an owned subscription. Verify the returned error snapshot and acquire event demand so the complete suite can validate workspace identity changes.

Co-authored-by: Darrell <DT@Terastar.biz>

---------

Co-authored-by: Darrell <DT@Terastar.biz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 18:06:59 +00:00
paseo-bot[bot]andluke ea30d2616a Restore Mermaid source toggles and bound inline previews (#8932)
* fix(app): keep Mermaid controls usable in inline previews

* test(app): verify Mermaid controls through user journeys

Keep source-toggle mechanics in diagram helpers and compare preview geometry with its screen and controls instead of requiring exact pixel heights.

Co-authored-by: luke <luopeihuan@gmail.com>

---------

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 16:58:28 +00:00
Mohamed Boudra f21699a37a chore(release): reconcile 0.15.3 metadata (#8943) 2026-11-06 23:10:33 +07:00
Mohamed Boudra a9bce9a18d Keep the chat still when an agent's turn ends (#8944)
* Land a turn's reply, completed footer, and tool summary in one frame

The stream handler closed turn liveness as soon as turn_completed arrived,
while the final reply waited in the frame-coalesced reducer queue. For one
commit the turn was finished with no reply: the footer vanished and the
timeline jumped twice, and tool turns folded into their summary later.

The viewed timeline owner now takes stream events whole: a turn boundary
commits the queued content before liveness changes. Running and completed
footers share one fixed content height so the phase switch moves nothing.

* Document the turn-boundary commit and stop racing the re-pin in the turn-open test

The turn-open test scrolled the received message into view with a
programmatic scroll, which leaves the chat following its end. Pressing
the turn summary swaps its icon, and that DOM change re-pins the chat to
the end between pointerdown and pointerup. The summary had moved 14 px,
which left the pointer 2 px inside the button; with the completed footer
now 2 px taller, it moved 16 px and the click landed on the reply below.
The message's fade and Show more are already on screen at the end, so
the test no longer scrolls.
2026-11-06 23:10:29 +07:00
paseo-bot[bot] 73448d483e test(app): scope command-center stability checks to the panel (#8927)
Document-wide layout shifts could fail a stable file-search panel. Count only shifts attributed to the panel or its descendants, and exercise both unrelated and in-panel movement in the existing browser journey.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 15:55:09 +00:00
55 changed files with 980 additions and 209 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.15.3 - 2026-11-06
### Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ ([#8918](https://github.com/getpaseo/paseo/pull/8918) by [@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### Added
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.15.3 - 2026-11-06
### 修复
- 修复了 iOS 26 及更高版本中打开或收起键盘时聊天内容变模糊的问题 ([#8918](https://github.com/getpaseo/paseo/pull/8918),贡献者:[@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### 新增
+1
View File
@@ -34,6 +34,7 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A turn boundary commits the queue before turn liveness changes.** The final reply arrives a few milliseconds before `turn_completed`, so it is still queued when the turn ends. `applyStreamEvent` in `viewed-timeline-sync.ts` flushes the agent's queue first; otherwise the chat paints a finished turn with no reply, drops the footer, and jumps twice.
- **A history row re-renders only when its item or layout item identity changes.** Every
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
+3
View File
@@ -195,6 +195,9 @@ See the [public usage source reference](../public-docs/plugins/reference.md#usag
contract, account and window identity, provider-derived period names, login fallback, and
read-only credential rules. Usage adapters own the interpretation of provider fields; the app
renders their names and resolves pins without provider-specific duration guesses.
Claude's OAuth usage endpoint is undocumented. Extra-usage amounts are reported in minor currency
units and displayed only for USD; the balance contract does not represent other currencies.
Codex individual spending limits provide percentages, not dollar amounts.
---
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.2",
"version": "0.15.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.2",
"version": "0.15.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.2",
"version": "0.15.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/server": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.2",
"version": "0.15.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.2",
"@getpaseo/highlight": "0.15.2",
"@getpaseo/plugin": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.2",
"version": "0.15.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.2",
"version": "0.15.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.2",
"version": "0.15.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,200 @@
import type { Page } from "@playwright/test";
import { expect, test as baseTest } from "../support/fixtures";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import { submitMessage } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
// A turn ends with its final reply, `turn_completed`, and the idle status snapshot arriving
// within a few milliseconds of each other. These tests hold all three, let the running
// turn paint, then release them together and sample every painted frame.
interface Box {
top: number;
height: number;
}
interface TurnEndFrame {
at: number;
running: boolean;
footer: Box | null;
userRow: Box | null;
replyVisible: boolean;
}
declare global {
interface Window {
__turnEndFrames?: { active: boolean; frames: TurnEndFrame[] };
}
}
const test = baseTest.extend<{ agent: MockAgentWorkspace }>({
agent: async ({ page: _page }, provide) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-end-",
title: "Turn end",
initialPrompt: "emit 4 tool agent stream updates",
});
await agent.client.waitForFinish(agent.agentId, 15_000);
await provide(agent);
await agent.cleanup();
},
});
async function recordTurnEndFrames(
page: Page,
input: { prompt: string; reply: string | null },
): Promise<void> {
await page.evaluate(({ prompt, reply }) => {
const state = { active: true, frames: [] as TurnEndFrame[] };
const isVisible = (element: Element | null | undefined) => Boolean(element?.checkVisibility());
const boxOf = (element: Element | null | undefined): Box | null => {
if (!element || !isVisible(element)) return null;
const rect = element.getBoundingClientRect();
return rect.height > 0 ? { top: rect.top, height: rect.height } : null;
};
const sample = () => {
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => isVisible(candidate));
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
.map(boxOf)
.filter((box): box is Box => box !== null)
.sort((left, right) => left.top - right.top);
const userRow = Array.from(
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
).find((candidate) => candidate.textContent?.includes(prompt));
const replyRow =
reply === null
? undefined
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
(candidate) => candidate.textContent?.includes(reply),
);
state.frames.push({
at: performance.now(),
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
footer: footers.at(-1) ?? null,
userRow: boxOf(userRow),
replyVisible: boxOf(replyRow) !== null,
});
if (state.active) requestAnimationFrame(() => setTimeout(sample, 0));
};
window.__turnEndFrames = state;
requestAnimationFrame(() => setTimeout(sample, 0));
}, input);
}
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
await expect
.poll(() => page.evaluate(() => window.__turnEndFrames?.frames.at(-1)?.at ?? 0))
.toBeGreaterThanOrEqual(until);
}
async function stopRecording(page: Page): Promise<TurnEndFrame[]> {
return page.evaluate(() => {
const state = window.__turnEndFrames;
if (!state) throw new Error("Turn end frames were not recorded");
state.active = false;
return state.frames;
});
}
async function recordTurnEnd(
page: Page,
agent: MockAgentWorkspace,
input: { prompt: string; reply: string | null },
): Promise<TurnEndFrame[]> {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectAgentIdle(page);
if (input.reply === null) {
gate.setAgentStreamItemSuppressed("assistant_message", true);
} else {
gate.holdNextAgentStreamItem("assistant_message");
}
gate.holdNextAgentStreamEvent("turn_completed");
// The daemon sends more than one idle snapshot; hold the first and drop the rest until release.
gate.holdNextAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", true);
await recordTurnEndFrames(page, input);
await submitMessage(page, input.prompt);
await Promise.all([
input.reply === null ? Promise.resolve() : gate.waitForHeldAgentStreamItem("assistant_message"),
gate.waitForHeldAgentStreamEvent("turn_completed"),
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
]);
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await recordPaintsFor(page, 200);
if (input.reply !== null) gate.releaseHeldAgentStreamItem("assistant_message");
gate.releaseHeldAgentStreamEvent("turn_completed");
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", false);
await expectAgentIdle(page);
await recordPaintsFor(page, 300);
return stopRecording(page);
}
function describeFrames(frames: TurnEndFrame[]): string {
return frames
.map(
(frame, index) =>
`${index}: ${frame.running ? "running " : "complete"} footer=${
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
)
.join("\n");
}
function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | null }): void {
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
const firstComplete = frames.findIndex((frame, index) => index > firstRunning && !frame.running);
const record = `frames:\n${describeFrames(frames)}`;
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
expect(firstComplete, record).toBeGreaterThan(firstRunning);
const transition = frames.slice(firstRunning);
const runningHeight = transition[0].footer?.height;
expect(
transition.map((frame) => frame.footer?.height ?? null),
`the footer must stay painted at one height\n${record}`,
).toEqual(transition.map(() => runningHeight));
if (input.reply !== null) {
expect(
frames.slice(firstComplete).every((frame) => frame.replyVisible),
`a completed turn must show its reply in the same frame\n${record}`,
).toBe(true);
}
// Tool rows land while the turn runs; only the turn end itself must be one step.
const ending = frames.slice(firstComplete - 1);
const promptMoves = ending
.slice(1)
.filter((frame, index) => frame.userRow?.top !== ending[index].userRow?.top);
expect(
promptMoves.length,
`the prompt must move at most once as the turn ends\n${record}`,
).toBeLessThanOrEqual(1);
}
test.describe("Turn end", () => {
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("ends a tool turn with its reply and completed footer together", async ({ page, agent }) => {
const input = { prompt: "emit 3 tool agent stream updates", reply: "Completed 3 commands" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("keeps the footer height when a turn ends on a tool call", async ({ page, agent }) => {
const input = { prompt: "emit 2 tool agent stream updates", reply: null };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
});
@@ -5,8 +5,10 @@ import {
holdDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
expectLayoutObservationToCatchPanelShift,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
shiftOutsideCommandCenter,
} from "../support/helpers/command-center-file-search";
import {
scrollCommandCenterAwayFromSelection,
@@ -84,7 +86,9 @@ test("workspace file search stays geometrically stable through delayed loading a
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await shiftOutsideCommandCenter(page);
await expectStableCommandCenterLayout(page);
await expectLayoutObservationToCatchPanelShift(page);
await row.click();
await expectFileTabOpen(page, FILE_PATH);
@@ -1,6 +1,8 @@
import { test } from "../support/fixtures";
import {
closeDiagramFullscreen,
expectInlineDiagramFitsViewport,
toggleDiagramSourceAndVerifyControls,
closeDiagramFullscreenFromOutside,
expectCompletedDiagram,
expectFullscreenDiagram,
@@ -21,6 +23,36 @@ const MERMAID_DIAGRAM = [
"```",
].join("\n");
test("bounds a tall inline diagram", async ({ page }) => {
test.setTimeout(120_000);
const diagram = [
"```mermaid",
"flowchart TD",
...Array.from({ length: 30 }, (_, i) => `N${i} --> N${i + 1}`),
"```",
].join("\n");
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-tall-",
title: "Tall Mermaid",
featureValues: {
mockStreamingAssistantResponse: diagram,
mockStreamingAssistantIntervalMs: 10,
},
});
try {
await openAgentRoute(page, agent);
await requestDiagram(agent);
await waitForDiagramTurnToComplete(agent);
await expectCompletedDiagram(page, ["N0", "N30"]);
await expectInlineDiagramFitsViewport(page);
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["N0", "N30"]);
await closeDiagramFullscreen(page);
} finally {
await agent.cleanup();
}
});
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
test.setTimeout(120_000);
@@ -40,6 +72,10 @@ test("opens a Mermaid diagram fullscreen and closes it again", async ({ page })
await expectCompletedDiagram(page, ["Start", "Ship"]);
});
await test.step("Source toggles preserve the diagram and exclude hidden controls", async () => {
await toggleDiagramSourceAndVerifyControls(page);
});
await test.step("The toolbar opens the diagram fullscreen", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
@@ -45,6 +45,11 @@ process.stdin.on("data", (chunk) => {
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
const pasted = JSON.stringify(paste[1]).replace(
/\s/g,
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
);
process.stdout.write("PASTED:" + pasted + "\r\n");
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
@@ -202,6 +207,21 @@ test.describe("Terminal protocol queries", () => {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
// Every bracketed paste body the receiver consumed, in order and joined: what a
// shell would read as one command line.
async function pastedText(terminalId: string): Promise<string> {
const output = await terminalOutput(terminalId);
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
.map(([, json]) => JSON.parse(json) as string)
.join("");
}
// Exactly one ASCII space after each quoted path and nothing else between them,
// so the paths stay separate shell words.
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
@@ -238,6 +258,23 @@ test.describe("Terminal protocol queries", () => {
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
try {
await dropFiles(page, [first]);
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
await dropFiles(page, [second]);
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
@@ -407,7 +407,6 @@ test("opening a turn keeps the received message above it clipped on every frame"
.getByTestId("user-message")
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
await bubble.scrollIntoViewIfNeeded();
await watchReceivedMessagePreview(page, "Sent by Reviewer");
await page
@@ -99,6 +99,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
const observedElements = new WeakSet<Element>();
const resizeObserver = new ResizeObserver(record);
const mutationObserver = new MutationObserver(discover);
let performanceObserver: PerformanceObserver | undefined;
function record(): void {
if (!observation.active) return;
@@ -132,6 +133,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (!observation.active) {
mutationObserver.disconnect();
resizeObserver.disconnect();
performanceObserver?.disconnect();
return;
}
record();
@@ -141,9 +143,16 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (typeof PerformanceObserver !== "undefined") {
try {
const performanceObserver = new PerformanceObserver((list) => {
performanceObserver = new PerformanceObserver((list) => {
const panel = document.querySelector(selectors.panel);
for (const entry of list.getEntries()) {
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node && panel?.contains(source.node))) {
observation.layoutShift += shift.value;
}
}
});
performanceObserver.observe({ type: "layout-shift", buffered: false });
@@ -174,7 +183,54 @@ export async function expectStableCommandCenterLayout(page: Page): Promise<void>
).toBeLessThanOrEqual(1);
}
}
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
expect(
observation.layoutShift,
`command center layoutShift: ${JSON.stringify(observation)}`,
).toBeLessThanOrEqual(0.001);
}
/** Exercise an unrelated page shift while the real search panel is being observed. */
export async function shiftOutsideCommandCenter(page: Page): Promise<void> {
await shiftLayoutProbe(page.locator("body"));
}
export async function expectLayoutObservationToCatchPanelShift(page: Page): Promise<void> {
await startCommandCenterLayoutObservation(page);
await shiftLayoutProbe(page.getByTestId("command-center-panel"));
await expect(expectStableCommandCenterLayout(page)).rejects.toThrow("command center layoutShift");
}
async function shiftLayoutProbe(parent: Locator): Promise<void> {
const recordedShift = await parent.evaluate(async (container) => {
const sibling = document.createElement("div");
sibling.textContent = "Layout probe";
sibling.style.cssText =
"position:fixed;left:0;top:0;width:300px;height:100px;background:white;z-index:99999";
container.append(sibling);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const value = await new Promise<number>((resolve) => {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node === sibling)) {
observer.disconnect();
resolve(shift.value);
}
}
});
observer.observe({ type: "layout-shift", buffered: false });
sibling.style.left = "400px";
});
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
sibling.remove();
return value;
});
expect(recordedShift, "the browser recorded a substantial probe shift").toBeGreaterThan(0.001);
}
export async function expectOneLineTruncatedFileResult(
@@ -148,3 +148,59 @@ export async function expectFullscreenDiagram(
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
}
/** Tall diagrams stay within the screen so their controls remain reachable. */
export async function expectInlineDiagramFitsViewport(page: Page): Promise<void> {
const viewport = page.getByTestId("mermaid-viewport").last();
const box = await viewport.boundingBox();
expect(box, "the inline diagram has measurable layout").not.toBeNull();
const screenHeight = page.viewportSize()!.height;
expect(box!.height, "the inline diagram fits within the screen").toBeLessThan(screenHeight);
const contentHeight = await diagramSvg(page, "mermaid-viewport").evaluate(
(svg) => (svg as SVGSVGElement).viewBox.baseVal.height,
);
expect(box!.height, "a tall diagram is bounded rather than expanding the chat").toBeLessThan(
contentHeight,
);
}
/** Exercise pointer toggles and the controls encountered along that same journey. */
export async function toggleDiagramSourceAndVerifyControls(page: Page): Promise<void> {
const viewport = page.getByTestId("mermaid-viewport").last();
const iframe = await viewport.locator("iframe").elementHandle();
for (let iteration = 0; iteration < 3; iteration++) {
await viewport.hover();
await page.getByRole("button", { name: "View source" }).last().click();
const viewDiagram = page.getByRole("button", { name: "View diagram" }).last();
await viewDiagram.click({ timeout: 10_000 });
await expect(viewDiagram).toHaveCount(0);
await expectFullscreenDiagramClosed(page);
expect(
await viewport.locator("iframe").evaluate((node, original) => node === original, iframe),
"switching views preserves the rendered diagram",
).toBe(true);
}
await viewport.hover();
await page.getByRole("button", { name: "View source" }).last().click();
const hiddenFullscreen = viewport.getByRole("button", {
name: "Fullscreen",
includeHidden: true,
});
await hiddenFullscreen.focus();
await expect(hiddenFullscreen).not.toBeFocused();
await page.getByRole("button", { name: "View diagram" }).last().click();
await viewport.hover();
const fullscreen = viewport.getByRole("button", { name: "Fullscreen", exact: true });
const box = await viewport.boundingBox();
const control = await fullscreen.boundingBox();
expect(box).not.toBeNull();
expect(control).not.toBeNull();
expect(control!.y, "the toolbar starts inside the preview").toBeGreaterThanOrEqual(box!.y);
expect(control!.y + control!.height, "the toolbar fits inside the preview").toBeLessThanOrEqual(
box!.y + box!.height,
);
await viewport.getByRole("button", { name: "Zoom in" }).click();
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeEnabled();
await viewport.getByRole("button", { name: "Reset view" }).click();
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeDisabled();
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.2",
"version": "0.15.3",
"private": true,
"main": "index.ts",
"scripts": {
+11 -3
View File
@@ -23,6 +23,10 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
// Every footer variant (running, completed with Copy, completed with only its duration) gets
// this content height, so a turn ending swaps the footer's controls without moving the rows
// above it.
const TURN_FOOTER_CONTENT_HEIGHT = 24;
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
@@ -208,7 +212,11 @@ function CompletedTurnFooter({
function TurnFooterRow({ children }: { children: ReactNode }) {
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
return <View style={rowStyle}>{children}</View>;
return (
<View style={rowStyle} testID="turn-footer">
{children}
</View>
);
}
const stylesheet = StyleSheet.create((theme) => ({
@@ -229,11 +237,11 @@ const stylesheet = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
minHeight: 24,
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
},
turnFooterContent: {
height: 24,
height: TURN_FOOTER_CONTENT_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
@@ -72,7 +72,7 @@ function MermaidFenceHostImpl({
() => [
getDiagramBoxStyle(textStyle),
containerStyle,
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
{ height: Math.min(runtimeHeight, MAX_PREVIEW_HEIGHT), minHeight: MIN_DIAGRAM_BOX_HEIGHT },
],
[runtimeHeight, textStyle],
);
@@ -111,20 +111,22 @@ function MermaidFenceHostImpl({
) : null}
</View>
) : null}
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={diagramSize}
style={viewportStyle}
testID="mermaid-viewport"
wheelActivation="modifier"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
<div inert={!diagramVisible} style={boundaryStyle}>
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={diagramSize}
style={viewportStyle}
testID="mermaid-viewport"
wheelActivation="modifier"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
</div>
{isFullscreen && visible ? (
<MermaidFullscreenViewer
source={visible.source}
@@ -142,6 +144,8 @@ const MEASURING_SIZE = { width: 240, height: 240 };
* with `overflow: hidden`, so a shorter box leaves the buttons half-clipped and unclickable.
*/
const MIN_DIAGRAM_BOX_HEIGHT = 56;
const MAX_PREVIEW_HEIGHT = 480;
const boundaryStyle: React.CSSProperties = { display: "contents" };
const sourceContainerStyle: ViewStyle = { position: "relative" };
/**
* The viewport's own root is `flex: 1`, so as a flex item it has `flex-basis: 0%` and that basis
@@ -1,34 +0,0 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, test } from "vitest";
import { Portal, PortalProvider } from "@gorhom/portal";
import { FloatingPanelPortalHost, measureFloatingPanelPortalHost } from "./floating-panel-portal";
describe("Floating panel host lifetime", () => {
test("removing the newest host preserves the surviving host measurement", async () => {
const container = document.createElement("div");
container.style.cssText = "position:relative;width:800px;height:600px";
document.body.appendChild(container);
const root = createRoot(container);
function Content({ overlap }: { overlap: boolean }) {
return (
<PortalProvider shouldAddRootHost={false}>
<FloatingPanelPortalHost name="workspace" />
{overlap && <FloatingPanelPortalHost name="workspace" />}
<Portal hostName="workspace" name="picker">
<div data-testid="picker">Commands</div>
</Portal>
</PortalProvider>
);
}
try {
await act(async () => root.render(<Content overlap={true} />));
expect(await measureFloatingPanelPortalHost("workspace")).not.toBeNull();
await act(async () => root.render(<Content overlap={false} />));
expect(await measureFloatingPanelPortalHost("workspace")).not.toBeNull();
} finally {
await act(async () => root.unmount());
container.remove();
}
});
});
+1 -15
View File
@@ -12,7 +12,6 @@ import {
refreshAgentInitializationTimeout,
} from "@/hooks/use-agent-initialization";
import type { StreamItem } from "@/types/stream";
import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducers";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync";
@@ -360,8 +359,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const onStream = (message: SessionOutboundMessage) => {
if (message.type !== "agent_stream") return;
const { agentId, event, timestamp, seq, epoch } = message.payload;
const parsedTimestamp = new Date(timestamp);
const streamEvent = event;
if (
event.type === "turn_started" ||
event.type === "turn_completed" ||
@@ -370,18 +367,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
) {
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
}
const turnLiveness = deriveAgentStreamTurnLiveness([
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
]);
if (turnLiveness.length > 0) {
getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness);
}
sync.enqueueStreamEvent(agentId, {
event: streamEvent,
seq,
epoch,
timestamp: parsedTimestamp,
});
sync.applyStreamEvent(agentId, { event, seq, epoch, timestamp: new Date(timestamp) });
// NOTE: We don't update lastActivityAt on every stream event to prevent
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
+2
View File
@@ -2852,6 +2852,8 @@ export class HostRuntimeStore {
replica,
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId),
applyTurnLiveness: (agentId, transitions) =>
directory.applyAgentTurnLiveness(agentId, transitions),
ports,
});
}
@@ -74,7 +74,8 @@ describe("terminal file paste queue", () => {
firstUpload.resolve("/host/uploads/a.png");
await settle();
expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]);
expect(host.pasted).toEqual(["'/host/uploads/a.png' ", "'/host/uploads/b.png' "]);
expect(host.pasted.join("")).toBe("'/host/uploads/a.png' '/host/uploads/b.png' ");
expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] });
});
@@ -85,7 +86,7 @@ describe("terminal file paste queue", () => {
queue.add(Promise.resolve(file("shot.png")), host);
await settle();
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']);
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png" ']);
});
it("uploads under a file name that needs no shell quoting", async () => {
@@ -120,7 +121,7 @@ describe("terminal file paste queue", () => {
expect(events.failures).toEqual(["disk full"]);
expect(events.uploaded).toBe(1);
expect(host.pasted).toEqual(["'/host/uploads/b.png'"]);
expect(host.pasted).toEqual(["'/host/uploads/b.png' "]);
});
it("reports unreadable and oversized files without uploading them", async () => {
@@ -166,7 +167,7 @@ describe("terminal file paste queue", () => {
queue.add(Promise.resolve(file("next.png")), host);
await settle();
expect(host.pasted).toEqual(["'/host/uploads/next.png'"]);
expect(host.pasted).toEqual(["'/host/uploads/next.png' "]);
expect(events.uploading).toEqual([true, false, true, false]);
expect(queue.cancel()).toBe(false);
});
@@ -75,7 +75,7 @@ export class TerminalFilePasteQueue {
this.setUploading(true);
const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) });
if (isCurrent()) {
target.paste(prepareTerminalPath(path));
target.paste(`${prepareTerminalPath(path)} `);
this.events.onUploaded();
}
} catch (error) {
+90 -6
View File
@@ -11,6 +11,7 @@ import type { CachedTimeline } from "@/runtime/replica-cache";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import type { StreamItem } from "@/types/stream";
import type { TurnLivenessTransition } from "./turn-liveness";
import {
createTimelineReplica,
createViewedTimelineOwner,
@@ -40,7 +41,13 @@ function cachedTimeline(): CachedTimeline {
};
}
function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
function createOwner(
storage: TimelineReplicaStorage,
applyTurnLiveness: (
agentId: string,
transitions: readonly TurnLivenessTransition[],
) => void = () => undefined,
): ViewedTimelineOwner {
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage,
@@ -51,6 +58,7 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
replica,
replaceDemandedAgentIds: () => undefined,
drainQueuedAgentMessage: () => undefined,
applyTurnLiveness,
ports: {
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
readCursor: () => undefined,
@@ -98,7 +106,7 @@ describe("viewed timeline persistence", () => {
.status,
)
.toBe("painted");
owner.enqueueStreamEvent(AGENT_ID, {
owner.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
@@ -147,7 +155,7 @@ describe("viewed timeline persistence", () => {
.map((entry) => entry.text);
};
expect.soft(replyTexts()).toEqual([complete]);
owner.enqueueStreamEvent(AGENT_ID, {
owner.applyStreamEvent(AGENT_ID, {
event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload,
seq: 7,
epoch: "epoch-1",
@@ -281,7 +289,7 @@ describe("viewed timeline persistence", () => {
)
.toMatchObject({ status: "painted" });
first.enqueueStreamEvent(AGENT_ID, {
first.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
@@ -460,7 +468,7 @@ describe("viewed timeline persistence", () => {
readTimeline: async () => undefined,
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
});
owner.enqueueStreamEvent(AGENT_ID, {
owner.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
@@ -525,7 +533,7 @@ describe("viewed timeline persistence", () => {
[AGENT_ID, 9],
["agent-2", 4],
] as const) {
owner.enqueueStreamEvent(agentId, {
owner.applyStreamEvent(agentId, {
event: {
type: "timeline",
provider: "codex",
@@ -543,6 +551,81 @@ describe("viewed timeline persistence", () => {
});
});
describe("turn boundaries", () => {
it("commits a turn's final reply before the turn stops running", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
applySynced(AGENT_ID, 8);
const committedKinds = () => {
const session = useSessionStore.getState().sessions[SERVER_ID];
return [
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]
.filter((entry) => entry.id !== `network-${AGENT_ID}`)
.map((entry) => entry.kind);
};
const livenessChanges: { type: string; committed: string[] }[] = [];
const owner = createOwner(
{ readTimeline: async () => undefined, commitTimeline: () => undefined },
(_agentId, transitions) => {
for (const transition of transitions) {
livenessChanges.push({ type: transition.type, committed: committedKinds() });
}
},
);
const at = (second: number) => new Date(`2026-08-26T10:00:0${second}.000Z`);
// One frame's worth of live events: the frame flush that would commit them never runs.
owner.applyStreamEvent(AGENT_ID, {
event: { type: "turn_started", provider: "codex", turnId: "turn-1" },
seq: undefined,
epoch: undefined,
timestamp: at(1),
});
owner.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
turnId: "turn-1",
item: {
type: "tool_call",
callId: "call-1",
name: "Bash",
status: "completed",
error: null,
detail: { type: "shell", command: "ls" },
},
} as AgentStreamEventPayload,
seq: 9,
epoch: "epoch-1",
timestamp: at(2),
});
owner.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
turnId: "turn-1",
item: { type: "assistant_message", text: "Done.", messageId: "reply-1" },
} as AgentStreamEventPayload,
seq: 10,
epoch: "epoch-1",
timestamp: at(3),
});
owner.applyStreamEvent(AGENT_ID, {
event: { type: "turn_completed", provider: "codex", turnId: "turn-1" },
seq: undefined,
epoch: undefined,
timestamp: at(4),
});
expect(livenessChanges.at(-1)).toEqual({
type: "stream_close",
committed: ["tool_call", "assistant_message"],
});
owner.dispose();
});
});
describe("create handoff lifetime", () => {
const DRAFT_ID = "draft-1";
@@ -606,6 +689,7 @@ describe("create handoff lifetime", () => {
replica,
replaceDemandedAgentIds: () => undefined,
drainQueuedAgentMessage: () => undefined,
applyTurnLiveness: () => undefined,
ports: {
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
readCursor: () => undefined,
@@ -21,12 +21,14 @@ import {
} from "@/utils/agent-initialization";
import {
createSessionAgentStreamReducerQueue,
deriveAgentStreamTurnLiveness,
type AgentStreamReducerEvent,
type ProcessTimelineResponseOutput,
processTimelineResponse,
} from "./session-stream-reducers";
import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan";
import { replaceWithCanonicalStream } from "@/types/stream";
import type { TurnLivenessTransition } from "./turn-liveness";
export interface TimelineReplicaStorage {
readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined>;
@@ -361,7 +363,12 @@ export type ViewedTimelineOwnerPorts = Omit<
export interface ViewedTimelineOwner extends ViewedTimelineSync {
applyTimelineResponse(payload: TimelineResponsePayload): void;
enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void;
/**
* Accepts one live stream event. Content is coalesced to the next frame; a turn boundary
* commits the turn's queued content before its liveness changes, so the view never shows
* a finished turn without its final reply.
*/
applyStreamEvent(agentId: string, event: AgentStreamReducerEvent): void;
flushStreamAgent(agentId: string): void;
}
@@ -370,6 +377,7 @@ export function createViewedTimelineOwner(input: {
replica: TimelineReplica;
replaceDemandedAgentIds: (agentIds: string[]) => void;
drainQueuedAgentMessage: (agentId: string) => void;
applyTurnLiveness: (agentId: string, transitions: readonly TurnLivenessTransition[]) => void;
ports: ViewedTimelineOwnerPorts;
}): ViewedTimelineOwner {
const forcedTailReplacements = new Set<string>();
@@ -409,8 +417,12 @@ export function createViewedTimelineOwner(input: {
});
if (accepted) input.replica.timelineUpdated(payload.agentId);
},
enqueueStreamEvent(agentId, event) {
applyStreamEvent(agentId, event) {
streamQueue.enqueue(agentId, event);
const turnLiveness = deriveAgentStreamTurnLiveness([event]);
if (turnLiveness.length === 0) return;
streamQueue.flushAgent(agentId);
input.applyTurnLiveness(agentId, turnLiveness);
},
flushStreamAgent(agentId) {
streamQueue.flushAgent(agentId);
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.15.2",
"version": "0.15.3",
"description": "Paseo CLI - control your AI coding agents from the command line",
"license": "Apache-2.0",
"bin": {
@@ -29,9 +29,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/server": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"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.15.2",
"version": "0.15.3",
"description": "Paseo client SDK package",
"license": "Apache-2.0",
"files": [
@@ -48,8 +48,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.15.2",
"version": "0.15.3",
"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.15.2",
"version": "0.15.3",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.15.2",
"version": "0.15.3",
"description": "Paseo plugin SDK package",
"license": "Apache-2.0",
"files": [
@@ -78,8 +78,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -89,8 +89,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.15.2",
"version": "0.15.3",
"description": "Paseo shared protocol schemas and wire types",
"license": "Apache-2.0",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.15.2",
"version": "0.15.3",
"description": "Paseo relay for bridging daemon and client connections",
"license": "Apache-2.0",
"files": [
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.15.2",
"version": "0.15.3",
"description": "Paseo backend server",
"license": "Apache-2.0",
"files": [
@@ -108,11 +108,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.2",
"@getpaseo/highlight": "0.15.2",
"@getpaseo/plugin": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -3286,6 +3286,78 @@ test.each([
},
);
test("createAgent and resume give the agent its workspace id", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-workspace-env-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
createLaunchContext: AgentLaunchContext | undefined;
resumeLaunchContext: AgentLaunchContext | undefined;
override async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.createLaunchContext = launchContext;
return new TestAgentSession(config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.resumeLaunchContext = launchContext;
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
return new TestAgentSession({ ...metadata, ...overrides, provider: "codex", cwd: workdir });
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000140",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: "ws-agent-home",
});
expect(client.createLaunchContext?.env).toEqual({
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
PASEO_WORKSPACE_ID: "ws-agent-home",
});
// The stored record supplies the workspace when the caller doesn't.
const handle: AgentPersistenceHandle = {
provider: "codex",
sessionId: "workspace-env-session",
metadata: { provider: "codex", cwd: workdir },
};
const expectedEnv = {
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
PASEO_WORKSPACE_ID: "ws-agent-home",
};
await manager.closeAgent(snapshot.id);
const resumed = await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id);
expect(client.resumeLaunchContext?.env).toEqual(expectedEnv);
expect(resumed.workspaceId).toBe("ws-agent-home");
expect((await storage.get(snapshot.id))?.workspaceId).toBe("ws-agent-home");
// Each restart reads what the previous resume stored.
client.resumeLaunchContext = undefined;
await manager.closeAgent(snapshot.id);
await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id);
expect(client.resumeLaunchContext?.env).toEqual(expectedEnv);
});
test("createAgent passes persistSession to provider create options", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -4731,6 +4803,7 @@ test("importProviderSession imports the selected session without listing and pub
env: {
PASEO_AGENT_ID: imported.id,
PASEO_AGENT_CWD: workdir,
PASEO_WORKSPACE_ID: "ws-imported",
},
});
expect(imported.lifecycle).toBe("idle");
@@ -664,6 +664,36 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
return Object.assign(new Error(message), { name: "AbortError" });
}
// PASEO_WORKSPACE_ID lets an agent rename or archive its own workspace; several workspaces can
// share one cwd, so the agent can't work it out from PASEO_AGENT_CWD.
function agentIdentityEnv({
agentId,
cwd,
opening,
}: {
agentId: string;
cwd: string;
opening: { workspaceId?: string | null } | undefined;
}): Record<string, string> {
const workspaceId = opening?.workspaceId;
return {
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
...(workspaceId ? { PASEO_WORKSPACE_ID: workspaceId } : {}),
};
}
// Launch env and the registered agent must agree, or the next snapshot erases the stored workspace.
function resolveResumeWorkspaceId({
options,
record,
}: {
options: { workspaceId?: string } | undefined;
record: Pick<StoredAgentRecord, "workspaceId"> | null;
}): string | undefined {
return options?.workspaceId ?? record?.workspaceId;
}
function validateAgentId(agentId: string, source: string): string {
const result = AgentIdSchema.safeParse(agentId);
if (!result.success) {
@@ -1571,6 +1601,7 @@ export class AgentManager {
const currentResumeOptions = { purpose } satisfies AgentResumeSessionOptions;
const launchEnv = record?.launchEnv;
const workspaceId = resolveResumeWorkspaceId({ options, record });
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
@@ -1593,7 +1624,7 @@ export class AgentManager {
{
reason: "resume",
purpose,
workspaceId: options?.workspaceId ?? null,
workspaceId,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
},
@@ -1609,6 +1640,7 @@ export class AgentManager {
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
workspaceId,
launchEnv,
persistence: handle,
restoring: true,
@@ -5774,8 +5806,7 @@ export class AgentManager {
mcpServerOwners,
env: {
...env,
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
...agentIdentityEnv({ agentId, cwd, opening }),
},
};
const useNativeTools =
@@ -349,7 +349,11 @@ type OpenCodeAgentConfig = Omit<AgentSessionConfig, "providerOptions"> & {
providerOptions: OpenCodeProviderOptions;
};
const OPENCODE_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
const OPENCODE_SESSION_ENV_KEYS = new Set([
"PASEO_AGENT_ID",
"PASEO_AGENT_CWD",
"PASEO_WORKSPACE_ID",
]);
function requiresDedicatedOpenCodeServer(
config: OpenCodeAgentConfig,
@@ -24,7 +24,14 @@ describe("OpenCode v2 helper isolation", () => {
expect(
requiresDedicatedV2Server(
{ provider: "opencode", cwd: "/tmp/project" },
{ agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } },
{
agentId: "agent",
env: {
PASEO_AGENT_ID: "agent",
PASEO_AGENT_CWD: "/tmp/project",
PASEO_WORKSPACE_ID: "ws-1",
},
},
),
).toBe(false);
});
@@ -72,7 +72,7 @@ export async function applyResumeOverrides(
// it must not force a dedicated server. Custom MCP is location-scoped, and any
// other env key belongs to the process, so both need isolation. Mirrors the v1
// adapter's `requiresDedicatedOpenCodeServer`.
const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD", "PASEO_WORKSPACE_ID"]);
export function requiresDedicatedV2Server(
config: AgentSessionConfig,
@@ -48,7 +48,8 @@ afterEach(() => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
// Windows can briefly retain a lock on the copied executable after its probe exits.
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -18,6 +18,7 @@ import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-config
import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
import type {
AgentClient,
AgentLaunchContext,
AgentPersistenceHandle,
AgentRunResult,
AgentSession,
@@ -324,6 +325,7 @@ class RecordingMcpResumeClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities;
readonly resumeOverrides: Array<Partial<AgentSessionConfig> | undefined> = [];
readonly resumeLaunchContexts: Array<AgentLaunchContext | undefined> = [];
private readonly inner = createTestAgentClients({ supportsMcpServers: true }).codex;
constructor() {
@@ -341,8 +343,10 @@ class RecordingMcpResumeClient implements AgentClient {
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.resumeOverrides.push(overrides);
this.resumeLaunchContexts.push(launchContext);
return this.inner.resumeSession(handle, overrides);
}
@@ -402,7 +406,7 @@ class RejectingMcpResumeClient implements AgentClient {
}
}
test("createAgent fails when the initial turn cannot start", async () => {
test("createAgent preserves the error snapshot when the initial turn cannot start", async () => {
const testAgent = new StubAgentClient({
sessionId: "start-turn-failure-session",
supportsStreaming: false,
@@ -421,16 +425,18 @@ test("createAgent fails when the initial turn cannot start", async () => {
await client.connect();
await client.fetchAgents({ subscribe: {} });
await expect(
client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Start failure agent",
modeId: "full-access",
model: "gpt-5.4-mini",
initialPrompt: "Run exactly: sleep 30",
}),
).rejects.toThrow("Initial turn failed to start");
const created = await client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Start failure agent",
modeId: "full-access",
model: "gpt-5.4-mini",
initialPrompt: "Run exactly: sleep 30",
});
expect(created.status).toBe("error");
expect(created.lastError).toBe("Initial turn failed to start");
const fetched = await client.fetchAgent({ agentId: created.id });
expect(fetched?.agent.lastError).toBe("Initial turn failed to start");
} finally {
await client.close();
await daemon.close();
@@ -1034,6 +1040,38 @@ test("resume_agent rehydrates the newest private MCP config from a redacted pers
}
});
test("resume_agent keeps the workspace of the agent it matches", async () => {
const cwd = tmpCwd();
const provider = new RecordingMcpResumeClient();
const localCtx = await createDaemonTestContext({
agentClients: { codex: provider },
});
try {
const opened = await localCtx.client.openProject(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? "Workspace did not open");
}
const workspaceId = opened.workspace.id;
const created = await localCtx.client.createAgent({
config: { provider: "codex", cwd },
workspaceId,
});
if (!created.persistence) {
throw new Error("Expected a persistence handle");
}
await localCtx.client.archiveAgent(created.id);
const resumed = await localCtx.client.resumeAgent(created.persistence);
expect(provider.resumeLaunchContexts[0]?.env?.PASEO_WORKSPACE_ID).toBe(workspaceId);
expect(resumed.workspaceId).toBe(workspaceId);
} finally {
await localCtx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
});
test("resume_agent restores archive state when an MCP-capable provider returns an unsupported session", async () => {
const cwd = tmpCwd();
const provider = new RejectingMcpResumeClient();
@@ -1369,27 +1407,6 @@ test("creates agent and exercises lifecycle", async () => {
});
const createRequestId = `create-${Date.now()}`;
const createdStatusPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as {
status?: string;
agentId?: string;
requestId?: string;
};
if (payload.status !== "agent_created") {
return;
}
if (payload.requestId !== createRequestId) {
return;
}
resolve(message);
});
return unsubscribe;
});
const agent = await ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd,
@@ -1404,30 +1421,8 @@ test("creates agent and exercises lifecycle", async () => {
const agentUpdate = await agentUpdatePromise;
expect(agentUpdate.payload.agent.id).toBe(agent.id);
const createdStatus = await createdStatusPromise;
expect((createdStatus.payload as { agentId?: string }).agentId).toBe(agent.id);
const failRequestId = `fail-${Date.now()}`;
const failedStatusPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as {
status?: string;
requestId?: string;
};
if (payload.status !== "agent_create_failed") {
return;
}
if (payload.requestId !== failRequestId) {
return;
}
resolve(message);
});
return unsubscribe;
});
await expect(
ctx.client.createAgent({
...getFullAccessConfig("codex"),
@@ -1436,7 +1431,6 @@ test("creates agent and exercises lifecycle", async () => {
requestId: failRequestId,
}),
).rejects.toThrow("Working directory does not exist");
await failedStatusPromise;
let sawRefresh = false;
const unsubscribe = ctx.client.subscribe((event) => {
@@ -1615,6 +1609,11 @@ test("creates agent and exercises lifecycle", async () => {
}, 300000);
test("handles permission flow", async () => {
const permissionEvents = ctx.client.observeEvents([
"agent_permission_request",
"agent_permission_resolved",
]);
await permissionEvents.ready;
const cwd = tmpCwd();
const filePath = path.join(cwd, "permission.txt");
@@ -1683,6 +1682,7 @@ test("handles permission flow", async () => {
// Prevent unhandled rejections if the test fails before promises resolve.
await permissionRequestPromise.catch(() => {});
await permissionResolvedPromise.catch(() => {});
await permissionEvents.release();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}
+6 -1
View File
@@ -4856,7 +4856,12 @@ export class Session {
: overrides;
let snapshot: ManagedAgent;
try {
snapshot = await this.agentManager.resumeAgentFromPersistence(handle, effectiveOverrides);
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
effectiveOverrides,
undefined,
{ workspaceId: matched?.record.workspaceId },
);
} catch (error) {
if (matched?.didUnarchive && matched.originalArchivedAt) {
await this.agentManager.archiveSnapshot(matched.record.id, matched.originalArchivedAt);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.15.2",
"version": "0.15.3",
"private": true,
"type": "module",
"scripts": {
@@ -162,6 +162,64 @@ describe("claude usage source", () => {
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it.each([false, true])("reports USD extra usage with rolling windows %j", async (hasWindows) => {
writeClaudeCredentials(claudeHome, "at_valid");
fetchApi = mockFetch(
new Map([
[
"https://api.anthropic.com/api/oauth/usage",
() =>
jsonResponse({
...(hasWindows ? makeClaudeResponse() : {}),
extra_usage: {
is_enabled: true,
monthly_limit: 5000,
used_credits: 1250,
currency: "USD",
},
}),
],
]),
);
const claude = findProvider(await service().listUsage(), "claude");
expect(claude.balances).toEqual([
expect.objectContaining({
id: "extra_usage_monthly",
used: 12.5,
remaining: 37.5,
limit: 50,
unit: "usd",
}),
]);
expect(claude.details).toContainEqual(
expect.objectContaining({ id: "extra_usage", value: "Enabled" }),
);
});
it.each(["EUR", null, undefined])(
"does not report extra usage balances for currency %j",
async (currency) => {
writeClaudeCredentials(claudeHome, "at_valid");
fetchApi = mockFetch(
new Map([
[
"https://api.anthropic.com/api/oauth/usage",
() =>
jsonResponse({
...makeClaudeResponse(),
extra_usage: { monthly_limit: 5000, used_credits: 1250, currency },
}),
],
]),
);
const claude = findProvider(await service().listUsage(), "claude");
expect(claude.balances).toEqual([]);
},
);
it("accepts a null Claude resets_at when a window has no scheduled reset", async () => {
writeClaudeCredentials(claudeHome, "at_valid");
+28 -4
View File
@@ -68,6 +68,9 @@ const ClaudeUsageResponseSchema = z.object({
extra_usage: z
.object({
is_enabled: z.boolean().optional(),
monthly_limit: ApiNumberSchema.nullish(),
used_credits: ApiNumberSchema.nullish(),
currency: z.string().nullish(),
})
.nullish(),
});
@@ -491,6 +494,26 @@ function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRec
: null;
}
function extraUsageBalances(extraUsage: ClaudeUsageResponse["extra_usage"]) {
if (extraUsage?.currency?.toUpperCase() !== "USD") return [];
const used = extraUsage.used_credits == null ? undefined : extraUsage.used_credits / 100;
const limit = extraUsage.monthly_limit == null ? undefined : extraUsage.monthly_limit / 100;
if (used == null && limit == null) return [];
return [
{
id: "extra_usage_monthly",
label: "Extra usage",
used,
remaining: used != null && limit != null ? Math.max(0, limit - used) : undefined,
limit,
unit: "usd" as const,
tone: toneFromUsedPct(
used != null && limit != null && limit > 0 ? (used / limit) * 100 : null,
),
},
];
}
export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
@@ -594,20 +617,21 @@ export async function fetchUsage(
}
const details: UsageDetail[] = [];
const extraUsageEnabled = resp.extra_usage?.is_enabled;
if (extraUsageEnabled !== undefined) {
const extraUsage = resp.extra_usage;
if (extraUsage?.is_enabled !== undefined) {
details.push({
id: "extra_usage",
label: "Extra usage",
value: extraUsageEnabled ? "Enabled" : "Disabled",
value: extraUsage.is_enabled ? "Enabled" : "Disabled",
});
}
const balances = extraUsageBalances(extraUsage);
return {
status: "available",
planLabel: plan ?? undefined,
windows,
balances: [],
balances,
details,
};
}
@@ -117,6 +117,56 @@ test("coerces credit balance and marks a 96 percent window dangerous", async ()
]);
});
test("reports enterprise individual spend limit percentage and reset", async () => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({
plan_type: "enterprise",
spend_control: {
individual_limit: { used_percent: 5, remaining_percent: 95, reset_at: 1795996800 },
},
}),
);
expect(report).toMatchObject({
planLabel: "enterprise",
windows: [
expect.objectContaining({
id: "individual_limit",
label: "Individual spend limit",
summary: true,
usedPct: 5,
remainingPct: 95,
resetsAt: new Date(1795996800 * 1000).toISOString(),
}),
],
});
});
test.each([
[{ used_percent: 5 }, 5, 95],
[{ remaining_percent: 95 }, 5, 95],
[{ used_percent: 5, remaining_percent: 90 }, 5, 90],
[{ used_percent: 110 }, 110, 0],
])(
"completes individual spending percentages for %j",
async (individual_limit, usedPct, remainingPct) => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({ spend_control: { individual_limit } }),
);
expect(report).toMatchObject({
status: "available",
windows: [expect.objectContaining({ id: "individual_limit", usedPct, remainingPct })],
});
},
);
test("omits individual spend limit without a percentage", async () => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({ spend_control: { individual_limit: { reset_at: 1795996800 } } }),
);
expect(report.windows).toEqual([]);
});
test("summarizes the session and weekly windows by default, not code review", async () => {
const report = await fetchUsage(
authInput(fixtureHome),
@@ -34,6 +34,15 @@ const rateLimitSchema = z.object({
primary_window: windowSchema.nullish(),
secondary_window: windowSchema.nullish(),
});
const spendControlSchema = z.object({
individual_limit: z
.object({
used_percent: number.nullish(),
remaining_percent: number.nullish(),
reset_at: number.nullish(),
})
.nullish(),
});
const responseSchema = z.object({
plan_type: z.string().optional(),
email: z.string().optional(),
@@ -48,6 +57,7 @@ const responseSchema = z.object({
)
.nullish(),
code_review_rate_limit: rateLimitSchema.nullish(),
spend_control: spendControlSchema.nullish(),
credits: z.object({ balance: number.optional() }).nullish(),
});
@@ -227,6 +237,27 @@ export async function fetchUsage(
},
});
}),
...(usage.spend_control?.individual_limit
? (() => {
const limit = usage.spend_control.individual_limit;
const usedPct =
limit.used_percent ??
(limit.remaining_percent != null ? 100 - limit.remaining_percent : undefined);
if (usedPct === undefined) return [];
return [
{
id: "individual_limit",
label: "Individual spend limit",
summary: true,
usedPct,
remainingPct: limit.remaining_percent ?? Math.max(0, 100 - usedPct),
resetsAt:
limit.reset_at != null ? new Date(limit.reset_at * 1000).toISOString() : null,
tone: toneFromUsedPct(usedPct),
},
];
})()
: []),
...usageWindows({
rateLimit: usage.code_review_rate_limit,
scope: { id: "code_review", label: "Code review" },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.15.2",
"version": "0.15.3",
"private": true,
"type": "module",
"scripts": {
+2
View File
@@ -211,6 +211,8 @@ paseo workspace rename <workspace-id> --reset # back to the branch or director
paseo workspace archive <workspace-id>
```
Inside an agent session, `PASEO_WORKSPACE_ID` holds the agent's own workspace ID, so an agent can run `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` or `paseo workspace archive "$PASEO_WORKSPACE_ID"` even when other workspaces share its directory.
Add `--forge <name>` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services.
## Terminals
+2
View File
@@ -202,6 +202,8 @@ paseo workspace rename <workspace-id> --reset # back to the branch or director
paseo workspace archive <workspace-id>
```
在 Agent 会话中,`PASEO_WORKSPACE_ID` 保存该 Agent 自己的工作区 ID。即使其他工作区共享同一个目录,Agent 也可以运行 `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` 或 `paseo workspace archive "$PASEO_WORKSPACE_ID"`。
当 Paseo 无法从源检出推断代码托管平台时,为 PR 检出添加 `--forge <name>`。setup 钩子和服务见 [Git worktree](/docs/worktrees)。
## 终端 {#terminals}
+1 -1
View File
@@ -598,7 +598,7 @@ Creation request
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
→ open provider session and save agent configuration
```
+1 -1
View File
@@ -581,7 +581,7 @@ Creation request
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
→ open provider session and save agent configuration
```