mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
168836c97e | ||
|
|
abc23d7721 | ||
|
|
2c23cd4927 | ||
|
|
de50243885 | ||
|
|
5b7b8d6e81 | ||
|
|
25721c7847 | ||
|
|
78f580f9e8 |
+11
-10
@@ -133,11 +133,12 @@ jobs:
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
# The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04.
|
||||
typecheck:
|
||||
name: typecheck
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -395,7 +396,7 @@ jobs:
|
||||
name: app-tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -429,7 +430,7 @@ jobs:
|
||||
name: playwright (shard 1/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "1/8"
|
||||
PLAYWRIGHT_ARTIFACT: "1"
|
||||
@@ -485,7 +486,7 @@ jobs:
|
||||
name: playwright (shard 2/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "2/8"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
@@ -495,7 +496,7 @@ jobs:
|
||||
name: playwright (shard 3/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "3/8"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
@@ -505,7 +506,7 @@ jobs:
|
||||
name: playwright (shard 4/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "4/8"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
@@ -515,7 +516,7 @@ jobs:
|
||||
name: playwright (shard 5/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "5/8"
|
||||
PLAYWRIGHT_ARTIFACT: "5"
|
||||
@@ -525,7 +526,7 @@ jobs:
|
||||
name: playwright (shard 6/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "6/8"
|
||||
PLAYWRIGHT_ARTIFACT: "6"
|
||||
@@ -535,7 +536,7 @@ jobs:
|
||||
name: playwright (shard 7/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "7/8"
|
||||
PLAYWRIGHT_ARTIFACT: "7"
|
||||
@@ -545,7 +546,7 @@ jobs:
|
||||
name: playwright (shard 8/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "8/8"
|
||||
PLAYWRIGHT_ARTIFACT: "8"
|
||||
|
||||
@@ -382,6 +382,15 @@ grace. During daemon startup, plugin sessions may connect while application WebS
|
||||
paused; the daemon accepts clients only after configured plugins have settled and the initial
|
||||
catalog is complete.
|
||||
|
||||
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
|
||||
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
|
||||
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
|
||||
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
|
||||
passing the bundle string still costs memory and time once per client lifetime.
|
||||
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
|
||||
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
|
||||
in-memory bundles usable, and a later catalog retries persistence.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
withTimelinePlugin,
|
||||
viewInlineThinkingExample,
|
||||
@@ -6,6 +7,8 @@ import {
|
||||
interactWithStreamingCard,
|
||||
expectWholeCompletedCard,
|
||||
expectBothConsecutiveTools,
|
||||
trackPluginChatRecovery,
|
||||
reconnectPluginChat,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
|
||||
for (const width of [1100, 390]) {
|
||||
@@ -13,13 +16,24 @@ for (const width of [1100, 390]) {
|
||||
page,
|
||||
}, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await withTimelinePlugin(page, info, "assistant", async (agent) => {
|
||||
await requestPluginTimeline(agent);
|
||||
await interactWithStreamingCard(page);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await expectWholeCompletedCard(page);
|
||||
// Local state is checked during growth; the viewport remounts on the history handoff.
|
||||
const recovery = trackPluginChatRecovery(page);
|
||||
gate.holdTimelineResponses(agent.agentId);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await recovery.expectCatalogWithoutRetransmission();
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expectWholeCompletedCard(page);
|
||||
await recovery.expectRecoveredWithoutRetransmission();
|
||||
recovery.reset();
|
||||
await reconnectPluginChat(page, gate);
|
||||
await recovery.expectRecoveredWithoutRetransmission();
|
||||
await expectWholeCompletedCard(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
@@ -214,3 +216,66 @@ test.describe("provider settings overlay stack", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function openTimedOutProvider(page: Page, workspaceId: string, width: number) {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
if (width < 600) {
|
||||
await page.getByTestId("agent-controls-model").click();
|
||||
} else {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
}
|
||||
await page.getByTestId("model-provider-picker-timeout").click();
|
||||
await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectErrorHasSideGutters(page: Page) {
|
||||
const error = page.getByText(/Timed out refreshing Pi timeout/);
|
||||
const bounds = await error.evaluate((element) => {
|
||||
const text = element.getBoundingClientRect();
|
||||
const state = element.parentElement!.getBoundingClientRect();
|
||||
return { left: text.left - state.left, right: state.right - text.right };
|
||||
});
|
||||
expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0);
|
||||
expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0);
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: {
|
||||
catalogRefreshTimeoutMs: 2000,
|
||||
providers: {
|
||||
"picker-timeout": {
|
||||
extends: "pi",
|
||||
label: "Pi timeout",
|
||||
enabled: true,
|
||||
command: [
|
||||
process.execPath,
|
||||
path.resolve("e2e/fixtures/fake-pi-rpc.mjs"),
|
||||
"--stall-model-discovery",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("model discovery timeout", () => {
|
||||
for (const width of [1200, 390]) {
|
||||
test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
const workspace = await withWorkspace({ prefix: "model-error-spacing-" });
|
||||
await openTimedOutProvider(page, workspace.workspaceId, width);
|
||||
await page.screenshot({ path: testInfo.outputPath("model-error.png") });
|
||||
await expectErrorHasSideGutters(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -475,7 +475,20 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
|
||||
await expect(group).toContainText(/read 1 file/i);
|
||||
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
|
||||
await group.getByRole("button").first().click();
|
||||
await expect(reasoning).toBeVisible();
|
||||
// Expand thinking is off: the group opens its rows, but the thought row stays closed
|
||||
// until the reader opens it.
|
||||
const thought = group
|
||||
.getByTestId("tool-call-badge")
|
||||
.filter({ hasText: /^Thinking/ })
|
||||
.first();
|
||||
await expect(thought).toBeVisible();
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
// A press right after the group opens occasionally does not register; retry until the
|
||||
// thought row opens.
|
||||
await expect(async () => {
|
||||
await thought.getByRole("button").first().click();
|
||||
await expect(reasoning).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ for await (const line of lines) {
|
||||
const command = JSON.parse(line);
|
||||
let data = {};
|
||||
if (command.type === "get_available_models") {
|
||||
if (process.argv.includes("--stall-model-discovery")) continue;
|
||||
data = {
|
||||
models: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Native turn reading-position diagnostic
|
||||
|
||||
Reproduction for [#8057](https://github.com/getpaseo/paseo/issues/8057).
|
||||
This uses the production turn presentation and native stream strategy with fixed-height
|
||||
message rows. It isolates viewport behavior; it does not exercise a provider, the full
|
||||
conversation screen, iOS, or the precise automatic-collapse reading-row report.
|
||||
|
||||
Build the Android development client from this checkout. In a disposable checkout,
|
||||
temporarily replace `packages/app/index.ts` with:
|
||||
|
||||
```tsx
|
||||
import "./src/polyfills/install-early";
|
||||
import "./src/styles/unistyles";
|
||||
import { registerRootComponent } from "expo";
|
||||
import Diagnostic from "./e2e/mobile/turn-scroll/native";
|
||||
registerRootComponent(Diagnostic);
|
||||
```
|
||||
|
||||
Start Metro from `packages/app` on an available port:
|
||||
|
||||
```sh
|
||||
APP_VARIANT=development npx expo start --lan --port 8097
|
||||
```
|
||||
|
||||
With an Android emulator running, forward that port with `adb reverse tcp:8097 tcp:8097`.
|
||||
From the repository root, run:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/turn-scroll/android.mjs turn-scroll http://127.0.0.1:8097
|
||||
```
|
||||
|
||||
The test expands the middle summary, brings it back into view, collapses it, and
|
||||
asserts its native window Y stays within two points. Baseline fails with approximately
|
||||
`expand=-450.3, collapse=450.3`. Measurements use `measureInWindow`, not accessibility
|
||||
bounds of off-screen rows.
|
||||
|
||||
The separate buttons expose the latest turn's running-to-finished projection with a
|
||||
five-second delay. They measure its summary before and after completion, rather than
|
||||
claiming to follow a disappearing reading row.
|
||||
|
||||
Restore `index.ts`, close the device session, stop Metro, and stop the emulator after use.
|
||||
This is an intentionally failing diagnostic, not a production fix or a CI unit test.
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , session, metroUrl] = process.argv;
|
||||
assert(session && metroUrl, "Pass a session name and the Metro URL serving native.tsx");
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
ad(
|
||||
"open",
|
||||
"sh.paseo.debug",
|
||||
`exp+voice-mobile://expo-development-client/?url=${encodeURIComponent(metroUrl)}`,
|
||||
"--platform",
|
||||
"android",
|
||||
"--relaunch",
|
||||
);
|
||||
ad("wait", "text", "Ready");
|
||||
ad("press", 'id="host-7"', "--settle");
|
||||
ad("wait", "text", "expand host-7: before=");
|
||||
const expansion = ad("get", "text", 'id="result"');
|
||||
console.log(expansion.trim());
|
||||
const expandedDelta = Number(/delta=([\d.-]+)/.exec(expansion)?.[1]);
|
||||
ad("scroll", "up", "--until", 'id="host-7"');
|
||||
ad("wait", "stable", "1000", "10000");
|
||||
ad("press", 'id="host-7"', "--settle");
|
||||
ad("wait", "text", "collapse host-7: before=");
|
||||
const collapse = ad("get", "text", 'id="result"');
|
||||
console.log(collapse.trim());
|
||||
const collapsedDelta = Number(/delta=([\d.-]+)/.exec(collapse)?.[1]);
|
||||
assert(
|
||||
Number.isFinite(expandedDelta) && Number.isFinite(collapsedDelta),
|
||||
"Both native measurements must complete",
|
||||
);
|
||||
assert(
|
||||
Math.abs(expandedDelta) <= 2 && Math.abs(collapsedDelta) <= 2,
|
||||
`The summary must stay under the reader: expand=${expandedDelta}, collapse=${collapsedDelta}`,
|
||||
);
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Button, Pressable, Text, View, type ViewStyle } from "react-native";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import { createNativeStreamStrategy } from "@/agent-stream/strategy-native";
|
||||
import { createStreamPresentation } from "@/agent-stream/presentation";
|
||||
import type { StreamViewportHandle } from "@/agent-stream/strategy";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
const frameStyle: ViewStyle = { flex: 1, paddingTop: 45, paddingBottom: 30 };
|
||||
const listStyle: ViewStyle = { flex: 1 };
|
||||
const resultStyle = { color: "#111", height: 50 };
|
||||
const textStyle = { color: "#111", padding: 8 };
|
||||
const rowStyles: ViewStyle[] = [false, true].flatMap((work) =>
|
||||
[false, true].map((host) => ({
|
||||
height: work ? 90 : 60,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#bbb",
|
||||
backgroundColor: host ? "#ccddff" : "#fff",
|
||||
justifyContent: "center",
|
||||
})),
|
||||
);
|
||||
function Row({
|
||||
item,
|
||||
expanded,
|
||||
isHost,
|
||||
attach,
|
||||
toggle,
|
||||
}: {
|
||||
item: StreamItem;
|
||||
expanded: boolean;
|
||||
isHost: boolean;
|
||||
attach(id: string, view: View | null): void;
|
||||
toggle(id: string): void;
|
||||
}) {
|
||||
const ref = useCallback((view: View | null) => attach(item.id, view), [attach, item.id]);
|
||||
const onPress = useCallback(() => toggle(item.id), [toggle, item.id]);
|
||||
const style = rowStyles[(item.id.startsWith("work") ? 2 : 0) + (isHost ? 1 : 0)];
|
||||
const stateLabel = expanded ? " Collapse" : " Expand";
|
||||
return (
|
||||
<View ref={ref} collapsable={false} style={style}>
|
||||
<Pressable testID={item.id} onPress={onPress}>
|
||||
<Text style={textStyle}>
|
||||
{item.id}
|
||||
{isHost ? stateLabel : ""}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
function measureY(view: View | undefined): Promise<number> {
|
||||
if (!view) return Promise.reject(new Error("Diagnostic row is not mounted"));
|
||||
return new Promise((resolve) => view.measureInWindow((_x, y) => resolve(y)));
|
||||
}
|
||||
function settleLayout(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
}
|
||||
const strategy = createNativeStreamStrategy();
|
||||
const tail: StreamItem[] = Array.from({ length: 10 }, (_, n) => {
|
||||
const turnId = `turn-${n}`;
|
||||
const timestamp = new Date(2026, 0, 1, 0, n);
|
||||
return [
|
||||
{ kind: "user_message" as const, id: `host-${n}`, turnId, timestamp, text: `Prompt ${n}` },
|
||||
...Array.from({ length: 5 }, (_work, i) => ({
|
||||
kind: "assistant_message" as const,
|
||||
id: `work-${n}-${i}`,
|
||||
turnId,
|
||||
timestamp,
|
||||
text: `Work ${n}-${i}`,
|
||||
})),
|
||||
{ kind: "assistant_message" as const, id: `reply-${n}`, turnId, timestamp, text: `Reply ${n}` },
|
||||
];
|
||||
}).flat();
|
||||
|
||||
function Transcript() {
|
||||
const present = useMemo(createStreamPresentation, []);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
|
||||
const [running, setRunning] = useState(false);
|
||||
const [report, setReport] = useState("Ready");
|
||||
const rows = useRef(new Map<string, View>());
|
||||
const presentation = present({
|
||||
tail,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
display: "collapseTurns",
|
||||
expandedTurnHostIds: expanded,
|
||||
isTurnActive: running,
|
||||
activeTurnId: running ? "turn-9" : null,
|
||||
});
|
||||
const historyMounted = strategy.orderTail(presentation.tail);
|
||||
const sample = useCallback(async (id: string, next: () => void, label = "complete") => {
|
||||
const before = await measureY(rows.current.get(id));
|
||||
next();
|
||||
await settleLayout();
|
||||
const after = await measureY(rows.current.get(id));
|
||||
setReport(
|
||||
`${label} ${id}: before=${before.toFixed(1)} after=${after.toFixed(1)} delta=${(after - before).toFixed(1)}`,
|
||||
);
|
||||
}, []);
|
||||
const attach = useCallback((id: string, view: View | null) => {
|
||||
if (view) rows.current.set(id, view);
|
||||
else rows.current.delete(id);
|
||||
}, []);
|
||||
const toggle = useCallback(
|
||||
(id: string) => {
|
||||
const turn = presentation.turns.turnsByHostId.get(id);
|
||||
if (!turn) return;
|
||||
sample(
|
||||
id,
|
||||
() => {
|
||||
viewportRef.current?.holdReadingPosition();
|
||||
setExpanded((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
turn.expanded ? "collapse" : "expand",
|
||||
);
|
||||
},
|
||||
[presentation.turns.turnsByHostId, sample],
|
||||
);
|
||||
const renderRow = (item: StreamItem) => {
|
||||
const turn = presentation.turns.turnsByHostId.get(item.id);
|
||||
return (
|
||||
<Row
|
||||
item={item}
|
||||
isHost={!!turn}
|
||||
expanded={!!turn?.expanded}
|
||||
attach={attach}
|
||||
toggle={toggle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const start = useCallback(() => setRunning(true), []);
|
||||
const finish = useCallback(
|
||||
() => setTimeout(() => sample("host-9", () => setRunning(false)), 5000),
|
||||
[sample],
|
||||
);
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<Text testID="result" style={resultStyle}>
|
||||
{report}
|
||||
</Text>
|
||||
<Button title="Start running turn" onPress={start} />
|
||||
<Button title="Finish in 5 seconds" onPress={finish} />
|
||||
<View style={listStyle}>
|
||||
{strategy.render({
|
||||
agentId: "diagnostic",
|
||||
segments: { historyVirtualized: [], historyMounted, liveHead: [] },
|
||||
liveHeadRowRevision: expanded,
|
||||
historyRowRevision: {
|
||||
contentById: new Set(),
|
||||
displayStateById: new Set([...expanded, ...presentation.turns.turnsByHostId.keys()]),
|
||||
globalDisplayState: running,
|
||||
},
|
||||
readingRowReplacements: presentation.readingRowReplacements,
|
||||
boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false },
|
||||
renderers: {
|
||||
renderHistoryVirtualizedRow: renderRow,
|
||||
renderHistoryMountedRow: renderRow,
|
||||
renderLiveHeadRow: renderRow,
|
||||
renderLiveAuxiliary: () => null,
|
||||
},
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: () => false,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: { flex: 1 },
|
||||
baseListContentContainerStyle: {},
|
||||
forwardListContentContainerStyle: {},
|
||||
contentMaxWidth: 400,
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
export default function Diagnostic() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<Transcript />
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,64 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { expectReconnectingToastVisible } from "./workspace-ui";
|
||||
|
||||
const PLUGIN_ID = "timeline-transform-e2e";
|
||||
|
||||
/** Observe the app's real socket, including fresh sockets after a reload. */
|
||||
export function trackPluginChatRecovery(page: Page) {
|
||||
const messages: {
|
||||
type: string;
|
||||
payload?: { plugins?: { id: string; clientBundle?: string }[] };
|
||||
}[] = [];
|
||||
page.on("websocket", (socket) => {
|
||||
const collect = ({ payload }: { payload: string | Buffer }) => {
|
||||
if (typeof payload !== "string") return;
|
||||
const frame = JSON.parse(payload);
|
||||
if (frame.type === "session") messages.push(frame.message);
|
||||
};
|
||||
socket.on("framesent", collect);
|
||||
socket.on("framereceived", collect);
|
||||
});
|
||||
return {
|
||||
reset() {
|
||||
messages.length = 0;
|
||||
},
|
||||
async expectCatalogWithoutRetransmission() {
|
||||
await expect
|
||||
.poll(() =>
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.type === "plugin.catalog.get.response" &&
|
||||
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
const entry = messages
|
||||
.find((message) => message.type === "plugin.catalog.get.response")
|
||||
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry).not.toHaveProperty("clientBundle");
|
||||
},
|
||||
async expectRecoveredWithoutRetransmission() {
|
||||
await this.expectCatalogWithoutRetransmission();
|
||||
await expect
|
||||
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
|
||||
.toBe(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function reconnectPluginChat(
|
||||
page: Page,
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
) {
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
gate.restore();
|
||||
}
|
||||
export const ASSISTANT_TEXT =
|
||||
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
|
||||
|
||||
|
||||
@@ -702,6 +702,28 @@ it("does not reopen the previous turn while a new run waits for its first row",
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
|
||||
// the completed one; it must stay folded until the new turn's rows arrive.
|
||||
it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => {
|
||||
const timestamp = new Date();
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
];
|
||||
for (const item of tail) item.turnId = "completed";
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns",
|
||||
isTurnActive: true,
|
||||
activeTurnId: null,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
|
||||
const present = createStreamPresentation();
|
||||
const timestamp = new Date();
|
||||
|
||||
@@ -113,14 +113,16 @@ export function createStreamPresentation() {
|
||||
}
|
||||
// Live text updates reach the head only, so the folded history is reused until the
|
||||
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
|
||||
const isLatestTurnOpen =
|
||||
(input.isTurnActive || projection.head.length > 0) &&
|
||||
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
|
||||
!(
|
||||
input.activeTurnId &&
|
||||
projection.tail.at(-1)?.turnId &&
|
||||
input.activeTurnId !== projection.tail.at(-1)?.turnId
|
||||
);
|
||||
const last = projection.tail.at(-1) ?? null;
|
||||
const first = projection.head[0] ?? null;
|
||||
const activeTurnIsAnother = Boolean(
|
||||
input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId,
|
||||
);
|
||||
// Without head rows, the latest retained turn is live only while the active run is that
|
||||
// turn. A run that has not opened yet carries no id and belongs to the next turn.
|
||||
const isLatestTurnOpen = first
|
||||
? !isTurnBoundary(last, first) && !activeTurnIsAnother
|
||||
: input.isTurnActive && (last?.turnId === undefined || input.activeTurnId === last.turnId);
|
||||
if (
|
||||
!collapsed ||
|
||||
collapsedSource !== projection.tail ||
|
||||
|
||||
@@ -898,12 +898,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
? group.run.calls.map((call, index) => (
|
||||
<React.Fragment key={call.id}>
|
||||
{call.kind === "thought" ? (
|
||||
// Reasoning is grouped only while Expand thinking is off, so an opened
|
||||
// group shows each thought as a closed row like any other call.
|
||||
<ThoughtSlot
|
||||
itemId={call.id}
|
||||
text={call.text}
|
||||
status={call.status}
|
||||
isLastInSequence={index === group.run.calls.length - 1}
|
||||
defaultExpanded
|
||||
defaultExpanded={false}
|
||||
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1692,7 +1692,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
import { CryptoDigestAlgorithm, digestStringAsync, randomUUID } from "expo-crypto";
|
||||
import { createPluginClientBundleStore } from "./store";
|
||||
|
||||
export function createHostPluginClientBundleStore(serverId: string) {
|
||||
const directory = `${FileSystem.cacheDirectory}plugin-client-bundles/${encodeURIComponent(serverId)}/`;
|
||||
const ensureDirectory = async () => {
|
||||
if (!FileSystem.cacheDirectory) throw new Error("Plugin bundle cache directory unavailable");
|
||||
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
|
||||
};
|
||||
return createPluginClientBundleStore(
|
||||
{
|
||||
async list() {
|
||||
await ensureDirectory();
|
||||
return FileSystem.readDirectoryAsync(directory);
|
||||
},
|
||||
read: (name) => FileSystem.readAsStringAsync(`${directory}${name}`),
|
||||
async writeAtomic(name, contents) {
|
||||
await ensureDirectory();
|
||||
const temporary = `${directory}${randomUUID()}.tmp`;
|
||||
try {
|
||||
await FileSystem.writeAsStringAsync(temporary, contents);
|
||||
await FileSystem.moveAsync({ from: temporary, to: `${directory}${name}` });
|
||||
} finally {
|
||||
await FileSystem.deleteAsync(temporary, { idempotent: true });
|
||||
}
|
||||
},
|
||||
remove: (name) => FileSystem.deleteAsync(`${directory}${name}`, { idempotent: true }),
|
||||
},
|
||||
(contents) => digestStringAsync(CryptoDigestAlgorithm.SHA256, contents),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createPluginClientBundleStore } from "./store";
|
||||
|
||||
const DATABASE_NAME = "paseo-plugin-client-bundles";
|
||||
const STORE_NAME = "bundles";
|
||||
let opening: Promise<IDBDatabase> | undefined;
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
opening ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, 1);
|
||||
request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME));
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
}).catch((error) => {
|
||||
opening = undefined;
|
||||
throw error;
|
||||
});
|
||||
return opening;
|
||||
}
|
||||
|
||||
export function createHostPluginClientBundleStore(serverId: string) {
|
||||
const prefix = `${encodeURIComponent(serverId)}/`;
|
||||
const range = () => IDBKeyRange.bound(prefix, `${prefix}\uffff`);
|
||||
async function run<T>(
|
||||
mode: IDBTransactionMode,
|
||||
operation: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
const database = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(STORE_NAME, mode);
|
||||
const request = operation(transaction.objectStore(STORE_NAME));
|
||||
transaction.addEventListener("complete", () => resolve(request.result));
|
||||
transaction.addEventListener("abort", () => reject(transaction.error));
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
});
|
||||
}
|
||||
return createPluginClientBundleStore(
|
||||
{
|
||||
async list() {
|
||||
return (await run("readonly", (store) => store.getAllKeys(range())))
|
||||
.filter((key): key is string => typeof key === "string")
|
||||
.map((key) => key.slice(prefix.length));
|
||||
},
|
||||
async read(name) {
|
||||
const result: unknown = await run("readonly", (store) => store.get(`${prefix}${name}`));
|
||||
if (typeof result !== "string") throw new Error("Missing plugin client bundle");
|
||||
return result;
|
||||
},
|
||||
async writeAtomic(name, contents) {
|
||||
await run("readwrite", (store) => store.put(contents, `${prefix}${name}`));
|
||||
},
|
||||
async remove(name) {
|
||||
await run("readwrite", (store) => store.delete(`${prefix}${name}`));
|
||||
},
|
||||
},
|
||||
async (contents) => {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createHostPluginClientBundleStore } from "./index.web";
|
||||
|
||||
async function sha256(contents: string) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
test("browser bundles survive a fresh store and remain isolated by host", async () => {
|
||||
const first = createHostPluginClientBundleStore("bundle-test-host-a");
|
||||
const second = createHostPluginClientBundleStore("bundle-test-host-b");
|
||||
const clientBundle = "bundle".repeat(250_000);
|
||||
const entry = {
|
||||
id: "same-plugin",
|
||||
clientBundle,
|
||||
clientBundleHash: await sha256(clientBundle),
|
||||
};
|
||||
const otherCode = "other host code";
|
||||
const other = {
|
||||
id: entry.id,
|
||||
clientBundle: otherCode,
|
||||
clientBundleHash: await sha256(otherCode),
|
||||
};
|
||||
try {
|
||||
await first.replace([entry]);
|
||||
await second.replace([other]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([entry]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-b").load()).toEqual([other]);
|
||||
await first.replace([]);
|
||||
expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([]);
|
||||
expect(await second.load()).toEqual([other]);
|
||||
} finally {
|
||||
await first.replace([]);
|
||||
await second.replace([]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { expect, test } from "vitest";
|
||||
import { createPluginClientBundleStore, type PluginBundleFiles } from "./store";
|
||||
|
||||
const sha256 = async (contents: string) => createHash("sha256").update(contents).digest("hex");
|
||||
|
||||
class BundleFiles implements PluginBundleFiles {
|
||||
readonly contents = new Map<string, string>();
|
||||
readonly writes: string[] = [];
|
||||
async list() {
|
||||
return [...this.contents.keys()];
|
||||
}
|
||||
async read(name: string) {
|
||||
const contents = this.contents.get(name);
|
||||
if (contents === undefined) throw new Error("File disappeared");
|
||||
return contents;
|
||||
}
|
||||
async writeAtomic(name: string, contents: string) {
|
||||
this.writes.push(name);
|
||||
this.contents.set(name, contents);
|
||||
}
|
||||
async remove(name: string) {
|
||||
this.contents.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
async function bundle(id: string, clientBundle: string) {
|
||||
return { id, clientBundle, clientBundleHash: await sha256(clientBundle) };
|
||||
}
|
||||
|
||||
test("a cold store loads verified bundles and prunes versions missing from the latest catalog", async () => {
|
||||
const files = new BundleFiles();
|
||||
const old = await bundle("changed", "old");
|
||||
const kept = await bundle("kept", "Unicode: 折叠屏 🐚");
|
||||
await createPluginClientBundleStore(files, sha256).replace([old, kept]);
|
||||
const cold = createPluginClientBundleStore(files, sha256);
|
||||
expect(await cold.load()).toEqual([old, kept]);
|
||||
const changed = await bundle("changed", "new");
|
||||
await cold.replace([kept, changed]);
|
||||
expect(files.contents.size).toBe(2);
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([kept, changed]);
|
||||
expect(files.writes).toHaveLength(3);
|
||||
await cold.replace([]);
|
||||
expect(files.contents.size).toBe(0);
|
||||
});
|
||||
|
||||
test("same-length corruption, truncated files and unreadable files are not advertised", async () => {
|
||||
const files = new BundleFiles();
|
||||
const entries = await Promise.all([
|
||||
bundle("same-length", "good"),
|
||||
bundle("truncated", "long"),
|
||||
bundle("missing", "lost"),
|
||||
]);
|
||||
await createPluginClientBundleStore(files, sha256).replace(entries);
|
||||
const names = [...files.contents.keys()];
|
||||
files.contents.set(names[0]!, "evil");
|
||||
files.contents.set(names[1]!, "lo");
|
||||
// The directory entry still exists while reading the file fails.
|
||||
const unreadable: PluginBundleFiles = {
|
||||
list: () => files.list(),
|
||||
read: (name) =>
|
||||
name === names[2] ? Promise.reject(new Error("Unreadable")) : files.read(name),
|
||||
writeAtomic: (name, contents) => files.writeAtomic(name, contents),
|
||||
remove: (name) => files.remove(name),
|
||||
};
|
||||
const cold = createPluginClientBundleStore(unreadable, sha256);
|
||||
expect(await cold.load()).toEqual([]);
|
||||
await cold.replace(entries);
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual(entries);
|
||||
});
|
||||
|
||||
test("an interrupted atomic write leaves the prior catalog available for a cold reader", async () => {
|
||||
const files = new BundleFiles();
|
||||
const previous = await bundle("plugin", "previous");
|
||||
await createPluginClientBundleStore(files, sha256).replace([previous]);
|
||||
const failing: PluginBundleFiles = {
|
||||
list: () => files.list(),
|
||||
read: (name) => files.read(name),
|
||||
remove: (name) => files.remove(name),
|
||||
writeAtomic: async () => {
|
||||
throw new Error("Disk full");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
createPluginClientBundleStore(failing, sha256).replace([await bundle("plugin", "next")]),
|
||||
).rejects.toThrow("Disk full");
|
||||
expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([previous]);
|
||||
});
|
||||
|
||||
// Exercise the app store through a fresh platform-agnostic client, using its transport port.
|
||||
test.each(["valid", "corrupt", "missing"])(
|
||||
"cold catalog uses a %s persisted bundle correctly",
|
||||
async (state) => {
|
||||
const files = new BundleFiles();
|
||||
const entry = await bundle("plugin", "good code");
|
||||
await createPluginClientBundleStore(files, sha256).replace([entry]);
|
||||
if (state === "corrupt") files.contents.set([...files.contents.keys()][0]!, "evil code");
|
||||
if (state === "missing") files.contents.clear();
|
||||
let open = () => {};
|
||||
let receive: (data: unknown, binary: boolean) => void = () => {};
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "plugin-cache-test",
|
||||
reconnect: { enabled: false },
|
||||
createPluginClientBundleStore: () => createPluginClientBundleStore(files, sha256),
|
||||
transportFactory: () => ({
|
||||
send(data) {
|
||||
if (typeof data !== "string") return;
|
||||
const frame = JSON.parse(data);
|
||||
if (frame.type !== "session") return;
|
||||
const request = frame.message;
|
||||
if (request.type !== "plugin.catalog.get.request") return;
|
||||
requests.push(request);
|
||||
const { clientBundle: _code, ...reference } = entry;
|
||||
receive(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
plugins: [request.cachedClientBundles ? reference : entry],
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
},
|
||||
close() {},
|
||||
onOpen(handler) {
|
||||
open = handler;
|
||||
return () => {};
|
||||
},
|
||||
onMessage(handler) {
|
||||
receive = handler;
|
||||
return () => {};
|
||||
},
|
||||
onClose() {
|
||||
return () => {};
|
||||
},
|
||||
onError() {
|
||||
return () => {};
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const connecting = client.connect();
|
||||
open();
|
||||
receive(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "cache-host",
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { plugins: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
await connecting;
|
||||
expect(await client.getPluginCatalog()).toEqual([entry]);
|
||||
if (state === "valid")
|
||||
expect(requests[0]?.cachedClientBundles).toEqual([
|
||||
{ id: entry.id, clientBundleHash: entry.clientBundleHash },
|
||||
]);
|
||||
else expect(requests[0]).not.toHaveProperty("cachedClientBundles");
|
||||
await expect.poll(() => createPluginClientBundleStore(files, sha256).load()).toEqual([entry]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
PluginClientBundle,
|
||||
PluginClientBundleStore,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
/** Each adapter is scoped to one host. Writes publish a complete file/record atomically. */
|
||||
export interface PluginBundleFiles {
|
||||
list(): Promise<string[]>;
|
||||
read(name: string): Promise<string>;
|
||||
writeAtomic(name: string, contents: string): Promise<void>;
|
||||
remove(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createPluginClientBundleStore(
|
||||
files: PluginBundleFiles,
|
||||
sha256: (contents: string) => Promise<string>,
|
||||
): PluginClientBundleStore {
|
||||
const verified = new Set<string>();
|
||||
const filename = (bundle: PluginClientBundle) =>
|
||||
`${encodeURIComponent(bundle.id)}.${bundle.clientBundleHash}.js`;
|
||||
return {
|
||||
async load() {
|
||||
const bundles: PluginClientBundle[] = [];
|
||||
for (const name of await files.list()) {
|
||||
try {
|
||||
const match = /^(.*)\.([a-f0-9]{64})\.js$/.exec(name);
|
||||
if (!match) continue;
|
||||
const id = decodeURIComponent(match[1]!);
|
||||
const clientBundleHash = match[2]!;
|
||||
const clientBundle = await files.read(name);
|
||||
if ((await sha256(clientBundle)) !== clientBundleHash) continue;
|
||||
verified.add(name);
|
||||
bundles.push({ id, clientBundleHash, clientBundle });
|
||||
} catch {
|
||||
// Missing, unreadable or corrupt files are cache misses, never executable code.
|
||||
}
|
||||
}
|
||||
return bundles;
|
||||
},
|
||||
async replace(bundles) {
|
||||
const retained = new Set<string>();
|
||||
for (const bundle of bundles) {
|
||||
const name = filename(bundle);
|
||||
retained.add(name);
|
||||
if (verified.has(name)) continue;
|
||||
await files.writeAtomic(name, bundle.clientBundle);
|
||||
verified.add(name);
|
||||
}
|
||||
for (const name of await files.list()) {
|
||||
if (retained.has(name)) continue;
|
||||
await files.remove(name);
|
||||
verified.delete(name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
|
||||
import { watchdog } from "@/watchdog";
|
||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
@@ -589,6 +590,7 @@ function createDefaultDeps(
|
||||
capabilities: appCapabilities,
|
||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||
providerSnapshots: "wire",
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
...(host.password ? { password: host.password } : {}),
|
||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||
} satisfies Omit<DaemonClientConfig, "url">;
|
||||
@@ -668,6 +670,7 @@ function createDefaultDeps(
|
||||
),
|
||||
reconnect: { enabled: false },
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
@@ -684,6 +687,7 @@ function createDefaultDeps(
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
capabilities: appCapabilities,
|
||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||
createPluginClientBundleStore: createHostPluginClientBundleStore,
|
||||
});
|
||||
},
|
||||
getClientId: () => getOrCreateClientId(),
|
||||
|
||||
@@ -371,6 +371,22 @@ describe("viewed timeline persistence", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preparing an already painted cache again keeps history out of the live head", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
storage: { readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined },
|
||||
prepareAgent: async () => undefined,
|
||||
});
|
||||
await replica.prepare(AGENT_ID);
|
||||
const painted = useSessionStore.getState().sessions[SERVER_ID]!.agentStreamTail.get(AGENT_ID);
|
||||
expect(painted).toEqual(cachedTimeline().items);
|
||||
await replica.prepare(AGENT_ID);
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID]!;
|
||||
expect(session.agentStreamHead.get(AGENT_ID) ?? []).toEqual([]);
|
||||
expect(session.agentStreamTail.get(AGENT_ID)).toBe(painted);
|
||||
});
|
||||
|
||||
it("reconciles a live head that overlaps the cached canonical timeline", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
let release!: (value: CachedTimeline) => void;
|
||||
|
||||
@@ -109,6 +109,15 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
async prepare(agentId: string): Promise<void> {
|
||||
const existing = this.preparations.get(agentId);
|
||||
if (existing) return existing;
|
||||
const timeline = selectAgentTimelineState(
|
||||
useSessionStore.getState().sessions[this.serverId],
|
||||
agentId,
|
||||
);
|
||||
// A route and the visible-chat owner can both prepare before catch-up finishes.
|
||||
// Repainting that same cache would treat its history as a live head.
|
||||
if (timeline.status !== "cold" && this.cachedRanges.has(agentId)) {
|
||||
return;
|
||||
}
|
||||
const preparation = this.load(agentId).finally(() => {
|
||||
if (this.preparations.get(agentId) === preparation) {
|
||||
this.preparations.delete(agentId);
|
||||
|
||||
@@ -129,6 +129,23 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the bundle cache factory on a probe client that the host runtime can adopt", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const createPluginClientBundleStore = () => ({
|
||||
async load() {
|
||||
return [];
|
||||
},
|
||||
async replace() {},
|
||||
});
|
||||
const { client } = await connectToDaemon(
|
||||
{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" },
|
||||
{ createPluginClientBundleStore },
|
||||
probe.deps,
|
||||
);
|
||||
expect(client.config.createPluginClientBundleStore).toBe(createPluginClientBundleStore);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("encodes the local socket target into the client config", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
|
||||
@@ -132,12 +132,13 @@ function resolveConnectionCredentials(
|
||||
export async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string,
|
||||
options?: {
|
||||
options: {
|
||||
capabilities?: DaemonClientConfig["capabilities"];
|
||||
trace?: DaemonClientConfig["trace"];
|
||||
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
|
||||
password?: string;
|
||||
localCredential?: DaemonClientConfig["localCredential"];
|
||||
},
|
||||
} = {},
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
| "getClientId"
|
||||
@@ -157,8 +158,9 @@ export async function buildClientConfig(
|
||||
suppressSendErrors: true,
|
||||
reconnect: { enabled: false },
|
||||
...resolveConnectionCredentials(connection, options),
|
||||
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
|
||||
...(options?.trace ? { trace: options.trace } : {}),
|
||||
...(options.capabilities ? { capabilities: options.capabilities } : {}),
|
||||
...(options.trace ? { trace: options.trace } : {}),
|
||||
createPluginClientBundleStore: options.createPluginClientBundleStore,
|
||||
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
||||
desktopTransportFactory
|
||||
? { transportFactory: desktopTransportFactory }
|
||||
@@ -288,6 +290,7 @@ interface ProbeOptions {
|
||||
localCredential?: DaemonClientConfig["localCredential"];
|
||||
capabilities?: DaemonClientConfig["capabilities"];
|
||||
trace?: DaemonClientConfig["trace"];
|
||||
createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"];
|
||||
}
|
||||
|
||||
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { startTestDaemon, runPaseoCli } from "./helpers/test-daemon.ts";
|
||||
|
||||
// Port zero leaves allocation to the listening daemon, with no reserve/release gap.
|
||||
const daemon = await startTestDaemon({ port: 0 });
|
||||
try {
|
||||
assert(daemon.port > 0, "the helper returns the port the daemon actually bound");
|
||||
assert.equal(daemon.wsUrl, `ws://127.0.0.1:${daemon.port}`);
|
||||
const result = await runPaseoCli(daemon, ["agent", "ls", "--host", daemon.wsUrl, "--json"]);
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), []);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { ChildProcess, spawn } from "child_process";
|
||||
import { getAvailablePort } from "./network.ts";
|
||||
import { readDaemonInstance } from "@getpaseo/server/daemon-control";
|
||||
|
||||
export interface TestDaemonContext {
|
||||
/** Available local port for test daemon (never 6767) */
|
||||
@@ -195,17 +195,20 @@ async function probeDaemonReady(
|
||||
}
|
||||
|
||||
async function waitForDaemonReady(
|
||||
port: number,
|
||||
paseoHome: string,
|
||||
timeout = 30000,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
): Promise<number> {
|
||||
const deadline = Date.now() + timeout;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (await probeDaemonReady(port, paseoHome, env)) return;
|
||||
async function poll(): Promise<number> {
|
||||
const instance = await readDaemonInstance(paseoHome);
|
||||
if (instance?.listen) {
|
||||
const port = Number(new URL(`http://${instance.listen}`).port);
|
||||
if (await probeDaemonReady(port, paseoHome, env)) return port;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`);
|
||||
throw new Error(`Daemon failed to publish a ready endpoint within ${timeout}ms`);
|
||||
}
|
||||
await sleep(100);
|
||||
return poll();
|
||||
@@ -231,15 +234,14 @@ export async function startTestDaemon(options?: {
|
||||
timeout?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<TestDaemonContext> {
|
||||
const port = options?.port ?? (await getAvailablePort());
|
||||
// Let the listening process allocate its port, without a reserve/release race.
|
||||
const requestedPort = options?.port ?? 0;
|
||||
const { paseoHome, workDir } =
|
||||
options?.paseoHome && options?.workDir
|
||||
? { paseoHome: options.paseoHome, workDir: options.workDir }
|
||||
: await createTempDirs();
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
|
||||
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`;
|
||||
|
||||
// Find the CLI entry point - use the source file directly with tsx
|
||||
const cliDir = join(import.meta.dirname, "..", "..");
|
||||
const cliSrcPath = join(cliDir, "src", "index.ts");
|
||||
@@ -252,7 +254,7 @@ export async function startTestDaemon(options?: {
|
||||
),
|
||||
...TEST_DAEMON_ENV_DEFAULTS,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
|
||||
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${requestedPort}`,
|
||||
// Force no TTY to prevent QR code output
|
||||
CI: "true",
|
||||
...options?.env,
|
||||
@@ -312,20 +314,18 @@ export async function startTestDaemon(options?: {
|
||||
}
|
||||
});
|
||||
|
||||
const ctx: TestDaemonContext = {
|
||||
port,
|
||||
wsUrl,
|
||||
paseoHome,
|
||||
workDir,
|
||||
process: daemonProcess,
|
||||
isReady: false,
|
||||
stop: cleanup,
|
||||
};
|
||||
|
||||
// Wait for daemon to be ready
|
||||
try {
|
||||
await waitForDaemonReady(port, paseoHome, timeout, options?.env);
|
||||
ctx.isReady = true;
|
||||
const port = await waitForDaemonReady(paseoHome, timeout, options?.env);
|
||||
return {
|
||||
port,
|
||||
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
|
||||
paseoHome,
|
||||
workDir,
|
||||
process: daemonProcess,
|
||||
isReady: true,
|
||||
stop: cleanup,
|
||||
};
|
||||
} catch (err) {
|
||||
// Daemon failed to start - clean up and rethrow
|
||||
await cleanup();
|
||||
@@ -335,8 +335,6 @@ export async function startTestDaemon(options?: {
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
DaemonClient,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type CreateAgentRequestOptions,
|
||||
type DaemonTransport,
|
||||
type Logger,
|
||||
type PluginClientBundle,
|
||||
type PluginClientBundleStore,
|
||||
} from "./daemon-client";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
@@ -2099,6 +2101,188 @@ test("gets a structured plugin log snapshot", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
describe("plugin catalog", () => {
|
||||
async function connectedClient(createStore?: (serverId: string) => PluginClientBundleStore) {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger: createMockLogger(),
|
||||
createPluginClientBundleStore: createStore,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ features: { plugins: true } });
|
||||
await connectPromise;
|
||||
return { client, mock };
|
||||
}
|
||||
|
||||
async function fetchCatalog(
|
||||
harness: Awaited<ReturnType<typeof connectedClient>>,
|
||||
respond: (request: Record<string, unknown>) => Array<Record<string, unknown>>,
|
||||
) {
|
||||
const before = harness.mock.sent.length;
|
||||
const response = harness.client.getPluginCatalog();
|
||||
await vi.waitFor(() => expect(harness.mock.sent.length).toBeGreaterThan(before));
|
||||
const request = parseSentFrame(harness.mock.sent.at(-1));
|
||||
harness.mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: { requestId: request.requestId, plugins: respond(request) },
|
||||
}),
|
||||
);
|
||||
return { request, plugins: await response };
|
||||
}
|
||||
|
||||
test("reuses retained client bundles the daemon leaves out", async () => {
|
||||
const harness = await connectedClient();
|
||||
const first = await fetchCatalog(harness, () => [
|
||||
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundle: "old bundle", clientBundleHash: "hash-old" },
|
||||
]);
|
||||
expect(first.request).not.toHaveProperty("cachedClientBundles");
|
||||
|
||||
const second = await fetchCatalog(harness, () => [
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
]);
|
||||
|
||||
expect(second.request.cachedClientBundles).toEqual([
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundleHash: "hash-old" },
|
||||
]);
|
||||
expect(second.plugins).toEqual([
|
||||
{ id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
]);
|
||||
const third = await fetchCatalog(harness, () => []);
|
||||
expect(third.request.cachedClientBundles).toEqual([
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
{ id: "changed", clientBundleHash: "hash-new" },
|
||||
]);
|
||||
const fourth = await fetchCatalog(harness, () => []);
|
||||
expect(fourth.request).not.toHaveProperty("cachedClientBundles");
|
||||
});
|
||||
|
||||
test("keeps fetching every bundle from a daemon that sends no bundle hashes", async () => {
|
||||
const harness = await connectedClient();
|
||||
const legacyCatalog = () => [{ id: "legacy", clientBundle: "legacy bundle" }];
|
||||
await fetchCatalog(harness, legacyCatalog);
|
||||
|
||||
const second = await fetchCatalog(harness, legacyCatalog);
|
||||
|
||||
expect(second.request).not.toHaveProperty("cachedClientBundles");
|
||||
expect(second.plugins).toEqual([{ id: "legacy", clientBundle: "legacy bundle" }]);
|
||||
});
|
||||
|
||||
test("a fresh client advertises persisted bundles on its first fetch and expands omitted code", async () => {
|
||||
let persisted: PluginClientBundle[] = [];
|
||||
let loads = 0;
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
loads++;
|
||||
return persisted;
|
||||
},
|
||||
async replace(bundles) {
|
||||
persisted = bundles;
|
||||
},
|
||||
};
|
||||
const createdFor: string[] = [];
|
||||
const createStore = (serverId: string) => {
|
||||
createdFor.push(serverId);
|
||||
return store;
|
||||
};
|
||||
const bundle = { id: "kept", clientBundleHash: "hash-kept", clientBundle: "stored code" };
|
||||
const warm = await connectedClient(createStore);
|
||||
await fetchCatalog(warm, () => [bundle]);
|
||||
const cold = await connectedClient(createStore);
|
||||
const first = await fetchCatalog(cold, () => [{ id: "kept", clientBundleHash: "hash-kept" }]);
|
||||
expect(first.request.cachedClientBundles).toEqual([
|
||||
{ id: "kept", clientBundleHash: "hash-kept" },
|
||||
]);
|
||||
expect(first.plugins).toEqual([bundle]);
|
||||
expect(loads).toBe(2);
|
||||
await fetchCatalog(cold, () => []);
|
||||
expect(loads).toBe(2);
|
||||
expect(createdFor).toEqual([
|
||||
warm.client.getLastServerInfoMessage()!.serverId,
|
||||
cold.client.getLastServerInfoMessage()!.serverId,
|
||||
]);
|
||||
expect(persisted).toEqual([]);
|
||||
});
|
||||
|
||||
test.each(["missing", "unreadable"])(
|
||||
"a %s persistent cache requests full bundles",
|
||||
async (state) => {
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
if (state === "unreadable") throw new Error("Storage unavailable");
|
||||
return [];
|
||||
},
|
||||
async replace() {},
|
||||
};
|
||||
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
|
||||
const result = await fetchCatalog(await connectedClient(() => store), () => [bundle]);
|
||||
expect(result.request).not.toHaveProperty("cachedClientBundles");
|
||||
expect(result.plugins).toEqual([bundle]);
|
||||
},
|
||||
);
|
||||
|
||||
test("failed cache writes never fail plugin loading", async () => {
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
return [];
|
||||
},
|
||||
async replace() {
|
||||
throw new Error("Disk full");
|
||||
},
|
||||
};
|
||||
const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" };
|
||||
expect(
|
||||
(await fetchCatalog(await connectedClient(() => store), () => [bundle])).plugins,
|
||||
).toEqual([bundle]);
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"usable catalogs do not wait for storage; latest write survives a failed first write: %s",
|
||||
async (failFirstWrite) => {
|
||||
const writes: PluginClientBundle[][] = [];
|
||||
let finishWrite = () => {};
|
||||
const blockedWrite = new Promise<void>((resolve) => {
|
||||
finishWrite = resolve;
|
||||
});
|
||||
const store: PluginClientBundleStore = {
|
||||
async load() {
|
||||
return [];
|
||||
},
|
||||
async replace(bundles) {
|
||||
writes.push(bundles);
|
||||
if (writes.length === 1) {
|
||||
await blockedWrite;
|
||||
if (failFirstWrite) throw new Error("Disk full");
|
||||
}
|
||||
},
|
||||
};
|
||||
const harness = await connectedClient(() => store);
|
||||
const first = { id: "plugin", clientBundle: "old", clientBundleHash: "hash-old" };
|
||||
const changed = { id: "plugin", clientBundle: "new", clientBundleHash: "hash-new" };
|
||||
try {
|
||||
expect((await fetchCatalog(harness, () => [first])).plugins).toEqual([first]);
|
||||
await vi.waitFor(() => expect(writes).toEqual([[first]]));
|
||||
expect((await fetchCatalog(harness, () => [changed])).plugins).toEqual([changed]);
|
||||
expect((await fetchCatalog(harness, () => [])).plugins).toEqual([]);
|
||||
expect(writes).toEqual([[first]]);
|
||||
finishWrite();
|
||||
await vi.waitFor(() => expect(writes).toEqual([[first], []]));
|
||||
} finally {
|
||||
finishWrite();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a plugin reload pending past the session RPC deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
try {
|
||||
|
||||
@@ -396,6 +396,17 @@ export type DaemonEventHandler = (event: DaemonEvent) => void;
|
||||
export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest;
|
||||
export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse;
|
||||
|
||||
export interface PluginClientBundle {
|
||||
id: string;
|
||||
clientBundleHash: string;
|
||||
clientBundle: string;
|
||||
}
|
||||
|
||||
/** Host-scoped storage. load must return only bundles whose contents have been verified. */
|
||||
export interface PluginClientBundleStore {
|
||||
load(): Promise<PluginClientBundle[]>;
|
||||
replace(bundles: PluginClientBundle[]): Promise<void>;
|
||||
}
|
||||
/** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */
|
||||
export type RelaySecurityMode = "legacy" | "protected";
|
||||
|
||||
@@ -406,6 +417,8 @@ export interface DaemonClientConfig {
|
||||
sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[];
|
||||
url: string;
|
||||
clientId: string;
|
||||
/** Create one cache for the confirmed daemon identity, retained for this client lifetime. */
|
||||
createPluginClientBundleStore?: (serverId: string) => PluginClientBundleStore;
|
||||
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
|
||||
appVersion?: string;
|
||||
runtimeGeneration?: number | null;
|
||||
@@ -1016,6 +1029,12 @@ type CorrelatedResponsePayloads = {
|
||||
type CorrelatedResponsePayload<TType extends CorrelatedResponseType> =
|
||||
CorrelatedResponsePayloads[TType];
|
||||
|
||||
type PluginCatalogResponseEntry =
|
||||
CorrelatedResponsePayload<"plugin.catalog.get.response">["plugins"][number];
|
||||
export type PluginCatalogEntry = Omit<PluginCatalogResponseEntry, "clientBundle"> & {
|
||||
clientBundle: string;
|
||||
};
|
||||
|
||||
export class DaemonConnectionError extends Error {
|
||||
readonly serverId?: string;
|
||||
readonly outcome?: "unknown";
|
||||
@@ -1343,6 +1362,15 @@ export class DaemonClient {
|
||||
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
||||
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
|
||||
private completedBinaryFileReads = new Map<string, FileReadResult>();
|
||||
/** The last catalog's client bundles by plugin id, so later catalogs can leave them out. */
|
||||
private pluginClientBundles = new Map<
|
||||
string,
|
||||
{ clientBundleHash: string; clientBundle: string }
|
||||
>();
|
||||
private pluginClientBundleStore: PluginClientBundleStore | undefined;
|
||||
private pluginClientBundlesLoaded: Promise<void> | undefined;
|
||||
private pluginClientBundlesWrite: PluginClientBundle[] | undefined;
|
||||
private pluginClientBundlesWriting = false;
|
||||
private logger: Logger;
|
||||
private pendingSendQueue: PendingSend[] = [];
|
||||
/**
|
||||
@@ -5793,14 +5821,82 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getPluginCatalog() {
|
||||
async getPluginCatalog(): Promise<PluginCatalogEntry[]> {
|
||||
if (this.config.createPluginClientBundleStore && !this.pluginClientBundleStore) {
|
||||
const serverInfo = this.getLastServerInfoMessage();
|
||||
if (!serverInfo) throw new Error("Plugin catalog requires a connected host");
|
||||
this.pluginClientBundleStore = this.config.createPluginClientBundleStore(serverInfo.serverId);
|
||||
}
|
||||
const store = this.pluginClientBundleStore;
|
||||
if (store) {
|
||||
this.pluginClientBundlesLoaded ??= store.load().then(
|
||||
(bundles) => {
|
||||
for (const { id, ...bundle } of bundles) this.pluginClientBundles.set(id, bundle);
|
||||
return undefined;
|
||||
},
|
||||
(error) => this.logger.warn({ err: error }, "Failed to load plugin client bundles"),
|
||||
);
|
||||
await this.pluginClientBundlesLoaded;
|
||||
}
|
||||
const requestId = this.createRequestId();
|
||||
const cached = this.pluginClientBundles;
|
||||
const cachedClientBundles = [...cached].map(([id, { clientBundleHash }]) => ({
|
||||
id,
|
||||
clientBundleHash,
|
||||
}));
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: { type: "plugin.catalog.get.request", requestId },
|
||||
message: {
|
||||
type: "plugin.catalog.get.request",
|
||||
requestId,
|
||||
...(cachedClientBundles.length > 0 ? { cachedClientBundles } : {}),
|
||||
},
|
||||
responseType: "plugin.catalog.get.response",
|
||||
});
|
||||
return payload.plugins;
|
||||
const retained = new Map<string, { clientBundleHash: string; clientBundle: string }>();
|
||||
const plugins = payload.plugins.map(({ clientBundle: sent, ...plugin }): PluginCatalogEntry => {
|
||||
const previous = cached.get(plugin.id);
|
||||
const clientBundle =
|
||||
sent ??
|
||||
(previous?.clientBundleHash === plugin.clientBundleHash
|
||||
? previous?.clientBundle
|
||||
: undefined);
|
||||
if (clientBundle === undefined) {
|
||||
throw new Error(`Plugin catalog left out the client bundle for ${plugin.id}`);
|
||||
}
|
||||
// COMPAT(pluginCatalogBundleHash): introduced by PR #6732 (unreleased); remove after
|
||||
// 2027-04-27 once the daemon floor includes bundle hashes. Older bundles are not retained.
|
||||
if (plugin.clientBundleHash !== undefined) {
|
||||
retained.set(plugin.id, { clientBundleHash: plugin.clientBundleHash, clientBundle });
|
||||
}
|
||||
return Object.assign(plugin, { clientBundle });
|
||||
});
|
||||
this.pluginClientBundles = retained;
|
||||
if (store) {
|
||||
this.pluginClientBundlesWrite = [...retained].map(([id, bundle]) =>
|
||||
Object.assign({ id }, bundle),
|
||||
);
|
||||
void this.persistPluginClientBundles(store);
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
private async persistPluginClientBundles(store: PluginClientBundleStore): Promise<void> {
|
||||
if (this.pluginClientBundlesWriting) return;
|
||||
this.pluginClientBundlesWriting = true;
|
||||
try {
|
||||
while (this.pluginClientBundlesWrite) {
|
||||
const bundles = this.pluginClientBundlesWrite;
|
||||
this.pluginClientBundlesWrite = undefined;
|
||||
try {
|
||||
await store.replace(bundles);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to persist plugin client bundles");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.pluginClientBundlesWriting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async listPlugins(): Promise<PluginListItem[]> {
|
||||
|
||||
@@ -23,6 +23,29 @@ describe("plugin protocol compatibility", () => {
|
||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
{ type: "plugin.catalog.get.request", requestId: "catalog" },
|
||||
{
|
||||
type: "plugin.catalog.get.request",
|
||||
requestId: "catalog",
|
||||
cachedClientBundles: [{ id: "example", clientBundleHash: "a".repeat(64) }],
|
||||
},
|
||||
])("parses plugin catalog requests %j", (request) => {
|
||||
expect(SessionInboundMessageSchema.parse(request)).toEqual(request);
|
||||
});
|
||||
it("parses plugin catalogs that leave out a client bundle the client already holds", () => {
|
||||
const message = {
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: "catalog",
|
||||
plugins: [
|
||||
{ id: "cached", clientBundleHash: "a".repeat(64) },
|
||||
{ id: "changed", clientBundle: "bundle", clientBundleHash: "b".repeat(64) },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||
});
|
||||
it("preserves manifest metadata in plugin list responses", () => {
|
||||
const message = {
|
||||
type: "plugin.list.response",
|
||||
|
||||
@@ -1470,6 +1470,10 @@ export const DiagnosticsRequestSchema = z.object({
|
||||
export const PluginCatalogGetRequestSchema = z.object({
|
||||
type: z.literal("plugin.catalog.get.request"),
|
||||
requestId: z.string(),
|
||||
/** Client bundles the client already holds; the daemon leaves out each one whose hash matches. */
|
||||
cachedClientBundles: z
|
||||
.array(z.object({ id: PluginIdSchema, clientBundleHash: z.string() }))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const PluginListRequestSchema = z.object({
|
||||
@@ -6757,7 +6761,10 @@ export const PluginCatalogGetResponseSchema = z.object({
|
||||
plugins: z.array(
|
||||
z.object({
|
||||
id: PluginIdSchema,
|
||||
clientBundle: z.string(),
|
||||
/** Left out only when the request listed this plugin with the same `clientBundleHash`. */
|
||||
clientBundle: z.string().optional(),
|
||||
/** SHA-256 of `clientBundle`, hex encoded. */
|
||||
clientBundleHash: z.string().optional(),
|
||||
requirements: PluginRequirementsSchema.optional(),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -78,6 +78,45 @@ test("listed client bundle is published while plugins are disabled; unlisted dir
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("a repeated catalog fetch leaves out the client bundle the client already holds", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-catalog-cache-"));
|
||||
roots.push(root);
|
||||
const directory = await fixture(root, "large", true);
|
||||
// About the size of a real plugin client bundle.
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`const payload = ${JSON.stringify("x".repeat(1_000_000))};
|
||||
export default function contribute() { void payload; return () => {}; }`,
|
||||
);
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
daemonVersion: "0.9.2",
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins: new BuiltinPluginLoader(root, ["large"]),
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" });
|
||||
const responseBytes: number[] = [];
|
||||
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||
if (message.type === "plugin.catalog.get.response") {
|
||||
responseBytes.push(JSON.stringify(message).length);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const first = await client.getPluginCatalog();
|
||||
const second = await client.getPluginCatalog();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(second[0]?.clientBundle).toContain("x".repeat(1_000_000));
|
||||
expect(responseBytes).toHaveLength(2);
|
||||
expect(responseBytes[0]).toBeGreaterThan(1_000_000);
|
||||
expect(responseBytes[1]).toBeLessThan(500);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("directory, Git, and npm installs reject a built-in ID", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-install-"));
|
||||
roots.push(root);
|
||||
|
||||
@@ -257,6 +257,22 @@ function makeHermesInteropEager(code: string): string {
|
||||
return code.replaceAll("get: () => from[key]", "value: from[key]");
|
||||
}
|
||||
|
||||
async function minifyClientBundle(code: string): Promise<string> {
|
||||
const { transform } = loadEsbuild();
|
||||
// Minify after the eager replace. Whitespace minify rewrites the getter this
|
||||
// replace matches, and identifier minify can delete the helper entirely.
|
||||
const result = await transform(code, {
|
||||
minifyWhitespace: true,
|
||||
minifySyntax: true,
|
||||
minifyIdentifiers: false,
|
||||
target: "es2020",
|
||||
supported: { "async-await": false },
|
||||
charset: "ascii",
|
||||
legalComments: "inline",
|
||||
});
|
||||
return result.code;
|
||||
}
|
||||
|
||||
function runtimeSpecifierError(
|
||||
specifier: string,
|
||||
target: PluginBuildTarget | "shared",
|
||||
@@ -368,7 +384,10 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
|
||||
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
|
||||
const output = result.outputFiles[0]?.text;
|
||||
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
|
||||
return wrapCommonJsBundle(makeHermesInteropEager(output));
|
||||
const eager = makeHermesInteropEager(output);
|
||||
let prepared = eager;
|
||||
if (target === "client") prepared = await minifyClientBundle(eager);
|
||||
return wrapCommonJsBundle(prepared);
|
||||
}
|
||||
|
||||
export async function compilePlugin(entryPaths: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import * as zod from "zod";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
compilePlugin,
|
||||
@@ -569,6 +570,101 @@ export default function contribute(client) {
|
||||
expect(clientBundle).toContain("__async");
|
||||
});
|
||||
|
||||
it("reduces client bytes while preserving registration and leaving server output readable", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
const source = `import { z } from "zod";
|
||||
const commandSchema = z.object({ id: z.string(), title: z.string() });
|
||||
export default function contribute(client) {
|
||||
const command = commandSchema.parse({ id: "open", title: "Open – plugin" });
|
||||
client.addCommandCenterItem({
|
||||
...command,
|
||||
icon: "Scan",
|
||||
context: "global",
|
||||
onSelect() { return Promise.resolve(command.title); },
|
||||
});
|
||||
return () => undefined;
|
||||
}`;
|
||||
await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]);
|
||||
const { clientBundle, serverBundle } = await compilePlugin(entries);
|
||||
if (clientBundle === null || serverBundle === null) {
|
||||
throw new Error("compilePlugin returned no bundle");
|
||||
}
|
||||
|
||||
for (const bundle of [clientBundle, serverBundle]) {
|
||||
const registered: { id: string; title: string; onSelect: () => Promise<string> }[] = [];
|
||||
const exports = new Function(`return ${bundle}`)()((specifier: string) => {
|
||||
expect(specifier).toBe("zod");
|
||||
return zod;
|
||||
});
|
||||
const cleanup = exports.default({
|
||||
addCommandCenterItem: (command: (typeof registered)[number]) => registered.push(command),
|
||||
});
|
||||
expect(registered).toMatchObject([{ id: "open", title: "Open – plugin" }]);
|
||||
await expect(registered[0]!.onSelect()).resolves.toBe("Open – plugin");
|
||||
expect(cleanup()).toBeUndefined();
|
||||
}
|
||||
expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9);
|
||||
expect(clientBundle.split("").every((char) => char.charCodeAt(0) <= 0x7f)).toBe(true);
|
||||
expect(serverBundle).toContain('title: "Open');
|
||||
});
|
||||
|
||||
it("retains dependency license notices while minifying executable client code", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
const dependency = path.join(entries.directory, "node_modules/licensed-dependency");
|
||||
const notice = "Copyright Example Authors. SPDX-License-Identifier: MIT";
|
||||
await mkdir(dependency, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dependency, "package.json"),
|
||||
JSON.stringify({ name: "licensed-dependency", main: "index.js" }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(dependency, "index.js"),
|
||||
`/*! ${notice} */
|
||||
exports.title = function title(input) {
|
||||
const trimmed = input.trim();
|
||||
return "Licensed " + trimmed;
|
||||
};`,
|
||||
);
|
||||
const source = `import { title } from "licensed-dependency";
|
||||
export default function contribute() { return title(" plugin "); }`;
|
||||
await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]);
|
||||
const { clientBundle, serverBundle } = await compilePlugin(entries);
|
||||
if (clientBundle === null || serverBundle === null) {
|
||||
throw new Error("compilePlugin returned no bundle");
|
||||
}
|
||||
|
||||
for (const bundle of [clientBundle, serverBundle]) {
|
||||
expect(bundle).toContain(notice);
|
||||
const exports = new Function(`return ${bundle}`)()(() => {
|
||||
throw new Error("The dependency must be bundled");
|
||||
});
|
||||
expect(exports.default()).toBe("Licensed plugin");
|
||||
}
|
||||
expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9);
|
||||
});
|
||||
|
||||
it("keeps distinct host exports eager when minifying in the worker", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
await writeFile(
|
||||
entries.client,
|
||||
`import * as host from "zod";
|
||||
export default function contribute() { return [host.first, host.second]; }`,
|
||||
);
|
||||
const { clientBundle } = await compilePlugin({ client: entries.client, server: null });
|
||||
const host = { first: "first export", second: "second export" };
|
||||
const factory = new Function(`return ${clientBundle}`)() as (
|
||||
require: (specifier: string) => typeof host,
|
||||
) => { default: () => unknown };
|
||||
function requireHost(specifier: string): typeof host {
|
||||
expect(specifier).toBe("zod");
|
||||
return host;
|
||||
}
|
||||
const exports = factory(requireHost);
|
||||
host.first = "changed first export";
|
||||
host.second = "changed second export";
|
||||
expect(exports.default()).toEqual(["first export", "second export"]);
|
||||
});
|
||||
|
||||
it("rejects node imports from the client entry", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
await writeFile(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -68,9 +69,18 @@ it("rejects incompatible installs without persisting them, then accepts the corr
|
||||
await expect(service.installDirectory({ path: root })).resolves.toMatchObject({
|
||||
status: "running",
|
||||
});
|
||||
expect(service.catalog()).toEqual([
|
||||
{ id: "example", requirements: { paseo: "^0.8.0" }, clientBundle: expect.any(String) },
|
||||
const catalog = service.catalog();
|
||||
expect(catalog).toEqual([
|
||||
{
|
||||
id: "example",
|
||||
requirements: { paseo: "^0.8.0" },
|
||||
clientBundle: expect.any(String),
|
||||
clientBundleHash: expect.any(String),
|
||||
},
|
||||
]);
|
||||
expect(catalog[0]!.clientBundleHash).toBe(
|
||||
createHash("sha256").update(catalog[0]!.clientBundle).digest("hex"),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks pre-0.8 plugins failed on startup and recovers after migration and reload", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { fork } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PassThrough } from "node:stream";
|
||||
import path from "node:path";
|
||||
@@ -106,6 +107,12 @@ function createReloadChild(
|
||||
};
|
||||
}
|
||||
|
||||
function sha256(text: string | undefined): string {
|
||||
return createHash("sha256")
|
||||
.update(text ?? "")
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function createTestRuntime(
|
||||
dependencies: NonNullable<ConstructorParameters<typeof PluginRuntime>[2]> = {},
|
||||
logger = pino({ level: "silent" }),
|
||||
@@ -1536,6 +1543,7 @@ export default function contribute(server: { registerProvider(provider: Provider
|
||||
|
||||
expect(spawnChild).not.toHaveBeenCalled();
|
||||
expect(runtime.catalog()[0]?.clientBundle).toContain("addTheme");
|
||||
expect(runtime.catalog()[0]?.clientBundleHash).toBe(sha256(runtime.catalog()[0]?.clientBundle));
|
||||
await runtime.stopAll();
|
||||
});
|
||||
|
||||
@@ -1698,6 +1706,7 @@ export function inspectHost(_input: z.input<typeof inspectRpc.input>) {
|
||||
const plugin = runtime.catalog()[0];
|
||||
expect(plugin?.clientBundle).toContain("Client-only surface");
|
||||
expect(plugin?.clientBundle).not.toContain("node:os");
|
||||
expect(plugin?.clientBundleHash).toBe(sha256(plugin?.clientBundle));
|
||||
await expect(runtime.invoke("split-runtime", "inspect", {})).resolves.toMatchObject({
|
||||
platform: expect.any(String),
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.j
|
||||
import { fork } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type pino from "pino";
|
||||
import {
|
||||
PROVIDER_CAPABILITIES,
|
||||
@@ -74,6 +74,8 @@ interface LoadedPlugin {
|
||||
instanceId: string;
|
||||
requirements: PluginRequirements | undefined;
|
||||
clientBundle: string;
|
||||
/** Lets a client that already holds this bundle skip downloading it again. */
|
||||
clientBundleHash: string;
|
||||
methods: ReadonlySet<string>;
|
||||
hooks: { events: string[]; before: string[] };
|
||||
providers: readonly PluginProviderMetadata[];
|
||||
@@ -246,6 +248,10 @@ function terminatePluginChild(child: PluginChild): void {
|
||||
if (!child.killed) child.kill();
|
||||
}
|
||||
|
||||
function hashClientBundle(clientBundle: string): string {
|
||||
return createHash("sha256").update(clientBundle).digest("hex");
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -369,9 +375,19 @@ export class PluginRuntime {
|
||||
return true;
|
||||
}
|
||||
|
||||
catalog(): Array<{ id: string; clientBundle: string; requirements?: PluginRequirements }> {
|
||||
catalog(): Array<{
|
||||
id: string;
|
||||
clientBundle: string;
|
||||
clientBundleHash: string;
|
||||
requirements?: PluginRequirements;
|
||||
}> {
|
||||
return [...this.plugins.values()]
|
||||
.map(({ id, clientBundle, requirements }) => ({ id, clientBundle, requirements }))
|
||||
.map(({ id, clientBundle, clientBundleHash, requirements }) => ({
|
||||
id,
|
||||
clientBundle,
|
||||
clientBundleHash,
|
||||
requirements,
|
||||
}))
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
@@ -739,10 +755,12 @@ export class PluginRuntime {
|
||||
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
|
||||
const serverBundle = bundles.serverBundle;
|
||||
if (!serverBundle) {
|
||||
const clientBundle = bundles.clientBundle ?? "";
|
||||
return {
|
||||
id: pluginId,
|
||||
instanceId: randomUUID(),
|
||||
clientBundle: bundles.clientBundle ?? "",
|
||||
clientBundle,
|
||||
clientBundleHash: hashClientBundle(clientBundle),
|
||||
requirements: manifest.requirements,
|
||||
methods: new Set(),
|
||||
hooks: { events: [], before: [] },
|
||||
@@ -872,6 +890,7 @@ export class PluginRuntime {
|
||||
id: pluginId,
|
||||
instanceId: randomUUID(),
|
||||
clientBundle,
|
||||
clientBundleHash: hashClientBundle(clientBundle),
|
||||
requirements,
|
||||
methods: new Set(ready.methods),
|
||||
hooks: ready.hooks ?? { events: [], before: [] },
|
||||
|
||||
@@ -588,6 +588,78 @@ test("routes plugin requests and releases its owned catalog subscription on clea
|
||||
expect(releasePluginSubscription).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("leaves out the client bundles a catalog request already holds", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const pluginRuntime: NonNullable<SessionOptions["pluginRuntime"]> = {
|
||||
before: async (_name, request) => request,
|
||||
emit: () => {},
|
||||
listPlugins: async () => [],
|
||||
getLogs: () => [],
|
||||
installDirectory: async () => {
|
||||
throw new Error("Unexpected install");
|
||||
},
|
||||
inspectDirectory: async () => ({ id: "unused" }),
|
||||
reloadPlugin: async () => {
|
||||
throw new Error("Unexpected reload");
|
||||
},
|
||||
enablePlugin: async () => {
|
||||
throw new Error("Unexpected enable");
|
||||
},
|
||||
disablePlugin: async () => {
|
||||
throw new Error("Unexpected disable");
|
||||
},
|
||||
removePlugin: async () => undefined,
|
||||
subscribe: () => () => {},
|
||||
catalog: () => [
|
||||
{ id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
|
||||
],
|
||||
invokePluginRpc: async () => null,
|
||||
listUsageReports: async () => [],
|
||||
listLegacyUsage: async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }),
|
||||
};
|
||||
const session = createSessionForTest({ messages, pluginRuntime });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "plugin.catalog.get.request",
|
||||
requestId: "reconnect",
|
||||
cachedClientBundles: [
|
||||
{ id: "cached", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundleHash: "hash-old" },
|
||||
{ id: "removed", clientBundleHash: "hash-removed" },
|
||||
],
|
||||
});
|
||||
// Clients from before cachedClientBundles always receive every bundle.
|
||||
await session.handleMessage({ type: "plugin.catalog.get.request", requestId: "legacy" });
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: "reconnect",
|
||||
plugins: [
|
||||
{ id: "cached", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: "legacy",
|
||||
plugins: [
|
||||
{ id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
describe("workspace label subscriptions", () => {
|
||||
type LabelSubscription = Awaited<ReturnType<WorkspaceLabelService["subscribe"]>>;
|
||||
type LabelChange = Parameters<Parameters<WorkspaceLabelService["subscribe"]>[0]["onChange"]>[0];
|
||||
|
||||
@@ -518,7 +518,7 @@ export interface SessionOptions {
|
||||
removePlugin(pluginId: string): Promise<void>;
|
||||
subscribe(listener: (pluginId: string) => void): () => void;
|
||||
subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void;
|
||||
catalog(): Array<{ id: string; clientBundle: string }>;
|
||||
catalog(): Array<{ id: string; clientBundle: string; clientBundleHash?: string }>;
|
||||
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
|
||||
listUsageReports(options?: {
|
||||
forceRefresh?: boolean;
|
||||
@@ -2584,12 +2584,17 @@ export class Session {
|
||||
});
|
||||
}
|
||||
if (msg.type === "plugin.catalog.get.request") {
|
||||
const cached = new Map(
|
||||
msg.cachedClientBundles?.map((entry) => [entry.id, entry.clientBundleHash]),
|
||||
);
|
||||
const plugins = (this.pluginRuntime?.catalog() ?? []).map(({ clientBundle, ...plugin }) =>
|
||||
plugin.clientBundleHash !== undefined && cached.get(plugin.id) === plugin.clientBundleHash
|
||||
? plugin
|
||||
: Object.assign(plugin, { clientBundle }),
|
||||
);
|
||||
this.emit({
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
plugins: this.pluginRuntime?.catalog() ?? [],
|
||||
},
|
||||
payload: { requestId: msg.requestId, plugins },
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3770,3 +3770,60 @@ index eaa3279..05a02eb 100644
|
||||
private:
|
||||
int forceHeightRecalculationCounter_{0};
|
||||
std::shared_ptr<MarkdownTextMeasurementManager> measurementsManager_;
|
||||
diff --git a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
|
||||
--- a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
|
||||
+++ b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
|
||||
@@ -1639,6 +1639,7 @@
|
||||
struct EnrichedMarkdownMarkdownStyleCodeStruct {
|
||||
std::string fontFamily{};
|
||||
Float fontSize{0.0};
|
||||
+ Float fontScale{0.0};
|
||||
SharedColor color{};
|
||||
SharedColor backgroundColor{};
|
||||
SharedColor borderColor{};
|
||||
@@ -1651,6 +1652,7 @@
|
||||
folly::dynamic result = folly::dynamic::object();
|
||||
result["fontFamily"] = fontFamily;
|
||||
result["fontSize"] = fontSize;
|
||||
+ result["fontScale"] = fontScale;
|
||||
result["color"] = ::facebook::react::toDynamic(color);
|
||||
result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor);
|
||||
result["borderColor"] = ::facebook::react::toDynamic(borderColor);
|
||||
@@ -1670,6 +1672,10 @@
|
||||
if (tmp_fontSize != map.end()) {
|
||||
fromRawValue(context, tmp_fontSize->second, result.fontSize);
|
||||
}
|
||||
+ auto tmp_fontScale = map.find("fontScale");
|
||||
+ if (tmp_fontScale != map.end()) {
|
||||
+ fromRawValue(context, tmp_fontScale->second, result.fontScale);
|
||||
+ }
|
||||
auto tmp_color = map.find("color");
|
||||
if (tmp_color != map.end()) {
|
||||
fromRawValue(context, tmp_color->second, result.color);
|
||||
@@ -6185,6 +6191,7 @@
|
||||
struct EnrichedMarkdownTextMarkdownStyleCodeStruct {
|
||||
std::string fontFamily{};
|
||||
Float fontSize{0.0};
|
||||
+ Float fontScale{0.0};
|
||||
SharedColor color{};
|
||||
SharedColor backgroundColor{};
|
||||
SharedColor borderColor{};
|
||||
@@ -6197,6 +6204,7 @@
|
||||
folly::dynamic result = folly::dynamic::object();
|
||||
result["fontFamily"] = fontFamily;
|
||||
result["fontSize"] = fontSize;
|
||||
+ result["fontScale"] = fontScale;
|
||||
result["color"] = ::facebook::react::toDynamic(color);
|
||||
result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor);
|
||||
result["borderColor"] = ::facebook::react::toDynamic(borderColor);
|
||||
@@ -6216,6 +6224,10 @@
|
||||
if (tmp_fontSize != map.end()) {
|
||||
fromRawValue(context, tmp_fontSize->second, result.fontSize);
|
||||
}
|
||||
+ auto tmp_fontScale = map.find("fontScale");
|
||||
+ if (tmp_fontScale != map.end()) {
|
||||
+ fromRawValue(context, tmp_fontScale->second, result.fontScale);
|
||||
+ }
|
||||
auto tmp_color = map.find("color");
|
||||
if (tmp_color != map.end()) {
|
||||
fromRawValue(context, tmp_color->second, result.color);
|
||||
|
||||
@@ -10,6 +10,38 @@ const bin = path.resolve("node_modules", ".bin");
|
||||
const packageName = "react-native-draggable-flatlist";
|
||||
const patchFile = `${packageName}+4.0.3.patch`;
|
||||
|
||||
test("patched Markdown ships iOS props generated from its native specs", () => {
|
||||
const packageDir = path.resolve("packages/app/node_modules/react-native-enriched-markdown");
|
||||
const output = mkdtempSync(path.join(tmpdir(), "markdown-ios-codegen-"));
|
||||
try {
|
||||
// includesGeneratedCode makes CocoaPods use the shipped headers instead of
|
||||
// regenerating them after patch-package changes the TypeScript specs.
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve("node_modules/react-native/scripts/generate-codegen-artifacts.js"),
|
||||
"-p",
|
||||
packageDir,
|
||||
"-t",
|
||||
"ios",
|
||||
"-o",
|
||||
output,
|
||||
"-s",
|
||||
"library",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
const header = "ReactCodegen/EnrichedMarkdownTextSpec/Props.h";
|
||||
assert.equal(
|
||||
readFileSync(path.join(packageDir, "ios/generated", header), "utf8"),
|
||||
readFileSync(path.join(output, header), "utf8"),
|
||||
);
|
||||
} finally {
|
||||
rmSync(output, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createCheckout() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "postinstall-patches-"));
|
||||
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "checkout" }));
|
||||
|
||||
Reference in New Issue
Block a user