mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd845129e9 |
@@ -1,11 +1,5 @@
|
||||
# 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
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# 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
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -34,7 +34,6 @@ 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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
|
||||
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37468,7 +37468,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39637,13 +39637,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/server": "0.15.3",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/server": "0.15.2",
|
||||
"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.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/relay": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.2",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -39906,7 +39906,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"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.3",
|
||||
"version": "0.15.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40195,7 +40195,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -40225,7 +40225,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"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.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@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.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"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.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
@@ -40295,7 +40295,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
@@ -40517,17 +40517,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"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.3",
|
||||
"@getpaseo/highlight": "0.15.3",
|
||||
"@getpaseo/plugin": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/relay": "0.15.3",
|
||||
"@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",
|
||||
"@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.3",
|
||||
"version": "0.15.2",
|
||||
"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.3",
|
||||
"version": "0.15.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
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,10 +5,8 @@ import {
|
||||
holdDirectorySuggestionResponses,
|
||||
expectOneLineTruncatedFileResult,
|
||||
expectStableCommandCenterLayout,
|
||||
expectLayoutObservationToCatchPanelShift,
|
||||
failDirectorySuggestionRequests,
|
||||
startCommandCenterLayoutObservation,
|
||||
shiftOutsideCommandCenter,
|
||||
} from "../support/helpers/command-center-file-search";
|
||||
import {
|
||||
scrollCommandCenterAwayFromSelection,
|
||||
@@ -86,9 +84,7 @@ 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,8 +1,6 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeDiagramFullscreen,
|
||||
expectInlineDiagramFitsViewport,
|
||||
toggleDiagramSourceAndVerifyControls,
|
||||
closeDiagramFullscreenFromOutside,
|
||||
expectCompletedDiagram,
|
||||
expectFullscreenDiagram,
|
||||
@@ -23,36 +21,6 @@ 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);
|
||||
|
||||
@@ -72,10 +40,6 @@ 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"]);
|
||||
|
||||
@@ -407,6 +407,7 @@ 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,7 +99,6 @@ 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;
|
||||
@@ -133,7 +132,6 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
|
||||
if (!observation.active) {
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
performanceObserver?.disconnect();
|
||||
return;
|
||||
}
|
||||
record();
|
||||
@@ -143,16 +141,9 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
|
||||
|
||||
if (typeof PerformanceObserver !== "undefined") {
|
||||
try {
|
||||
performanceObserver = new PerformanceObserver((list) => {
|
||||
const panel = document.querySelector(selectors.panel);
|
||||
const performanceObserver = 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 && panel?.contains(source.node))) {
|
||||
observation.layoutShift += shift.value;
|
||||
}
|
||||
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
|
||||
}
|
||||
});
|
||||
performanceObserver.observe({ type: "layout-shift", buffered: false });
|
||||
@@ -183,54 +174,7 @@ export async function expectStableCommandCenterLayout(page: Page): Promise<void>
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
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);
|
||||
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
|
||||
}
|
||||
|
||||
export async function expectOneLineTruncatedFileResult(
|
||||
|
||||
@@ -148,59 +148,3 @@ 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -23,10 +23,6 @@ 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: {
|
||||
@@ -212,11 +208,7 @@ function CompletedTurnFooter({
|
||||
|
||||
function TurnFooterRow({ children }: { children: ReactNode }) {
|
||||
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
|
||||
return (
|
||||
<View style={rowStyle} testID="turn-footer">
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return <View style={rowStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
const stylesheet = StyleSheet.create((theme) => ({
|
||||
@@ -237,11 +229,11 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
|
||||
minHeight: 24,
|
||||
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
|
||||
},
|
||||
turnFooterContent: {
|
||||
height: TURN_FOOTER_CONTENT_HEIGHT,
|
||||
height: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
|
||||
@@ -72,7 +72,7 @@ function MermaidFenceHostImpl({
|
||||
() => [
|
||||
getDiagramBoxStyle(textStyle),
|
||||
containerStyle,
|
||||
{ height: Math.min(runtimeHeight, MAX_PREVIEW_HEIGHT), minHeight: MIN_DIAGRAM_BOX_HEIGHT },
|
||||
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
|
||||
],
|
||||
[runtimeHeight, textStyle],
|
||||
);
|
||||
@@ -111,22 +111,20 @@ function MermaidFenceHostImpl({
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<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>
|
||||
<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>
|
||||
{isFullscreen && visible ? (
|
||||
<MermaidFullscreenViewer
|
||||
source={visible.source}
|
||||
@@ -144,8 +142,6 @@ 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,65 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFloatingPanelHosts,
|
||||
type FloatingPanelHostRect,
|
||||
type MeasurableFloatingPanelHost,
|
||||
} from "./floating-panel-hosts";
|
||||
|
||||
function createHost(rect: FloatingPanelHostRect): MeasurableFloatingPanelHost {
|
||||
return {
|
||||
measureInWindow: (callback) => callback(rect.x, rect.y, rect.width, rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
const WORKSPACE_RECT: FloatingPanelHostRect = { x: 10, y: 20, width: 800, height: 600 };
|
||||
const RETAINED_VIEWPORT: FloatingPanelHostRect = { x: 0, y: 0, width: 1200, height: 900 };
|
||||
|
||||
describe("floating panel hosts", () => {
|
||||
it("measures the newest host registered under the name", async () => {
|
||||
const hosts = createFloatingPanelHosts();
|
||||
hosts.register("workspace", createHost(WORKSPACE_RECT));
|
||||
hosts.register("workspace", createHost(RETAINED_VIEWPORT));
|
||||
|
||||
expect(await hosts.measure("workspace")).toEqual(RETAINED_VIEWPORT);
|
||||
});
|
||||
|
||||
it("keeps measuring a survivor after the newest host unregisters", async () => {
|
||||
const hosts = createFloatingPanelHosts();
|
||||
const unregisterSurvivor = hosts.register("workspace", createHost(WORKSPACE_RECT));
|
||||
const unregisterNewest = hosts.register("workspace", createHost(RETAINED_VIEWPORT));
|
||||
|
||||
unregisterNewest();
|
||||
|
||||
expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT);
|
||||
unregisterSurvivor();
|
||||
});
|
||||
|
||||
it("returns null once every host under the name is gone", async () => {
|
||||
const hosts = createFloatingPanelHosts();
|
||||
const unregisterFirst = hosts.register("workspace", createHost(WORKSPACE_RECT));
|
||||
const unregisterSecond = hosts.register("workspace", createHost(RETAINED_VIEWPORT));
|
||||
|
||||
unregisterFirst();
|
||||
unregisterSecond();
|
||||
|
||||
expect(await hosts.measure("workspace")).toBeNull();
|
||||
});
|
||||
|
||||
it("skips hosts that are not laid out", async () => {
|
||||
const hosts = createFloatingPanelHosts();
|
||||
hosts.register("workspace", createHost(WORKSPACE_RECT));
|
||||
hosts.register("workspace", createHost({ x: 0, y: 0, width: 0, height: 0 }));
|
||||
|
||||
expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT);
|
||||
});
|
||||
|
||||
it("ignores a repeated unregister of the same host", async () => {
|
||||
const hosts = createFloatingPanelHosts();
|
||||
const unregister = hosts.register("workspace", createHost(WORKSPACE_RECT));
|
||||
|
||||
unregister();
|
||||
unregister();
|
||||
|
||||
expect(await hosts.measure("workspace")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
export interface FloatingPanelHostRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface MeasurableFloatingPanelHost {
|
||||
measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void;
|
||||
}
|
||||
|
||||
export interface FloatingPanelHosts<T extends MeasurableFloatingPanelHost> {
|
||||
register(name: string, host: T): () => void;
|
||||
measure(name: string): Promise<FloatingPanelHostRect | null>;
|
||||
}
|
||||
|
||||
function measureHost(host: MeasurableFloatingPanelHost): Promise<FloatingPanelHostRect> {
|
||||
return new Promise((resolve) => {
|
||||
host.measureInWindow((x, y, width, height) => resolve({ x, y, width, height }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts are addressed by name, and one name can have several live hosts: a workspace
|
||||
* screen mounts its own host while retained copies of that screen stay mounted. Track
|
||||
* every host so unmounting one leaves the others measurable.
|
||||
*/
|
||||
export function createFloatingPanelHosts<
|
||||
T extends MeasurableFloatingPanelHost,
|
||||
>(): FloatingPanelHosts<T> {
|
||||
const hostsByName = new Map<string, T[]>();
|
||||
|
||||
function register(name: string, host: T): () => void {
|
||||
const hosts = hostsByName.get(name) ?? [];
|
||||
hostsByName.set(name, hosts);
|
||||
hosts.push(host);
|
||||
|
||||
return () => {
|
||||
const index = hosts.indexOf(host);
|
||||
if (index === -1) return;
|
||||
hosts.splice(index, 1);
|
||||
if (hosts.length === 0 && hostsByName.get(name) === hosts) {
|
||||
hostsByName.delete(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function measure(name: string): Promise<FloatingPanelHostRect | null> {
|
||||
const hosts = hostsByName.get(name);
|
||||
if (!hosts || hosts.length === 0) return null;
|
||||
|
||||
const rects = await Promise.all(hosts.toReversed().map(measureHost));
|
||||
return rects.find((rect) => rect.width > 0 && rect.height > 0) ?? rects[0];
|
||||
}
|
||||
|
||||
return { register, measure };
|
||||
}
|
||||
@@ -23,15 +23,9 @@ describe("Floating panel host lifetime", () => {
|
||||
}
|
||||
try {
|
||||
await act(async () => root.render(<Content overlap={true} />));
|
||||
expect(await measureFloatingPanelPortalHost("workspace")).toMatchObject({
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
expect(await measureFloatingPanelPortalHost("workspace")).not.toBeNull();
|
||||
await act(async () => root.render(<Content overlap={false} />));
|
||||
expect(await measureFloatingPanelPortalHost("workspace")).toMatchObject({
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
expect(await measureFloatingPanelPortalHost("workspace")).not.toBeNull();
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
|
||||
@@ -8,21 +8,32 @@ import {
|
||||
} from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { PortalHost } from "@gorhom/portal";
|
||||
import {
|
||||
createFloatingPanelHosts,
|
||||
type FloatingPanelHostRect,
|
||||
} from "@/components/ui/floating-panel-hosts";
|
||||
|
||||
export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels";
|
||||
|
||||
const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST);
|
||||
|
||||
const portalHosts = createFloatingPanelHosts<View>();
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function measureFloatingPanelPortalHost(
|
||||
name: string,
|
||||
): Promise<FloatingPanelHostRect | null> {
|
||||
return portalHosts.measure(name);
|
||||
const portalHostElements = new Map<string, View>();
|
||||
|
||||
function measureElement(element: View): Promise<Rect> {
|
||||
return new Promise((resolve) => {
|
||||
element.measureInWindow((x, y, width, height) => {
|
||||
resolve({ x, y, width, height });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function measureFloatingPanelPortalHost(name: string): Promise<Rect | null> {
|
||||
const element = portalHostElements.get(name);
|
||||
if (!element) return Promise.resolve(null);
|
||||
return measureElement(element);
|
||||
}
|
||||
|
||||
export function FloatingPanelPortalHostNameProvider({
|
||||
@@ -53,7 +64,12 @@ export function FloatingPanelPortalHost({
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return undefined;
|
||||
return portalHosts.register(name, host);
|
||||
portalHostElements.set(name, host);
|
||||
return () => {
|
||||
if (portalHostElements.get(name) === host) {
|
||||
portalHostElements.delete(name);
|
||||
}
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ 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";
|
||||
@@ -359,6 +360,8 @@ 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" ||
|
||||
@@ -367,7 +370,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
) {
|
||||
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
|
||||
}
|
||||
sync.applyStreamEvent(agentId, { event, seq, epoch, timestamp: new Date(timestamp) });
|
||||
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,
|
||||
});
|
||||
|
||||
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
||||
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
|
||||
|
||||
@@ -2852,8 +2852,6 @@ export class HostRuntimeStore {
|
||||
replica,
|
||||
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
|
||||
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId),
|
||||
applyTurnLiveness: (agentId, transitions) =>
|
||||
directory.applyAgentTurnLiveness(agentId, transitions),
|
||||
ports,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ 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,
|
||||
@@ -41,13 +40,7 @@ function cachedTimeline(): CachedTimeline {
|
||||
};
|
||||
}
|
||||
|
||||
function createOwner(
|
||||
storage: TimelineReplicaStorage,
|
||||
applyTurnLiveness: (
|
||||
agentId: string,
|
||||
transitions: readonly TurnLivenessTransition[],
|
||||
) => void = () => undefined,
|
||||
): ViewedTimelineOwner {
|
||||
function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
storage,
|
||||
@@ -58,7 +51,6 @@ function createOwner(
|
||||
replica,
|
||||
replaceDemandedAgentIds: () => undefined,
|
||||
drainQueuedAgentMessage: () => undefined,
|
||||
applyTurnLiveness,
|
||||
ports: {
|
||||
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
|
||||
readCursor: () => undefined,
|
||||
@@ -106,7 +98,7 @@ describe("viewed timeline persistence", () => {
|
||||
.status,
|
||||
)
|
||||
.toBe("painted");
|
||||
owner.applyStreamEvent(AGENT_ID, {
|
||||
owner.enqueueStreamEvent(AGENT_ID, {
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
@@ -155,7 +147,7 @@ describe("viewed timeline persistence", () => {
|
||||
.map((entry) => entry.text);
|
||||
};
|
||||
expect.soft(replyTexts()).toEqual([complete]);
|
||||
owner.applyStreamEvent(AGENT_ID, {
|
||||
owner.enqueueStreamEvent(AGENT_ID, {
|
||||
event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload,
|
||||
seq: 7,
|
||||
epoch: "epoch-1",
|
||||
@@ -289,7 +281,7 @@ describe("viewed timeline persistence", () => {
|
||||
)
|
||||
.toMatchObject({ status: "painted" });
|
||||
|
||||
first.applyStreamEvent(AGENT_ID, {
|
||||
first.enqueueStreamEvent(AGENT_ID, {
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
@@ -468,7 +460,7 @@ describe("viewed timeline persistence", () => {
|
||||
readTimeline: async () => undefined,
|
||||
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
|
||||
});
|
||||
owner.applyStreamEvent(AGENT_ID, {
|
||||
owner.enqueueStreamEvent(AGENT_ID, {
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
@@ -533,7 +525,7 @@ describe("viewed timeline persistence", () => {
|
||||
[AGENT_ID, 9],
|
||||
["agent-2", 4],
|
||||
] as const) {
|
||||
owner.applyStreamEvent(agentId, {
|
||||
owner.enqueueStreamEvent(agentId, {
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
@@ -551,81 +543,6 @@ 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";
|
||||
|
||||
@@ -689,7 +606,6 @@ describe("create handoff lifetime", () => {
|
||||
replica,
|
||||
replaceDemandedAgentIds: () => undefined,
|
||||
drainQueuedAgentMessage: () => undefined,
|
||||
applyTurnLiveness: () => undefined,
|
||||
ports: {
|
||||
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
|
||||
readCursor: () => undefined,
|
||||
|
||||
@@ -21,14 +21,12 @@ 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>;
|
||||
@@ -363,12 +361,7 @@ export type ViewedTimelineOwnerPorts = Omit<
|
||||
|
||||
export interface ViewedTimelineOwner extends ViewedTimelineSync {
|
||||
applyTimelineResponse(payload: TimelineResponsePayload): 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;
|
||||
enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void;
|
||||
flushStreamAgent(agentId: string): void;
|
||||
}
|
||||
|
||||
@@ -377,7 +370,6 @@ 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>();
|
||||
@@ -417,12 +409,8 @@ export function createViewedTimelineOwner(input: {
|
||||
});
|
||||
if (accepted) input.replica.timelineUpdated(payload.agentId);
|
||||
},
|
||||
applyStreamEvent(agentId, event) {
|
||||
enqueueStreamEvent(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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"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.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/server": "0.15.3",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/server": "0.15.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"description": "Paseo client SDK package",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
@@ -48,8 +48,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/relay": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@getpaseo/relay": "0.15.2",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"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.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"@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.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/client": "0.15.2",
|
||||
"@getpaseo/protocol": "0.15.2",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"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.3",
|
||||
"@getpaseo/highlight": "0.15.3",
|
||||
"@getpaseo/plugin": "0.15.3",
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
"@getpaseo/relay": "0.15.3",
|
||||
"@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",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -3286,78 +3286,6 @@ 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");
|
||||
@@ -4803,7 +4731,6 @@ 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,36 +664,6 @@ 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) {
|
||||
@@ -1601,7 +1571,6 @@ 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,
|
||||
@@ -1624,7 +1593,7 @@ export class AgentManager {
|
||||
{
|
||||
reason: "resume",
|
||||
purpose,
|
||||
workspaceId,
|
||||
workspaceId: options?.workspaceId ?? null,
|
||||
title: record?.title ?? mergedConfig.title ?? null,
|
||||
labels: record?.labels ?? options?.labels ?? {},
|
||||
},
|
||||
@@ -1640,7 +1609,6 @@ export class AgentManager {
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
...options,
|
||||
workspaceId,
|
||||
launchEnv,
|
||||
persistence: handle,
|
||||
restoring: true,
|
||||
@@ -5806,7 +5774,8 @@ export class AgentManager {
|
||||
mcpServerOwners,
|
||||
env: {
|
||||
...env,
|
||||
...agentIdentityEnv({ agentId, cwd, opening }),
|
||||
PASEO_AGENT_ID: agentId,
|
||||
PASEO_AGENT_CWD: cwd,
|
||||
},
|
||||
};
|
||||
const useNativeTools =
|
||||
|
||||
@@ -349,11 +349,7 @@ type OpenCodeAgentConfig = Omit<AgentSessionConfig, "providerOptions"> & {
|
||||
providerOptions: OpenCodeProviderOptions;
|
||||
};
|
||||
|
||||
const OPENCODE_SESSION_ENV_KEYS = new Set([
|
||||
"PASEO_AGENT_ID",
|
||||
"PASEO_AGENT_CWD",
|
||||
"PASEO_WORKSPACE_ID",
|
||||
]);
|
||||
const OPENCODE_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
|
||||
|
||||
function requiresDedicatedOpenCodeServer(
|
||||
config: OpenCodeAgentConfig,
|
||||
|
||||
@@ -24,14 +24,7 @@ describe("OpenCode v2 helper isolation", () => {
|
||||
expect(
|
||||
requiresDedicatedV2Server(
|
||||
{ provider: "opencode", cwd: "/tmp/project" },
|
||||
{
|
||||
agentId: "agent",
|
||||
env: {
|
||||
PASEO_AGENT_ID: "agent",
|
||||
PASEO_AGENT_CWD: "/tmp/project",
|
||||
PASEO_WORKSPACE_ID: "ws-1",
|
||||
},
|
||||
},
|
||||
{ agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } },
|
||||
),
|
||||
).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", "PASEO_WORKSPACE_ID"]);
|
||||
const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
|
||||
|
||||
export function requiresDedicatedV2Server(
|
||||
config: AgentSessionConfig,
|
||||
|
||||
@@ -18,7 +18,6 @@ import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-config
|
||||
import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentPersistenceHandle,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
@@ -325,7 +324,6 @@ 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() {
|
||||
@@ -343,10 +341,8 @@ 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);
|
||||
}
|
||||
|
||||
@@ -406,7 +402,7 @@ class RejectingMcpResumeClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
test("createAgent preserves the error snapshot when the initial turn cannot start", async () => {
|
||||
test("createAgent fails when the initial turn cannot start", async () => {
|
||||
const testAgent = new StubAgentClient({
|
||||
sessionId: "start-turn-failure-session",
|
||||
supportsStreaming: false,
|
||||
@@ -425,18 +421,16 @@ test("createAgent preserves the error snapshot when the initial turn cannot star
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
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");
|
||||
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");
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
@@ -1040,38 +1034,6 @@ 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();
|
||||
@@ -1407,6 +1369,27 @@ 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,
|
||||
@@ -1421,8 +1404,30 @@ 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"),
|
||||
@@ -1431,6 +1436,7 @@ 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) => {
|
||||
@@ -1609,11 +1615,6 @@ 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");
|
||||
|
||||
@@ -1682,7 +1683,6 @@ 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 });
|
||||
}
|
||||
|
||||
@@ -4856,12 +4856,7 @@ export class Session {
|
||||
: overrides;
|
||||
let snapshot: ManagedAgent;
|
||||
try {
|
||||
snapshot = await this.agentManager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
effectiveOverrides,
|
||||
undefined,
|
||||
{ workspaceId: matched?.record.workspaceId },
|
||||
);
|
||||
snapshot = await this.agentManager.resumeAgentFromPersistence(handle, effectiveOverrides);
|
||||
} catch (error) {
|
||||
if (matched?.didUnarchive && matched.originalArchivedAt) {
|
||||
await this.agentManager.archiveSnapshot(matched.record.id, matched.originalArchivedAt);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.15.3",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -211,8 +211,6 @@ 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
|
||||
|
||||
@@ -202,8 +202,6 @@ 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}
|
||||
|
||||
@@ -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, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
|
||||
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
|
||||
→ open provider session and save agent configuration
|
||||
```
|
||||
|
||||
|
||||
@@ -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, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
|
||||
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
|
||||
→ open provider session and save agent configuration
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user